Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions js/src/collapse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ 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 { enableHashTarget } from './util/hash-target.js'
import {
getElement,
getTransitionDurationFromElement,
Expand Down Expand Up @@ -245,5 +246,36 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (
}
})

/**
* Find a trigger that targets this collapse.
*/
const getCollapseTrigger = (collapseEl: HTMLElement): HTMLElement | null => {
for (const trigger of SelectorEngine.find(SELECTOR_DATA_TOGGLE)) {
if (SelectorEngine.getMultipleElementsFromSelector(trigger).includes(collapseEl)) {
return trigger
}
}

return null
}

/**
* Open opted-in collapses when the URL fragment matches their id.
* Put `data-bs-hash` on the collapsible target.
*/
enableHashTarget(`${EVENT_KEY}${DATA_API_KEY}`, {
matches: element => element.classList.contains(CLASS_NAME_COLLAPSE),
getAnchor: getCollapseTrigger,
open(element, done) {
if (element.classList.contains(CLASS_NAME_SHOW)) {
done()
return
}

EventHandler.one(element, EVENT_SHOWN, done)
Collapse.getOrCreateInstance(element).show()
}
})

export default Collapse
export type { CollapseConfig }
3 changes: 2 additions & 1 deletion js/src/dom/event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ const nativeEvents = new Set([
'error',
'abort',
'scroll',
'scrollend'
'scrollend',
'hashchange'
])

/**
Expand Down
47 changes: 47 additions & 0 deletions js/src/tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import BaseComponent from './base-component.js'
import EventHandler, { type BootstrapEvent } from './dom/event-handler.js'
import SelectorEngine from './dom/selector-engine.js'
import { enableHashTarget } from './util/hash-target.js'
import {
getNextActiveElement, getTransitionDurationFromElement, isDisabled, setAriaAttribute
} from './util/index.js'
Expand Down Expand Up @@ -302,4 +303,50 @@ EventHandler.on(window, EVENT_LOAD_DATA_API, () => {
}
})

/**
* Find the tab trigger that targets a pane.
*/
const getTabTriggerFromPane = (pane: HTMLElement): HTMLElement | null => {
const labelledBy = pane.getAttribute('aria-labelledby')
if (labelledBy) {
const byLabel = document.getElementById(labelledBy)
if (byLabel?.matches(SELECTOR_DATA_TOGGLE)) {
return byLabel
}
}

const { id } = pane
if (!id) {
return null
}

const escapedId = CSS.escape(id)
return SelectorEngine.findOne(
`${SELECTOR_DATA_TOGGLE}[data-bs-target="#${escapedId}"], ${SELECTOR_DATA_TOGGLE}[href="#${escapedId}"]`
)
}

/**
* Open opted-in tab panes when the URL fragment matches their id.
* Put `data-bs-hash` on the tab pane.
*/
enableHashTarget(`${EVENT_KEY}.data-api`, {
matches: element => Boolean(getTabTriggerFromPane(element)),
getAnchor: getTabTriggerFromPane,
open(element, done) {
const trigger = getTabTriggerFromPane(element)
if (!trigger) {
return
}

if (trigger.classList.contains(CLASS_NAME_ACTIVE)) {
done()
return
}

EventHandler.one(trigger, EVENT_SHOWN, done)
Tab.getOrCreateInstance(trigger).show()
}
})

export default Tab
99 changes: 99 additions & 0 deletions js/src/util/hash-target.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* --------------------------------------------------------------------------
* Bootstrap util/hash-target.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/

import EventHandler from '../dom/event-handler.js'

/**
* Constants
*/

const ATTR_HASH = 'data-bs-hash'

type HashTargetHandler = {
/** Return true when this component should open the hashed element. */
matches: (element: HTMLElement) => boolean
/**
* Optional visible anchor to scroll before opening (usually the trigger).
* Closed collapses and inactive tab panes use `display: none`, so they cannot
* be scrolled to until after they open.
*/
getAnchor?: (element: HTMLElement) => HTMLElement | null
/**
* Open the target. Call `done` after the open animation finishes,
* or immediately when the target is already open.
*/
open: (element: HTMLElement, done: () => void) => void
}

/**
* Decode a URL fragment id, tolerating malformed escapes (returns it as-is).
*/
const decodeFragment = (value: string): string => {
try {
return decodeURIComponent(value)
} catch {
return value
}
}

/**
* Resolve the element named by `location.hash` when it opts in with `data-bs-hash`.
*/
const getHashTarget = (): HTMLElement | null => {
const { hash } = window.location
if (!hash || hash === '#') {
return null
}

const id = decodeFragment(hash.slice(1))
if (!id) {
return null
}

const element = document.getElementById(id)
if (!element?.hasAttribute(ATTR_HASH)) {
return null
}

return element
}

/**
* Open an opted-in hash target on `load` and `hashchange`.
* Put `data-bs-hash` on the collapse or tab pane that the URL fragment names.
*
* Scrolls a visible anchor first when one exists, then opens the target, then
* scrolls the target into view after it is shown.
*/
const enableHashTarget = (namespace: string, handler: HashTargetHandler): void => {
const onHash = (): void => {
const element = getHashTarget()
if (!element || !handler.matches(element)) {
return
}

const anchor = handler.getAnchor?.(element)
if (anchor) {
anchor.scrollIntoView()
}

handler.open(element, () => {
element.scrollIntoView()
})
}

EventHandler.on(window, `load${namespace}`, onHash)
EventHandler.on(window, `hashchange${namespace}`, onHash)
}

export {
ATTR_HASH,
decodeFragment,
enableHashTarget,
getHashTarget
}
export type { HashTargetHandler }
86 changes: 85 additions & 1 deletion js/tests/unit/collapse.spec.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import Collapse from '../../src/collapse.js'
import EventHandler from '../../src/dom/event-handler.js'
import { clearFixture, getFixture } from '../helpers/fixture.js'
import { clearFixture, createEvent, getFixture } from '../helpers/fixture.js'

const setHash = hash => {
const url = new URL(window.location.href)
url.hash = hash
window.history.replaceState(null, '', url)
}

const clearHash = () => {
setHash('')
}

describe('Collapse', () => {
let fixtureEl
Expand All @@ -11,6 +21,7 @@ describe('Collapse', () => {

afterEach(() => {
clearFixture()
clearHash()
})

describe('VERSION', () => {
Expand Down Expand Up @@ -955,4 +966,77 @@ describe('Collapse', () => {
expect(collapse2._config.parent).toEqual(fixtureEl)
})
})

describe('hash target', () => {
it('should scroll the trigger, show the collapse, then scroll the target on load', () => {
return new Promise(resolve => {
fixtureEl.innerHTML = [
'<a id="collapseHashTrigger" href="#collapseHash" data-bs-toggle="collapse">Toggle</a>',
'<div id="collapseHash" class="collapse" data-bs-hash></div>'
].join('')

const triggerEl = fixtureEl.querySelector('#collapseHashTrigger')
const collapseEl = fixtureEl.querySelector('#collapseHash')
const triggerScrollSpy = spyOn(triggerEl, 'scrollIntoView')

spyOn(collapseEl, 'scrollIntoView').and.callFake(() => {
expect(triggerScrollSpy).toHaveBeenCalled()
expect(collapseEl).toHaveClass('show')
resolve()
})

setHash('collapseHash')
window.dispatchEvent(createEvent('load'))
})
})

it('should scroll the trigger, show the collapse, then scroll the target on hashchange', () => {
return new Promise(resolve => {
fixtureEl.innerHTML = [
'<a id="collapseHashChangeTrigger" href="#collapseHashChange" data-bs-toggle="collapse">Toggle</a>',
'<div id="collapseHashChange" class="collapse" data-bs-hash></div>'
].join('')

const triggerEl = fixtureEl.querySelector('#collapseHashChangeTrigger')
const collapseEl = fixtureEl.querySelector('#collapseHashChange')
const triggerScrollSpy = spyOn(triggerEl, 'scrollIntoView')

spyOn(collapseEl, 'scrollIntoView').and.callFake(() => {
expect(triggerScrollSpy).toHaveBeenCalled()
expect(collapseEl).toHaveClass('show')
resolve()
})

setHash('collapseHashChange')
window.dispatchEvent(createEvent('hashchange'))
})
})

it('should scroll an already-open hash target into view', () => {
fixtureEl.innerHTML = '<div id="collapseHashOpen" class="collapse show" data-bs-hash></div>'

const collapseEl = fixtureEl.querySelector('#collapseHashOpen')
const scrollSpy = spyOn(collapseEl, 'scrollIntoView')
const showSpy = spyOn(Collapse.prototype, 'show').and.callThrough()

setHash('collapseHashOpen')
window.dispatchEvent(createEvent('load'))

expect(showSpy).not.toHaveBeenCalled()
expect(scrollSpy).toHaveBeenCalled()
})

it('should ignore hash targets without data-bs-hash', () => {
fixtureEl.innerHTML = '<div id="collapseNoHash" class="collapse"></div>'

const collapseEl = fixtureEl.querySelector('#collapseNoHash')
const showSpy = spyOn(Collapse.prototype, 'show').and.callThrough()

setHash('collapseNoHash')
window.dispatchEvent(createEvent('load'))

expect(collapseEl).not.toHaveClass('show')
expect(showSpy).not.toHaveBeenCalled()
})
})
})
Loading
Loading