/** * 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 { html, CSSResultArray, TemplateResult, PropertyValues } from 'lit'; import { customElement, property, query } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { styles } from './nile-side-bar-action.css'; import NileElement from '../internal/nile-element'; import { animateTo, stopAnimations } from '../internal/animate'; import { getAnimation, setDefaultAnimation } from '../utilities/animation-registry'; import { getTabbableBoundary } from '../internal/tabbable'; import { waitForEvent } from '../internal/event'; import { watch } from '../internal/watch'; 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'; /** * Nile side-bar-action component. * * @tag nile-side-bar-action * * @slot trigger - The clickable trigger (icon/button). * @slot - The dropdown panel content (menu, custom content). * * @event nile-show - Fired when dropdown opens. * @event nile-after-show - Fired after dropdown + animations. * @event nile-hide - Fired when dropdown closes. * @event nile-after-hide - Fired after dropdown closes + animations. */ @customElement('nile-side-bar-action') export class NileSideBarAction extends NileElement { static styles: CSSResultArray = [styles]; @query('.dropdown') popup: NilePopup; @query('.dropdown__trigger') trigger: HTMLSlotElement; @query('.dropdown__panel') panel: HTMLSlotElement; /** Whether the dropdown is open */ @property({ type: Boolean, reflect: true }) open = false; /** Preferred placement relative to trigger */ @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'; /** Disable interaction */ @property({ type: Boolean, reflect: true }) disabled = false; /** Keep open on select */ @property({ attribute: 'stay-open-on-select', type: Boolean, reflect: true }) stayOpenOnSelect = false; /** External containing element (default: self) */ @property({ attribute: false }) containingElement?: HTMLElement; /** Offsets */ @property({ type: Number }) distance = 0; @property({ type: Number }) skidding = 0; /** Sync size with trigger */ @property() sync: 'width' | 'height' | 'both'; /** Hoist above scroll containers */ @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; private portalManager: DropdownPortalManager | null = null; @property({ reflect: true }) triggerDropdown: 'click' | 'hover' = 'click'; private hoverTimeout: number | undefined; 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); this.handleTriggerMouseEnter = this.handleTriggerMouseEnter.bind(this); this.handleTriggerMouseLeave = this.handleTriggerMouseLeave.bind(this); if (!this.containingElement) { this.containingElement = this; } this.emit('nile-init'); requestAnimationFrame(() => { if (this.portal && !this.portalManager) { this.portalManager = new DropdownPortalManager(this); } }); } firstUpdated() { this.panel.hidden = !this.open; if (this.triggerDropdown === 'hover') { const triggerEl = this.trigger; if (triggerEl) { triggerEl.addEventListener('mouseenter', this.handleTriggerMouseEnter); triggerEl.addEventListener('mouseleave', this.handleTriggerMouseLeave); } if (this.panel) { this.panel.addEventListener('mouseenter', this.handleTriggerMouseEnter); this.panel.addEventListener('mouseleave', this.handleTriggerMouseLeave); } } else { } if (this.open) { this.addOpenListeners(); this.popup.active = true; } } handleTriggerMouseEnter() { clearTimeout(this.hoverTimeout); this.show(); } handleTriggerMouseLeave() { clearTimeout(this.hoverTimeout); this.hoverTimeout = window.setTimeout(() => this.hide(), 200); } disconnectedCallback() { super.disconnectedCallback(); 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) { if (this.open && event.key === 'Escape') { event.stopPropagation(); this.hide(); this.focusOnTrigger(); } } handleDocumentKeyDown(event: KeyboardEvent) { if (event.key === 'Escape' && this.open) { event.stopPropagation(); this.focusOnTrigger(); this.hide(); return; } if (event.key === 'Tab') { 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) { 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; if (!this.stayOpenOnSelect && target.tagName.toLowerCase() === 'nile-menu') { this.hide(); this.focusOnTrigger(); } } handleTriggerClick() { if (this.open) { this.hide(); } else { this.show(); this.focusOnTrigger(); } } handleTriggerKeyDown(event: KeyboardEvent) { const menu = this.getMenu(); if (menu) { const menuItems = menu.getAllItems(); const firstMenuItem = menuItems[0]; const lastMenuItem = menuItems[menuItems.length - 1]; if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) { event.preventDefault(); if (!this.open) this.show(); if (menuItems.length > 0) { 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) { if (event.key === ' ') { event.preventDefault(); } } handleTriggerSlotChange() { this.updateAccessibleTrigger(); } updateAccessibleTrigger() { const assignedElements = this.trigger.assignedElements({ flatten: true }) as HTMLElement[]; const accessibleTrigger = assignedElements.find(el => getTabbableBoundary(el).start); let target: HTMLElement; if (accessibleTrigger) { switch (accessibleTrigger.tagName.toLowerCase()) { case 'nile-button': case 'nile-icon-button': target = (accessibleTrigger as NileButton | NileIconButton).button; break; default: target = accessibleTrigger; } target.setAttribute('aria-haspopup', 'true'); target.setAttribute('aria-expanded', this.open ? 'true' : 'false'); } } /** Public methods */ async show() { if (this.open) return; this.open = true; return waitForEvent(this, 'nile-after-show'); } async hide() { if (!this.open) return; this.open = false; return waitForEvent(this, 'nile-after-hide'); } reposition() { this.popup.reposition(); } addOpenListeners() { this.panel.addEventListener('nile-select', this.handlePanelSelect); this.panel.addEventListener('keydown', this.handleKeyDown); document.addEventListener('keydown', this.handleDocumentKeyDown); document.addEventListener('mousedown', this.handleDocumentMouseDown); } removeOpenListeners() { if (this.panel) { this.panel.removeEventListener('nile-select', this.handlePanelSelect); this.panel.removeEventListener('keydown', this.handleKeyDown); } document.removeEventListener('keydown', this.handleDocumentKeyDown); document.removeEventListener('mousedown', this.handleDocumentMouseDown); } protected updated(changedProperties: PropertyValues): void { super.updated(changedProperties); if (changedProperties.has('portal')) { if (this.portal && !this.portalManager) { this.portalManager = new DropdownPortalManager(this); if (this.open) { this.portalManager.setupPortalAppend(); } } else if (!this.portal && this.portalManager) { this.portalManager.cleanupPortalAppend(); this.portalManager = null; } } } @watch('open', { waitUntilFirstUpdate: true }) async handleOpenChange() { if (this.disabled) { this.open = false; return; } this.updateAccessibleTrigger(); if (this.open) { this.emit('nile-show'); this.addOpenListeners(); if (this.portal && this.portalManager) { this.portalManager.setupPortalAppend(); } else if (this.portal && !this.portalManager) { this.portalManager = new DropdownPortalManager(this); this.portalManager.setupPortalAppend(); } await stopAnimations(this); // Only show panel and animate if not using portal if (!this.portal) { this.panel.hidden = false; this.popup.active = true; const { keyframes, options } = getAnimation(this, 'dropdown.show', { dir: '' }); await animateTo(this.popup.popup, keyframes, options); } this.emit('nile-after-show'); } else { this.emit('nile-hide'); this.removeOpenListeners(); if (this.portal && this.portalManager) { this.portalManager.cleanupPortalAppend(); } await stopAnimations(this); if (!this.portal) { const { keyframes, options } = getAnimation(this, 'dropdown.hide', { dir: '' }); await animateTo(this.popup.popup, keyframes, options); this.panel.hidden = true; this.popup.active = false; } this.emit('nile-after-hide'); } } render(): TemplateResult { return html` `; } } /* Default animations */ setDefaultAnimation('dropdown.show', { keyframes: [ { opacity: 0, scale: 0.9 }, { opacity: 1, scale: 1 } ], options: { duration: 100, easing: 'ease' } }); setDefaultAnimation('dropdown.hide', { keyframes: [ { opacity: 1, scale: 1 }, { opacity: 0, scale: 0.9 } ], options: { duration: 100, easing: 'ease' } }); export default NileSideBarAction; declare global { interface HTMLElementTagNameMap { 'nile-side-bar-action': NileSideBarAction; } }