diff --git a/CHANGELOG.md b/CHANGELOG.md index 470c84c..31591d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +### [0.38.5](https://github.com/Fantom-foundation/Fantom-Vue3-Components/compare/v0.38.4...v0.38.5) (2026-08-20) + + +### Features + +* **FTabs:** add hierarchical URL hash support with anchor linking ([3bd0bf6](https://github.com/Fantom-foundation/Fantom-Vue3-Components/commit/3bd0bf64c18afbf1a91eb6b51cb89d7c1a786609)) + ### [0.38.4](https://github.com/Fantom-foundation/Fantom-Vue3-Components/compare/v0.38.3...v0.38.4) (2026-08-20) diff --git a/package-lock.json b/package-lock.json index 40b7b7a..d772350 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "fantom-vue3-components", - "version": "0.38.4", + "version": "0.38.5", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "fantom-vue3-components", - "version": "0.38.4", + "version": "0.38.5", "license": "MIT", "dependencies": { "@vueuse/core": "^13.0.0", diff --git a/package.json b/package.json index 17ad79a..9540e23 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fantom-vue3-components", - "version": "0.38.4", + "version": "0.38.5", "description": "Library of various Vue 3 components with focus on accessibility", "author": "Fantom Foundation", "license": "MIT", diff --git a/src/components/FTabs/FTabs.spec.js b/src/components/FTabs/FTabs.spec.js index 2849e1f..f48c493 100644 --- a/src/components/FTabs/FTabs.spec.js +++ b/src/components/FTabs/FTabs.spec.js @@ -248,7 +248,7 @@ describe('FTabs', () => { expect(window.location.hash).toBe('#some-random-hash'); }); - it('should activate outer tab and nested tab when URL hash matches a nested tab', async () => { + it('should activate outer tab and nested tab when URL hash uses hierarchical path, even with strategy="create"', async () => { const NestedHashPlayground = { components: { FTabs, FTab }, template: ` @@ -256,7 +256,7 @@ describe('FTabs', () => { Outer Tab 1 - + Nested 1 @@ -269,7 +269,7 @@ describe('FTabs', () => { `, }; - window.location.hash = 'tab_nested2'; + window.location.hash = 'tab/tab_nested2'; wrapper = mount(NestedHashPlayground); await delay(); @@ -281,7 +281,75 @@ describe('FTabs', () => { expect(outerTabs[1].attributes('aria-selected')).toBe('true'); expect(innerTabs[0].attributes('aria-selected')).toBe('false'); expect(innerTabs[1].attributes('aria-selected')).toBe('true'); - expect(window.location.hash).toBe('#tab_nested2'); + expect(window.location.hash).toBe('#tab/tab_nested2'); + }); + + it('should support anchor permalink in hierarchical URL hash', async () => { + const AnchorPlayground = { + components: { FTabs, FTab }, + template: ` + + + Outer Tab 1 + + + + + Nested 1 + + +
Target Heading
+
+
+
+
+ `, + }; + window.location.hash = 'tab/tab_nested2:target-heading'; + wrapper = mount(AnchorPlayground, { attachTo: document.body }); + await delay(); + + const tablists = wrapper.findAll('ul[role="tablist"]'); + const outerTabs = tablists[0].findAll('li'); + const innerTabs = tablists[1].findAll('li'); + + expect(outerTabs[1].attributes('aria-selected')).toBe('true'); + expect(innerTabs[1].attributes('aria-selected')).toBe('true'); + expect(window.location.hash).toBe('#tab/tab_nested2:target-heading'); + }); + + it('should update hierarchical URL hash when nested tab is clicked', async () => { + const NestedHashPlayground = { + components: { FTabs, FTab }, + template: ` + + + Outer Tab 1 + + + + + Nested 1 + + + Nested 2 + + + + + `, + }; + window.location.hash = 'tab/tab_nested'; + wrapper = mount(NestedHashPlayground); + await delay(); + + const tablists = wrapper.findAll('ul[role="tablist"]'); + const innerTabs = tablists[1].findAll('li'); + + await innerTabs[1].trigger('click'); + await delay(); + + expect(window.location.hash).toBe('#tab/tab_nested2'); }); }); diff --git a/src/components/FTabs/FTabs.vue b/src/components/FTabs/FTabs.vue index 3e634b6..3fe4ef7 100755 --- a/src/components/FTabs/FTabs.vue +++ b/src/components/FTabs/FTabs.vue @@ -38,6 +38,12 @@ export default { emits: ['tab-set'], + inject: { + parentFTabs: { + default: null, + }, + }, + props: { /** No tablist style */ noStyle: { @@ -66,9 +72,16 @@ export default { }; }, + computed: { + level() { + return this.parentFTabs ? this.parentFTabs.level + 1 : 0; + }, + }, + provide() { return { tabs: this.tabs, + parentFTabs: this, }; }, @@ -86,35 +99,88 @@ export default { return actionHandler(this.tabs, action, value); }, + parseUrlHash() { + const rawHash = + typeof window !== 'undefined' && window.location.hash ? window.location.hash.replace(/^#/, '') : ''; + + if (!rawHash) { + return { tabPath: '', segments: [], anchor: '' }; + } + + const [tabPath, anchor] = rawHash.split(':'); + const segments = tabPath ? tabPath.split('/') : []; + + return { + tabPath: tabPath || '', + segments, + anchor: anchor || '', + }; + }, + + scrollToAnchor() { + if (typeof window === 'undefined' || typeof document === 'undefined') { + return; + } + + const { anchor } = this.parseUrlHash(); + + if (anchor) { + this.$nextTick(() => { + const el = document.getElementById(anchor); + + if (el && typeof el.scrollIntoView === 'function') { + el.scrollIntoView({ behavior: 'smooth' }); + } + }); + } + }, + updateUrlHash(tabPanel) { + if (typeof window === 'undefined') { + return; + } + const hasAnyUrlHash = this.dTabPanels.some((panel) => !!panel.urlHash); if (hasAnyUrlHash) { - const currentHash = window.location.hash.replace(/^#/, ''); + let { segments, anchor } = this.parseUrlHash(); + + if (tabPanel.urlHash) { + const cleanHash = tabPanel.urlHash.replace(/^#/, ''); - if (currentHash && this.$el) { - const panelEl = this.$el.querySelector(`#${tabPanel.id}`); - if (panelEl?.querySelector(`[data-url-hash="${currentHash}"]`)) { - return; + if (segments[this.level] !== cleanHash) { + segments[this.level] = cleanHash; + segments.length = this.level + 1; + anchor = ''; } + } else { + segments.splice(this.level); + anchor = ''; } - if (tabPanel.urlHash) { - const cleanHash = tabPanel.urlHash.replace(/^#/, ''); + const newTabPath = segments.filter(Boolean).join('/'); + let newHash = ''; - if (currentHash !== cleanHash) { + if (newTabPath) { + newHash = anchor ? `${newTabPath}:${anchor}` : newTabPath; + } + + const currentFullHash = window.location.hash.replace(/^#/, ''); + + if (newHash !== currentFullHash) { + if (newHash) { window.history.replaceState( null, document.title, - window.location.pathname + window.location.search + `#${cleanHash}` + window.location.pathname + window.location.search + `#${newHash}` + ); + } else if (window.location.hash) { + window.history.replaceState( + null, + document.title, + window.location.pathname + window.location.search ); } - } else if (window.location.hash) { - window.history.replaceState( - null, - document.title, - window.location.pathname + window.location.search - ); } } }, @@ -159,6 +225,8 @@ export default { this.updateUrlHash(tabPanels[activeIndex]); } } + + this.scrollToAnchor(); }, /** @@ -201,6 +269,7 @@ export default { this.$emit('tab-set', { tabId: tabPanel.id }); this.updateUrlHash(tabPanel); + this.scrollToAnchor(); } }, @@ -307,25 +376,16 @@ export default { }, getMatchingHashIndex() { - const currentHash = window.location.hash.replace(/^#/, ''); + const { segments } = this.parseUrlHash(); + const targetSegment = segments[this.level]; let matchingHashIndex = -1; - if (currentHash) { + if (targetSegment) { const tabPanels = clone(this.tabs.states); matchingHashIndex = tabPanels.findIndex((panel) => { - return panel.urlHash && !panel.hidden && panel.urlHash.replace(/^#/, '') === currentHash; + return panel.urlHash && !panel.hidden && panel.urlHash.replace(/^#/, '') === targetSegment; }); - - if (matchingHashIndex === -1 && this.$el) { - matchingHashIndex = tabPanels.findIndex((panel) => { - if (panel.hidden) { - return false; - } - const panelEl = this.$el.querySelector(`#${panel.id}`); - return panelEl?.querySelector(`[data-url-hash="${currentHash}"]`) !== null; - }); - } } return matchingHashIndex;