Add js web stuff to landing page + documentation
This commit is contained in:
172
go/jsruntime/uikit/Tabs.tsx
Normal file
172
go/jsruntime/uikit/Tabs.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user