import type { Instance as PopperInstance } from '@popperjs/core/dist/esm'; import { createPopper } from '@popperjs/core/dist/esm'; import { LitElement, html } from 'lit'; import { customElement, property, query } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import styles from './dropdown.styles'; import type SlMenuItem from '~/components/menu-item/menu-item'; import type SlMenu from '~/components/menu/menu'; import { animateTo, stopAnimations } from '~/internal/animate'; import { emit, waitForEvent } from '~/internal/event'; import { scrollIntoView } from '~/internal/scroll'; import { getTabbableBoundary } from '~/internal/tabbable'; import { watch } from '~/internal/watch'; import { setDefaultAnimation, getAnimation } from '~/utilities/animation-registry'; /** * @since 2.0 * @status stable * * @slot - The dropdown's content. * @slot trigger - The dropdown's trigger, usually a `` element. * * @event sl-show - Emitted when the dropdown opens. * @event sl-after-show - Emitted after the dropdown opens and all animations are complete. * @event sl-hide - Emitted when the dropdown closes. * @event sl-after-hide - Emitted after the dropdown closes and all animations are complete. * * @csspart base - The component's base wrapper. * @csspart trigger - The container that wraps the trigger. * @csspart panel - The panel that gets shown when the dropdown is open. * * @animation dropdown.show - The animation to use when showing the dropdown. * @animation dropdown.hide - The animation to use when hiding the dropdown. */ @customElement('sl-dropdown') export default class SlDropdown extends LitElement { static styles = styles; @query('.dropdown__trigger') trigger: HTMLElement; @query('.dropdown__panel') panel: HTMLElement; @query('.dropdown__positioner') positioner: HTMLElement; private popover?: PopperInstance; /** Indicates whether or not the dropdown is open. You can use this in lieu of the show/hide methods. */ @property({ type: Boolean, reflect: true }) open = false; /** * The preferred placement of the dropdown panel. Note that the actual placement may vary as needed to keep the panel * inside of the viewport. */ @property() placement: | 'top' | 'top-start' | 'top-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'right' | 'right-start' | 'right-end' | 'left' | 'left-start' | 'left-end' = 'bottom-start'; /** Disables the dropdown so the panel will not open. */ @property({ type: Boolean }) disabled = false; /** * By default, the dropdown is closed when an item is selected. This attribute will keep it open instead. Useful for * controls that allow multiple selections. */ @property({ attribute: 'stay-open-on-select', type: Boolean, reflect: true }) stayOpenOnSelect = false; /** The dropdown will close when the user interacts outside of this element (e.g. clicking). */ @property({ attribute: false }) containingElement?: HTMLElement; /** The distance in pixels from which to offset the panel away from its trigger. */ @property({ type: Number }) distance = 0; /** The distance in pixels from which to offset the panel along its trigger. */ @property({ type: Number }) skidding = 0; /** * 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; connectedCallback() { super.connectedCallback(); this.handleMenuItemActivate = this.handleMenuItemActivate.bind(this); this.handlePanelSelect = this.handlePanelSelect.bind(this); this.handleDocumentKeyDown = this.handleDocumentKeyDown.bind(this); this.handleDocumentMouseDown = this.handleDocumentMouseDown.bind(this); if (typeof this.containingElement === 'undefined') { this.containingElement = this; } // Create the popover after render void this.updateComplete.then(() => { this.popover = createPopper(this.trigger, this.positioner, { placement: this.placement, strategy: this.hoist ? 'fixed' : 'absolute', modifiers: [ { name: 'flip', options: { boundary: 'viewport' } }, { name: 'offset', options: { offset: [this.skidding, this.distance] } } ] }); }); } firstUpdated() { this.panel.hidden = !this.open; } disconnectedCallback() { super.disconnectedCallback(); void void this.hide(); this.popover?.destroy(); } focusOnTrigger() { const slot = this.trigger.querySelector('slot')!; const trigger = slot.assignedElements({ flatten: true })[0] as HTMLElement | undefined; if (typeof trigger?.focus === 'function') { trigger.focus(); } } getMenu() { const slot = this.panel.querySelector('slot')!; return slot.assignedElements({ flatten: true }).find(el => el.tagName.toLowerCase() === 'sl-menu') as | SlMenu | undefined; } handleDocumentKeyDown(event: KeyboardEvent) { // Close when escape is pressed if (event.key === 'Escape') { void this.hide(); this.focusOnTrigger(); return; } // Handle tabbing if (event.key === 'Tab') { // Tabbing within an open menu should close the dropdown and refocus the trigger if (this.open && document.activeElement?.tagName.toLowerCase() === 'sl-menu-item') { event.preventDefault(); void this.hide(); this.focusOnTrigger(); return; } // Tabbing outside of the containing element closes the panel // // If the dropdown is used within a shadow DOM, we need to obtain the activeElement within that shadowRoot, // otherwise `document.activeElement` will only return the name of the parent shadow DOM element. setTimeout(() => { const activeElement = this.containingElement?.getRootNode() instanceof ShadowRoot ? document.activeElement?.shadowRoot?.activeElement : document.activeElement; if ( typeof this.containingElement === 'undefined' || activeElement?.closest(this.containingElement.tagName.toLowerCase()) !== this.containingElement ) { void this.hide(); } }); } } handleDocumentMouseDown(event: MouseEvent) { // Close when clicking outside of the containing element const path = event.composedPath(); if (typeof this.containingElement !== 'undefined' && !path.includes(this.containingElement)) { void this.hide(); } } handleMenuItemActivate(event: CustomEvent) { const item = event.target as SlMenuItem; scrollIntoView(item, this.panel); } handlePanelSelect(event: CustomEvent) { const target = event.target as HTMLElement; // Hide the dropdown when a menu item is selected if (!this.stayOpenOnSelect && target.tagName.toLowerCase() === 'sl-menu') { void this.hide(); this.focusOnTrigger(); } } @watch('distance') @watch('hoist') @watch('placement') @watch('skidding') handlePopoverOptionsChange() { void this.popover?.setOptions({ placement: this.placement, strategy: this.hoist ? 'fixed' : 'absolute', modifiers: [ { name: 'flip', options: { boundary: 'viewport' } }, { name: 'offset', options: { offset: [this.skidding, this.distance] } } ] }); } handleTriggerClick() { if (this.open) { void this.hide(); } else { void this.show(); } } handleTriggerKeyDown(event: KeyboardEvent) { const menu = this.getMenu(); const menuItems = typeof menu !== 'undefined' ? ([...menu.querySelectorAll('sl-menu-item')] as SlMenuItem[]) : []; const firstMenuItem = menuItems[0]; const lastMenuItem = menuItems[menuItems.length - 1]; // Close when escape or tab is pressed if (event.key === 'Escape') { this.focusOnTrigger(); void this.hide(); return; } // When spacebar/enter is pressed, show the panel but don't focus on the menu. This let's the user press the same // key again to hide the menu in case they don't want to make a selection. if ([' ', 'Enter'].includes(event.key)) { event.preventDefault(); this.handleTriggerClick(); return; } // When up/down is pressed, we make the assumption that the user is familiar with the menu and plans to make a // selection. Rather than toggle the panel, we focus on the menu (if one exists) and activate the first item for // faster navigation. if (['ArrowDown', 'ArrowUp'].includes(event.key)) { event.preventDefault(); // Show the menu if it's not already open if (!this.open) { void this.show(); } // Focus on a menu item if (event.key === 'ArrowDown' && typeof firstMenuItem !== 'undefined') { menu!.setCurrentItem(firstMenuItem); firstMenuItem.focus(); return; } if (event.key === 'ArrowUp' && typeof lastMenuItem !== 'undefined') { menu!.setCurrentItem(lastMenuItem); lastMenuItem.focus(); return; } } // Other keys bring focus to the menu and initiate type-to-select behavior const ignoredKeys = ['Tab', 'Shift', 'Meta', 'Ctrl', 'Alt']; if (this.open && !ignoredKeys.includes(event.key)) { menu?.typeToSelect(event.key); } } handleTriggerKeyUp(event: KeyboardEvent) { // Prevent space from triggering a click event in Firefox if (event.key === ' ') { event.preventDefault(); } } handleTriggerSlotChange() { this.updateAccessibleTrigger(); } // // Slotted triggers can be arbitrary content, but we need to link them to the dropdown panel with `aria-haspopup` and // `aria-expanded`. These must be applied to the "accessible trigger" (the tabbable portion of the trigger element // that gets slotted in) so screen readers will understand them. The accessible trigger could be the slotted element, // a child of the slotted element, or an element in the slotted element's shadow root. // // For example, the accessible trigger of an is a