diff --git a/src/components/tab-nav/tab-nav.tsx b/src/components/tab-nav/tab-nav.tsx index 1a8e141334c..092b0c3f82f 100644 --- a/src/components/tab-nav/tab-nav.tsx +++ b/src/components/tab-nav/tab-nav.tsx @@ -19,7 +19,7 @@ import { } from "../../utils/dom"; import { createObserver } from "../../utils/observers"; import { Scale } from "../interfaces"; -import { TabChangeEventDetail } from "../tab/interfaces"; +import { TabChangeEventDetail, TabCloseEventDetail } from "../tab/interfaces"; import { TabID, TabLayout, TabPosition } from "../tabs/interfaces"; /** @@ -110,7 +110,8 @@ export class TabNav { this.selectedTitle = await this.getTabTitleById(this.selectedTabId); } - @Watch("selectedTitle") selectedTitleChanged(): void { + @Watch("selectedTitle") + selectedTitleChanged(): void { this.updateOffsetPosition(); this.updateActiveWidth(); // reset the animation time on tab selection @@ -181,6 +182,7 @@ export class TabNav { // eslint-disable-next-line react/jsx-sort-props ref={(el: HTMLDivElement) => (this.tabNavEl = el)} > +
(this.activeIndicatorEl = el as HTMLElement)} />
- ); @@ -231,14 +232,19 @@ export class TabNav { ? event.detail.tab : this.getIndexOfTabTitle(event.target as HTMLCalciteTabTitleElement); event.stopPropagation(); - event.preventDefault(); } - @Listen("calciteTabsActivate") activateTabHandler(event: CustomEvent): void { + @Listen("calciteTabsActivate") + activateTabHandler(event: CustomEvent): void { this.calciteTabChange.emit(); + event.stopPropagation(); + } + @Listen("calciteInternalTabsClose") + internalCloseTabHandler(event: CustomEvent): void { + const closedTabTitleEl = event.target as HTMLCalciteTabTitleElement; + this.handleTabTitleClose(closedTabTitleEl); event.stopPropagation(); - event.preventDefault(); } /** @@ -330,7 +336,6 @@ export class TabNav { focusElementInGroup(this.enabledTabTitles, el, destination); event.stopPropagation(); - event.preventDefault(); }; handleContainerScroll = (): void => { @@ -373,6 +378,37 @@ export class TabNav { return filterDirectChildren( this.el, "calcite-tab-title:not([disabled])" + ).filter((tabTitle) => !tabTitle.closed); + } + + private handleTabTitleClose(closedTabTitleEl: HTMLCalciteTabTitleElement): void { + const { tabTitles } = this; + + const visibleTabTitlesIndices = tabTitles.reduce( + (tabTitleIndices, tabTitle, index) => + !tabTitle.closed ? [...tabTitleIndices, index] : tabTitleIndices, + [] ); + const totalVisibleTabTitles = visibleTabTitlesIndices.length; + + if (totalVisibleTabTitles === 1 && tabTitles[visibleTabTitlesIndices[0]].closable) { + tabTitles[visibleTabTitlesIndices[0]].closable = false; + this.selectedTabId = visibleTabTitlesIndices[0]; + } else if (totalVisibleTabTitles > 1) { + const closedTabTitleIndex = tabTitles.findIndex((el) => el === closedTabTitleEl); + const nextTabTitleIndex = visibleTabTitlesIndices.find( + (value) => value > closedTabTitleIndex + ); + + if (this.selectedTabId === closedTabTitleIndex) { + this.selectedTabId = nextTabTitleIndex ? nextTabTitleIndex : totalVisibleTabTitles - 1; + } + } + + requestAnimationFrame(() => { + this.updateOffsetPosition(); + this.updateActiveWidth(); + tabTitles[this.selectedTabId].focus(); + }); } } diff --git a/src/components/tab-title/assets/tab-title/t9n/messages.json b/src/components/tab-title/assets/tab-title/t9n/messages.json new file mode 100644 index 00000000000..0c5bb0e5a1c --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages.json @@ -0,0 +1,3 @@ +{ + "close": "Close" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_ar.json b/src/components/tab-title/assets/tab-title/t9n/messages_ar.json new file mode 100644 index 00000000000..86447323701 --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_ar.json @@ -0,0 +1,3 @@ +{ + "close": "إغلاق" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_bg.json b/src/components/tab-title/assets/tab-title/t9n/messages_bg.json new file mode 100644 index 00000000000..b9bb24edef9 --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_bg.json @@ -0,0 +1,3 @@ +{ + "close": "Затваряне" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_bs.json b/src/components/tab-title/assets/tab-title/t9n/messages_bs.json new file mode 100644 index 00000000000..db94104b416 --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_bs.json @@ -0,0 +1,3 @@ +{ + "close": "Zatvori" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_ca.json b/src/components/tab-title/assets/tab-title/t9n/messages_ca.json new file mode 100644 index 00000000000..f41c36ec6c9 --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_ca.json @@ -0,0 +1,3 @@ +{ + "close": "Tanca" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_cs.json b/src/components/tab-title/assets/tab-title/t9n/messages_cs.json new file mode 100644 index 00000000000..97b131a500e --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_cs.json @@ -0,0 +1,3 @@ +{ + "close": "Zavřít" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_da.json b/src/components/tab-title/assets/tab-title/t9n/messages_da.json new file mode 100644 index 00000000000..2fd65d6cd22 --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_da.json @@ -0,0 +1,3 @@ +{ + "close": "Luk" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_de.json b/src/components/tab-title/assets/tab-title/t9n/messages_de.json new file mode 100644 index 00000000000..f04b9650f38 --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_de.json @@ -0,0 +1,3 @@ +{ + "close": "Schließen" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_el.json b/src/components/tab-title/assets/tab-title/t9n/messages_el.json new file mode 100644 index 00000000000..a4330b8a476 --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_el.json @@ -0,0 +1,3 @@ +{ + "close": "Κλείσιμο" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_en.json b/src/components/tab-title/assets/tab-title/t9n/messages_en.json new file mode 100644 index 00000000000..0c5bb0e5a1c --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_en.json @@ -0,0 +1,3 @@ +{ + "close": "Close" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_es.json b/src/components/tab-title/assets/tab-title/t9n/messages_es.json new file mode 100644 index 00000000000..32a5e0fa0a5 --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_es.json @@ -0,0 +1,3 @@ +{ + "close": "Cerrar" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_et.json b/src/components/tab-title/assets/tab-title/t9n/messages_et.json new file mode 100644 index 00000000000..654e30fb367 --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_et.json @@ -0,0 +1,3 @@ +{ + "close": "Sule" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_fi.json b/src/components/tab-title/assets/tab-title/t9n/messages_fi.json new file mode 100644 index 00000000000..9f769e1bbf4 --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_fi.json @@ -0,0 +1,3 @@ +{ + "close": "Sulje" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_fr.json b/src/components/tab-title/assets/tab-title/t9n/messages_fr.json new file mode 100644 index 00000000000..fae7179a10f --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_fr.json @@ -0,0 +1,3 @@ +{ + "close": "Fermer" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_he.json b/src/components/tab-title/assets/tab-title/t9n/messages_he.json new file mode 100644 index 00000000000..6be91cee2da --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_he.json @@ -0,0 +1,3 @@ +{ + "close": "סגירה" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_hr.json b/src/components/tab-title/assets/tab-title/t9n/messages_hr.json new file mode 100644 index 00000000000..db94104b416 --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_hr.json @@ -0,0 +1,3 @@ +{ + "close": "Zatvori" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_hu.json b/src/components/tab-title/assets/tab-title/t9n/messages_hu.json new file mode 100644 index 00000000000..b4b179d4c64 --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_hu.json @@ -0,0 +1,3 @@ +{ + "close": "Bezárás" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_id.json b/src/components/tab-title/assets/tab-title/t9n/messages_id.json new file mode 100644 index 00000000000..b1bc146cc7a --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_id.json @@ -0,0 +1,3 @@ +{ + "close": "Tutup" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_it.json b/src/components/tab-title/assets/tab-title/t9n/messages_it.json new file mode 100644 index 00000000000..40cf2a93cfa --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_it.json @@ -0,0 +1,3 @@ +{ + "close": "Chiudi" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_ja.json b/src/components/tab-title/assets/tab-title/t9n/messages_ja.json new file mode 100644 index 00000000000..93c4744dd42 --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_ja.json @@ -0,0 +1,3 @@ +{ + "close": "閉じる" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_ko.json b/src/components/tab-title/assets/tab-title/t9n/messages_ko.json new file mode 100644 index 00000000000..ee041777348 --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_ko.json @@ -0,0 +1,3 @@ +{ + "close": "닫기" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_lt.json b/src/components/tab-title/assets/tab-title/t9n/messages_lt.json new file mode 100644 index 00000000000..0b9bcbbfb26 --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_lt.json @@ -0,0 +1,3 @@ +{ + "close": "Uždaryti" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_lv.json b/src/components/tab-title/assets/tab-title/t9n/messages_lv.json new file mode 100644 index 00000000000..844b8c630e8 --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_lv.json @@ -0,0 +1,3 @@ +{ + "close": "Aizvērt" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_nl.json b/src/components/tab-title/assets/tab-title/t9n/messages_nl.json new file mode 100644 index 00000000000..97cb041c3a4 --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_nl.json @@ -0,0 +1,3 @@ +{ + "close": "Sluiten" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_no.json b/src/components/tab-title/assets/tab-title/t9n/messages_no.json new file mode 100644 index 00000000000..ae990c1be53 --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_no.json @@ -0,0 +1,3 @@ +{ + "close": "Lukk" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_pl.json b/src/components/tab-title/assets/tab-title/t9n/messages_pl.json new file mode 100644 index 00000000000..6122f93e8f1 --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_pl.json @@ -0,0 +1,3 @@ +{ + "close": "Zamknij" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_pt-BR.json b/src/components/tab-title/assets/tab-title/t9n/messages_pt-BR.json new file mode 100644 index 00000000000..7243d9f953f --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_pt-BR.json @@ -0,0 +1,3 @@ +{ + "close": "Fechar" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_pt-PT.json b/src/components/tab-title/assets/tab-title/t9n/messages_pt-PT.json new file mode 100644 index 00000000000..7243d9f953f --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_pt-PT.json @@ -0,0 +1,3 @@ +{ + "close": "Fechar" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_ro.json b/src/components/tab-title/assets/tab-title/t9n/messages_ro.json new file mode 100644 index 00000000000..913e516a1f4 --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_ro.json @@ -0,0 +1,3 @@ +{ + "close": "Închidere" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_ru.json b/src/components/tab-title/assets/tab-title/t9n/messages_ru.json new file mode 100644 index 00000000000..eeeebe6d6b5 --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_ru.json @@ -0,0 +1,3 @@ +{ + "close": "Закрыть" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_sk.json b/src/components/tab-title/assets/tab-title/t9n/messages_sk.json new file mode 100644 index 00000000000..388831f6738 --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_sk.json @@ -0,0 +1,3 @@ +{ + "close": "Zatvoriť" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_sl.json b/src/components/tab-title/assets/tab-title/t9n/messages_sl.json new file mode 100644 index 00000000000..50bc09c72d3 --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_sl.json @@ -0,0 +1,3 @@ +{ + "close": "Zapri" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_sr.json b/src/components/tab-title/assets/tab-title/t9n/messages_sr.json new file mode 100644 index 00000000000..db94104b416 --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_sr.json @@ -0,0 +1,3 @@ +{ + "close": "Zatvori" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_sv.json b/src/components/tab-title/assets/tab-title/t9n/messages_sv.json new file mode 100644 index 00000000000..9ff8f09568e --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_sv.json @@ -0,0 +1,3 @@ +{ + "close": "Stäng" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_th.json b/src/components/tab-title/assets/tab-title/t9n/messages_th.json new file mode 100644 index 00000000000..1e72a72b05e --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_th.json @@ -0,0 +1,3 @@ +{ + "close": "ปิด" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_tr.json b/src/components/tab-title/assets/tab-title/t9n/messages_tr.json new file mode 100644 index 00000000000..9ed73bb69ca --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_tr.json @@ -0,0 +1,3 @@ +{ + "close": "Kapat" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_uk.json b/src/components/tab-title/assets/tab-title/t9n/messages_uk.json new file mode 100644 index 00000000000..b8f3443d079 --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_uk.json @@ -0,0 +1,3 @@ +{ + "close": "Закрити" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_vi.json b/src/components/tab-title/assets/tab-title/t9n/messages_vi.json new file mode 100644 index 00000000000..97ee3048de7 --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_vi.json @@ -0,0 +1,3 @@ +{ + "close": "Đóng" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_zh-CN.json b/src/components/tab-title/assets/tab-title/t9n/messages_zh-CN.json new file mode 100644 index 00000000000..74bb1264ef9 --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_zh-CN.json @@ -0,0 +1,3 @@ +{ + "close": "关闭" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_zh-HK.json b/src/components/tab-title/assets/tab-title/t9n/messages_zh-HK.json new file mode 100644 index 00000000000..388446c4e18 --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_zh-HK.json @@ -0,0 +1,3 @@ +{ + "close": "關閉" +} diff --git a/src/components/tab-title/assets/tab-title/t9n/messages_zh-TW.json b/src/components/tab-title/assets/tab-title/t9n/messages_zh-TW.json new file mode 100644 index 00000000000..388446c4e18 --- /dev/null +++ b/src/components/tab-title/assets/tab-title/t9n/messages_zh-TW.json @@ -0,0 +1,3 @@ +{ + "close": "關閉" +} diff --git a/src/components/tab-title/resources.ts b/src/components/tab-title/resources.ts index 7f77684e88e..7f8a5de2bf5 100644 --- a/src/components/tab-title/resources.ts +++ b/src/components/tab-title/resources.ts @@ -1,8 +1,14 @@ export const CSS = { + closeButton: "close-button", container: "container", - containerHasText: "container--has-text", + content: "content", + contentHasText: "content--has-text", iconEnd: "icon-end", iconStart: "icon-start", iconPresent: "icon-present", titleIcon: "calcite-tab-title--icon" }; + +export const ICONS = { + close: "x" +}; diff --git a/src/components/tab-title/tab-title.e2e.ts b/src/components/tab-title/tab-title.e2e.ts index 7c04c5856ac..2c14eba1d28 100644 --- a/src/components/tab-title/tab-title.e2e.ts +++ b/src/components/tab-title/tab-title.e2e.ts @@ -1,14 +1,48 @@ -import { newE2EPage } from "@stencil/core/testing"; +import { newE2EPage, E2EPage, E2EElement } from "@stencil/core/testing"; import { disabled, HYDRATED_ATTR, renders, hidden } from "../../tests/commonTests"; +import { html } from "../../../support/formatting"; import { CSS } from "./resources"; describe("calcite-tab-title", () => { const tabTitleHtml = ""; - const iconStartHtml = `calcite-tab-title >>> .${CSS.titleIcon}.${CSS.iconStart}`; - const iconEndHtml = `calcite-tab-title >>> .${CSS.titleIcon}.${CSS.iconEnd}`; + const tabTitleClosableHtml = ""; + const multiTabTitleClosableMarkup = ` + + + Watercraft + Automobiles + Aircrafts + Bicycles + + + +
Recommended for coastal use
+
+
+ + +
A good choice for inland adventure
+
+
+ + +
Cross continents quickly
+
+
+ + +
Healthy and gets you from point A to B
+
+
+
+ `; + const iconStartSelector = `calcite-tab-title >>> .${CSS.titleIcon}.${CSS.iconStart}`; + const iconEndSelector = `calcite-tab-title >>> .${CSS.titleIcon}.${CSS.iconEnd}`; + const closeSelector = `calcite-tab-title >>> .${CSS.closeButton}`; describe("renders", () => { renders(tabTitleHtml, { display: "block" }); + renders(multiTabTitleClosableMarkup, { display: "flex" }); }); describe("honors hidden attribute", () => { @@ -21,8 +55,8 @@ describe("calcite-tab-title", () => { const page = await newE2EPage(); await page.setContent(`Text`); const element = await page.find("calcite-tab-title"); - const iconStart = await page.find(iconStartHtml); - const iconEnd = await page.find(iconEndHtml); + const iconStart = await page.find(iconStartSelector); + const iconEnd = await page.find(iconEndSelector); expect(element).toHaveAttribute(HYDRATED_ATTR); expect(iconStart).not.toBeNull(); expect(iconEnd).toBeNull(); @@ -32,8 +66,8 @@ describe("calcite-tab-title", () => { const page = await newE2EPage(); await page.setContent(`Text`); const element = await page.find("calcite-tab-title"); - const iconStart = await page.find(iconStartHtml); - const iconEnd = await page.find(iconEndHtml); + const iconStart = await page.find(iconStartSelector); + const iconEnd = await page.find(iconEndSelector); expect(element).toHaveAttribute(HYDRATED_ATTR); expect(iconStart).toBeNull(); expect(iconEnd).not.toBeNull(); @@ -43,13 +77,248 @@ describe("calcite-tab-title", () => { const page = await newE2EPage(); await page.setContent(`Text`); const element = await page.find("calcite-tab-title"); - const iconStart = await page.find(iconStartHtml); - const iconEnd = await page.find(iconEndHtml); + const iconStart = await page.find(iconStartSelector); + const iconEnd = await page.find(iconEndSelector); expect(element).toHaveAttribute(HYDRATED_ATTR); expect(iconStart).not.toBeNull(); expect(iconEnd).not.toBeNull(); }); + describe("basic closing behavior", () => { + let page: E2EPage; + + beforeEach(async () => { + page = await newE2EPage(); + await page.setContent(tabTitleClosableHtml); + }); + + it("clicking on close button closes the tab", async () => { + const close = await page.find(closeSelector); + + await close.click(); + await page.waitForChanges(); + + const containerEl = await page.find(`calcite-tab-title >>> .${CSS.container}`); + expect(await containerEl.getProperty("hidden")).toBe(true); + }); + + it("becomes no longer closable when it's the last remaining tab", async () => { + page = await newE2EPage(); + await page.setContent( + html` + + Text + Text + + ` + ); + + let containerElOne = await page.find(`calcite-tab-title[id='one']`); + const closeOne = await page.find(`calcite-tab-title[id='one'] >>> .${CSS.closeButton}`); + expect(containerElOne).toHaveAttribute(HYDRATED_ATTR); + + await closeOne.click(); + await page.waitForChanges(); + + containerElOne = await page.find(`calcite-tab-title[id='one']>>> .${CSS.container}`); + expect(await containerElOne.getProperty("hidden")).toBe(true); + + const closeTwo = await page.find(`calcite-tab-title[id='two'] >>> .${CSS.closeButton}`); + expect(await closeTwo.getProperty("closable")).not.toBe(true); + }); + }); + + describe("closing sequence", () => { + let page: E2EPage; + + let matchingTabEl: E2EElement; + let tabTitleCloseButtonEl: E2EElement; + + const closeTabsInSequenceOfGivenArrayOfIds = async (arrayOfIds: string[]): Promise => { + for (let i = 0; i < arrayOfIds.length - 1; i++) { + let tabEl: E2EElement; + let tabTitleContainerEl: E2EElement; + + const id = arrayOfIds[i]; + + tabEl = await page.find(`#${id}`); + tabTitleContainerEl = await page.find(`calcite-tab-title[id='${id}'] >>> .${CSS.container}`); + const tabTitleCloseButtonEl = await page.find(`calcite-tab-title[id='${id}'] >>> .${CSS.closeButton}`); + + expect(await tabTitleContainerEl.getProperty("hidden")).not.toBe(true); + + await tabTitleCloseButtonEl.click(); + await page.waitForChanges(); + + const tabTitleEl = await page.find(`#${id}`); + tabEl = await page.find(`#${id}`); + tabTitleContainerEl = await page.find(`calcite-tab-title[id='${id}'] >>> .${CSS.container}`); + + expect(await tabTitleContainerEl.getProperty("hidden")).toBe(true); + expect(await tabTitleEl.getProperty("selected")).not.toBe(true); + expect(await tabEl.getProperty("selected")).not.toBe(true); + + const nextId = arrayOfIds[i + 1]; + const nextTabTitleEl = await page.find(`#${nextId}`); + const nextTabEl = await page.find(`#${nextId}`); + const nextTabTitleContainerEl = await page.find(`calcite-tab-title[id='${nextId}'] >>> .${CSS.container}`); + + expect(await nextTabTitleContainerEl.getProperty("hidden")).not.toBe(true); + expect(await nextTabTitleEl.getProperty("selected")).toBe(true); + expect(await nextTabEl.getProperty("selected")).toBe(true); + } + }; + + beforeEach(async () => { + page = await newE2EPage(); + await page.setContent(multiTabTitleClosableMarkup); + }); + + it(`when closing tab-titles in sequence 1 (first selected) through 4, + tab-title and corresponding tab become hidden, + and selection fallback is the next tab`, async () => { + await closeTabsInSequenceOfGivenArrayOfIds(["title1", "title2", "title3", "title4"]); + }); + + it(`when closing tab-titles in sequence 4 (last selected) through 1, + tab-title and corresponding tab become hidden, + and selection fallback is the previous tab`, async () => { + const arrayOfReversedIds = ["title1", "title2", "title3", "title4"].reverse(); + + const bikingTabTitleEl = await page.find(`#title4`); + bikingTabTitleEl.setProperty("selected", true); + + await page.waitForChanges(); + + await closeTabsInSequenceOfGivenArrayOfIds(arrayOfReversedIds); + }); + + it(`closing an unselected tab-title does not deselect the current selection`, async () => { + const selectedEmbarkTabEl = await page.find("#tab1"); + const selectedEmbarkTabTitleContainerEl = await page.find(`#title1>>> .${CSS.container}`); + + const carTabTitleContainerEl = await page.find(`#title2 >>> .${CSS.container}`); + matchingTabEl = await page.find("#tab2"); + tabTitleCloseButtonEl = await page.find(`#title2 >>> .${CSS.closeButton}`); + + await tabTitleCloseButtonEl.click(); + await page.waitForChanges(); + + expect(await carTabTitleContainerEl.getProperty("hidden")).toBe(true); + expect(await matchingTabEl.getProperty("selected")).not.toBe(true); + + expect(await selectedEmbarkTabTitleContainerEl.getProperty("hidden")).not.toBe(true); + expect(await selectedEmbarkTabEl.getProperty("selected")).toBe(true); + + const planeTabTitleContainerEl = await page.find(`#title3 >>> .${CSS.container}`); + matchingTabEl = await page.find("#tab3"); + tabTitleCloseButtonEl = await page.find(`#title3 >>> .${CSS.closeButton}`); + + await tabTitleCloseButtonEl.click(); + await page.waitForChanges(); + + expect(await carTabTitleContainerEl.getProperty("hidden")).toBe(true); + + expect(await planeTabTitleContainerEl.getProperty("hidden")).toBe(true); + expect(await matchingTabEl.getProperty("selected")).not.toBe(true); + + expect(await selectedEmbarkTabTitleContainerEl.getProperty("hidden")).not.toBe(true); + expect(await selectedEmbarkTabEl.getProperty("selected")).toBe(true); + + const bikingTabTitleContainerEl = await page.find(`#title4 >>> .${CSS.container}`); + matchingTabEl = await page.find("#tab4"); + tabTitleCloseButtonEl = await page.find(`#title4 >>> .${CSS.closeButton}`); + + await tabTitleCloseButtonEl.click(); + await page.waitForChanges(); + + expect(await carTabTitleContainerEl.getProperty("hidden")).toBe(true); + expect(await planeTabTitleContainerEl.getProperty("hidden")).toBe(true); + expect(await bikingTabTitleContainerEl.getProperty("hidden")).toBe(true); + expect(await matchingTabEl.getProperty("selected")).not.toBe(true); + + expect(await selectedEmbarkTabTitleContainerEl.getProperty("hidden")).not.toBe(true); + expect(await selectedEmbarkTabEl.getProperty("selected")).toBe(true); + }); + + it(`case 1: works with randomized closing sequence with mixed selected and not`, async () => { + const carTabTitleEl = await page.find(`#title2`); + const carTabTitleContainerEl = await page.find(`#title2 >>> .${CSS.container}`); + matchingTabEl = await page.find("#tab2"); + + carTabTitleEl.setProperty("selected", true); + await page.waitForChanges(); + + expect(await matchingTabEl.getProperty("selected")).toBe(true); + + const embarkTabTitleContainerEl = await page.find(`#title1 >>> .${CSS.container}`); + tabTitleCloseButtonEl = await page.find(`#title1 >>> .${CSS.closeButton}`); + + await tabTitleCloseButtonEl.click(); + await page.waitForChanges(); + + expect(await embarkTabTitleContainerEl.getProperty("hidden")).toBe(true); + expect(await carTabTitleContainerEl.getProperty("hidden")).not.toBe(true); + expect(await carTabTitleEl.getProperty("selected")).toBe(true); + expect(await matchingTabEl.getProperty("selected")).toBe(true); + + const planeTabTitleContainerEl = await page.find(`#title3 >>> .${CSS.container}`); + matchingTabEl = await page.find("#tab3"); + tabTitleCloseButtonEl = await page.find(`#title2 >>> .${CSS.closeButton}`); + + await tabTitleCloseButtonEl.click(); + await page.waitForChanges(); + + expect(await carTabTitleContainerEl.getProperty("hidden")).toBe(true); + expect(await planeTabTitleContainerEl.getProperty("hidden")).not.toBe(true); + expect(await carTabTitleEl.getProperty("selected")).not.toBe(true); + expect(await matchingTabEl.getProperty("selected")).toBe(true); + }); + + it(`case 2: works with randomized closing sequence with mixed selected and not`, async () => { + const carTabTitleEl = await page.find(`#title2`); + const carTabTitleContainerEl = await page.find(`#title2 >>> .${CSS.container}`); + matchingTabEl = await page.find("#tab2"); + + carTabTitleEl.setProperty("selected", true); + await page.waitForChanges(); + + expect(await matchingTabEl.getProperty("selected")).toBe(true); + + const embarkTabTitleContainerEl = await page.find(`#title1 >>> .${CSS.container}`); + tabTitleCloseButtonEl = await page.find(`#title1 >>> .${CSS.closeButton}`); + + await tabTitleCloseButtonEl.click(); + await page.waitForChanges(); + + expect(await embarkTabTitleContainerEl.getProperty("hidden")).toBe(true); + expect(await carTabTitleContainerEl.getProperty("hidden")).not.toBe(true); + expect(await carTabTitleEl.getProperty("selected")).toBe(true); + expect(await matchingTabEl.getProperty("selected")).toBe(true); + + const planeTabTitleEl = await page.find(`#title3`); + + planeTabTitleEl.setProperty("selected", true); + await page.waitForChanges(); + + expect(await planeTabTitleEl.getProperty("selected")).toBe(true); + + const planeTabTitleContainerEl = await page.find(`#title3 >>> .${CSS.container}`); + const bikingTabTitleContainerEl = await page.find(`#title4 >>> .${CSS.container}`); + matchingTabEl = await page.find("#title3"); + tabTitleCloseButtonEl = await page.find(`#title2 >>> .${CSS.closeButton}`); + + await tabTitleCloseButtonEl.click(); + await page.waitForChanges(); + + expect(await planeTabTitleContainerEl.getProperty("hidden")).not.toBe(true); + expect(await carTabTitleContainerEl.getProperty("hidden")).toBe(true); + expect(await bikingTabTitleContainerEl.getProperty("hidden")).not.toBe(true); + expect(await planeTabTitleEl.getProperty("selected")).toBe(true); + expect(await matchingTabEl.getProperty("selected")).toBe(true); + }); + }); + it.skip("emits active event on user interaction only", async () => { const page = await newE2EPage(); await page.setContent(`Title`); @@ -108,49 +377,73 @@ describe("calcite-tab-title", () => { }); describe("scale property", () => { - it("should inherit small scale from tab-nav", async () => { - const page = await newE2EPage({ - html: ` - Tab Title - Tab 2 Title - ` - }); - const element = await page.find("calcite-tab-title"); - const container = await page.find("calcite-tab-title >>> .container"); - const containerStyles = await container.getComputedStyle(); - expect(element).toEqualAttribute("scale", "s"); - expect(containerStyles.fontSize).toEqual("12px"); - expect(containerStyles.lineHeight).toEqual("16px"); // 1rem + let page: E2EPage; + + beforeEach(async () => { + page = await newE2EPage(); }); - it("should inherit medium scale from tab-nav", async () => { - const page = await newE2EPage({ - html: ` - Tab Title - Tab 2 Title - ` - }); - const element = await page.find("calcite-tab-title"); - const container = await page.find("calcite-tab-title >>> .container"); - const containerStyles = await container.getComputedStyle(); - expect(element).toEqualAttribute("scale", "m"); - expect(containerStyles.fontSize).toEqual("14px"); - expect(containerStyles.lineHeight).toEqual("16px"); // 1rem + it("should inherit default medium scale from tab-nav", async () => { + await page.setContent( + html` + + Tab Title + Tab 2 Title + + ` + ); + + const tabTitleNav = await page.find("calcite-tab-nav"); + const tabTitleEl = await page.find("calcite-tab-title"); + const content = await page.find(`calcite-tab-title >>> .${CSS.content}`); + const contentStyles = await content.getComputedStyle(); + + expect(tabTitleEl).toEqualAttribute("scale", "m"); + expect(tabTitleNav).toEqualAttribute("scale", "m"); + expect(contentStyles.fontSize).toEqual("14px"); + expect(contentStyles.lineHeight).toEqual("16px"); + }); + + it("should inherit small scale from tab-nav", async () => { + await page.setContent( + html` + + Tab Title + Tab 2 Title + + ` + ); + + const tabTitleNav = await page.find("calcite-tab-nav"); + const tabTitleEl = await page.find("calcite-tab-title"); + const content = await page.find(`calcite-tab-title >>> .${CSS.content}`); + const contentStyles = await content.getComputedStyle(); + + expect(tabTitleEl).toEqualAttribute("scale", "s"); + expect(tabTitleNav).toEqualAttribute("scale", "s"); + expect(contentStyles.fontSize).toEqual("12px"); + expect(contentStyles.lineHeight).toEqual("16px"); }); it("should inherit large scale from tab-nav", async () => { - const page = await newE2EPage({ - html: ` - Tab 1 Title - Tab 2 Title - ` - }); - const element = await page.find("calcite-tab-title"); - const container = await page.find("calcite-tab-title >>> .container"); - const containerStyles = await container.getComputedStyle(); - expect(element).toEqualAttribute("scale", "l"); - expect(containerStyles.fontSize).toEqual("16px"); - expect(containerStyles.lineHeight).toEqual("20px"); // 1.25rem + await page.setContent( + html` + + Tab Title + Tab 2 Title + + ` + ); + + const tabTitleNav = await page.find("calcite-tab-nav"); + const tabTitleEl = await page.find("calcite-tab-title"); + const content = await page.find(`calcite-tab-title >>> .${CSS.content}`); + const contentStyles = await content.getComputedStyle(); + + expect(tabTitleEl).toEqualAttribute("scale", "l"); + expect(tabTitleNav).toEqualAttribute("scale", "l"); + expect(contentStyles.fontSize).toEqual("16px"); + expect(contentStyles.lineHeight).toEqual("20px"); }); }); }); diff --git a/src/components/tab-title/tab-title.scss b/src/components/tab-title/tab-title.scss index 9c6c6b6c62b..2cd2101d851 100644 --- a/src/components/tab-title/tab-title.scss +++ b/src/components/tab-title/tab-title.scss @@ -17,12 +17,20 @@ border-block-start-style: solid; } -:host .container { +:host([closed]) { + @apply hidden; +} + +.container { @apply focus-base; } :host(:focus) .container { @apply focus-inset; + + &:focus-within { + @apply focus-base; + } } :host(:active), @@ -45,42 +53,46 @@ :host([scale="s"]) { margin-inline-end: 1rem; - .container { + .content { @apply text-n2h py-1; } } :host([scale="m"]) { - .container { + .content { @apply text-n1h py-2; } } :host([scale="l"]) { margin-inline-end: 1.5rem; - .container { + .content { @apply text-0h py-2.5; } } .container { - @apply border-b-color-transparent - text-color-3 - text-n1h - transition-default + @apply border-b-2 + border-b-color-transparent box-border + content-center + cursor-pointer flex h-full - w-full - cursor-pointer - appearance-none - justify-center - truncate - border-b-2 - px-0; + justify-between + px-0 + text-color-3 + text-n1h + transition-default + w-full; + border-block-end-style: solid; } +.content { + @apply flex items-center justify-center; +} + .calcite-tab-title--icon { @apply relative m-0 @@ -91,18 +103,59 @@ } } -.container--has-text { +.content--has-text { @apply p-1; } -.container--has-text .calcite-tab-title--icon.icon-start { +.content--has-text .calcite-tab-title--icon.icon-start { margin-inline-end: theme("margin.2"); } -.container--has-text .calcite-tab-title--icon.icon-end { +.content--has-text .calcite-tab-title--icon.icon-end { margin-inline-start: theme("margin.2"); } +.close-button { + @apply appearance-none + bg-foreground-1 + border-none + content-center + cursor-pointer + flex + focus-base + items-center + justify-center + self-center + text-color-3 + transition-default; + + block-size: calc(100% - 2px); // fit within top/bottom borders + background-color: var(--calcite-button-transparent-1); + margin-inline-start: auto; + + &:focus { + @apply focus-inset; + + // ⚠️overriding outline-offset should be avoided as it won't honor --calcite-ui-focus-offset-invert + outline-offset: -1px; + } + + &:focus, + &:hover { + @apply text-color-1; + background-color: var(--calcite-ui-foreground-2); + } + + &:active { + @apply text-color-1; + background-color: var(--calcite-ui-foreground-3); + } + + & calcite-icon { + color: inherit; + } +} + // compensate for spacing when no hastext and two icons :host([icon-start][icon-end]) { .calcite-tab-title--icon:first-child { @@ -137,12 +190,14 @@ } } +:host([closable]) .container, :host([bordered]) .container { border-block-end-style: unset; border-inline-start: 1px solid transparent; border-inline-end: 1px solid transparent; } +:host([closable][position="bottom"]) .container, :host([bordered][position="bottom"]) .container { border-block-start-style: unset; } @@ -153,24 +208,21 @@ } :host([bordered]) { - .container { + .content { @apply px-3; } } :host([bordered][scale="s"]) { - .container { + .content { @apply px-2; } } :host([bordered][scale="l"]) { - .container { + .content { @apply px-4; } - .icon-present { - padding-block: 11px; - } } @media (forced-colors: active) { diff --git a/src/components/tab-title/tab-title.tsx b/src/components/tab-title/tab-title.tsx index 9ed68e80df4..6244ee52cdb 100644 --- a/src/components/tab-title/tab-title.tsx +++ b/src/components/tab-title/tab-title.tsx @@ -13,14 +13,27 @@ import { VNode, Watch } from "@stencil/core"; -import { getElementDir, getElementProp, toAriaBoolean } from "../../utils/dom"; +import { getElementDir, getElementProp, toAriaBoolean, nodeListToArray } from "../../utils/dom"; import { guid } from "../../utils/guid"; import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; import { createObserver } from "../../utils/observers"; import { FlipContext, Scale } from "../interfaces"; -import { TabChangeEventDetail } from "../tab/interfaces"; -import { CSS } from "./resources"; +import { TabChangeEventDetail, TabCloseEventDetail } from "../tab/interfaces"; +import { CSS, ICONS } from "./resources"; import { TabID, TabLayout, TabPosition } from "../tabs/interfaces"; +import { connectLocalized, disconnectLocalized, LocalizedComponent } from "../../utils/locale"; +import { + connectMessages, + disconnectMessages, + setUpMessages, + T9nComponent, + updateMessages +} from "../../utils/t9n"; +import { TabTitleMessages } from "./assets/tab-title/t9n"; + +/** + * Tab-titles are optionally individually closable. + */ /** * @slot - A slot for adding text. @@ -28,9 +41,10 @@ import { TabID, TabLayout, TabPosition } from "../tabs/interfaces"; @Component({ tag: "calcite-tab-title", styleUrl: "tab-title.scss", - shadow: true + shadow: true, + assetsDirs: ["assets"] }) -export class TabTitle implements InteractiveComponent { +export class TabTitle implements InteractiveComponent, LocalizedComponent, T9nComponent { //-------------------------------------------------------------------------- // // Element @@ -59,6 +73,12 @@ export class TabTitle implements InteractiveComponent { } } + /** When `true`, a close button is added to the component. */ + @Prop({ reflect: true }) closable = false; + + /** When `true`, does not display or position the component. */ + @Prop({ reflect: true, mutable: true }) closed = false; + /** When `true`, interaction is prevented and the component is displayed with lower opacity. */ @Prop({ reflect: true }) disabled = false; @@ -98,6 +118,25 @@ export class TabTitle implements InteractiveComponent { */ @Prop({ reflect: true }) tab: string; + /** + * Made into a prop for testing purposes only + * + * @internal + */ + // eslint-disable-next-line @stencil-community/strict-mutable -- updated by t9n module + @Prop({ mutable: true }) messages: TabTitleMessages; + + /** + * Use this property to override individual strings used by the component. + */ + // eslint-disable-next-line @stencil-community/strict-mutable -- updated by t9n module + @Prop({ mutable: true }) messageOverrides: Partial; + + @Watch("messageOverrides") + onMessagesChange(): void { + /* wired up by t9n util */ + } + //-------------------------------------------------------------------------- // // Lifecycle @@ -105,6 +144,8 @@ export class TabTitle implements InteractiveComponent { //-------------------------------------------------------------------------- connectedCallback(): void { + connectLocalized(this); + connectMessages(this); this.setupTextContentObserver(); this.parentTabNavEl = this.el.closest("calcite-tab-nav"); this.parentTabsEl = this.el.closest("calcite-tabs"); @@ -119,9 +160,12 @@ export class TabTitle implements InteractiveComponent { }) ); this.resizeObserver?.disconnect(); + disconnectLocalized(this); + disconnectMessages(this); } - componentWillLoad(): void { + async componentWillLoad(): Promise { + await setUpMessages(this); if (Build.isBrowser) { this.updateHasText(); } @@ -145,7 +189,8 @@ export class TabTitle implements InteractiveComponent { } render(): VNode { - const id = this.el.id || this.guid; + const { el, closed } = this; + const id = el.id || this.guid; const iconStartEl = (