UNPKG

@esri/calcite-components

Version:

Web Components for Esri's Calcite Design System.

377 lines (376 loc) • 18.7 kB
/* COPYRIGHT Esri - https://js.arcgis.com/5.0/LICENSE.txt */ import { C as CSS_UTILITY, c as customElement } from "../../chunks/runtime.js"; import { ref } from "lit/directives/ref.js"; import { keyed } from "lit/directives/keyed.js"; import { a as calciteSize24, b as calciteSize32, d as calciteSize44 } from "../../chunks/core.js"; import { css, html } from "lit"; import { LitElement, createEvent, safeClassMap } from "@arcgis/lumina"; import { g as getElementDir, B as filterDirectChildren, f as focusElementInGroup, j as focusElement } from "../../chunks/dom.js"; import { c as createObserver } from "../../chunks/observers.js"; import { u as useT9n } from "../../chunks/useT9n.js"; const ICON = { chevronRight: "chevron-right", chevronLeft: "chevron-left" }; const CSS = { container: "container", scrollButton: "scroll-button", scrollButtonContainer: "scroll-button-container", scrollBackwardButton: "scroll-button--backward", scrollForwardButton: "scroll-button--forward", tabTitleSlotWrapper: "tab-titles-slot-wrapper", scale: (scale) => `scale-${scale}`, position: (position) => `position-${position}` }; const styles = css`:host{position:relative;display:flex}:host([bordered]) .scroll-button--forward calcite-button{--calcite-internal-button-border-inline-end-color: var(--calcite-tab-border-color, var(--calcite-color-border-1))}:host([bordered]) .scroll-button--backward calcite-button{--calcite-internal-button-border-inline-start-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}.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)}.tab-titles-slot-wrapper{flex:1 1 0%}.container,.tab-titles-slot-wrapper{display:flex;inline-size:100%;justify-content:flex-start;overflow:hidden;white-space:nowrap}.scroll-button{position:absolute;inset-block:0px}.scroll-button 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{display:flex;inset-block-start:var(--calcite-border-width-md);inset-block-end:var(--calcite-border-width-md);inset-inline-end:0;inline-size:calc(2 * var(--calcite-internal-tab-nav-button-width))}.scroll-button--forward{inset-inline-end:0;z-index:var(--calcite-z-index)}.scroll-button--backward{inset-inline-end:var(--calcite-internal-tab-nav-button-width);z-index:var(--calcite-z-index)}:host(:not([bordered])) .scroll-button--backward:before{background-color:var(--calcite-tab-border-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;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 tabTitleContainer = this.tabTitleContainerEl; if (!tabTitleContainer) { return; } const containerBounds = tabTitleContainer.getBoundingClientRect(); const tabTitleBounds = activatedTabTitle.getBoundingClientRect(); const scrollPosition = tabTitleContainer.scrollLeft; if (tabTitleBounds.left < containerBounds.left) { const left = scrollPosition + (tabTitleBounds.left - containerBounds.left); tabTitleContainer.scrollTo({ left, behavior }); } else if (tabTitleBounds.right > containerBounds.right) { const left = scrollPosition + (tabTitleBounds.right - containerBounds.right); 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); } setTabTitleContainerEl(el) { this.tabTitleContainerEl = el; this.intersectionObserver?.disconnect(); if (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; if (!tabTitleContainer) { return; } 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 = tabTitleBounds.x < containerBounds.x && tabTitleEndX > containerBounds.x; if (crossingContainerStart) { closestToEdge = tabTitle; } } } }); let scrollTo; if (closestToEdge) { const scrollerButtonContainerWidth = 2 * this.scrollerButtonWidth; const offsetAdjustment = direction === "forward" && effectiveDir === "ltr" || direction === "backward" && effectiveDir === "rtl" ? -scrollerButtonContainerWidth : closestToEdge.offsetWidth - (tabTitleContainer.clientWidth + scrollerButtonContainerWidth); scrollTo = closestToEdge.offsetLeft + offsetAdjustment; } else { const scrollPosition = tabTitleContainer.scrollLeft; const containerWidth = containerBounds.width; const totalContentWidth = tabTitleContainer.scrollWidth; const hiddenContentWidth = totalContentWidth - (containerWidth + Math.abs(scrollPosition)); if (hiddenContentWidth > 0) { const directionMultiplier = effectiveDir === "ltr" ? 1 : -1; scrollTo = scrollPosition + directionMultiplier * hiddenContentWidth; } } 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(() => { focusElement(tabTitles[this.selectedTabId]); }); } render() { this.el.role = "tablist"; return html`<div class=${safeClassMap({ [CSS.container]: true, [CSS.scale(this.scale)]: true, [CSS.position(this.position)]: true, [CSS_UTILITY.rtl]: this.effectiveDir === "rtl" })}><div class=${safeClassMap({ [CSS.tabTitleSlotWrapper]: true })} @scroll=${this.onTabTitleScroll} @wheel=${this.onTabTitleWheel} ${ref(this.setTabTitleContainerEl)}><slot @slotchange=${this.onSlotChange}></slot></div><div class=${safeClassMap(CSS.scrollButtonContainer)} .hidden=${!this.hasOverflowingEndTabTitle && !this.hasOverflowingStartTabTitle}>${this.renderScrollButton("start")}${this.renderScrollButton("end")}</div></div>`; } renderScrollButton(overflowDirection) { const { messages, scale, hasOverflowingEndTabTitle, hasOverflowingStartTabTitle } = this; const isEnd = overflowDirection === "end"; return keyed(overflowDirection, html`<div class=${safeClassMap({ [CSS.scrollButton]: true, [CSS.scrollBackwardButton]: !isEnd, [CSS.scrollForwardButton]: isEnd })}><calcite-button .ariaLabel=${isEnd ? messages.nextTabTitles : messages.previousTabTitles} .disabled=${isEnd ? !hasOverflowingEndTabTitle : !hasOverflowingStartTabTitle} 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 };