import { LitElement, html, css } from 'lit'; import { property } from 'lit/decorators.js'; import type { PropertyValues } from 'lit'; /** * Event to notify if the sidebar is open or closed */ export interface AgSidebarToggleEventDetail { open: boolean; } /** * Event to notify if the sidebar is collapsed (rail mode) or expanded (desktop mode) */ export interface AgSidebarCollapseEventDetail { collapsed: boolean; } /** * Prop definitions */ export interface AgSidebarProps { /** Whether the sidebar is open or closed */ open?: boolean; /** Whether the sidebar is collapsed (rail mode) or expanded (desktop mode) */ collapsed?: boolean; /** Whether the sidebar is on the left or right */ position?: 'left' | 'right'; /** ARIA label for the sidebar */ ariaLabel?: string; /** Variant of the sidebar */ variant?: 'default' | 'bordered' | 'elevated'; /** Whether to disable transitions */ noTransition?: boolean; /** Width of the sidebar */ width?: string; /** Whether to disable compact/rail mode. When true, sidebar is either full-width or hidden (no intermediate collapsed state) */ disableCompactMode?: boolean; /** Whether to show the mobile toggle button */ showMobileToggle?: boolean; /** Position of the mobile toggle button. Default: top-left */ mobileTogglePosition?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; /** Whether to show the header toggle button */ showHeaderToggle?: boolean; /** Callback function to handle toggle open/close events */ onToggle?: (event: AgSidebarToggleEvent) => void; /** Callback function to handle expanded/collapsed events */ onCollapse?: (event: AgSidebarCollapseEvent) => void; } /** * Event type aliases */ export type AgSidebarToggleEvent = CustomEvent; export type AgSidebarCollapseEvent = CustomEvent; /** * AgSidebar - A self-contained, accessible sidebar navigation component. * * Provides a responsive sidebar with mobile overlay and desktop rail/collapse modes. * Supports customizable header, navigation content, and footer sections with full * keyboard navigation and focus management. * * @element ag-sidebar * * @slot header - Header content area for logo, title, or actions. When `collapsed` is true on desktop, content fades out but slot remains in DOM for accessibility. If provided, overrides composable header slots (header-start, header-end, header-toggle). * @slot header-start - Left side of header for logo or title. Hidden when sidebar is collapsed. Ignored if `header` slot has content. * @slot header-end - Right side of header for action buttons. Always visible, even when collapsed. Ignored if `header` slot has content. * @slot header-toggle - Specific slot for custom toggle button in header. Auto-positioned at the end. Ignored if `header` slot has content. * @slot - Default slot for main navigation content. Use `` component for structured navigation with proper aria attributes. * @slot footer - Footer content area for copyright info, version numbers, or help links. Remains visible when sidebar is collapsed, content should be icon-only or very compact. * @slot ag-toggle-icon - Customizes the floating mobile toggle button icon and header toggle icon. Must be an SVG element sized 18x18px. Falls back to built-in panel icon if not provided. * * @fires ag-sidebar-toggle - Fired when sidebar open/close state changes (mobile overlay). Detail: `{ open: boolean }` * @fires ag-sidebar-collapse - Fired when sidebar expanded/collapsed state changes (desktop rail). Detail: `{ collapsed: boolean }` * * @csspart ag-sidebar-backdrop - The backdrop overlay shown on mobile when sidebar is open * @csspart ag-sidebar-toggle-button - The floating toggle button shown on mobile (when `show-mobile-toggle` is true) * @csspart ag-sidebar-container - The main sidebar container element (aside) * @csspart ag-sidebar-header - The header section wrapper * @csspart ag-sidebar-header-toggle - The collapse/expand toggle button in header (when `show-header-toggle` is true) * @csspart ag-sidebar-content - The main content scrollable area * @csspart ag-sidebar-footer - The footer section wrapper * * @cssprop --ag-sidebar-width - Width of expanded sidebar. Default: `18rem` * @cssprop --ag-sidebar-width-collapsed - Width of collapsed sidebar (rail mode). Default: `3rem` * @cssprop --ag-sidebar-padding - Internal padding for content area. Default: `0.5rem` * @cssprop --ag-sidebar-background - Background color of sidebar. Default: `#ffffff` * @cssprop --ag-sidebar-border - Border color. Default: `#e5e7eb` * @cssprop --ag-sidebar-transition-duration - Animation duration for state changes. Default: `200ms` * @cssprop --ag-sidebar-transition-easing - Animation easing function. Default: `ease-in-out` * @cssprop --ag-sidebar-z-index - Z-index for sidebar container on mobile. Default: `1000` * @cssprop --ag-sidebar-backdrop-z-index - Z-index for backdrop overlay on mobile. Default: `999` * @cssprop --ag-sidebar-toggle-z-index - Z-index for floating toggle button. Default: `1001` * * @example * ```html * *
*

My App

*
* * * Home * About * * *
*

© 2024 Company

*
*
* ``` * * @example * ```html * * *

My App

* * *
* ``` * * @example * ```html * * * * * *
Navigation
*
* ``` */ export class AgSidebar extends LitElement implements AgSidebarProps { static styles = css` :host { display: block; position: relative; --ag-sidebar-width: 18rem; --ag-sidebar-width-collapsed: 3rem; --ag-sidebar-padding: var(--ag-space-2, 0.5rem); --ag-sidebar-background: var(--ag-background-primary, #ffffff); --ag-sidebar-border: var(--ag-border, #e5e7eb); --ag-sidebar-transition-duration: var(--ag-motion-duration-normal, 200ms); --ag-sidebar-transition-easing: var(--ag-motion-easing-standard, ease-in-out); --ag-sidebar-z-index: var(--ag-z-sidebar, 1000); --ag-sidebar-backdrop-z-index: var(--ag-z-sidebar-backdrop, 999); --ag-sidebar-toggle-z-index: var(--ag-z-sidebar-toggle, 1001); } .sidebar-container { display: flex; flex-direction: column; height: 100%; background: var(--ag-sidebar-background); } :host([collapsed]) { overflow: visible; } /* Allow popovers to escape sidebar in collapsed mode */ :host([collapsed]) .sidebar-content { overflow: visible; /* Center icon button items in the compact sidebar */ display: flex; flex-direction: column; align-items: center; } /* Set collapsed width globally so it applies before media queries kick in */ :host([collapsed]) .sidebar-container { width: var(--ag-sidebar-width-collapsed); overflow: visible; } /* Variant: Bordered */ :host([variant="bordered"]) .sidebar-container { border-inline-end: 1px solid var(--ag-sidebar-border); } :host([variant="bordered"][position="right"]) .sidebar-container { border-inline-end: none; border-inline-start: 1px solid var(--ag-sidebar-border); } /* Variant: Elevated */ :host([variant="elevated"]) .sidebar-container { box-shadow: var(--ag-shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)); z-index: 1; border-inline-end: none; } /* No Transition */ :host([no-transition]) *, :host([no-transition]) .sidebar-container, :host([no-transition]) .backdrop, :host([no-transition]) .header-content, :host([no-transition]) .toggle-button { transition: none !important; } .sidebar-header, .sidebar-footer { padding-block: var(--ag-space-2); padding-inline: var(--ag-space-4); flex-shrink: 0; transition: opacity var(--ag-sidebar-transition-duration) var(--ag-sidebar-transition-easing), padding var(--ag-sidebar-transition-duration) var(--ag-sidebar-transition-easing); } .sidebar-header { border-bottom: 1px solid var(--ag-sidebar-border); } .sidebar-footer { border-top: 1px solid var(--ag-sidebar-border); overflow: hidden; white-space: nowrap; } /* Collapsed state: fade out header content and footer */ :host([collapsed]) .header-content, :host([collapsed]) .sidebar-footer { opacity: 0; pointer-events: none; } /* Composable header layout system */ .header-layout { display: flex; align-items: center; justify-content: space-between; gap: var(--ag-space-2); width: 100%; } :host([collapsed]) .header-layout { gap: unset; } .header-start { flex: 1; min-width: 0; /* Allow text truncation */ overflow: hidden; transition: opacity var(--ag-sidebar-transition-duration) var(--ag-sidebar-transition-easing); } .header-end { display: flex; align-items: center; gap: var(--ag-space-2); flex-shrink: 0; transition: opacity var(--ag-sidebar-transition-duration) var(--ag-sidebar-transition-easing); } /* Collapsed state: hide start content and header-end-content, but keep ag-header-toggle visible */ :host([collapsed]) .header-start { opacity: 0; pointer-events: none; } :host([collapsed]) .header-end-content { display: none; } :host([collapsed]) .header-layout { justify-content: center; } /* Header layout wrapper for showHeaderToggle. Base wrapper - no gap */ .header-wrapper { display: flex; align-items: center; justify-content: space-between; width: 100%; } /* Gap only when expanded */ :host(:not([collapsed])) .header-wrapper { gap: var(--ag-space-2); } .header-content { flex: 1; min-width: 0; opacity: 1; transition: opacity var(--ag-sidebar-transition-duration) var(--ag-sidebar-transition-easing); } /* Collapsed state: fade out header content */ :host([collapsed]) .header-content { display: none; } /* Collapsed state: center the toggle button */ :host([collapsed]) .header-wrapper { justify-content: center; } /* Built-in header toggle button styling */ .header-toggle-button { display: flex; align-items: center; justify-content: center; width: var(--ag-space-7); height: var(--ag-space-7); border: 1px solid var(--ag-border-subtle); border-radius: 0.375rem; background: var(--ag-background-secondary); color: var(--ag-text-primary); cursor: pointer; transition: background 0.15s; flex-shrink: 0; } .header-toggle-button:hover { background: var(--ag-background-tertiary); } .header-toggle-button:active { transform: scale(0.95); } .header-toggle-button svg { width: 18px; height: 18px; fill: currentColor; } .sidebar-content { flex: 1; overflow-y: auto; padding: var(--ag-sidebar-padding); } .backdrop { display: none; } .toggle-button { display: none; } /* =========================================== MOBILE: Below breakpoint Overlay drawer that slides in/out =========================================== */ @media (max-width: 1023px) { .sidebar-container { position: fixed; top: 0; bottom: 0; z-index: var(--ag-sidebar-z-index); transition: transform var(--ag-sidebar-transition-duration) var(--ag-sidebar-transition-easing); } /* Full width in mobile when NOT collapsed */ :host(:not([collapsed])) .sidebar-container { width: var(--ag-sidebar-width); } :host([position="left"]) .sidebar-container { inset-inline-start: 0; transform: translateX(-100%); } :host([position="right"]) .sidebar-container { inset-inline-end: 0; transform: translateX(100%); } /* RTL Support */ :host-context([dir="rtl"]):host([position="left"]) .sidebar-container { transform: translateX(100%); } :host-context([dir="rtl"]):host([position="right"]) .sidebar-container { transform: translateX(-100%); } :host([open]) .sidebar-container { transform: translateX(0); } .backdrop { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.5); z-index: var(--ag-sidebar-backdrop-z-index); opacity: 0; pointer-events: none; transition: opacity var(--ag-sidebar-transition-duration) var(--ag-sidebar-transition-easing); } :host([open]) .backdrop { display: block; opacity: 1; pointer-events: auto; } } /* =========================================== GLOBAL: Toggle Button Styles Shared between mobile overlay and desktop disable-compact-mode =========================================== */ :host([show-mobile-toggle]) .toggle-button { display: flex; position: fixed; width: var(--ag-space-8); height: var(--ag-space-8); border-radius: 50%; background: var(--ag-sidebar-background); border: 1px solid var(--ag-sidebar-border); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); cursor: pointer; z-index: var(--ag-sidebar-toggle-z-index); align-items: center; justify-content: center; transition: opacity var(--ag-sidebar-transition-duration) var(--ag-sidebar-transition-easing); /* Default hidden on desktop unless disable-compact-mode is active */ opacity: 0; pointer-events: none; } /* Show toggle on mobile */ @media (max-width: 1023px) { :host([show-mobile-toggle]) .toggle-button { opacity: 1; pointer-events: auto; } } /* Show toggle on desktop ONLY if disable-compact-mode is active */ @media (min-width: 1024px) { :host([disable-compact-mode][show-mobile-toggle]) .toggle-button { opacity: 1; pointer-events: auto; } } /* Always hide toggle when sidebar is open - consumer's header button takes over */ :host([show-mobile-toggle][open]) .toggle-button { opacity: 0 !important; pointer-events: none !important; } .toggle-button:hover { background: var(--ag-background-hover, #f3f4f6); } .toggle-button:active { transform: scale(0.95); } .toggle-button svg { width: 18px; height: 18px; fill: currentColor; } /* Ensure consumer-provided slotted SVGs match the fallback sizing */ .toggle-button ::slotted(svg) { width: 18px; height: 18px; fill: currentColor; display: block; } .toggle-button.top-left { top: var(--ag-space-4); inset-inline-start: var(--ag-space-4); } .toggle-button.top-right { top: var(--ag-space-4); inset-inline-end: var(--ag-space-4); } .toggle-button.bottom-left { bottom: var(--ag-space-4); inset-inline-start: var(--ag-space-4); } .toggle-button.bottom-right { bottom: var(--ag-space-4); inset-inline-end: var(--ag-space-4); } /* =========================================== DESKTOP: At/above breakpoint Static sidebar with expand/collapse =========================================== */ @media (min-width: 1024px) { .sidebar-container { position: relative; } :host(:not([collapsed])) .sidebar-container { width: var(--ag-sidebar-width); transition: width var(--ag-sidebar-transition-duration) var(--ag-sidebar-transition-easing); } :host([collapsed]) .sidebar-container { width: var(--ag-sidebar-width-collapsed); transition: width var(--ag-sidebar-transition-duration) var(--ag-sidebar-transition-easing); } /* When compact mode is disabled, ignore collapsed state on desktop */ :host([disable-compact-mode]) .sidebar-container, :host([disable-compact-mode][collapsed]) .sidebar-container { position: fixed; inset-inline-start: 0; top: 0; height: 100%; width: var(--ag-sidebar-width); transform: translateX(0); transition: transform var(--ag-sidebar-transition-duration) var(--ag-sidebar-transition-easing); } :host([disable-compact-mode]:not([open])) .sidebar-container, :host([disable-compact-mode][collapsed]:not([open])) .sidebar-container { transform: translateX(-100%); } :host([disable-compact-mode][position="right"]:not([open])) .sidebar-container, :host([disable-compact-mode][collapsed][position="right"]:not([open])) .sidebar-container { transform: translateX(100%); } } @media (prefers-reduced-motion: reduce) { .sidebar-container, .backdrop, .toggle-button, .header-content { transition: none !important; } } `; @property({ type: Boolean, reflect: true }) declare open: boolean; @property({ type: Boolean, reflect: true }) declare collapsed: boolean; @property({ type: String, reflect: true }) declare position: 'left' | 'right'; @property({ type: String, attribute: 'aria-label' }) declare ariaLabel: string; @property({ type: String, reflect: true }) declare variant: 'default' | 'bordered' | 'elevated'; @property({ type: Boolean, attribute: 'no-transition', reflect: true }) declare noTransition: boolean; @property({ type: String }) declare width?: string; @property({ type: Boolean, attribute: 'disable-compact-mode', reflect: true }) declare disableCompactMode: boolean; @property({ type: Boolean, attribute: 'show-mobile-toggle', reflect: true }) declare showMobileToggle: boolean; @property({ type: String, attribute: 'mobile-toggle-position' }) declare mobileTogglePosition: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; @property({ type: Boolean, attribute: 'show-header-toggle', reflect: true }) declare showHeaderToggle: boolean; @property({ attribute: false }) declare onToggle?: (event: AgSidebarToggleEvent) => void; @property({ attribute: false }) declare onCollapse?: (event: AgSidebarCollapseEvent) => void; constructor() { super(); this.open = false; this.collapsed = false; this.position = 'left'; this.ariaLabel = 'Navigation'; this.variant = 'default'; this.noTransition = false; this.width = undefined; this.disableCompactMode = false; this.showMobileToggle = true; this.mobileTogglePosition = 'top-left'; this.showHeaderToggle = false; } private _mainContentRef?: HTMLElement; /** * Checks if the current viewport width is below the mobile breakpoint. * * This helper ensures consistent viewport detection across the component. * Called on-demand rather than using resize listeners to avoid memory overhead. * * @returns true if viewport width is less than breakpoint, false otherwise * @private */ private _isMobileViewport(): boolean { return window.innerWidth < 1024; } /** * Toggle the collapsed state (desktop rail mode) */ public toggleCollapse() { this.collapsed = !this.collapsed; this._dispatchCollapseEvent(); } /** * Intelligent toggle that handles both mobile and desktop contexts. * * **Behavior:** * - Mobile (< breakpoint) + sidebar open: closes the sidebar overlay * - Desktop (>= breakpoint): toggles collapsed state (rail mode) * - Mobile (< breakpoint) + sidebar closed: toggles collapsed state * * **When to use:** * Use this method in custom header buttons when you want the sidebar to close * on mobile when it's already open (dismissing the overlay), but toggle * collapsed state on desktop or when closed on mobile. * * **Comparison with built-in toggle:** * - Built-in `showHeaderToggle`: Always toggles collapsed state (never closes on mobile) * - `toggleResponsive()`: Closes overlay on mobile when open, otherwise toggles collapsed * * **Resize awareness:** * Checks viewport width on each invocation, ensuring correct behavior even * after window resizes without requiring resize event listeners. * * @example * ```html * * * * ``` * * @example * ```typescript * // In Lit component * const sidebar = this.shadowRoot.querySelector('ag-sidebar'); * sidebar.toggleResponsive(); * ``` */ public toggleResponsive(): void { if (this.disableCompactMode) { // When compact mode is disabled, only toggle open/close this.open = !this.open; this._dispatchToggleEvent(); } else if (this._isMobileViewport() && this.open) { // Mobile + open: close the overlay this.open = false; this._dispatchToggleEvent(); } else { // Desktop or mobile closed: toggle collapsed state this.collapsed = !this.collapsed; this._dispatchCollapseEvent(); } } override connectedCallback() { super.connectedCallback(); } override disconnectedCallback() { super.disconnectedCallback(); document.removeEventListener('keydown', this._handleKeydown); } override willUpdate(changedProperties: PropertyValues) { if (changedProperties.has('width')) { if (this.width) { this.style.setProperty('--ag-sidebar-width', this.width); } else { this.style.removeProperty('--ag-sidebar-width'); } } if (changedProperties.has('open')) { if (this.open) { document.addEventListener('keydown', this._handleKeydown); this.updateComplete.then(() => this._trapFocus()); } else { document.removeEventListener('keydown', this._handleKeydown); this._releaseFocus(); } } } private _handleBackdropClick = () => { this.open = false; this._dispatchToggleEvent(); }; private _handleToggleClick = () => { this.open = !this.open; this._dispatchToggleEvent(); }; private _handleHeaderToggleClick = () => { if (this.disableCompactMode) { // When compact mode is disabled, toggle open/close instead of collapsed state this.open = !this.open; this._dispatchToggleEvent(); } else { // Normal mode: toggles collapsed state (rail mode) // On mobile when open, this collapses to rail mode while keeping overlay open this.collapsed = !this.collapsed; this._dispatchCollapseEvent(); } }; private _handleKeydown = (event: KeyboardEvent) => { if (event.key === 'Escape' && this.open) { event.preventDefault(); this.open = false; this._dispatchToggleEvent(); } }; private _dispatchToggleEvent() { const event = new CustomEvent('ag-sidebar-toggle', { detail: { open: this.open }, bubbles: true, composed: true, }); this.dispatchEvent(event); this.onToggle?.(event); } private _dispatchCollapseEvent() { const event = new CustomEvent('ag-sidebar-collapse', { detail: { collapsed: this.collapsed }, bubbles: true, composed: true, }); this.dispatchEvent(event); this.onCollapse?.(event); } private _findMainContent(): HTMLElement | null { return (this.closest('main') || this.parentElement?.querySelector('main') || document.querySelector('main') || document.body); } private _trapFocus() { const mainContent = this._findMainContent(); if (mainContent && mainContent !== this && !mainContent.contains(this)) { mainContent.setAttribute('inert', ''); this._mainContentRef = mainContent; } this.updateComplete.then(() => { const firstFocusable = this.shadowRoot?.querySelector('button:not([disabled]), a[href], input:not([disabled]), [tabindex]:not([tabindex="-1"])') as HTMLElement; firstFocusable?.focus(); }); } private _releaseFocus() { if (this._mainContentRef) { this._mainContentRef.removeAttribute('inert'); this._mainContentRef = undefined; } } private _handleSlotClick(event: Event) { const path = event.composedPath(); const target = path[0] as HTMLElement; const button = target.closest('[aria-expanded]'); if (button) { const isExpanded = button.getAttribute('aria-expanded') === 'true'; button.setAttribute('aria-expanded', String(!isExpanded)); const submenu = button.nextElementSibling; if (submenu && submenu.tagName === 'AG-SIDEBAR-NAV-SUBMENU') { if (isExpanded) { submenu.removeAttribute('open'); } else { submenu.setAttribute('open', ''); } } } } private _renderToggleIcon() { // Provide a named slot so consumers can override the floating toggle icon. // Keep the existing SVG as the default fallback. return html` `; } override render() { return html` `; } }