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 { CFTab } from "../cf-tab/cf-tab.ts"; import type { CFTabPanel } from "../cf-tab-panel/cf-tab-panel.ts"; /** * CFTabs - Container component that manages tab navigation and content panels * * @element cf-tabs * * @attr {string} value - Currently selected tab value (plain string) * @prop {CellHandle|string} value - Selected tab value (supports Cell for two-way binding) * @attr {string} orientation - Tab orientation: "horizontal" | "vertical" (default: "horizontal") * * @slot - Default slot for cf-tab-list and cf-tab-panel elements * * @fires cf-change - Fired ONLY for user-initiated tab changes (click or * keyboard navigation), with detail: { value, oldValue }. It is intentionally * NOT fired for programmatic / cell-driven changes to the bound value — a * bound $value cell updated elsewhere syncs the selection visually without * re-emitting cf-change. This lets a consumer write the bound cell from an * oncf-change handler without forming a feedback loop. * * @example Plain string value * * * Tab 1 * Tab 2 * * Content 1 * Content 2 * * * @example With Cell binding ($value for two-way binding) * const activeTab = cell("tab1"); * * ... * */ export class CFTabs extends BaseElement { static override styles = [ BaseElement.baseStyles, css` :host { display: flex; flex-direction: column; width: var(--cf-tabs-width, 100%); max-width: 100%; min-width: 0; min-height: 0; flex: var(--cf-tabs-flex, 1); } .tabs { display: flex; flex-direction: column; width: 100%; max-width: 100%; min-width: 0; flex: 1; min-height: 0; } .tabs[data-orientation="horizontal"] { flex-direction: column; } .tabs[data-orientation="vertical"] { flex-direction: row; } /* Ensure proper layout for slotted content */ ::slotted(cf-tab-list) { flex: 0 1 auto; min-width: 0; max-width: 100%; } ::slotted(cf-tab-panel) { flex: 1; } /* Handle vertical orientation */ .tabs[data-orientation="vertical"] ::slotted(cf-tab-list) { flex-direction: column; height: 100%; } /* Ensure panels are properly hidden */ ::slotted(cf-tab-panel[hidden]) { display: none !important; } `, ]; static override properties = { value: { attribute: false }, // Cell or string, not reflected as attribute orientation: { type: String }, }; declare value: CellHandle | string; declare orientation: "horizontal" | "vertical"; // Track last known value to detect external cell changes private _lastKnownValue: string = ""; /* ---------- Cell controller for value binding ---------- */ private _cellController = createStringCellController(this, { timing: { strategy: "immediate" }, // Tab changes should be immediate onChange: (newValue: string, _oldValue: string) => { // This callback fires for BOTH user-initiated writes (via setValue from a // tab click / keyboard nav) and cell-driven/programmatic changes (the // CellHandle subscription firing when the bound cell changes elsewhere). // // We must NOT emit "cf-change" here. Emitting on every cell change makes // cf-change indistinguishable from a user gesture, so any oncf-change // handler that writes the bound cell forms an unbreakable feedback loop // (cell write -> onChange -> cf-change -> handler -> cell write -> ...), // which the scheduler aborts as "Too many iterations" (CT-1745, CT-1746). // // "cf-change" is emitted only from the user-gesture paths // (handleTabClick / handleKeydown via emitUserChange). Here we just keep // the visual selection in sync with the cell. this._lastKnownValue = newValue; this.updateTabSelection(); }, }); constructor() { super(); this.value = ""; this.orientation = "horizontal"; } override connectedCallback() { super.connectedCallback(); // Set ARIA attributes this.setAttribute("role", "tablist"); // Add event listeners this.addEventListener("tab-click", this.handleTabClick as EventListener); this.addEventListener("keydown", this.handleKeydown); } override disconnectedCallback() { super.disconnectedCallback(); this.removeEventListener("tab-click", this.handleTabClick as EventListener); this.removeEventListener("keydown", this.handleKeydown); } override firstUpdated() { // Initialize cell controller binding this._cellController.bind(this.value, stringSchema); // Set up slotchange listener to handle dynamically added content const slot = this.shadowRoot?.querySelector("slot"); if (slot) { slot.addEventListener("slotchange", this.handleSlotChange); } // Track initial value and initialize tab selection this._lastKnownValue = this._cellController.getValue(); this.updateTabSelection(); } override willUpdate( changedProperties: Map, ) { super.willUpdate(changedProperties); // If the value property itself changed (e.g., switched to a different cell) if (changedProperties.has("value")) { this._cellController.bind(this.value, stringSchema); } } override updated(changedProperties: Map) { super.updated(changedProperties); // Always check if the cell value changed (handles both property changes // and external cell updates that trigger requestUpdate via sink) const currentValue = this._cellController.getValue(); if (currentValue !== this._lastKnownValue) { this._lastKnownValue = currentValue; this.updateTabSelection(); } } override render() { return html`
`; } private getTabs(): NodeListOf { return this.querySelectorAll("cf-tab"); } private getTabPanels(): NodeListOf { return this.querySelectorAll("cf-tab-panel"); } private _pendingRetry: number | null = null; private updateTabSelection(): void { const tabs = this.getTabs(); const panels = this.getTabPanels(); const currentValue = this._cellController.getValue(); // When tabs exist in DOM but the VDOM framework hasn't set their properties yet, // defer selection until the next frame when properties will be available. // This handles the timing gap between DOM element creation and property assignment. if (tabs.length > 0 && (tabs[0] as CFTab).value === undefined) { if (this._pendingRetry !== null) { cancelAnimationFrame(this._pendingRetry); } this._pendingRetry = requestAnimationFrame(() => { this._pendingRetry = null; this.updateTabSelection(); }); return; } // Decide which value the selection should reflect. // // An empty / undefined value means the bound cell either hasn't delivered // its value yet (the durable `$value` mount transient — getValue() coerces // the not-yet-synced value to "") OR was explicitly cleared. In NEITHER // case do we snap to the first tab: doing so makes the selection briefly // show the FIRST tab and then jump to the real value once the cell resolves // — the "flickers between two tabs" on every (re)mount. We leave // effectiveValue empty, so the apply loop below matches no tab and thus // deselects every tab / hides every panel. On a fresh mount nothing was // selected, so that is a no-op and the real value re-syncs a beat later via // the controller's onChange; on an explicit clear it correctly drops the // (now stale) prior selection rather than leaving it stuck. // // A NON-empty value that matches no tab is a genuinely stale/invalid // selection: fall back to the first enabled tab — but VISUALLY ONLY. We // must NOT write the cell from here. cf-tabs is bound through `$value` and // may be instantiated inside a render `computed()` that reads the same // cell; writing the cell during mount/selection-sync would re-trigger that // computed, re-mount cf-tabs, and write again — a non-settling "Too many // iterations" spin (the CT-1677 settle class). So selection-sync stays a // pure read: the cell is committed only on a genuine user gesture // (handleTabClick / handleKeydown via emitUserChange). This mirrors // cf-input, which writes only on input events and never on bind. A consumer // that needs the cell to carry a default should initialize it (e.g. // `Writable.of("active")`). const isEmpty = currentValue === undefined || currentValue === ""; const tabValues = Array.from(tabs, (tab) => (tab as CFTab).value); let effectiveValue = currentValue; if (!isEmpty && !tabValues.includes(currentValue) && tabs.length > 0) { const firstEnabled = Array.from(tabs).find( (tab) => !(tab as CFTab).disabled, ) as CFTab | undefined; if (firstEnabled) effectiveValue = firstEnabled.value; } // Update tabs - use property access instead of getAttribute // because JSX sets properties, not attributes tabs.forEach((tab) => { const tabValue = (tab as CFTab).value; if (tabValue === effectiveValue) { tab.setAttribute("aria-selected", "true"); tab.setAttribute("data-selected", "true"); (tab as CFTab).selected = true; } else { tab.setAttribute("aria-selected", "false"); tab.removeAttribute("data-selected"); (tab as CFTab).selected = false; } }); // Update panels - use property access instead of getAttribute // because JSX sets properties, not attributes panels.forEach((panel) => { const panelValue = (panel as CFTabPanel).value; if (panelValue === effectiveValue) { panel.removeAttribute("hidden"); panel.setAttribute("data-selected", "true"); (panel as CFTabPanel).hidden = false; } else { panel.setAttribute("hidden", ""); panel.removeAttribute("data-selected"); (panel as CFTabPanel).hidden = true; } }); } private handleSlotChange = () => { // Set up listener on cf-tab-list's internal slot when it appears. // cf-tab elements are nested inside cf-tab-list (not direct children of cf-tabs), // so we need to listen to the inner slot to detect when tabs are added. this.setupTabListSlotListener(); this.updateTabSelection(); }; private _tabListSlotListenerSetup = false; /** * Sets up a slotchange listener on cf-tab-list's internal slot. * This is necessary because cf-tab elements are slotted into cf-tab-list, * not directly into cf-tabs. Without this listener, we wouldn't know when * tabs are actually added to the DOM. */ private setupTabListSlotListener(): void { if (this._tabListSlotListenerSetup) return; const tabList = this.querySelector("cf-tab-list") as | (Element & { updateComplete?: Promise }) | null; if (!tabList) return; // Wait for cf-tab-list to have its shadow DOM ready const tabListSlot = tabList.shadowRoot?.querySelector("slot"); if (!tabListSlot) { // cf-tab-list hasn't rendered yet, retry after it updates tabList.updateComplete?.then(() => { this.setupTabListSlotListener(); }); return; } this._tabListSlotListenerSetup = true; tabListSlot.addEventListener("slotchange", () => { this.updateTabSelection(); }); // Check if tabs are already present (slotchange may have already fired) if (tabListSlot.assignedElements().length > 0) { this.updateTabSelection(); } } private handleTabClick = (event: CustomEvent<{ tab: Element }>) => { const tab = event.detail.tab as CFTab; // Use property access instead of getAttribute because JSX sets properties if (tab && tab.value && !tab.disabled) { const currentValue = this._cellController.getValue(); if (currentValue !== tab.value) { // User-initiated change: write the value through to the bound cell // (this propagates to a pattern Writable on the $value binding) and // emit cf-change. Emitting here — only on the user-gesture path — // rather than from the controller's onChange is what prevents the // cell-echo feedback loop (see _cellController.onChange above). this.emitUserChange(tab.value, currentValue); } } }; /** * Apply a user-initiated tab selection: write the value to the bound cell * and emit exactly one cf-change event. This is the ONLY place cf-change is * emitted, so cf-change always corresponds to a genuine user gesture and * never to a cell-driven/programmatic update. */ private emitUserChange(newValue: string, oldValue: string): void { // Write through to the bound cell/pattern Writable. This is the load-bearing // propagation that lets a consumer bind $value alone (no oncf-change) and // still have the selection switch on click. this._cellController.setValue(newValue); this.emit("cf-change", { value: newValue, oldValue }); } private handleKeydown = (event: KeyboardEvent): void => { // Only handle keyboard navigation when focus is on a tab const target = event.target as HTMLElement; if (target.tagName !== "CF-TAB") return; const tabs = Array.from(this.getTabs()) as CFTab[]; // Use property access instead of getAttribute because JSX sets properties const enabledTabs = tabs.filter((tab) => !tab.disabled); if (enabledTabs.length === 0) return; const currentIndex = enabledTabs.findIndex((tab) => tab === target); let nextIndex = currentIndex; const isHorizontal = this.orientation === "horizontal"; const nextKey = isHorizontal ? "ArrowRight" : "ArrowDown"; const prevKey = isHorizontal ? "ArrowLeft" : "ArrowUp"; switch (event.key) { case nextKey: event.preventDefault(); nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % enabledTabs.length; break; case prevKey: event.preventDefault(); nextIndex = currentIndex === -1 ? enabledTabs.length - 1 : (currentIndex - 1 + enabledTabs.length) % enabledTabs.length; break; case "Home": event.preventDefault(); nextIndex = 0; break; case "End": event.preventDefault(); nextIndex = enabledTabs.length - 1; break; default: return; } // Focus and select the next tab const nextTab = enabledTabs[nextIndex]; if (nextTab) { nextTab.focus(); // Trigger click to select the tab nextTab.click(); } }; /** * Get the currently selected tab value */ getValue(): string { return this._cellController.getValue(); } /** * Set the selected tab by value */ setValue(value: string): void { this._cellController.setValue(value); } /** * Select the first tab */ selectFirst(): void { const tabs = this.getTabs(); // Use property access instead of getAttribute because JSX sets properties const firstEnabledTab = Array.from(tabs).find( (tab) => !(tab as CFTab).disabled, ) as CFTab | undefined; if (firstEnabledTab?.value) { this._cellController.setValue(firstEnabledTab.value); } } /** * Select the last tab */ selectLast(): void { const tabs = this.getTabs(); // Use property access instead of getAttribute because JSX sets properties const enabledTabs = Array.from(tabs).filter((tab) => !(tab as CFTab).disabled ) as CFTab[]; const lastTab = enabledTabs[enabledTabs.length - 1]; if (lastTab?.value) { this._cellController.setValue(lastTab.value); } } }