import { html, LitElement } from 'lit'; import { customElement, property, query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import '../../components/dropdown/dropdown'; import '../../components/icon-button/icon-button'; import '../../components/icon/icon'; import '../../components/menu/menu'; import '../../components/tag/tag'; import { emit } from '../../internal/event'; import { FormSubmitController } from '../../internal/form'; import { getTextContent, HasSlotController } from '../../internal/slot'; import { watch } from '../../internal/watch'; import styles from './select.styles'; import type SlDropdown from '../../components/dropdown/dropdown'; import type SlIconButton from '../../components/icon-button/icon-button'; import type SlMenuItem from '../../components/menu-item/menu-item'; import type { MenuSelectEventDetail } from '../../components/menu/menu'; import type SlMenu from '../../components/menu/menu'; import type { TemplateResult } from 'lit'; /** * @since 2.0 * @status stable * * @dependency sl-dropdown * @dependency sl-icon * @dependency sl-icon-button * @dependency sl-menu * @dependency sl-tag * * @slot - The select's options in the form of menu items. * @slot prefix - Used to prepend an icon or similar element to the select. * @slot suffix - Used to append an icon or similar element to the select. * @slot clear-icon - An icon to use in lieu of the default clear icon. * @slot label - The select's label. Alternatively, you can use the label prop. * @slot help-text - Help text that describes how to use the select. * * @event sl-clear - Emitted when the clear button is activated. * @event sl-change - Emitted when the control's value changes. * @event sl-focus - Emitted when the control gains focus. * @event sl-blur - Emitted when the control loses focus. * * @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 base - The component's internal wrapper. * @csspart clear-button - The clear button. * @csspart control - The container that holds the prefix, label, and suffix. * @csspart display-label - The label that displays the current selection. Not available when used with `multiple`. * @csspart icon - The select's icon. * @csspart prefix - The select's prefix. * @csspart suffix - The select's suffix. * @csspart menu - The select menu, an `` element. * @csspart tag - The multi select option, an `` element. * @csspart tag__base - The tag's `base` part. * @csspart tag__content - The tag's `content` part. * @csspart tag__remove-button - The tag's `remove-button` part. * @csspart tags - The container in which multi select options are rendered. */ @customElement('sl-select') export default class SlSelect extends LitElement { static styles = styles; @query('.select') dropdown: SlDropdown; @query('.select__control') control: SlDropdown; @query('.select__hidden-select') input: HTMLInputElement; @query('.select__menu') menu: SlMenu; // @ts-expect-error -- Controller is currently unused private readonly formSubmitController = new FormSubmitController(this); private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label'); private resizeObserver: ResizeObserver; @state() private hasFocus = false; @state() private isOpen = false; @state() private displayLabel = ''; @state() private displayTags: TemplateResult[] = []; /** Enables multi select. With this enabled, value will be an array. */ @property({ type: Boolean, reflect: true }) multiple = false; /** * The maximum number of tags 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 -1 to remove the limit. */ @property({ attribute: 'max-tags-visible', type: Number }) maxTagsVisible = 3; /** Disables the select control. */ @property({ type: Boolean, reflect: true }) disabled = false; /** The select's name. */ @property() name = ''; /** The select's placeholder text. */ @property() placeholder = ''; /** The select's size. */ @property() size: 'small' | 'medium' | 'large' = 'medium'; /** * Enable this option to prevent the panel from being clipped when the component is placed inside a container with * `overflow: auto|scroll`. */ @property({ type: Boolean }) hoist = false; /** The value of the control. This will be a string or an array depending on `multiple`. */ @property() value: string | string[] = ''; /** 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. Alternatively, you can use the label slot. */ @property() label = ''; /** * The preferred placement of the select's menu. Note that the actual placement may vary as needed to keep the panel * inside of the viewport. */ @property() placement: 'top' | 'bottom' = 'bottom'; /** The select's help text. Alternatively, you can use the help-text slot. */ @property({ attribute: 'help-text' }) helpText = ''; /** The select's required attribute. */ @property({ type: Boolean, reflect: true }) required = false; /** Adds a clear button when the select is populated. */ @property({ type: Boolean }) clearable = false; /** This will be true when the control is in an invalid state. Validity is determined by the `required` prop. */ @property({ type: Boolean, reflect: true }) invalid = false; connectedCallback() { super.connectedCallback(); this.handleMenuSlotChange = this.handleMenuSlotChange.bind(this); this.resizeObserver = new ResizeObserver(() => this.resizeMenu()); this.updateComplete.then(() => { this.resizeObserver.observe(this); this.syncItemsFromValue(); }); } firstUpdated() { this.invalid = !this.input.checkValidity(); } disconnectedCallback() { super.disconnectedCallback(); this.resizeObserver.unobserve(this); } /** Checks for validity and shows the browser's validation message if the control is invalid. */ reportValidity() { return this.input.reportValidity(); } /** Sets a custom validation message. If `message` is not empty, the field will be considered invalid. */ setCustomValidity(message: string) { this.input.setCustomValidity(message); this.invalid = !this.input.checkValidity(); } getItemLabel(item: SlMenuItem) { const slot = item.shadowRoot!.querySelector('slot:not([name])'); return getTextContent(slot); } getItems() { return [...this.querySelectorAll('sl-menu-item')]; } getValueAsArray() { // Single selects use '' as an empty selection value, so convert this to [] for an empty multi select if (this.multiple && this.value === '') { return []; } return Array.isArray(this.value) ? this.value : [this.value]; } /** Sets focus on the control. */ focus(options?: FocusOptions) { this.control.focus(options); } /** Removes focus from the control. */ blur() { this.control.blur(); } handleBlur() { // Don't blur if the control is open. We'll move focus back once it closes. if (!this.isOpen) { this.hasFocus = false; emit(this, 'sl-blur'); } } handleClearClick(event: MouseEvent) { event.stopPropagation(); this.value = this.multiple ? [] : ''; emit(this, 'sl-clear'); this.syncItemsFromValue(); } @watch('disabled', { waitUntilFirstUpdate: true }) handleDisabledChange() { if (this.disabled && this.isOpen) { this.dropdown.hide(); } // Disabled form controls are always valid, so we need to recheck validity when the state changes this.input.disabled = this.disabled; this.invalid = !this.input.checkValidity(); } handleFocus() { if (!this.hasFocus) { this.hasFocus = true; emit(this, 'sl-focus'); } } handleKeyDown(event: KeyboardEvent) { const target = event.target as HTMLElement; const items = this.getItems(); const firstItem = items[0]; const lastItem = items[items.length - 1]; // Ignore key presses on tags if (target.tagName.toLowerCase() === 'sl-tag') { return; } // Tabbing out of the control closes it if (event.key === 'Tab') { if (this.isOpen) { this.dropdown.hide(); } return; } // Up/down opens the menu if (['ArrowDown', 'ArrowUp'].includes(event.key)) { event.preventDefault(); // Show the menu if it's not already open if (!this.isOpen) { this.dropdown.show(); } // Focus on a menu item if (event.key === 'ArrowDown') { this.menu.setCurrentItem(firstItem); firstItem.focus(); return; } if (event.key === 'ArrowUp') { this.menu.setCurrentItem(lastItem); lastItem.focus(); return; } } // don't open the menu when a CTRL/Command key is pressed if (event.ctrlKey || event.metaKey) { return; } // All other "printable" keys open the menu and initiate type to select if (!this.isOpen && event.key.length === 1) { event.stopPropagation(); event.preventDefault(); this.dropdown.show(); this.menu.typeToSelect(event); } } handleLabelClick() { this.focus(); } handleMenuSelect(event: CustomEvent) { const item = event.detail.item; if (this.multiple) { this.value = this.value.includes(item.value) ? (this.value as []).filter(v => v !== item.value) : [...this.value, item.value]; } else { this.value = item.value; } this.syncItemsFromValue(); } handleMenuShow() { this.resizeMenu(); this.isOpen = true; } handleMenuHide() { this.isOpen = false; // Restore focus on the box after the menu is hidden this.control.focus(); } @watch('multiple') handleMultipleChange() { // Cast to array | string based on `this.multiple` const value = this.getValueAsArray(); this.value = this.multiple ? value : value[0] ?? ''; this.syncItemsFromValue(); } async handleMenuSlotChange() { // Wait for items to render before gathering labels otherwise the slot won't exist const items = this.getItems(); // Check for duplicate values in menu items const values: string[] = []; items.forEach(item => { if (values.includes(item.value)) { console.error(`Duplicate value found in menu item: '${item.value}'`, item); } values.push(item.value); }); await Promise.all(items.map(item => item.render)).then(() => this.syncItemsFromValue()); } handleTagInteraction(event: KeyboardEvent | MouseEvent) { // Don't toggle the menu when a tag's clear button is activated const path = event.composedPath(); const clearButton = path.find((el: SlIconButton) => { if (el instanceof HTMLElement) { const element = el as HTMLElement; return element.classList.contains('tag__remove'); } return false; }); if (clearButton) { event.stopPropagation(); } } @watch('value', { waitUntilFirstUpdate: true }) async handleValueChange() { this.syncItemsFromValue(); await this.updateComplete; this.invalid = !this.input.checkValidity(); emit(this, 'sl-change'); } resizeMenu() { this.menu.style.width = `${this.control.clientWidth}px`; this.dropdown.reposition(); } syncItemsFromValue() { const items = this.getItems(); const value = this.getValueAsArray(); // Sync checked states items.map(item => (item.checked = value.includes(item.value))); // Sync display label and tags if (this.multiple) { const checkedItems = items.filter(item => value.includes(item.value)); this.displayLabel = checkedItems.length > 0 ? this.getItemLabel(checkedItems[0]) : ''; this.displayTags = checkedItems.map((item: SlMenuItem) => { return html` { event.stopPropagation(); if (!this.disabled) { item.checked = false; this.syncValueFromItems(); } }} > ${this.getItemLabel(item)} `; }); if (this.maxTagsVisible > 0 && this.displayTags.length > this.maxTagsVisible) { const total = this.displayTags.length; this.displayLabel = ''; this.displayTags = this.displayTags.slice(0, this.maxTagsVisible); this.displayTags.push(html` +${total - this.maxTagsVisible} `); } } else { const checkedItem = items.find(item => item.value === value[0]); this.displayLabel = checkedItem ? this.getItemLabel(checkedItem) : ''; this.displayTags = []; } } syncValueFromItems() { const items = this.getItems(); const checkedItems = items.filter(item => item.checked); const checkedValues = checkedItems.map(item => item.value); if (this.multiple) { this.value = (this.value as []).filter(val => checkedValues.includes(val)); } else { this.value = checkedValues.length > 0 ? checkedValues[0] : ''; } } render() { const hasLabelSlot = this.hasSlotController.test('label'); const hasHelpTextSlot = this.hasSlotController.test('help-text'); const hasSelection = this.multiple ? this.value.length > 0 : this.value !== ''; const hasLabel = this.label ? true : !!hasLabelSlot; const hasHelpText = this.helpText ? true : !!hasHelpTextSlot; return html`
0, 'select--placeholder-visible': this.displayLabel === '', 'select--small': this.size === 'small', 'select--medium': this.size === 'medium', 'select--large': this.size === 'large', 'select--pill': this.pill, 'select--invalid': this.invalid })} @sl-show=${this.handleMenuShow} @sl-hide=${this.handleMenuHide} >
${this.displayTags.length > 0 ? html` ${this.displayTags} ` : this.displayLabel.length > 0 ? this.displayLabel : this.placeholder}
${this.clearable && hasSelection ? html` ` : ''} this.control.focus()} />
${this.helpText}
`; } } declare global { interface HTMLElementTagNameMap { 'sl-select': SlSelect; } }