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