import { css, html } from "lit";
import { type Cell } from "@commontools/runner";
import { BaseElement } from "../../core/base-element.ts";
import { createStringCellController } from "../../core/cell-controller.ts";
import type { CTTab } from "../ct-tab/ct-tab.ts";
import type { CTTabPanel } from "../ct-tab-panel/ct-tab-panel.ts";
/**
* CTTabs - Container component that manages tab navigation and content panels
*
* @element ct-tabs
*
* @attr {string} value - Currently selected tab value (plain string)
* @prop {Cell|string} value - Selected tab value (supports Cell for two-way binding)
* @attr {string} orientation - Tab orientation: "horizontal" | "vertical" (default: "horizontal")
*
* @slot - Default slot for ct-tab-list and ct-tab-panel elements
*
* @fires ct-change - Fired when selected tab changes with detail: { value, oldValue }
*
* @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 CTTabs extends BaseElement {
static override styles = [
BaseElement.baseStyles,
css`
:host {
display: block;
width: 100%;
}
.tabs {
display: flex;
flex-direction: column;
width: 100%;
}
.tabs[data-orientation="horizontal"] {
flex-direction: column;
}
.tabs[data-orientation="vertical"] {
flex-direction: row;
}
/* Ensure proper layout for slotted content */
::slotted(ct-tab-list) {
flex-shrink: 0;
}
::slotted(ct-tab-panel) {
flex: 1;
}
/* Handle vertical orientation */
.tabs[data-orientation="vertical"] ::slotted(ct-tab-list) {
flex-direction: column;
height: 100%;
}
/* Ensure panels are properly hidden */
::slotted(ct-tab-panel[hidden]) {
display: none !important;
}
`,
];
static override properties = {
value: { attribute: false }, // Cell or string, not reflected as attribute
orientation: { type: String },
};
declare value: Cell | string;
declare orientation: "horizontal" | "vertical";
private _changeGroup = crypto.randomUUID();
// 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
changeGroup: this._changeGroup,
onChange: (newValue: string, oldValue: string) => {
// Track this internal change so render() doesn't double-update
this._lastKnownValue = newValue;
// Update tab/panel selection when cell value changes
this.updateTabSelection();
// Emit change event
this.emit("ct-change", { value: newValue, oldValue });
},
});
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);
// Track initial value
this._lastKnownValue = this._cellController.getValue();
// Initialize tab selection
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);
}
}
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("ct-tab");
}
private getTabPanels(): NodeListOf {
return this.querySelectorAll("ct-tab-panel");
}
private updateTabSelection(): void {
const tabs = this.getTabs();
const panels = this.getTabPanels();
const currentValue = this._cellController.getValue();
// Update tabs - use property access instead of getAttribute
// because JSX sets properties, not attributes
tabs.forEach((tab) => {
const tabValue = (tab as CTTab).value;
if (tabValue === currentValue) {
tab.setAttribute("aria-selected", "true");
tab.setAttribute("data-selected", "true");
(tab as CTTab).selected = true;
} else {
tab.setAttribute("aria-selected", "false");
tab.removeAttribute("data-selected");
(tab as CTTab).selected = false;
}
});
// Update panels - use property access instead of getAttribute
// because JSX sets properties, not attributes
panels.forEach((panel) => {
const panelValue = (panel as CTTabPanel).value;
if (panelValue === currentValue) {
panel.removeAttribute("hidden");
panel.setAttribute("data-selected", "true");
(panel as CTTabPanel).hidden = false;
} else {
panel.setAttribute("hidden", "");
panel.removeAttribute("data-selected");
(panel as CTTabPanel).hidden = true;
}
});
}
private handleTabClick = (event: CustomEvent<{ tab: Element }>) => {
const tab = event.detail.tab as CTTab;
// 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) {
// Use cell controller to set value - this handles both Cell and plain values
// and triggers onChange callback which emits ct-change event
this._cellController.setValue(tab.value);
}
}
};
private handleKeydown = (event: KeyboardEvent): void => {
// Only handle keyboard navigation when focus is on a tab
const target = event.target as HTMLElement;
if (target.tagName !== "CT-TAB") return;
const tabs = Array.from(this.getTabs()) as CTTab[];
// 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 CTTab).disabled
) as CTTab | 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 CTTab).disabled
) as CTTab[];
const lastTab = enabledTabs[enabledTabs.length - 1];
if (lastTab?.value) {
this._cellController.setValue(lastTab.value);
}
}
}
globalThis.customElements.define("ct-tabs", CTTabs);