/** * Copyright Aquera Inc 2023 * * This source code is licensed under the BSD-3-Clause license found in the * LICENSE file in the root directory of this source tree. */ import {LitElement, html, CSSResultArray, TemplateResult, PropertyValues} from 'lit'; import { customElement, property } from 'lit/decorators.js'; import {styles} from './nile-dropdown.css'; import { animateTo, stopAnimations } from '../internal/animate'; import { classMap } from 'lit/directives/class-map.js'; import { query } from 'lit/decorators.js'; import { getAnimation, setDefaultAnimation } from '../utilities/animation-registry'; import { getTabbableBoundary } from '../internal/tabbable'; import { waitForEvent } from '../internal/event'; import { watch } from '../internal/watch'; import NileElement from '../internal/nile-element'; import type { CSSResultGroup } from 'lit'; import type NileButton from '../nile-button/nile-button'; import type NileIconButton from '../nile-icon-button/nile-icon-button'; import type { NileMenu } from '../nile-menu'; import type { NilePopup } from '../nile-popup'; import '../nile-popup'; import { DropdownPortalManager } from './portal-manager'; import { VisibilityManager } from '../utilities/visibility-manager.js'; /** * Nile icon component. * * @tag nile-dropdown * @summary Dropdowns expose additional content that "drops down" in a panel. * @dependency nile-popup * * @slot - The dropdown's main content. * @slot trigger - The dropdown's trigger, usually a `` element. * * @event nile-show - Emitted when the dropdown opens. * @event nile-after-show - Emitted after the dropdown opens and all animations are complete. * @event nile-hide - Emitted when the dropdown closes. * @event nile-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('nile-dropdown') export class NileDropdown extends NileElement { static styles: CSSResultGroup = styles; @query('.dropdown') popup: NilePopup; @query('.dropdown__trigger') trigger: HTMLSlotElement; @query('.dropdown__panel') panel: HTMLSlotElement; /** * 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; @property({ type: Boolean }) noOpenOnClick = false; /** The distance in pixels from which to offset the panel along its trigger. */ @property({ type: Number }) skidding = 0; /** Syncs the popup's width or height to that of the anchor element. */ @property() sync: 'width' | 'height' | 'both'; /** * 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; /** * Enable portal mode to render the dropdown panel in a portal outside the component's DOM tree. * This provides better positioning control and prevents clipping issues in complex layouts. */ @property({ type: Boolean, reflect: true }) portal = false; @property({ type: Boolean, reflect: true }) enableVisibilityEffect = false; @property({ type: Boolean, reflect: true }) enableTabClose = false; private portalManager: DropdownPortalManager | null = null; private visibilityManager?: VisibilityManager; connectedCallback() { super.connectedCallback(); 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; } this.emit('nile-init'); requestAnimationFrame(() => { if (this.portal && !this.portalManager) { this.portalManager = new DropdownPortalManager(this); } }); } protected async firstUpdated(_changed: PropertyValues) { this.panel.hidden = !this.open; // If the dropdown is visible on init, update its position if (this.open) { this.addOpenListeners(); this.popup.active = true; } await this.updateComplete; const triggerNode = this.trigger.assignedElements({ flatten: true })[0] as HTMLElement | undefined; this.visibilityManager = new VisibilityManager({ host: this, target: triggerNode || null, enableVisibilityEffect: this.enableVisibilityEffect, enableTabClose: this.enableTabClose, isOpen: () => this.open, onAnchorOutOfView: () => { this.hide(); this.emit('nile-visibility-change', { visible: false, reason: 'anchor-out-of-view', }); }, onDocumentHidden: () => { this.hide(); this.emit('nile-visibility-change', { visible: false, reason: 'document-hidden', }); }, emit: (event, detail) => this.emit(`nile-${event}`, detail), }); } disconnectedCallback() { super.disconnectedCallback(); this.visibilityManager?.cleanup(); this.removeOpenListeners(); this.hide(); this.emit('nile-destroy'); if (this.portalManager) { this.portalManager.cleanupPortalAppend(); this.portalManager = null; } } 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() === 'nile-menu') as | NileMenu | 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) { // Close when escape or tab is pressed if (event.key === 'Escape' && this.open) { event.stopPropagation(); this.focusOnTrigger(); this.hide(); 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() === 'nile-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; const hitSelf = this.containingElement && activeElement?.closest(this.containingElement.tagName.toLowerCase()) === this.containingElement; const hitPortalAppend = this.portal && this.portalManager?.portalContainerElement && (activeElement === this.portalManager.portalContainerElement || activeElement?.closest('.nile-dropdown-portal-append') === this.portalManager.portalContainerElement); if (!hitSelf && !hitPortalAppend) { this.hide(); } }); } } handleDocumentMouseDown(event: MouseEvent) { // Close when clicking outside of the containing element const path = event.composedPath(); const hitSelf = this.containingElement && path.includes(this.containingElement); const hitPortalAppend = this.portal && this.portalManager?.portalContainerElement && path.includes(this.portalManager.portalContainerElement); if (!hitSelf && !hitPortalAppend) { this.hide(); } } handlePanelSelect(event: any) { const target = event.target as HTMLElement; // Hide the dropdown when a menu item is selected if (!this.stayOpenOnSelect && target.tagName.toLowerCase() === 'nile-menu') { this.hide(); this.focusOnTrigger(); } else if(this.stayOpenOnSelect && target.tagName.toLowerCase() === 'nile-menu') { this.show(); this.focusOnTrigger(); } } handleTriggerClick() { if(this.noOpenOnClick){ return; } if (this.disabled || this.isTriggerDisabled()) { return; } if (this.open) { this.hide(); } else { this.show(); this.focusOnTrigger(); } } handleTriggerKeyDown(event: KeyboardEvent) { // 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.getAllItems(); 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 this.updateComplete.then(() => { 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(); } private isTriggerDisabled(): boolean { const trigger = this.querySelector('[slot="trigger"]') as any; return trigger?.hasAttribute?.('disabled'); } // // 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