diff --git a/js/src/collapse.ts b/js/src/collapse.ts index cf6a8f9af784..a4def0b2af11 100644 --- a/js/src/collapse.ts +++ b/js/src/collapse.ts @@ -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, @@ -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 } diff --git a/js/src/dom/event-handler.ts b/js/src/dom/event-handler.ts index 8942d69c3404..ceef67016cbd 100644 --- a/js/src/dom/event-handler.ts +++ b/js/src/dom/event-handler.ts @@ -92,7 +92,8 @@ const nativeEvents = new Set([ 'error', 'abort', 'scroll', - 'scrollend' + 'scrollend', + 'hashchange' ]) /** diff --git a/js/src/tab.ts b/js/src/tab.ts index 992f61268e97..2130cb0bd249 100644 --- a/js/src/tab.ts +++ b/js/src/tab.ts @@ -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' @@ -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 diff --git a/js/src/util/hash-target.ts b/js/src/util/hash-target.ts new file mode 100644 index 000000000000..1682ef23bbcc --- /dev/null +++ b/js/src/util/hash-target.ts @@ -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 } diff --git a/js/tests/unit/collapse.spec.js b/js/tests/unit/collapse.spec.js index f277cb966549..85edc6ffbf2f 100644 --- a/js/tests/unit/collapse.spec.js +++ b/js/tests/unit/collapse.spec.js @@ -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 @@ -11,6 +21,7 @@ describe('Collapse', () => { afterEach(() => { clearFixture() + clearHash() }) describe('VERSION', () => { @@ -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 = [ + 'Toggle', + '
' + ].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 = [ + 'Toggle', + '' + ].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 = '' + + 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 = '' + + 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() + }) + }) }) diff --git a/js/tests/unit/tab.spec.js b/js/tests/unit/tab.spec.js index dae8dd209f7d..06a33148e919 100644 --- a/js/tests/unit/tab.spec.js +++ b/js/tests/unit/tab.spec.js @@ -3,6 +3,16 @@ 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('Tab', () => { let fixtureEl @@ -12,6 +22,7 @@ describe('Tab', () => { afterEach(() => { clearFixture() + clearHash() }) describe('VERSION', () => { @@ -1244,4 +1255,107 @@ describe('Tab', () => { }) }) }) + + describe('hash target', () => { + it('should scroll the trigger, show the pane, then scroll the pane on load', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '', + '