173 lines
6.7 KiB
TypeScript
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-ink-muted border-line hover:text-ink";
|
|
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-line 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>
|
|
);
|
|
}
|