import { css, html } from "lit"; import { type CellHandle } from "@commonfabric/runtime-client"; import { stringSchema } from "@commonfabric/runner/schemas"; import { BaseElement } from "../../core/base-element.ts"; import { createStringCellController } from "../../core/cell-controller.ts"; import type { CFTabBarItem } from "./cf-tab-bar-item.ts"; /** * CFTabBar - Navigation bar for mobile and app-like UIs * * The bar is fixed-position by default. When placed in a `slot="footer"`, * it participates in layout so containers such as `cf-screen` can reserve * space for the footer chrome. * * @element cf-tab-bar * * @attr {string} position - "bottom" | "top" (default: "bottom") * @attr {string} variant - "default" | "inset" (default: "default") * @prop {CellHandle|string} value - Selected item value; use $value for Cell binding * * @slot - cf-tab-bar-item elements * @slot action - Optional primary action element (e.g. a cf-button). Renders to the right of the navigation pill. * * @fires cf-change - Fired when selected item changes with detail: { value, oldValue } * * @csspart container - The outermost flex row holding the nav pill and action slot side by side. * @csspart bar - The nav pill surface containing the navigation items. * @csspart action - The wrapper around the action slot. Hidden when the slot is empty. * * @cssprop --cf-tab-bar-height - Height of the tab bar container; contributes to footer reserved space when slotted into `cf-screen`. * @cssprop --cf-tab-bar-inset-margin - Inset clearance from the screen edge; contributes to footer reserved space for inset footer bars. * * @example * const activeTab = cell("home"); * * * 🏠 * * */ export class CFTabBar extends BaseElement { static override properties = { value: { attribute: false }, // Cell or string, not reflected as attribute position: { type: String, reflect: true }, variant: { type: String, reflect: true }, _hasAction: { state: true }, }; static override styles = [ BaseElement.baseStyles, css` :host { /* Internal fallback defaults for footer tab-bar spacing. */ --_cf-tab-bar-height-default: 4rem; --_cf-tab-bar-inset-margin-default: 1rem; --_cf-tab-bar-height: var( --cf-tab-bar-height, var(--_cf-tab-bar-height-default) ); --_cf-tab-bar-inset-margin: var( --cf-tab-bar-inset-margin, var(--_cf-tab-bar-inset-margin-default) ); display: block; position: fixed; z-index: var(--cf-tab-bar-z-index, 50); } :host([position="bottom"]) { bottom: 0; left: 0; right: 0; } :host([position="top"]) { top: 0; left: 0; right: 0; } /* === Container === */ .container { display: flex; align-items: center; justify-content: center; height: var(--_cf-tab-bar-height); gap: var(--cf-spacing-2, 0.5rem); padding-inline: var(--cf-spacing-2, 0.5rem); } /* === Bar (nav items) - always the visual surface === */ .bar { display: flex; align-items: center; flex: 1; height: 100%; background: var(--cf-tab-bar-background, rgba(241, 245, 249, 0.88)); backdrop-filter: blur(var(--cf-tab-bar-backdrop-blur, 12px)); -webkit-backdrop-filter: blur(var(--cf-tab-bar-backdrop-blur, 12px)); } /* Default: bar spans full width with top/bottom border */ :host([position="bottom"]:not([variant="inset"])) .bar { border-top: 1px solid var(--cf-tab-bar-border-color, var(--cf-theme-color-border, #e5e7eb)); } :host([position="top"]:not([variant="inset"])) .bar { border-bottom: 1px solid var(--cf-tab-bar-border-color, var(--cf-theme-color-border, #e5e7eb)); } :host([position="bottom"]) .container { padding-bottom: env(safe-area-inset-bottom, 0px); } :host([position="top"]) .container { padding-top: env(safe-area-inset-top, 0px); } /* === Action slot === */ .action { display: flex; align-items: center; justify-content: center; flex-shrink: 0; align-self: center; } .action.empty { display: none; } /* === Inset variant === */ :host([variant="inset"]) { left: 0; right: 0; } :host([variant="inset"][position="bottom"]) { bottom: calc( var(--_cf-tab-bar-inset-margin) + env(safe-area-inset-bottom, 0px) ); } :host([variant="inset"][position="top"]) { top: calc(var(--_cf-tab-bar-inset-margin) + env(safe-area-inset-top, 0px)); } :host([variant="inset"]) .container { width: fit-content; margin: 0 auto; padding-bottom: 0; } :host([variant="inset"][position="top"]) .container { padding-top: 0; } :host([variant="inset"]) .bar { flex: 0 1 auto; border-radius: var( --cf-tab-bar-inset-radius, var(--cf-border-radius-full, 9999px) ); box-shadow: var( --cf-shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1) ); border: 1px solid var(--cf-tab-bar-border-color, var(--cf-theme-color-border, #e5e7eb)); padding-inline: var( --cf-tab-bar-padding-inline, var(--cf-spacing-2, 0.5rem) ); } :host([variant="inset"]) ::slotted(cf-tab-bar-item) { flex: 0 0 auto; min-width: 3.5rem; } /* Footer-slotted bars are in-flow so cf-screen can reserve space. */ :host([slot="footer"]) { position: relative; top: auto; right: auto; bottom: auto; left: auto; } :host([slot="footer"][variant="inset"][position="bottom"]) { bottom: auto; padding-bottom: calc( var(--_cf-tab-bar-inset-margin) + env(safe-area-inset-bottom, 0px) ); } :host([slot="footer"][variant="inset"][position="top"]) { top: auto; padding-top: calc( var(--_cf-tab-bar-inset-margin) + env(safe-area-inset-top, 0px) ); } /* === Reduced Motion === */ @media (prefers-reduced-motion: reduce) { .bar, .container { transition: none; } } `, ]; declare value: CellHandle | string; declare position: "bottom" | "top"; declare variant: "default" | "inset"; declare _hasAction: boolean; private _lastKnownValue: string = ""; private _cellController = createStringCellController(this, { timing: { strategy: "immediate" }, onChange: (newValue: string, oldValue: string) => { this._lastKnownValue = newValue; this.updateItemSelection(); this.emit("cf-change", { value: newValue, oldValue }); }, }); private _pendingRetry: number | null = null; constructor() { super(); this.value = ""; this.position = "bottom"; this.variant = "default"; this._hasAction = false; } override connectedCallback() { super.connectedCallback(); this.setAttribute("role", "navigation"); // Set default aria-label if not already set by the author if (!this.hasAttribute("aria-label")) { this.setAttribute("aria-label", "Main navigation"); } this.addEventListener( "tab-bar-click", this._handleItemClick as EventListener, ); this.addEventListener("keydown", this._handleKeydown); } override disconnectedCallback() { super.disconnectedCallback(); this.removeEventListener( "tab-bar-click", this._handleItemClick as EventListener, ); this.removeEventListener("keydown", this._handleKeydown); } override firstUpdated() { this._cellController.bind(this.value, stringSchema); const slot = this.shadowRoot?.querySelector("slot"); if (slot) { slot.addEventListener("slotchange", this._handleSlotChange); } this._lastKnownValue = this._cellController.getValue(); this.updateItemSelection(); } override willUpdate( changedProperties: Map, ) { super.willUpdate(changedProperties); if (changedProperties.has("value")) { this._cellController.bind(this.value, stringSchema); } } override updated( changedProperties: Map, ) { super.updated(changedProperties); const currentValue = this._cellController.getValue(); if (currentValue !== this._lastKnownValue) { this._lastKnownValue = currentValue; this.updateItemSelection(); } } override render() { return html`
`; } private _handleActionSlotChange = (e: Event) => { const slot = e.target as HTMLSlotElement; this._hasAction = slot.assignedElements().length > 0; }; private _getItems(): NodeListOf { return this.querySelectorAll("cf-tab-bar-item"); } updateItemSelection(): void { const items = this._getItems(); const currentValue = this._cellController.getValue(); // Defer selection until next frame if items exist but values aren't set yet if ( items.length > 0 && (items[0] as CFTabBarItem).value === undefined ) { if (this._pendingRetry !== null) { cancelAnimationFrame(this._pendingRetry); } this._pendingRetry = requestAnimationFrame(() => { this._pendingRetry = null; this.updateItemSelection(); }); return; } items.forEach((item) => { const tabBarItem = item as CFTabBarItem; tabBarItem.selected = tabBarItem.value === currentValue; }); } private _handleSlotChange = () => { this.updateItemSelection(); }; private _handleItemClick = ( event: CustomEvent<{ item: CFTabBarItem }>, ): void => { const item = event.detail.item; if (item && item.value && !item.disabled) { const currentValue = this._cellController.getValue(); if (currentValue !== item.value) { this._cellController.setValue(item.value); } } }; private _handleKeydown = (event: KeyboardEvent): void => { const target = event.target as HTMLElement; if (target.tagName !== "CF-TAB-BAR-ITEM") return; const items = Array.from(this._getItems()) as CFTabBarItem[]; const enabledItems = items.filter((item) => !item.disabled); if (enabledItems.length === 0) return; const currentIndex = enabledItems.findIndex((item) => item === target); let nextIndex = currentIndex; switch (event.key) { case "ArrowRight": event.preventDefault(); nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % enabledItems.length; break; case "ArrowLeft": event.preventDefault(); nextIndex = currentIndex === -1 ? enabledItems.length - 1 : (currentIndex - 1 + enabledItems.length) % enabledItems.length; break; case "Home": event.preventDefault(); nextIndex = 0; break; case "End": event.preventDefault(); nextIndex = enabledItems.length - 1; break; default: return; } const nextItem = enabledItems[nextIndex]; if (nextItem) { nextItem.focus(); const button = nextItem.button; if (button) { button.click(); } else { nextItem.click(); } } }; /** * Get the currently selected item value */ getValue(): string { return this._cellController.getValue(); } /** * Set the selected item by value */ setValue(value: string): void { this._cellController.setValue(value); } }