UNPKG

@esri/calcite-components

Version:

Web Components for Esri's Calcite Design System.

371 lines (370 loc) • 20.3 kB
/*! All material copyright ESRI, All Rights Reserved, unless otherwise specified. See https://github.com/Esri/calcite-design-system/blob/dev/LICENSE.md for details. v3.2.1 */ import { C as CSS_UTILITY, c as customElement } from "../../chunks/runtime.js"; import { ref } from "lit-html/directives/ref.js"; import { keyed } from "lit-html/directives/keyed.js"; import { c as calciteSize24, a as calciteSize32, b as calciteSize44 } from "../../chunks/core.js"; import { html } from "lit"; import { LitElement, createEvent, safeClassMap } from "@arcgis/lumina"; import { g as getElementDir, f as filterDirectChildren, d as focusElementInGroup } from "../../chunks/dom.js"; import { c as createObserver } from "../../chunks/observers.js"; import { u as useT9n } from "../../chunks/useT9n.js"; import { css } from "@lit/reactive-element/css-tag.js"; const ICON = { chevronRight: "chevron-right", chevronLeft: "chevron-left" }; const CSS = { container: "container", containerHasEndTabTitleOverflow: "container--end-overflow", containerHasStartTabTitleOverflow: "container--start-overflow", scrollButton: "scroll-button", scrollButtonContainer: "scroll-button-container", scrollBackwardContainerButton: "scroll-button-container--backward", scrollForwardContainerButton: "scroll-button-container--forward", tabTitleSlotWrapper: "tab-titles-slot-wrapper" }; const styles = css`:host{--calcite-internal-tab-nav-gradient-start-side: left;--calcite-internal-tab-nav-gradient-end-side: right;position:relative;display:flex}:host([bordered]):not([selected]) .container{background-color:var(--calcite-tab-background-color, var(--calcite-color-foreground-1))}:host([bordered]) calcite-button{--calcite-button-border-color: var(--calcite-tab-border-color, var(--calcite-color-border-1))}.scale-s{--calcite-internal-tab-nav-button-width: 24px;min-block-size:1.5rem}.scale-m{--calcite-internal-tab-nav-button-width: 32px;min-block-size:2rem}.scale-l{--calcite-internal-tab-nav-button-width: 44px;min-block-size:2.75rem}.calcite--rtl{--calcite-internal-tab-nav-gradient-start-side: right;--calcite-internal-tab-nav-gradient-end-side: left}.container--start-overflow .tab-titles-slot-wrapper{mask-image:linear-gradient(to var(--calcite-internal-tab-nav-gradient-end-side),transparent,transparent var(--calcite-internal-tab-nav-button-width),white var(--calcite-internal-tab-nav-button-width),white 51%)}.container--end-overflow .tab-titles-slot-wrapper{mask-image:linear-gradient(to var(--calcite-internal-tab-nav-gradient-start-side),transparent,transparent var(--calcite-internal-tab-nav-button-width),white var(--calcite-internal-tab-nav-button-width),white 51%)}.container--start-overflow.container--end-overflow .tab-titles-slot-wrapper{mask-image:linear-gradient(to var(--calcite-internal-tab-nav-gradient-end-side),transparent,transparent var(--calcite-internal-tab-nav-button-width),white var(--calcite-internal-tab-nav-button-width),white 51%,transparent 51%),linear-gradient(to var(--calcite-internal-tab-nav-gradient-start-side),transparent,transparent var(--calcite-internal-tab-nav-button-width),white var(--calcite-internal-tab-nav-button-width),white 51%,transparent 51%)}.container::-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}:host([layout=center]) ::slotted(calcite-tab-title[selected]){overflow:unset}:host(:not([bordered])) .scale-l{--calcite-internal-tab-nav-gap: var(--calcite-spacing-xxl)}:host(:not([bordered])) .scale-m{--calcite-internal-tab-nav-gap: var(--calcite-spacing-xl)}:host(:not([bordered])) .scale-s{--calcite-internal-tab-nav-gap: var(--calcite-spacing-lg)}:host(:not([bordered])) .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-xl)}.container,.tab-titles-slot-wrapper{display:flex;inline-size:100%;justify-content:flex-start;overflow:hidden;white-space:nowrap}.scroll-button-container{position:absolute;inset-block:0px}.scroll-button-container calcite-button{--calcite-button-text-color: var(--calcite-tab-text-color, var(--calcite-color-text-3));--calcite-button-background-color: var(--calcite-color-transparent);--calcite-offset-invert-focus: 1;block-size:var(--calcite-container-size-content-fluid)}.scroll-button-container calcite-button:hover .scroll-button-container calcite-button:focus{--calcite-button-background-color: var(--calcite-color-transparent-hover)}.scroll-button-container calcite-button:active{--calcite-button-background-color: var(--calcite-color-transparent-press)}.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:before,:host(:not([bordered])) .scroll-button-container--forward:before{background-color:var(--calcite-tab-border-color, var(--calcite-color-border-1));opacity:.5;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}:host(:not([bordered])) .scroll-button-container--backward:before{inset-inline-end:0}:host(:not([bordered])) .scroll-button-container--forward:before{inset-inline-start:0}:host([hidden]){display:none}[hidden]{display:none}`; class TabNav extends LitElement { constructor() { super(); this.effectiveDir = "ltr"; this.lastScrollWheelAxis = "x"; this.resizeObserver = createObserver("resize", () => { this.updateScrollingState(); }); this.makeFirstVisibleTabClosable = false; this.messages = useT9n(); this.hasOverflowingEndTabTitle = false; this.hasOverflowingStartTabTitle = false; this.bordered = false; this.layout = "inline"; this.position = "bottom"; this.scale = "m"; this.selectedTitle = null; this.calciteInternalTabChange = createEvent({ cancelable: false }); this.calciteInternalTabNavSlotChange = createEvent(); this.calciteTabChange = createEvent({ cancelable: false }); this.listen("calciteInternalTabsFocusPrevious", this.focusPreviousTabHandler); this.listen("calciteInternalTabsFocusNext", this.focusNextTabHandler); this.listen("calciteInternalTabsFocusFirst", this.focusFirstTabHandler); this.listen("calciteInternalTabsFocusLast", this.focusLastTabHandler); this.listen("calciteInternalTabsActivate", this.internalActivateTabHandler); this.listen("calciteInternalTabsClose", this.internalCloseTabHandler); this.listen("calciteInternalTabTitleRegister", this.updateTabTitles); this.listenOn(document.body, "calciteInternalTabChange", this.globalInternalTabChangeHandler); } static { this.properties = { hasOverflowingEndTabTitle: [16, {}, { state: true }], hasOverflowingStartTabTitle: [16, {}, { state: true }], selectedTabId: [16, {}, { state: true }], bordered: [7, {}, { reflect: true, type: Boolean }], layout: [3, {}, { reflect: true }], messageOverrides: [0, {}, { attribute: false }], position: 1, scale: 1, selectedTitle: [0, {}, { attribute: false }], storageId: [3, {}, { reflect: true }], syncId: [3, {}, { reflect: true }] }; } static { this.styles = styles; } connectedCallback() { super.connectedCallback(); this.parentTabsEl = this.el.closest("calcite-tabs"); this.resizeObserver?.observe(this.el); } async load() { const storageKey = `calcite-tab-nav-${this.storageId}`; if (localStorage && this.storageId && localStorage.getItem(storageKey)) { const storedTab = JSON.parse(localStorage.getItem(storageKey)); this.selectedTabId = storedTab; } } willUpdate(changes) { if (changes.has("selectedTitle") && (this.hasUpdated || this.selectedTitle !== null)) { this.calciteInternalTabChange.emit({ tab: this.selectedTabId }); } if (changes.has("selectedTabId")) { this.selectedTabIdChanged(); } const { parentTabsEl } = this; this.layout = parentTabsEl?.layout; this.bordered = parentTabsEl?.bordered; this.effectiveDir = getElementDir(this.el); } loaded() { this.scrollTabTitleIntoView(this.selectedTitle, "instant"); if (this.tabTitles.length && this.tabTitles.every((title) => !title.selected) && !this.selectedTabId) { this.tabTitles[0].getTabIdentifier().then((tab) => { this.calciteInternalTabChange.emit({ tab }); }); } } disconnectedCallback() { super.disconnectedCallback(); this.resizeObserver?.disconnect(); } get enabledTabTitles() { return filterDirectChildren(this.el, "calcite-tab-title:not([disabled])").filter((tabTitle) => !tabTitle.closed); } get scrollerButtonWidth() { const { scale } = this; return parseInt(scale === "s" ? calciteSize24 : scale === "m" ? calciteSize32 : calciteSize44); } get tabTitles() { return filterDirectChildren(this.el, "calcite-tab-title"); } focusPreviousTabHandler(event) { this.handleTabFocus(event, event.target, "previous"); } focusNextTabHandler(event) { this.handleTabFocus(event, event.target, "next"); } focusFirstTabHandler(event) { this.handleTabFocus(event, event.target, "first"); } focusLastTabHandler(event) { this.handleTabFocus(event, event.target, "last"); } internalActivateTabHandler(event) { const activatedTabTitle = event.target; const currentSelectedTabTitle = this.selectedTitle; this.selectedTabId = event.detail.tab ? event.detail.tab : this.getIndexOfTabTitle(activatedTabTitle); event.stopPropagation(); this.selectedTitle = activatedTabTitle; if (currentSelectedTabTitle?.id !== activatedTabTitle.id && event.detail.userTriggered) { this.calciteTabChange.emit(); } this.scrollTabTitleIntoView(activatedTabTitle); } scrollTabTitleIntoView(activatedTabTitle, behavior = "smooth") { if (!activatedTabTitle) { return; } requestAnimationFrame(() => { const isLTR = this.effectiveDir === "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 }); } }); } internalCloseTabHandler(event) { const closedTabTitleEl = event.target; this.handleTabTitleClose(closedTabTitleEl); event.stopPropagation(); } async updateTabTitles(event) { if (event.target.selected) { this.selectedTabId = event.detail; this.selectedTitle = await this.getTabTitleById(this.selectedTabId); } } globalInternalTabChangeHandler(event) { if (this.syncId && event.target !== this.el && event.target.syncId === this.syncId && this.selectedTabId !== event.detail.tab) { this.selectedTabId = event.detail.tab; } event.stopPropagation(); } async selectedTabIdChanged() { await this.componentOnReady(); if (localStorage && this.storageId && this.selectedTabId !== void 0 && this.selectedTabId !== null) { localStorage.setItem(`calcite-tab-nav-${this.storageId}`, JSON.stringify(this.selectedTabId)); } this.calciteInternalTabChange.emit({ tab: this.selectedTabId }); } onTabTitleWheel(event) { event.preventDefault(); const { deltaX, deltaY } = event; const x = Math.abs(deltaX); const y = Math.abs(deltaY); let scrollBy; 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.effectiveDir === "rtl" ? -1 : 1) * scrollBy; event.currentTarget.scrollBy(scrollByX, 0); } onSlotChange() { this.intersectionObserver?.disconnect(); const tabTitles = this.tabTitles; tabTitles.forEach((child) => { this.intersectionObserver?.observe(child); }); const visibleTabTitlesIndices = this.getVisibleTabTitlesIndices(tabTitles); const totalVisibleTabTitles = visibleTabTitlesIndices.length; if (totalVisibleTabTitles > 1 && this.makeFirstVisibleTabClosable) { tabTitles[visibleTabTitlesIndices[0]].closable = true; this.makeFirstVisibleTabClosable = false; } this.calciteInternalTabNavSlotChange.emit(tabTitles); } storeTabTitleWrapperRef(el) { if (!el) { return; } this.tabTitleContainerEl = el; this.intersectionObserver = createObserver("intersection", () => this.updateScrollingState(), { root: el, threshold: [0, 0.5, 1] }); } updateScrollingState() { const tabTitleContainer = this.tabTitleContainerEl; if (!tabTitleContainer) { return; } let isOverflowStart; let isOverflowEnd; const scrollPosition = tabTitleContainer.scrollLeft; const visibleWidth = tabTitleContainer.clientWidth; const totalContentWidth = tabTitleContainer.scrollWidth; if (this.effectiveDir === "ltr") { isOverflowStart = scrollPosition > 0; isOverflowEnd = scrollPosition + visibleWidth < totalContentWidth; } else { isOverflowStart = scrollPosition < 0; isOverflowEnd = scrollPosition !== -(totalContentWidth - visibleWidth); } this.hasOverflowingStartTabTitle = isOverflowStart; this.hasOverflowingEndTabTitle = isOverflowEnd; } scrollToTabTitles(direction) { requestAnimationFrame(() => { const tabTitleContainer = this.tabTitleContainerEl; const containerBounds = tabTitleContainer.getBoundingClientRect(); const tabTitles = Array.from(this.el.querySelectorAll("calcite-tab-title")); const { effectiveDir } = this; if (direction === "forward") { tabTitles.reverse(); } let closestToEdge = null; tabTitles.forEach((tabTitle) => { const tabTitleBounds = tabTitle.getBoundingClientRect(); const containerEndX = containerBounds.x + containerBounds.width; const tabTitleEndX = tabTitleBounds.x + tabTitleBounds.width; if (direction === "forward" && effectiveDir === "ltr" || direction === "backward" && effectiveDir === "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" && effectiveDir === "ltr" || direction === "backward" && effectiveDir === "rtl" ? -scrollerButtonWidth : closestToEdge.offsetWidth - tabTitleContainer.clientWidth + scrollerButtonWidth; const scrollTo = closestToEdge.offsetLeft + offsetAdjustment; tabTitleContainer.scrollTo({ left: scrollTo, behavior: "smooth" }); } }); } scrollToNextTabTitles() { this.scrollToTabTitles("forward"); } scrollToPreviousTabTitles() { this.scrollToTabTitles("backward"); } handleTabFocus(event, el, destination) { const focused = focusElementInGroup(this.enabledTabTitles, el, destination); this.scrollTabTitleIntoView(focused, "instant"); event.stopPropagation(); } getIndexOfTabTitle(el, tabTitles = this.tabTitles) { return tabTitles.indexOf(el); } onTabTitleScroll() { this.updateScrollingState(); } async getTabTitleById(id) { return Promise.all(this.tabTitles.map((el) => el.getTabIdentifier())).then((ids) => { return this.tabTitles[ids.indexOf(id)]; }); } getVisibleTabTitlesIndices(tabTitles) { return tabTitles.reduce((tabTitleIndices, tabTitle, index) => !tabTitle.closed ? [...tabTitleIndices, index] : tabTitleIndices, []); } handleTabTitleClose(closedTabTitleEl) { const { tabTitles } = this; const selectionModified = closedTabTitleEl.selected; const visibleTabTitlesIndices = this.getVisibleTabTitlesIndices(tabTitles); const totalVisibleTabTitles = visibleTabTitlesIndices.length; if (totalVisibleTabTitles === 1 && tabTitles[visibleTabTitlesIndices[0]].closable) { this.makeFirstVisibleTabClosable = true; tabTitles[visibleTabTitlesIndices[0]].closable = false; this.selectedTabId = visibleTabTitlesIndices[0]; if (selectionModified) { tabTitles[visibleTabTitlesIndices[0]].activateTab(); } } 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; tabTitles[this.selectedTabId].activateTab(); } } requestAnimationFrame(() => { tabTitles[this.selectedTabId].focus(); }); } render() { this.el.role = "tablist"; return html`<div class=${safeClassMap({ [CSS.container]: true, [CSS.containerHasStartTabTitleOverflow]: !!this.hasOverflowingStartTabTitle, [CSS.containerHasEndTabTitleOverflow]: !!this.hasOverflowingEndTabTitle, [`scale-${this.scale}`]: true, [`position-${this.position}`]: true, [CSS_UTILITY.rtl]: this.effectiveDir === "rtl" })}>${this.renderScrollButton("start")}<div class=${safeClassMap({ [CSS.tabTitleSlotWrapper]: true })} @scroll=${this.onTabTitleScroll} @wheel=${this.onTabTitleWheel} ${ref(this.storeTabTitleWrapperRef)}><slot @slotchange=${this.onSlotChange}></slot></div>${this.renderScrollButton("end")}</div>`; } renderScrollButton(overflowDirection) { const { bordered, messages, hasOverflowingStartTabTitle, hasOverflowingEndTabTitle, scale } = this; const isEnd = overflowDirection === "end"; return keyed(overflowDirection, html`<div class=${safeClassMap({ [CSS.scrollButtonContainer]: true, [CSS.scrollBackwardContainerButton]: !isEnd, [CSS.scrollForwardContainerButton]: isEnd })} .hidden=${isEnd && !hasOverflowingEndTabTitle || !isEnd && !hasOverflowingStartTabTitle}><calcite-button .appearance=${bordered ? "outline-fill" : "transparent"} .ariaLabel=${isEnd ? messages.nextTabTitles : messages.previousTabTitles} class=${safeClassMap({ [CSS.scrollButton]: true })} icon-flip-rtl=both .iconStart=${isEnd ? ICON.chevronRight : ICON.chevronLeft} kind=neutral @click=${isEnd ? this.scrollToNextTabTitles : this.scrollToPreviousTabTitles} .scale=${scale} tabindex=-1></calcite-button></div>`); } } customElement("calcite-tab-nav", TabNav); export { TabNav };