@seznam/szn-select
Version:
Accessible HTML selectbox with customizable UI. Based on web components and easy to integrate with various frameworks like React or Angular.
1,437 lines (1,161 loc) • 92.4 kB
JavaScript
'use strict';
(global => {
const SznElements = global.SznElements = global.SznElements || {};
/**
* @typedef {{width: number, height: number}} ContentDimensions
*/
/**
* @typedef {{screenX: number, screenY: number, x: number, y: number, width: number, height: number}} TetherBounds
*/
const CSS_STYLES = `
szn-tethered {
display: block;
position: absolute;
left: 0;
top: 0;
width: 0;
height: 0;
transform: translate(0, 0);
}
szn-tethered > [data-szn-tethered--content] {
position: absolute;
left: 0;
top: 0;
}
szn-tethered[data-horizontal-align='right'] > [data-szn-tethered--content] {
left: auto;
right: 0;
}
szn-tethered[data-vertical-align='top'] > [data-szn-tethered--content] {
top: auto;
bottom: 0;
}
`;
const HORIZONTAL_ALIGN = {
LEFT: 'HORIZONTAL_ALIGN.LEFT',
RIGHT: 'HORIZONTAL_ALIGN.RIGHT'
};
const VERTICAL_ALIGN = {
TOP: 'VERTICAL_ALIGN.TOP',
BOTTOM: 'VERTICAL_ALIGN.BOTTOM'
};
const MIN_BOTTOM_SPACE = 160; // px
const OBSERVED_DOM_EVENTS = ['resize', 'scroll', 'wheel', 'touchmove'];
if (Object.freeze) {
Object.freeze(HORIZONTAL_ALIGN);
Object.freeze(VERTICAL_ALIGN);
}
let transformsSupported = null;
/**
* The <code>szn-tethered</code> element is used to tether the on-screen position of content to another element
* located elsewhere in the document (i.e. when such positioning is not feasible through CSS alone).
*/
SznElements['szn-tethered'] = class SznTethered {
constructor(rootElement) {
if (transformsSupported === null) {
transformsSupported = 'transform' in rootElement.style;
}
rootElement.HORIZONTAL_ALIGN = HORIZONTAL_ALIGN;
rootElement.VERTICAL_ALIGN = VERTICAL_ALIGN;
rootElement.setTether = tether => this.setTether(tether);
rootElement.updatePosition = () => this.updatePosition();
if (!rootElement.hasOwnProperty('horizontalAlign')) {
Object.defineProperty(rootElement, 'horizontalAlign', {
get: () => rootElement._broker.horizontalAlignment
});
}
if (!rootElement.hasOwnProperty('verticalAlignment')) {
Object.defineProperty(rootElement, 'verticalAlignment', {
get: () => rootElement._broker.verticalAlignment
});
}
if (!rootElement.hasOwnProperty('minBottomSpace')) {
Object.defineProperty(rootElement, 'minBottomSpace', {
get: () => rootElement._broker.minBottomSpace,
set: value => {
rootElement._broker.minBottomSpace = value;
}
});
}
rootElement.onVerticalAlignmentChange = null;
rootElement.onHorizontalAlignmentChange = null;
/**
* The currently used horizontal alignment of the content to the tethering element.
*
* @type {string}
*/
this.horizontalAlignment = HORIZONTAL_ALIGN.LEFT;
/**
* The currently used vertical alignment of the content to the tethering element.
*
* @type {string}
*/
this.verticalAlignment = VERTICAL_ALIGN.BOTTOM;
/**
* The minimum number of pixels that must be available below the current tethering element in the viewport for
* the content to be tethered to the bottom edge of the tethering element. If there is less space available, the
* content will be tethered to the top edge of the tethering element.
*
* @type {number}
*/
this.minBottomSpace = MIN_BOTTOM_SPACE;
/**
* The szn-tethered element itself (the DOM element instance).
*/
this._root = rootElement;
/**
* The current tethering element.
*
* @type {?Element}
*/
this._tether = null;
/**
* Whether or not this szn-tethered element is currently mounted into the document.
*
* @type {boolean}
*/
this._mounted = false;
/**
* The previously set horizontal alignment of the tethered content to the tether, before updating the alignment
* data attributes of the szn-tethered element.
*
* @type {string}
*/
this._lastHorizontalAlignment = null;
/**
* The previously set vertical alignment of the tethered content to the tether, before updating the alignment
* data attributes of the szn-tethered element.
*
* @type {string}
*/
this._lastVerticalAlignment = null;
SznElements.injectStyles(CSS_STYLES, 'szn-tethered');
}
onMount() {
for (const eventType of OBSERVED_DOM_EVENTS) {
addEventListener(eventType, this._root.updatePosition);
}
this._mounted = true;
this.updatePosition();
}
onUnmount() {
for (const eventType of OBSERVED_DOM_EVENTS) {
removeEventListener(eventType, this._root.updatePosition);
}
this._mounted = false;
}
/**
* Sets the element to which this element will be tethered.
*
* @param {Element} tether The element to which this szn-tethered element should be tethered.
*/
setTether(tether) {
if (tether === this._tether) {
return;
}
this._tether = tether;
this.updatePosition();
}
/**
* Updates the location and alignment of the tethered content to the tethering element. The method may be invoked
* manually if updating the location of the tethered content is needed for any reason.
*
* Note that this method is automatically invoked whenever any of the following events occur on the page: resize,
* scroll, wheel, touchmove.
*
* This method has no effect if the szn-tethered element is unmounted or no tethering element is currently set.
*/
updatePosition() {
if (!this._mounted || !this._tether) {
return;
}
const tetherBounds = getTetherBounds(this._tether);
const contentSize = getContentDimensions(this);
const viewportWidth = document.documentElement.clientWidth; // window.innerWidth - scrollbars
// Lets hope the document element (<html>) is at least 100vh high - which it usually is.
const viewportHeight = Math.min(document.documentElement.clientHeight, // window.innerHeight - scrollbars
window.innerHeight);
const lastHorizontalAlignment = this.horizontalAlignment;
const lastVerticalAlignment = this.verticalAlignment;
if (tetherBounds.screenX + contentSize.width > viewportWidth && tetherBounds.screenX + tetherBounds.width - contentSize.width >= 0) {
this.horizontalAlignment = HORIZONTAL_ALIGN.RIGHT;
} else {
this.horizontalAlignment = HORIZONTAL_ALIGN.LEFT;
}
if (viewportHeight - (tetherBounds.screenY + tetherBounds.height) < this.minBottomSpace) {
this.verticalAlignment = VERTICAL_ALIGN.TOP;
} else {
this.verticalAlignment = VERTICAL_ALIGN.BOTTOM;
}
updateAttributes(this);
updatePosition(this, tetherBounds);
if (this.horizontalAlignment !== lastHorizontalAlignment && this._root.onHorizontalAlignmentChange) {
this._root.onHorizontalAlignmentChange(this.horizontalAlignment);
}
if (this.verticalAlignment !== lastVerticalAlignment && this._root.onVerticalAlignmentChange) {
this._root.onVerticalAlignmentChange(this.verticalAlignment);
}
}
};
/**
* Updates the position of the szn-tethered element according to the current tethering alignment and the provided
* bounds of the tethering element.
*
* @param {SznElements.SznTethered} instance The szn-tethered element instance.
* @param {TetherBounds} tetherBounds The bounds (location and size) of the tethering element.
*/
function updatePosition(instance, tetherBounds) {
const x = tetherBounds.x + (instance.horizontalAlignment === HORIZONTAL_ALIGN.LEFT ? 0 : tetherBounds.width);
const y = tetherBounds.y + (instance.verticalAlignment === VERTICAL_ALIGN.TOP ? 0 : tetherBounds.height);
if (transformsSupported) {
instance._root.style.transform = `translate(${x}px, ${y}px)`;
} else {
instance._root.style.left = `${x}px`;
instance._root.style.top = `${y}px`;
}
}
/**
* Updates the attributes on the szn-tethered element reporting the current alignment to the tethering element.
*
* @param {SznElements.SznTethered} instance The szn-tethered element instance.
*/
function updateAttributes(instance) {
if (instance.horizontalAlignment !== instance._lastHorizontalAlignment) {
const horizontalAlignment = instance.horizontalAlignment === HORIZONTAL_ALIGN.LEFT ? 'left' : 'right';
instance._root.setAttribute('data-horizontal-align', horizontalAlignment);
instance._lastHorizontalAlignment = instance.horizontalAlignment;
}
if (instance.verticalAlignment !== instance._lastVerticalAlignment) {
const verticalAlignment = instance.verticalAlignment === VERTICAL_ALIGN.TOP ? 'top' : 'bottom';
instance._root.setAttribute('data-vertical-align', verticalAlignment);
instance._lastVerticalAlignment = instance.verticalAlignment;
}
}
/**
* Calculates and returns both the on-screen and on-page location and dimensions of the provided tether element.
*
* @param {Element} tether The tethering element.
* @return {TetherBounds} The on-screen and on-page location and dimensions of the element.
*/
function getTetherBounds(tether) {
const bounds = tether.getBoundingClientRect();
const width = bounds.width;
const height = bounds.height;
let x = 0;
let y = 0;
let tetherOffsetContainer = tether;
while (tetherOffsetContainer) {
x += tetherOffsetContainer.offsetLeft;
y += tetherOffsetContainer.offsetTop;
tetherOffsetContainer = tetherOffsetContainer.offsetParent;
}
return {
screenX: bounds.left,
screenY: bounds.top,
x,
y,
width,
height
};
}
/**
* Returns the dimensions of the content of the provided szn-tethered element.
*
* @param {SznElements.SznTethered} instance The instance of the szn-tethered element.
* @return {ContentDimensions} The dimensions of the tethered content.
*/
function getContentDimensions(instance) {
const contentElement = instance._root.firstElementChild;
if (!contentElement) {
return {
width: 0,
height: 0
};
}
let width;
let height;
if (window.devicePixelRatio > 1) {
// This is much less performant, so we use in only on HiDPi displays
const bounds = contentElement.getBoundingClientRect();
width = bounds.width;
height = bounds.height;
} else {
width = contentElement.scrollWidth;
height = contentElement.scrollHeight;
}
return {
width,
height
};
}
if (SznElements.init) {
SznElements.init();
}
})(self);
;
'use strict';
(global => {
const SznElements = global.SznElements = global.SznElements || {};
const CSS_STYLES = `
szn-select--button{display:block;position:relative;box-sizing:border-box;border:1px solid #ccc;border:var(--szn-select--button--border-width,1px) solid var(--szn-select--button--border-color,#ccc);border-radius:4px;border-radius:var(--szn-select--button--border-radius,4px);width:100%;height:100%;line-height:30px;line-height:calc(var(--szn-select--button--height-px, 32px) - 2 * var(--szn-select--button--border-width, 1px));line-height:calc(2em - 2 * 1px);line-height:calc(var(--szn-select--button--height, 2em) - 2 * var(--szn-select--button--border-width, 1px));color:#000;color:var(--szn-select--button--text-color,#000);background:#fff;background:var(--szn-select--button--background-color,#fff);cursor:pointer;user-select:none;-moz-user-select:none}szn-select--button[data-szn-select--button--open]{border-radius:4px 4px 0 0;border-radius:var(--szn-select--button--border-radius,4px) var(--szn-select--button--border-radius,4px) 0 0}szn-select--button[data-szn-select--button--open-at-top]{border-radius:0 0 4px 4px;border-radius:0 0 var(--szn-select--button--border-radius,4px) var(--szn-select--button--border-radius,4px)}szn-select--button[disabled]{color:#ccc;color:var(--szn-select--button--disabled-text-color,#ccc);cursor:default}szn-select--button [data-szn-select--button--label]{display:block;box-sizing:border-box;border-right:2em solid transparent;border-right:var(--szn-select--button--mark-width,2em) transparent solid;padding:0 5px 0 12px;padding:var(--szn-select--button--padding,0 5px 0 12px);width:100%;height:100%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}szn-select--button [data-szn-select--button--mark]{display:block;position:absolute;right:0;top:0;width:2em;width:var(--szn-select--button--mark-width,2em);height:100%;text-align:center}szn-select--button [data-szn-select--button--mark]:after{display:inline;content:"▼";content:var(--szn-select--button--icon-closed,"▼")}szn-select--button[data-szn-select--button--open] [data-szn-select--button--mark]:after{content:"▲";content:var(--szn-select--button--icon-opened,"▲")}
`;
const OPENING_POSITION = {
UP: 'OPENING_POSITION.UP',
DOWN: 'OPENING_POSITION.DOWN'
};
if (Object.freeze) {
Object.freeze(OPENING_POSITION);
}
SznElements['szn-select--button'] = class SznSelectButton {
constructor(rootElement) {
this.OPENING_POSITION = OPENING_POSITION;
this._root = rootElement;
this._select = null;
this._label = null;
this._observer = new MutationObserver(() => {
updateLabel(this);
updateDisabledStatus(this);
});
rootElement.OPENING_POSITION = OPENING_POSITION;
rootElement.setSelectElement = this.setSelectElement.bind(this);
rootElement.setOpen = this.setOpen.bind(this);
rootElement.setOpeningPosition = this.setOpeningPosition.bind(this);
this._onChange = onChange.bind(null, this);
SznElements.injectStyles(CSS_STYLES, 'szn-select--button');
}
onMount() {
observeSelect(this);
addEventListeners(this);
updateLabel(this);
updateDisabledStatus(this);
}
onUnmount() {
this._observer.disconnect();
removeEventListeners(this);
}
setSelectElement(selectElement) {
if (selectElement === this._select) {
return;
}
if (!this._label) {
this._root.appendChild(buildUI(this));
}
if (this._select) {
removeEventListeners(this);
this._observer.disconnect();
}
this._select = selectElement;
observeSelect(this);
addEventListeners(this);
updateLabel(this);
updateDisabledStatus(this);
}
setOpen(isOpen) {
if (isOpen) {
this._root.setAttribute('data-szn-select--button--open', '');
} else {
this._root.removeAttribute('data-szn-select--button--open');
this._root.removeAttribute('data-szn-select--button--open-at-top');
}
}
setOpeningPosition(openingPosition) {
if (openingPosition === OPENING_POSITION.UP) {
this._root.setAttribute('data-szn-select--button--open-at-top', '');
} else {
this._root.removeAttribute('data-szn-select--button--open-at-top');
}
}
};
function addEventListeners(instance) {
if (!instance._select) {
return;
}
instance._select.addEventListener('change', instance._onChange);
}
function removeEventListeners(instance) {
if (!instance._select) {
return;
}
instance._select.removeEventListener('change', instance._onChange);
}
function onChange(instance) {
updateLabel(instance);
}
function observeSelect(instance) {
if (!instance._select) {
return;
}
instance._observer.observe(instance._select, {
childList: true,
attributes: true,
characterData: true,
subtree: true,
attributeFilter: ['disabled', 'selected']
});
}
function buildUI(instance) {
return SznElements.buildDom(`
<szn- data-szn-select--button--label data-szn-ref></szn->
<szn- data-szn-select--button--mark></szn->
`, label => {
instance._label = label;
}, false);
}
function updateLabel(instance) {
if (!instance._select) {
return;
}
const selectedOption = instance._select.options.item(instance._select.selectedIndex);
const selectedOptionText = selectedOption.text;
if (instance._label.innerText !== selectedOptionText) {
instance._label.innerText = selectedOptionText;
}
}
function updateDisabledStatus(instance) {
if (!instance._select) {
return;
}
if (instance._select.disabled) {
instance._root.setAttribute('disabled', '');
} else {
instance._root.removeAttribute('disabled');
}
}
})(self);
'use strict';
(global => {
const SznElements = global.SznElements = global.SznElements || {};
const CSS_STYLES = `
szn-select--options{display:block;z-index:1;border:solid #ccc;border:solid var(--szn-select--options--border-color,#ccc);border-width:1px;border-width:var(--szn-select--options--border-width,1px);border-radius:0 0 4px 4px;border-radius:var(--szn-select--options--border-radius-dropdown,0 0 4px 4px);height:100%;box-sizing:border-box;white-space:nowrap;color:#000;color:var(--szn-select--options--text-color,#000);background:#fff;background:var(--szn-select--options--background-color,#fff);user-select:none;-moz-user-select:none}szn-select--options[data-szn-select--options--multiple]{border-radius:4px;border-radius:var(--szn-select--options--border-radius-mutli-select,4px)}szn-select--options [data-szn-select--options--option]{display:block;padding:5px 5px 5px 12px;padding:var(--szn-select--options--item-padding,5px 5px 5px 12px);overflow:auto;overflow-x:hidden;text-overflow:ellipsis;cursor:pointer}szn-select--options [data-szn-select--options--option][disabled],szn-select--options [disabled] [data-szn-select--options--option],szn-select--options[disabled] [data-szn-select-options--option]{cursor:default}szn-select--options [data-szn-select--options--selected]{background:#eee;background:var(--szn-select--options--selected-background,#eee)}szn-select--options [data-szn-select--options--optgroup]{display:block}szn-select--options [data-szn-select--options--optgroup] [data-szn-select--options--option]{padding-left:24px;padding-left:calc(2 * var(--szn-select--options--item-indent, 12px))}szn-select--options [data-szn-select--options--optgroup-label]:before{display:block;padding:5px 5px 5px 12px;padding:var(--szn-select--options--item-padding,5px 5px 5px 12px);content:attr(data-szn-select--options--optgroup-label);font-weight:700}szn-select--options[data-szn-select--options--highlighting] [data-szn-select-options--selected]{background:none}szn-select--options[data-szn-select--options--highlighting] [data-szn-select--options--highlighted]{background:#eee;background:var(--szn-select--options--selected-background,#eee)}szn-select--options[disabled],szn-select--options [disabled]{color:#ccc;color:var(--szn-select--options--disabled-text,#ccc)}szn-tethered[data-vertical-align=top] szn-select--options{border-radius:4px 4px 0 0;border-radius:var(--szn-select--options--border-radius-dropup,4px 4px 0 0)}
`;
class SznSelectOptions {
/**
* Initializes the szn-options element's implementation.
*
* @param {Element} rootElement The root HTML element of this custom element's implementation.
*/
constructor(rootElement) {
rootElement.setOptions = options => this.setOptions(options);
rootElement.updateUi = () => updateUi(this);
/**
* The root HTML element of this custom element's implementation.
*
* @type {Element}
*/
this._root = rootElement;
/**
* The container of the options this element provides the UI for.
*
* @type {?HTMLElement}
*/
this._options = null;
/**
* The option represented the element over which the user started to drag the mouse cursor to perform a
* multiple-items selection.
*
* This field is used only for multi-selects.
*
* @type {?HTMLOptionElement}
*/
this._dragSelectionStartOption = null;
/**
* Flag signalling whether the element is currently mounted.
*
* @type {boolean}
*/
this._mounted = false;
/**
* The DOM mutation observer used to observe modifications to the associated options.
*
* @type {MutationObserver}
*/
this._observer = new MutationObserver(rootElement.updateUi);
/**
* The previously used indexes when the <code>scrollToSelection</code> function has been called for this
* instance.
*
* @type {{start: number, end: number}}
* @see scrollToSelection
*/
this._lastSelectionIndexes = {
start: -1,
end: -1
/**
* The indexes of options that are to be selected as well while performing an additive multi-select (dragging the
* mouse over a multi-select while holding the Ctrl key).
*
* @type {Array<number>}
*/
};
this._additionalSelectedIndexes = [];
/**
* Set to <code>true</code> if the user started to drag the mouse pointer over an already selected item while
* holding the Ctrl key. The items selected by the user using the current action will be deselected.
*
* @type {boolean}
*/
this._invertSelection = false;
/**
* The index of the option at which the multi-items selection started the last time.
*
* @type {number}
*/
this._previousSelectionStartIndex = -1;
/**
* The ID of the current touch being observed for possible option interaction.
*
* @type {?number}
*/
this._observedTouchId = null;
this._onItemHovered = event => onItemHovered(this, event.target);
this._onItemClicked = event => onItemClicked(this, event.target);
this._onItemSelectionStart = event => onItemSelectionStart(this, event.target, event);
this._onTouchStart = onTouchStart.bind(null, this);
this._onTouchEnd = onTouchEnd.bind(null, this);
this._onSelectionEnd = () => {
this._dragSelectionStartOption = null;
this._additionalSelectedIndexes = [];
};
this._onSelectionChange = () => {
this._root.removeAttribute('data-szn-select--options--highlighting');
updateUi(this);
};
SznElements.injectStyles(CSS_STYLES, 'szn-options');
}
onMount() {
this._mounted = true;
addEventListeners(this);
updateUi(this);
registerOptionsObserver(this);
if (this._options) {
scrollToSelection(this, this._options.selectedIndex, this._options.selectedIndex);
}
}
onUnmount() {
removeEventListeners(this);
this._root.removeAttribute('data-szn-select--options--highlighting');
this._mounted = false;
this._observer.disconnect();
}
/**
* Sets the element containing the options to display in this szn-options element.
*
* @param {HTMLElement} options The element containing the options to display.
*/
setOptions(options) {
if (options === this._options) {
return;
}
if (this._options) {
removeEventListeners(this);
this._observer.disconnect();
}
this._options = options;
addEventListeners(this);
updateUi(this);
registerOptionsObserver(this);
const selectedIndex = typeof options.selectedIndex === 'number' ? options.selectedIndex : -1;
this._previousSelectionStartIndex = selectedIndex;
if (this._mounted) {
scrollToSelection(this, selectedIndex, selectedIndex);
}
}
}
/**
* Registers the provided szn-options element's DOM mutation observer to observe the related options for changes.
*
* @param {SznSelectOptions} instance The szn-options element instance.
*/
function registerOptionsObserver(instance) {
if (!instance._mounted || !instance._options) {
return;
}
instance._observer.observe(instance._options, {
childList: true,
attributes: true,
characterData: true,
subtree: true,
attributeFilter: ['disabled', 'label', 'selected', 'title', 'multiple']
});
}
/**
* Registers event listeners that the provided szn-options instance requires to function correctly.
*
* The function has no effect if the provided szn-options element is not mounted into the document or has not been
* provided with its options yet.
*
* @param {SznSelectOptions} instance The szn-options element instance.
*/
function addEventListeners(instance) {
if (!instance._mounted || !instance._options) {
return;
}
instance._options.addEventListener('change', instance._onSelectionChange);
instance._root.addEventListener('mouseover', instance._onItemHovered);
instance._root.addEventListener('mousedown', instance._onItemSelectionStart);
instance._root.addEventListener('mouseup', instance._onItemClicked);
instance._root.addEventListener('touchstart', instance._onTouchStart);
addEventListener('mouseup', instance._onSelectionEnd);
addEventListener('touchend', instance._onTouchEnd);
}
/**
* Deregisters all event listeners used by the provided szn-options element.
*
* @param {SznSelectOptions} instance The szn-options element instance.
*/
function removeEventListeners(instance) {
if (instance._options) {
instance._options.removeEventListener('change', instance._onSelectionChange);
}
instance._root.removeEventListener('mouseover', instance._onItemHovered);
instance._root.removeEventListener('mousedown', instance._onItemSelectionStart);
instance._root.removeEventListener('mouseup', instance._onItemClicked);
instance._root.removeEventListener('touchstart', instance._onTouchStart);
removeEventListener('mouseup', instance._onSelectionEnd);
removeEventListener('touchend', instance._onTouchEnd);
}
/**
* @param {SznSelectOptions} instance
* @param {TouchEvent} event
*/
function onTouchStart(instance, event) {
if (instance._observedTouchId) {
return;
}
const touch = event.changedTouches[0];
instance._observedTouchId = touch.identifier;
}
function onTouchEnd(instance, event) {
if (!instance._options.multiple) {
return;
}
const touch = Array.from(event.changedTouches).find(someTouch => someTouch.identifier === instance._observedTouchId);
if (!touch) {
return;
}
instance._observedTouchId = null;
event.preventDefault(); // prevent mouse events
const option = event.target._option;
if (!isOptionEnabled(option)) {
return;
}
option.selected = !option.selected;
instance._options.dispatchEvent(new CustomEvent('change', {
bubbles: true,
cancelable: true
}));
}
/**
* Handles the user moving the mouse pointer over an option in the szn-options element's UI. The function updates the
* current multiple-items selection if the element represents a multi-select, or updates the currently highlighted
* item in the UI of a single-select.
*
* @param {SznSelectOptions} instance The szn-options element instance.
* @param {Element} itemUi The element which's area the mouse pointer entered.
*/
function onItemHovered(instance, itemUi) {
if (instance._options.disabled || !isEnabledOptionUi(itemUi)) {
return;
}
if (instance._options.multiple) {
if (instance._dragSelectionStartOption) {
updateMultiSelection(instance, itemUi);
}
return;
}
instance._root.setAttribute('data-szn-select--options--highlighting', '');
const previouslyHighlighted = instance._root.querySelector('[data-szn-select--options--highlighted]');
if (previouslyHighlighted) {
previouslyHighlighted.removeAttribute('data-szn-select--options--highlighted');
}
itemUi.setAttribute('data-szn-select--options--highlighted', '');
}
/**
* Handles the user releasing the primary mouse button over an element representing an item.
*
* The function ends multiple-items selection for multi-selects, ends options highlighting and marks the the selected
* option for single-selects.
*
* @param {SznSelectOptions} instance The szn-options element instance.
* @param {Element} itemUi The element at which the user released the primary mouse button.
*/
function onItemClicked(instance, itemUi) {
if (instance._dragSelectionStartOption) {
// multi-select
instance._dragSelectionStartOption = null;
return;
}
if (instance._options.disabled || !isEnabledOptionUi(itemUi)) {
return;
}
instance._root.removeAttribute('data-szn-select--options--highlighting');
instance._options.selectedIndex = itemUi._option.index;
instance._options.dispatchEvent(new CustomEvent('change', {
bubbles: true,
cancelable: true
}));
}
/**
* Handles start of the user dragging the mouse pointer over the UI of a multi-selection szn-options element. The
* function marks the starting item.
*
* The function marks the starting item used previously as the current starting item if the Shift key is pressed. The
* function marks the indexes of the currently selected items if the Ctrl key is pressed and the Shift key is not.
* Also, if the Ctrl key pressed, the Shift key is not, and the user starts at an already selected item, the function
* will mark this as inverted selection.
*
* The function has no effect for single-selects.
*
* @param {SznSelectOptions} instance The szn-options element instance.
* @param {Element} itemUi The element at which the user pressed the primary mouse button down.
* @param {MouseEvent} event The mouse event representing the user's action.
*/
function onItemSelectionStart(instance, itemUi, event) {
if (instance._options.disabled || !instance._options.multiple || !isEnabledOptionUi(itemUi)) {
return;
}
const options = instance._options.options;
if (event.shiftKey && instance._previousSelectionStartIndex > -1) {
instance._dragSelectionStartOption = options.item(instance._previousSelectionStartIndex);
} else {
if (event.ctrlKey) {
instance._additionalSelectedIndexes = [];
for (let i = 0, length = options.length; i < length; i++) {
if (options.item(i).selected) {
instance._additionalSelectedIndexes.push(i);
}
}
instance._invertSelection = itemUi._option.selected;
} else {
instance._invertSelection = false;
}
instance._dragSelectionStartOption = itemUi._option;
}
instance._previousSelectionStartIndex = instance._dragSelectionStartOption.index;
updateMultiSelection(instance, itemUi);
}
/**
* Scrolls, only if necessary, the UI of the provided szn-options element to make the last selected option visible.
* Which option is the last selected one is determined by comparing the provided index with the indexes passed to the
* previous call of this function.
*
* @param {SznSelectOptions} instance The szn-options element instance.
* @param {number} selectionStartIndex The index of the first selected option. The index must be a non-negative
* integer and cannot be greater that the total number of options; or set to <code>-1</code> if there is no
* option currently selected.
* @param {number} selectionEndIndex The index of the last selected option. The index must be a non-negative integer,
* cannot be greater than the total number of options and must not be lower than the
* <code>selectionStartIndex</code>; or set to <code>-1</code> if there is no option currently selected.
*/
function scrollToSelection(instance, selectionStartIndex, selectionEndIndex) {
const lastSelectionIndexes = instance._lastSelectionIndexes;
if (selectionStartIndex !== -1 && (selectionStartIndex !== lastSelectionIndexes.start || selectionEndIndex !== lastSelectionIndexes.end)) {
const changedIndex = selectionStartIndex !== lastSelectionIndexes.start ? selectionStartIndex : selectionEndIndex;
scrollToOption(instance, changedIndex);
}
lastSelectionIndexes.start = selectionStartIndex;
lastSelectionIndexes.end = selectionEndIndex;
}
/**
* Scrolls, only if necessary, the UI of the provided szn-options element to make the option at the specified index
* fully visible.
*
* @param {SznSelectOptions} instance The szn-options element instance.
* @param {number} optionIndex The index of the option to select. The index must be a non-negative integer and cannot
* be greater than the total number of options.
*/
function scrollToOption(instance, optionIndex) {
const ui = instance._root;
if (ui.clientHeight >= ui.scrollHeight) {
return;
}
const uiBounds = ui.getBoundingClientRect();
const options = instance._root.querySelectorAll('[data-szn-select--options--option]');
const optionBounds = options[optionIndex].getBoundingClientRect();
if (optionBounds.top >= uiBounds.top && optionBounds.bottom <= uiBounds.bottom) {
return;
}
const delta = optionBounds.top < uiBounds.top ? optionBounds.top - uiBounds.top : optionBounds.bottom - uiBounds.bottom;
ui.scrollTop += delta;
}
/**
* Updates the multiple-items selection. This function is meant to be used with multi-selects when the user is
* selecting multiple items by dragging the mouse pointer over them.
*
* Any item which's index is in the provided instance's list of additionally selected items will be marked as
* selected as well.
*
* @param {SznSelectOptions} instance The szn-options element instance.
* @param {Element} lastHoveredItem The element representing the UI of the last option the user has hovered using
* their mouse pointer.
*/
function updateMultiSelection(instance, lastHoveredItem) {
const startIndex = instance._dragSelectionStartOption.index;
const lastIndex = lastHoveredItem._option.index;
const minIndex = Math.min(startIndex, lastIndex);
const maxIndex = Math.max(startIndex, lastIndex);
const options = instance._options.options;
const additionalIndexes = instance._additionalSelectedIndexes;
for (let i = 0, length = options.length; i < length; i++) {
const option = options.item(i);
if (isOptionEnabled(option)) {
let isOptionSelected = additionalIndexes.indexOf(i) > -1;
if (i >= minIndex && i <= maxIndex) {
isOptionSelected = !instance._invertSelection;
}
option.selected = isOptionSelected;
}
}
instance._options.dispatchEvent(new CustomEvent('change', {
bubbles: true,
cancelable: true
}));
}
/**
* Tests whether the provided elements represents the UI of an enabled option.
*
* @param {Element} optionUi The UI element to test.
* @return {boolean} <code>true</code> iff the option is enabled and can be interacted with.
* @see isOptionEnabled
*/
function isEnabledOptionUi(optionUi) {
return optionUi.hasAttribute('data-szn-select--options--option') && isOptionEnabled(optionUi._option);
}
/**
* Tests whether the provided option is enabled - it is not disabled nor it is a child of a disabled options group.
* The provided option cannot be an orphan.
*
* @param {HTMLOptionElement} option The option element to test.
* @return {boolean} <code>true</code> iff the option is enabled and can be interacted with.
*/
function isOptionEnabled(option) {
return !option.disabled && !option.parentNode.disabled;
}
/**
* Updates the UI, if the provided szn-options element has already been provided with the options to display. The
* functions synchronizes the displayed UI to reflect the available options, their status, and scrolls to the last
* selected option if it is not visible.
*
* @param {SznSelectOptions} instance The szn-options element's instance.
*/
function updateUi(instance) {
if (!instance._options) {
return;
}
if (instance._options.disabled) {
instance._root.setAttribute('disabled', '');
} else {
instance._root.removeAttribute('disabled');
}
if (instance._options.multiple) {
instance._root.setAttribute('data-szn-select--options--multiple', '');
} else {
instance._root.removeAttribute('data-szn-select--options--multiple');
}
updateGroupUi(instance._root, instance._options);
if (instance._mounted) {
const options = instance._options.options;
let lastSelectedIndex = -1;
for (let i = options.length - 1; i >= 0; i--) {
if (options.item(i).selected) {
lastSelectedIndex = i;
break;
}
}
scrollToSelection(instance, instance._options.selectedIndex, lastSelectedIndex);
}
}
/**
* Updates the contents of the provided UI to reflect the options in the provided options container. The function
* removes removed options from the UI, updates the existing and adds the missing ones.
*
* @param {Element} uiContainer The element containing the constructed UI reflecting the provided options.
* @param {HTMLElement} optionsGroup The element containing the options to be reflected in the UI.
*/
function updateGroupUi(uiContainer, optionsGroup) {
removeRemovedItems(uiContainer, optionsGroup);
updateExistingItems(uiContainer);
addMissingItems(uiContainer, optionsGroup);
}
/**
* Removes UI items from the UI that have been representing the options and option groups that have been removed from
* the provided container of options.
*
* @param {Element} uiContainer The element containing the elements reflecting the provided options and providing the
* UI for the options.
* @param {HTMLElement} optionsGroup The element containing the options for which this szn-options element is
* providing the UI.
*/
function removeRemovedItems(uiContainer, optionsGroup) {
const options = Array.prototype.slice.call(optionsGroup.children);
let currentItemUi = uiContainer.firstElementChild;
while (currentItemUi) {
if (options.indexOf(currentItemUi._option) > -1) {
currentItemUi = currentItemUi.nextElementSibling;
continue;
}
const itemToRemove = currentItemUi;
currentItemUi = currentItemUi.nextElementSibling;
uiContainer.removeChild(itemToRemove);
}
}
/**
* Updates all items in the provided UI container to reflect the current state of their associated options.
*
* @param {Element} groupUi The element containing the elements representing the UIs of the options.
*/
function updateExistingItems(groupUi) {
let itemUi = groupUi.firstElementChild;
while (itemUi) {
updateItem(itemUi);
itemUi = itemUi.nextElementSibling;
}
}
/**
* Updates the UI item to reflect the current state of its associated <code>option</code>/<code>optgroup</code>
* element.
*
* If the element represents an option group (<code>optgroup</code>), the children options will be updated as well.
*
* @param {HTMLElement} itemUi The element representing the UI of an <code>option</code>/<code>optgroup</code>
* element.
*/
function updateItem(itemUi) {
const option = itemUi._option;
if (option.disabled) {
itemUi.setAttribute('disabled', '');
} else {
itemUi.removeAttribute('disabled');
}
if (option.tagName === 'OPTGROUP') {
updateGroupUi(itemUi, option);
itemUi.setAttribute('data-szn-select--options--optgroup-label', option.label);
return;
}
itemUi.innerText = option.text;
if (option.title) {
itemUi.setAttribute('title', option.title);
} else {
itemUi.removeAttribute('title');
}
if (option.selected) {
itemUi.setAttribute('data-szn-select--options--selected', '');
} else {
itemUi.removeAttribute('data-szn-select--options--selected');
}
}
/**
* Adds the options present in the options container missing the UI into the UI, while preserving the order of the
* options. Option groups are added recursively.
*
* @param {Element} groupUi The element containing the UIs of the options. The new options will be inserted into this
* element's children.
* @param {HTMLElement} options An element containing the <code>option</code> and <code>optgroup</code> elements that
* the UI reflects.
*/
function addMissingItems(groupUi, options) {
let nextItemUi = groupUi.firstElementChild;
let nextOption = options.firstElementChild;
while (nextOption) {
if (!nextItemUi || nextItemUi._option !== nextOption) {
const newItemUi = document.createElement('szn-');
newItemUi._option = nextOption;
newItemUi.setAttribute('data-szn-select--options--' + (nextOption.tagName === 'OPTGROUP' ? 'optgroup' : 'option'), '');
updateItem(newItemUi);
groupUi.insertBefore(newItemUi, nextItemUi);
} else {
nextItemUi = nextItemUi && nextItemUi.nextElementSibling;
}
nextOption = nextOption.nextElementSibling;
}
}
SznElements['szn-select--options'] = SznSelectOptions;
})(self);
'use strict';
(global => {
const SznElements = global.SznElements = global.SznElements || {};
const CSS_STYLES = `
szn-select--ui{display:inline-block;width:100%;height:100%}szn-select--ui[data-szn-select--ui--active] szn-select--button,szn-select--ui[data-szn-select--ui--active] szn-select--options{border-color:#7dbfff;border-color:var(--szn-select--ui--active-border-color,#7dbfff);box-shadow:0 0 3px rgba(0,132,255,.4);box-shadow:var(--szn-select--ui--active-box-shadow,0 0 3px rgba(0,132,255,.4))}body>szn-tethered>[data-szn-select--ui--dropdown]{display:block;top:-1px;top:var(--szn-select--ui--dropdown-offset,-1px);min-width:109px;min-width:var(--szn-select--ui--dropdown-min-width,109px)}body>szn-tethered[data-vertical-align=top]>[data-szn-select--ui--dropdown]{bottom:-1px;bottom:var(--szn-select--ui--dropdown-offset,-1px)}
`;
const MIN_BOTTOM_SPACE = 160; // px
const INTERACTION_DOM_EVENTS = ['mousedown', 'click', 'touchstart'];
const RESIZE_RELATED_DOM_EVENTS = ['resize', 'scroll', 'wheel', 'touchmove'];
const DEFAULT_DROPDOWN_CONTAINER = document.body;
SznElements['szn-select--ui'] = class SznSelectUi {
constructor(rootElement) {
if (!rootElement.hasOwnProperty('minBottomSpace')) {
Object.defineProperty(rootElement, 'minBottomSpace', {
get: () => rootElement._broker._minBottomSpace,
set: value => {
if (rootElement._broker._dropdown && rootElement._broker._dropdown._broker) {
rootElement._broker._dropdown.minBottomSpace = value;
}
rootElement._broker._minBottomSpace = value;
}
});
}
if (!rootElement.hasOwnProperty('dropdownClassName')) {
Object.defineProperty(rootElement, 'dropdownClassName', {
get: () => rootElement._broker._dropdownClassName,
set: value => {
const broker = rootElement._broker;
broker._dropdownClassName = value;
if (broker._dropdownOptions && broker._select && !broker._select.multiple) {
broker._dropdownOptions.className = value;
}
}
});
}
if (!rootElement.hasOwnProperty('dropdownContainer')) {
Object.defineProperty(rootElement, 'dropdownContainer', {
get: () => rootElement._broker._dropdownContainer,
set: value => {
if (typeof Node === 'function'
/* IE8 */
&& !(value instanceof Node)) {
throw new TypeError('The provided dropdown container is not a DOM node: ' + value);
}
const instance = rootElement._broker;
if (!instance || value === instance._dropdownContainer) {
return;
}
const isDropdownRebuildNeeded = [instance._dropdownContainer, value].includes(DEFAULT_DROPDOWN_CONTAINER);
instance._dropdownContainer = value;
if (!instance._dropdown || !instance._dropdown.parentNode) {
return;
}
if (isDropdownRebuildNeeded) {
instance._dropdown = createDropdown(instance);
value.appendChild(instance._dropdown);
if (value === DEFAULT_DROPDOWN_CONTAINER) {
onDropdownPositionChange(instance, instance._dropdown.verticalAlignment);
} else {
instance._dropdownContent.style.height = '';
}
} else {
value.appendChild(instance._dropdown);
}
}
});
}
rootElement.setSelectElement = this.setSelectElement.bind(this);
rootElement.setFocus = this.setFocus.bind(this);
rootElement.setOpen = this.setOpen.bind(this);
rootElement.onUiInteracted = rootElement.onUiInteracted || null;
this._root = rootElement;
this._select = null;
this._button = null;
this._dropdown = null;
this._dropdownClassName = '';
this._dropdownPosition = null;
this._dropdownContent = SznElements.buildDom('<szn- data-szn-select--ui--dropdown data-szn-tethered--content></szn->');
this._dropdownOptions = null;
this._dropdownContainer = DEFAULT_DROPDOWN_CONTAINER;
this._minBottomSpace = MIN_BOTTOM_SPACE;
this._observer = new MutationObserver(onDomMutated.bind(this));
this._onDropdownPositionChange = onDropdownPositionChange.bind(null, this);
this._onDropdownSizeUpdateNeeded = onDropdownSizeUpdateNeeded.bind(null, this);
this._onUiInteracted = onUiInteracted.bind(null, this);
SznElements.injectStyles(CSS_STYLES, 'szn-select--ui');
}
onMount() {
this._root.setAttribute('aria-hidden', 'true');
addEventListeners(this);
this._observer.observe(this._root, {
childList: true,
attributes: true,
characterData: true,
subtree: true,
attributeFilter: ['disabled', 'multiple', 'selected']
});
}
onUnmount() {
if (this._dropdown && this._dropdown.parentNode) {
this._dropdown.parentNode.removeChild(this._dropdown);
}
removeEventListeners(this);
this._observer.disconnect();
}
setSelectElement(select) {
if (select === this._select) {
return;
}
this._select = select;
createUI(this);
}
setOpen(isOpen) {
if (this._select.multiple || this._select.disabled) {
return;
}
if (isOpen) {
if (this._button._broker) {
this._button.setOpen(true);
}
this._dropdownContainer.appendChild(this._dropdown);
let dropdownReady = false;
let optionsReady = false;
SznElements.awaitElementReady(this._dropdown, () => {
dropdownReady = true;
if (optionsReady) {
initDropdown(this, this._dropdown, this._dropdownOptions);
}
});
SznElements.awaitElementReady(this._dropdownOptions, () => {
optionsReady = true;
if (dropdownReady) {
initDropdown(this, this._dropdown, this._dropdownOptions);
}
});
} else {
if (!this._dropdown.parentNode) {
return;
}
if (this._button._broker) {
this._button.setOpen(false);
}
this._dropdown.parentNode.removeChild(this._dropdown);
}
}
setFocus(hasFocus) {
if (hasFocus) {
this._root.setAttribute('data-szn-select--ui--active', '');
} else {
this._root.removeAttribute('data-szn-select--ui--active');
}
}
};
function addEventListeners(instance) {
for (const eventType of INTERACTION_DOM_EVENTS) {
instance._root.addEventListener(eventType, instance._onUiInteracted);
}
for (const eventType of RESIZE_RELATED_DOM_EVENTS) {
addEventListener(eventType, instance._onDropdownSizeUpdateNeeded);
}
}
function removeEventListeners(instance) {
for (const eventType of INTERACTION_DOM_EVENTS) {
instance._root.removeEventListener(eventType, instance._onUiInteracted);
instance._dropdownContent.removeEventListener(eventType, instance._onUiInteracted);
instance._dropdownContainer.removeEventListener(eventType, instance._onUiInteracted);
}
for (const eventType of RESIZE_RELATED_DOM_EVENTS) {
removeEventListener(eventType, instance._onDropdownSizeUpdateNeeded);
}
}
function onUiInteracted(instance, event) {
if (instance._root.onUiInteracted) {
instance._root.onUiInteracted(event);
}
}
function onDomMutated(instance) {
// Since we are mutating our subtree, there will be false positives, so we always need to check what has changed
const select = instance._select;
if (select && (select.multiple && instance._button || !select.multiple && !instance._button)) {
createUI(instance);
}
}
function onDropdownSizeUpdateNeeded(instance) {
if (!instance._dropdown || !instance._dropdown._broker || !instance._dropdownOptions._broker || instance._dropdownContainer !== DEFAULT_DROPDOWN_CONTAINER) {
return;
}
const