From 3c25c8492874ee259c96064fb5c31b62d1f99560 Mon Sep 17 00:00:00 2001 From: Stephane Comeau Date: Tue, 16 Jan 2024 06:42:41 -0800 Subject: [PATCH 1/9] working --- .../fast-foundation/src/tabs/tabs.template.ts | 4 +- .../fast-foundation/src/tabs/tabs.ts | 495 +++++++++++++----- 2 files changed, 352 insertions(+), 147 deletions(-) diff --git a/packages/web-components/fast-foundation/src/tabs/tabs.template.ts b/packages/web-components/fast-foundation/src/tabs/tabs.template.ts index 192ff626579..fa7dc6f3e5e 100644 --- a/packages/web-components/fast-foundation/src/tabs/tabs.template.ts +++ b/packages/web-components/fast-foundation/src/tabs/tabs.template.ts @@ -1,4 +1,4 @@ -import { ElementViewTemplate, html, slotted } from "@microsoft/fast-element"; +import { ElementViewTemplate, html, ref, slotted } from "@microsoft/fast-element"; import { endSlotTemplate, startSlotTemplate } from "../patterns/index.js"; import type { FASTTabs } from "./tabs.js"; import type { TabsOptions } from "./tabs.options.js"; @@ -12,7 +12,7 @@ export function tabsTemplate( ): ElementViewTemplate { return html` ${startSlotTemplate(options)} -
+
${endSlotTemplate(options)} diff --git a/packages/web-components/fast-foundation/src/tabs/tabs.ts b/packages/web-components/fast-foundation/src/tabs/tabs.ts index b7e88c71d9e..93484bfaf7c 100644 --- a/packages/web-components/fast-foundation/src/tabs/tabs.ts +++ b/packages/web-components/fast-foundation/src/tabs/tabs.ts @@ -1,4 +1,4 @@ -import { attr, FASTElement, observable } from "@microsoft/fast-element"; +import { attr, FASTElement, observable, Updates } from "@microsoft/fast-element"; import { keyArrowDown, keyArrowLeft, @@ -27,6 +27,9 @@ import { TabsOrientation } from "./tabs.options.js"; * @public */ export class FASTTabs extends FASTElement { + private static gridHorizontalProperty: string = "gridColumn"; + private static gridVerticalProperty: string = "gridRow"; + /** * The orientation * @public @@ -40,7 +43,7 @@ export class FASTTabs extends FASTElement { */ public orientationChanged(): void { if (this.$fastController.isConnected) { - this.setTabs(); + this.queueTabUpdate(); } } /** @@ -51,19 +54,112 @@ export class FASTTabs extends FASTElement { * HTML Attribute: activeid */ @attr - public activeid: string; + public activeid: string | undefined; + /** + * @internal + */ + public activeidChanged(): void { + if (this.$fastController.isConnected || !this.updatingActiveid) { + this.updateActiveid(); + } + } + + /** + * An array of id's that specifies the order of tabs. + * If an author does not specify this (or sets it to undefined) + * the component will create and manage the tabOrder + * based on the order of tab elements in the DOM. + * The component uses this to manage keyboard navigation. + * + * @public + */ + @observable + public tabOrder: string[] | undefined = []; /** * @internal */ - public activeidChanged(oldValue: string, newValue: string): void { + public tabOrderChanged(prev: string[] | undefined, next: string[] | undefined): void { + if (!this.$fastController.isConnected) { + return; + } + if (!this.tabOrder) { + // setting the tabOrder to undefined prompts the component take over + this.manageTabOrder = true; + this.tabOrder = []; + this.queueTabUpdate(); + return; + } + + if (!prev && next === [] && this.manageTabOrder) { + // avoid self-triggering + return; + } + + // this is now an author managed list + // Note: component code should not replace the array instance and + // modify the existing array instead + this.manageTabOrder = false; + this.queueTabUpdate(); + } + + /** + * Gets the active tab Element + * @public + */ + public get activetab(): HTMLElement | undefined { + if (!this.$fastController.isConnected) { + return; + } + if (this.activeid) { + return this.tabs.find(tab => tab.id === this.activeid); + } + return undefined; + } + /** + * Sets the active tab Element + * @public + */ + public set activetab(tabElement: HTMLElement | undefined) { + if (!this.$fastController.isConnected || !tabElement) { + return; + } + if (!this.tabs.includes(tabElement) || !this.isFocusableElement(tabElement)) { + // invalid tab element, ignore + return; + } + this.activeid = tabElement.id; + } + + /** + * Gets the active tab index, -1 if no valid tabs + * @public + */ + public get activeTabIndex(): number { + if (!this.$fastController.isConnected) { + return -1; + } + if (this.tabOrder && this.activeid) { + return this.tabOrder.findIndex(tabId => tabId === this.activeid); + } + return -1; + } + /** + * Sets the active tab index based on tabOrder. + * If the target tab is not focusable setting this will have no effect. + * @public + */ + public set activeTabIndex(index: number) { if ( - this.$fastController.isConnected && - this.tabs.length <= this.tabpanels.length + !this.$fastController.isConnected || + !this.tabOrder || + index < 0 || + index >= this.tabOrder.length ) { - this.prevActiveTabIndex = this.tabs.findIndex( - (item: HTMLElement) => item.id === oldValue - ); - this.setTabs(); + return; + } + const targetTab = this.tabs.find(tab => tab.id === this.activeid); + if (targetTab) { + this.activeid = targetTab.id; } } @@ -76,14 +172,8 @@ export class FASTTabs extends FASTElement { * @internal */ public tabsChanged(): void { - if ( - this.$fastController.isConnected && - this.tabs.length <= this.tabpanels.length - ) { - this.tabIds = this.getTabIds(); - this.tabpanelIds = this.getTabPanelIds(); - - this.setTabs(); + if (this.$fastController.isConnected) { + this.queueTabUpdate(); } } @@ -96,31 +186,21 @@ export class FASTTabs extends FASTElement { * @internal */ public tabpanelsChanged(): void { - if ( - this.$fastController.isConnected && - this.tabpanels.length <= this.tabs.length - ) { - this.tabIds = this.getTabIds(); - this.tabpanelIds = this.getTabPanelIds(); - - this.setTabs(); + if (this.$fastController.isConnected) { + this.queueTabUpdate(); } } /** - * A reference to the active tab - * @public + * Ref to the tablist element + * @internal */ - public activetab: HTMLElement; + public tabList!: HTMLElement; - private prevActiveTabIndex: number = 0; - private activeTabIndex: number = 0; - private tabIds: Array; - private tabpanelIds: Array; - - private change = (): void => { - this.$emit("change", this.activetab); - }; + private manageTabOrder: boolean = true; + private mutationObserver: MutationObserver | undefined; + private tabUpdateQueued: boolean = false; + private updatingActiveid: boolean = false; private isDisabledElement = (el: Element): el is HTMLElement => { return el.getAttribute("aria-disabled") === "true"; @@ -134,108 +214,224 @@ export class FASTTabs extends FASTElement { return !this.isDisabledElement(el) && !this.isHiddenElement(el); }; - private getActiveIndex(): number { - const id: string = this.activeid; - if (id !== undefined) { - return this.tabIds.indexOf(this.activeid) === -1 - ? 0 - : this.tabIds.indexOf(this.activeid); - } else { - return 0; + private isHorizontal(): boolean { + return this.orientation === TabsOrientation.horizontal; + } + + private isValidTabId(tabId: string): boolean { + if (this.tabOrder && this.tabOrder.includes(tabId)) { + return true; + } + return false; + } + + /** + * @internal + */ + public connectedCallback(): void { + super.connectedCallback(); + this.tabList.addEventListener("mousedown", this.handleTabMouseDown); + this.tabList.addEventListener("keydown", this.handleTabKeyDown); + + this.mutationObserver = new MutationObserver(this.onChildListChange); + this.mutationObserver.observe(this, { childList: true }); + + this.queueTabUpdate(); + } + + /** + * @internal + */ + public disconnectedCallback(): void { + super.disconnectedCallback(); + this.tabList.removeEventListener("mousedown", this.handleTabMouseDown); + this.tabList.removeEventListener("keydown", this.handleTabKeyDown); + this.mutationObserver?.disconnect(); + this.mutationObserver = undefined; + } + + /** + * Mutation observer callback + */ + private onChildListChange = (): void => { + this.queueTabUpdate(); + }; + + /** + * Queues up an update after DOM changes. + * + */ + private queueTabUpdate(): void { + if (this.tabUpdateQueued) { + return; } + this.tabUpdateQueued = true; + Updates.enqueue(() => this.setTabs()); } /** - * Function that is invoked whenever the selected tab or the tab collection changes. + * Function that is invoked whenever the tab collection changes. * * @public */ protected setTabs(): void { - const gridHorizontalProperty: string = "gridColumn"; - const gridVerticalProperty: string = "gridRow"; - const gridProperty: string = this.isHorizontal() - ? gridHorizontalProperty - : gridVerticalProperty; - - this.activeTabIndex = this.getActiveIndex(); + this.tabUpdateQueued = false; + this.updateIds(); this.tabs.forEach((tab: HTMLElement, index: number) => { - if (tab.slot === "tab") { - const isActiveTab = - this.activeTabIndex === index && this.isFocusableElement(tab); - - const tabId: string = this.tabIds[index]; - const tabpanelId: string = this.tabpanelIds[index]; - tab.setAttribute("id", tabId); - tab.setAttribute("aria-selected", isActiveTab ? "true" : "false"); - tab.setAttribute("aria-controls", tabpanelId); - tab.addEventListener("click", this.handleTabClick); - tab.addEventListener("keydown", this.handleTabKeyDown); - tab.setAttribute("tabindex", isActiveTab ? "0" : "-1"); - if (isActiveTab) { - this.activetab = tab; - this.activeid = tabId; - } - } - - // If the original property isn't emptied out, - // the next set will morph into a grid-area style setting that is not what we want - tab.style[gridHorizontalProperty] = ""; - tab.style[gridVerticalProperty] = ""; - tab.style[gridProperty] = `${index + 1}`; !this.isHorizontal() ? tab.classList.add("vertical") : tab.classList.remove("vertical"); }); - this.setTabPanels(); - } - private setTabPanels(): void { - this.tabpanels.forEach((tabpanel: HTMLElement, index: number) => { - const tabId: string = this.tabIds[index]; - const tabpanelId: string = this.tabpanelIds[index]; - tabpanel.setAttribute("id", tabpanelId); - tabpanel.setAttribute("aria-labelledby", tabId); - this.activeTabIndex !== index - ? tabpanel.setAttribute("hidden", "") - : tabpanel.removeAttribute("hidden"); - }); + this.updateActiveid(); } - private getTabIds(): Array { - return this.tabs.map((tab: HTMLElement) => { - return tab.getAttribute("id") ?? `tab-${uniqueId()}`; + /** + * Ensure all tabs and tabpanels have an id and default values + * for id based attributes are also set. + */ + private updateIds(): void { + const newTabIds: string[] = []; + const newTabPanelIds: string[] = []; + this.tabpanels.forEach((tabPanel: HTMLElement, index: number) => { + if (!tabPanel.hasAttribute("id")) { + tabPanel.setAttribute("id", `panel-${uniqueId()}`); + } + newTabPanelIds.push(tabPanel.id); + }); + this.tabs.forEach((tab: HTMLElement, index: number) => { + if (!tab.hasAttribute("id")) { + tab.setAttribute("id", `tab-${uniqueId()}`); + } + newTabIds.push(tab.id); }); - } - private getTabPanelIds(): Array { - return this.tabpanels.map((tabPanel: HTMLElement) => { - return tabPanel.getAttribute("id") ?? `panel-${uniqueId()}`; + // Do a second pass to set other default id based attributes if they have not been set. + // This enables a default behavior where tabs and tabpanels are associated based on + // their index in the DOM (ie. first tab associated with first tab panel, etc...) + this.tabpanels.forEach((tabPanel: HTMLElement, index: number) => { + if (!tabPanel.hasAttribute("aria-labelledby") && newTabIds.length > index) { + // if a tab does not already have a controlled panel defined + // assign the id of the panel of the same index if it exists + tabPanel.setAttribute("aria-labelledby", newTabIds[index]); + } + }); + this.tabs.forEach((tab: HTMLElement, index: number) => { + if (!tab.hasAttribute("aria-controls") && newTabPanelIds.length > index) { + // if a tab does not already have a controlled panel defined + // assign the id of the panel of the same index if it exists + tab.setAttribute("aria-controls", newTabPanelIds[index]); + } }); + + if (this.manageTabOrder && this.tabOrder) { + this.tabOrder.splice(0, this.tabOrder.length, ...newTabIds); + } } - private setComponent(): void { - if (this.activeTabIndex !== this.prevActiveTabIndex) { - this.activeid = this.tabIds[this.activeTabIndex] as string; - this.focusTab(); - this.change(); + /** + * Function that is invoked whenever the active tab changes. + */ + protected updateActiveid(): void { + if (this.updatingActiveid) { + return; } + // flag that prevents self-triggering + this.updatingActiveid = true; + + if (!this.tabOrder || this.tabOrder.length === 0) { + this.activeid = undefined; + } else { + const validTabs: HTMLElement[] = []; + this.tabOrder.forEach((tabId: string, index: number) => { + const tabElement: HTMLElement | undefined = this.tabs.find( + (tab: HTMLElement) => tab.id === tabId + ); + if (tabElement && this.isFocusableElement(tabElement)) { + validTabs.push(tabElement); + } + }); + + if (validTabs.length === 0) { + // no valid tabs + this.activeid === undefined; + } + + // validate current active tab + if (this.activeid) { + const activeTab: HTMLElement | undefined = validTabs.find( + tabElement => tabElement.id === this.activeid + ); + if (!activeTab) { + this.activeid === undefined; + } + } + + if (!this.activeid) { + this.activeid = validTabs[0].id; + } + } + + const gridProperty: string = this.isHorizontal() + ? FASTTabs.gridHorizontalProperty + : FASTTabs.gridVerticalProperty; + + let tabElement: HTMLElement | undefined; + let activeTab: HTMLElement | undefined; + this.tabOrder?.forEach((tabId: string, index: number) => { + tabElement = this.tabs.find(tab => tab.id === tabId); + if (tabElement) { + if (tabId === this.activeid) { + activeTab = tabElement; + } + // If the original property isn't emptied out, + // the next set will morph into a grid-area style setting that is not what we want + tabElement.style[FASTTabs.gridHorizontalProperty] = ""; + tabElement.style[FASTTabs.gridVerticalProperty] = ""; + tabElement.style[gridProperty] = `${index + 1}`; + tabElement.setAttribute( + "aria-selected", + tabElement === activeTab ? "true" : "false" + ); + tabElement.setAttribute( + "tabindex", + tabElement === activeTab ? "0" : "-1" + ); + } + }); + + this.tabpanels.forEach((tabpanel: HTMLElement, index: number) => { + tabpanel.id !== activeTab?.getAttribute("aria-controls") + ? tabpanel.setAttribute("hidden", "") + : tabpanel.removeAttribute("hidden"); + }); + + this.updatingActiveid = false; } - private handleTabClick = (event: MouseEvent): void => { - const selectedTab = event.currentTarget as HTMLElement; + /** + * Mousedown handler + * @internal + */ + public handleTabMouseDown = (event: MouseEvent): void => { + if (event.defaultPrevented) { + return; + } + const selectedTab = event.target as HTMLElement; if (selectedTab.nodeType === 1 && this.isFocusableElement(selectedTab)) { - this.prevActiveTabIndex = this.activeTabIndex; - this.activeTabIndex = this.tabs.indexOf(selectedTab); - this.setComponent(); + this.activeid = selectedTab.id; } }; - private isHorizontal(): boolean { - return this.orientation === TabsOrientation.horizontal; - } - - private handleTabKeyDown = (event: KeyboardEvent): void => { + /** + * Keydown handler + * @internal + */ + public handleTabKeyDown = (event: KeyboardEvent): void => { + if (event.defaultPrevented) { + return; + } if (this.isHorizontal()) { switch (event.key) { case keyArrowLeft: @@ -278,8 +474,23 @@ export class FASTTabs extends FASTElement { * This method allows the active index to be adjusted by numerical increments */ public adjust(adjustment: number): void { - const focusableTabs = this.tabs.filter(t => this.isFocusableElement(t)); - const currentActiveTabIndex = focusableTabs.indexOf(this.activetab); + if (!this.tabOrder || !this.activeid) { + return; + } + const focusableTabs: HTMLElement[] = []; + let tabElement: HTMLElement | undefined; + let currentActiveTabIndex: number = 0; + this.tabOrder.forEach(tabId => { + tabElement = this.tabs.find(tab => { + return this.isFocusableElement(tab); + }); + if (tabElement) { + focusableTabs.push(tabElement); + if (tabElement.id === this.activeid) { + currentActiveTabIndex = focusableTabs.length - 1; + } + } + }); const nextTabIndex = limit( 0, @@ -291,26 +502,25 @@ export class FASTTabs extends FASTElement { const nextIndex = this.tabs.indexOf(focusableTabs[nextTabIndex]); if (nextIndex > -1) { - this.moveToTabByIndex(this.tabs, nextIndex); + this.moveToTabByIndex(nextIndex); } } private adjustForward(e: KeyboardEvent): void { - const group: HTMLElement[] = this.tabs; let index: number = 0; - index = this.activetab ? group.indexOf(this.activetab) + 1 : 1; - if (index === group.length) { + index = this.activetab ? this.tabs.indexOf(this.activetab) + 1 : 1; + if (index === this.tabs.length) { index = 0; } - while (index < group.length && group.length > 1) { - if (this.isFocusableElement(group[index])) { - this.moveToTabByIndex(group, index); + while (index < this.tabs.length && this.tabs.length > 1) { + if (this.isFocusableElement(this.tabs[index])) { + this.moveToTabByIndex(index); break; - } else if (this.activetab && index === group.indexOf(this.activetab)) { + } else if (this.activetab && index === this.tabs.indexOf(this.activetab)) { break; - } else if (index + 1 >= group.length) { + } else if (index + 1 >= this.tabs.length) { index = 0; } else { index += 1; @@ -319,46 +529,41 @@ export class FASTTabs extends FASTElement { } private adjustBackward(e: KeyboardEvent): void { - const group: HTMLElement[] = this.tabs; let index: number = 0; - index = this.activetab ? group.indexOf(this.activetab) - 1 : 0; - index = index < 0 ? group.length - 1 : index; + index = this.activetab ? this.tabs.indexOf(this.activetab) - 1 : 0; + index = index < 0 ? this.tabs.length - 1 : index; - while (index >= 0 && group.length > 1) { - if (this.isFocusableElement(group[index])) { - this.moveToTabByIndex(group, index); + while (index >= 0 && this.tabs.length > 1) { + if (this.isFocusableElement(this.tabs[index])) { + this.moveToTabByIndex(index); break; } else if (index - 1 < 0) { - index = group.length - 1; + index = this.tabs.length - 1; } else { index -= 1; } } } - private moveToTabByIndex(group: HTMLElement[], index: number) { - const tab: HTMLElement = group[index] as HTMLElement; - this.activetab = tab; - this.prevActiveTabIndex = this.activeTabIndex; - this.activeTabIndex = index; - tab.focus(); - this.setComponent(); + private moveToTabByIndex(index: number) { + const tab: HTMLElement = this.tabs[index]; + this.activeid = tab.id; + this.focusTab(); } private focusTab(): void { - this.tabs[this.activeTabIndex].focus(); + if (this.activetab) { + this.activetab.focus(); + } } - /** - * @internal - */ - public connectedCallback(): void { - super.connectedCallback(); - - this.tabIds = this.getTabIds(); - this.tabpanelIds = this.getTabPanelIds(); - this.activeTabIndex = this.getActiveIndex(); + private getRootActiveElement(): Element | null { + const rootNode = this.getRootNode(); + if (rootNode instanceof ShadowRoot) { + return rootNode.activeElement; + } + return document.activeElement; } } From 1b29f6b939114ae5605f3dfd1a51932b273ed409 Mon Sep 17 00:00:00 2001 From: Stephane Comeau Date: Wed, 17 Jan 2024 08:18:57 -0800 Subject: [PATCH 2/9] adjust --- .../fast-foundation/src/tabs/tabs.ts | 107 +++++------------- 1 file changed, 30 insertions(+), 77 deletions(-) diff --git a/packages/web-components/fast-foundation/src/tabs/tabs.ts b/packages/web-components/fast-foundation/src/tabs/tabs.ts index 93484bfaf7c..bfa090c6a3a 100644 --- a/packages/web-components/fast-foundation/src/tabs/tabs.ts +++ b/packages/web-components/fast-foundation/src/tabs/tabs.ts @@ -436,22 +436,22 @@ export class FASTTabs extends FASTElement { switch (event.key) { case keyArrowLeft: event.preventDefault(); - this.adjustBackward(event); + this.adjust(-1); break; case keyArrowRight: event.preventDefault(); - this.adjustForward(event); + this.adjust(+1); break; } } else { switch (event.key) { case keyArrowUp: event.preventDefault(); - this.adjustBackward(event); + this.adjust(-1); break; case keyArrowDown: event.preventDefault(); - this.adjustForward(event); + this.adjust(+1); break; } } @@ -467,6 +467,22 @@ export class FASTTabs extends FASTElement { } }; + private getFocusableTabs(): HTMLElement[] { + if (!this.tabOrder) { + return []; + } + const focusableTabs: HTMLElement[] = []; + this.tabOrder.forEach(tabId => { + const tabElement: HTMLElement | undefined = this.tabs.find( + tab => tab.id === tabId + ); + if (tabElement && this.isFocusableElement(tabElement)) { + focusableTabs.push(tabElement); + } + }); + return focusableTabs; + } + /** * The adjust method for FASTTabs * @public @@ -477,94 +493,31 @@ export class FASTTabs extends FASTElement { if (!this.tabOrder || !this.activeid) { return; } - const focusableTabs: HTMLElement[] = []; - let tabElement: HTMLElement | undefined; - let currentActiveTabIndex: number = 0; - this.tabOrder.forEach(tabId => { - tabElement = this.tabs.find(tab => { - return this.isFocusableElement(tab); - }); - if (tabElement) { - focusableTabs.push(tabElement); - if (tabElement.id === this.activeid) { - currentActiveTabIndex = focusableTabs.length - 1; - } - } - }); + const focusableTabs: HTMLElement[] = this.getFocusableTabs(); + const currentActiveTabIndex: number = focusableTabs.findIndex( + element => element.id === this.activeid + ); + if (currentActiveTabIndex === -1) { + return; + } - const nextTabIndex = limit( + const nextIndex: number = limit( 0, focusableTabs.length - 1, currentActiveTabIndex + adjustment ); - // the index of the next focusable tab within the context of all available tabs - const nextIndex = this.tabs.indexOf(focusableTabs[nextTabIndex]); - if (nextIndex > -1) { - this.moveToTabByIndex(nextIndex); + this.activeid = focusableTabs[nextIndex].id; + this.focusTab(); } } - private adjustForward(e: KeyboardEvent): void { - let index: number = 0; - - index = this.activetab ? this.tabs.indexOf(this.activetab) + 1 : 1; - if (index === this.tabs.length) { - index = 0; - } - - while (index < this.tabs.length && this.tabs.length > 1) { - if (this.isFocusableElement(this.tabs[index])) { - this.moveToTabByIndex(index); - break; - } else if (this.activetab && index === this.tabs.indexOf(this.activetab)) { - break; - } else if (index + 1 >= this.tabs.length) { - index = 0; - } else { - index += 1; - } - } - } - - private adjustBackward(e: KeyboardEvent): void { - let index: number = 0; - - index = this.activetab ? this.tabs.indexOf(this.activetab) - 1 : 0; - index = index < 0 ? this.tabs.length - 1 : index; - - while (index >= 0 && this.tabs.length > 1) { - if (this.isFocusableElement(this.tabs[index])) { - this.moveToTabByIndex(index); - break; - } else if (index - 1 < 0) { - index = this.tabs.length - 1; - } else { - index -= 1; - } - } - } - - private moveToTabByIndex(index: number) { - const tab: HTMLElement = this.tabs[index]; - this.activeid = tab.id; - this.focusTab(); - } - private focusTab(): void { if (this.activetab) { this.activetab.focus(); } } - - private getRootActiveElement(): Element | null { - const rootNode = this.getRootNode(); - if (rootNode instanceof ShadowRoot) { - return rootNode.activeElement; - } - return document.activeElement; - } } /** From 40552ee46e9381fe08d915739cdf4488d0c8df04 Mon Sep 17 00:00:00 2001 From: Stephane Comeau Date: Wed, 17 Jan 2024 11:53:47 -0800 Subject: [PATCH 3/9] setindex --- packages/web-components/fast-foundation/src/tabs/tabs.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/web-components/fast-foundation/src/tabs/tabs.ts b/packages/web-components/fast-foundation/src/tabs/tabs.ts index bfa090c6a3a..0f927808b47 100644 --- a/packages/web-components/fast-foundation/src/tabs/tabs.ts +++ b/packages/web-components/fast-foundation/src/tabs/tabs.ts @@ -157,9 +157,12 @@ export class FASTTabs extends FASTElement { ) { return; } - const targetTab = this.tabs.find(tab => tab.id === this.activeid); - if (targetTab) { - this.activeid = targetTab.id; + const targetTabId: string = this.tabOrder[index]; + const tabElement: HTMLElement | undefined = this.tabs.find( + (tab: HTMLElement) => tab.id === targetTabId + ); + if (tabElement && this.isFocusableElement(tabElement)) { + this.activeid = tabElement.id; } } From bcfe7bd40f3405d92b1557f65e99665d013086e4 Mon Sep 17 00:00:00 2001 From: Stephane Comeau Date: Mon, 22 Jan 2024 00:59:52 -0800 Subject: [PATCH 4/9] customTabOrder --- .../fast-foundation/docs/api-report.md | 23 +++++- .../fast-foundation/src/tabs/README.md | 22 +++--- .../fast-foundation/src/tabs/tabs.ts | 72 +++++++++---------- 3 files changed, 67 insertions(+), 50 deletions(-) diff --git a/packages/web-components/fast-foundation/docs/api-report.md b/packages/web-components/fast-foundation/docs/api-report.md index 6c297230a42..6852e2b8ece 100644 --- a/packages/web-components/fast-foundation/docs/api-report.md +++ b/packages/web-components/fast-foundation/docs/api-report.md @@ -1955,17 +1955,33 @@ export class FASTTabPanel extends FASTElement { // // @public export class FASTTabs extends FASTElement { - activeid: string; + activeid: string | undefined; // @internal (undocumented) - activeidChanged(oldValue: string, newValue: string): void; - activetab: HTMLElement; + activeidChanged(): void; + get activetab(): HTMLElement | undefined; + set activetab(tabElement: HTMLElement | undefined); + get activeTabIndex(): number; + set activeTabIndex(index: number); adjust(adjustment: number): void; // @internal (undocumented) connectedCallback(): void; + customTabOrder: string[] | undefined; + // (undocumented) + customTabOrderChanged(): void; + // @internal (undocumented) + disconnectedCallback(): void; + // @internal + handleTabKeyDown: (event: KeyboardEvent) => void; + // @internal + handleTabMouseDown: (event: MouseEvent) => void; orientation: TabsOrientation; // @internal (undocumented) orientationChanged(): void; protected setTabs(): void; + // @internal + tabList: HTMLElement; + // @internal + tabOrder: string[]; // @internal (undocumented) tabpanels: HTMLElement[]; // @internal (undocumented) @@ -1974,6 +1990,7 @@ export class FASTTabs extends FASTElement { tabs: HTMLElement[]; // @internal (undocumented) tabsChanged(): void; + protected updateActiveid(): void; } // @internal diff --git a/packages/web-components/fast-foundation/src/tabs/README.md b/packages/web-components/fast-foundation/src/tabs/README.md index 88757a44664..e2f8c9bc1f1 100644 --- a/packages/web-components/fast-foundation/src/tabs/README.md +++ b/packages/web-components/fast-foundation/src/tabs/README.md @@ -126,18 +126,22 @@ export const myTabs = Tabs.compose({ #### Fields -| Name | Privacy | Type | Default | Description | Inherited From | -| ------------- | ------- | ----------------- | ------- | ----------------------------- | -------------- | -| `orientation` | public | `TabsOrientation` | | The orientation | | -| `activeid` | public | `string` | | The id of the active tab | | -| `activetab` | public | `HTMLElement` | | A reference to the active tab | | +| Name | Privacy | Type | Default | Description | Inherited From | +| ---------------- | ------- | -------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------ | -------------- | +| `orientation` | public | `TabsOrientation` | | The orientation | | +| `activeid` | public | `string or undefined` | | The id of the active tab | | +| `customTabOrder` | public | `string[] or undefined` | | An array of id's that specifies the order of tabs. This overrides the component's default DOM order based tabOrder | | +| `activetab` | public | `HTMLElement or undefined` | | Sets the active tab Element | | +| `activeTabIndex` | public | `number` | | Sets the active tab index based on tabOrder. If the target tab is not focusable setting this will have no effect. | | #### Methods -| Name | Privacy | Description | Parameters | Return | Inherited From | -| --------- | ------- | --------------------------------------------------------------------------------- | -------------------- | ------ | -------------- | -| `setTabs` | public | Function that is invoked whenever the selected tab or the tab collection changes. | | `void` | | -| `adjust` | public | The adjust method for FASTTabs | `adjustment: number` | `void` | | +| Name | Privacy | Description | Parameters | Return | Inherited From | +| ----------------------- | --------- | ------------------------------------------------------------- | -------------------- | ------ | -------------- | +| `customTabOrderChanged` | public | | | `void` | | +| `setTabs` | public | Function that is invoked whenever the tab collection changes. | | `void` | | +| `updateActiveid` | protected | Function that is invoked whenever the active tab changes. | | `void` | | +| `adjust` | public | The adjust method for FASTTabs | `adjustment: number` | `void` | | #### Events diff --git a/packages/web-components/fast-foundation/src/tabs/tabs.ts b/packages/web-components/fast-foundation/src/tabs/tabs.ts index 0f927808b47..d4f4a056fe7 100644 --- a/packages/web-components/fast-foundation/src/tabs/tabs.ts +++ b/packages/web-components/fast-foundation/src/tabs/tabs.ts @@ -66,42 +66,35 @@ export class FASTTabs extends FASTElement { /** * An array of id's that specifies the order of tabs. - * If an author does not specify this (or sets it to undefined) - * the component will create and manage the tabOrder - * based on the order of tab elements in the DOM. - * The component uses this to manage keyboard navigation. + * This overrides the component's default DOM order based tabOrder * * @public */ @observable - public tabOrder: string[] | undefined = []; - /** - * @internal - */ - public tabOrderChanged(prev: string[] | undefined, next: string[] | undefined): void { - if (!this.$fastController.isConnected) { - return; - } - if (!this.tabOrder) { - // setting the tabOrder to undefined prompts the component take over - this.manageTabOrder = true; + public customTabOrder: string[] | undefined; + public customTabOrderChanged(): void { + if (this.customTabOrder) { + this.tabOrder = this.customTabOrder; + } else { this.tabOrder = []; - this.queueTabUpdate(); - return; } - - if (!prev && next === [] && this.manageTabOrder) { - // avoid self-triggering - return; + if (this.$fastController.isConnected) { + this.queueTabUpdate(); } - - // this is now an author managed list - // Note: component code should not replace the array instance and - // modify the existing array instead - this.manageTabOrder = false; - this.queueTabUpdate(); } + /** + * An array of id's that specifies the order of tabs. + * If an author does not specify this (or sets it to undefined) + * the component will create and manage the tabOrder + * based on the order of tab elements in the DOM unless authors set a customTabOrder. + * The component uses this to manage keyboard navigation. + * + * @internal + */ + @observable + public tabOrder: string[] = []; + /** * Gets the active tab Element * @public @@ -200,7 +193,6 @@ export class FASTTabs extends FASTElement { */ public tabList!: HTMLElement; - private manageTabOrder: boolean = true; private mutationObserver: MutationObserver | undefined; private tabUpdateQueued: boolean = false; private updatingActiveid: boolean = false; @@ -221,13 +213,6 @@ export class FASTTabs extends FASTElement { return this.orientation === TabsOrientation.horizontal; } - private isValidTabId(tabId: string): boolean { - if (this.tabOrder && this.tabOrder.includes(tabId)) { - return true; - } - return false; - } - /** * @internal */ @@ -328,7 +313,7 @@ export class FASTTabs extends FASTElement { } }); - if (this.manageTabOrder && this.tabOrder) { + if (!this.customTabOrder) { this.tabOrder.splice(0, this.tabOrder.length, ...newTabIds); } } @@ -517,10 +502,21 @@ export class FASTTabs extends FASTElement { } private focusTab(): void { - if (this.activetab) { - this.activetab.focus(); + const activeTab: HTMLElement | undefined = this.activetab; + if (activeTab && this.getRootActiveElement() !== activeTab) { + activeTab.focus(); } } + + private getRootActiveElement(): Element | null { + const rootNode = this.getRootNode(); + + if (rootNode instanceof ShadowRoot) { + return rootNode.activeElement; + } + + return document.activeElement; + } } /** From df8312a4884e103655b8bf1cc2fd42ff1e16f498 Mon Sep 17 00:00:00 2001 From: Stephane Comeau Date: Mon, 22 Jan 2024 20:42:35 -0800 Subject: [PATCH 5/9] fix tests --- .../fast-foundation/src/tabs/tabs.ts | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/web-components/fast-foundation/src/tabs/tabs.ts b/packages/web-components/fast-foundation/src/tabs/tabs.ts index d4f4a056fe7..a5646d3461d 100644 --- a/packages/web-components/fast-foundation/src/tabs/tabs.ts +++ b/packages/web-components/fast-foundation/src/tabs/tabs.ts @@ -298,20 +298,21 @@ export class FASTTabs extends FASTElement { // Do a second pass to set other default id based attributes if they have not been set. // This enables a default behavior where tabs and tabpanels are associated based on // their index in the DOM (ie. first tab associated with first tab panel, etc...) - this.tabpanels.forEach((tabPanel: HTMLElement, index: number) => { - if (!tabPanel.hasAttribute("aria-labelledby") && newTabIds.length > index) { + if ( + this.tabpanels.length === this.tabs.length && + this.customTabOrder === undefined + ) { + this.tabpanels.forEach((tabPanel: HTMLElement, index: number) => { // if a tab does not already have a controlled panel defined - // assign the id of the panel of the same index if it exists + // assign the id of the panel of the same index tabPanel.setAttribute("aria-labelledby", newTabIds[index]); - } - }); - this.tabs.forEach((tab: HTMLElement, index: number) => { - if (!tab.hasAttribute("aria-controls") && newTabPanelIds.length > index) { + }); + this.tabs.forEach((tab: HTMLElement, index: number) => { // if a tab does not already have a controlled panel defined - // assign the id of the panel of the same index if it exists + // assign the id of the panel of the same index tab.setAttribute("aria-controls", newTabPanelIds[index]); - } - }); + }); + } if (!this.customTabOrder) { this.tabOrder.splice(0, this.tabOrder.length, ...newTabIds); From 216e699edfa01a9d160c35bb0e3fbcde1c10b99c Mon Sep 17 00:00:00 2001 From: Stephane Comeau Date: Mon, 22 Jan 2024 22:57:06 -0800 Subject: [PATCH 6/9] example --- .../src/tabs/stories/tabs.stories.ts | 15 +++++++++++++++ .../fast-foundation/src/tabs/tabs.ts | 4 ++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/web-components/fast-foundation/src/tabs/stories/tabs.stories.ts b/packages/web-components/fast-foundation/src/tabs/stories/tabs.stories.ts index 66107519f12..f0cb2471467 100644 --- a/packages/web-components/fast-foundation/src/tabs/stories/tabs.stories.ts +++ b/packages/web-components/fast-foundation/src/tabs/stories/tabs.stories.ts @@ -95,3 +95,18 @@ DisabledTabs.args = { ], }, }; + +const tabOrderStoryTemplate = html>` + + Tab 1 + Tab 2 + Tab 3 + Tab 1 + Tab 2 + Tab 3 + +`; + +export const WithTabOrder: Story = renderComponent(tabOrderStoryTemplate).bind( + {} +); diff --git a/packages/web-components/fast-foundation/src/tabs/tabs.ts b/packages/web-components/fast-foundation/src/tabs/tabs.ts index a5646d3461d..ff9ab0f8b23 100644 --- a/packages/web-components/fast-foundation/src/tabs/tabs.ts +++ b/packages/web-components/fast-foundation/src/tabs/tabs.ts @@ -303,8 +303,8 @@ export class FASTTabs extends FASTElement { this.customTabOrder === undefined ) { this.tabpanels.forEach((tabPanel: HTMLElement, index: number) => { - // if a tab does not already have a controlled panel defined - // assign the id of the panel of the same index + // if a tab-panel does not already have a labbelledby defined + // assign the id of the tab of the same index tabPanel.setAttribute("aria-labelledby", newTabIds[index]); }); this.tabs.forEach((tab: HTMLElement, index: number) => { From 633b3861a294e3c4e2e9f84e1568de4a4b470cd3 Mon Sep 17 00:00:00 2001 From: Stephane Comeau Date: Tue, 23 Jan 2024 09:34:55 -0800 Subject: [PATCH 7/9] shared panel --- .../src/tabs/stories/tabs.stories.ts | 26 ++++++++++++++----- .../fast-foundation/src/tabs/tabs.ts | 21 +++++++++++---- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/packages/web-components/fast-foundation/src/tabs/stories/tabs.stories.ts b/packages/web-components/fast-foundation/src/tabs/stories/tabs.stories.ts index f0cb2471467..4729f4959e5 100644 --- a/packages/web-components/fast-foundation/src/tabs/stories/tabs.stories.ts +++ b/packages/web-components/fast-foundation/src/tabs/stories/tabs.stories.ts @@ -101,12 +101,26 @@ const tabOrderStoryTemplate = html>` Tab 1 Tab 2 Tab 3 - Tab 1 - Tab 2 - Tab 3 + Tab 1 + Tab 2 + Tab 3 `; -export const WithTabOrder: Story = renderComponent(tabOrderStoryTemplate).bind( - {} -); +export const TabsWithTabOrder: Story = renderComponent( + tabOrderStoryTemplate +).bind({}); + +const tabOrderSharedPanelStoryTemplate = html>` + + Tab 1 + Tab 2 + Tab 3 + Tab 1 + Tab 2 and 3 share this + +`; + +export const TabsWithSharedPanel: Story = renderComponent( + tabOrderSharedPanelStoryTemplate +).bind({}); diff --git a/packages/web-components/fast-foundation/src/tabs/tabs.ts b/packages/web-components/fast-foundation/src/tabs/tabs.ts index ff9ab0f8b23..2c6167646a6 100644 --- a/packages/web-components/fast-foundation/src/tabs/tabs.ts +++ b/packages/web-components/fast-foundation/src/tabs/tabs.ts @@ -304,12 +304,14 @@ export class FASTTabs extends FASTElement { ) { this.tabpanels.forEach((tabPanel: HTMLElement, index: number) => { // if a tab-panel does not already have a labbelledby defined - // assign the id of the tab of the same index + // assign the id of the tab of the same index. + // When using a custom tab order the labelledby attribute is only + // applied when a tab-panel is selected. tabPanel.setAttribute("aria-labelledby", newTabIds[index]); }); this.tabs.forEach((tab: HTMLElement, index: number) => { // if a tab does not already have a controlled panel defined - // assign the id of the panel of the same index + // assign the id of the panel of the same index. tab.setAttribute("aria-controls", newTabPanelIds[index]); }); } @@ -391,9 +393,18 @@ export class FASTTabs extends FASTElement { }); this.tabpanels.forEach((tabpanel: HTMLElement, index: number) => { - tabpanel.id !== activeTab?.getAttribute("aria-controls") - ? tabpanel.setAttribute("hidden", "") - : tabpanel.removeAttribute("hidden"); + if ( + this.activeid && + this.activeid !== "" && + tabpanel.id === activeTab?.getAttribute("aria-controls") + ) { + tabpanel.removeAttribute("hidden"); + // update labelled-by as a single panel can be associated with multiple tab elements + // this ensures the currently active panel is labelled by the currently active tab. + tabpanel.setAttribute("aria-labelledby", this.activeid); + } else { + tabpanel.setAttribute("hidden", ""); + } }); this.updatingActiveid = false; From 420efc1922f947e91b91454610059932b21fae3b Mon Sep 17 00:00:00 2001 From: Stephane Comeau Date: Tue, 23 Jan 2024 10:42:08 -0800 Subject: [PATCH 8/9] tweak --- .../fast-foundation/src/tabs/tabs.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/web-components/fast-foundation/src/tabs/tabs.ts b/packages/web-components/fast-foundation/src/tabs/tabs.ts index 2c6167646a6..9c06d68a1b5 100644 --- a/packages/web-components/fast-foundation/src/tabs/tabs.ts +++ b/packages/web-components/fast-foundation/src/tabs/tabs.ts @@ -295,25 +295,19 @@ export class FASTTabs extends FASTElement { newTabIds.push(tab.id); }); - // Do a second pass to set other default id based attributes if they have not been set. + // Do a second pass to set other default id based attributes. // This enables a default behavior where tabs and tabpanels are associated based on // their index in the DOM (ie. first tab associated with first tab panel, etc...) if ( this.tabpanels.length === this.tabs.length && this.customTabOrder === undefined ) { - this.tabpanels.forEach((tabPanel: HTMLElement, index: number) => { - // if a tab-panel does not already have a labbelledby defined - // assign the id of the tab of the same index. - // When using a custom tab order the labelledby attribute is only - // applied when a tab-panel is selected. - tabPanel.setAttribute("aria-labelledby", newTabIds[index]); - }); this.tabs.forEach((tab: HTMLElement, index: number) => { - // if a tab does not already have a controlled panel defined - // assign the id of the panel of the same index. tab.setAttribute("aria-controls", newTabPanelIds[index]); }); + this.tabpanels.forEach((tabPanel: HTMLElement, index: number) => { + tabPanel.setAttribute("aria-labelledby", newTabIds[index]); + }); } if (!this.customTabOrder) { From e17f4a1fddf95a2c39ae9e6409d602bc1efebadb Mon Sep 17 00:00:00 2001 From: Stephane Comeau Date: Tue, 23 Jan 2024 16:20:13 -0800 Subject: [PATCH 9/9] narrator hack --- packages/web-components/fast-foundation/src/tabs/tabs.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/web-components/fast-foundation/src/tabs/tabs.ts b/packages/web-components/fast-foundation/src/tabs/tabs.ts index 9c06d68a1b5..7ee6da10638 100644 --- a/packages/web-components/fast-foundation/src/tabs/tabs.ts +++ b/packages/web-components/fast-foundation/src/tabs/tabs.ts @@ -369,6 +369,13 @@ export class FASTTabs extends FASTElement { if (tabElement) { if (tabId === this.activeid) { activeTab = tabElement; + tabElement.setAttribute("role", "tab"); + tabElement.setAttribute("aria-posinset", `${index + 1}`); + tabElement.setAttribute("aria-setsize", `${this.tabOrder.length}`); + } else { + tabElement.removeAttribute("role"); + tabElement.removeAttribute("aria-posinset"); + tabElement.removeAttribute("aria-setsize"); } // If the original property isn't emptied out, // the next set will morph into a grid-area style setting that is not what we want