0
0
mirror of https://github.com/twbs/bootstrap.git synced 2025-02-24 21:54:24 +01:00
Bootstrap/js/src/collapse.js
GeoSot 6e1c9096f0
Move get element functionality to a helper (#33327)
Looking around on js components I found out many checks, different expressed but with same purpose.
Some of them are trying to parse string to element, others, jQuery element to js simple nodeElement etc

With this Pr, I am trying to give a standard way to parse an element

So this pr:

* Creates `getElement` helper that tries to parse an argument to element or null
* Changes `isElement` to make  explicit checks and return Boolean 
* fixes tests deficiencies
2021-05-13 18:17:20 +03:00

389 lines
9.9 KiB
JavaScript

/**
* --------------------------------------------------------------------------
* Bootstrap (v5.0.0): collapse.js
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import {
defineJQueryPlugin,
getElement,
getSelectorFromElement,
getElementFromSelector,
reflow,
typeCheckConfig
} from './util/index'
import Data from './dom/data'
import EventHandler from './dom/event-handler'
import Manipulator from './dom/manipulator'
import SelectorEngine from './dom/selector-engine'
import BaseComponent from './base-component'
/**
* ------------------------------------------------------------------------
* Constants
* ------------------------------------------------------------------------
*/
const NAME = 'collapse'
const DATA_KEY = 'bs.collapse'
const EVENT_KEY = `.${DATA_KEY}`
const DATA_API_KEY = '.data-api'
const Default = {
toggle: true,
parent: ''
}
const DefaultType = {
toggle: 'boolean',
parent: '(string|element)'
}
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_COLLAPSING = 'collapsing'
const CLASS_NAME_COLLAPSED = 'collapsed'
const WIDTH = 'width'
const HEIGHT = 'height'
const SELECTOR_ACTIVES = '.show, .collapsing'
const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="collapse"]'
/**
* ------------------------------------------------------------------------
* Class Definition
* ------------------------------------------------------------------------
*/
class Collapse extends BaseComponent {
constructor(element, config) {
super(element)
this._isTransitioning = false
this._config = this._getConfig(config)
this._triggerArray = SelectorEngine.find(
`${SELECTOR_DATA_TOGGLE}[href="#${this._element.id}"],` +
`${SELECTOR_DATA_TOGGLE}[data-bs-target="#${this._element.id}"]`
)
const toggleList = SelectorEngine.find(SELECTOR_DATA_TOGGLE)
for (let i = 0, len = toggleList.length; i < len; i++) {
const elem = toggleList[i]
const selector = getSelectorFromElement(elem)
const filterElement = SelectorEngine.find(selector)
.filter(foundElem => foundElem === this._element)
if (selector !== null && filterElement.length) {
this._selector = selector
this._triggerArray.push(elem)
}
}
this._parent = this._config.parent ? this._getParent() : null
if (!this._config.parent) {
this._addAriaAndCollapsedClass(this._element, this._triggerArray)
}
if (this._config.toggle) {
this.toggle()
}
}
// Getters
static get Default() {
return Default
}
static get NAME() {
return NAME
}
// Public
toggle() {
if (this._element.classList.contains(CLASS_NAME_SHOW)) {
this.hide()
} else {
this.show()
}
}
show() {
if (this._isTransitioning || this._element.classList.contains(CLASS_NAME_SHOW)) {
return
}
let actives
let activesData
if (this._parent) {
actives = SelectorEngine.find(SELECTOR_ACTIVES, this._parent)
.filter(elem => {
if (typeof this._config.parent === 'string') {
return elem.getAttribute('data-bs-parent') === this._config.parent
}
return elem.classList.contains(CLASS_NAME_COLLAPSE)
})
if (actives.length === 0) {
actives = null
}
}
const container = SelectorEngine.findOne(this._selector)
if (actives) {
const tempActiveData = actives.find(elem => container !== elem)
activesData = tempActiveData ? Data.get(tempActiveData, DATA_KEY) : null
if (activesData && activesData._isTransitioning) {
return
}
}
const startEvent = EventHandler.trigger(this._element, EVENT_SHOW)
if (startEvent.defaultPrevented) {
return
}
if (actives) {
actives.forEach(elemActive => {
if (container !== elemActive) {
Collapse.collapseInterface(elemActive, 'hide')
}
if (!activesData) {
Data.set(elemActive, DATA_KEY, null)
}
})
}
const dimension = this._getDimension()
this._element.classList.remove(CLASS_NAME_COLLAPSE)
this._element.classList.add(CLASS_NAME_COLLAPSING)
this._element.style[dimension] = 0
if (this._triggerArray.length) {
this._triggerArray.forEach(element => {
element.classList.remove(CLASS_NAME_COLLAPSED)
element.setAttribute('aria-expanded', true)
})
}
this.setTransitioning(true)
const complete = () => {
this._element.classList.remove(CLASS_NAME_COLLAPSING)
this._element.classList.add(CLASS_NAME_COLLAPSE, CLASS_NAME_SHOW)
this._element.style[dimension] = ''
this.setTransitioning(false)
EventHandler.trigger(this._element, EVENT_SHOWN)
}
const capitalizedDimension = dimension[0].toUpperCase() + dimension.slice(1)
const scrollSize = `scroll${capitalizedDimension}`
this._queueCallback(complete, this._element, true)
this._element.style[dimension] = `${this._element[scrollSize]}px`
}
hide() {
if (this._isTransitioning || !this._element.classList.contains(CLASS_NAME_SHOW)) {
return
}
const startEvent = EventHandler.trigger(this._element, EVENT_HIDE)
if (startEvent.defaultPrevented) {
return
}
const dimension = this._getDimension()
this._element.style[dimension] = `${this._element.getBoundingClientRect()[dimension]}px`
reflow(this._element)
this._element.classList.add(CLASS_NAME_COLLAPSING)
this._element.classList.remove(CLASS_NAME_COLLAPSE, CLASS_NAME_SHOW)
const triggerArrayLength = this._triggerArray.length
if (triggerArrayLength > 0) {
for (let i = 0; i < triggerArrayLength; i++) {
const trigger = this._triggerArray[i]
const elem = getElementFromSelector(trigger)
if (elem && !elem.classList.contains(CLASS_NAME_SHOW)) {
trigger.classList.add(CLASS_NAME_COLLAPSED)
trigger.setAttribute('aria-expanded', false)
}
}
}
this.setTransitioning(true)
const complete = () => {
this.setTransitioning(false)
this._element.classList.remove(CLASS_NAME_COLLAPSING)
this._element.classList.add(CLASS_NAME_COLLAPSE)
EventHandler.trigger(this._element, EVENT_HIDDEN)
}
this._element.style[dimension] = ''
this._queueCallback(complete, this._element, true)
}
setTransitioning(isTransitioning) {
this._isTransitioning = isTransitioning
}
// Private
_getConfig(config) {
config = {
...Default,
...config
}
config.toggle = Boolean(config.toggle) // Coerce string values
typeCheckConfig(NAME, config, DefaultType)
return config
}
_getDimension() {
return this._element.classList.contains(WIDTH) ? WIDTH : HEIGHT
}
_getParent() {
let { parent } = this._config
parent = getElement(parent)
const selector = `${SELECTOR_DATA_TOGGLE}[data-bs-parent="${parent}"]`
SelectorEngine.find(selector, parent)
.forEach(element => {
const selected = getElementFromSelector(element)
this._addAriaAndCollapsedClass(
selected,
[element]
)
})
return parent
}
_addAriaAndCollapsedClass(element, triggerArray) {
if (!element || !triggerArray.length) {
return
}
const isOpen = element.classList.contains(CLASS_NAME_SHOW)
triggerArray.forEach(elem => {
if (isOpen) {
elem.classList.remove(CLASS_NAME_COLLAPSED)
} else {
elem.classList.add(CLASS_NAME_COLLAPSED)
}
elem.setAttribute('aria-expanded', isOpen)
})
}
// Static
static collapseInterface(element, config) {
let data = Data.get(element, DATA_KEY)
const _config = {
...Default,
...Manipulator.getDataAttributes(element),
...(typeof config === 'object' && config ? config : {})
}
if (!data && _config.toggle && typeof config === 'string' && /show|hide/.test(config)) {
_config.toggle = false
}
if (!data) {
data = new Collapse(element, _config)
}
if (typeof config === 'string') {
if (typeof data[config] === 'undefined') {
throw new TypeError(`No method named "${config}"`)
}
data[config]()
}
}
static jQueryInterface(config) {
return this.each(function () {
Collapse.collapseInterface(this, config)
})
}
}
/**
* ------------------------------------------------------------------------
* 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.tagName === 'A' || (event.delegateTarget && event.delegateTarget.tagName === 'A')) {
event.preventDefault()
}
const triggerData = Manipulator.getDataAttributes(this)
const selector = getSelectorFromElement(this)
const selectorElements = SelectorEngine.find(selector)
selectorElements.forEach(element => {
const data = Data.get(element, DATA_KEY)
let config
if (data) {
// update parent attribute
if (data._parent === null && typeof triggerData.parent === 'string') {
data._config.parent = triggerData.parent
data._parent = data._getParent()
}
config = 'toggle'
} else {
config = triggerData
}
Collapse.collapseInterface(element, config)
})
})
/**
* ------------------------------------------------------------------------
* jQuery
* ------------------------------------------------------------------------
* add .Collapse to jQuery only if jQuery is present
*/
defineJQueryPlugin(Collapse)
export default Collapse