UNPKG

vira

Version:

A simple and highly versatile design system using element-vir.

806 lines (771 loc) 33 kB
import { check } from '@augment-vir/assert'; import { arrayToObject, filterMap, getObjectTypedKeys, } from '@augment-vir/common'; import { ContrastLevelName } from '@electrovir/color/dist/data/contrast/contrast.js'; import { classMap, css, defineElementEvent, html, listen, nothing, onDomCreated, unsafeCSS, } from 'element-vir'; import { routeHasPaths, } from 'spa-router-vir'; import { themeDefaultKey } from 'theme-vir/dist/color-theme/color-theme.js'; import { Check24Icon } from '../icons/icon-svgs/24/check-24.icon.js'; import { createFocusStyles } from '../styles/focus.js'; import { viraFontCssVars } from '../styles/font.js'; import { viraFormCssVars } from '../styles/form-styles.js'; import { standaloneThemeColorNames, ViraColorVariant, viraColorVariantToHostClassKey, } from '../styles/form-variants.js'; import { noNativeFormStyles, noUserSelect, viraDisabledStyles, viraTheme } from '../styles/index.js'; import { viraThemeByKeys, ViraThemeColorName } from '../styles/vira-color-theme-object.js'; import { defineViraElement } from '../util/define-vira-element.js'; import { renderMenuItemEntries } from '../util/pop-up-helpers.js'; import { ViraMenuTrigger } from './pop-up/vira-menu-trigger.element.js'; import { ViraMenuCornerStyle } from './pop-up/vira-menu.element.js'; import { ViraBoldText } from './vira-bold-text.element.js'; import { ViraButton } from './vira-button.element.js'; import { ViraIcon } from './vira-icon.element.js'; import { ViraLink } from './vira-link.element.js'; /** * Controls which edge of the tab the selection indicator bar appears on. * * @category Internal */ export var ViraTabsBarDirection; (function (ViraTabsBarDirection) { ViraTabsBarDirection["Top"] = "top"; ViraTabsBarDirection["Bottom"] = "bottom"; ViraTabsBarDirection["Left"] = "left"; ViraTabsBarDirection["Right"] = "right"; })(ViraTabsBarDirection || (ViraTabsBarDirection = {})); /** * Controls whether tab icons render above/below or beside the label text. * * @category Internal */ export var ViraTabsIconLayout; (function (ViraTabsIconLayout) { /** Icon renders above (or below) the label. */ ViraTabsIconLayout["Vertical"] = "vertical"; /** Icon renders beside the label. */ ViraTabsIconLayout["Horizontal"] = "horizontal"; })(ViraTabsIconLayout || (ViraTabsIconLayout = {})); function measureElementWidth(mirrorElement, selector) { const element = mirrorElement.querySelector(selector); return element ? element.getBoundingClientRect().width : 0; } /** * Groups a flat list of tabs into clusters. Consecutive tabs sharing the same `group` string are * merged into a single cluster; ungrouped tabs (and tabs whose `group` differs from their * predecessor) each start a new cluster. * * @category Internal */ export function buildClusters(tabList) { return tabList.reduce((accumulatedClusters, tab) => { const lastCluster = accumulatedClusters[accumulatedClusters.length - 1]; if (tab.group != undefined && lastCluster && lastCluster.group === tab.group) { return [ ...accumulatedClusters.slice(0, -1), { group: lastCluster.group, tabs: [ ...lastCluster.tabs, tab, ], }, ]; } return [ ...accumulatedClusters, { group: tab.group, tabs: [tab], }, ]; }, []); } function renderGroupedContent(tabList, renderItem) { const clusters = buildClusters(tabList); return clusters.map((cluster, clusterIndex) => { const previousCluster = clusters[clusterIndex - 1]; const dividerTemplate = previousCluster && previousCluster.group != undefined && cluster.group != undefined ? html ` <span class="tab-cluster-divider"></span> ` : nothing; const labelTemplate = cluster.group == undefined ? nothing : html ` <span class="tab-group-label">${cluster.group}</span> `; return html ` ${dividerTemplate} <div class=${classMap({ 'tab-cluster': true, standalone: cluster.group == undefined, })} > ${labelTemplate} <div class="tab-cluster-tabs">${cluster.tabs.map(renderItem)}</div> </div> `; }); } /** * A tab bar element that renders an array of tabs with an animated selection indicator. * * @category Elements */ export const ViraTabs = defineViraElement()({ tagName: 'vira-tabs', events: { /** Fires when a tab is clicked with the corresponding tab entry. */ tabSelect: defineElementEvent(), }, state() { return { /** How many of the visible tabs are collapsed into the overflow "more" menu. */ overflowCount: 0, /** A callback to remove all internal observers. */ cleanupObserver: undefined, }; }, hostClasses: { 'vira-tabs-bar-top': ({ inputs }) => inputs.barDirection === ViraTabsBarDirection.Top, 'vira-tabs-bar-bottom': ({ inputs }) => !inputs.barDirection || inputs.barDirection === ViraTabsBarDirection.Bottom, 'vira-tabs-bar-left': ({ inputs }) => inputs.barDirection === ViraTabsBarDirection.Left, 'vira-tabs-bar-right': ({ inputs }) => inputs.barDirection === ViraTabsBarDirection.Right, ...arrayToObject(getObjectTypedKeys(viraColorVariantToHostClassKey), (colorVariant) => { const colorKey = viraColorVariantToHostClassKey[colorVariant]; return { key: `vira-tabs-color-${colorKey}`, value: ({ inputs, }) => { return inputs.color === colorVariant || inputs.color === colorKey; }, }; }, { useRequired: true, }), 'vira-tabs-color-plain': ({ inputs, }) => !inputs.color || inputs.color === ViraColorVariant.Plain, 'vira-tabs-color-neutral': ({ inputs, }) => inputs.color === ViraColorVariant.Neutral, ...arrayToObject(standaloneThemeColorNames, (colorName) => { return { key: `vira-tabs-color-${colorName}`, value: ({ inputs, }) => { return inputs.color === colorName; }, }; }, { useRequired: true, }), 'vira-tabs-icon-layout-vertical': ({ inputs }) => !inputs.iconLayout || inputs.iconLayout === ViraTabsIconLayout.Vertical, 'vira-tabs-icon-layout-horizontal': ({ inputs }) => inputs.iconLayout === ViraTabsIconLayout.Horizontal, 'vira-tabs-fill-width': ({ inputs }) => !!inputs.shouldFillWidth, }, cssVars: { 'vira-tabs-active-color': viraThemeByKeys[viraColorVariantToHostClassKey[ViraColorVariant.Info]]['behind-bg'][ContrastLevelName.NonBodyText].background.value, 'vira-tabs-active-hover-color': viraThemeByKeys[viraColorVariantToHostClassKey[ViraColorVariant.Info]]['behind-bg'][ContrastLevelName.Header].background.value, 'vira-tabs-inactive-color': viraTheme.colors[themeDefaultKey].foreground.value, 'vira-tabs-inactive-hover-color': viraTheme.colors[themeDefaultKey].foreground.value, 'vira-tabs-bar-thickness': '2px', }, styles: ({ hostClasses, cssVars }) => { function buildThemedTabsColors(colorName) { return { active: viraThemeByKeys[colorName]['behind-bg'][ContrastLevelName.NonBodyText] .background, hover: viraThemeByKeys[colorName]['behind-bg'][ContrastLevelName.Header].background, }; } function buildVariantCssRule(variantSelector, colors) { return css ` ${variantSelector} { ${cssVars['vira-tabs-active-color'].name}: ${colors.active.value}; ${cssVars['vira-tabs-active-hover-color'].name}: ${colors.hover.value}; } `; } function generateVariantCss() { const plainColors = { active: viraTheme.colors['vira-grey-foreground-small-body'].foreground, hover: viraTheme.colors['vira-grey-foreground-body'].foreground, }; const themedStyles = getObjectTypedKeys(viraColorVariantToHostClassKey).map((colorVariant) => { const colorKey = viraColorVariantToHostClassKey[colorVariant]; const variantSelector = hostClasses[`vira-tabs-color-${colorKey}`].selector; const colors = buildThemedTabsColors(colorKey); return buildVariantCssRule(variantSelector, colors); }); const plainStyle = buildVariantCssRule(hostClasses['vira-tabs-color-plain'].selector, plainColors); const neutralStyle = buildVariantCssRule(hostClasses['vira-tabs-color-neutral'].selector, buildThemedTabsColors(ViraThemeColorName.grey)); const standaloneStyles = standaloneThemeColorNames.map((colorName) => { const variantSelector = hostClasses[`vira-tabs-color-${colorName}`].selector; const colors = buildThemedTabsColors(colorName); return buildVariantCssRule(variantSelector, colors); }); return unsafeCSS([ ...themedStyles, plainStyle, neutralStyle, ...standaloneStyles, ].join('\n')); } return css ` :host { display: flex; box-sizing: border-box; ${noUserSelect}; width: 100%; /** Tab labels, the overflow trigger, and menu items never wrap to a second line. */ white-space: nowrap; } .tabs-container { display: flex; position: relative; margin: 0; padding: 0; } .tabs-measure { position: absolute; top: 0; left: 0; overflow: visible; /** * visibility:hidden keeps the mirror's links out of the tab order, but a selected * tab's label re-sets visibility:visible on itself (see ViraBoldText), which would * override the inherited hidden state and paint the label. opacity:0 cannot be * overridden by a descendant, so it keeps the mirror invisible while still allowing * width measurement. */ visibility: hidden; opacity: 0; pointer-events: none; } ${hostClasses['vira-tabs-bar-top'].selector}, ${hostClasses['vira-tabs-bar-bottom'].selector} { & .tabs-container { flex-direction: row; } } ${hostClasses['vira-tabs-bar-left'].selector}, ${hostClasses['vira-tabs-bar-right'].selector} { & .tabs-container { flex-direction: column; } } .tab-item { ${noNativeFormStyles}; cursor: pointer; display: flex; align-items: center; justify-content: center; text-align: center; position: relative; color: ${cssVars['vira-tabs-inactive-color'].value}; font-size: ${viraFormCssVars['vira-form-small-text-size'].value}; font-weight: ${viraFontCssVars['vira-font-weight-medium'].value}; text-decoration: none; ${createFocusStyles({ renderInside: true, elementBorderSize: '0', })} &::after { content: ''; position: absolute; background-color: transparent; } &:hover { color: ${cssVars['vira-tabs-inactive-hover-color'].value}; background-color: ${viraTheme.colors['vira-grey-on-self-small-body'].background .value}; } &.selected { pointer-events: none; color: ${cssVars['vira-tabs-active-color'].value}; &::after { background-color: ${cssVars['vira-tabs-active-color'].value}; } } &.disabled { ${viraDisabledStyles}; } } ${hostClasses['vira-tabs-bar-bottom'].selector} { border-bottom: 1px solid ${viraTheme.colors['vira-grey-foreground-decoration'].foreground.value}; & .tab-item::after { bottom: 0; left: 0; right: 0; height: ${cssVars['vira-tabs-bar-thickness'].value}; border-radius: 0; } } ${hostClasses['vira-tabs-bar-top'].selector} { border-top: 1px solid ${viraTheme.colors['vira-grey-foreground-decoration'].foreground.value}; & .tab-item::after { top: 0; left: 0; right: 0; height: ${cssVars['vira-tabs-bar-thickness'].value}; border-radius: 0; } } ${hostClasses['vira-tabs-bar-left'].selector} { border-left: 1px solid ${viraTheme.colors['vira-grey-foreground-decoration'].foreground.value}; & .tab-item::after { top: 0; bottom: 0; left: 0; width: ${cssVars['vira-tabs-bar-thickness'].value}; border-radius: 0; } } ${hostClasses['vira-tabs-bar-right'].selector} { border-right: 1px solid ${viraTheme.colors['vira-grey-foreground-decoration'].foreground.value}; & .tab-item::after { top: 0; bottom: 0; right: 0; width: ${cssVars['vira-tabs-bar-thickness'].value}; border-radius: 0; } } ${generateVariantCss()} .tab-content { display: flex; align-items: center; justify-content: center; } ${hostClasses['vira-tabs-icon-layout-vertical'].selector} { & .tab-content { flex-direction: column; gap: 4px; } } ${hostClasses['vira-tabs-icon-layout-horizontal'].selector} { & .tab-content { flex-direction: row; gap: 8px; } } ${hostClasses['vira-tabs-fill-width'].selector} { & .tabs-container { flex-grow: 1; } & .tab-item { flex-grow: 1; } & .tab-item ${ViraLink} { flex-grow: 1; justify-content: center; } } ${ViraLink} { text-decoration: none; } .tab-item ${ViraLink} { display: flex; padding: 8px 16px; } .tab-more { display: flex; flex-shrink: 0; align-items: center; & ${ViraButton} { flex-shrink: 0; } } .tabs-container.grouped { align-items: flex-end; } .tab-cluster { display: flex; flex-direction: column; justify-content: flex-end; } .tab-cluster-tabs { display: flex; flex-direction: row; } .tab-group-label { font-size: 11px; font-weight: ${viraFontCssVars['vira-font-weight-medium'].value}; padding: 8px 16px 4px; color: ${viraTheme.colors['vira-grey-foreground-header'].foreground.value}; } .tab-cluster-divider { flex-shrink: 0; align-self: stretch; width: 1px; margin: 8px 0; background-color: ${viraTheme.colors['vira-grey-foreground-decoration'].foreground .value}; } `; }, cleanup({ state }) { state.cleanupObserver?.(); }, render({ inputs, state, updateState, host, dispatch, events }) { function renderTabInner(tab, forMeasurement) { const isSelected = routeHasPaths(inputs.currentRoute, tab.paths, { exactMatch: tab.exactMatch, }); const iconTemplate = tab.icon ? html ` <${ViraIcon.assign({ icon: tab.icon, })}></${ViraIcon}> ` : nothing; const isInert = isSelected || !!tab.isDisabled; return { isSelected, content: html ` <${ViraLink.assign({ route: { router: inputs.router, route: { paths: tab.paths.fullPaths, }, scrollToTop: true, }, disableLinkStyles: true, attributePassthrough: forMeasurement ? undefined : { a: { role: 'tab', 'aria-selected': String(isSelected), 'aria-disabled': String(!!tab.isDisabled), tabindex: isInert ? '-1' : undefined, }, }, })}> <span class="tab-content"> ${iconTemplate} <${ViraBoldText.assign({ text: tab.label, bold: isSelected, })} class="tab-label" ></${ViraBoldText}> </span> </${ViraLink}> `, }; } function handleTabClick(tab) { if (!tab.isDisabled) { dispatch(new events.tabSelect(tab)); } } function renderTabItem(tab) { const { isSelected, content } = renderTabInner(tab, false); return html ` <div class=${classMap({ 'tab-item': true, selected: isSelected, disabled: !!tab.isDisabled, })} role="presentation" ${listen('click', () => handleTabClick(tab))} > ${content} </div> `; } function renderMeasureTabItem(tab) { const { isSelected, content } = renderTabInner(tab, true); return html ` <div class=${classMap({ 'tab-item': true, 'measure-selected': isSelected, })} > ${content} </div> `; } function buildMenuItems(tabList) { return renderMenuItemEntries(filterMap(tabList, (tab) => { const isSelected = routeHasPaths(inputs.currentRoute, tab.paths, { exactMatch: tab.exactMatch, }); const iconTemplate = tab.icon ? html ` <${ViraIcon.assign({ icon: tab.icon, })}></${ViraIcon}> ` : nothing; return { content: html ` <${ViraLink.assign({ route: { router: inputs.router, route: { paths: tab.paths.fullPaths, }, scrollToTop: true, }, disableLinkStyles: true, stylePassthrough: { a: css ` display: flex; align-items: center; gap: 8px; `, }, })}> ${iconTemplate} ${tab.label} </${ViraLink}> `, selected: isSelected, disabled: tab.isDisabled, onClick() { handleTabClick(tab); }, }; }, check.isTruthy)); } function measureOverflow(mirrorElement) { const availableWidth = host.clientWidth; if (!availableWidth) { return; } /** * Vertical (left/right) tab bars stack their tabs in a column, so horizontal * width-based overflow does not apply: keep every tab inline. */ const isVertical = globalThis .getComputedStyle(mirrorElement) .flexDirection.startsWith('column'); if (isVertical) { if (state.overflowCount !== 0) { updateState({ overflowCount: 0, }); } return; } const tabElements = Array.from(mirrorElement.querySelectorAll('.tab-item')); const tabCount = tabElements.length; if (!tabCount) { return; } const tabWidths = tabElements.map((element) => element.getBoundingClientRect().width); /** * In grouped mode a partially-shown cluster still renders its group label and its * leading divider, so its inline width is `max(labelWidth, shownTabsWidth)` rather than * a bare sum of tab widths. Capture each tab's cluster geometry (in tab order) so the * inline width for any leading count can be derived from what is actually rendered. */ const clusterElements = Array.from(mirrorElement.querySelectorAll('.tab-cluster')); const containerLeft = mirrorElement.getBoundingClientRect().left; const tabClusterInfo = clusterElements.flatMap((clusterElement) => { const clusterLeft = clusterElement.getBoundingClientRect().left - containerLeft; const labelElement = clusterElement.querySelector('.tab-group-label'); const clusterLabelWidth = labelElement ? labelElement.getBoundingClientRect().width : 0; return Array.from(clusterElement.querySelectorAll('.tab-item')).map((tabElement, indexInCluster) => { return { clusterLeft, clusterLabelWidth, indexInCluster, width: tabElement.getBoundingClientRect().width, }; }); }); /** Width of the inline area when the first `count` tabs are shown. */ function inlineWidthForCount(count) { if (count <= 0) { return 0; } else if (!clusterElements.length) { return tabWidths.slice(0, count).reduce((sum, width) => sum + width, 0); } const lastInfo = tabClusterInfo[count - 1]; if (!lastInfo) { return 0; } const inlineTabsInLastCluster = tabClusterInfo .slice(count - 1 - lastInfo.indexInCluster, count) .reduce((sum, info) => sum + info.width, 0); return (lastInfo.clusterLeft + Math.max(lastInfo.clusterLabelWidth, inlineTabsInLastCluster)); } const totalWidth = inlineWidthForCount(tabCount); /** * Largest number of leading tabs whose inline width fits within the available width * minus the given reserve (the width the "more" button will occupy). Can return 0. */ function maxInlineForReserve(reservePx) { const limit = availableWidth - reservePx; return tabElements.reduce((fittingCount, _tabElement, index) => inlineWidthForCount(index + 1) <= limit ? index + 1 : fittingCount, 0); } /** When every tab fits on its own, there is no overflow and no "more" button. */ if (totalWidth <= availableWidth) { if (state.overflowCount !== 0) { updateState({ overflowCount: 0, }); } return; } const defaultButtonWidth = measureElementWidth(mirrorElement, '.measure-more-default'); const labeledButtonWidth = measureElementWidth(mirrorElement, '.measure-more-labeled'); const selectedElement = mirrorElement.querySelector('.tab-item.measure-selected'); const selectedIndex = selectedElement ? tabElements.indexOf(selectedElement) : -1; /** * Reserving the default ("more") trigger reveals whether the selected tab would end up * collapsed. */ const defaultInlineCount = maxInlineForReserve(defaultButtonWidth); const selectedIsCollapsed = selectedIndex >= 0 && selectedIndex >= defaultInlineCount; /** * When the selected tab is collapsed, the trigger always shows that tab's own label * (with a checkmark), reserving its width even when it cannot fully fit (it is allowed * to overflow rather than hide which tab is selected). Otherwise the trigger shows the * plain default overflow label. */ const inlineCount = selectedIsCollapsed ? maxInlineForReserve(labeledButtonWidth) : defaultInlineCount; const newOverflowCount = Math.max(1, tabCount - inlineCount); if (newOverflowCount !== state.overflowCount) { updateState({ overflowCount: newOverflowCount, }); } } function setUpOverflowMeasurement(mirrorElement) { state.cleanupObserver?.(); let frameId = undefined; function scheduleMeasure() { if (frameId != undefined) { return; } frameId = requestAnimationFrame(() => { frameId = undefined; measureOverflow(mirrorElement); }); } const resizeObserver = new ResizeObserver(scheduleMeasure); resizeObserver.observe(host); /** * Observing the (out-of-flow) measurement mirror catches late layout of its async * `ViraButton` children, whose real widths drive the overflow math. */ resizeObserver.observe(mirrorElement); const mutationObserver = new MutationObserver(scheduleMeasure); mutationObserver.observe(mirrorElement, { childList: true, subtree: true, characterData: true, }); scheduleMeasure(); updateState({ cleanupObserver() { if (frameId != undefined) { cancelAnimationFrame(frameId); } resizeObserver.disconnect(); mutationObserver.disconnect(); }, }); } const visibleTabs = inputs.tabs.filter((tab) => !tab.isHidden); const hasGroups = visibleTabs.some((tab) => tab.group != undefined); const overflowCount = Math.min(visibleTabs.length, Math.max(0, state.overflowCount)); const inlineCount = visibleTabs.length - overflowCount; const inlineTabs = visibleTabs.slice(0, inlineCount); const overflowTabs = visibleTabs.slice(inlineCount); const selectedIndex = visibleTabs.findIndex((tab) => routeHasPaths(inputs.currentRoute, tab.paths, { exactMatch: tab.exactMatch, })); const selectedTab = selectedIndex >= 0 ? visibleTabs[selectedIndex] : undefined; /** Whether the selected tab is currently collapsed into the overflow menu. */ const selectedIsCollapsed = selectedIndex >= 0 && selectedIndex >= inlineCount; const inlineContent = hasGroups ? renderGroupedContent(inlineTabs, renderTabItem) : inlineTabs.map(renderTabItem); const measureContent = hasGroups ? renderGroupedContent(visibleTabs, renderMeasureTabItem) : visibleTabs.map(renderMeasureTabItem); const overflowLabel = inputs.overflowLabel || 'More'; /** * Hidden copies of the "more" button in each of its possible shapes, rendered inside the * measurement mirror so their true widths can be measured before choosing which one fits: * the default overflow label, and the collapsed selected tab's own label (with a * checkmark). */ const measureMoreButtons = html ` <${ViraButton.assign({ text: overflowLabel, showMenuCaret: true, color: ViraColorVariant.Neutral, })} class="measure-more-default" ></${ViraButton}> ${selectedTab ? html ` <${ViraButton.assign({ text: selectedTab.label, icon: Check24Icon, showMenuCaret: true, color: ViraColorVariant.Neutral, })} class="measure-more-labeled" ></${ViraButton}> ` : nothing} `; const overflowMenuTemplate = overflowTabs.length ? html ` <div class="tab-more"> <${ViraMenuTrigger.assign({ horizontalAnchor: inputs.menuHorizontalAnchor, isDisabled: inputs.menuIsDisabled, popUpOffset: inputs.menuPopUpOffset, menuCornerStyle: ViraMenuCornerStyle.AllRounded, })}> <${ViraButton.assign({ text: selectedIsCollapsed ? selectedTab?.label : overflowLabel, icon: selectedIsCollapsed ? Check24Icon : undefined, showMenuCaret: true, color: ViraColorVariant.Neutral, })} slot=${ViraMenuTrigger.slotNames['vira-menu-trigger-trigger']} ></${ViraButton}> ${buildMenuItems(overflowTabs)} </${ViraMenuTrigger}> </div> ` : nothing; return html ` <div class=${classMap({ 'tabs-container': true, 'tabs-measure': true, grouped: hasGroups, })} aria-hidden="true" ${onDomCreated(setUpOverflowMeasurement)} > ${measureContent} ${measureMoreButtons} </div> <div class=${classMap({ 'tabs-container': true, grouped: hasGroups, })} role="tablist" > ${inlineContent} ${overflowMenuTemplate} </div> `; }, });