import { ZuiFormAssociatedElement } from '@zywave/zui-base'; import { html, nothing } from 'lit'; import { property, query } from 'lit/decorators.js'; import { style } from './zui-select-dropdown-css.js'; import { repeat } from 'lit/directives/repeat.js'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { classMap } from 'lit/directives/class-map.js'; import { escapeRegexChars } from '@zywave/zui-base/dist/utils/escape-regex-chars.js'; import '@zywave/zui-icons'; import '@zywave/zui-checkbox'; import '@zywave/zui-spinner'; import { ZuiOptionObject, ZuiOptionElement } from './zui-option.js'; import { ZuiOptionGroupElement } from './zui-option-group.js'; import type { IZuiOptionObject } from './zui-option'; import type { PropertyValues, TemplateResult } from 'lit'; /** * `` is an evolved `` that supports multiselect, typeahead, tagging, grouping, and asynchronous option retrieval. Great for longer imperatively created lists. * @element zui-select-dropdown * * @event change - Event dispatches on selected option(s) changed * @event tag - Event dispatches on new option tagged; event contains `value` details * @event query - Event dispatches on search term typed in the input. If debounce > 0, only fires after timeout; event contains `value` details * * @slot - Default, unnamed slot; for inserting `` or `` elements into `` * * @attr {string | null} [name=null] - The name of this element that is associated with form submission * @attr {boolean} [disabled=false] - Represents whether a user can make changes to this element; if true, the value of this element will be excluded from the form submission * @attr {boolean} [readonly=false] - Represents whether a user can make changes to this element; the value of this element will still be included in the form submission * @attr {boolean} [autofocus=false] - If true, this element will be focused when connected to the document * * @prop {string | null} [name=null] - The name of this element that is associated with form submission * @prop {boolean} [disabled=false] - Represents whether a user can make changes to this element; if true, the value of this element will be excluded from the form submission * @prop {boolean} [readOnly=false] - Represents whether a user can make changes to this element; the value of this element will still be included in the form submission * @prop {boolean} [autofocus=false] - If true, this element will be focused when connected to the document * * @csspart control - For custom styling of the underlying select control; this is exposed as a CSS shadow part and can be accessed with `::part(control)` */ export class ZuiSelectDropdownElement extends ZuiFormAssociatedElement { get _focusControlSelector(): string { return '.control'; } get _formValue(): string[] | string | null { if (this.#selectedValues.length || this.allSelected) { return this.allSelected && this.enableSelectAllOverride ? [this.selectAllOptionValue!] : this.#selectedValues; } else { return null; } } /** * Text to show within input when no options selected */ @property({ type: String, reflect: true }) placeholder: string | null = null; /** * Allow multiple options to be selected */ @property({ type: Boolean, reflect: true }) multiple = false; /** * Allow typing a search term within the input to filter options */ @property({ type: Boolean, reflect: true }) searchable = false; /** * Delay in milliseconds after typing before retrieving options */ @property({ type: Number, reflect: true }) debounce: number | null = null; /** * Emphasize parts of the option text after typing that were not typed */ @property({ type: Boolean, reflect: true }) typeahead = false; /** * Allow creating the desired option after typing if no option matches the search term */ @property({ type: Boolean, reflect: true }) taggable = false; /** * Text to show on the taggable option before the search term */ @property({ type: String, attribute: 'taggable-label', reflect: true }) taggableLabel = 'Create'; /** * Only applicable when labelling option groups. If there are options untied to a group label, file under this label; defaults to "Other" if null. */ @property({ type: String, attribute: 'ungrouped-label', reflect: true }) ungroupedLabel: string | null = null; /** * The message to appear if a group contains no options */ @property({ type: String, attribute: 'no-results-message', reflect: true }) noResultsMessage: string | null = null; /** * Hide group and no results message if there are no options */ @property({ type: Boolean, attribute: 'hide-empty-groups', reflect: true }) hideEmptyGroups = false; /** * If set, can provide hints to form validation and prevent users from clearing out a single select */ @property({ type: Boolean }) required = false; /** * When enabled, the "Select all" feature can be utilized. Note: this only applies when `multiple` is true */ @property({ type: Boolean, attribute: 'enable-select-all' }) get enableSelectAll(): boolean { return this.#isSelectAllValid; } set enableSelectAll(val: boolean) { const oldVal = this.#enableSelectAll; this.#enableSelectAll = val; this.requestUpdate('enableSelectAll', oldVal); } /** * Provides the user-facing text in the dropdown list for the "Select all" option. * Required when allowing a user to select all options with `enable-select-all` / `enableSelectAll`. */ @property({ type: String, attribute: 'select-all-option-label' }) selectAllOptionLabel: string | null = null; /** * Provides the user-facing text in the result container when the "Select all" option is selected. * Required when allowing a user to select all options with `enable-select-all` / `enableSelectAll`. */ @property({ type: String, attribute: 'select-all-result-label' }) selectAllResultLabel: string | null = null; /** * Optional property used alongside `enable-select-all` / `enableSelectAll` to control the value selected when "Select all" is selected. * If set, and the user has selected all value, the value of this property will be included in the selection list. */ @property({ type: String, attribute: 'select-all-option-value' }) selectAllOptionValue: string | null = null; /** * When enabled, when a user indicates to "select all", then all options will be selected, * the `selectAllResultLabel` will be rendered alone in the result container, and the user will be unable to deselect individual options until they deselect "select all". */ @property({ type: Boolean, attribute: 'enable-select-all-override' }) get enableSelectAllOverride(): boolean { return this.#enableSelectAllOverride; } set enableSelectAllOverride(val: boolean) { const oldVal = this.#enableSelectAllOverride; this.#enableSelectAllOverride = val; this.requestUpdate('enableSelectAllOverride', oldVal); } /** * Controls the maximum number of results to display in the result container. * Must be used with `truncated-result-message-format` / `truncatedResultMessageFormat`. */ @property({ type: Number, attribute: 'maximum-results-display-count' }) maximumResultsDisplayCount = 5; /** * Controls how the truncated result option is rendered. * Can use `{0}` to merge in the number of results that were not displayed. * @example "{0} more" */ @property({ type: String, attribute: 'truncated-result-message-format' }) truncatedResultMessageFormat: string | null = null; /** * An alternative or supplementary way to retrieve options. Must be a function that accepts a string argument and an optional array argument of `ZuiOptionObject` elements. Must return an array or a `Promise` of an array of strings or objects with a `label` property and optional `value`, `disabled`, and/or `group` properties. The search term will be passed to the first argument and the existing options will be passed to the second argument. */ @property({ type: Function, attribute: false }) queryHandler?: QueryHandlerCallback; /** * Determines if all options are selected */ get allSelected(): boolean { return ( this.enableSelectAll && (this.#allSelected || (!this.enableSelectAllOverride && this.#selectedOptions.length === this.#zuiOptions.length)) ); } @query('.control') _control: HTMLElement; @query('input') _input: HTMLInputElement; @query('.options-container-parent') _optionsContainerParent: HTMLElement; @query('.options-container') _optionsContainer: HTMLElement; #open = false; #options: ZuiOptionObject[] = []; #optionGroups: string[] = []; #selectedOptions: IZuiOptionObject[] = []; private _highlightedIndex: number | undefined = undefined; #inputTimeout: number | null = null; #preventInputUpdate = false; #loadingOptions = false; #dropdownMaxHeight = 304; #dropdownOptionHeight = 36; #dropdownGroupHeaderHeight = 24; #dropdownGroupHeaderMargin = 20; #dropdownPadding = 5; // hack to defer the change event from being fired when nothing has changed on iniitial render #initialized = false; #allSelected = false; #enableSelectAllOverride = false; #enableSelectAll = false; #hasEnteredQuery = false; #controlFocused: boolean = false; get _query(): string { return this._input?.value; } /** * Represents if the "Select all" feature can be utilized */ get #isSelectAllValid() { return !!( this.multiple && this.#enableSelectAll && this.selectAllOptionLabel && (!this.enableSelectAllOverride || (this.selectAllOptionValue && this.selectAllResultLabel)) ); } get #isTruncationValid() { return !!(this.multiple && this.truncatedResultMessageFormat && this.maximumResultsDisplayCount > 0); } get type(): string { return this.multiple ? 'select-multiple' : 'select-one'; } get options(): ZuiOptionElement[] { this.#zuiOptions.forEach((o: ZuiOptionElement) => { if (o.value) { o.selected = this.#selectedValues.includes(o.value); } }); return this.#zuiOptions; } get value(): string { let value = ''; if (this.#selectedOptions.length > 0) { value = this.#selectedOptions[0]?.value ?? ''; } return value; } /** * @ignore */ set value(_val: unknown) { /* eslint-disable no-console */ console.warn('Setting value on is not supported.'); } /** * Returns the selected options as an array of `ZuiOptionObject` objects. See {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLSelectElement/selectedOptions | selectedOptions} for browser-native documentation. */ get selectedOptions() { return [...this.#selectedOptions]; } get #zuiOptions(): ZuiOptionElement[] { return Array.from(this.querySelectorAll('zui-option')); } get #zuiOptionGroups(): ZuiOptionGroupElement[] { return Array.from(this.querySelectorAll('zui-option-group')); } get #selectedValues(): string[] { return this.#selectedOptions.map((o: ZuiOptionObject) => this.#getOptionValue(o)); } get #anySelected(): boolean { return this.#selectedValues.length > 0; } get #hasOneAvailableOption(): boolean { return this._optionsLength === 1 && (this.#canCreateOption || !this.#optionDisabled(this.#options[0])); } get #singleSelect(): boolean { return !this.multiple; } get #canCreateOption(): boolean { return ( this.taggable && this._query && (this.multiple || !this.#anySelected) && !this.#options.some((o: ZuiOptionObject) => { this.#getOptionText(o)?.localeCompare(this._query, undefined, { sensitivity: 'accent' }) === 0; }) ); } get _optionsLength(): number { return this.#options.length + (this.#canCreateOption ? 1 : 0); } get _defaultOption(): ZuiOptionObject | null { return this.#singleSelect && !this.placeholder && this.#zuiOptions.length > 0 ? this.#getZuiOptionObject(this.#zuiOptions[0]) : null; } static get styles() { return [super.styles, style]; } /** * Retrieves the currently selected options from the DOM elements * @returns {IZuiOptionObject[]} Array of currently selected options */ get #currentlySelectedOptions(): IZuiOptionObject[] { return this.#zuiOptions.filter((o) => o.selected).map((o) => this.#getZuiOptionObject(o)); } /** * Sets the initial 'all selected' state based on whether all options are selected at startup * @param {IZuiOptionObject[]} selectedOptions - The array of initially selected options */ #setInitialAllSelectedState(selectedOptions: IZuiOptionObject[]) { if (this.enableSelectAll && selectedOptions.length === this.#zuiOptions.length) { this.#allSelected = true; this.requestUpdate(); } } firstUpdated(changedProps: PropertyValues) { super.firstUpdated(changedProps); this.addEventListener('focusin', () => this.#toggleOpen(true)); this.addEventListener('focusout', () => this.#toggleOpen(false)); this.addEventListener('updated', async (e) => { if ((e.target as Element).matches('zui-option')) { await this.updateComplete; const newSelectedZuiOptions = this.#zuiOptions .filter((o) => o.selected) .map((o) => this.#getZuiOptionObject(o)); const oldSelectedZuiOptions = this.#selectedOptions.filter((o) => this.#isZuiOption(o)); const addedZuiOptions = newSelectedZuiOptions.filter( (a) => !oldSelectedZuiOptions.some((b) => this.#getOptionValue(a) === this.#getOptionValue(b)) ); const removedZuiOptions = oldSelectedZuiOptions.filter( (a) => !newSelectedZuiOptions.some((b) => this.#getOptionValue(a) === this.#getOptionValue(b)) ); if (addedZuiOptions.length > 0 || removedZuiOptions.length > 0) { const selectedOptions = [ ...this.#selectedOptions.filter((o) => !this.#isZuiOption(o)), ...newSelectedZuiOptions, ]; this.#setSelectedOptions(selectedOptions); } } }); const initialSelectedOptions = this.#currentlySelectedOptions; this.#setSelectedOptions(initialSelectedOptions); this.#setInitialAllSelectedState(initialSelectedOptions); } updated(changedProps: PropertyValues) { super.updated(changedProps); if (changedProps.has('multiple')) { this.#updateInputValue(); this.requestUpdate(); } this.#setOptionsContainerStyles(); } protected formResetCallback() { this.#selectedOptions = []; // todo preserve initial state to reset to } /** * Return a `ZuiOptionElement` based upon number index value passed in * @param {number} index * @returns {ZuiOptionElement | null} */ item(index: number): ZuiOptionObject | null { return this.#options?.[index] ?? null; } /** * Clears all selected options */ clear() { this.#selectedOptions = []; this._setFormValue(null); this.#hasEnteredQuery = false; if (this.#initialized) { this.#dispatchChangeEvent(); } this.#updateInputValue(); this.requestUpdate(); } /** * Opens the dropdown. See {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/showPicker | showPicker} for browser-native comparisons. */ showPicker() { this.#toggleOpen(true); } render() { // TODO: Temporary fix so the dropdown doesn't get cut off in zui-dialog (which wraps MWC dialog) // We will REMOVE this at a later date, so do not rely on it! // See MR for more details: https://gitlab.com/zywave/devkit/web-sdk/zui/-/merge_requests/1123 const isInScrollingDialog = !!this.closest('zui-dialog')?.shadowRoot?.querySelector('dialog.scrolling'); let selectAllOption; if (this.enableSelectAll && !this._query) { selectAllOption = html`
${this.selectAllOptionLabel}
`; } else { selectAllOption = nothing; } return html`
${this.#renderSelectedOptions()}
${this.#renderInput()}${this.#renderSpinner()}${this.#renderRemoveButton()}${this.#renderChevron()}
${selectAllOption} ${this.#renderDropdownList()} ${this.#renderCreateNewOption()}
`; } #renderDropdownList() { const hasGroups = this.#optionGroups.length > 0; const hasOptions = this.#options.length > 0; if (hasGroups && hasOptions) { return repeat(this.#optionGroups, (group) => this.#renderGroup(group)); } if (hasGroups && !hasOptions && this.hideEmptyGroups) { return this.#renderNoResultsMessage(true); } if (!hasGroups && hasOptions) { return repeat(this.#options, (option, index) => this.#renderOption(option, index)); } return this.#renderNoResultsMessage(true); } #renderSelectedOptions() { if (!this.multiple) { return nothing; } if (this.enableSelectAllOverride && this.allSelected) { return html`
${this.selectAllResultLabel}
`; } const items = this.#isTruncationValid ? this.#selectedOptions.slice(0, this.maximumResultsDisplayCount) : this.#selectedOptions; let truncatedSelection; if (this.#isTruncationValid && this.#selectedOptions.length > this.maximumResultsDisplayCount) { const truncatedCount = this.#selectedOptions.length - this.maximumResultsDisplayCount; const truncatedMessage = this.truncatedResultMessageFormat.replace('{0}', truncatedCount.toString()); truncatedSelection = html`
${truncatedMessage}
`; } else { truncatedSelection = nothing; } return html` ${repeat( items, (selection) => html`
${this.#getOptionText(selection)}${this.#renderRemoveButton(selection)}
` )} ${truncatedSelection} `; } #renderInput() { return html` `; } #renderSpinner() { return this.#loadingOptions ? html`` : nothing; } #renderRemoveButton(selection?: IZuiOptionObject) { return selection || (this.#singleSelect && this.placeholder && this.#anySelected && !this.required) ? html` ` : nothing; } #renderChevron() { return html` `; } #renderNoResultsMessage(shouldRender: boolean) { return shouldRender && this.#open && !this.#loadingOptions ? html`${this.noResultsMessage || 'No results'}` : nothing; } #renderGroup(group: string) { const map = this.#options .filter((o) => o.group === group) .map((option) => { return { index: this.#options.findIndex((o) => option === o), option: option }; }) .filter((temp) => temp.index > -1); const groupLabel = group === undefined ? this.ungroupedLabel || 'Other' : group; const renderGroup = !this.hideEmptyGroups || map.length > 0; return renderGroup ? html` ${groupLabel} ${repeat(map, (temp) => this.#renderOption(temp.option, temp.index))} ${this.#renderNoResultsMessage(map.length === 0)} ` : nothing; } #renderOption(option: ZuiOptionObject, index: number, content?: TemplateResult | unknown) { content = content || (!option ? this.#renderNoResultsMessage(true) : this.multiple ? this.#renderMultiselectOptionContent(option) : this.#renderOptionContent(option)); return html`
${content}
`; } #renderOptionContent(option: ZuiOptionObject) { const element = this.#getOptionElement(option); if (this.typeahead) { const regex = new RegExp('(' + (escapeRegexChars(this._query) || '.*') + ')', 'i'); const textNodes = this.#getTextNodes(element, []); for (const textNode of textNodes) { const html = textNode.nodeValue .split(regex) .filter((s) => s.length > 0) .map((s) => (regex.test(s) ? s : `${s}`)) .join(''); (textNode as ChildNode).replaceWith(this.#createSpan(html)); } } return html`${unsafeHTML(element?.innerHTML)}`; } #renderMultiselectOptionContent(option: ZuiOptionObject) { // these checkboxes should never have focus. if they do, assume the user tabbed and meant to tab off the dropdown return html`
${this.#renderOptionContent(option)}
`; } #renderCreateNewOption() { return this.#canCreateOption ? this.#renderOption( undefined, this.#options.length, html` ${this.taggableLabel} "${this._query}"` ) : nothing; } #setOptionsContainerStyles() { let optionsCount = this.#optionGroups.length > 0 ? this.#optionGroups .map((g) => Math.max(this.#options.filter((o) => o.group === g).length, 1)) .reduce((acc, cur) => acc + cur, 0) + (this.#canCreateOption ? 1 : 0) : this._optionsLength; optionsCount += this.enableSelectAll ? 1 : 0; const dropdownHeight = optionsCount * this.#dropdownOptionHeight + this.#optionGroups.length * this.#dropdownGroupHeaderHeight + Math.max(this.#optionGroups.length - 1, 0) * this.#dropdownGroupHeaderMargin + this.#dropdownPadding * 2; const containerHeight = optionsCount > 0 ? Math.min(dropdownHeight, this.#dropdownMaxHeight) : 0; const controlTop = this._control ? this._control.getBoundingClientRect().top : 0; const spaceBelow = window.innerHeight - controlTop - this._control.offsetHeight; const shouldDisplayAbove = containerHeight > spaceBelow && controlTop > spaceBelow; const controlHeight = (this._control.offsetHeight || 0) + 1; this._optionsContainer.style.padding = this.#open && !this.enableSelectAllOverride && optionsCount > 0 ? `${this.#dropdownPadding}px 0` : '0'; this._optionsContainer.style.overflowY = dropdownHeight > this.#dropdownMaxHeight ? 'scroll' : 'hidden'; this._optionsContainerParent.style.top = (this._control.offsetTop || 0) + controlHeight + 'px'; this._optionsContainerParent.style.left = (this._control.offsetLeft || 0) + 'px'; this._optionsContainer.style.width = (this._control.offsetWidth || 0) + 'px'; this._optionsContainer.style.bottom = shouldDisplayAbove ? controlHeight + 1 + 'px' : ''; } #getOptionClass(option: ZuiOptionObject, index: number): string { const classes = ['option']; if (this.#optionDisabled(option)) { classes.push('disabled'); } else if (this._highlightedIndex === index) { classes.push('highlighted'); } if (this.#optionSelected(option)) { classes.push('selected'); } if (index === this.#options.length) { classes.push('tag'); } if (this.allSelected && this.enableSelectAllOverride) { classes.push('readonly'); } return classes.join(' '); } #createSpan(innerHTML: string) { const element = document.createElement('span'); element.innerHTML = innerHTML; return element; } #getOptionElement(option: ZuiOptionObject | ZuiOptionElement | IZuiOptionObject): HTMLSpanElement | ZuiOptionElement { let element: HTMLSpanElement | ZuiOptionElement; if (option instanceof ZuiOptionObject) { element = this.#createSpan((option as ZuiOptionObject).label); } else if (option instanceof ZuiOptionElement) { element = option; } return element; } #getTextNodes(node: Node, textNodes: Node[]): Node[] { textNodes = textNodes || []; if (node?.hasChildNodes) { for (let i = 0; i < node.childNodes.length; i++) { const childNode = node.childNodes[i]; if (childNode.nodeType === Node.TEXT_NODE) { textNodes = [...textNodes, childNode]; } else { textNodes = this.#getTextNodes(childNode, textNodes); } } } return textNodes; } #getOptionText(option: ZuiOptionObject | ZuiOptionElement | IZuiOptionObject): string { return this.#getTextNodes(this.#getOptionElement(option), []) .map((n) => n.nodeValue) .join(''); } #getOptionValue(option: ZuiOptionObject | ZuiOptionElement | IZuiOptionObject): string { return option?.value || this.#getOptionText(option); } #getZuiOptionObject(option: IZuiOptionObject | ZuiOptionElement | string): ZuiOptionObject { if (option instanceof ZuiOptionObject) { return option; } else if (option instanceof ZuiOptionElement) { const zuiOption = option as ZuiOptionElement; return new ZuiOptionObject( zuiOption.innerHTML, zuiOption.value, zuiOption.disabled, zuiOption.parentElement instanceof ZuiOptionGroupElement ? zuiOption.parentElement.label : undefined ); } else if (option && typeof option === 'object') { const zuiOptionObject = option as IZuiOptionObject; return new ZuiOptionObject( zuiOptionObject.label !== undefined ? `${zuiOptionObject.label}` : `${zuiOptionObject.value}`, zuiOptionObject.value !== undefined ? `${zuiOptionObject.value}` : `${zuiOptionObject.label}`, !!zuiOptionObject.disabled, zuiOptionObject.group !== undefined ? `${zuiOptionObject.group}` : undefined ); } else { return new ZuiOptionObject(`${option}`); } } async #getOptions() { const queryHandler = (this.queryHandler ?? this.#defaultQueryHandler).bind(this); let options = this.#zuiOptions.map((o) => this.#getZuiOptionObject(o)); this.#loadingOptions = true; this.requestUpdate(); let query: string | null | undefined; if (this.#hasEnteredQuery) { query = this._query; } else { query = null; } const queryHandlerResults = await queryHandler(query, options); this.#loadingOptions = false; // don't re-open dropdown if async results returned after dropdown closed if (this.#controlFocused) { options = Array.isArray(queryHandlerResults) ? queryHandlerResults.map((o) => this.#getZuiOptionObject(o)) : []; const optionGroups = this.#zuiOptionGroups.map((g) => g.label); if (optionGroups.length > 0 || options.some((o) => o.group !== undefined)) { options.forEach((o) => { if (!optionGroups.includes(o.group)) { optionGroups.push(o.group); } }); } this.#options = options; for (const o of this.#options) { o.addEventListener('selectedchange', (e) => { const selected = (e.target as ZuiOptionObject).selected; this.#toggleOptionSelected(e.target as ZuiOptionObject, selected); }); } this.#optionGroups = optionGroups; // if only one option, highlight it so that it's automatically selected on blur this._highlightedIndex = this.#hasOneAvailableOption ? 0 : undefined; } this.requestUpdate(); } #optionDisabled(option: ZuiOptionObject) { return ( (this.allSelected && this.enableSelectAllOverride) || this.#options.filter((o) => o.disabled).some((o) => this.#getOptionValue(o) === this.#getOptionValue(option)) ); } #optionSelected(option: IZuiOptionObject) { return this.allSelected || this.#selectedValues.some((v) => v === this.#getOptionValue(option)); } /** * Toggles an option in the list of selected options * @param option The option to toggle * @param selected An optional override, to force it to be selected or unselected */ #toggleOptionSelected(option?: IZuiOptionObject, selected?: boolean) { const selectedOptions = this.multiple ? [...this.#selectedOptions] : []; option = option || (this.#anySelected ? this.#selectedOptions[this.#selectedOptions.length - 1] : undefined); if (this.#optionSelected(option) && (selected === undefined || !selected)) { // remove if was selected prior selectedOptions.splice( selectedOptions.findIndex((o) => this.#getOptionValue(o) === this.#getOptionValue(option)), 1 ); this.#allSelected = false; } else if (option && (selected === undefined || selected)) { // add if wasn't selected prior selectedOptions.push(option); } this.#setSelectedOptions(selectedOptions); } async #toggleSelectAll(force?: boolean) { if (this.enableSelectAll) { this.#allSelected = force ?? !this.#allSelected; this.#setSelectedOptions(this.#allSelected ? this.#zuiOptions : []); this.requestUpdate(); await this.updateComplete; } } #removeSelectionFromInterface(selection?: IZuiOptionObject) { if (this.multiple) { this.#preventInputUpdate = true; } this.#toggleOptionSelected(selection); if (this.#open) { this.#getOptions(); } } #highlightNextOption(downArrowKey: boolean) { if (this.#hasOneAvailableOption) { this.#highlightIndex(0); } else if (this._optionsLength > 1) { // if hitting down on the keypad, and no index selected OR the last option is highlighted, // and the first option is a fake option for select all, select it if ( (this._highlightedIndex === undefined || this._highlightedIndex === this._optionsLength - 1) && this.enableSelectAll && downArrowKey ) { this.#highlightIndex(-1); } else if (this._highlightedIndex === 0 && !downArrowKey) { // if hitting up on the keypoad, and the currently selected option is the first real option, // select the fake select all option this.#highlightIndex(-1); } else { const start = this._highlightedIndex === undefined || this._highlightedIndex === -1 ? downArrowKey ? -1 : 0 : this._highlightedIndex; const len = this._optionsLength; for (let i = 1; i < len; i++) { const index = (((downArrowKey ? start + i : start - i) % len) + len) % len; if (!this.#optionDisabled(this.#options[index])) { this.#highlightIndex(index); break; } } } } } #scrollToOption(keyDown: boolean) { if (this._highlightedIndex !== undefined) { let selectedOption: HTMLElement; if (this._highlightedIndex > -1) { const options = this._optionsContainer.querySelectorAll('.option:not(.readonly)'); selectedOption = options[this._highlightedIndex] as HTMLElement; } else if (this.enableSelectAll) { selectedOption = this._optionsContainer.querySelector('.option.select-all'); } const selectedPosition = selectedOption.offsetTop; const lastVisiblePosition = this.#dropdownMaxHeight - this.#dropdownOptionHeight; if ( selectedPosition > this._optionsContainer.scrollTop + lastVisiblePosition || selectedPosition < this._optionsContainer.scrollTop ) { let scrollTop = keyDown ? lastVisiblePosition : 0; // Don't cut the last option off if it's selected if (keyDown && this._highlightedIndex === this._optionsLength) { scrollTop -= this.#dropdownOptionHeight; } this._optionsContainer.scrollTop = selectedPosition - scrollTop; } } } #selectHighlightedOption() { if (this.multiple) { this.#preventInputUpdate = true; } if (this._highlightedIndex !== undefined) { if (this._highlightedIndex === -1) { this.#toggleSelectAll(); } else if (this._highlightedIndex < this.#options.length) { const highlightedOption = this.#options[this._highlightedIndex]; if (!this.#optionSelected(highlightedOption) || this.multiple) { this.#toggleOptionSelected(highlightedOption); } } else { this.dispatchEvent(new CustomEvent('tag', { detail: this._query })); } } if (this.#singleSelect) { this.#toggleOpen(false); } } #setSelectedOptions(options: IZuiOptionObject[]) { if (!options.length && this._defaultOption) { options.push(this._defaultOption); } else if (this.#singleSelect) { options.splice(0, options.length - 1); } this.#selectedOptions = options; for (const option of this.#zuiOptions) { option.selected = options.some((o: IZuiOptionObject) => this.#getOptionValue(o) === this.#getOptionValue(option)); } this._setFormValue(this._formValue); if (this.#initialized) { this.#dispatchChangeEvent(); } this.#initialized = true; this.#updateInputValue(); this.#hasEnteredQuery = false; this.requestUpdate(); } #clearOptions() { this._highlightedIndex = undefined; this.#options = []; this.requestUpdate(); } #handleKeydown(e: KeyboardEvent) { if (this._optionsLength > 0) { const downArrowKey = e.key === 'ArrowDown' || e.key === 'Down'; const upArrowKey = e.key === 'ArrowUp' || e.key === 'Up'; if (downArrowKey || upArrowKey) { e.preventDefault(); this.#highlightNextOption(downArrowKey); this.#scrollToOption(downArrowKey); } else if (e.key === 'Enter') { this.#selectHighlightedOption(); this.#toggleOpen(this.multiple); } else if (e.key === 'Escape') { this.#toggleOpen(false); } } if (e.key === 'Backspace' && this._input?.selectionStart === 0 && this.#anySelected) { this.#preventInputUpdate = true; this.#removeSelectionFromInterface(); } } #handleInput() { if (this.#singleSelect && this.#anySelected && this.#hasEnteredQuery && !this._query) { this.#preventInputUpdate = true; this.#toggleOptionSelected(); this.#clearOptions(); } this.#inputTimeout && window.clearTimeout(this.#inputTimeout); this.#inputTimeout = window.setTimeout(() => { this.#hasEnteredQuery = true; this.#getOptions(); this.dispatchEvent(new CustomEvent('query', { detail: this._query })); }, this.debounce || 0); } #updateInputValue() { if (this._input && !this.#preventInputUpdate) { if (this.#singleSelect && this.#anySelected) { this._input.value = this.#getOptionText(this.#selectedOptions[0]); } else { this._input.value = ''; } } this.#preventInputUpdate = false; } #setFocused(on: boolean) { if (this._control) { if (on) { this._control.classList.add('focused'); this.#controlFocused = true; this.focus(); this._input.focus(); } else { this._control.classList.remove('focused'); this.#controlFocused = false; this.blur(); this._input.blur(); } } } async #toggleOpen(open: boolean) { if (this.#open !== open) { this.#open = open; this.#setFocused(open); if (open) { await this.#getOptions(); } else { this.#hasEnteredQuery = false; this.#clearOptions(); this.#updateInputValue(); } } } #highlightIndex(index: number) { this._highlightedIndex = index; this.requestUpdate(); } #preventIfDisabled(e: Event, f?: () => void, disabled?: boolean) { disabled = disabled === undefined ? this.disabled : disabled; if (disabled) { e.preventDefault(); } else if (f) { f(); } } #defaultQueryHandler(query: string | null | undefined, options: ZuiOptionObject[]) { let results = options || []; if (this.searchable && query) { const regex = new RegExp('(' + (escapeRegexChars(query) || '.') + ')', 'i'); results = results.filter((o) => regex.test(this.#getOptionText(o))); } return results; } #isZuiOption(option: IZuiOptionObject) { return this.#zuiOptions.some((o) => this.#getOptionValue(o) === this.#getOptionValue(option)); } async #dispatchChangeEvent() { await this.updateComplete; let detail: string[] | string | null = this._formValue; if (this._formValue && !this.multiple) { detail = this._formValue[0]; } this.dispatchEvent(new CustomEvent('change', { detail, bubbles: true })); } } window.customElements.define('zui-select-dropdown', ZuiSelectDropdownElement); declare global { interface HTMLElementTagNameMap { 'zui-select-dropdown': ZuiSelectDropdownElement; } } export type QueryResult = IZuiOptionObject | string; export type QueryHandlerCallback = ( query: string | null | undefined, options?: ZuiOptionObject[] ) => QueryResult[] | Promise;