admin-lte
Version:
Responsive open source admin dashboard and control panel.
476 lines (377 loc) • 14.4 kB
text/typescript
/**
* ----------------------------------------------------------------------------
* @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