import '../icon/icon'; import '../popup/popup'; import '../tag/tag'; import { animateTo, stopAnimations } from '../../internal/animate'; import { classMap } from 'lit/directives/class-map.js'; import { customElement, property, query, state } from 'lit/decorators.js'; import { defaultValue } from '../../internal/default-value'; import { FormControlController } from '../../internal/form'; import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry'; import { HasSlotController } from '../../internal/slot'; import { html } from 'lit'; import { LocalizeController } from '../../utilities/localize'; import { scrollIntoView } from 'src/internal/scroll'; import { waitForEvent } from '../../internal/event'; import { watch } from '../../internal/watch'; import ShoelaceElement from '../../internal/shoelace-element'; import styles from './select.styles'; import type { CSSResultGroup } from 'lit'; import type { ShoelaceFormControl } from '../../internal/shoelace-element'; import type SlOption from '../option/option'; import type SlPopup from '../popup/popup'; /** * @summary Selects allow you to choose items from a menu of predefined options. * @documentation https://shoelace.style/components/select * @status stable * @since 2.0 * * @dependency sl-icon * @dependency sl-popup * @dependency sl-tag * * @slot - The listbox options. Must be `` elements. You can use `` to group items visually. * @slot label - The input's label. Alternatively, you can use the `label` attribute. * @slot prefix - Used to prepend a presentational icon or similar element to the combobox. * @slot clear-icon - An icon to use in lieu of the default clear icon. * @slot expand-icon - The icon to show when the control is expanded and collapsed. Rotates on open and close. * @slot help-text - Text that describes how to use the input. Alternatively, you can use the `help-text` attribute. * * @event sl-change - Emitted when the control's value changes. * @event sl-clear - Emitted when the control's value is cleared. * @event sl-input - Emitted when the control receives input. * @event sl-focus - Emitted when the control gains focus. * @event sl-blur - Emitted when the control loses focus. * @event sl-show - Emitted when the select's menu opens. * @event sl-after-show - Emitted after the select's menu opens and all animations are complete. * @event sl-hide - Emitted when the select's menu closes. * @event sl-after-hide - Emitted after the select's menu closes and all animations are complete. * * @csspart form-control - The form control that wraps the label, input, and help text. * @csspart form-control-label - The label's wrapper. * @csspart form-control-input - The select's wrapper. * @csspart form-control-help-text - The help text's wrapper. * @csspart combobox - The container the wraps the prefix, combobox, clear icon, and expand button. * @csspart prefix - The container that wraps the prefix slot. * @csspart display-input - The element that displays the selected option's label, an `` element. * @csspart listbox - The listbox container where options are slotted. * @csspart tags - The container that houses option tags when `multiselect` is used. * @csspart tag - The individual tags that represent each multiselect option. * @csspart clear-button - The clear button. * @csspart expand-icon - The container that wraps the expand icon. */ @customElement('sl-select') export default class SlSelect extends ShoelaceElement implements ShoelaceFormControl { static styles: CSSResultGroup = styles; private readonly formControlController = new FormControlController(this); private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label'); private readonly localize = new LocalizeController(this); private typeToSelectString = ''; private typeToSelectTimeout: number; @query('.select') popup: SlPopup; @query('.select__combobox') combobox: HTMLSlotElement; @query('.select__display-input') displayInput: HTMLInputElement; @query('.select__value-input') valueInput: HTMLInputElement; @query('.select__listbox') listbox: HTMLSlotElement; @state() private hasFocus = false; @state() displayLabel = ''; @state() currentOption: SlOption; @state() selectedOptions: SlOption[] = []; /** The name of the select, submitted as a name/value pair with form data. */ @property() name = ''; /** * The current value of the select, submitted as a name/value pair with form data. When `multiple` is enabled, the * value will be a space-delimited list of values based on the options selected. */ @property({ converter: { fromAttribute: (value: string) => value.split(' '), toAttribute: (value: string[]) => value.join(' ') } }) value: string | string[] = ''; /** The default value of the form control. Primarily used for resetting the form control. */ @defaultValue() defaultValue: string | string[] = ''; /** The select's size. */ @property() size: 'small' | 'medium' | 'large' = 'medium'; /** Placeholder text to show as a hint when the select is empty. */ @property() placeholder = ''; /** Allows more than one option to be selected. */ @property({ type: Boolean, reflect: true }) multiple = false; /** * The maximum number of selected options to show when `multiple` is true. After the maximum, "+n" will be shown to * indicate the number of additional items that are selected. Set to 0 to remove the limit. */ @property({ attribute: 'max-options-visible', type: Number }) maxOptionsVisible = 3; /** Disables the select control. */ @property({ type: Boolean, reflect: true }) disabled = false; /** Adds a clear button when the select is not empty. */ @property({ type: Boolean }) clearable = false; /** * Indicates whether or not the select is open. You can toggle this attribute to show and hide the menu, or you can * use the `show()` and `hide()` methods and this attribute will reflect the select's open state. */ @property({ type: Boolean, reflect: true }) open = false; /** * Enable this option to prevent the listbox from being clipped when the component is placed inside a container with * `overflow: auto|scroll`. Hoisting uses a fixed positioning strategy that works in many, but not all, scenarios. */ @property({ type: Boolean }) hoist = false; /** Draws a filled select. */ @property({ type: Boolean, reflect: true }) filled = false; /** Draws a pill-style select with rounded edges. */ @property({ type: Boolean, reflect: true }) pill = false; /** The select's label. If you need to display HTML, use the `label` slot instead. */ @property() label = ''; /** * The preferred placement of the select's menu. Note that the actual placement may vary as needed to keep the listbox * inside of the viewport. */ @property({ reflect: true }) placement: 'top' | 'bottom' = 'bottom'; /** The select's help text. If you need to display HTML, use the `help-text` slot instead. */ @property({ attribute: 'help-text' }) helpText = ''; /** * By default, form controls are associated with the nearest containing `
` element. This attribute allows you * to place the form control outside of a form and associate it with the form that has this `id`. The form must be in * the same document or shadow root for this to work. */ @property({ reflect: true }) form = ''; /** The select's required attribute. */ @property({ type: Boolean, reflect: true }) required = false; connectedCallback() { super.connectedCallback(); this.handleDocumentFocusIn = this.handleDocumentFocusIn.bind(this); this.handleDocumentKeyDown = this.handleDocumentKeyDown.bind(this); this.handleDocumentMouseDown = this.handleDocumentMouseDown.bind(this); // Because this is a form control, it shouldn't be opened initially this.open = false; } private addOpenListeners() { document.addEventListener('focusin', this.handleDocumentFocusIn); document.addEventListener('keydown', this.handleDocumentKeyDown); document.addEventListener('mousedown', this.handleDocumentMouseDown); } private removeOpenListeners() { document.removeEventListener('focusin', this.handleDocumentFocusIn); document.removeEventListener('keydown', this.handleDocumentKeyDown); document.removeEventListener('mousedown', this.handleDocumentMouseDown); } private handleFocus() { this.hasFocus = true; this.displayInput.setSelectionRange(0, 0); this.emit('sl-focus'); } private handleBlur() { this.hasFocus = false; this.emit('sl-blur'); } private handleDocumentFocusIn(event: KeyboardEvent) { // Close when focusing out of the select const path = event.composedPath(); if (this && !path.includes(this)) { this.hide(); } } private handleDocumentKeyDown(event: KeyboardEvent) { const target = event.target as HTMLElement; const isClearButton = target.closest('.select__clear') !== null; const isIconButton = target.closest('sl-icon-button') !== null; // Ignore presses when the target is an icon button (e.g. the remove button in ) if (isClearButton || isIconButton) { return; } // Close when pressing escape if (event.key === 'Escape' && this.open) { event.preventDefault(); event.stopPropagation(); this.hide(); this.displayInput.focus({ preventScroll: true }); } // Handle enter and space. When pressing space, we allow for type to select behaviors so if there's anything in the // buffer we _don't_ close it. if (event.key === 'Enter' || (event.key === ' ' && this.typeToSelectString === '')) { event.preventDefault(); event.stopImmediatePropagation(); // If it's not open, open it if (!this.open) { this.show(); return; } // If it is open, update the value based on the current selection and close it if (this.currentOption && !this.currentOption.disabled) { if (this.multiple) { this.toggleOptionSelection(this.currentOption); } else { this.setSelectedOptions(this.currentOption); } this.emit('sl-input'); this.emit('sl-change'); if (!this.multiple) { this.hide(); this.displayInput.focus({ preventScroll: true }); } } return; } // Navigate options if (['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) { const allOptions = this.getAllOptions(); const currentIndex = allOptions.indexOf(this.currentOption); let newIndex = Math.max(0, currentIndex); // Prevent scrolling event.preventDefault(); // Open it if (!this.open) { this.show(); // If an option is already selected, stop here because we want that one to remain highlighted when the listbox // opens for the first time if (this.currentOption) { return; } } if (event.key === 'ArrowDown') { newIndex = currentIndex + 1; if (newIndex > allOptions.length - 1) newIndex = 0; } else if (event.key === 'ArrowUp') { newIndex = currentIndex - 1; if (newIndex < 0) newIndex = allOptions.length - 1; } else if (event.key === 'Home') { newIndex = 0; } else if (event.key === 'End') { newIndex = allOptions.length - 1; } this.setCurrentOption(allOptions[newIndex]); } // All other "printable" keys trigger type to select if (event.key.length === 1 || event.key === 'Backspace') { const allOptions = this.getAllOptions(); // Don't block important key combos like CMD+R if (event.metaKey || event.ctrlKey || event.altKey) { return; } // Open, unless the key that triggered is backspace if (!this.open) { if (event.key === 'Backspace') { return; } this.show(); } event.stopPropagation(); event.preventDefault(); clearTimeout(this.typeToSelectTimeout); this.typeToSelectTimeout = window.setTimeout(() => (this.typeToSelectString = ''), 1000); if (event.key === 'Backspace') { this.typeToSelectString = this.typeToSelectString.slice(0, -1); } else { this.typeToSelectString += event.key.toLowerCase(); } for (const option of allOptions) { const label = option.getTextLabel().toLowerCase(); if (label.startsWith(this.typeToSelectString)) { this.setCurrentOption(option); break; } } } } private handleDocumentMouseDown(event: MouseEvent) { // Close when clicking outside of the select const path = event.composedPath(); if (this && !path.includes(this)) { this.hide(); } } private handleLabelClick() { this.displayInput.focus(); } private handleComboboxMouseDown(event: MouseEvent) { const path = event.composedPath(); const isIconButton = path.some(el => el instanceof Element && el.tagName.toLowerCase() === 'sl-icon-button'); // Ignore disabled controls and clicks on tags (remove buttons) if (this.disabled || isIconButton) { return; } event.preventDefault(); this.displayInput.focus({ preventScroll: true }); this.open = !this.open; } private handleComboboxKeyDown(event: KeyboardEvent) { event.stopPropagation(); this.handleDocumentKeyDown(event); } private handleClearClick(event: MouseEvent) { event.stopPropagation(); if (this.value !== '') { this.setSelectedOptions([]); this.displayInput.focus({ preventScroll: true }); this.emit('sl-clear'); this.emit('sl-input'); this.emit('sl-change'); } } private handleClearMouseDown(event: MouseEvent) { // Don't lose focus or propagate events when clicking the clear button event.stopPropagation(); event.preventDefault(); } private handleOptionClick(event: MouseEvent) { const target = event.target as HTMLElement; const option = target.closest('sl-option'); const oldValue = this.value; if (option && !option.disabled) { if (this.multiple) { this.toggleOptionSelection(option); } else { this.setSelectedOptions(option); } // Set focus after updating so the value is announced by screen readers this.updateComplete.then(() => this.displayInput.focus({ preventScroll: true })); if (this.value !== oldValue) { this.emit('sl-input'); this.emit('sl-change'); } if (!this.multiple) { this.hide(); this.displayInput.focus({ preventScroll: true }); } } } private handleDefaultSlotChange() { const allOptions = this.getAllOptions(); const value = Array.isArray(this.value) ? this.value : [this.value]; const values: string[] = []; // Check for duplicate values in menu items allOptions.forEach(option => { if (values.includes(option.value)) { console.error( `An option with a duplicate value of "${option.value}" has been found in . All options must have unique values.`, option ); } values.push(option.value); }); // Select only the options that match the new value this.setSelectedOptions(allOptions.filter(el => value.includes(el.value))); } private handleTagRemove(event: CustomEvent, option: SlOption) { event.stopPropagation(); if (!this.disabled) { this.toggleOptionSelection(option, false); this.emit('sl-input'); this.emit('sl-change'); } } // Gets an array of all elements private getAllOptions() { return [...this.querySelectorAll('sl-option')]; } // Gets the first element private getFirstOption() { return this.querySelector('sl-option'); } // Sets the current option, which is the option the user is currently interacting with (e.g. via keyboard). Only one // option may be "current" at a time. private setCurrentOption(option: SlOption | null) { const allOptions = this.getAllOptions(); // Clear selection allOptions.forEach(el => { el.current = false; el.tabIndex = -1; }); // Select the target option if (option) { this.currentOption = option; option.current = true; option.tabIndex = 0; option.focus(); scrollIntoView(option, this.listbox); } } // Sets the selected option(s) private setSelectedOptions(option: SlOption | SlOption[]) { const allOptions = this.getAllOptions(); const newSelectedOptions = Array.isArray(option) ? option : [option]; // Clear existing selection allOptions.forEach(el => (el.selected = false)); // Set the new selection if (newSelectedOptions.length) { newSelectedOptions.forEach(el => (el.selected = true)); // Scroll the first selected option into view scrollIntoView(newSelectedOptions[0]!, this.listbox); } // Update selection, value, and display label this.selectionChanged(); } // Toggles an option's selected state private toggleOptionSelection(option: SlOption, force?: boolean) { if (force === true || force === false) { option.selected = force; } else { option.selected = !option.selected; } this.selectionChanged(); } // This method must be called whenever the selection changes. It will update the selected options cache, the current // value, and the display value private selectionChanged() { // Update selected options cache this.selectedOptions = this.getAllOptions().filter(el => el.selected); // Update the value and display label if (this.multiple) { this.value = this.selectedOptions.map(el => el.value); if (this.placeholder && this.value.length === 0) { // When no items are selected, keep the value empty so the placeholder shows this.displayLabel = ''; } else { this.displayLabel = this.localize.term('numOptionsSelected', this.selectedOptions.length); } } else { this.value = this.selectedOptions[0]?.value ?? ''; this.displayLabel = this.selectedOptions[0]?.getTextLabel() ?? ''; } // Update validity this.updateComplete.then(() => { this.formControlController.updateValidity(); }); } @watch('disabled', { waitUntilFirstUpdate: true }) handleDisabledChange() { // Close the listbox when the control is disabled if (this.disabled) { this.open = false; this.handleOpenChange(); } } @watch('value', { waitUntilFirstUpdate: true }) handleValueChange() { const allOptions = this.getAllOptions(); const value = Array.isArray(this.value) ? this.value : [this.value]; // Select only the options that match the new value this.setSelectedOptions(allOptions.filter(el => value.includes(el.value))); } @watch('open', { waitUntilFirstUpdate: true }) async handleOpenChange() { if (this.open && !this.disabled) { // Reset the current option this.setCurrentOption(this.selectedOptions[0] || this.getFirstOption()); // Show this.emit('sl-show'); this.addOpenListeners(); await stopAnimations(this); this.listbox.hidden = false; this.popup.active = true; // Select the appropriate option based on value after the listbox opens requestAnimationFrame(() => { this.setCurrentOption(this.currentOption); }); const { keyframes, options } = getAnimation(this, 'select.show', { dir: this.localize.dir() }); await animateTo(this.popup.popup, keyframes, options); // Make sure the current option is scrolled into view (required for Safari) if (this.currentOption) { scrollIntoView(this.currentOption, this.listbox, 'vertical', 'auto'); } this.emit('sl-after-show'); } else { // Hide this.emit('sl-hide'); this.removeOpenListeners(); await stopAnimations(this); const { keyframes, options } = getAnimation(this, 'select.hide', { dir: this.localize.dir() }); await animateTo(this.popup.popup, keyframes, options); this.listbox.hidden = true; this.popup.active = false; this.emit('sl-after-hide'); } } /** Shows the listbox. */ async show() { if (this.open || this.disabled) { this.open = false; return undefined; } this.open = true; return waitForEvent(this, 'sl-after-show'); } /** Hides the listbox. */ async hide() { if (!this.open || this.disabled) { this.open = false; return undefined; } this.open = false; return waitForEvent(this, 'sl-after-hide'); } /** Checks for validity but does not show the browser's validation message. */ checkValidity() { return this.valueInput.checkValidity(); } /** Checks for validity and shows the browser's validation message if the control is invalid. */ reportValidity() { return this.valueInput.reportValidity(); } /** Sets a custom validation message. Pass an empty string to restore validity. */ setCustomValidity(message: string) { this.valueInput.setCustomValidity(message); this.formControlController.updateValidity(); } /** Sets focus on the control. */ focus(options?: FocusOptions) { this.displayInput.focus(options); } /** Removes focus from the control. */ blur() { this.displayInput.blur(); } render() { const hasLabelSlot = this.hasSlotController.test('label'); const hasHelpTextSlot = this.hasSlotController.test('help-text'); const hasLabel = this.label ? true : !!hasLabelSlot; const hasHelpText = this.helpText ? true : !!hasHelpTextSlot; const hasClearIcon = this.clearable && !this.disabled && this.value.length > 0; const isPlaceholderVisible = this.placeholder && this.value.length === 0; return html`
${this.multiple ? html`
${this.selectedOptions.map((option, index) => { if (index < this.maxOptionsVisible || this.maxOptionsVisible <= 0) { return html` this.handleTagRemove(event, option)} > ${option.getTextLabel()} `; } else if (index === this.maxOptionsVisible) { return html` +${this.selectedOptions.length - index} `; } else { return null; } })}
` : ''} this.focus()} /> ${hasClearIcon ? html` ` : ''}
${this.helpText}
`; } } setDefaultAnimation('select.show', { keyframes: [ { opacity: 0, scale: 0.9 }, { opacity: 1, scale: 1 } ], options: { duration: 100, easing: 'ease' } }); setDefaultAnimation('select.hide', { keyframes: [ { opacity: 1, scale: 1 }, { opacity: 0, scale: 0.9 } ], options: { duration: 100, easing: 'ease' } }); declare global { interface HTMLElementTagNameMap { 'sl-select': SlSelect; } }