diff --git a/packages/craftcms-ui/src/components/tabs/tabs.test.ts b/packages/craftcms-ui/src/components/tabs/tabs.test.ts index 2227e0d7367..54b3253eb66 100644 --- a/packages/craftcms-ui/src/components/tabs/tabs.test.ts +++ b/packages/craftcms-ui/src/components/tabs/tabs.test.ts @@ -82,6 +82,9 @@ function declarations(fragment: string): string { beforeEach(() => { document.body.innerHTML = ''; + // Tabs sync the hash by default, so one test's selection would pick the + // starting tab for the next. + window.history.replaceState(null, '', window.location.pathname); }); describe('structure', () => { @@ -632,6 +635,85 @@ describe('external-panel mode', () => { * skips a selection it can't find in it — needs a `slotchange` happy-dom never * fires, so it's covered by the IconToolbar story. */ +describe('location hash', () => { + const setHash = (hash: string) => { + window.history.replaceState(null, '', `#${hash}`); + }; + + it('selects the panel the hash names on load', async () => { + setHash('panel-2'); + + const {element} = await createExternalTabs(); + + expect(element.selectedIndex).toBe(2); + }); + + it('writes the selected panel’s id to the hash', async () => { + const {element} = await createExternalTabs(); + + element.selectedIndex = 1; + await element.updateComplete; + + expect(window.location.hash).toBe('#panel-1'); + }); + + it('syncs without being asked to', async () => { + const {element} = await createExternalTabs(); + + element.selectedIndex = 1; + await element.updateComplete; + + expect(window.location.hash).toBe('#panel-1'); + }); + + it('leaves the hash alone when turned off', async () => { + const {element} = await createExternalTabs({ + attrs: {'sync-location-hash': 'false'}, + }); + + element.selectedIndex = 1; + await element.updateComplete; + + expect(window.location.hash).toBe(''); + }); + + it('leaves the hash alone for a slotted strip, which has no panel ids', async () => { + const element = document.createElement('craft-tabs') as CraftTabs; + for (let i = 0; i < 2; i++) { + const tab = document.createElement('craft-tab') as CraftTab; + tab.slot = 'tab'; + tab.textContent = `Tab ${i}`; + element.append(tab); + } + document.body.append(element); + await element.updateComplete; + + element.selectedIndex = 1; + await element.updateComplete; + + expect(window.location.hash).toBe(''); + }); + + it('ignores a hash that names no panel', async () => { + setHash('nothing-here'); + + const {element} = await createExternalTabs(); + + expect(element.selectedIndex).toBe(0); + expect(window.location.hash).toBe('#nothing-here'); + }); + + it('follows the hash when it changes', async () => { + const {element} = await createExternalTabs(); + + setHash('panel-2'); + window.dispatchEvent(new HashChangeEvent('hashchange')); + await element.updateComplete; + + expect(element.selectedIndex).toBe(2); + }); +}); + describe('collapsible', () => { /** Escape, as it arrives from a focused tab. */ function escape(tab: CraftTab) { diff --git a/packages/craftcms-ui/src/components/tabs/tabs.ts b/packages/craftcms-ui/src/components/tabs/tabs.ts index 08a9bf08e21..02cfc64f937 100644 --- a/packages/craftcms-ui/src/components/tabs/tabs.ts +++ b/packages/craftcms-ui/src/components/tabs/tabs.ts @@ -248,6 +248,23 @@ export default class CraftTabs extends LionTabs { * Only the block placements divide a width; an inline strip runs down the * block axis, where its tabs already span it, so this does nothing there. */ + /** + * Whether the selected tab is mirrored in `location.hash`, on by default. + * + * Only a tab naming a panel through `controls` has a hash: that id is the + * page's, so it means something in a URL and survives a reload. A slotted + * strip's ids are generated per render, so there is nothing to link to and + * nothing is written. + * + * Set `sync-location-hash="false"` where the strip isn't the page's own — a + * dialog, or a slideout over a page whose URL belongs to what's behind it. + */ + @property({ + attribute: 'sync-location-hash', + converter: {fromAttribute: (value: string | null) => value !== 'false'}, + }) + syncLocationHash = true; + @property({type: Boolean, reflect: true, attribute: 'equal-width'}) equalWidth = false; @@ -395,6 +412,11 @@ export default class CraftTabs extends LionTabs { this.addEventListener('click', this.#handleClick, true); this.addEventListener('keydown', this.#handleEscape); + if (this.syncLocationHash) { + this.#selectFromHash(); + window.addEventListener('hashchange', this.#selectFromHash); + } + // Overflow is independent of the mode: the strip is the same either way. tabSlot?.addEventListener('slotchange', this.#queueMeasure); this.#resizeObserver = new ResizeObserver(this.#queueMeasure); @@ -404,6 +426,7 @@ export default class CraftTabs extends LionTabs { override disconnectedCallback() { super.disconnectedCallback(); + window.removeEventListener('hashchange', this.#selectFromHash); this.#teardownExternal(); this.#resizeObserver?.disconnect(); this.#resizeObserver = undefined; @@ -430,6 +453,7 @@ export default class CraftTabs extends LionTabs { // here under our own name. The guard keeps the initial render quiet: // there is no previous index to have changed from. if (changedProperties.get('selectedIndex') !== undefined) { + this.#writeHash(); this.dispatchEvent(new CustomEvent('craft-tab-show')); } } @@ -569,6 +593,49 @@ export default class CraftTabs extends LionTabs { * The tab a closed strip keeps in the tab order and reopens on: the one the * selection was last on, or the first usable tab if that one's gone. */ + /** The hash a tab is reached by: the id of the panel it names. */ + #hashFor(index: number): string | null { + return this.#tabs[index]?.controls || null; + } + + /** Selects the tab the current hash names, if it names one. */ + #selectFromHash = () => { + const hash = decodeURIComponent(window.location.hash.slice(1)); + + if (hash === '') { + return; + } + + const index = this.#tabs.findIndex( + (_, position) => this.#hashFor(position) === hash + ); + + if (index >= 0 && index !== this.selectedIndex) { + this.selectedIndex = index; + } + }; + + /** + * Mirrors the selection into the hash. + * + * Replaces rather than pushes: moving between tabs isn't navigation, and a + * history entry per tab would make Back walk them instead of leaving the + * page. + */ + #writeHash(): void { + const hash = this.syncLocationHash + ? this.#hashFor(this.selectedIndex) + : null; + + if (hash === null) { + return; + } + + const url = new URL(window.location.href); + url.hash = hash; + window.history.replaceState(window.history.state, '', url); + } + #entryTab(): number { const tabs = this.#tabs; const focusable = (index: number) => diff --git a/resources/js/modules/forms/FormNodeList.vue b/resources/js/modules/forms/FormNodeList.vue index 2e5cc542b4d..9e8ce499080 100644 --- a/resources/js/modules/forms/FormNodeList.vue +++ b/resources/js/modules/forms/FormNodeList.vue @@ -4,6 +4,7 @@ import '@craftcms/ui/components/icon/icon'; import {t} from '@craftcms/ui/utilities/translate'; import {computed, ref, watch} from 'vue'; + import {useIsSlideout} from '@/common/composables/screen'; import FormNode from './FormNode.vue'; import {formTabPanelId, pathsMatch} from './runtime'; import type {FormChange, FormNodePayload, FormPayload} from './types'; @@ -16,6 +17,12 @@ scope: string[]; refreshable: boolean; }>(); + /** + * A slideout sits over a page whose URL is about what's behind it, so its + * tabs leave the hash alone. + */ + const isSlideout = useIsSlideout(); + const emit = defineEmits<{ (event: 'change', change: FormChange): void; }>(); @@ -87,7 +94,11 @@