blob: cf6a8f9af784ddb5522ed47618c6fb65b522468c [file] [edit]
/**
* --------------------------------------------------------------------------
* Bootstrap collapse.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import BaseComponent from './base-component.js'
import EventHandler from './dom/event-handler.js'
import SelectorEngine from './dom/selector-engine.js'
import type { ComponentConfig } from './util/config.js'
import {
getElement,
getTransitionDurationFromElement,
setAriaAttribute
} from './util/index.js'
/**
* Constants
*/
const NAME = 'collapse'
const DATA_KEY = 'bs.collapse'
const EVENT_KEY = `.${DATA_KEY}`
const DATA_API_KEY = '.data-api'
const EVENT_SHOW = `show${EVENT_KEY}`
const EVENT_SHOWN = `shown${EVENT_KEY}`
const EVENT_HIDE = `hide${EVENT_KEY}`
const EVENT_HIDDEN = `hidden${EVENT_KEY}`
const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
const CLASS_NAME_SHOW = 'show'
const CLASS_NAME_COLLAPSE = 'collapse'
const CLASS_NAME_DEEPER_CHILDREN = `:scope .${CLASS_NAME_COLLAPSE} .${CLASS_NAME_COLLAPSE}`
const SELECTOR_ACTIVES = '.collapse.show'
const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="collapse"]'
type CollapseConfig = {
parent: string | Element | null
}
const Default: CollapseConfig = {
parent: null
}
const DefaultType = {
parent: '(null|element)'
}
/**
* Class definition
*/
class Collapse extends BaseComponent {
protected declare _config: CollapseConfig
protected declare _isTransitioning: boolean
protected declare _triggerArray: HTMLElement[]
constructor(element?: string | Element | null, config?: Partial<CollapseConfig> | null) {
super(element, config)
this._isTransitioning = false
this._triggerArray = []
const toggleList = SelectorEngine.find(SELECTOR_DATA_TOGGLE)
for (const elem of toggleList) {
const selector = SelectorEngine.getSelectorFromElement(elem)
const filterElement = SelectorEngine.find(selector!)
.filter(foundElement => foundElement === this._element)
if (selector !== null && filterElement.length) {
this._triggerArray.push(elem)
}
}
this._initializeChildren()
if (!this._config.parent) {
this._setAriaExpanded(this._triggerArray, this._isShown())
}
}
// Getters
static override get Default(): CollapseConfig {
return Default
}
static override get DefaultType(): Record<string, string> {
return DefaultType
}
static override get NAME(): string {
return NAME
}
// Public
toggle(): Promise<void> {
return this._isShown() ? this.hide() : this.show()
}
async show(): Promise<void> {
if (this._isTransitioning || this._isShown()) {
return
}
let activeChildren: Collapse[] = []
// find active children
if (this._config.parent) {
activeChildren = this._getFirstLevelChildren(SELECTOR_ACTIVES)
.filter(element => element !== this._element && !this._sharesTrigger(element))
.map(element => Collapse.getOrCreateInstance(element))
}
if (activeChildren.length && activeChildren[0]._isTransitioning) {
return
}
const startEvent = EventHandler.trigger(this._element, EVENT_SHOW)
if (startEvent.defaultPrevented) {
return
}
for (const activeInstance of activeChildren) {
activeInstance.hide()
}
// The CSS owns the animation: .show is the only state, and the browser
// interpolates the size from 0 to the shown size on its own.
this._element.classList.add(CLASS_NAME_SHOW)
this._setAriaExpanded(this._triggerArray, true)
this._isTransitioning = true
const complete = () => {
this._isTransitioning = false
EventHandler.trigger(this._element, EVENT_SHOWN)
}
await this._queueCallback(complete, this._element, this._isAnimated())
}
async hide(): Promise<void> {
if (this._isTransitioning || !this._isShown()) {
return
}
const startEvent = EventHandler.trigger(this._element, EVENT_HIDE)
if (startEvent.defaultPrevented) {
return
}
this._element.classList.remove(CLASS_NAME_SHOW)
for (const trigger of this._triggerArray) {
const element = SelectorEngine.getElementFromSelector(trigger)
if (element && !this._isShown(element)) {
this._setAriaExpanded([trigger], false)
}
}
this._isTransitioning = true
const complete = () => {
this._isTransitioning = false
EventHandler.trigger(this._element, EVENT_HIDDEN)
}
await this._queueCallback(complete, this._element, this._isAnimated())
}
// Private
protected _isShown(element: HTMLElement = this._element): boolean {
return element.classList.contains(CLASS_NAME_SHOW)
}
// The collapse declares its transition in CSS, so a zero computed duration
// (reduced motion, .transition-none, or transitions disabled) means no wait.
protected _isAnimated(): boolean {
return getTransitionDurationFromElement(this._element) > 0
}
// One trigger can target more than one collapse. Those collapses open
// together, so a shared parent must not close them against each other.
protected _sharesTrigger(element: HTMLElement): boolean {
return this._triggerArray.some(
trigger => SelectorEngine.getMultipleElementsFromSelector(trigger).includes(element)
)
}
protected override _configAfterMerge(config: ComponentConfig): ComponentConfig {
config.parent = getElement(config.parent)
return config
}
protected _initializeChildren(): void {
if (!this._config.parent) {
return
}
const children = this._getFirstLevelChildren(SELECTOR_DATA_TOGGLE)
for (const element of children) {
const selected = SelectorEngine.getElementFromSelector(element)
if (selected) {
this._setAriaExpanded([element], this._isShown(selected))
}
}
}
protected _getFirstLevelChildren(selector: string): HTMLElement[] {
const children = SelectorEngine.find(CLASS_NAME_DEEPER_CHILDREN, this._config.parent as HTMLElement)
// remove children if greater depth
return SelectorEngine.find(selector, this._config.parent as HTMLElement).filter(element => !children.includes(element))
}
protected _setAriaExpanded(triggerArray: HTMLElement[], isOpen: boolean): void {
if (!triggerArray.length) {
return
}
for (const element of triggerArray) {
setAriaAttribute(element, 'aria-expanded', isOpen)
}
}
}
/**
* Data API implementation
*/
EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) {
// preventDefault only for <a> elements (which change the URL) not inside the collapsible element
if ((event.target as Element).tagName === 'A' || (event.delegateTarget && event.delegateTarget.tagName === 'A')) {
event.preventDefault()
}
for (const element of SelectorEngine.getMultipleElementsFromSelector(this)) {
Collapse.getOrCreateInstance(element).toggle()
}
})
export default Collapse
export type { CollapseConfig }