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}` :