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 = [ + '', + '
', + '
', + '
', + '
' + ].join('') + + const trigger = fixtureEl.querySelector('#triggerProfileHash') + const pane = fixtureEl.querySelector('#profileHash') + const triggerScrollSpy = spyOn(trigger, 'scrollIntoView') + + spyOn(pane, 'scrollIntoView').and.callFake(() => { + expect(triggerScrollSpy).toHaveBeenCalled() + expect(trigger).toHaveClass('active') + expect(pane).toHaveClass('active') + resolve() + }) + + setHash('profileHash') + window.dispatchEvent(createEvent('load')) + }) + }) + + it('should scroll the trigger, show the pane, then scroll the pane on hashchange', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '', + '
', + '
', + '
', + '
' + ].join('') + + const trigger = fixtureEl.querySelector('#triggerProfileHashChange') + const pane = fixtureEl.querySelector('#profileHashChange') + const triggerScrollSpy = spyOn(trigger, 'scrollIntoView') + + spyOn(pane, 'scrollIntoView').and.callFake(() => { + expect(triggerScrollSpy).toHaveBeenCalled() + expect(trigger).toHaveClass('active') + expect(pane).toHaveClass('active') + resolve() + }) + + setHash('profileHashChange') + window.dispatchEvent(createEvent('hashchange')) + }) + }) + + it('should scroll an already-active hash tab pane into view', () => { + fixtureEl.innerHTML = [ + '', + '
', + '
', + '
' + ].join('') + + const pane = fixtureEl.querySelector('#homeHashOpen') + const scrollSpy = spyOn(pane, 'scrollIntoView') + const showSpy = spyOn(Tab.prototype, 'show').and.callThrough() + + setHash('homeHashOpen') + window.dispatchEvent(createEvent('load')) + + expect(showSpy).not.toHaveBeenCalled() + expect(scrollSpy).toHaveBeenCalled() + }) + + it('should ignore tab panes without data-bs-hash', () => { + fixtureEl.innerHTML = [ + '', + '
', + '
', + '
', + '
' + ].join('') + + const trigger = fixtureEl.querySelector('#triggerProfileNoHash') + const showSpy = spyOn(Tab.prototype, 'show').and.callThrough() + + setHash('profileNoHash') + window.dispatchEvent(createEvent('load')) + + expect(trigger).not.toHaveClass('active') + expect(showSpy).not.toHaveBeenCalled() + }) + }) }) diff --git a/site/src/content/docs/components/collapse.mdx b/site/src/content/docs/components/collapse.mdx index 8417c9ad2129..9c362eb55c60 100644 --- a/site/src/content/docs/components/collapse.mdx +++ b/site/src/content/docs/components/collapse.mdx @@ -85,6 +85,22 @@ Conversely, multiple ` +

+
+
+

Some placeholder content for a hash-linked collapse. Visiting #collapseHashExample opens this panel and scrolls it into view.

+
+
`} /> + ## Accessibility Be sure to add `aria-expanded` to the control element. This attribute explicitly conveys the current state of the collapsible element tied to the control to screen readers and similar assistive technologies. If the collapsible element is closed by default, the attribute on the control element should have a value of `aria-expanded="false"`. If you’ve set the collapsible element to be open by default using the `show` class, set `aria-expanded="true"` on the control instead. The plugin will automatically toggle this attribute on the control based on whether or not the collapsible element has been opened or closed (via JavaScript, or because the user triggered another control element also tied to the same collapsible element). If the control element’s HTML element is not a button (e.g., an `` or `
`), the attribute `role="button"` should be added to the element. @@ -128,6 +144,7 @@ To add accordion-like group management to a collapsible area, add the data attri | `data-bs-toggle="collapse"` | Initializes the collapse plugin on the trigger element. | | `data-bs-target` | CSS selector for the element(s) to expand and collapse. | | `data-bs-parent` | When set on the collapsible element, closes sibling collapses under the same parent (accordion behavior). | +| `data-bs-hash` | When set on the collapsible target, opens that collapse when the URL fragment matches its `id` (on load and `hashchange`). See [Hash links](#hash-links). | ### Via JavaScript diff --git a/site/src/content/docs/components/tab.mdx b/site/src/content/docs/components/tab.mdx index 96be7764c352..c5609b9e3c7c 100644 --- a/site/src/content/docs/components/tab.mdx +++ b/site/src/content/docs/components/tab.mdx @@ -162,6 +162,38 @@ The tab plugin also works with [list groups]([[docsref:/components/list-group]])
`} /> +## Hash links + +Add `data-bs-hash` to a tab pane to show it when the URL fragment matches its `id`. Use a normal in-page link to try it. You may need to use `scroll-margin-block-start` for some viewport offsetting if using a stickied or fixed navbar. + + +
#hash-home + #hash-profile + #hash-contact +

+ +
+
+

This is some placeholder content the Home tab’s associated content.

+
+
+

This is some placeholder content the Profile tab’s associated content.

+
+
+

This is some placeholder content the Contact tab’s associated content.

+
+
`} /> + ## Accessibility Dynamic tabbed interfaces, as described in the [ARIA Authoring Practices Guide tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabpanel/), require `role="tablist"`, `role="tab"`, `role="tabpanel"`, and additional `aria-` attributes in order to convey their structure, functionality, and current state to users of assistive technologies (such as screen readers). As a best practice, we recommend using `