Skip to content
Closed
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
82 changes: 82 additions & 0 deletions packages/craftcms-ui/src/components/tabs/tabs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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) {
Expand Down
67 changes: 67 additions & 0 deletions packages/craftcms-ui/src/components/tabs/tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -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'));
}
}
Expand Down Expand Up @@ -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) =>
Expand Down
13 changes: 12 additions & 1 deletion resources/js/modules/forms/FormNodeList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
}>();
Expand Down Expand Up @@ -87,7 +94,11 @@

<template>
<!-- FormNodeList -->
<craft-tabs v-if="tabs.length > 1" @craft-tab-show="onSelectionChanged">
<craft-tabs
v-if="tabs.length > 1"
:sync-location-hash="isSlideout ? 'false' : null"
@craft-tab-show="onSelectionChanged"
>
<craft-tab
v-for="tab in tabs"
slot="tab"
Expand Down
4 changes: 2 additions & 2 deletions resources/templates/_layouts/cp.twig
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,9 @@
{% set userPhoto = include('_layouts/components/header-photo.twig') %}

{% js at head %}
// Remove the hash so the browser doesn't scroll to it
// Read by the legacy tab manager. The hash itself stays on the URL: `craft-tabs`
// reads it to pick a tab, and a saved screen returns to the tab it was on.
window.LOCATION_HASH = document.location.hash ? decodeURIComponent(document.location.hash.substr(1)) : null;
history.replaceState(undefined, undefined, window.location.href.match(/^[^#]*/)[0]);
{% endjs %}

{% block body %}
Expand Down
15 changes: 15 additions & 0 deletions src/Cp/Components/Tabs.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ class Tabs extends ViewComponent

protected bool $equalWidth = false;

protected bool $syncLocationHash = true;

protected function tagName(): string
{
return 'craft-tabs';
Expand Down Expand Up @@ -162,6 +164,18 @@ public function equalWidth(bool $equalWidth = true): static
return $this;
}

/**
* Mirrors the selected tab in `location.hash`, and selects the tab the hash
* names on load. On by default, and only for tabs naming a panel. Turn it
* off where the strip isn't the page's own.
*/
public function syncLocationHash(bool $syncLocationHash = true): static
{
$this->syncLocationHash = $syncLocationHash;

return $this;
}

/**
* Which axis the strip runs along; the web component defaults to
* `horizontal`. Strings (e.g. from Twig `ui()` config) are validated
Expand Down Expand Up @@ -221,6 +235,7 @@ protected function hostAttributes(): array
'placement' => $this->getPlacement(),
'collapsible' => $this->collapsible,
'equal-width' => $this->equalWidth,
'sync-location-hash' => $this->syncLocationHash ? null : 'false',
'layout' => $this->getLayout(),
];
}
Expand Down
Loading