@esri/calcite-components
Version:
Web Components for Esri's Calcite Design System.
371 lines (370 loc) • 20.3 kB
JavaScript
/*! 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
};