diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f2cdd3..70db389 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,16 @@
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.0](https://github.com/Fantom-foundation/Fantom-Vue3-Components/compare/v0.37.42...v0.38.0) (2026-07-13)
+
+
+### Features
+
+* **FTabs:** activate nearest non-disabled tab when a disabled tab is clicked ([3ae83ae](https://github.com/Fantom-foundation/Fantom-Vue3-Components/commit/3ae83ae6ba6aef99d6a6a7361bd4e8565333bb15))
+* **FTabs:** add `disableTabs` method to programmatically disable tabs by IDs ([a0aa89c](https://github.com/Fantom-foundation/Fantom-Vue3-Components/commit/a0aa89cfd1f9614fb444053d5e55b669e396c7ac))
+* **FTabs:** add `urlHash` prop to sync tab state with URL hash ([069a60e](https://github.com/Fantom-foundation/Fantom-Vue3-Components/commit/069a60ed95dc87bb14084a19deb1834f8507ebc2))
+* **FToken:** add support for `logo` slot to allow customization ([a2c2830](https://github.com/Fantom-foundation/Fantom-Vue3-Components/commit/a2c2830f1af51de8b1ee1818315df3a2a1fe79e8))
+
### [0.37.42](https://github.com/Fantom-foundation/Fantom-Vue3-Components/compare/v0.37.41...v0.37.42) (2026-06-14)
diff --git a/package-lock.json b/package-lock.json
index 173b84d..f560053 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "fantom-vue3-components",
- "version": "0.37.42",
+ "version": "0.38.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "fantom-vue3-components",
- "version": "0.37.42",
+ "version": "0.38.0",
"license": "MIT",
"dependencies": {
"@vueuse/core": "^13.0.0",
diff --git a/package.json b/package.json
index 841dcc0..1c23cf8 100755
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "fantom-vue3-components",
- "version": "0.37.42",
+ "version": "0.38.0",
"description": "Library of various Vue 3 components with focus on accessibility",
"author": "Fantom Foundation",
"license": "MIT",
diff --git a/src/components/FTabs/FTab.vue b/src/components/FTabs/FTab.vue
index f217121..27de366 100755
--- a/src/components/FTabs/FTab.vue
+++ b/src/components/FTabs/FTab.vue
@@ -71,6 +71,11 @@ export default {
type: Boolean,
default: false,
},
+ /** URL hash value associated with the tab */
+ urlHash: {
+ type: String,
+ default: '',
+ },
},
data() {
@@ -135,6 +140,7 @@ export default {
title: this.title,
titleSlot: this.titleSlot,
titleClass: this.titleClass,
+ urlHash: this.urlHash,
};
},
},
diff --git a/src/components/FTabs/FTabs.spec.js b/src/components/FTabs/FTabs.spec.js
index 96ff996..69897f7 100644
--- a/src/components/FTabs/FTabs.spec.js
+++ b/src/components/FTabs/FTabs.spec.js
@@ -13,10 +13,10 @@ const Playground = {
components: { FTabs, FTab },
template: `
-
+
Tab 1
-
+
Tab 2
@@ -55,6 +55,14 @@ function getContentElement(id) {
return wrapper.find(`#${id}`);
}
+function getTabElement(index) {
+ return wrapper.findAll('li')[index];
+}
+
+function getFTabs(wrapper) {
+ return wrapper.findComponent(FTabs);
+}
+
async function activateTabByIndex(index = 0) {
await wrapper.findAll('li')[index].trigger('click');
await delay();
@@ -62,6 +70,7 @@ async function activateTabByIndex(index = 0) {
afterEach(() => {
destroyWrapper(wrapper);
+ window.location.hash = '';
});
describe('FTabs', () => {
@@ -109,9 +118,107 @@ describe('FTabs', () => {
await activateTabByIndex(1);
+ expect(getTabElement(0).attributes('aria-disabled')).toBe('true');
+ expect(getTabElement(1).attributes('aria-disabled')).toBe('true');
expect(getContentElement('tab1_content').exists()).toBe(true);
expect(getContentElement('tab2_content').exists()).toBe(false);
});
+
+ it('should disable tabs given by ids', async () => {
+ wrapper = await createWrapper({ propsData: { tabStrategy2: 'create-destroy' } });
+
+ await activateTabByIndex(1);
+
+ await getFTabs(wrapper).vm.disableTabs(['tabpanel2']);
+
+ expect(getTabElement(1).attributes('aria-disabled')).toBe('true');
+ expect(getTabElement(1).attributes('aria-selected')).toBe('false');
+ expect(getTabElement(0).attributes('aria-selected')).toBe('true');
+ });
+
+ it('should activate nearest non-disabled tab to the right when activating a disabled tab', async () => {
+ const DisabledTabPlayground = {
+ components: { FTabs, FTab },
+ template: `
+
+ Tab 1
+ Tab 2
+ Tab 3
+ Tab 4
+
+ `,
+ };
+ wrapper = mount(DisabledTabPlayground);
+ await delay();
+
+ expect(wrapper.findAll('li')[0].attributes('aria-selected')).toBe('true');
+
+ await wrapper.findAll('li')[1].trigger('click');
+ await delay();
+
+ expect(wrapper.findAll('li')[0].attributes('aria-selected')).toBe('false');
+ expect(wrapper.findAll('li')[1].attributes('aria-selected')).toBe('false');
+ expect(wrapper.findAll('li')[2].attributes('aria-selected')).toBe('false');
+ expect(wrapper.findAll('li')[3].attributes('aria-selected')).toBe('true');
+ });
+
+ describe('urlHash prop logic', () => {
+ const HashPlayground = {
+ components: { FTabs, FTab },
+ template: `
+
+
+ Tab 1
+
+
+ Tab 2
+
+
+ Tab 3
+
+
+ `,
+ };
+
+ async function createHashWrapper() {
+ wrapper = mount(HashPlayground);
+ await delay();
+ return wrapper;
+ }
+
+ it('should activate the tab corresponding to the URL hash on mount', async () => {
+ window.location.hash = 'hash2';
+ wrapper = await createHashWrapper();
+
+ const tab1 = wrapper.findAll('li')[0];
+ const tab2 = wrapper.findAll('li')[1];
+
+ expect(tab2.attributes('aria-selected')).toBe('true');
+ expect(tab1.attributes('aria-selected')).toBe('false');
+ });
+
+ it('should update the browser URL hash when a tab with urlHash is activated', async () => {
+ wrapper = await createHashWrapper();
+
+ expect(window.location.hash).toBe('#hash1');
+
+ await wrapper.findAll('li')[1].trigger('click');
+ await delay();
+
+ expect(window.location.hash).toBe('#hash2');
+ });
+
+ it('should not update the browser URL hash when a tab without urlHash is activated', async () => {
+ wrapper = await createHashWrapper();
+
+ expect(window.location.hash).toBe('#hash1');
+
+ await wrapper.findAll('li')[2].trigger('click');
+ await delay();
+
+ expect(window.location.hash).toBe('#hash1');
+ });
+ });
});
/* eslint-enable no-undef */
diff --git a/src/components/FTabs/FTabs.vue b/src/components/FTabs/FTabs.vue
index 2359658..4296ef0 100755
--- a/src/components/FTabs/FTabs.vue
+++ b/src/components/FTabs/FTabs.vue
@@ -112,8 +112,29 @@ export default {
await this.tabAction('setData', tabsData);
- if (!activePanelExists) {
+ const currentHash = window.location.hash.replace(/^#/, '');
+ let matchingHashIndex = -1;
+ if (currentHash) {
+ matchingHashIndex = tabPanels.findIndex((panel) => {
+ return panel.urlHash && panel.urlHash.replace(/^#/, '') === currentHash;
+ });
+ }
+
+ if (matchingHashIndex > -1) {
+ await this.setActiveTabByIndex(matchingHashIndex);
+ } else if (!activePanelExists) {
await this.setActiveTabByIndex(0);
+ } else {
+ const activeIndex = tabPanels.findIndex((panel) => panel.active);
+ if (activeIndex > -1) {
+ const activePanel = tabPanels[activeIndex];
+ if (activePanel.urlHash) {
+ const cleanHash = activePanel.urlHash.replace(/^#/, '');
+ if (window.location.hash.replace(/^#/, '') !== cleanHash) {
+ window.location.hash = cleanHash;
+ }
+ }
+ }
}
},
@@ -137,7 +158,13 @@ export default {
* @param {int} _index
*/
async setActiveTabByIndex(_index) {
- const tabPanel = this.dTabPanels[_index];
+ let indexToActivate = _index;
+
+ while (indexToActivate < this.dTabPanels.length && this.dTabPanels[indexToActivate].disabled) {
+ indexToActivate++;
+ }
+
+ const tabPanel = this.dTabPanels[indexToActivate];
if (tabPanel && !tabPanel.disabled && !this.disabled) {
await this.deactivateActivePanel();
@@ -146,6 +173,32 @@ export default {
await this.tabAction('activate', tabPanel.id);
this.$emit('tab-set', { tabId: tabPanel.id });
+
+ if (tabPanel.urlHash) {
+ const cleanHash = tabPanel.urlHash.replace(/^#/, '');
+ if (window.location.hash.replace(/^#/, '') !== cleanHash) {
+ window.location.hash = cleanHash;
+ }
+ }
+ }
+ },
+
+ async disableTabs(tabIds = [], enableOthers = false) {
+ const { dTabPanels } = this;
+ const tabIdsSet = new Set(tabIds);
+
+ for (let i = 0, len1 = dTabPanels.length; i < len1; i++) {
+ const tabPanel = dTabPanels[i];
+ if (tabIdsSet.has(tabPanel.id)) {
+ tabPanel.disabled = true;
+ tabPanel.active = false;
+ } else if (enableOthers) {
+ tabPanel.disabled = false;
+ }
+ }
+
+ if (!this.tabs.activate) {
+ await this.setActiveTabByIndex(0);
}
},
diff --git a/src/components/FToken/FToken.vue b/src/components/FToken/FToken.vue
index 57de1ce..9775427 100644
--- a/src/components/FToken/FToken.vue
+++ b/src/components/FToken/FToken.vue
@@ -75,14 +75,16 @@ const cTitle = computed(() => (props.value ? `${props.value} ${cSymbol.value}` :
-
+
+
+