Repository: BrightspaceUI/core
Pull request #5459: fix: Tabs: New Structure: Warn if tab/panel mismatch
Author: login: margaree
Labels: name: released
Created: 2025-03-21T13:18:18Z | Merged: 2025-03-21T14:56:55Z

[Jira task](https://desire2learn.atlassian.net/browse/GAUD-7658)

Warn consumers if there are tab/panel mismatches when using the new `d2l-tab`/TabMixin with matching `d2l-tab-panel`  structure.


Diff:
diff --git a/components/tabs/tabs.js b/components/tabs/tabs.js
index e72c33b3ee6..e08ea389dbb 100644
--- a/components/tabs/tabs.js
+++ b/components/tabs/tabs.js
@@ -358,6 +358,10 @@ class Tabs extends LocalizeCoreElement(ArrowKeysMixin(SkeletonMixin(LitElement))
 		return this.shadowRoot.querySelector('.d2l-tabs-container-list').getBoundingClientRect();
 	}
 
+	#checkTabPanelMatchRequested;
+	#panels;
+	#updateAriaControlsRequested;
+
 	_animateTabAddition(tabInfo) {
 		const tab = this.shadowRoot
 			&& this.shadowRoot.querySelector(`d2l-tab-internal[controls-panel="${cssEscape(tabInfo.id)}"]`);
@@ -507,10 +511,8 @@ class Tabs extends LocalizeCoreElement(ArrowKeysMixin(SkeletonMixin(LitElement))
 	_getPanel(id) {
 		if (this._defaultSlotBehavior) return this._getPanelDefaultSlotBehavior(id);
 
-		if (!this.shadowRoot) return;
-		const slot = this.shadowRoot.querySelector('slot[name="panels"]');
-		const panels = this._getPanels(slot);
-		return panels.find(panel => panel.labelledBy === id);
+		if (!this.#panels) return;
+		return this.#panels.find(panel => panel.labelledBy === id);
 	}
 
 	// remove after d2l-tab/d2l-tab-panel backport
@@ -518,7 +520,7 @@ class Tabs extends LocalizeCoreElement(ArrowKeysMixin(SkeletonMixin(LitElement))
 		if (!this.shadowRoot) return;
 		// use simple selector for slot (Edge)
 		const slot = this.shadowRoot.querySelector('.d2l-panels-container').querySelector('slot');
-		const panels = this._getPanels(slot);
+		const panels = this._getPanelsDefaultSlotBehavior(slot);
 		for (let i = 0; i < panels.length; i++) {
 			if (panels[i].nodeType === Node.ELEMENT_NODE && panels[i].role === 'tabpanel' && panels[i].id === id) {
 				return panels[i];
@@ -526,7 +528,8 @@ class Tabs extends LocalizeCoreElement(ArrowKeysMixin(SkeletonMixin(LitElement))
 		}
 	}
 
-	_getPanels(slot) {
+	// remove after d2l-tab/d2l-tab-panel backport
+	_getPanelsDefaultSlotBehavior(slot) {
 		if (!slot) return;
 		return slot.assignedElements({ flatten: true }).filter((node) => node.role === 'tabpanel');
 	}
@@ -539,7 +542,7 @@ class Tabs extends LocalizeCoreElement(ArrowKeysMixin(SkeletonMixin(LitElement))
 	async _handleDefaultSlotChange(e) {
 		if (!this._defaultSlotBehavior) return;
 
-		const panels = this._getPanels(e.target);
+		const panels = this._getPanelsDefaultSlotBehavior(e.target);
 
 		// handle case where there are less than two tabs initially
 		this._updateTabListVisibility(panels);
@@ -637,9 +640,11 @@ class Tabs extends LocalizeCoreElement(ArrowKeysMixin(SkeletonMixin(LitElement))
 		this.requestUpdate();
 	}
 
-	_handlePanelsSlotChange() {
+	_handlePanelsSlotChange(e) {
 		if (this._defaultSlotBehavior) return;
 
+		this.#panels = e.target.assignedElements({ flatten: true }).filter((node) => node.role === 'tabpanel');
+		this.#checkTabPanelMatch();
 		this.#setAriaControls();
 	}
 
@@ -794,9 +799,10 @@ class Tabs extends LocalizeCoreElement(ArrowKeysMixin(SkeletonMixin(LitElement))
 		if (selectedTab) {
 			this.#updateSelectedTab(selectedTab);
 		}
-		this.#setAriaControls();
 
 		await this.updateComplete;
+		this.#checkTabPanelMatch();
+		this.#setAriaControls();
 
 		if (!this._initialized && this._tabs.length > 0) {
 			this._initialized = true;
@@ -1001,32 +1007,50 @@ class Tabs extends LocalizeCoreElement(ArrowKeysMixin(SkeletonMixin(LitElement))
 		return this.updateComplete;
 	}
 
+	#checkTabPanelMatch() {
+		// debounce so only runs once when tabs/panels slots changing
+		if (this.#checkTabPanelMatchRequested) return;
+
+		this.#checkTabPanelMatchRequested = true;
+		setTimeout(() => {
+			if ((this._tabs && !this.#panels) || (this.#panels && !this._tabs)) {
+				console.warn('d2l-tabs: tabs and panels are not in sync');
+			} else if (this._tabs.length !== this.#panels.length) {
+				console.warn('d2l-tabs: number of tabs and panels does not match');
+			}
+			this.#checkTabPanelMatchRequested = false;
+		}, 0);
+	}
+
 	#isRTL() {
 		return document.documentElement.getAttribute('dir') === 'rtl';
 	}
 
 	#setAriaControls() {
 		// debounce so only runs once when tabs/panels slots changing
-		if (this._updateAriaControlsRequested) return;
+		if (this.#updateAriaControlsRequested) return;
 
-		this._updateAriaControlsRequested = true;
+		this.#updateAriaControlsRequested = true;
 		setTimeout(() => {
 			this._tabs?.forEach((tab) => {
 				const panel = this._getPanel(tab.id);
-				if (!panel) return;
+				if (!panel) {
+					console.warn('d2l-tabs: tab without matching panel');
+					return;
+				}
 				tab.setAttribute('aria-controls', `${panel.id}`);
 			});
-			this._updateAriaControlsRequested = false;
+			this.#updateAriaControlsRequested = false;
 		}, 0);
 	}
 
 	async #updateSelectedTab(selectedTab) {
-		const selectedPanel = this._getPanel(selectedTab.id);
 		selectedTab.tabIndex = 0;
 
 		await this.updateComplete;
 
-		selectedPanel.selected = true;
+		const selectedPanel = this._getPanel(selectedTab.id);
+		if (selectedPanel) selectedPanel.selected = true;
 		this._tabs.forEach((tab) => {
 			if (tab.id !== selectedTab.id) {
 				if (tab.selected) {
diff --git a/components/tabs/test/tabs.test.js b/components/tabs/test/tabs.test.js
index 5b40a6562b1..7291f23f7b1 100644
--- a/components/tabs/test/tabs.test.js
+++ b/components/tabs/test/tabs.test.js
@@ -2,6 +2,7 @@ import '../tab.js';
 import '../tabs.js';
 import '../tab-panel.js';
 import { clickElem, expect, fixture, html, oneEvent, runConstructor } from '@brightspace-ui/testing';
+import { spy } from 'sinon';
 
 const defaultFixture = html`
 	<div>
@@ -116,5 +117,58 @@ describe('d2l-tabs', () => {
 			checkIfSelected(tabs[0], false);
 			checkIfSelected(tabs[2], true);
 		});
+
+		it('should warn if number of tabs does not equal number of panels', async() => {
+			const consoleSpy = spy(console, 'warn');
+			await fixture(html`
+				<div>
+					<d2l-tabs>
+						<d2l-tab id="all" text="All" slot="tabs" selected></d2l-tab>
+						<d2l-tab-panel labelled-by="all" slot="panels">Tab content for All</d2l-tab-panel>
+						<d2l-tab id="biology" text="Biology" slot="tabs"></d2l-tab>
+						<d2l-tab id="chemistry" text="Chemistry" slot="tabs"></d2l-tab>
+						<d2l-tab-panel labelled-by="biology" slot="panels">Tab content for Biology</d2l-tab-panel>
+					</d2l-tabs>
+				</div>
+			`);
+			expect(consoleSpy.called).to.be.true;
+			consoleSpy.restore();
+		});
+
+		it('should warn if tab does not have corresponding panel due to missing labelled-by', async() => {
+			const consoleSpy = spy(console, 'warn');
+			await fixture(html`
+				<div>
+					<d2l-tabs>
+						<d2l-tab id="all" text="All" slot="tabs" selected></d2l-tab>
+						<d2l-tab-panel labelled-by="all" slot="panels">Tab content for All</d2l-tab-panel>
+						<d2l-tab id="biology" text="Biology" slot="tabs"></d2l-tab>
+						<d2l-tab-panel labelled-by="biology" slot="panels">Tab content for Biology</d2l-tab-panel>
+						<d2l-tab id="chemistry" text="Chemistry" slot="tabs"></d2l-tab>
+						<d2l-tab-panel slot="panels">Tab content for Chemistry</d2l-tab-panel>
+					</d2l-tabs>
+				</div>
+			`);
+			expect(consoleSpy.called).to.be.true;
+			consoleSpy.restore();
+		});
+
+		it('should warn if tab does not have corresponding panel due to missing id', async() => {
+			const consoleSpy = spy(console, 'warn');
+			await fixture(html`
+				<div>
+					<d2l-tabs>
+						<d2l-tab id="all" text="All" slot="tabs" selected></d2l-tab>
+						<d2l-tab-panel labelled-by="all" slot="panels">Tab content for All</d2l-tab-panel>
+						<d2l-tab id="biology" text="Biology" slot="tabs"></d2l-tab>
+						<d2l-tab-panel labelled-by="biology" slot="panels">Tab content for Biology</d2l-tab-panel>
+						<d2l-tab text="Chemistry" slot="tabs"></d2l-tab>
+						<d2l-tab-panel labelled-by="chemistry" slot="panels">Tab content for Chemistry</d2l-tab-panel>
+					</d2l-tabs>
+				</div>
+			`);
+			expect(consoleSpy.called).to.be.true;
+			consoleSpy.restore();
+		});
 	});
 });


Reviews:
user: login: margaree
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T13:27:39Z
user: login: dbatiste
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T14:29:39Z
user: login: dbatiste
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T14:33:45Z
user: login: dbatiste
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T14:35:00Z
user: login: dbatiste
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T14:35:07Z
user: login: dbatiste
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T14:39:15Z
user: login: margaree
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T14:41:38Z

Review comments:
diff_hunk: @@ -507,26 +507,25 @@ class Tabs extends LocalizeCoreElement(ArrowKeysMixin(SkeletonMixin(LitElement))
 	_getPanel(id) {
 		if (this._defaultSlotBehavior) return this._getPanelDefaultSlotBehavior(id);
 
-		if (!this.shadowRoot) return;
-		const slot = this.shadowRoot.querySelector('slot[name="panels"]');
-		const panels = this._getPanels(slot);
-		return panels.find(panel => panel.labelledBy === id);
+		if (!this._panels) return;
+		return this._panels.find(panel => panel.labelledBy === id);
path: components/tabs/tabs.js
user: login: margaree
body: I wanted to simplify this so we weren't querying the slot for `tabpanel`s everytime we needed to get a panel, so I changed it to just get them once on `slotchange` of the `panels` slot.
created_at: 2025-03-21T13:20:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -794,9 +795,10 @@ class Tabs extends LocalizeCoreElement(ArrowKeysMixin(SkeletonMixin(LitElement))
 		if (selectedTab) {
 			this.#updateSelectedTab(selectedTab);
 		}
-		this.#setAriaControls();
 
 		await this.updateComplete;
+		this.#checkTabPanelMatch();
+		this.#setAriaControls();
path: components/tabs/tabs.js
user: login: margaree
body: In testing I found that moving this to later seemed to allow enough time for the panels to also be ready.
created_at: 2025-03-21T13:22:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1001,6 +1003,21 @@ class Tabs extends LocalizeCoreElement(ArrowKeysMixin(SkeletonMixin(LitElement))
 		return this.updateComplete;
 	}
 
+	#checkTabPanelMatch() {
path: components/tabs/tabs.js
user: login: margaree
body: This method checks if the number of panels and tabs are equal, and warns if not
created_at: 2025-03-21T13:22:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1013,19 +1030,22 @@ class Tabs extends LocalizeCoreElement(ArrowKeysMixin(SkeletonMixin(LitElement))
 		setTimeout(() => {
 			this._tabs?.forEach((tab) => {
 				const panel = this._getPanel(tab.id);
-				if (!panel) return;
+				if (!panel) {
+					console.warn('d2l-tabs: tab without matching panel');
path: components/tabs/tabs.js
user: login: margaree
body: Since this was looping through the tabs and looking for matching panels anyways, I added a warning here for if a matching panel wasn't found.

I believe length mismatch and then subsequent check for tab/panel id match should cover the possible cases for incorrect structure setup, but let me know if there are other cases we should be covering.
created_at: 2025-03-21T13:24:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1001,6 +1003,21 @@ class Tabs extends LocalizeCoreElement(ArrowKeysMixin(SkeletonMixin(LitElement))
 		return this.updateComplete;
 	}
 
+	#checkTabPanelMatch() {
path: components/tabs/tabs.js
user: login: dbatiste
body: This would pass if there was tabs A & B but panels B & C. I don't necessarily think we need to jump through hoops though - these cases should never come up if the consumer is using the component properly.

If there was an orphaned panel (no corresponding tab), I think it should just remain hidden in the DOM since there would be no tab to actually show it.

If there was an orphaned tab (no corresponding panel), the tab would be broken for the user. We could try to warn that case and/or hide it as it's misconfigured. One way might be to hide using a selector like `:host(:not([aria-controls]))`, but I wouldn't want that to throw off any measurement during initialization before that attribute is wired up. 

Would be nice if there was a nice (free) way to do an outer join of two id arrays in JavaScript.

But again, not convinced its really worth doing anything extra, and certainly not adding any complexity for it. I am content with the checks you have here.
created_at: 2025-03-21T14:29:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1013,19 +1030,22 @@ class Tabs extends LocalizeCoreElement(ArrowKeysMixin(SkeletonMixin(LitElement))
 		setTimeout(() => {
 			this._tabs?.forEach((tab) => {
 				const panel = this._getPanel(tab.id);
-				if (!panel) return;
+				if (!panel) {
+					console.warn('d2l-tabs: tab without matching panel');
path: components/tabs/tabs.js
user: login: dbatiste
body: Ok that's great. You can probably ignore my previous comment.
created_at: 2025-03-21T14:33:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1001,6 +1003,21 @@ class Tabs extends LocalizeCoreElement(ArrowKeysMixin(SkeletonMixin(LitElement))
 		return this.updateComplete;
 	}
 
+	#checkTabPanelMatch() {
path: components/tabs/tabs.js
user: login: dbatiste
body: Just saw your other comment. Can probably ignore this.
created_at: 2025-03-21T14:34:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1001,6 +1003,21 @@ class Tabs extends LocalizeCoreElement(ArrowKeysMixin(SkeletonMixin(LitElement))
 		return this.updateComplete;
 	}
 
+	#checkTabPanelMatch() {
+		// debounce so only runs once when tabs/panels slots changing
+		if (this._checkTabPanelMatchRequested) return;
+
+		this._checkTabPanelMatchRequested = true;
path: components/tabs/tabs.js
user: login: dbatiste
body: Notice we're using `_` here for this new private variable. What are your thoughts on `#` - I think that's what I'd normally do now, and we've started to use it for methods here. Do we eventually want to make a sweep through this code and switch, maybe once the clean-up is done? What would be the best timing to do that?
created_at: 2025-03-21T14:39:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1001,6 +1003,21 @@ class Tabs extends LocalizeCoreElement(ArrowKeysMixin(SkeletonMixin(LitElement))
 		return this.updateComplete;
 	}
 
+	#checkTabPanelMatch() {
+		// debounce so only runs once when tabs/panels slots changing
+		if (this._checkTabPanelMatchRequested) return;
+
+		this._checkTabPanelMatchRequested = true;
path: components/tabs/tabs.js
user: login: margaree
body: I'd generally been using `_` to keep the new `...DefaultSlotBehavior` functions together with the old in the diff, and then was planning a sweeping change later to update to `#`. But yeah I think for cases like this it would make sense just to use `#` now. I'll update that.
created_at: 2025-03-21T14:41:38Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-21T13:19:25Z
body: <!-- gh-actions_comment-on-pr -->

Thanks for the PR! 🎉

We've deployed an automatic preview for this PR - you can see your changes here:

| URL | https://live.d2l.dev/prs/BrightspaceUI/core/pr-5459/ |
|---|---|

> [!NOTE]
> The build needs to finish before your changes are deployed.
> Changes to the PR will automatically update the instance.

author_association: CONTRIBUTOR
user: login: d2l-github-release-tokens[bot]
created_at: 2025-03-21T14:59:40Z
body: :tada: This PR is included in version 3.94.2 :tada:

The release is available on:
- [GitHub release](https://github.com/BrightspaceUI/core/releases/tag/v3.94.2)
- [npm package (@latest dist-tag)](https://www.npmjs.com/package/@brightspace-ui/core/v/3.94.2)

Your **[semantic-release](https://github.com/semantic-release/semantic-release)** bot :package::rocket:
author_association: NONE