restructure project, add claudemd
This commit is contained in:
166
web/kit/CrmTabs.tsx
Normal file
166
web/kit/CrmTabs.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { createSignal, onCleanup, onMount, Show, For, JSXElement } from "solid-js";
|
||||
|
||||
// CrmTabGroup / CrmSubTabGroup — drop-in, behaviourally identical siblings of
|
||||
// TabGroup (same props, storageKey syncing, controlled/uncontrolled index) with
|
||||
// a different look:
|
||||
// - CrmTabGroup → boxed tabs with a sky-blue top accent (ported from the old cdrl_2.0 tabs)
|
||||
// - CrmSubTabGroup → interlocking right-pointing arrows (a process-flow strip)
|
||||
|
||||
interface CrmTabItem {
|
||||
title: string;
|
||||
badge?: number;
|
||||
content: JSXElement;
|
||||
}
|
||||
|
||||
interface CrmTabGroupProps {
|
||||
items: CrmTabItem[];
|
||||
storageKey?: string;
|
||||
activeIndex?: number;
|
||||
onTabChange?: (index: number) => void;
|
||||
defaultIndex?: number;
|
||||
}
|
||||
|
||||
function resolveCrmBadge(badge: number | undefined): number | undefined {
|
||||
return typeof badge === "function" ? (badge as () => number)() : badge;
|
||||
}
|
||||
|
||||
// Shared active-index state: mirrors TabGroup exactly (localStorage persistence,
|
||||
// cross-component sync via synthetic storage events, optional controlled index).
|
||||
function createCrmTabState(props: CrmTabGroupProps) {
|
||||
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);
|
||||
window.dispatchEvent(new StorageEvent("storage", { key: props.storageKey, newValue: v }));
|
||||
}
|
||||
props.onTabChange && props.onTabChange(i);
|
||||
};
|
||||
|
||||
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));
|
||||
});
|
||||
|
||||
return { activeIndex, setActiveIndex };
|
||||
}
|
||||
|
||||
function crmTabContent(items: CrmTabItem[], activeIndex: () => number) {
|
||||
return <For each={items}>{(item: CrmTabItem, index: () => number) => (
|
||||
<div class={index() === activeIndex() ? "" : "hidden"}>
|
||||
{item.content}
|
||||
</div>
|
||||
)}</For>;
|
||||
}
|
||||
|
||||
// --- CrmTabGroup: boxed top-accent tabs -------------------------------------
|
||||
|
||||
// Ported from the old cdrl_2.0 TabGroup. Inactive tabs are flat with only a
|
||||
// bottom border (neutral-300) that forms the baseline; a trailing flex-1 filler
|
||||
// extends that baseline past the last tab to the right edge. The active tab
|
||||
// drops its bottom border and gains 1px left/right borders plus a 2px sky-700
|
||||
// top edge, so it reads as a raised box connected to the content below. No body
|
||||
// panel — content sits flat beneath the row, as in the old design.
|
||||
//
|
||||
// Per-tab borders (no negative-margin overlap) mean the row's overflow-x-auto
|
||||
// can't clip anything: the baseline simply has a gap under the active tab.
|
||||
const CRM_TAB_ROW = "flex w-full overflow-x-auto text-sm";
|
||||
const CRM_TAB_BASE = "flex items-center gap-1.5 cursor-pointer p-4 font-medium border-neutral-300 transition-colors";
|
||||
const CRM_TAB_INACTIVE = "border-b text-neutral-500 hover:text-neutral-800";
|
||||
const CRM_TAB_ACTIVE = "border-x border-t-2 border-t-sky-700 text-primary";
|
||||
const CRM_TAB_BADGE = "inline-flex items-center justify-center min-w-5 h-5 px-1 text-xs font-semibold bg-primary text-white rounded-full";
|
||||
|
||||
export function CrmTabGroup(props: CrmTabGroupProps) {
|
||||
const { activeIndex, setActiveIndex } = createCrmTabState(props);
|
||||
|
||||
return <div class="w-full">
|
||||
<div class={CRM_TAB_ROW}>
|
||||
<For each={props.items}>{(item: CrmTabItem, index: () => number) => (
|
||||
<button type="button" onclick={() => setActiveIndex(index())} class={CRM_TAB_BASE + " " + (index() === activeIndex() ? CRM_TAB_ACTIVE : CRM_TAB_INACTIVE)}>
|
||||
{item.title}
|
||||
<Show when={(() => {
|
||||
const b = resolveCrmBadge(item.badge);
|
||||
return b != null && b > 0;
|
||||
})()}>
|
||||
<span class={CRM_TAB_BADGE}>{resolveCrmBadge(item.badge)}</span>
|
||||
</Show>
|
||||
</button>
|
||||
)}</For>
|
||||
<div class="flex-1 border-b border-neutral-300"></div>
|
||||
</div>
|
||||
<div>
|
||||
{crmTabContent(props.items, activeIndex)}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
// --- CrmSubTabGroup: segmented control --------------------------------------
|
||||
|
||||
// A single rounded, bordered group split into segments with dividers between
|
||||
// them. The active segment is filled gray with white text; inactive segments
|
||||
// are white and recede. The group is left-aligned (flush with the file-folder
|
||||
// tabs) and a full-width baseline separates the bar from the content below.
|
||||
const CRM_SUBTAB_WRAP = "flex pb-3 border-b border-neutral-300 overflow-x-auto";
|
||||
const CRM_SUBTAB_GROUP = "inline-flex items-stretch rounded-md border border-neutral-300 overflow-hidden text-sm select-none";
|
||||
const CRM_SUBTAB_BASE = "flex items-center gap-1.5 py-1 px-3 cursor-pointer font-medium whitespace-nowrap transition-colors";
|
||||
const CRM_SUBTAB_DIVIDER = "border-l border-neutral-300";
|
||||
const CRM_SUBTAB_ACTIVE = "bg-neutral-500 text-white";
|
||||
const CRM_SUBTAB_INACTIVE = "bg-white text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900";
|
||||
const CRM_SUBTAB_BADGE = "inline-flex items-center justify-center min-w-5 h-5 px-1 text-xs font-semibold bg-black/10 text-current rounded-full";
|
||||
|
||||
export function CrmSubTabGroup(props: CrmTabGroupProps) {
|
||||
const { activeIndex, setActiveIndex } = createCrmTabState(props);
|
||||
|
||||
return <div class="w-full pt-3">
|
||||
<div class={CRM_SUBTAB_WRAP}>
|
||||
<div class={CRM_SUBTAB_GROUP}>
|
||||
<For each={props.items}>{(item: CrmTabItem, index: () => number) => (
|
||||
<button type="button" onclick={() => setActiveIndex(index())} class={CRM_SUBTAB_BASE + (index() > 0 ? " " + CRM_SUBTAB_DIVIDER : "") + " " + (index() === activeIndex() ? CRM_SUBTAB_ACTIVE : CRM_SUBTAB_INACTIVE)}>
|
||||
{item.title}
|
||||
<Show when={(() => {
|
||||
const b = resolveCrmBadge(item.badge);
|
||||
return b != null && b > 0;
|
||||
})()}>
|
||||
<span class={CRM_SUBTAB_BADGE}>{resolveCrmBadge(item.badge)}</span>
|
||||
</Show>
|
||||
</button>
|
||||
)}</For>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-3">
|
||||
{crmTabContent(props.items, activeIndex)}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
Reference in New Issue
Block a user