diff --git a/packages/tabsheet/src/vaadin-tabsheet-mixin.js b/packages/tabsheet/src/vaadin-tabsheet-mixin.js index b9160e2d9b..f3d31f90fd 100644 --- a/packages/tabsheet/src/vaadin-tabsheet-mixin.js +++ b/packages/tabsheet/src/vaadin-tabsheet-mixin.js @@ -18,11 +18,29 @@ class TabsSlotController extends SlotController { super(host, 'tabs'); this.__tabsItemsChangedListener = this.__tabsItemsChangedListener.bind(this); this.__tabsSelectedChangedListener = this.__tabsSelectedChangedListener.bind(this); + this.__tabIdObserver = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + const tab = mutation.target; + + host.__linkTabAndPanel(tab); + + if (tab.selected) { + host.__togglePanels(tab); + } + }); + }); } /** @private */ __tabsItemsChangedListener() { - this.host._setItems(this.tabs.items); + this.__tabIdObserver.disconnect(); + const items = this.tabs.items || []; + items.forEach((tab) => { + this.__tabIdObserver.observe(tab, { + attributeFilter: ['id'], + }); + }); + this.host._setItems(items); } /** @private */ @@ -142,15 +160,7 @@ export const TabSheetMixin = (superClass) => return; } items.forEach((tabItem) => { - const panel = panels.find((panel) => panel.getAttribute('tab') === tabItem.id); - if (panel) { - panel.role = 'tabpanel'; - if (!panel.id) { - panel.id = `tabsheet-panel-${generateUniqueId()}`; - } - panel.setAttribute('aria-labelledby', tabItem.id); - tabItem.setAttribute('aria-controls', panel.id); - } + this.__linkTabAndPanel(tabItem, panels); }); } @@ -162,10 +172,14 @@ export const TabSheetMixin = (superClass) => if (!items || !panels || selected === undefined) { return; } - const content = this.shadowRoot.querySelector('[part="content"]'); - const selectedTab = items[selected]; + this.__togglePanels(items[selected], panels); + } + + /** @private */ + __togglePanels(selectedTab, panels = this.__panels) { const selectedTabId = selectedTab ? selectedTab.id : ''; const selectedPanel = panels.find((panel) => panel.getAttribute('tab') === selectedTabId); + const content = this.shadowRoot.querySelector('[part="content"]'); // Mark loading state if a selected panel is not found. this.toggleAttribute('loading', !selectedPanel); @@ -183,4 +197,17 @@ export const TabSheetMixin = (superClass) => panel.hidden = panel !== selectedPanel; }); } + + /** @private */ + __linkTabAndPanel(tab, panels = this.__panels) { + const panel = panels.find((panel) => panel.getAttribute('tab') === tab.id); + if (panel) { + panel.role = 'tabpanel'; + if (!panel.id) { + panel.id = `tabsheet-panel-${generateUniqueId()}`; + } + panel.setAttribute('aria-labelledby', tab.id); + tab.setAttribute('aria-controls', panel.id); + } + } }; diff --git a/packages/tabsheet/test/tabsheet.test.js b/packages/tabsheet/test/tabsheet.test.js index 9a264201de..a5bb81b69e 100644 --- a/packages/tabsheet/test/tabsheet.test.js +++ b/packages/tabsheet/test/tabsheet.test.js @@ -353,3 +353,89 @@ describe('tabsheet - lazy tabs', () => { expect(getPanels()[2].hidden).to.be.true; }); }); + +describe('tabsheet - tabs without ID', () => { + let tabsheet, tabs; + + function getPanels() { + return [...tabsheet.querySelectorAll(`[tab]`)]; + } + + beforeEach(async () => { + tabsheet = fixtureSync(` + + + Tab 1 + Tab 2 + + +
Panel 1
+
Panel 2
+
+ `); + + await nextFrame(); + tabs = tabsheet.querySelector('vaadin-tabs'); + }); + + it('should be in loading state until ID is set on the tab', () => { + expect(tabsheet.hasAttribute('loading')).to.be.true; + }); + + it('should not be in loading state after setting ID on the tab', async () => { + tabs.items[0].id = 'tab-1'; + await nextFrame(); + expect(tabsheet.hasAttribute('loading')).to.be.false; + }); + + it('should restore loading state after removing ID from the tab', async () => { + tabs.items[0].id = 'tab-1'; + await nextFrame(); + + tabs.items[0].id = null; + await nextFrame(); + expect(tabsheet.hasAttribute('loading')).to.be.true; + }); + + it('should have all panels hidden until ID is set on the tab', () => { + expect(getPanels()[0].hidden).to.be.true; + expect(getPanels()[1].hidden).to.be.true; + }); + + it('should have matching panel visible after setting ID on the selected tab', async () => { + tabs.items[0].id = 'tab-1'; + await nextFrame(); + expect(getPanels()[0].hidden).to.be.false; + expect(getPanels()[1].hidden).to.be.true; + }); + + it('should not have matching panel visible after setting ID on the not selected tab', async () => { + tabs.items[1].id = 'tab-2'; + await nextFrame(); + expect(getPanels()[0].hidden).to.be.true; + expect(getPanels()[1].hidden).to.be.true; + }); + + it('should not have matching panel visible after setting ID on the detached tab', async () => { + // Move selected tab out of the `vaadin-tabs` + const tab = tabs.items[0]; + tabsheet.parentNode.appendChild(tab); + await nextFrame(); + tab.id = 'tab-1'; + await nextFrame(); + expect(getPanels()[0].hidden).to.be.true; + expect(getPanels()[1].hidden).to.be.true; + }); + + it('should link tab with panel after setting ID regardless of tab selected state', async () => { + tabs.items[0].id = 'tab-1'; + await nextFrame(); + expect(tabs.items[0].getAttribute('aria-controls')).to.equal(getPanels()[0].id); + expect(getPanels()[0].getAttribute('aria-labelledby')).to.equal('tab-1'); + + tabs.items[1].id = 'tab-2'; + await nextFrame(); + expect(tabs.items[1].getAttribute('aria-controls')).to.equal(getPanels()[1].id); + expect(getPanels()[1].getAttribute('aria-labelledby')).to.equal('tab-2'); + }); +});