Files
Primora/apps/frontend/src/components/Tabs.tsx
T
Tomas DvorakandDevin 420502eec1 feat: ship phase 1 surface area (v0.2.0)
- primora CLI + @primora/mcp server over generated client
- auth users admin panel, mobile bottom nav
- release workflow: GHCR images, compiled binaries, npm tarballs
- fix gzip Content-Length truncation, nginx upstream re-resolution

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-16 14:04:43 +02:00

139 lines
3.8 KiB
TypeScript

import { type JSX, For, Show, createSignal, splitProps } from "solid-js";
interface Tab {
id: string;
label: string;
icon?: JSX.Element;
badge?: string | number;
disabled?: boolean;
content?: JSX.Element;
}
interface TabsProps {
tabs: Tab[];
defaultTab?: string;
activeTab?: string;
onChange?: (tabId: string) => void;
variant?: "default" | "pills" | "underline";
size?: "sm" | "md" | "lg";
}
export function Tabs(props: TabsProps) {
const [local] = splitProps(props, ["tabs", "defaultTab", "activeTab", "onChange", "variant", "size"]);
const [internalActiveTab, setInternalActiveTab] = createSignal(local.defaultTab ?? local.tabs[0]?.id);
const activeTab = () => local.activeTab ?? internalActiveTab();
const variant = () => local.variant ?? "default";
const size = () => local.size ?? "md";
const handleTabChange = (tabId: string) => {
setInternalActiveTab(tabId);
local.onChange?.(tabId);
};
const sizeClasses = {
sm: "text-xs px-3 py-1.5",
md: "text-sm px-4 py-2",
lg: "text-base px-5 py-2.5",
};
const getTabClasses = (tab: Tab) => {
const isActive = activeTab() === tab.id;
const base = `${sizeClasses[size()]} font-medium transition-all duration-150 flex items-center gap-2`;
if (tab.disabled) {
return `${base} opacity-50 cursor-not-allowed`;
}
switch (variant()) {
case "pills":
return `${base} rounded-lg ${
isActive
? "bg-accent text-white shadow-sm"
: "text-text-2 hover:text-text-1 hover:bg-surface-2"
}`;
case "underline":
return `${base} border-b-2 ${
isActive
? "border-accent text-accent"
: "border-transparent text-text-2 hover:text-text-1 hover:border-border-strong"
}`;
default:
return `${base} rounded-lg ${
isActive
? "bg-surface-2 text-text-1"
: "text-text-2 hover:text-text-1 hover:bg-surface"
}`;
}
};
return (
<div class="w-full">
<div
class={`flex gap-1 ${variant() === "underline" ? "border-b border-border" : ""}`}
role="tablist"
>
<For each={local.tabs}>
{(tab) => (
<button
class={getTabClasses(tab)}
onClick={() => !tab.disabled && handleTabChange(tab.id)}
disabled={tab.disabled}
role="tab"
aria-selected={activeTab() === tab.id}
aria-controls={`panel-${tab.id}`}
id={`tab-${tab.id}`}
>
<Show when={tab.icon}>
<span class="flex-shrink-0">{tab.icon}</span>
</Show>
<span>{tab.label}</span>
<Show when={tab.badge}>
<span class="badge-neutral text-xs">{tab.badge}</span>
</Show>
</button>
)}
</For>
</div>
<div class="mt-4">
<For each={local.tabs}>
{(tab) => (
<Show when={activeTab() === tab.id}>
<div
role="tabpanel"
id={`panel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`}
class="animate-fade-in"
>
{tab.content}
</div>
</Show>
)}
</For>
</div>
</div>
);
}
interface TabPanelProps extends JSX.HTMLAttributes<HTMLDivElement> {
value: string;
activeValue: string;
}
export function TabPanel(props: TabPanelProps) {
const [local, rest] = splitProps(props, ["value", "activeValue", "children", "class"]);
return (
<Show when={local.value === local.activeValue}>
<div
role="tabpanel"
class={`animate-fade-in ${local.class ?? ""}`}
{...rest}
>
{local.children}
</div>
</Show>
);
}