diff --git a/packages/calcite-components/.storybook/utils.tsx b/packages/calcite-components/.storybook/utils.tsx index 3f9dc6d9172..60e2231cb09 100644 --- a/packages/calcite-components/.storybook/utils.tsx +++ b/packages/calcite-components/.storybook/utils.tsx @@ -146,7 +146,7 @@ export const filterComponentAttributes = ( /** * This helper creates a story that captures all breakpoints across all scales for testing. * - * @param singleStoryHtml – HTML story template with placeholders for `scale` attributes (e.g., `{scale}`). + * @param singleStoryHtml – HTML story template with placeholders for `scale` attributes (e.g., `{scale}`). You can additionally use `.breakpoint-stories-container` and `.breakpoint-story-container` to style breakpoint story containers. * @param [focused] – when specified, creates a single story for the provided breakpoint and scale. * This should only be used if multiple stories cannot be displayed side-by-side. */ diff --git a/packages/calcite-components/src/components.d.ts b/packages/calcite-components/src/components.d.ts index 2ac3aa8b583..a1eb89aa1df 100644 --- a/packages/calcite-components/src/components.d.ts +++ b/packages/calcite-components/src/components.d.ts @@ -77,6 +77,7 @@ import { StepperMessages } from "./components/stepper/assets/stepper/t9n"; import { StepperItemChangeEventDetail, StepperItemEventDetail, StepperItemKeyEventDetail } from "./components/stepper/interfaces"; import { StepperItemMessages } from "./components/stepper-item/assets/stepper-item/t9n"; import { TabID, TabLayout, TabPosition } from "./components/tabs/interfaces"; +import { TabNavMessages } from "./components/tab-nav/assets/tab-nav/t9n"; import { TabChangeEventDetail, TabCloseEventDetail } from "./components/tab/interfaces"; import { TabTitleMessages } from "./components/tab-title/assets/tab-title/t9n"; import { RowType, TableLayout, TableRowFocusEvent } from "./components/table/interfaces"; @@ -163,6 +164,7 @@ export { StepperMessages } from "./components/stepper/assets/stepper/t9n"; export { StepperItemChangeEventDetail, StepperItemEventDetail, StepperItemKeyEventDetail } from "./components/stepper/interfaces"; export { StepperItemMessages } from "./components/stepper-item/assets/stepper-item/t9n"; export { TabID, TabLayout, TabPosition } from "./components/tabs/interfaces"; +export { TabNavMessages } from "./components/tab-nav/assets/tab-nav/t9n"; export { TabChangeEventDetail, TabCloseEventDetail } from "./components/tab/interfaces"; export { TabTitleMessages } from "./components/tab-title/assets/tab-title/t9n"; export { RowType, TableLayout, TableRowFocusEvent } from "./components/table/interfaces"; @@ -4546,6 +4548,14 @@ export namespace Components { "indicatorOffset": number; "indicatorWidth": number; "layout": TabLayout; + /** + * Use this property to override individual strings used by the component. + */ + "messageOverrides": Partial; + /** + * Made into a prop for testing purposes only. + */ + "messages": TabNavMessages; /** * Specifies the position of `calcite-tab-nav` and `calcite-tab-title` components in relation to, and is inherited from the parent `calcite-tabs`, defaults to `top`. */ @@ -12018,6 +12028,14 @@ declare namespace LocalJSX { "indicatorOffset"?: number; "indicatorWidth"?: number; "layout"?: TabLayout; + /** + * Use this property to override individual strings used by the component. + */ + "messageOverrides"?: Partial; + /** + * Made into a prop for testing purposes only. + */ + "messages"?: TabNavMessages; "onCalciteInternalTabChange"?: (event: CalciteTabNavCustomEvent) => void; /** * Emits when the selected `calcite-tab` changes. diff --git a/packages/calcite-components/src/components/button/button.e2e.ts b/packages/calcite-components/src/components/button/button.e2e.ts index 60507aeea43..bdad412f737 100644 --- a/packages/calcite-components/src/components/button/button.e2e.ts +++ b/packages/calcite-components/src/components/button/button.e2e.ts @@ -648,8 +648,7 @@ describe("calcite-button", () => { t9n("calcite-button"); }); - describe('automatic tooltip', ()=>{ - + describe("automatic tooltip", () => { it("shows tooltip for buttons with truncated long text", async () => { const shortText = "Hi!"; const longText = @@ -685,7 +684,6 @@ describe("calcite-button", () => { expect(button).not.toHaveAttribute("title"); }); - }); it("should set aria-expanded attribute on shadowDOM element when used as trigger", async () => { diff --git a/packages/calcite-components/src/components/tab-nav/resources.ts b/packages/calcite-components/src/components/tab-nav/resources.ts index 7f1cc8d7095..ae679c3acbc 100644 --- a/packages/calcite-components/src/components/tab-nav/resources.ts +++ b/packages/calcite-components/src/components/tab-nav/resources.ts @@ -1,3 +1,16 @@ +export const ICON = { + chevronRight: "chevron-right", + chevronLeft: "chevron-left", +}; + export const CSS = { + activeIndicatorContainer: "tab-nav-active-indicator-container", container: "tab-nav", + containerHasEndTabTitleOverflow: "tab-nav--end-overflow", + containerHasStartTabTitleOverflow: "tab-nav--start-overflow", + scrollButton: "scroll-button", + scrollButtonContainer: "scroll-button-container", + scrollBackwardContainerButton: "scroll-button-container--backward", + scrollForwardContainerButton: "scroll-button-container--forward", + tabTitleSlotWrapper: "tab-titles-slot-wrapper", }; diff --git a/packages/calcite-components/src/components/tab-nav/tab-nav.e2e.ts b/packages/calcite-components/src/components/tab-nav/tab-nav.e2e.ts index 9ddf3df4c49..90621edc6d4 100644 --- a/packages/calcite-components/src/components/tab-nav/tab-nav.e2e.ts +++ b/packages/calcite-components/src/components/tab-nav/tab-nav.e2e.ts @@ -1,6 +1,8 @@ -import { newE2EPage } from "@stencil/core/testing"; -import { accessible, defaults, renders, hidden } from "../../tests/commonTests"; +import { E2EElement, E2EPage, newE2EPage } from "@stencil/core/testing"; +import { accessible, defaults, hidden, renders, t9n } from "../../tests/commonTests"; import { html } from "../../../support/formatting"; +import { CSS } from "./resources"; +import { getElementRect } from "../../tests/utils"; describe("calcite-tab-nav", () => { describe("defaults", () => { @@ -19,6 +21,10 @@ describe("calcite-tab-nav", () => { accessible("calcite-tab-nav"); }); + describe("translation support", () => { + t9n("calcite-tab-nav"); + }); + it("emits on user interaction", async () => { const page = await newE2EPage(); await page.setContent( @@ -110,4 +116,169 @@ describe("calcite-tab-nav", () => { await page.keyboard.press("Home"); expect(await page.evaluate(() => document.activeElement.id)).toBe("tab1"); }); + + describe("responsiveness", () => { + const tabsHTML = html` + + + Tab 1 Title + Tab 2 Title + Tab 3 Title + Tab 4 Title + Tab 5 Title + Tab 6 Title + Tab 7 Title + Tab 8 Title + + Tab 1 Content + Tab 2 Content + Tab 3 Content + Tab 4 Content + Tab 5 Content + Tab 6 Content + Tab 7 Content + Tab 8 Content + + `; + const sizeShowingAllTabs = { width: 1200, height: 1200 }; + const sizeShowingSomeTabs = { width: 350, height: 1200 }; + + let page: E2EPage; + let scrollBackButton: E2EElement; + let scrollForwardButton: E2EElement; + let scrollContainer: E2EElement; + + async function assertScrollButtonVisibility( + backExpectedVisibility: boolean, + expectedForwardVisibility: boolean, + ): Promise { + /* we need to find the scroll buttons to ensure visibility */ + expect(await scrollBackButton.isVisible()).toBe(backExpectedVisibility); + expect(await scrollForwardButton.isVisible()).toBe(expectedForwardVisibility); + } + + beforeEach(async () => { + page = await newE2EPage(); + await page.setContent(tabsHTML); + await page.setViewport(sizeShowingSomeTabs); + await page.waitForChanges(); + scrollBackButton = await page.find(`calcite-tab-nav >>> .${CSS.scrollBackwardContainerButton}`); + scrollForwardButton = await page.find(`calcite-tab-nav >>> .${CSS.scrollForwardContainerButton}`); + scrollContainer = await page.find(`calcite-tab-nav >>> .${CSS.tabTitleSlotWrapper}`); + }); + + it("shows scrolling buttons if tab-titles overflow", async () => { + await assertScrollButtonVisibility(false, true); + + await page.click("calcite-tab-title:nth-child(4)"); + await page.waitForChanges(); + + await assertScrollButtonVisibility(true, true); + + await page.setViewport(sizeShowingAllTabs); + await page.waitForChanges(); + + await assertScrollButtonVisibility(false, false); + + await page.setViewport(sizeShowingSomeTabs); + await page.waitForChanges(); + + await assertScrollButtonVisibility(false, true); + + await page.click("calcite-tab-title:nth-child(4)"); + await page.waitForChanges(); + + await assertScrollButtonVisibility(true, true); + }); + + it("scrolling tabs via buttons", async () => { + await assertScrollButtonVisibility(false, true); + + let scrollEnd = scrollContainer.waitForEvent("scrollend"); + await scrollForwardButton.click(); + await page.waitForChanges(); + await scrollEnd; + + await assertScrollButtonVisibility(true, true); + + scrollEnd = scrollContainer.waitForEvent("scrollend"); + await scrollForwardButton.click(); + await page.waitForChanges(); + await scrollEnd; + + await assertScrollButtonVisibility(true, false); + + scrollEnd = scrollContainer.waitForEvent("scrollend"); + await scrollBackButton.click(); + await page.waitForChanges(); + await scrollEnd; + + await assertScrollButtonVisibility(true, true); + + scrollEnd = scrollContainer.waitForEvent("scrollend"); + await scrollBackButton.click(); + await page.waitForChanges(); + await scrollEnd; + + await assertScrollButtonVisibility(false, true); + }); + + it("scrolling tabs via mouse wheel", async () => { + await assertScrollButtonVisibility(false, true); + + const tabNavBounds = await getElementRect(page, "calcite-tab-nav"); + await page.mouse.move(tabNavBounds.x + tabNavBounds.width / 2, tabNavBounds.y + tabNavBounds.height / 2); + await page.mouse.wheel({ deltaY: 200 }); + await page.waitForChanges(); + + await assertScrollButtonVisibility(true, true); + + await page.mouse.wheel({ deltaY: 200 }); + await page.waitForChanges(); + + await assertScrollButtonVisibility(true, false); + + await page.mouse.wheel({ deltaY: -200 }); + await page.waitForChanges(); + + await assertScrollButtonVisibility(true, true); + + await page.mouse.wheel({ deltaY: -200 }); + await page.waitForChanges(); + + await assertScrollButtonVisibility(false, true); + }); + + it("scrolls into view clipped start or end tab-title when selected", async () => { + const tabNavBounds = await getElementRect(page, "calcite-tab-nav"); + await page.mouse.move(tabNavBounds.x + tabNavBounds.width / 2, tabNavBounds.y + tabNavBounds.height / 2); + await page.waitForChanges(); + + await page.mouse.wheel({ deltaY: 1 }); + await page.waitForChanges(); + + await assertScrollButtonVisibility(true, true); + + let scrollEnd = scrollContainer.waitForEvent("scrollend"); + const firstTab = await page.find("calcite-tab-title:first-child"); + await firstTab.callMethod("click"); // we call method to avoid having E2E click element in the middle, which would hit the scroll button + await page.waitForChanges(); + await scrollEnd; + + await assertScrollButtonVisibility(false, true); + + await page.mouse.wheel({ deltaY: 180 }); + await page.waitForChanges(); + + await assertScrollButtonVisibility(true, true); + + scrollEnd = scrollContainer.waitForEvent("scrollend"); + const lastTab = await page.find("calcite-tab-title:last-child"); + await lastTab.callMethod("click"); // we call method to avoid having E2E click element in the middle, which would hit the scroll button + await page.waitForChanges(); + await scrollEnd; + + await assertScrollButtonVisibility(true, false); + }); + }); }); diff --git a/packages/calcite-components/src/components/tab-nav/tab-nav.scss b/packages/calcite-components/src/components/tab-nav/tab-nav.scss index 368fd06a1d1..ff0279df298 100644 --- a/packages/calcite-components/src/components/tab-nav/tab-nav.scss +++ b/packages/calcite-components/src/components/tab-nav/tab-nav.scss @@ -1,78 +1,198 @@ :host { + --calcite-internal-tab-nav-gradient-start-side: left; + --calcite-internal-tab-nav-gradient-end-side: right; + @apply relative flex; } .scale-s { + --calcite-internal-tab-nav-scroller-button-width: #{$calcite-size-24}; min-block-size: theme("spacing.6"); } .scale-m { + --calcite-internal-tab-nav-scroller-button-width: #{$calcite-size-32}; min-block-size: theme("spacing.8"); } .scale-l { + --calcite-internal-tab-nav-scroller-button-width: #{$calcite-size-44}; min-block-size: theme("spacing.11"); } -:host([layout="center"]:not([bordered])) { - // `tab-nav` in all scales in layout="center" has a padding of 20px on both ends - padding-inline: theme("margin.5"); +.calcite--rtl { + --calcite-internal-tab-nav-gradient-start-side: right; + --calcite-internal-tab-nav-gradient-end-side: left; +} - //override margin-inline-end for the last child for tab-nav to implement the 20px padding on both ends instead - .tab-nav { - ::slotted(calcite-tab-title:last-child) { - margin-inline-end: theme("margin.0"); - } +$last-mask-color-stop-position: 51%; // we go beyond the half point to ensure the mask color stops overlap when both start and end are overflowing + +.tab-nav--start-overflow { + .tab-nav-active-indicator-container, + .tab-titles-slot-wrapper { + mask-image: linear-gradient( + to var(--calcite-internal-tab-nav-gradient-end-side), + transparent, + transparent var(--calcite-internal-tab-nav-scroller-button-width), + white var(--calcite-internal-tab-nav-scroller-button-width), + white $last-mask-color-stop-position + ); + } +} + +.tab-nav--end-overflow { + .tab-nav-active-indicator-container, + .tab-titles-slot-wrapper { + mask-image: linear-gradient( + to var(--calcite-internal-tab-nav-gradient-start-side), + transparent, + transparent var(--calcite-internal-tab-nav-scroller-button-width), + white var(--calcite-internal-tab-nav-scroller-button-width), + white $last-mask-color-stop-position + ); + } +} + +.tab-nav--start-overflow.tab-nav--end-overflow { + .tab-nav-active-indicator-container, + .tab-titles-slot-wrapper { + mask-image: linear-gradient( + to var(--calcite-internal-tab-nav-gradient-end-side), + transparent, + transparent var(--calcite-internal-tab-nav-scroller-button-width), + white var(--calcite-internal-tab-nav-scroller-button-width), + white $last-mask-color-stop-position, + transparent $last-mask-color-stop-position + ), + linear-gradient( + to var(--calcite-internal-tab-nav-gradient-start-side), + transparent, + transparent var(--calcite-internal-tab-nav-scroller-button-width), + white var(--calcite-internal-tab-nav-scroller-button-width), + white $last-mask-color-stop-position, + transparent $last-mask-color-stop-position + ); + } +} + +.tab-nav::-webkit-scrollbar { + display: none; + -ms-overflow-style: none; + scrollbar-width: none; +} + +:host([layout="center"]) { + ::slotted(calcite-tab-title) { + display: flex; + flex-grow: 1; + flex-shrink: 0; + min-inline-size: auto; + white-space: nowrap; + } + + ::slotted(calcite-tab-title[selected]) { + overflow: unset; } } :host(:not([bordered])) { .scale-l { - ::slotted(calcite-tab-title) { - margin-inline-end: theme("margin.6"); - } + --calcite-internal-tab-nav-gap: var(--calcite-size-xxl); } .scale-m { - ::slotted(calcite-tab-title) { - margin-inline-end: theme("margin.5"); - } + --calcite-internal-tab-nav-gap: var(--calcite-size-xl); } .scale-s { - ::slotted(calcite-tab-title) { - margin-inline-end: theme("margin.4"); - } + --calcite-internal-tab-nav-gap: var(--calcite-size-lg); + } + + .tab-titles-slot-wrapper { + gap: var(--calcite-internal-tab-nav-gap); + } +} + +:host([layout="center"]:not([bordered])) { + .tab-titles-slot-wrapper { + padding-inline: var(--calcite-spacing-xxl); } } -.tab-nav { +.tab-nav, +.tab-titles-slot-wrapper { @apply flex w-full justify-start - overflow-auto; + whitespace-nowrap + overflow-hidden; } // prevent indicator overflow in horizontal scrolling situations .tab-nav-active-indicator-container { @apply absolute - inset-x-0 bottom-0 h-0.5 - w-full - overflow-hidden; + inset-x-0 + overflow-hidden + w-full; } .tab-nav-active-indicator { - @apply bg-brand - absolute + @apply absolute + bg-brand bottom-0 block h-0.5 - transition-all - ease-out; + ease-out + transition-all; } -:host([layout="center"]) .tab-nav { - @apply justify-evenly; +.scroll-button-container { + @apply absolute bottom-0 top-0; + + calcite-button { + --calcite-offset-invert-focus: 1; + --calcite-color-text-1: var(--calcite-color-text-3); + + block-size: 100%; + + &:hover { + --calcite-color-text-1: unset; + --calcite-color-foreground-1: var(--calcite-color-transparent-hover); + --calcite-color-foreground-3: var(--calcite-color-transparent); + } + } +} + +.scroll-button-container--forward { + inset-inline-end: 0; + z-index: var(--calcite-z-index); +} + +.scroll-button-container--backward { + inset-inline-start: 0; + z-index: var(--calcite-z-index); +} + +:host(:not([bordered])) { + .scroll-button-container--backward, + .scroll-button-container--forward { + &::before { + background-color: var(--calcite-color-border-3); + content: ""; + inline-size: var(--calcite-border-width-sm); + inset-block-start: var(--calcite-border-width-md); + inset-block-end: var(--calcite-border-width-md); + position: absolute; + } + } + + .scroll-button-container--backward::before { + inset-inline-end: 0; + } + + .scroll-button-container--forward::before { + inset-inline-start: 0; + } } :host .position-bottom .tab-nav-active-indicator { 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 9b49fcd7053..7cc7c681213 100644 --- a/packages/calcite-components/src/components/tab-nav/tab-nav.tsx +++ b/packages/calcite-components/src/components/tab-nav/tab-nav.tsx @@ -7,11 +7,14 @@ import { Host, Listen, Prop, + readTask, State, VNode, Watch, } from "@stencil/core"; + import { + Direction, filterDirectChildren, focusElementInGroup, FocusElementInGroupDestination, @@ -21,7 +24,22 @@ import { createObserver } from "../../utils/observers"; import { Scale } from "../interfaces"; import { TabChangeEventDetail, TabCloseEventDetail } from "../tab/interfaces"; import { TabID, TabLayout, TabPosition } from "../tabs/interfaces"; -import { CSS } from "./resources"; +import { CSS, ICON } from "./resources"; +import { connectLocalized, disconnectLocalized, LocalizedComponent } from "../../utils/locale"; +import { + connectMessages, + disconnectMessages, + setUpMessages, + T9nComponent, + updateMessages, +} from "../../utils/t9n"; +import { TabNavMessages } from "./assets/tab-nav/t9n"; +import { + calciteSize24, + calciteSize32, + calciteSize44, +} from "@esri/calcite-design-tokens/dist/es6/core"; +import { CSS_UTILITY } from "../../utils/resources"; /** * @slot - A slot for adding `calcite-tab-title`s. @@ -30,8 +48,9 @@ import { CSS } from "./resources"; tag: "calcite-tab-nav", styleUrl: "tab-nav.scss", shadow: true, + assetsDirs: ["assets"], }) -export class TabNav { +export class TabNav implements LocalizedComponent, T9nComponent { //-------------------------------------------------------------------------- // // Properties @@ -89,6 +108,25 @@ export class TabNav { */ @Prop({ mutable: true }) indicatorWidth: number; + /** + * 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: TabNavMessages; + + /** + * 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 */ + } + @Watch("selectedTabId") async selectedTabIdChanged(): Promise { if ( @@ -109,10 +147,7 @@ export class TabNav { @Watch("selectedTitle") selectedTitleChanged(): void { - this.updateOffsetPosition(); - this.updateActiveWidth(); - // reset the animation time on tab selection - this.activeIndicatorEl.style.transitionDuration = `${this.animationActiveDuration}s`; + this.updateActiveIndicator(); } //-------------------------------------------------------------------------- @@ -124,14 +159,21 @@ export class TabNav { connectedCallback(): void { this.parentTabsEl = this.el.closest("calcite-tabs"); this.resizeObserver?.observe(this.el); + connectLocalized(this); + connectMessages(this); } - componentWillLoad(): void { + async componentWillLoad(): Promise { const storageKey = `calcite-tab-nav-${this.storageId}`; if (localStorage && this.storageId && localStorage.getItem(storageKey)) { const storedTab = JSON.parse(localStorage.getItem(storageKey)); this.selectedTabId = storedTab; } + await setUpMessages(this); + } + + componentDidLoad(): void { + this.scrollTabTitleIntoView(this.selectedTitle, "instant"); } componentWillRender(): void { @@ -139,9 +181,10 @@ export class TabNav { this.layout = parentTabsEl?.layout; this.bordered = parentTabsEl?.bordered; - // fix issue with active tab-title not lining up with blue indicator + this.dir = getElementDir(this.el); + if (this.selectedTitle) { - this.updateOffsetPosition(); + this.updateActiveIndicator(); } } @@ -162,28 +205,51 @@ export class TabNav { disconnectedCallback(): void { this.resizeObserver?.disconnect(); + disconnectLocalized(this); + disconnectMessages(this); } + //-------------------------------------------------------------------------- + // + // Render Methods + // + //-------------------------------------------------------------------------- + render(): VNode { - const dir = getElementDir(this.el); const width = `${this.indicatorWidth}px`; const offset = `${this.indicatorOffset}px`; - const indicatorStyle = dir !== "rtl" ? { width, left: offset } : { width, right: offset }; + const indicatorStyle = this.dir !== "rtl" ? { width, left: offset } : { width, right: offset }; + return (
(this.tabNavEl = el)} + ref={this.storeContainerRef} > - + {this.renderScrollButton("start")}
+ +
+
(this.activeIndicatorContainerEl = el)} > @@ -194,6 +260,7 @@ export class TabNav { ref={(el) => (this.activeIndicatorEl = el as HTMLElement)} />
+ {this.renderScrollButton("end")}
); @@ -227,10 +294,55 @@ export class TabNav { @Listen("calciteInternalTabsActivate") internalActivateTabHandler(event: CustomEvent): void { + const activatedTabTitle = event.target as HTMLCalciteTabTitleElement; + this.selectedTabId = event.detail.tab ? event.detail.tab - : this.getIndexOfTabTitle(event.target as HTMLCalciteTabTitleElement); + : this.getIndexOfTabTitle(activatedTabTitle); event.stopPropagation(); + + this.scrollTabTitleIntoView(activatedTabTitle); + } + + private scrollTabTitleIntoView( + activatedTabTitle: HTMLCalciteTabTitleElement, + behavior: ScrollBehavior = "smooth", + ): void { + if (!activatedTabTitle) { + return; + } + + readTask(() => { + const isLTR = this.dir === "ltr"; + const tabTitleContainer = this.tabTitleContainerEl; + const containerBounds = tabTitleContainer.getBoundingClientRect(); + const tabTitleBounds = activatedTabTitle.getBoundingClientRect(); + const scrollPosition = tabTitleContainer.scrollLeft; + const overflowingStartTabTitle = isLTR + ? this.hasOverflowingStartTabTitle + : this.hasOverflowingEndTabTitle; + const overflowingEndTabTitle = isLTR + ? this.hasOverflowingEndTabTitle + : this.hasOverflowingStartTabTitle; + + if ( + tabTitleBounds.left < + containerBounds.left + (overflowingStartTabTitle ? this.scrollerButtonWidth : 0) + ) { + const left = + scrollPosition + (tabTitleBounds.left - containerBounds.left) - this.scrollerButtonWidth; + tabTitleContainer.scrollTo({ left, behavior }); + } else if ( + tabTitleBounds.right > + containerBounds.right - (overflowingEndTabTitle ? this.scrollerButtonWidth : 0) + ) { + const left = + scrollPosition + + (tabTitleBounds.right - containerBounds.right) + + this.scrollerButtonWidth; + tabTitleContainer.scrollTo({ left, behavior }); + } + }); } @Listen("calciteTabsActivate") @@ -273,8 +385,7 @@ export class TabNav { @Listen("calciteInternalTabIconChanged") iconStartChangeHandler(): void { - this.updateActiveWidth(); - this.updateOffsetPosition(); + this.updateActiveIndicator(); } //-------------------------------------------------------------------------- @@ -301,71 +412,237 @@ export class TabNav { @Element() el: HTMLCalciteTabNavElement; - @State() selectedTabId: TabID; + @State() defaultMessages: TabNavMessages; + + @State() effectiveLocale = ""; + + @Watch("effectiveLocale") + effectiveLocaleChange(): void { + updateMessages(this, this.effectiveLocale); + } + + @State() private hasOverflowingStartTabTitle = false; + + @State() private hasOverflowingEndTabTitle = false; + + @State() private selectedTabId: TabID; + + private activeIndicatorEl: HTMLElement; - parentTabsEl: HTMLCalciteTabsElement; + private activeIndicatorContainerEl: HTMLDivElement; - tabNavEl: HTMLDivElement; + private dir: Direction = "ltr"; - activeIndicatorEl: HTMLElement; + private containerEl: HTMLDivElement; - activeIndicatorContainerEl: HTMLDivElement; + private lastScrollWheelAxis: "x" | "y" = "x"; - animationActiveDuration = 0.3; + private parentTabsEl: HTMLCalciteTabsElement; + + private tabTitleContainerEl: HTMLDivElement; + + private intersectionObserver: IntersectionObserver; + + private resizeObserver = createObserver("resize", () => { + this.updateScrollingState(); - resizeObserver = createObserver("resize", () => { if (!this.activeIndicatorEl) { return; } - // remove active indicator transition duration during resize to prevent wobble - this.activeIndicatorEl.style.transitionDuration = "0s"; - this.updateActiveWidth(); - this.updateOffsetPosition(); + this.updateActiveIndicator(); }); + private get scrollerButtonWidth(): number { + const { scale } = this; + return parseInt(scale === "s" ? calciteSize24 : scale === "m" ? calciteSize32 : calciteSize44); + } + //-------------------------------------------------------------------------- // // Private Methods // //-------------------------------------------------------------------------- + private updateActiveIndicator(): void { + const tabTitleScrollLeft = this.tabTitleContainerEl?.scrollLeft; + const containerScrollLeft = this.containerEl?.scrollLeft; + const navWidth = this.activeIndicatorContainerEl?.offsetWidth; + const tabLeft = this.selectedTitle?.offsetLeft; + const tabWidth = this.selectedTitle?.offsetWidth; + const offsetRight = navWidth - tabLeft - tabWidth; + const offsetBase = this.dir === "ltr" ? tabLeft : offsetRight; + const multiplier = this.dir === "ltr" ? -1 : 1; + + this.indicatorOffset = offsetBase + multiplier * (containerScrollLeft + tabTitleScrollLeft); + this.indicatorWidth = this.selectedTitle?.offsetWidth; + } + + private onTabTitleWheel = (event: WheelEvent): void => { + event.preventDefault(); + + const { deltaX, deltaY } = event; + const x = Math.abs(deltaX); + const y = Math.abs(deltaY); + + let scrollBy: number; + + if (x === y) { + scrollBy = this.lastScrollWheelAxis === "x" ? deltaX : deltaY; + } else if (x > y) { + scrollBy = deltaX; + this.lastScrollWheelAxis = "x"; + } else { + scrollBy = deltaY; + this.lastScrollWheelAxis = "y"; + } + + const scrollByX = (this.dir === "rtl" ? -1 : 1) * scrollBy; + (event.currentTarget as HTMLDivElement).scrollBy(scrollByX, 0); + requestAnimationFrame(() => this.updateActiveIndicator()); + }; + + private onSlotChange = (event: Event): void => { + this.intersectionObserver?.disconnect(); + + const slottedChildren = (event.target as HTMLSlotElement).assignedElements(); + slottedChildren.forEach((child) => { + this.intersectionObserver?.observe(child); + }); + }; + + private storeContainerRef = (el: HTMLDivElement) => (this.containerEl = el); + + private storeTabTitleWrapperRef = (el: HTMLDivElement) => { + this.tabTitleContainerEl = el; + this.intersectionObserver = createObserver("intersection", () => this.updateScrollingState(), { + root: el, + threshold: [0, 0.5, 1], + }); + }; + + private updateScrollingState(): void { + const tabTitleContainer = this.tabTitleContainerEl; + + if (!tabTitleContainer) { + return; + } + + let isOverflowStart: boolean; + let isOverflowEnd: boolean; + + const scrollPosition = tabTitleContainer.scrollLeft; + const visibleWidth = tabTitleContainer.clientWidth; + const totalContentWidth = tabTitleContainer.scrollWidth; + + if (this.dir === "ltr") { + isOverflowStart = scrollPosition > 0; + isOverflowEnd = scrollPosition + visibleWidth < totalContentWidth; + } else { + isOverflowStart = scrollPosition < 0; + isOverflowEnd = scrollPosition !== -(totalContentWidth - visibleWidth); + } + + this.hasOverflowingStartTabTitle = isOverflowStart; + this.hasOverflowingEndTabTitle = isOverflowEnd; + } + + private scrollToTabTitles = (direction: "forward" | "backward"): void => { + readTask(() => { + const tabTitleContainer = this.tabTitleContainerEl; + const containerBounds = tabTitleContainer.getBoundingClientRect(); + const tabTitles = Array.from(this.el.querySelectorAll("calcite-tab-title")); + const { dir } = this; + + if (direction === "forward") { + tabTitles.reverse(); + } + + let closestToEdge: HTMLCalciteTabTitleElement = null; + + tabTitles.forEach((tabTitle) => { + const tabTitleBounds = tabTitle.getBoundingClientRect(); + const containerEndX = containerBounds.x + containerBounds.width; + const tabTitleEndX = tabTitleBounds.x + tabTitleBounds.width; + + if ( + (direction === "forward" && dir === "ltr") || + (direction === "backward" && dir === "rtl") + ) { + const afterContainerEnd = tabTitleBounds.x > containerEndX; + + if (afterContainerEnd) { + closestToEdge = tabTitle; + } else { + const crossingContainerEnd = + tabTitleEndX > containerEndX && tabTitleBounds.x > containerBounds.x; + + if (crossingContainerEnd) { + closestToEdge = tabTitle; + } + } + } else { + const beforeContainerStart = tabTitleEndX < containerBounds.x; + + if (beforeContainerStart) { + closestToEdge = tabTitle; + } else { + const crossingContainerStart = + tabTitleEndX < containerEndX && tabTitleBounds.x < containerBounds.x; + + if (crossingContainerStart) { + closestToEdge = tabTitle; + } + } + } + }); + + if (closestToEdge) { + const { scrollerButtonWidth } = this; + const offsetAdjustment = + (direction === "forward" && dir === "ltr") || (direction === "backward" && dir === "rtl") + ? -scrollerButtonWidth + : closestToEdge.offsetWidth - tabTitleContainer.clientWidth + scrollerButtonWidth; + const scrollTo = closestToEdge.offsetLeft + offsetAdjustment; + + tabTitleContainer.scrollTo({ + left: scrollTo, + behavior: "smooth", + }); + } + }); + }; + + private scrollToNextTabTitles = (): void => this.scrollToTabTitles("forward"); + + private scrollToPreviousTabTitles = (): void => this.scrollToTabTitles("backward"); + handleTabFocus = ( event: CustomEvent, el: HTMLCalciteTabTitleElement, destination: FocusElementInGroupDestination, ): void => { - focusElementInGroup(this.enabledTabTitles, el, destination); + const focused = focusElementInGroup( + this.enabledTabTitles, + el, + destination, + ); + this.scrollTabTitleIntoView(focused, "instant"); event.stopPropagation(); }; - handleContainerScroll = (): void => { - // remove active indicator transition duration while container is scrolling to prevent wobble - this.activeIndicatorEl.style.transitionDuration = "0s"; - this.updateOffsetPosition(); - }; - - updateOffsetPosition(): void { - const dir = getElementDir(this.el); - const navWidth = this.activeIndicatorContainerEl?.offsetWidth; - const tabLeft = this.selectedTitle?.offsetLeft; - const tabWidth = this.selectedTitle?.offsetWidth; - const offsetRight = navWidth - (tabLeft + tabWidth); - this.indicatorOffset = - dir !== "rtl" ? tabLeft - this.tabNavEl?.scrollLeft : offsetRight + this.tabNavEl?.scrollLeft; - } - - updateActiveWidth(): void { - this.indicatorWidth = this.selectedTitle?.offsetWidth; - } - getIndexOfTabTitle(el: HTMLCalciteTabTitleElement, tabTitles = this.tabTitles): number { // In most cases, since these indexes correlate with tab contents, we want to consider all tab titles. // However, when doing relative index operations, it makes sense to pass in this.enabledTabTitles as the 2nd arg. return tabTitles.indexOf(el); } + private onTabTitleScroll = (): void => { + this.updateActiveIndicator(); + this.updateScrollingState(); + }; + async getTabTitleById(id: TabID): Promise { return Promise.all(this.tabTitles.map((el) => el.getTabIdentifier())).then((ids) => { return this.tabTitles[ids.indexOf(id)]; @@ -403,6 +680,7 @@ export class TabNav { } } else if (totalVisibleTabTitles > 1) { const closedTabTitleIndex = tabTitles.findIndex((el) => el === closedTabTitleEl); + const nextTabTitleIndex = visibleTabTitlesIndices.find( (value) => value > closedTabTitleIndex, ); @@ -414,9 +692,40 @@ export class TabNav { } requestAnimationFrame(() => { - this.updateOffsetPosition(); - this.updateActiveWidth(); + this.updateActiveIndicator(); tabTitles[this.selectedTabId].focus(); }); } + + private renderScrollButton = (overflowDirection: "start" | "end"): VNode => { + const { bordered, messages, hasOverflowingStartTabTitle, hasOverflowingEndTabTitle, scale } = + this; + const isEnd = overflowDirection === "end"; + + return ( + + ); + }; } diff --git a/packages/calcite-components/src/components/tab-title/tab-title.scss b/packages/calcite-components/src/components/tab-title/tab-title.scss index ca3a7f3feed..3f698ce7d25 100644 --- a/packages/calcite-components/src/components/tab-title/tab-title.scss +++ b/packages/calcite-components/src/components/tab-title/tab-title.scss @@ -11,47 +11,68 @@ @apply flex-auto; } -:host([layout="center"]) .scale-s, -:host([layout="center"]) .scale-m, -:host([layout="center"]) .scale-l { - @apply my-0 text-center; - flex-basis: theme("spacing.48"); - .content { - @apply m-auto; - } +.content { + @apply flex items-center justify-center; } -// center the text visually and not affected by the x button so as to avoid moving when on or off -:host([layout="center"][closable]) { +.scale-s { .content { - padding-inline-start: 32px; //28px button width + 0.25rem padding + @apply text-n2h py-1; } } -:host([layout="center"][bordered][closable]) .scale-s { +.scale-m { .content { - padding-inline-start: 36px; //28px button width + 0.5rem padding + @apply text-n1h py-2; } } -:host([layout="center"][bordered][closable]) .scale-m { +.scale-l { .content { - padding-inline-start: 40px; //28px button width + 0.75rem padding + @apply text-0h py-2.5; } } -:host([layout="center"][closable]) .scale-l { +:host([closable]) .content { + @apply h-full box-border border-b-color-transparent; +} + +:host([layout="inline"]), +:host([layout="center"]) { .content { - padding-inline-start: 40px; //36px button width + .25 padding + @apply px-1; } } -:host([layout="center"][closable][bordered]) .scale-s { +:host([layout="center"]) .scale-s, +:host([layout="center"]) .scale-m, +:host([layout="center"]) .scale-l { + @apply justify-center my-0 text-center; + .content { - padding-inline-start: 52px; //36px button width + 1rem padding + @apply flex-auto flex-grow; } } +.container { + @apply border-b-2 + border-b-color-transparent + box-border + content-center + cursor-pointer + flex + focus-base + h-full + justify-between + px-0 + text-color-3 + text-n1h + transition-default + w-full; + + border-block-end-style: solid; +} + :host([position="bottom"]) .container { @apply border-t-color-transparent border-b-0 @@ -63,8 +84,8 @@ @apply hidden; } -.container { - @apply focus-base; +:host([selected]) .container { + @apply text-color-1 border-color-transparent; } :host(:focus) .container { @@ -83,56 +104,12 @@ } } -:host([selected]) .container { - @apply text-color-1 border-color-transparent; -} - @include disabled() { .container { @apply pointer-events-none opacity-50; } } -.scale-s { - .content { - @apply text-n2h py-1; - } -} - -.scale-m { - .content { - @apply text-n1h py-2; - } -} - -.scale-l { - .content { - @apply text-0h py-2.5; - } -} - -.container { - @apply border-b-2 - border-b-color-transparent - box-border - content-center - cursor-pointer - flex - h-full - 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 @@ -165,11 +142,11 @@ focus-base items-center justify-center + p-1 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; @@ -216,14 +193,6 @@ box-shadow: inset 0 2px 0 var(--calcite-color-foreground-1); } -:host([bordered]:hover), -:host([bordered]:focus), -:host([bordered]:active) { - .container { - @apply relative; - } -} - :host([bordered]:hover) { .container { background-color: var(--calcite-color-transparent-hover); @@ -239,10 +208,6 @@ } } -:host([closable]) .content { - @apply h-full box-border border-b-color-transparent; -} - :host([closable][position="bottom"]) .container, :host([bordered][position="bottom"]) .container { border-block-start-style: unset; @@ -253,21 +218,34 @@ border-inline-end-color: var(--calcite-color-border-1); } -:host([bordered]) { - .content { - @apply px-3; +:host([layout="inline"][bordered]), +:host([layout="center"][bordered]) { + .scale-m { + .content { + @apply px-3; + } } -} -:host([bordered]) .scale-s { - .content { - @apply px-2; + .scale-s { + .content { + @apply px-2; + } + } + + .scale-l { + .content { + @apply px-4; + } } } -:host([bordered]) .scale-l { - .content { - @apply px-4; +:host([layout="inline"][closable]) { + .scale-s, + .scale-m, + .scale-l { + .content { + padding-inline-end: 0; + } } } diff --git a/packages/calcite-components/src/components/tabs/tabs.scss b/packages/calcite-components/src/components/tabs/tabs.scss index ed21a427295..96bf67d971e 100644 --- a/packages/calcite-components/src/components/tabs/tabs.scss +++ b/packages/calcite-components/src/components/tabs/tabs.scss @@ -5,10 +5,10 @@ :host([bordered]) { box-shadow: inset 0 1px 0 var(--calcite-color-border-1); background-color: var(--calcite-color-foreground-1); -} -:host([bordered]:not([position="bottom"])) ::slotted(calcite-tab-nav) { - margin-block-end: -1px; + section { + @apply border-color-1 border border-solid; + } } :host([bordered][position="bottom"]) { @@ -17,8 +17,8 @@ inset 0 -1px 0 var(--calcite-color-border-1); } -:host([bordered]) section { - @apply border-color-1 border border-solid; +:host([bordered]:not([position="bottom"])) ::slotted(calcite-tab-nav) { + margin-block-end: -1px; } :host([bordered][scale="s"]) section { diff --git a/packages/calcite-components/src/components/tabs/tabs.stories.ts b/packages/calcite-components/src/components/tabs/tabs.stories.ts index f767a33501f..5cd4bd71ebc 100644 --- a/packages/calcite-components/src/components/tabs/tabs.stories.ts +++ b/packages/calcite-components/src/components/tabs/tabs.stories.ts @@ -1,7 +1,7 @@ import { select } from "@storybook/addon-knobs"; import { boolean, iconNames, storyFilters } from "../../../.storybook/helpers"; import { placeholderImage } from "../../../.storybook/placeholderImage"; -import { modesDarkDefault } from "../../../.storybook/utils"; +import { createBreakpointStories, modesDarkDefault } from "../../../.storybook/utils"; import { html } from "../../../support/formatting"; import readme3 from "../tab-nav/readme.md"; import readme4 from "../tab-title/readme.md"; @@ -462,6 +462,101 @@ export const noVerticalScrollbarInsideShellPanel_TestOnly = (): string => html` `; +export const responsiveTabs = (): string => + createBreakpointStories(html` + + + + Tab 1 Title + Tab 2 Title + An Ultramarathon of a Tab Title, why not. + Tab 4 Title + Tab 5 Title + Tab 6 Title + Tab 7 Title + Tab 8 Title + + Tab 1 Content + Tab 2 Content + Tab 3 Content + Tab 4 Content + Tab 5 Content + Tab 6 Content + Tab 7 Content + Tab 8 Content + + + + + + Tab 1 Title + Tab 2 Title + An Ultramarathon of a Tab Title, why not. + Tab 4 Title + Tab 5 Title + Tab 6 Title + Tab 7 Title + Tab 8 Title + + Tab 1 Content + Tab 2 Content + Tab 3 Content + Tab 4 Content + Tab 5 Content + Tab 6 Content + Tab 7 Content + Tab 8 Content + + + + + + Tab 1 Title + Tab 2 Title + An Ultramarathon of a Tab Title, why not. + Tab 4 Title + Tab 5 Title + Tab 6 Title + Tab 7 Title + Tab 8 Title + + Tab 1 Content + Tab 2 Content + Tab 3 Content + Tab 4 Content + Tab 5 Content + Tab 6 Content + Tab 7 Content + Tab 8 Content + + + + + + Tab 1 Title + Tab 2 Title + An Ultramarathon of a Tab Title, why not. + Tab 4 Title + Tab 5 Title + Tab 6 Title + Tab 7 Title + Tab 8 Title + + Tab 1 Content + Tab 2 Content + Tab 3 Content + Tab 4 Content + Tab 5 Content + Tab 6 Content + Tab 7 Content + Tab 8 Content + + + `); export const paddingPropOverrideAtRootLevel = (): string => html`