1 | /**
|
---|
2 | * --------------------------------------------------------------------------
|
---|
3 | * Bootstrap tab.js
|
---|
4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
---|
5 | * --------------------------------------------------------------------------
|
---|
6 | */
|
---|
7 |
|
---|
8 | import BaseComponent from './base-component.js'
|
---|
9 | import EventHandler from './dom/event-handler.js'
|
---|
10 | import SelectorEngine from './dom/selector-engine.js'
|
---|
11 | import { defineJQueryPlugin, getNextActiveElement, isDisabled } from './util/index.js'
|
---|
12 |
|
---|
13 | /**
|
---|
14 | * Constants
|
---|
15 | */
|
---|
16 |
|
---|
17 | const NAME = 'tab'
|
---|
18 | const DATA_KEY = 'bs.tab'
|
---|
19 | const EVENT_KEY = `.${DATA_KEY}`
|
---|
20 |
|
---|
21 | const EVENT_HIDE = `hide${EVENT_KEY}`
|
---|
22 | const EVENT_HIDDEN = `hidden${EVENT_KEY}`
|
---|
23 | const EVENT_SHOW = `show${EVENT_KEY}`
|
---|
24 | const EVENT_SHOWN = `shown${EVENT_KEY}`
|
---|
25 | const EVENT_CLICK_DATA_API = `click${EVENT_KEY}`
|
---|
26 | const EVENT_KEYDOWN = `keydown${EVENT_KEY}`
|
---|
27 | const EVENT_LOAD_DATA_API = `load${EVENT_KEY}`
|
---|
28 |
|
---|
29 | const ARROW_LEFT_KEY = 'ArrowLeft'
|
---|
30 | const ARROW_RIGHT_KEY = 'ArrowRight'
|
---|
31 | const ARROW_UP_KEY = 'ArrowUp'
|
---|
32 | const ARROW_DOWN_KEY = 'ArrowDown'
|
---|
33 | const HOME_KEY = 'Home'
|
---|
34 | const END_KEY = 'End'
|
---|
35 |
|
---|
36 | const CLASS_NAME_ACTIVE = 'active'
|
---|
37 | const CLASS_NAME_FADE = 'fade'
|
---|
38 | const CLASS_NAME_SHOW = 'show'
|
---|
39 | const CLASS_DROPDOWN = 'dropdown'
|
---|
40 |
|
---|
41 | const SELECTOR_DROPDOWN_TOGGLE = '.dropdown-toggle'
|
---|
42 | const SELECTOR_DROPDOWN_MENU = '.dropdown-menu'
|
---|
43 | const NOT_SELECTOR_DROPDOWN_TOGGLE = `:not(${SELECTOR_DROPDOWN_TOGGLE})`
|
---|
44 |
|
---|
45 | const SELECTOR_TAB_PANEL = '.list-group, .nav, [role="tablist"]'
|
---|
46 | const SELECTOR_OUTER = '.nav-item, .list-group-item'
|
---|
47 | const SELECTOR_INNER = `.nav-link${NOT_SELECTOR_DROPDOWN_TOGGLE}, .list-group-item${NOT_SELECTOR_DROPDOWN_TOGGLE}, [role="tab"]${NOT_SELECTOR_DROPDOWN_TOGGLE}`
|
---|
48 | const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]' // TODO: could only be `tab` in v6
|
---|
49 | const SELECTOR_INNER_ELEM = `${SELECTOR_INNER}, ${SELECTOR_DATA_TOGGLE}`
|
---|
50 |
|
---|
51 | const SELECTOR_DATA_TOGGLE_ACTIVE = `.${CLASS_NAME_ACTIVE}[data-bs-toggle="tab"], .${CLASS_NAME_ACTIVE}[data-bs-toggle="pill"], .${CLASS_NAME_ACTIVE}[data-bs-toggle="list"]`
|
---|
52 |
|
---|
53 | /**
|
---|
54 | * Class definition
|
---|
55 | */
|
---|
56 |
|
---|
57 | class Tab extends BaseComponent {
|
---|
58 | constructor(element) {
|
---|
59 | super(element)
|
---|
60 | this._parent = this._element.closest(SELECTOR_TAB_PANEL)
|
---|
61 |
|
---|
62 | if (!this._parent) {
|
---|
63 | return
|
---|
64 | // TODO: should throw exception in v6
|
---|
65 | // throw new TypeError(`${element.outerHTML} has not a valid parent ${SELECTOR_INNER_ELEM}`)
|
---|
66 | }
|
---|
67 |
|
---|
68 | // Set up initial aria attributes
|
---|
69 | this._setInitialAttributes(this._parent, this._getChildren())
|
---|
70 |
|
---|
71 | EventHandler.on(this._element, EVENT_KEYDOWN, event => this._keydown(event))
|
---|
72 | }
|
---|
73 |
|
---|
74 | // Getters
|
---|
75 | static get NAME() {
|
---|
76 | return NAME
|
---|
77 | }
|
---|
78 |
|
---|
79 | // Public
|
---|
80 | show() { // Shows this elem and deactivate the active sibling if exists
|
---|
81 | const innerElem = this._element
|
---|
82 | if (this._elemIsActive(innerElem)) {
|
---|
83 | return
|
---|
84 | }
|
---|
85 |
|
---|
86 | // Search for active tab on same parent to deactivate it
|
---|
87 | const active = this._getActiveElem()
|
---|
88 |
|
---|
89 | const hideEvent = active ?
|
---|
90 | EventHandler.trigger(active, EVENT_HIDE, { relatedTarget: innerElem }) :
|
---|
91 | null
|
---|
92 |
|
---|
93 | const showEvent = EventHandler.trigger(innerElem, EVENT_SHOW, { relatedTarget: active })
|
---|
94 |
|
---|
95 | if (showEvent.defaultPrevented || (hideEvent && hideEvent.defaultPrevented)) {
|
---|
96 | return
|
---|
97 | }
|
---|
98 |
|
---|
99 | this._deactivate(active, innerElem)
|
---|
100 | this._activate(innerElem, active)
|
---|
101 | }
|
---|
102 |
|
---|
103 | // Private
|
---|
104 | _activate(element, relatedElem) {
|
---|
105 | if (!element) {
|
---|
106 | return
|
---|
107 | }
|
---|
108 |
|
---|
109 | element.classList.add(CLASS_NAME_ACTIVE)
|
---|
110 |
|
---|
111 | this._activate(SelectorEngine.getElementFromSelector(element)) // Search and activate/show the proper section
|
---|
112 |
|
---|
113 | const complete = () => {
|
---|
114 | if (element.getAttribute('role') !== 'tab') {
|
---|
115 | element.classList.add(CLASS_NAME_SHOW)
|
---|
116 | return
|
---|
117 | }
|
---|
118 |
|
---|
119 | element.removeAttribute('tabindex')
|
---|
120 | element.setAttribute('aria-selected', true)
|
---|
121 | this._toggleDropDown(element, true)
|
---|
122 | EventHandler.trigger(element, EVENT_SHOWN, {
|
---|
123 | relatedTarget: relatedElem
|
---|
124 | })
|
---|
125 | }
|
---|
126 |
|
---|
127 | this._queueCallback(complete, element, element.classList.contains(CLASS_NAME_FADE))
|
---|
128 | }
|
---|
129 |
|
---|
130 | _deactivate(element, relatedElem) {
|
---|
131 | if (!element) {
|
---|
132 | return
|
---|
133 | }
|
---|
134 |
|
---|
135 | element.classList.remove(CLASS_NAME_ACTIVE)
|
---|
136 | element.blur()
|
---|
137 |
|
---|
138 | this._deactivate(SelectorEngine.getElementFromSelector(element)) // Search and deactivate the shown section too
|
---|
139 |
|
---|
140 | const complete = () => {
|
---|
141 | if (element.getAttribute('role') !== 'tab') {
|
---|
142 | element.classList.remove(CLASS_NAME_SHOW)
|
---|
143 | return
|
---|
144 | }
|
---|
145 |
|
---|
146 | element.setAttribute('aria-selected', false)
|
---|
147 | element.setAttribute('tabindex', '-1')
|
---|
148 | this._toggleDropDown(element, false)
|
---|
149 | EventHandler.trigger(element, EVENT_HIDDEN, { relatedTarget: relatedElem })
|
---|
150 | }
|
---|
151 |
|
---|
152 | this._queueCallback(complete, element, element.classList.contains(CLASS_NAME_FADE))
|
---|
153 | }
|
---|
154 |
|
---|
155 | _keydown(event) {
|
---|
156 | if (!([ARROW_LEFT_KEY, ARROW_RIGHT_KEY, ARROW_UP_KEY, ARROW_DOWN_KEY, HOME_KEY, END_KEY].includes(event.key))) {
|
---|
157 | return
|
---|
158 | }
|
---|
159 |
|
---|
160 | event.stopPropagation()// stopPropagation/preventDefault both added to support up/down keys without scrolling the page
|
---|
161 | event.preventDefault()
|
---|
162 |
|
---|
163 | const children = this._getChildren().filter(element => !isDisabled(element))
|
---|
164 | let nextActiveElement
|
---|
165 |
|
---|
166 | if ([HOME_KEY, END_KEY].includes(event.key)) {
|
---|
167 | nextActiveElement = children[event.key === HOME_KEY ? 0 : children.length - 1]
|
---|
168 | } else {
|
---|
169 | const isNext = [ARROW_RIGHT_KEY, ARROW_DOWN_KEY].includes(event.key)
|
---|
170 | nextActiveElement = getNextActiveElement(children, event.target, isNext, true)
|
---|
171 | }
|
---|
172 |
|
---|
173 | if (nextActiveElement) {
|
---|
174 | nextActiveElement.focus({ preventScroll: true })
|
---|
175 | Tab.getOrCreateInstance(nextActiveElement).show()
|
---|
176 | }
|
---|
177 | }
|
---|
178 |
|
---|
179 | _getChildren() { // collection of inner elements
|
---|
180 | return SelectorEngine.find(SELECTOR_INNER_ELEM, this._parent)
|
---|
181 | }
|
---|
182 |
|
---|
183 | _getActiveElem() {
|
---|
184 | return this._getChildren().find(child => this._elemIsActive(child)) || null
|
---|
185 | }
|
---|
186 |
|
---|
187 | _setInitialAttributes(parent, children) {
|
---|
188 | this._setAttributeIfNotExists(parent, 'role', 'tablist')
|
---|
189 |
|
---|
190 | for (const child of children) {
|
---|
191 | this._setInitialAttributesOnChild(child)
|
---|
192 | }
|
---|
193 | }
|
---|
194 |
|
---|
195 | _setInitialAttributesOnChild(child) {
|
---|
196 | child = this._getInnerElement(child)
|
---|
197 | const isActive = this._elemIsActive(child)
|
---|
198 | const outerElem = this._getOuterElement(child)
|
---|
199 | child.setAttribute('aria-selected', isActive)
|
---|
200 |
|
---|
201 | if (outerElem !== child) {
|
---|
202 | this._setAttributeIfNotExists(outerElem, 'role', 'presentation')
|
---|
203 | }
|
---|
204 |
|
---|
205 | if (!isActive) {
|
---|
206 | child.setAttribute('tabindex', '-1')
|
---|
207 | }
|
---|
208 |
|
---|
209 | this._setAttributeIfNotExists(child, 'role', 'tab')
|
---|
210 |
|
---|
211 | // set attributes to the related panel too
|
---|
212 | this._setInitialAttributesOnTargetPanel(child)
|
---|
213 | }
|
---|
214 |
|
---|
215 | _setInitialAttributesOnTargetPanel(child) {
|
---|
216 | const target = SelectorEngine.getElementFromSelector(child)
|
---|
217 |
|
---|
218 | if (!target) {
|
---|
219 | return
|
---|
220 | }
|
---|
221 |
|
---|
222 | this._setAttributeIfNotExists(target, 'role', 'tabpanel')
|
---|
223 |
|
---|
224 | if (child.id) {
|
---|
225 | this._setAttributeIfNotExists(target, 'aria-labelledby', `${child.id}`)
|
---|
226 | }
|
---|
227 | }
|
---|
228 |
|
---|
229 | _toggleDropDown(element, open) {
|
---|
230 | const outerElem = this._getOuterElement(element)
|
---|
231 | if (!outerElem.classList.contains(CLASS_DROPDOWN)) {
|
---|
232 | return
|
---|
233 | }
|
---|
234 |
|
---|
235 | const toggle = (selector, className) => {
|
---|
236 | const element = SelectorEngine.findOne(selector, outerElem)
|
---|
237 | if (element) {
|
---|
238 | element.classList.toggle(className, open)
|
---|
239 | }
|
---|
240 | }
|
---|
241 |
|
---|
242 | toggle(SELECTOR_DROPDOWN_TOGGLE, CLASS_NAME_ACTIVE)
|
---|
243 | toggle(SELECTOR_DROPDOWN_MENU, CLASS_NAME_SHOW)
|
---|
244 | outerElem.setAttribute('aria-expanded', open)
|
---|
245 | }
|
---|
246 |
|
---|
247 | _setAttributeIfNotExists(element, attribute, value) {
|
---|
248 | if (!element.hasAttribute(attribute)) {
|
---|
249 | element.setAttribute(attribute, value)
|
---|
250 | }
|
---|
251 | }
|
---|
252 |
|
---|
253 | _elemIsActive(elem) {
|
---|
254 | return elem.classList.contains(CLASS_NAME_ACTIVE)
|
---|
255 | }
|
---|
256 |
|
---|
257 | // Try to get the inner element (usually the .nav-link)
|
---|
258 | _getInnerElement(elem) {
|
---|
259 | return elem.matches(SELECTOR_INNER_ELEM) ? elem : SelectorEngine.findOne(SELECTOR_INNER_ELEM, elem)
|
---|
260 | }
|
---|
261 |
|
---|
262 | // Try to get the outer element (usually the .nav-item)
|
---|
263 | _getOuterElement(elem) {
|
---|
264 | return elem.closest(SELECTOR_OUTER) || elem
|
---|
265 | }
|
---|
266 |
|
---|
267 | // Static
|
---|
268 | static jQueryInterface(config) {
|
---|
269 | return this.each(function () {
|
---|
270 | const data = Tab.getOrCreateInstance(this)
|
---|
271 |
|
---|
272 | if (typeof config !== 'string') {
|
---|
273 | return
|
---|
274 | }
|
---|
275 |
|
---|
276 | if (data[config] === undefined || config.startsWith('_') || config === 'constructor') {
|
---|
277 | throw new TypeError(`No method named "${config}"`)
|
---|
278 | }
|
---|
279 |
|
---|
280 | data[config]()
|
---|
281 | })
|
---|
282 | }
|
---|
283 | }
|
---|
284 |
|
---|
285 | /**
|
---|
286 | * Data API implementation
|
---|
287 | */
|
---|
288 |
|
---|
289 | EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) {
|
---|
290 | if (['A', 'AREA'].includes(this.tagName)) {
|
---|
291 | event.preventDefault()
|
---|
292 | }
|
---|
293 |
|
---|
294 | if (isDisabled(this)) {
|
---|
295 | return
|
---|
296 | }
|
---|
297 |
|
---|
298 | Tab.getOrCreateInstance(this).show()
|
---|
299 | })
|
---|
300 |
|
---|
301 | /**
|
---|
302 | * Initialize on focus
|
---|
303 | */
|
---|
304 | EventHandler.on(window, EVENT_LOAD_DATA_API, () => {
|
---|
305 | for (const element of SelectorEngine.find(SELECTOR_DATA_TOGGLE_ACTIVE)) {
|
---|
306 | Tab.getOrCreateInstance(element)
|
---|
307 | }
|
---|
308 | })
|
---|
309 | /**
|
---|
310 | * jQuery
|
---|
311 | */
|
---|
312 |
|
---|
313 | defineJQueryPlugin(Tab)
|
---|
314 |
|
---|
315 | export default Tab
|
---|