import '../popup/popup'; import { animateTo, stopAnimations } from '../../internal/animate'; import { classMap } from 'lit/directives/class-map.js'; import { customElement, property, query } from 'lit/decorators.js'; import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry'; import { getTabbableBoundary } from '../../internal/tabbable'; import { html } from 'lit'; import { LocalizeController } from '../../utilities/localize'; import { scrollIntoView } from '../../internal/scroll'; import { waitForEvent } from '../../internal/event'; import { watch } from '../../internal/watch'; import ShoelaceElement from '../../internal/shoelace-element'; import styles from './dropdown.styles'; import type { CSSResultGroup } from 'lit'; import type SlButton from '../button/button'; import type SlIconButton from '../icon-button/icon-button'; import type SlMenu from '../menu/menu'; import type SlMenuItem from '../menu-item/menu-item'; import type SlPopup from '../popup/popup'; /** * @summary Dropdowns expose additional content that "drops down" in a panel. * @documentation https://shoelace.style/components/dropdown * @status stable * @since 2.0 * * @dependency sl-popup * * @slot - The dropdown's main 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 ShoelaceElement { static styles: CSSResultGroup = styles; @query('.dropdown') popup: SlPopup; @query('.dropdown__trigger') trigger: HTMLSlotElement; @query('.dropdown__panel') panel: HTMLSlotElement; private readonly localize = new LocalizeController(this); /** * Indicates whether or not the dropdown is open. You can toggle this attribute to show and hide the dropdown, or you * can use the `show()` and `hide()` methods and this attribute will reflect the dropdown's open state. */ @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({ reflect: true }) 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, reflect: true }) disabled = false; /** * By default, the dropdown is closed when an item is selected. This attribute will keep it open instead. Useful for * dropdowns that allow for multiple interactions. */ @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). Useful for composing other * components that use a dropdown internally. */ @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`. Hoisting uses a fixed positioning strategy that works in many, but not all, scenarios. */ @property({ type: Boolean }) hoist = false; connectedCallback() { super.connectedCallback(); this.handleMenuItemActivate = this.handleMenuItemActivate.bind(this); this.handlePanelSelect = this.handlePanelSelect.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this); this.handleDocumentKeyDown = this.handleDocumentKeyDown.bind(this); this.handleDocumentMouseDown = this.handleDocumentMouseDown.bind(this); if (!this.containingElement) { this.containingElement = this; } } firstUpdated() { this.panel.hidden = !this.open; // If the dropdown is visible on init, update its position if (this.open) { this.addOpenListeners(); this.popup.active = true; } } disconnectedCallback() { super.disconnectedCallback(); this.removeOpenListeners(); this.hide(); } focusOnTrigger() { const trigger = this.trigger.assignedElements({ flatten: true })[0] as HTMLElement | undefined; if (typeof trigger?.focus === 'function') { trigger.focus(); } } getMenu() { return this.panel.assignedElements({ flatten: true }).find(el => el.tagName.toLowerCase() === 'sl-menu') as | SlMenu | undefined; } handleKeyDown(event: KeyboardEvent) { // Close when escape is pressed inside an open dropdown. We need to listen on the panel itself and stop propagation // in case any ancestors are also listening for this key. if (this.open && event.key === 'Escape') { event.stopPropagation(); this.hide(); this.focusOnTrigger(); } } handleDocumentKeyDown(event: KeyboardEvent) { // 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(); 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 ( !this.containingElement || activeElement?.closest(this.containingElement.tagName.toLowerCase()) !== this.containingElement ) { this.hide(); } }); } } handleDocumentMouseDown(event: MouseEvent) { // Close when clicking outside of the containing element const path = event.composedPath(); if (this.containingElement && !path.includes(this.containingElement)) { 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') { this.hide(); this.focusOnTrigger(); } } handleTriggerClick() { if (this.open) { this.hide(); } else { this.show(); } } handleTriggerKeyDown(event: KeyboardEvent) { // Close when escape or tab is pressed if (event.key === 'Escape' && this.open) { event.stopPropagation(); this.focusOnTrigger(); 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; } const menu = this.getMenu(); if (menu) { const menuItems = menu.defaultSlot.assignedElements({ flatten: true }) as SlMenuItem[]; const firstMenuItem = menuItems[0]; const lastMenuItem = menuItems[menuItems.length - 1]; // 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', 'Home', 'End'].includes(event.key)) { event.preventDefault(); // Show the menu if it's not already open if (!this.open) { this.show(); } if (menuItems.length > 0) { // Focus on the first/last menu item after showing requestAnimationFrame(() => { if (event.key === 'ArrowDown' || event.key === 'Home') { menu.setCurrentItem(firstMenuItem); firstMenuItem.focus(); } if (event.key === 'ArrowUp' || event.key === 'End') { menu.setCurrentItem(lastMenuItem); lastMenuItem.focus(); } }); } } } } 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