@limetech/lime-elements
Version:
535 lines (534 loc) • 20.6 kB
JavaScript
import { MDCFloatingLabel } from "@material/floating-label";
import { MDCSelectHelperText } from "@material/select/helper-text";
import { h, } from "@stencil/core";
import { isMobileDevice } from "../../util/device";
import { ENTER, SPACE } from "../../util/keycodes";
import { isMultiple } from "../../util/multiple";
import { createRandomString } from "../../util/random-string";
import { SelectTemplate, triggerIconColorWarning } from "./select.template";
/**
* @exampleComponent limel-example-select-basic
* @exampleComponent limel-example-select-with-icons
* @exampleComponent limel-example-select-with-separators
* @exampleComponent limel-example-select-with-secondary-text
* @exampleComponent limel-example-select-multiple
* @exampleComponent limel-example-select-multiple-icons
* @exampleComponent limel-example-select-with-empty-option
* @exampleComponent limel-example-select-preselected
* @exampleComponent limel-example-select-change-options
* @exampleComponent limel-example-select-dialog
*/
export class Select {
resetHasChanged() {
this.hasChanged = false;
}
constructor() {
/**
* Set to `true` to make the field disabled.
* and visually shows that the `select` component is editable but disabled.
* This tells the users that if certain requirements are met,
* the component may become interactable.
*/
this.disabled = false;
/**
* Set to `true` to make the field read-only.
* This visualizes the component slightly differently.
* But shows no visual sign indicating that the component is disabled
* or can ever become interactable.
*/
this.readonly = false;
/**
* True if the control requires a value.
*/
this.required = false;
/**
* List of options.
*/
this.options = [];
/**
* Set to `true` to allow multiple values to be selected.
*/
this.multiple = false;
this.menuOpen = false;
this.hasChanged = false;
this.checkValid = false;
this.handleMenuChange = this.handleMenuChange.bind(this);
this.handleNativeChange = this.handleNativeChange.bind(this);
this.handleMenuTriggerKeyPress =
this.handleMenuTriggerKeyPress.bind(this);
this.openMenu = this.openMenu.bind(this);
this.closeMenu = this.closeMenu.bind(this);
this.portalId = createRandomString();
}
connectedCallback() {
this.initialize();
}
componentWillLoad() {
this.isMobileDevice = isMobileDevice();
// It should not be possible to render the native select for consumers, but we still want to make it testable.
// We can set this attribute in tests to force rendering of the native select
if (Object.hasOwn(this.host.dataset, 'native')) {
this.isMobileDevice = true;
}
}
componentDidLoad() {
this.initialize();
triggerIconColorWarning(this.getOptionsExcludingSeparators());
this.updatePortalAnchor();
}
initialize() {
let element;
element = this.host.shadowRoot.querySelector('.mdc-floating-label');
if (!element) {
return;
}
this.mdcFloatingLabel = new MDCFloatingLabel(element);
element = this.host.shadowRoot.querySelector('.mdc-select-helper-text');
if (element) {
this.mdcSelectHelperText = new MDCSelectHelperText(element);
}
}
disconnectedCallback() {
this.cancelPendingFocus();
if (this.mdcFloatingLabel) {
this.mdcFloatingLabel.destroy();
}
if (this.mdcSelectHelperText) {
this.mdcSelectHelperText.destroy();
}
}
componentDidUpdate() {
if (this.menuOpen) {
this.setMenuFocus();
}
}
render() {
const dropdownZIndex = getComputedStyle(this.host).getPropertyValue('--dropdown-z-index');
return (h(SelectTemplate, { key: '0bc73012998fd97b022c0bdbab31e6806a3ddfd3', id: this.portalId, disabled: this.disabled || this.readonly, readonly: this.readonly, required: this.required, invalid: this.invalid, label: this.label, helperText: this.helperText, value: this.value, options: this.options, onMenuChange: this.handleMenuChange, onNativeChange: this.handleNativeChange, onTriggerPress: this.handleMenuTriggerKeyPress, multiple: this.multiple, isOpen: this.menuOpen, open: this.openMenu, close: this.closeMenu, checkValid: this.checkValid, native: this.isMobileDevice && !this.multiple, dropdownZIndex: dropdownZIndex, anchor: this.getAnchorElement() }));
}
watchOpen(newValue, oldValue) {
if (this.checkValid) {
return;
}
// Menu was closed for the first time
if (!newValue && oldValue) {
this.checkValid = true;
}
}
setMenuFocus() {
if (this.isMobileDevice) {
return;
}
this.cancelPendingFocus();
this.focusTimeoutId = setTimeout(() => {
this.focusTimeoutId = undefined;
if (!this.menuOpen) {
return;
}
const list = document.querySelector(`#${this.portalId} limel-menu-surface limel-list`);
if (!list) {
return;
}
this.focusObserver = new IntersectionObserver((entries) => {
const entry = entries[0];
if (!(entry === null || entry === void 0 ? void 0 : entry.isIntersecting)) {
return;
}
this.focusObserver.disconnect();
this.focusObserver = undefined;
if (!this.menuOpen) {
return;
}
this.focusFirstMenuItem(list);
});
this.focusObserver.observe(list);
}, 0);
}
cancelPendingFocus() {
if (this.focusTimeoutId !== undefined) {
clearTimeout(this.focusTimeoutId);
this.focusTimeoutId = undefined;
}
if (this.focusObserver) {
this.focusObserver.disconnect();
this.focusObserver = undefined;
}
}
focusFirstMenuItem(list) {
var _a;
const firstItem = (_a = list === null || list === void 0 ? void 0 : list.shadowRoot) === null || _a === void 0 ? void 0 : _a.querySelector('[tabindex]');
if (firstItem) {
firstItem.focus({ preventScroll: true });
}
}
setTriggerFocus() {
const trigger = this.host.shadowRoot.querySelector('.limel-select-trigger');
trigger.focus();
}
// During the first render(), the shadow DOM isn't populated yet, so
// querySelector('.limel-select-trigger') returns null and we fall back
// to this.host. componentDidLoad() calls updatePortalAnchor() to
// imperatively refresh the anchor once the shadow DOM is available.
getAnchorElement() {
var _a;
return ((_a = this.host.shadowRoot.querySelector('.limel-select-trigger')) !== null && _a !== void 0 ? _a : this.host);
}
updatePortalAnchor() {
const portal = this.host.shadowRoot.querySelector('limel-portal');
if (portal) {
portal.anchor = this.getAnchorElement();
}
}
handleMenuChange(event) {
var _a, _b;
event.stopPropagation();
if (isMultiple(event.detail)) {
const selector = `#${this.portalId} limel-menu-surface`;
const menuSurface = (_b = (_a = document
.querySelector(selector)) === null || _a === void 0 ? void 0 : _a.shadowRoot) === null || _b === void 0 ? void 0 : _b.querySelector('.mdc-menu-surface');
const scrollPosition = (menuSurface === null || menuSurface === void 0 ? void 0 : menuSurface.scrollTop) || 0;
const listItems = event.detail;
const options = listItems.map((item) => item.value);
this.change.emit(options);
// Using a single requestAnimationFrame or setTimeout doesn't
// work. Using two nested `requestAnimationFrame` worked most of
// the time, but not always. Using `setTimeout` inside the
// `requestAnimationFrame` seems to work consistently. /Ads
requestAnimationFrame(() => {
setTimeout(() => {
menuSurface.scrollTop = scrollPosition;
});
});
return;
}
if (!event.detail.selected) {
return;
}
const listItem = event.detail;
const option = listItem.value;
if (option.disabled) {
return;
}
this.change.emit(option);
this.menuOpen = false;
this.cancelPendingFocus();
this.setTriggerFocus();
}
openMenu() {
const autoSelectOption = this.getFirstNativeAutoSelectOption();
if (autoSelectOption) {
this.hasChanged = true;
this.change.emit(autoSelectOption);
}
this.menuOpen = true;
}
getFirstNativeAutoSelectOption() {
if (this.hasChanged || !this.isMobileDevice || this.multiple) {
return undefined;
}
const options = this.getOptionsExcludingSeparators();
// Also treat it as "no value" when the current value doesn't match
// any available option (e.g. an empty option that was filtered out
// by a required field).
const currentValue = this.value;
const hasMatchingValue = currentValue &&
!Array.isArray(currentValue) &&
options.some((o) => o.value === currentValue.value);
if (hasMatchingValue) {
return undefined;
}
if (options.length > 0 && !options[0].value) {
return undefined;
}
return options.find((o) => !o.disabled && o.value);
}
closeMenu() {
this.menuOpen = false;
this.cancelPendingFocus();
this.setTriggerFocus();
}
handleMenuTriggerKeyPress(event) {
const isEnter = event.key === ENTER;
const isSpace = event.key === SPACE;
if (!this.menuOpen && (isSpace || isEnter)) {
event.stopPropagation();
event.preventDefault();
this.menuOpen = true;
}
}
handleNativeChange(event) {
event.stopPropagation();
const element = this.host.shadowRoot.querySelector('select.limel-select__native-control');
const options = Array.apply(null, element.options) // eslint-disable-line prefer-spread
.filter((optionElement) => {
return !!optionElement.selected;
})
.map((optionElement) => {
return this.getOptionsExcludingSeparators().find((o) => o.value === optionElement.value);
});
if (this.multiple) {
this.change.emit(options);
return;
}
this.change.emit(options[0]);
this.menuOpen = false;
}
getOptionsExcludingSeparators() {
return this.options.filter((option) => !('separator' in option));
}
static get is() { return "limel-select"; }
static get encapsulation() { return "shadow"; }
static get originalStyleUrls() {
return {
"$": ["select.scss"]
};
}
static get styleUrls() {
return {
"$": ["select.css"]
};
}
static get properties() {
return {
"disabled": {
"type": "boolean",
"mutable": false,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "Set to `true` to make the field disabled.\nand visually shows that the `select` component is editable but disabled.\nThis tells the users that if certain requirements are met,\nthe component may become interactable."
},
"getter": false,
"setter": false,
"reflect": true,
"attribute": "disabled",
"defaultValue": "false"
},
"readonly": {
"type": "boolean",
"mutable": false,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "Set to `true` to make the field read-only.\nThis visualizes the component slightly differently.\nBut shows no visual sign indicating that the component is disabled\nor can ever become interactable."
},
"getter": false,
"setter": false,
"reflect": true,
"attribute": "readonly",
"defaultValue": "false"
},
"invalid": {
"type": "boolean",
"mutable": false,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "Set to `true` to indicate that the current value of the select is\ninvalid."
},
"getter": false,
"setter": false,
"reflect": true,
"attribute": "invalid"
},
"required": {
"type": "boolean",
"mutable": false,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "True if the control requires a value."
},
"getter": false,
"setter": false,
"reflect": true,
"attribute": "required",
"defaultValue": "false"
},
"label": {
"type": "string",
"mutable": false,
"complexType": {
"original": "string",
"resolved": "string",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "Text to display next to the select."
},
"getter": false,
"setter": false,
"reflect": true,
"attribute": "label"
},
"helperText": {
"type": "string",
"mutable": false,
"complexType": {
"original": "string",
"resolved": "string",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "Optional helper text to display below the input field when it has focus."
},
"getter": false,
"setter": false,
"reflect": true,
"attribute": "helper-text"
},
"value": {
"type": "unknown",
"mutable": false,
"complexType": {
"original": "Option | Option[]",
"resolved": "Option<string> | Option<string>[]",
"references": {
"Option": {
"location": "import",
"path": "../select/option.types",
"id": "src/components/select/option.types.ts::Option",
"referenceLocation": "Option"
}
}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "Currently selected value or values.\nIf `multiple` is `true`, this must be an array. Otherwise it must be a\nsingle value."
},
"getter": false,
"setter": false
},
"options": {
"type": "unknown",
"mutable": false,
"complexType": {
"original": "Array<Option | ListSeparator>",
"resolved": "(ListSeparator | Option<string>)[]",
"references": {
"Array": {
"location": "global",
"id": "global::Array"
},
"Option": {
"location": "import",
"path": "../select/option.types",
"id": "src/components/select/option.types.ts::Option",
"referenceLocation": "Option"
},
"ListSeparator": {
"location": "import",
"path": "../list-item/list-item.types",
"id": "src/components/list-item/list-item.types.ts::ListSeparator",
"referenceLocation": "ListSeparator"
}
}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "List of options."
},
"getter": false,
"setter": false,
"defaultValue": "[]"
},
"multiple": {
"type": "boolean",
"mutable": false,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "Set to `true` to allow multiple values to be selected."
},
"getter": false,
"setter": false,
"reflect": false,
"attribute": "multiple",
"defaultValue": "false"
}
};
}
static get states() {
return {
"menuOpen": {}
};
}
static get events() {
return [{
"method": "change",
"name": "change",
"bubbles": true,
"cancelable": true,
"composed": true,
"docs": {
"tags": [],
"text": "Emitted when the value is changed."
},
"complexType": {
"original": "Option | Option[]",
"resolved": "Option<string> | Option<string>[]",
"references": {
"Option": {
"location": "import",
"path": "../select/option.types",
"id": "src/components/select/option.types.ts::Option",
"referenceLocation": "Option"
}
}
}
}];
}
static get elementRef() { return "host"; }
static get watchers() {
return [{
"propName": "value",
"methodName": "resetHasChanged"
}, {
"propName": "options",
"methodName": "resetHasChanged"
}, {
"propName": "menuOpen",
"methodName": "watchOpen"
}];
}
}