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;