/** * Copyright Aquera Inc 2025 * * 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, nothing, PropertyValues } from 'lit'; import { customElement, property, query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { styles } from './nile-inline-sidebar.css'; import NileElement from '../internal/nile-element'; import { drag } from '../internal/drag'; import { clamp } from '../internal/math'; import NileInlineSidebarItem from '../nile-inline-sidebar-item/nile-inline-sidebar-item'; /** * Nile inline sidebar component. * * @tag nile-inline-sidebar */ @customElement('nile-inline-sidebar') export class NileInlineSidebar extends NileElement { @property({ type: Boolean, reflect: true }) collapsed = false; @property({ type: Boolean, reflect: true, attribute: true }) fixed = false; @property({ type: Boolean, reflect: true }) showTooltip = false; /** The side on which the sidebar is placed. */ @property({ type: String, reflect: true }) placement: 'left' | 'right' = 'left'; /** Enables a draggable resize handle on the sidebar's trailing edge. */ @property({ type: Boolean, reflect: true }) resizable = false; /** Current sidebar width in pixels when resizable is enabled. */ @property({ type: Number, attribute: true }) sidebarWidth = 216; /** Minimum sidebar width in pixels. */ @property({ type: Number, attribute: true }) minSidebarWidth = 120; @property({ type: Boolean, attribute: true }) showToggleBtn = true; /** Maximum sidebar width in pixels. */ @property({ type: Number, attribute: true}) maxSidebarWidth = 400; @property({ type: String, reflect: true, attribute: true }) variant?: 'minimal' | 'rich' = 'minimal'; @state() private activeIndex: number = -1; @query('slot') private defaultSlot!: HTMLSlotElement; private mutationObserver?: MutationObserver; public static get styles(): CSSResultArray { return [styles]; } private get sidebarItems(): HTMLElement[] { if (!this.defaultSlot) return []; const assigned = this.defaultSlot.assignedElements({ flatten: true }); const items = assigned.flatMap(el => el.tagName.toLowerCase() === 'nile-inline-sidebar-item' ? [el] : Array.from(el.querySelectorAll('nile-inline-sidebar-item')) ); return items as HTMLElement[]; } connectedCallback() { super.connectedCallback(); this.addEventListener('nile-click', this.handleItemSelect); this.addEventListener('keydown', this.handleKeyDown); this.mutationObserver = new MutationObserver(() => this.syncActiveFromItems() ); } disconnectedCallback() { super.disconnectedCallback(); this.mutationObserver?.disconnect(); this.removeEventListener('nile-click', this.handleItemSelect); this.removeEventListener('keydown', this.handleKeyDown); } firstUpdated() { this.observeSidebarItems(); this.syncActiveFromItems(); this.syncChildSidebarItemVariants(); if (this.fixed) { this.collapsed = false; } } updated(changedProperties: PropertyValues) { super.updated(changedProperties); if (changedProperties.has('variant')) { this.syncChildSidebarItemVariants(); } } /** Pushes sidebar `variant` onto each item (rich vs minimal) so layout stays in sync with slotted content. */ private syncChildSidebarItemVariants() { const items = this.querySelectorAll( 'nile-inline-sidebar-item', ) as NodeListOf; const v = this.variant ?? 'minimal'; items.forEach(item => { item.variant = v; }); } private observeSidebarItems() { if (!this.sidebarItems?.length || !this.mutationObserver) return; this.sidebarItems.forEach(item => this.mutationObserver!.observe(item, { attributes: true, attributeFilter: ['active'], }) ); } private syncActiveFromItems() { if (!this.sidebarItems?.length) return; const activeIndex = this.sidebarItems.findIndex(item => item.hasAttribute('active') ); if (activeIndex !== -1 && activeIndex !== this.activeIndex) { this.activeIndex = activeIndex; this.requestUpdate(); } this.updateTabIndices(); } private get focusableItems(): HTMLElement[] { return this.sidebarItems.filter( item => !item.hasAttribute('disabled') ); } private updateTabIndices() { const items = this.sidebarItems; if (!items.length) return; const focusTarget = this.activeIndex >= 0 ? items[this.activeIndex] : null; items.forEach(item => { if (item.hasAttribute('disabled')) { item.setAttribute('tabindex', '-1'); } else if (item === focusTarget) { item.setAttribute('tabindex', '0'); } else if (!focusTarget && item === this.focusableItems[0]) { item.setAttribute('tabindex', '0'); } else { item.setAttribute('tabindex', '-1'); } }); } private focusItem(item: HTMLElement) { const items = this.sidebarItems; items.forEach(el => el.setAttribute('tabindex', '-1')); item.setAttribute('tabindex', '0'); item.focus(); } private handleKeyDown = (event: KeyboardEvent) => { if (this.collapsed) return; const focusable = this.focusableItems; if (!focusable.length) return; const currentFocus = this.sidebarItems.find( item => item === document.activeElement || item === this.shadowRoot?.activeElement || item.matches(':focus-within') ); const currentIndex = currentFocus ? focusable.indexOf(currentFocus) : -1; let nextIndex: number | null = null; switch (event.key) { case 'ArrowDown': nextIndex = currentIndex < focusable.length - 1 ? currentIndex + 1 : 0; break; case 'ArrowUp': nextIndex = currentIndex > 0 ? currentIndex - 1 : focusable.length - 1; break; case 'Home': nextIndex = 0; break; case 'End': nextIndex = focusable.length - 1; break; case 'Enter': case ' ': return; default: return; } event.preventDefault(); if (nextIndex !== null && focusable[nextIndex]) { this.focusItem(focusable[nextIndex]); } }; private handleItemSelect = (event: CustomEvent) => { const selectedItem = event.detail.item as HTMLElement; const index = this.sidebarItems.indexOf(selectedItem); this.sidebarItems.forEach( (item, i) => ((item as any).active = i === index) ); this.activeIndex = index; this.updateTabIndices(); this.dispatchEvent( new CustomEvent('nile-change', { detail: { selectedItem, index }, bubbles: true, composed: true, }) ); this.requestUpdate(); }; private toggleCollapse() { if (this.fixed) return; this.collapsed = !this.collapsed; this.dispatchEvent( new CustomEvent('nile-toggle', { detail: { collapsed: this.collapsed }, bubbles: true, composed: true, }) ); } private handleMenuItemClick(index: number) { const selectedItem = this.sidebarItems[index]; if (!selectedItem || selectedItem.hasAttribute('disabled')) return; this.sidebarItems.forEach( (item, i) => ((item as any).active = i === index) ); this.activeIndex = index; const text = selectedItem.textContent?.trim() || ''; const href = (selectedItem as any).href; this.dispatchEvent( new CustomEvent('nile-click', { detail: { item: selectedItem, href, text }, bubbles: true, composed: true, }) ); if (href) window.location.href = href; this.requestUpdate(); } private handleSeparatorDrag(event: PointerEvent) { if (!this.resizable || this.collapsed) return; if (event.cancelable) event.preventDefault(); const isRight = this.placement === 'right'; this.style.setProperty('--sidebar-transition', 'none'); drag(this, { onMove: (x: number) => { const hostRect = this.getBoundingClientRect(); let newWidth: number; if (isRight) { newWidth = hostRect.width - x; } else { newWidth = x; } this.sidebarWidth = clamp(newWidth, this.minSidebarWidth, this.maxSidebarWidth); this.emit('nile-sidebar-resize', { width: this.sidebarWidth }); }, onStop: () => { this.style.removeProperty('--sidebar-transition'); }, initialEvent: event, }); } private handleSeparatorKeyDown(event: KeyboardEvent) { if (!this.resizable || this.collapsed) return; const step = event.shiftKey ? 10 : 1; let newWidth = this.sidebarWidth; switch (event.key) { case 'ArrowLeft': newWidth -= step; break; case 'ArrowRight': newWidth += step; break; case 'Home': newWidth = this.minSidebarWidth; break; case 'End': newWidth = this.maxSidebarWidth; break; default: return; } event.preventDefault(); this.sidebarWidth = clamp(newWidth, this.minSidebarWidth, this.maxSidebarWidth); this.emit('nile-sidebar-resize', { width: this.sidebarWidth }); } private get separatorTemplate() { if (!this.resizable || this.collapsed) return nothing; return html` `; } private get menuItemsTemplate() { if (!this.sidebarItems?.length) return null; const tooltipPlacement = this.placement === 'right' ? 'left' : 'right'; return this.sidebarItems.map((item, index) => { const shouldShowTooltip = (item as any).tooltip; const isTruncated = item.scrollWidth > item.clientWidth || item.scrollHeight > item.clientHeight; const content = html` this.handleMenuItemClick(index)} > ${item.textContent} `; return shouldShowTooltip || isTruncated ? html` ${content} ` : content; }); } public render(): TemplateResult { const isRight = this.placement === 'right'; const collapsedIcon = isRight ? 'menu_open' : 'menu_close'; const expandedIcon = isRight ? 'menu_close' : 'menu_open'; const actionPlacement = isRight ? 'bottom-end' : 'bottom-start'; const sidebarStyle = !this.collapsed ? `width: ${this.sidebarWidth}px;` : ''; return html` ${this.separatorTemplate} `; } } export default NileInlineSidebar; declare global { interface HTMLElementTagNameMap { 'nile-inline-sidebar': NileInlineSidebar; } }