import { LitElement, html, unsafeCSS } from 'lit';
import { customElement, property, query } from 'lit/decorators';
import { classMap } from 'lit-html/directives/class-map';
import { event, EventEmitter, watch } from '../../internal/decorators';
import styles from 'sass:./dropdown.scss';
import { SlMenu, SlMenuItem } from '../../shoelace';
import { scrollIntoView } from '../../internal/scroll';
import { getNearestTabbableElement } from '../../internal/tabbable';
import Popover from '../../internal/popover';
let id = 0;
/**
* @since 2.0
* @status stable
*
* @slot trigger - The dropdown's trigger, usually a `` element.
* @slot - The dropdown's content.
*
* @part base - The component's base wrapper.
* @part trigger - The container that wraps the trigger.
* @part panel - The panel that gets shown when the dropdown is open.
*/
@customElement('sl-dropdown')
export default class SlDropdown extends LitElement {
static styles = unsafeCSS(styles);
@query('.dropdown__trigger') trigger: HTMLElement;
@query('.dropdown__panel') panel: HTMLElement;
@query('.dropdown__positioner') positioner: HTMLElement;
private componentId = `dropdown-${++id}`;
private isVisible = false;
private popover: Popover;
/** 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';
/** Determines whether the dropdown should hide when a menu item is selected. */
@property({ attribute: 'close-on-select', type: Boolean, reflect: true }) closeOnSelect = true;
/** 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 = 2;
/** 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;
/** Emitted when the dropdown opens. Calling `event.preventDefault()` will prevent it from being opened. */
@event('sl-show') slShow: EventEmitter;
/** Emitted after the dropdown opens and all transitions are complete. */
@event('sl-after-show') slAfterShow: EventEmitter;
/** Emitted when the dropdown closes. Calling `event.preventDefault()` will prevent it from being closed. */
@event('sl-hide') slHide: EventEmitter;
/** Emitted after the dropdown closes and all transitions are complete. */
@event('sl-after-hide') slAfterHide: EventEmitter;
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 (!this.containingElement) {
this.containingElement = this;
}
}
firstUpdated() {
this.popover = new Popover(this.trigger, this.positioner, {
strategy: this.hoist ? 'fixed' : 'absolute',
placement: this.placement,
distance: this.distance,
skidding: this.skidding,
transitionElement: this.panel,
onAfterHide: () => this.slAfterHide.emit(),
onAfterShow: () => this.slAfterShow.emit(),
onTransitionEnd: () => {
if (!this.open) {
this.panel.scrollTop = 0;
}
}
});
// Show on init if open
if (this.open) {
this.show();
}
}
disconnectedCallback() {
super.disconnectedCallback();
this.hide();
this.popover.destroy();
}
focusOnTrigger() {
const slot = this.trigger.querySelector('slot')!;
const trigger = slot.assignedElements({ flatten: true })[0] as any;
if (trigger) {
if (typeof trigger.setFocus === 'function') {
trigger.setFocus();
} else if (typeof trigger.focus === 'function') {
trigger.focus();
}
}
}
getMenu() {
const slot = this.panel.querySelector('slot')!;
return slot.assignedElements({ flatten: true }).filter(el => el.tagName.toLowerCase() === 'sl-menu')[0] as SlMenu;
}
handleDocumentKeyDown(event: KeyboardEvent) {
// Close when escape is pressed
if (event.key === 'Escape') {
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();
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 (activeElement?.closest(this.containingElement.tagName.toLowerCase()) !== this.containingElement) {
this.hide();
return;
}
});
}
}
handleDocumentMouseDown(event: MouseEvent) {
// Close when clicking outside of the containing element
const path = event.composedPath() as Array;
if (!path.includes(this.containingElement)) {
this.hide();
return;
}
}
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.closeOnSelect && target.tagName.toLowerCase() === 'sl-menu') {
this.hide();
this.focusOnTrigger();
}
}
@watch('distance')
@watch('hoist')
@watch('placement')
@watch('skidding')
handlePopoverOptionsChange() {
if (this.popover) {
this.popover.setOptions({
strategy: this.hoist ? 'fixed' : 'absolute',
placement: this.placement,
distance: this.distance,
skidding: this.skidding
});
}
}
handleTriggerClick() {
this.open ? this.hide() : this.show();
}
handleTriggerKeyDown(event: KeyboardEvent) {
const menu = this.getMenu();
const menuItems = menu ? ([...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();
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.open ? this.hide() : this.show();
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) {
this.show();
}
// Focus on a menu item
if (event.key === 'ArrowDown' && firstMenuItem) {
firstMenuItem.setFocus();
return;
}
if (event.key === 'ArrowUp' && lastMenuItem) {
lastMenuItem.setFocus();
return;
}
}
// Other keys bring focus to the menu and initiate type-to-select behavior
const ignoredKeys = ['Tab', 'Shift', 'Meta', 'Ctrl', 'Alt'];
if (this.open && menu && !ignoredKeys.includes(event.key)) {
menu.typeToSelect(event.key);
return;
}
}
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