From bda619cdabcf8b25f269eb830d5ce2f7e649cabf Mon Sep 17 00:00:00 2001 From: Erik Harper Date: Tue, 23 Jul 2024 10:29:59 -0700 Subject: [PATCH] fix(tabs): handle tab close events that remove the associated tab-title and tab elements from the DOM (#9768) **Related Issue:** #7155 ## Summary This issue resolves some issues that happen when corresponding `calcite-tab` and `calcite-tab-title` elements are removed from the DOM when they are closed. --- .../calcite-components/src/components.d.ts | 66 +++--- .../src/components/tab-nav/tab-nav.tsx | 62 +++--- .../src/components/tab/tab.tsx | 29 +-- .../src/components/tabs/tabs.e2e.ts | 26 +++ .../src/components/tabs/tabs.tsx | 200 +++++++----------- .../calcite-components/src/demos/tabs.html | 57 +++++ .../calcite-components/src/utils/dom.spec.ts | 34 +++ packages/calcite-components/src/utils/dom.ts | 30 ++- 8 files changed, 288 insertions(+), 216 deletions(-) diff --git a/packages/calcite-components/src/components.d.ts b/packages/calcite-components/src/components.d.ts index 01c09a5418f..a9bcda91037 100644 --- a/packages/calcite-components/src/components.d.ts +++ b/packages/calcite-components/src/components.d.ts @@ -983,7 +983,7 @@ export namespace Components { "status": Status; /** * The current validation state of the component. - * @readonly + * @readonly * @mdn [ValidityState](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState) */ "validity": MutableValidityState; @@ -1347,7 +1347,7 @@ export namespace Components { "validationMessage": string; /** * The current validation state of the component. - * @readonly + * @readonly * @mdn [ValidityState](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState) */ "validity": MutableValidityState; @@ -2282,7 +2282,7 @@ export namespace Components { "validationMessage": string; /** * The current validation state of the component. - * @readonly + * @readonly * @mdn [ValidityState](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState) */ "validity": MutableValidityState; @@ -2405,7 +2405,7 @@ export namespace Components { "validationMessage": string; /** * The current validation state of the component. - * @readonly + * @readonly * @mdn [ValidityState](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState) */ "validity": MutableValidityState; @@ -2600,7 +2600,7 @@ export namespace Components { "validationMessage": string; /** * The current validation state of the component. - * @readonly + * @readonly * @mdn [ValidityState](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState) */ "validity": MutableValidityState; @@ -2741,7 +2741,7 @@ export namespace Components { "validationMessage": string; /** * The current validation state of the component. - * @readonly + * @readonly * @mdn [ValidityState](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState) */ "validity": MutableValidityState; @@ -2841,7 +2841,7 @@ export namespace Components { "validationMessage": string; /** * The current validation state of the component. - * @readonly + * @readonly * @mdn [ValidityState](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState) */ "validity": MutableValidityState; @@ -2929,7 +2929,7 @@ export namespace Components { "validationMessage": string; /** * The current validation state of the component. - * @readonly + * @readonly * @mdn [ValidityState](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState) */ "validity": MutableValidityState; @@ -4225,7 +4225,7 @@ export namespace Components { "validationMessage": string; /** * The current validation state of the component. - * @readonly + * @readonly * @mdn [ValidityState](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState) */ "validity": MutableValidityState; @@ -4320,7 +4320,7 @@ export namespace Components { "validationMessage": string; /** * The current validation state of the component. - * @readonly + * @readonly * @mdn [ValidityState](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState) */ "validity": MutableValidityState; @@ -5230,7 +5230,7 @@ export namespace Components { "placeholder": string; /** * When `true`, the component's `value` can be read, but cannot be modified. - * @readonly + * @readonly * @mdn [readOnly](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/readonly) */ "readOnly": boolean; @@ -5274,7 +5274,7 @@ export namespace Components { "validationMessage": string; /** * The current validation state of the component. - * @readonly + * @readonly * @mdn [ValidityState](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState) */ "validity": MutableValidityState; @@ -6056,10 +6056,6 @@ export interface CalciteSwitchCustomEvent extends CustomEvent { detail: T; target: HTMLCalciteSwitchElement; } -export interface CalciteTabCustomEvent extends CustomEvent { - detail: T; - target: HTMLCalciteTabElement; -} export interface CalciteTabNavCustomEvent extends CustomEvent { detail: T; target: HTMLCalciteTabNavElement; @@ -7496,18 +7492,7 @@ declare global { prototype: HTMLCalciteSwitchElement; new (): HTMLCalciteSwitchElement; }; - interface HTMLCalciteTabElementEventMap { - "calciteInternalTabRegister": void; - } interface HTMLCalciteTabElement extends Components.CalciteTab, HTMLStencilElement { - addEventListener(type: K, listener: (this: HTMLCalciteTabElement, ev: CalciteTabCustomEvent) => any, options?: boolean | AddEventListenerOptions): void; - addEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; - addEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; - addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; - removeEventListener(type: K, listener: (this: HTMLCalciteTabElement, ev: CalciteTabCustomEvent) => any, options?: boolean | EventListenerOptions): void; - removeEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void; - removeEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void; - removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void; } var HTMLCalciteTabElement: { prototype: HTMLCalciteTabElement; @@ -7515,6 +7500,7 @@ declare global { }; interface HTMLCalciteTabNavElementEventMap { "calciteTabChange": void; + "calciteInternalTabNavSlotChange": Element[]; "calciteInternalTabChange": TabChangeEventDetail; } interface HTMLCalciteTabNavElement extends Components.CalciteTabNav, HTMLStencilElement { @@ -8809,7 +8795,7 @@ declare namespace LocalJSX { "status"?: Status; /** * The current validation state of the component. - * @readonly + * @readonly * @mdn [ValidityState](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState) */ "validity"?: MutableValidityState; @@ -9202,7 +9188,7 @@ declare namespace LocalJSX { "validationMessage"?: string; /** * The current validation state of the component. - * @readonly + * @readonly * @mdn [ValidityState](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState) */ "validity"?: MutableValidityState; @@ -10178,7 +10164,7 @@ declare namespace LocalJSX { "validationMessage"?: string; /** * The current validation state of the component. - * @readonly + * @readonly * @mdn [ValidityState](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState) */ "validity"?: MutableValidityState; @@ -10312,7 +10298,7 @@ declare namespace LocalJSX { "validationMessage"?: string; /** * The current validation state of the component. - * @readonly + * @readonly * @mdn [ValidityState](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState) */ "validity"?: MutableValidityState; @@ -10509,7 +10495,7 @@ declare namespace LocalJSX { "validationMessage"?: string; /** * The current validation state of the component. - * @readonly + * @readonly * @mdn [ValidityState](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState) */ "validity"?: MutableValidityState; @@ -10655,7 +10641,7 @@ declare namespace LocalJSX { "validationMessage"?: string; /** * The current validation state of the component. - * @readonly + * @readonly * @mdn [ValidityState](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState) */ "validity"?: MutableValidityState; @@ -10766,7 +10752,7 @@ declare namespace LocalJSX { "validationMessage"?: string; /** * The current validation state of the component. - * @readonly + * @readonly * @mdn [ValidityState](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState) */ "validity"?: MutableValidityState; @@ -10873,7 +10859,7 @@ declare namespace LocalJSX { "validationMessage"?: string; /** * The current validation state of the component. - * @readonly + * @readonly * @mdn [ValidityState](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState) */ "validity"?: MutableValidityState; @@ -12232,7 +12218,7 @@ declare namespace LocalJSX { "validationMessage"?: string; /** * The current validation state of the component. - * @readonly + * @readonly * @mdn [ValidityState](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState) */ "validity"?: MutableValidityState; @@ -12331,7 +12317,7 @@ declare namespace LocalJSX { "validationMessage"?: string; /** * The current validation state of the component. - * @readonly + * @readonly * @mdn [ValidityState](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState) */ "validity"?: MutableValidityState; @@ -12875,7 +12861,6 @@ declare namespace LocalJSX { "value"?: any; } interface CalciteTab { - "onCalciteInternalTabRegister"?: (event: CalciteTabCustomEvent) => void; /** * Specifies the size of the component inherited from the parent `calcite-tabs`, defaults to `m`. */ @@ -12903,6 +12888,7 @@ declare namespace LocalJSX { */ "messages"?: TabNavMessages; "onCalciteInternalTabChange"?: (event: CalciteTabNavCustomEvent) => void; + "onCalciteInternalTabNavSlotChange"?: (event: CalciteTabNavCustomEvent) => void; /** * Emits when the selected `calcite-tab` changes. */ @@ -13278,7 +13264,7 @@ declare namespace LocalJSX { "placeholder"?: string; /** * When `true`, the component's `value` can be read, but cannot be modified. - * @readonly + * @readonly * @mdn [readOnly](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/readonly) */ "readOnly"?: boolean; @@ -13314,7 +13300,7 @@ declare namespace LocalJSX { "validationMessage"?: string; /** * The current validation state of the component. - * @readonly + * @readonly * @mdn [ValidityState](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState) */ "validity"?: MutableValidityState; diff --git a/packages/calcite-components/src/components/tab-nav/tab-nav.tsx b/packages/calcite-components/src/components/tab-nav/tab-nav.tsx index d1ef6cbba99..adaccde7ceb 100644 --- a/packages/calcite-components/src/components/tab-nav/tab-nav.tsx +++ b/packages/calcite-components/src/components/tab-nav/tab-nav.tsx @@ -23,6 +23,7 @@ import { focusElementInGroup, FocusElementInGroupDestination, getElementDir, + slotChangeGetAssignedElements, } from "../../utils/dom"; import { createObserver } from "../../utils/observers"; import { Scale } from "../interfaces"; @@ -73,6 +74,14 @@ export class TabNav implements LocalizedComponent, T9nComponent { */ @Prop({ mutable: true }) selectedTitle: HTMLCalciteTabTitleElement = null; + @Watch("selectedTitle") + selectedTitleChanged(): void { + this.calciteInternalTabChange.emit({ + tab: this.selectedTabId, + }); + this.updateActiveIndicator(); + } + /** * Specifies the size of the component inherited from the parent `calcite-tabs`, defaults to `m`. * @@ -126,29 +135,6 @@ export class TabNav implements LocalizedComponent, T9nComponent { /* wired up by t9n util */ } - @Watch("selectedTabId") - async selectedTabIdChanged(): Promise { - if ( - localStorage && - this.storageId && - this.selectedTabId !== undefined && - this.selectedTabId !== null - ) { - localStorage.setItem(`calcite-tab-nav-${this.storageId}`, JSON.stringify(this.selectedTabId)); - } - - this.calciteInternalTabChange.emit({ - tab: this.selectedTabId, - }); - - this.selectedTitle = await this.getTabTitleById(this.selectedTabId); - } - - @Watch("selectedTitle") - selectedTitleChanged(): void { - this.updateActiveIndicator(); - } - //-------------------------------------------------------------------------- // // Lifecycle @@ -297,6 +283,7 @@ export class TabNav implements LocalizedComponent, T9nComponent { : this.getIndexOfTabTitle(activatedTabTitle); event.stopPropagation(); + this.selectedTitle = activatedTabTitle; this.scrollTabTitleIntoView(activatedTabTitle); } @@ -360,9 +347,10 @@ export class TabNav implements LocalizedComponent, T9nComponent { * @param event */ @Listen("calciteInternalTabTitleRegister") - updateTabTitles(event: CustomEvent): void { + async updateTabTitles(event: CustomEvent): Promise { if ((event.target as HTMLCalciteTabTitleElement).selected) { this.selectedTabId = event.detail; + this.selectedTitle = await this.getTabTitleById(this.selectedTabId); } } @@ -395,6 +383,11 @@ export class TabNav implements LocalizedComponent, T9nComponent { */ @Event({ cancelable: false }) calciteTabChange: EventEmitter; + /** + * @internal + */ + @Event() calciteInternalTabNavSlotChange: EventEmitter; + /** * @internal */ @@ -423,6 +416,22 @@ export class TabNav implements LocalizedComponent, T9nComponent { @State() private selectedTabId: TabID; + @Watch("selectedTabId") + async selectedTabIdChanged(): Promise { + if ( + localStorage && + this.storageId && + this.selectedTabId !== undefined && + this.selectedTabId !== null + ) { + localStorage.setItem(`calcite-tab-nav-${this.storageId}`, JSON.stringify(this.selectedTabId)); + } + + this.calciteInternalTabChange.emit({ + tab: this.selectedTabId, + }); + } + private activeIndicatorEl: HTMLElement; private activeIndicatorContainerEl: HTMLDivElement; @@ -501,10 +510,11 @@ export class TabNav implements LocalizedComponent, T9nComponent { private onSlotChange = (event: Event): void => { this.intersectionObserver?.disconnect(); - const slottedChildren = (event.target as HTMLSlotElement).assignedElements(); - slottedChildren.forEach((child) => { + const slottedElements = slotChangeGetAssignedElements(event, "calcite-tab-title"); + slottedElements.forEach((child) => { this.intersectionObserver?.observe(child); }); + this.calciteInternalTabNavSlotChange.emit(slottedElements); }; private storeContainerRef = (el: HTMLDivElement) => (this.containerEl = el); diff --git a/packages/calcite-components/src/components/tab/tab.tsx b/packages/calcite-components/src/components/tab/tab.tsx index 7120ad97278..0ad0d719934 100644 --- a/packages/calcite-components/src/components/tab/tab.tsx +++ b/packages/calcite-components/src/components/tab/tab.tsx @@ -1,16 +1,4 @@ -import { - Component, - Element, - Event, - EventEmitter, - h, - Host, - Listen, - Method, - Prop, - State, - VNode, -} from "@stencil/core"; +import { Component, Element, h, Host, Listen, Method, Prop, State, VNode } from "@stencil/core"; import { nodeListToArray } from "../../utils/dom"; import { guid } from "../../utils/guid"; import { Scale } from "../interfaces"; @@ -81,10 +69,6 @@ export class Tab { this.parentTabsEl = this.el.closest("calcite-tabs"); } - componentDidLoad(): void { - this.calciteInternalTabRegister.emit(); - } - disconnectedCallback(): void { // Dispatching to body in order to be listened by other elements that are still connected to the DOM. document.body?.dispatchEvent( @@ -94,17 +78,6 @@ export class Tab { ); } - //-------------------------------------------------------------------------- - // - // Events - // - //-------------------------------------------------------------------------- - - /** - * @internal - */ - @Event({ cancelable: false }) calciteInternalTabRegister: EventEmitter; - //-------------------------------------------------------------------------- // // Event Listeners diff --git a/packages/calcite-components/src/components/tabs/tabs.e2e.ts b/packages/calcite-components/src/components/tabs/tabs.e2e.ts index 3f7ec456daa..98bd01bf3f2 100644 --- a/packages/calcite-components/src/components/tabs/tabs.e2e.ts +++ b/packages/calcite-components/src/components/tabs/tabs.e2e.ts @@ -431,5 +431,31 @@ describe("calcite-tabs", () => { expect(await allTabs[2].isVisible()).toBe(false); expect(await allTabs[3].isVisible()).toBe(true); }); + + it("should allow selecting the next tab after previous one is closed and removed from DOM", async () => { + type TestWindow = GlobalTestProps<{ selectedTitleTab: string }>; + + await page.evaluate(() => { + document.addEventListener("calciteTabChange", (event) => { + (window as TestWindow).selectedTitleTab = (event.target as HTMLCalciteTabNavElement).selectedTitle.innerText; + }); + document.addEventListener("calciteTabClose", (event) => { + const closedTabTitleElement = event.target as HTMLCalciteTabTitleElement; + const id = closedTabTitleElement.id.split("").at(-1); + closedTabTitleElement.remove(); + document.querySelector(`calcite-tab#tab-${id}`).remove(); + }); + }); + + const tab2 = await page.find("#tab-title-2"); + + await page.click(`#tab-title-1 >>> .${TabTitleCSS.closeButton}`); + await tab2.click(); + await page.waitForChanges(); + + const selectedTitleOnEmit = await page.evaluate(() => (window as TestWindow).selectedTitleTab); + + expect(selectedTitleOnEmit).toBe("Tab 2 Title"); + }); }); }); diff --git a/packages/calcite-components/src/components/tabs/tabs.tsx b/packages/calcite-components/src/components/tabs/tabs.tsx index a38b152e680..a2450959939 100644 --- a/packages/calcite-components/src/components/tabs/tabs.tsx +++ b/packages/calcite-components/src/components/tabs/tabs.tsx @@ -1,6 +1,6 @@ import { Component, Element, Fragment, h, Listen, Prop, State, VNode, Watch } from "@stencil/core"; import { Scale } from "../interfaces"; -import { createObserver } from "../../utils/observers"; +import { getSlotAssignedElements, slotChangeGetAssignedElements } from "../../utils/dom"; import { TabLayout, TabPosition } from "./interfaces"; import { SLOTS } from "./resources"; @@ -46,92 +46,20 @@ export class Tabs { */ @Prop() bordered = false; - //-------------------------------------------------------------------------- - // - // Lifecycle - // - //-------------------------------------------------------------------------- - - connectedCallback(): void { - this.mutationObserver.observe(this.el, { childList: true }); - this.updateItems(); - } - - async componentWillLoad(): Promise { - this.updateItems(); - } - - disconnectedCallback(): void { - this.mutationObserver?.disconnect(); - } - - render(): VNode { - return ( - - -
- -
-
- ); - } - //-------------------------------------------------------------------------- // // Event Listeners // //-------------------------------------------------------------------------- - /** - * @param event - * @internal - */ - @Listen("calciteInternalTabTitleRegister") - calciteInternalTabTitleRegister(event: CustomEvent): void { - this.titles = [...this.titles, event.target as HTMLCalciteTabTitleElement]; - this.registryHandler(); - event.stopPropagation(); - } - - /** - * @param event - * @internal - */ - @Listen("calciteTabTitleUnregister", { target: "body" }) - calciteTabTitleUnregister(event: CustomEvent): void { - this.titles = this.titles.filter((el) => el !== event.detail); - this.registryHandler(); - event.stopPropagation(); - } - - /** - * @param event - * @internal - */ - @Listen("calciteInternalTabRegister") - calciteInternalTabRegister(event: CustomEvent): void { - this.tabs = [...this.tabs, event.target as HTMLCalciteTabElement]; - this.registryHandler(); - event.stopPropagation(); - } - - /** - * @param event - * @internal - */ - @Listen("calciteTabUnregister", { target: "body" }) - calciteTabUnregister(event: CustomEvent): void { - this.tabs = this.tabs.filter((el) => el !== event.detail); - this.registryHandler(); + @Listen("calciteInternalTabNavSlotChange") + calciteInternalTabNavSlotChangeHandler(event: CustomEvent): void { event.stopPropagation(); + if (event.detail.length !== this.titles.length) { + this.titles = event.detail; + } } - //-------------------------------------------------------------------------- - // - // Events - // - //-------------------------------------------------------------------------- - //-------------------------------------------------------------------------- // // Private State/Props @@ -140,6 +68,12 @@ export class Tabs { @Element() el: HTMLCalciteTabsElement; + private defaultSlotChangeHandler = (event): void => { + this.tabs = slotChangeGetAssignedElements(event, "calcite-tab") as HTMLCalciteTabElement[]; + }; + + private slotEl: HTMLSlotElement; + /** * * Stores an array of ids of ``s to match up ARIA @@ -147,46 +81,22 @@ export class Tabs { */ @State() titles: HTMLCalciteTabTitleElement[] = []; + @Watch("titles") + titlesWatcher(): void { + this.updateAriaSettings(); + this.updateItems(); + } + /** * * Stores an array of ids of ``s to match up ARIA attributes. */ @State() tabs: HTMLCalciteTabElement[] = []; - mutationObserver = createObserver("mutation", (mutationsList: MutationRecord[]) => { - for (const mutation of mutationsList) { - const target = mutation.target as HTMLElement; - if ( - target.nodeName === "CALCITE-TAB-NAV" || - target.nodeName === "CALCITE-TAB-TITLE" || - target.nodeName === "CALCITE-TAB" - ) { - this.updateItems(); - } - } - }); - - private updateItems(): void { - const { position, scale } = this; - - const nav = this.el.querySelector("calcite-tab-nav"); - if (nav) { - nav.position = position; - nav.scale = scale; - } - - Array.from(this.el.querySelectorAll("calcite-tab")).forEach((tab: HTMLCalciteTabElement) => { - if (tab.parentElement === this.el) { - tab.scale = scale; - } - }); - - Array.from(this.el.querySelectorAll("calcite-tab-nav > calcite-tab-title")).forEach( - (title: HTMLCalciteTabTitleElement) => { - title.position = position; - title.scale = scale; - }, - ); + @Watch("tabs") + tabsWatcher(): void { + this.updateAriaSettings(); + this.updateItems(); } //-------------------------------------------------------------------------- @@ -201,15 +111,16 @@ export class Tabs { * update the ARIA attributes and link `` and * `` components. */ - async registryHandler(): Promise { + async updateAriaSettings(): Promise { let tabIds; let titleIds; + const tabs = getSlotAssignedElements(this.slotEl, "calcite-tab"); // determine if we are using `tab` based or `index` based tab identifiers. - if (this.tabs.some((el) => el.tab) || this.titles.some((el) => el.tab)) { + if (tabs.some((el) => el.tab) || this.titles.some((el) => el.tab)) { // if we are using `tab` based identifiers sort by `tab` to account for // possible out of order tabs and get the id of each tab - tabIds = this.tabs.sort((a, b) => a.tab.localeCompare(b.tab)).map((el) => el.id); + tabIds = tabs.sort((a, b) => a.tab.localeCompare(b.tab)).map((el) => el.id); titleIds = this.titles.sort((a, b) => a.tab.localeCompare(b.tab)).map((el) => el.id); } else { // if we are using index based tabs then the `` and @@ -217,14 +128,13 @@ export class Tabs { // order of `this.tabs` and `this.titles` might not reflect the DOM state, // and might not match each other so we need to get the index of all the // tabs and titles in the DOM order to match them up as a source of truth - const tabDomIndexes = await Promise.all(this.tabs.map((el) => el.getTabIndex())); - + const tabDomIndexes = await Promise.all(tabs.map((el) => el.getTabIndex())); const titleDomIndexes = await Promise.all(this.titles.map((el) => el.getTabIndex())); // once we have the DOM order as a source of truth we can build the // matching tabIds and titleIds arrays tabIds = tabDomIndexes.reduce((ids, indexInDOM, registryIndex) => { - ids[indexInDOM] = this.tabs[registryIndex].id; + ids[indexInDOM] = tabs[registryIndex].id; return ids; }, []); @@ -237,7 +147,59 @@ export class Tabs { // pass all our new aria information to each `` and // `` which will check if they can update their internal // `controlled` or `labeledBy` states and re-render if necessary - this.tabs.forEach((el) => el.updateAriaInfo(tabIds, titleIds)); + tabs.forEach((el) => el.updateAriaInfo(tabIds, titleIds)); this.titles.forEach((el) => el.updateAriaInfo(tabIds, titleIds)); } + + private updateItems(): void { + const { position, scale } = this; + + const nav = this.el.querySelector("calcite-tab-nav"); + if (nav) { + nav.position = position; + nav.scale = scale; + } + + Array.from(this.el.querySelectorAll("calcite-tab")).forEach((tab: HTMLCalciteTabElement) => { + if (tab.parentElement === this.el) { + tab.scale = scale; + } + }); + + Array.from(this.el.querySelectorAll("calcite-tab-nav > calcite-tab-title")).forEach( + (title: HTMLCalciteTabTitleElement) => { + title.position = position; + title.scale = scale; + }, + ); + } + + private setDefaultSlotRef = (el) => (this.slotEl = el); + + //-------------------------------------------------------------------------- + // + // Lifecycle + // + //-------------------------------------------------------------------------- + + connectedCallback(): void { + this.updateItems(); + } + + async componentWillLoad(): Promise { + this.updateItems(); + } + + disconnectedCallback(): void {} + + render(): VNode { + return ( + + +
+ +
+
+ ); + } } diff --git a/packages/calcite-components/src/demos/tabs.html b/packages/calcite-components/src/demos/tabs.html index ec9495f21b7..c1170a0952a 100644 --- a/packages/calcite-components/src/demos/tabs.html +++ b/packages/calcite-components/src/demos/tabs.html @@ -367,6 +367,63 @@ + + +
+
closable tabs with title and tab removed from DOM
+ +
+ + + Tab Title 1 + Tab Title 2 + Tab Title 3 + + Tab 1 content + Tab 2 content + Tab 3 content + +
+ +
+ + + Tab Title 1 + Tab Title 2 + Tab Title 3 + + Tab 1 content + Tab 2 content + Tab 3 content + +
+ +
+ + + Tab Title 1 + Tab Title 2 + Tab Title 3 + + Tab 1 content + Tab 2 content + Tab 3 content + +
+
+ + diff --git a/packages/calcite-components/src/utils/dom.spec.ts b/packages/calcite-components/src/utils/dom.spec.ts index d207fa2f6e7..487e95190c1 100644 --- a/packages/calcite-components/src/utils/dom.spec.ts +++ b/packages/calcite-components/src/utils/dom.spec.ts @@ -10,6 +10,7 @@ import { getElementProp, getModeName, getShadowRootNode, + getSlotAssignedElements, getSlotted, isBefore, isKeyboardTriggeredClick, @@ -402,6 +403,39 @@ describe("dom", () => { }); }); + describe("getSlotAssignedElements()", () => { + it("returns slotted elements with no selector", () => { + const slotEl = document.createElement("slot"); + slotEl.assignedElements = () => [document.createElement("div"), document.createElement("div")]; + expect(getSlotAssignedElements(slotEl)).toHaveLength(2); + }); + it("returns no slotted elements", () => { + const slotEl = document.createElement("slot"); + slotEl.assignedElements = () => []; + expect(getSlotAssignedElements(slotEl)).toHaveLength(0); + }); + it("returns slotted elements with direct element selector", () => { + const slotEl = document.createElement("slot"); + slotEl.assignedElements = () => [ + document.createElement("span"), + document.createElement("div"), + document.createElement("span"), + ]; + expect(getSlotAssignedElements(slotEl, "div")).toHaveLength(1); + expect(getSlotAssignedElements(slotEl, "span")).toHaveLength(2); + }); + it("returns slotted elements with class selector", () => { + const slotEl = document.createElement("slot"); + const spanEl = document.createElement("span"); + spanEl.className = "my-span"; + const divEl = document.createElement("div"); + divEl.className = "my-div"; + slotEl.assignedElements = () => [document.createElement("span"), spanEl, document.createElement("div"), divEl]; + expect(getSlotAssignedElements(slotEl, ".my-div")).toHaveLength(1); + expect(getSlotAssignedElements(slotEl, ".my-span")).toHaveLength(1); + }); + }); + describe("slotChangeGetAssignedElements()", () => { it("handles slotted elements", () => { const target = document.createElement("slot"); diff --git a/packages/calcite-components/src/utils/dom.ts b/packages/calcite-components/src/utils/dom.ts index e622ab4194c..4ed42e4cc6a 100644 --- a/packages/calcite-components/src/utils/dom.ts +++ b/packages/calcite-components/src/utils/dom.ts @@ -419,6 +419,17 @@ export function filterDirectChildren(el: Element, selector: s return Array.from(el.children).filter((child): child is T => child.matches(selector)); } +/** + * Filters an array of HTML elements by the provided css selector string. + * + * @param {Element[]} elements An array of elements, such as one returned by HTMLSlotElement.assignedElements(). + * @param {string} selector The CSS selector string to filter the returned elements by. + * @returns {Element[]} A filtered array of elements. + */ +export function filterElementsBySelector(elements: Element[], selector: string): T[] { + return elements.filter((element): element is T => element.matches(selector)); +} + /** * Set a default icon from a defined set or allow an override with an icon name string * @@ -565,12 +576,25 @@ export function slotChangeHasAssignedElement(event: Event): boolean { * ``` * * @param {Event} event The event. - * @returns {boolean} Whether the slot has any assigned elements. + * @param {string} selector The CSS selector string to filter the returned elements by. + * @returns {Element[]} An array of elements. + */ +export function slotChangeGetAssignedElements(event: Event, selector?: string): T[] | null { + return getSlotAssignedElements(event.target as HTMLSlotElement, selector); +} + +/** + * This helper returns the assigned elements on a `slot` element, filtered by an optional css selector. + * + * @param {HTMLSlotElement} slot The slot element. + * @param {string} selector CSS selector string to filter the returned elements by. + * @returns {Element[]} An array of elements. */ -export function slotChangeGetAssignedElements(event: Event): Element[] { - return (event.currentTarget as HTMLSlotElement).assignedElements({ +export function getSlotAssignedElements(slot: HTMLSlotElement, selector?: string): T[] | null { + const assignedElements = slot.assignedElements({ flatten: true, }); + return selector ? filterElementsBySelector(assignedElements, selector) : (assignedElements as T[]); } /**