admin-lte
Version:
Responsive open source admin dashboard and control panel.
300 lines (239 loc) • 8.64 kB
text/typescript
/**
* --------------------------------------------
* @file AdminLTE card-widget.ts
* @description Card widget for AdminLTE.
* @license MIT
* --------------------------------------------
*/
import { BaseComponent, dispatchCustomEvent } from './base-component'
import {
slideUp,
slideDown
} from './util/index'
/**
* Constants
* ====================================================
*/
const NAME = 'card-widget'
const EVENT_KEY = `.lte.${NAME}`
// Cancelable "before" events, dispatched on the card when an action starts.
const EVENT_COLLAPSE = `collapse${EVENT_KEY}`
const EVENT_EXPAND = `expand${EVENT_KEY}`
const EVENT_REMOVE = `remove${EVENT_KEY}`
// "After" events, dispatched on the card when the action has completed.
const EVENT_COLLAPSED = `collapsed${EVENT_KEY}`
const EVENT_EXPANDED = `expanded${EVENT_KEY}`
const EVENT_REMOVED = `removed${EVENT_KEY}`
const EVENT_MAXIMIZED = `maximized${EVENT_KEY}`
const EVENT_MINIMIZED = `minimized${EVENT_KEY}`
const CLASS_NAME_CARD = 'card'
const CLASS_NAME_COLLAPSED = 'collapsed-card'
const CLASS_NAME_COLLAPSING = 'collapsing-card'
const CLASS_NAME_EXPANDING = 'expanding-card'
const CLASS_NAME_WAS_COLLAPSED = 'was-collapsed'
const CLASS_NAME_MAXIMIZED = 'maximized-card'
const SELECTOR_DATA_REMOVE = '[data-lte-toggle="card-remove"]'
const SELECTOR_DATA_COLLAPSE = '[data-lte-toggle="card-collapse"]'
const SELECTOR_DATA_MAXIMIZE = '[data-lte-toggle="card-maximize"]'
const SELECTOR_CARD = `.${CLASS_NAME_CARD}`
const SELECTOR_CARD_BODY = '.card-body'
const SELECTOR_CARD_FOOTER = '.card-footer'
type Config = {
animationSpeed: number;
collapseTrigger: string;
removeTrigger: string;
maximizeTrigger: string;
}
const Default: Config = {
animationSpeed: 500,
collapseTrigger: SELECTOR_DATA_COLLAPSE,
removeTrigger: SELECTOR_DATA_REMOVE,
maximizeTrigger: SELECTOR_DATA_MAXIMIZE
}
class CardWidget extends BaseComponent {
static get NAME(): string {
return NAME
}
static getInstance(element: Element | null | undefined): CardWidget | null {
return this._getInstance(element) as CardWidget | null
}
static getOrCreateInstance(element: HTMLElement, config: Partial<Config> = {}): CardWidget {
return this.getInstance(element) ?? new this(element, config)
}
_parent: HTMLElement | undefined
_config: Config
constructor(element: HTMLElement, config: Partial<Config> = {}) {
super(element)
this._parent = element.closest(SELECTOR_CARD) as HTMLElement | undefined
if (element.classList.contains(CLASS_NAME_CARD)) {
this._parent = element
}
this._config = { ...Default, ...config }
}
collapse() {
if (!this._parent) {
return
}
if (dispatchCustomEvent(this._parent, EVENT_COLLAPSE, { cancelable: true }).defaultPrevented) {
return
}
this._parent.classList.add(CLASS_NAME_COLLAPSING)
this._parent.classList.remove(CLASS_NAME_EXPANDING)
// Only target direct children to avoid affecting nested cards
const elm = this._parent.querySelectorAll(`:scope > ${SELECTOR_CARD_BODY}, :scope > ${SELECTOR_CARD_FOOTER}`)
elm.forEach(el => {
if (el instanceof HTMLElement) {
slideUp(el, this._config.animationSpeed)
}
})
setTimeout(() => {
// Guard against a stale timer: if expand() ran while this collapse
// was still animating, the card must not end up marked collapsed.
if (this._parent?.classList.contains(CLASS_NAME_COLLAPSING)) {
this._parent.classList.add(CLASS_NAME_COLLAPSED)
this._parent.classList.remove(CLASS_NAME_COLLAPSING)
dispatchCustomEvent(this._parent, EVENT_COLLAPSED)
}
}, this._config.animationSpeed)
}
expand() {
if (!this._parent) {
return
}
if (dispatchCustomEvent(this._parent, EVENT_EXPAND, { cancelable: true }).defaultPrevented) {
return
}
this._parent.classList.add(CLASS_NAME_EXPANDING)
this._parent.classList.remove(CLASS_NAME_COLLAPSING, CLASS_NAME_COLLAPSED)
// Only target direct children to avoid affecting nested cards
const elm = this._parent.querySelectorAll(`:scope > ${SELECTOR_CARD_BODY}, :scope > ${SELECTOR_CARD_FOOTER}`)
elm.forEach(el => {
if (el instanceof HTMLElement) {
slideDown(el, this._config.animationSpeed)
}
})
setTimeout(() => {
// Same stale-timer guard as collapse(), in the other direction.
if (this._parent?.classList.contains(CLASS_NAME_EXPANDING)) {
this._parent.classList.remove(CLASS_NAME_EXPANDING)
dispatchCustomEvent(this._parent, EVENT_EXPANDED)
}
}, this._config.animationSpeed)
}
remove() {
if (!this._parent) {
return
}
if (dispatchCustomEvent(this._parent, EVENT_REMOVE, { cancelable: true }).defaultPrevented) {
return
}
const parent = this._parent
slideUp(parent, this._config.animationSpeed)
// Actually remove the card once the animation finishes — hiding it
// would keep its form fields submitting and its ids in the document.
// `removed` is dispatched just before detachment so it can still bubble.
setTimeout(() => {
dispatchCustomEvent(parent, EVENT_REMOVED)
parent.remove()
this.dispose()
}, this._config.animationSpeed)
}
toggle() {
// A card mid-collapse counts as collapsed so a second click during the
// animation reverses it instead of being swallowed.
if (this._parent?.classList.contains(CLASS_NAME_COLLAPSED) || this._parent?.classList.contains(CLASS_NAME_COLLAPSING)) {
this.expand()
return
}
this.collapse()
}
maximize() {
if (this._parent) {
this._parent.style.height = `${this._parent.offsetHeight}px`
this._parent.style.width = `${this._parent.offsetWidth}px`
this._parent.style.transition = 'all .15s'
setTimeout(() => {
const htmlTag = document.querySelector('html')
if (htmlTag) {
htmlTag.classList.add(CLASS_NAME_MAXIMIZED)
}
if (this._parent) {
this._parent.classList.add(CLASS_NAME_MAXIMIZED)
if (this._parent.classList.contains(CLASS_NAME_COLLAPSED)) {
this._parent.classList.add(CLASS_NAME_WAS_COLLAPSED)
}
dispatchCustomEvent(this._parent, EVENT_MAXIMIZED)
}
}, 150)
}
}
minimize() {
if (this._parent) {
this._parent.style.height = 'auto'
this._parent.style.width = 'auto'
this._parent.style.transition = 'all .15s'
setTimeout(() => {
const htmlTag = document.querySelector('html')
if (htmlTag) {
htmlTag.classList.remove(CLASS_NAME_MAXIMIZED)
}
if (this._parent) {
this._parent.classList.remove(CLASS_NAME_MAXIMIZED)
if (this._parent?.classList.contains(CLASS_NAME_WAS_COLLAPSED)) {
this._parent.classList.remove(CLASS_NAME_WAS_COLLAPSED)
}
dispatchCustomEvent(this._parent, EVENT_MINIMIZED)
// Drop the inline sizing/transition once the restore transition has
// finished so the card doesn't carry stale styles forever.
setTimeout(() => {
this._parent?.style.removeProperty('height')
this._parent?.style.removeProperty('width')
this._parent?.style.removeProperty('transition')
}, 150)
}
}, 10)
}
}
toggleMaximize() {
if (this._parent?.classList.contains(CLASS_NAME_MAXIMIZED)) {
this.minimize()
return
}
this.maximize()
}
}
/**
*
* Data Api implementation
* ====================================================
* A single delegated listener on `document` handles all card toggles — so
* cards inserted later (AJAX, Turbo Frames) work without re-initialisation,
* and the listener itself survives Turbo's <body> swaps.
*/
document.addEventListener('click', event => {
const target = event.target
if (!(target instanceof Element)) {
return
}
const collapseTrigger = target.closest(SELECTOR_DATA_COLLAPSE)
const removeTrigger = target.closest(SELECTOR_DATA_REMOVE)
const maximizeTrigger = target.closest(SELECTOR_DATA_MAXIMIZE)
const trigger = collapseTrigger ?? removeTrigger ?? maximizeTrigger
if (!trigger) {
return
}
event.preventDefault()
const card = trigger.closest(SELECTOR_CARD) as HTMLElement | null
if (!card) {
return
}
const widget = CardWidget.getOrCreateInstance(card)
if (collapseTrigger) {
widget.toggle()
} else if (removeTrigger) {
widget.remove()
} else {
widget.toggleMaximize()
}
})
export default CardWidget