UNPKG

admin-lte

Version:

Responsive open source admin dashboard and control panel.

476 lines (377 loc) 14.4 kB
/** * ---------------------------------------------------------------------------- * @file AdminLTE push-menu.ts * @description Push menu for AdminLTE. * @license MIT * ---------------------------------------------------------------------------- */ import { BaseComponent, dispatchCustomEvent } from './base-component' import { getLifecycleSignal, onDOMContentLoaded } from './util/index' /** * ---------------------------------------------------------------------------- * Constants * ---------------------------------------------------------------------------- */ const NAME = 'push-menu' const EVENT_KEY = `.lte.${NAME}` // Cancelable "before" events: preventDefault() aborts the state change. const EVENT_OPEN = `open${EVENT_KEY}` const EVENT_COLLAPSE = `collapse${EVENT_KEY}` // "After" events, dispatched once the classes have been applied. const EVENT_OPENED = `opened${EVENT_KEY}` const EVENT_COLLAPSED = `collapsed${EVENT_KEY}` const CLASS_NAME_SIDEBAR_MINI = 'sidebar-mini' const CLASS_NAME_SIDEBAR_EXPAND = 'sidebar-expand' const CLASS_NAME_SIDEBAR_OVERLAY = 'sidebar-overlay' // Classes used to explicitly indicate the sidebar state. // - sidebar-collapse: Indicates the sidebar is explicitly collapsed. // - sidebar-open: Indicates the sidebar is explicitly open on mobile sizes. const CLASS_NAME_SIDEBAR_COLLAPSE = 'sidebar-collapse' const CLASS_NAME_SIDEBAR_OPEN = 'sidebar-open' const SELECTOR_APP_SIDEBAR = '.app-sidebar' const SELECTOR_APP_WRAPPER = '.app-wrapper' const SELECTOR_SIDEBAR_EXPAND = `[class*="${CLASS_NAME_SIDEBAR_EXPAND}"]` const SELECTOR_SIDEBAR_TOGGLE = '[data-lte-toggle="sidebar"]' const STORAGE_KEY_SIDEBAR_STATE = 'lte.sidebar.state' /** * ---------------------------------------------------------------------------- * Configuration Object Interface * - sidebarBreakpoint: The screen width (in pixels) below which the sidebar * is considered to be on a "mobile" size and should be collapsed by default * unless explicitly opened. * - enablePersistence: Whether to save the sidebar state (collapsed/open) in * localStorage and restore it on page load. * ---------------------------------------------------------------------------- */ type Config = { sidebarBreakpoint: number; enablePersistence: boolean; } const Defaults: Config = { // Matches the CSS convention (breakpoint-max = breakpoint - .02) so that a // window of exactly 992px is classified as desktop by both JS and CSS. sidebarBreakpoint: 991.98, enablePersistence: false } /** * ---------------------------------------------------------------------------- * Class Definition * ---------------------------------------------------------------------------- */ class PushMenu extends BaseComponent { static get NAME(): string { return NAME } static getInstance(element: Element | null | undefined): PushMenu | null { return this._getInstance(element) as PushMenu | null } static getOrCreateInstance(element: HTMLElement, config: Partial<Config> = {}): PushMenu { return this.getInstance(element) ?? new this(element, config) } _config: Config constructor(element: HTMLElement, config: Partial<Config> = {}) { super(element) this._config = { ...Defaults, ...config } } /** * Check if the sidebar is collapsed. * * @returns True if the sidebar is collapsed, false otherwise. */ isCollapsed(): boolean { return document.body.classList.contains(CLASS_NAME_SIDEBAR_COLLAPSE) } /** * Check if the sidebar is explicitly open on mobile sizes. * * @returns True if the sidebar is explicitly open, false otherwise. */ isExplicitlyOpen(): boolean { return document.body.classList.contains(CLASS_NAME_SIDEBAR_OPEN) } /** * Check if the sidebar is in mini mode. * * @returns True if the sidebar is in mini mode, false otherwise. */ isMiniMode(): boolean { return document.body.classList.contains(CLASS_NAME_SIDEBAR_MINI) } /** * Check if the current screen size is considered "mobile" based on the * sidebarBreakpoint config value. * * @returns True if the screen size is mobile, false otherwise. */ isMobileSize(): boolean { return globalThis.innerWidth <= this._config.sidebarBreakpoint } /** * Expand the sidebar menu. */ expand(): void { // The "open" event is cancelable: preventDefault() keeps the sidebar // in its current state. if (dispatchCustomEvent(this._element, EVENT_OPEN, { cancelable: true }).defaultPrevented) { return } // Remove the sidebar-collapse class. Only on mobile, add the sidebar-open // class to indicate the sidebar is explicitly open. document.body.classList.remove(CLASS_NAME_SIDEBAR_COLLAPSE) if (this.isMobileSize()) { document.body.classList.add(CLASS_NAME_SIDEBAR_OPEN) } dispatchCustomEvent(this._element, EVENT_OPENED) } /** * Collapse the sidebar menu. */ collapse(): void { if (dispatchCustomEvent(this._element, EVENT_COLLAPSE, { cancelable: true }).defaultPrevented) { return } // Remove the sidebar-open class (if present), and add the sidebar-collapse // class. document.body.classList.remove(CLASS_NAME_SIDEBAR_OPEN) document.body.classList.add(CLASS_NAME_SIDEBAR_COLLAPSE) dispatchCustomEvent(this._element, EVENT_COLLAPSED) } /** * Toggle the sidebar menu state. */ toggle(): void { // Toggle the sidebar state. const isCollapsed = this.isCollapsed() if (isCollapsed) { this.expand() } else { this.collapse() } // If persistence is enabled, save the new state to localStorage. if (this._config.enablePersistence) { this.saveSidebarState( isCollapsed ? CLASS_NAME_SIDEBAR_OPEN : CLASS_NAME_SIDEBAR_COLLAPSE ) } } /** * Read the CSS breakpoint of the sidebar from the DOM and update the * sidebarBreakpoint config. This breakpoint is defined using a CSS ::before * pseudo element on the sidebar-expand element when @media queries are used * to change the sidebar behavior based on screen size. */ setupSidebarBreakPoint(): void { // Find the sidebar-expand element in the DOM. const sidebarExpand = document.querySelector(SELECTOR_SIDEBAR_EXPAND) if (!sidebarExpand) { return } // Read the content property of the ::before pseudo element to get the // breakpoint value. const content = globalThis.getComputedStyle(sidebarExpand, '::before') .getPropertyValue('content') // Update the config.sidebarBreakpoint value by extracting the numeric // value from the content string. if (!content || content === 'none') { return } const breakpointValue = Number(content.replace(/[^\d.-]/g, '')) if (Number.isNaN(breakpointValue)) { return } this._config = { ...this._config, sidebarBreakpoint: breakpointValue } } /** * Update the sidebar state based on the current screen size and the * sidebarBreakpoint config value. */ updateStateByResponsiveLogic(): void { if (this.isMobileSize()) { // On mobile sizes, keep the sidebar collapsed unless explicitly opened // by the user to prevent unintended collapse on scroll or resize events. if (!this.isExplicitlyOpen()) { this.collapse() } } else { // On big screen sizes, keep the sidebar expanded unless in mini mode and // explicitly collapsed. if (!(this.isMiniMode() && this.isCollapsed())) { this.expand() } } } /** * Save sidebar state to localStorage. * * @param state The state to save ('sidebar-open' or 'sidebar-collapse'). */ saveSidebarState(state: string): void { // Check for localStorage availability (not a SSR environment). if (globalThis.localStorage === undefined) { return } // Save the sidebar state to local storage. try { localStorage.setItem(STORAGE_KEY_SIDEBAR_STATE, state) } catch { // The localStorage may be unavailable (private browsing, quota exceeded, // etc.). Fail silently in these cases. } } /** * Load sidebar state from localStorage. */ loadSidebarState(): void { // Check for localStorage availability (not a SSR environment). if (globalThis.localStorage === undefined) { return } // Load the sidebar state from local storage. try { const storedState = localStorage.getItem(STORAGE_KEY_SIDEBAR_STATE) if (storedState === CLASS_NAME_SIDEBAR_COLLAPSE) { this.collapse() } else if (storedState === CLASS_NAME_SIDEBAR_OPEN) { this.expand() } else { // If null (never saved), let the responsive logic apply. this.updateStateByResponsiveLogic() } } catch { // The localStorage may be unavailable. Let the responsive logic apply. this.updateStateByResponsiveLogic() } } /** * Clear sidebar state from localStorage. */ clearSidebarState(): void { // Check for localStorage availability (not a SSR environment). if (globalThis.localStorage === undefined) { return } // Clear the sidebar state from local storage. try { localStorage.removeItem(STORAGE_KEY_SIDEBAR_STATE) } catch { // The localStorage may be unavailable. Fail silently in these cases. } } /** * Initialize the push menu plugin and setup the initial sidebar state. */ init(): void { // Read and setup the sidebar breakpoint from the DOM. This breakpoint is // used to determine when the sidebar should be considered "mobile" vs // "desktop". this.setupSidebarBreakPoint() // When persistence is not enabled, clear any saved state, just for safety. if (!this._config.enablePersistence) { this.clearSidebarState() } // When persistence is enabled and screen size is above the breakpoint, load // the saved sidebar state from local storage. Otherwise, use responsive // logic to set the initial state. On low screen sizes, the sidebar should // always be collapsed by default unless explicitly opened. if (this._config.enablePersistence && !this.isMobileSize()) { this.loadSidebarState() } else { this.updateStateByResponsiveLogic() } } } /** * ---------------------------------------------------------------------------- * Data Api implementation * ---------------------------------------------------------------------------- * Toggle clicks are handled by one delegated listener on `document`, so * toggle buttons added later work and the listener survives Turbo's <body> * swaps. The instance itself is created per page load below and can be * retrieved anywhere with: * * PushMenu.getInstance(document.querySelector('.app-sidebar')) */ document.addEventListener('click', event => { const target = event.target if (!(target instanceof Element)) { return } const button = target.closest(SELECTOR_SIDEBAR_TOGGLE) if (!button) { return } event.preventDefault() const sidebar = document.querySelector(SELECTOR_APP_SIDEBAR) as HTMLElement | null if (sidebar) { PushMenu.getOrCreateInstance(sidebar).toggle() } }) onDOMContentLoaded(() => { // Find the sidebar element in the DOM. const sidebar = document.querySelector(SELECTOR_APP_SIDEBAR) as HTMLElement | null if (!sidebar) { return } // Read config from data attributes on the sidebar element, falling back to // Defaults if not present. const sidebarBreakpointAttr = sidebar.dataset.sidebarBreakpoint const enablePersistenceAttr = sidebar.dataset.enablePersistence const config: Config = { sidebarBreakpoint: sidebarBreakpointAttr === undefined ? Defaults.sidebarBreakpoint : Number(sidebarBreakpointAttr), enablePersistence: enablePersistenceAttr === undefined ? Defaults.enablePersistence : enablePersistenceAttr === 'true' } // Initialize the PushMenu plugin (a unique instance per sidebar element; // Turbo navigations swap in a fresh element, so a fresh instance is // created and the old one is garbage-collected with the old <body>). const pushMenu = PushMenu.getOrCreateInstance(sidebar, config) pushMenu.init() // Update the sidebar state only when the viewport crosses the sidebar // breakpoint. matchMedia fires exactly on crossings, so height-only resizes // (mobile URL bar / keyboard show-hide) and same-side width changes never // disturb a sidebar state the user chose. init() has already read the // effective breakpoint from the CSS, so the query uses the final value. const breakpointQuery = globalThis.matchMedia(`(max-width: ${pushMenu._config.sidebarBreakpoint}px)`) breakpointQuery.addEventListener('change', () => { pushMenu.updateStateByResponsiveLogic() }, { signal: getLifecycleSignal() }) // Create the sidebar overlay element and append it to the app wrapper. // Reuse an existing overlay if present: Turbo restores cached <body> // snapshots that already contain the injected node (listeners are gone // after the restore — the snapshot is a clone — so they are rebound below). const appWrapper = document.querySelector(SELECTOR_APP_WRAPPER) let sidebarOverlay = appWrapper?.querySelector(`:scope > .${CLASS_NAME_SIDEBAR_OVERLAY}`) as HTMLElement | null if (!sidebarOverlay) { sidebarOverlay = document.createElement('div') sidebarOverlay.className = CLASS_NAME_SIDEBAR_OVERLAY appWrapper?.append(sidebarOverlay) } // Handle touch events on overlay (area outside sidebar), usually we want to // close the sidebar when the user taps outside the sidebar on mobile // devices. let overlayTouchMoved = false sidebarOverlay.addEventListener('touchstart', () => { overlayTouchMoved = false }, { passive: true }) sidebarOverlay.addEventListener('touchmove', () => { overlayTouchMoved = true }, { passive: true }) sidebarOverlay.addEventListener('touchend', event => { if (!overlayTouchMoved) { event.preventDefault() pushMenu.collapse() } overlayTouchMoved = false }, { passive: false }) sidebarOverlay.addEventListener('click', event => { event.preventDefault() pushMenu.collapse() }) }) export default PushMenu