Files
kjol/web/uikit/Tabs.tsx
2026-07-13 09:17:11 -04:00

173 lines
6.7 KiB
TypeScript

import { createSignal, onCleanup, onMount, Show, For, JSXElement } from "solid-js";
interface TabItem {
title: string;
badge?: number;
content?: JSXElement;
}
interface TabGroupProps {
items: TabItem[];
storageKey?: string;
activeIndex?: number;
onTabChange?: (index: number) => void;
defaultIndex?: number;
/** Optional content rendered in the right side of the tab bar (e.g. a PillSelect). */
actions?: JSXElement;
/** Extra classes on the root element (e.g. `ui-tabs` for structured panel CSS). */
class?: string;
/** When true, tabs fill available height and panels scroll internally (mobile POS). */
fill?: boolean;
/** When true, tab buttons share the header row equally below md. Defaults to true when `actions` is omitted. */
stretch?: boolean;
/** Tighter spacing for tabs above sibling panel content (use with PageLayout `tabs`). */
pageTabs?: boolean;
}
const TAB_BASE = "flex items-center gap-1.5 cursor-pointer py-2 px-4 text-sm font-medium bg-transparent border-0 border-b-2 transition-[color,border-color] duration-150";
const TAB_INACTIVE = "text-text-muted border-neutral-200 hover:text-text-body";
const TAB_ACTIVE = "text-primary border-primary";
function resolveBadge(badge: number | undefined): number | undefined {
return typeof badge === "function" ? (badge as () => number)() : badge;
}
export function TabGroup(props: TabGroupProps) {
const getInitialIndex = () => {
if (props.storageKey) {
const stored = localStorage.getItem(props.storageKey);
if (stored !== null) {
const parsed = parseInt(stored, 10);
if (!isNaN(parsed) && parsed >= 0 && parsed < props.items.length) {
return parsed;
}
}
}
return props.defaultIndex ?? 0;
};
const [_activeIndex, _setActiveIndex] = createSignal(getInitialIndex());
const activeIndex = (): number => {
const controlled = props.activeIndex;
if (controlled != null) {
return typeof controlled === "function" ? (controlled as () => number)() : controlled;
}
return _activeIndex();
};
const setActiveIndex = (i: number) => {
_setActiveIndex(i);
if (props.storageKey) {
const v = String(i);
localStorage.setItem(props.storageKey, v);
// localStorage writes don't fire `storage` events in the same
// tab; synthesize one so other components syncing on this key
// (e.g. an EventsSidebar tracking the active session) update.
window.dispatchEvent(new StorageEvent("storage", { key: props.storageKey, newValue: v }));
}
props.onTabChange && props.onTabChange(i);
};
// Sync uncontrolled tabs across components that share a storageKey: when
// another part of the UI writes to it (and dispatches a synthetic storage
// event), pick up the new value here too.
onMount(() => {
if (!props.storageKey) return;
const handler = (e: StorageEvent) => {
if (e.key !== props.storageKey || e.newValue == null) return;
const n = parseInt(e.newValue, 10);
if (!isNaN(n) && n >= 0 && n < props.items.length && n !== _activeIndex()) {
_setActiveIndex(n);
props.onTabChange && props.onTabChange(n);
}
};
window.addEventListener("storage", handler);
onCleanup(() => window.removeEventListener("storage", handler));
});
const structured = () => props.fill || (props.class || "").includes("ui-tabs");
const pageTabs = () => props.pageTabs || (props.class || "").includes("page-tabs");
const rootCls = () => {
const parts = props.fill
? ["ui-tabs flex w-full min-h-0 flex-1 flex-col overflow-hidden"]
: pageTabs()
? ["w-full page-tabs"]
: ["w-full pb-4"];
if (props.class) parts.push(props.class);
return parts.join(" ");
};
const headerCls = () => structured()
? "header overflow-x-auto flex flex-row w-full shrink-0 text-sm"
: "overflow-x-auto flex flex-row w-full text-sm";
const panelCls = (index: number) => {
const active = index === activeIndex();
if (!structured()) return active ? "" : "hidden";
if (!active) return "panel hidden";
return props.fill
? "panel flex min-h-0 flex-1 flex-col overflow-hidden"
: "panel";
};
const stretchTabs = () => {
if (props.actions != null) return props.stretch === true;
return props.stretch !== false;
};
const stretchCls = () => {
if (!stretchTabs()) return "";
return " flex-1 justify-center md:flex-initial md:justify-start";
};
const tabBtnCls = (index: number) => () =>
TAB_BASE
+ stretchCls()
+ " "
+ (index === activeIndex() ? TAB_ACTIVE : TAB_INACTIVE);
const hasInlinePanels = () => props.items.some((item) => item.content != null);
const actionsContent = () => {
if (props.actions == null) return null;
return typeof props.actions === "function" ? (props.actions as () => JSXElement)() : props.actions;
};
return (
<div class={rootCls()}>
<div class={headerCls()}>
<For each={props.items}>{(item, index) => (
<button type="button" onclick={() => setActiveIndex(index())} class={tabBtnCls(index())()}>
{item.title}
<Show when={(() => {
const b = resolveBadge(item.badge);
return b != null && b > 0;
})()}>
<span class="inline-flex items-center justify-center min-w-5 h-5 px-1 text-xs font-semibold bg-primary text-white rounded-full">{resolveBadge(item.badge)}</span>
</Show>
</button>
)}</For>
<Show when={props.actions != null && !!actionsContent()}>
<div class="tab-actions flex-1 self-end border-b-2 border-neutral-200 flex items-center justify-end pb-1">
<div class="flex items-center min-w-0">
{actionsContent()}
</div>
</div>
</Show>
</div>
<Show when={hasInlinePanels()}>
<For each={props.items}>{(item, index) => (
<Show when={item.content != null}>
<div class={panelCls(index())}>
{item.content}
</div>
</Show>
)}</For>
</Show>
</div>
);
}