Add js web stuff to landing page + documentation

This commit is contained in:
2026-07-14 10:33:12 -04:00
parent fec8ef4a3e
commit 02a6dc6c48
435 changed files with 69567 additions and 1522 deletions

View File

@@ -0,0 +1,127 @@
import { createSignal, createEffect, For, Show, JSXElement } from "solid-js";
import { Icon } from "./Icons.tsx";
// Baseline Tailwind for the scoped .ui-accordion stack. The ui-* class
// names are kept so page-specific CSS overrides (e.g. sale.css,
// licensing.css) can continue to layer on top.
const ROOT = "ui-accordion border border-line rounded-default overflow-hidden";
const ITEM = "ui-accordion-item border-b border-line last:border-b-0";
const TRIGGER = "ui-accordion-trigger flex items-center justify-between w-full py-3 px-4 text-left font-medium text-ink bg-surface-muted cursor-pointer border-none transition-colors hover:bg-surface-raised active:bg-surface-strong focus:outline-hidden disabled:text-ink-faint disabled:cursor-not-allowed disabled:bg-surface-muted";
const TITLE = "ui-accordion-title flex-1";
const CONTENT = "ui-accordion-content px-4 pb-4 text-ink";
function iconCls(open: boolean): string {
return "ui-accordion-icon text-ink-muted leading-none transition-transform duration-200" +
(open ? " rotate-180" : "");
}
interface AccordionItemProps {
startOpen?: boolean;
isOpen?: boolean;
disabled?: boolean;
title?: string;
children?: JSXElement;
}
export function AccordionItem(props: AccordionItemProps) {
const getStartOpen = () => typeof props.startOpen === "function" ? (props.startOpen as () => boolean)() : !!props.startOpen;
const [isOpen, setIsOpen] = createSignal(getStartOpen());
createEffect(() => {
if (props.isOpen === undefined) return;
const v = typeof props.isOpen === "function" ? (props.isOpen as () => boolean)() : !!props.isOpen;
setIsOpen(v);
});
const isDisabled = () => typeof props.disabled === "function" ? (props.disabled as () => boolean)() : !!props.disabled;
const title = () => typeof props.title === "function" ? (props.title as () => string)() : props.title;
const toggle = () => {
if (isDisabled()) return;
setIsOpen(!isOpen());
};
return (
<div class={ITEM}>
<button type="button" class={TRIGGER} disabled={isDisabled()} onclick={toggle} aria-expanded={isOpen()}>
<span class={TITLE}>{title()}</span>
<Show when={!isDisabled()}>
<span class={iconCls(isOpen())}>
<Icon icon={isOpen() ? "chevron-up" : "chevron-down"} size={18}/>
</span>
</Show>
</button>
<Show when={isOpen() && !isDisabled()}>
<div class={CONTENT}>
{props.children}
</div>
</Show>
</div>
);
}
interface AccordionItemData {
title: string;
content: JSXElement;
disabled?: boolean;
}
interface AccordionProps {
items?: AccordionItemData[];
}
export function Accordion(props: AccordionProps) {
const items = () => props.items || [];
return (
<div class={ROOT}>
<For each={items()}>{(item) => (
<AccordionItem title={(() => {
const t = item.title;
return typeof t === "function" ? (t as () => string)() : t;
})()} disabled={(() => {
const d = item.disabled;
return typeof d === "function" ? (d as () => boolean)() : !!d;
})()}>
{item.content}
</AccordionItem>
)}</For>
</div>
);
}
interface SingleAccordionProps {
items?: AccordionItemData[];
startOpen?: number;
}
export function SingleAccordion(props: SingleAccordionProps) {
const items = () => props.items || [];
const [openIndex, setOpenIndex] = createSignal(props.startOpen ?? 0);
const toggleItem = (index: number) => {
setOpenIndex(openIndex() === index ? -1 : index);
};
return (
<div class={ROOT}>
<For each={items()}>{(item, index) => (
<div class={ITEM}>
<button type="button" class={TRIGGER} disabled={!!item.disabled} onclick={() => toggleItem(index())} aria-expanded={openIndex() === index()}>
<span class={TITLE}>{item.title}</span>
<Show when={!item.disabled}>
<span class={iconCls(openIndex() === index())}>
<Icon icon={openIndex() === index() ? "chevron-up" : "chevron-down"} size={18}/>
</span>
</Show>
</button>
<Show when={openIndex() === index() && !item.disabled}>
<div class={CONTENT}>
{item.content}
</div>
</Show>
</div>
)}</For>
</div>
);
}

View File

@@ -0,0 +1,44 @@
import { JSXElement, Show } from "solid-js";
type AlertColor = "white" | "gray" | "blue" | "green" | "red" | "yellow";
interface AlertProps {
header?: string;
class?: string;
children: JSXElement;
}
const BASE = "p-4 rounded-default shadow-xs border";
const COLORS: Record<AlertColor, string> = {
white: "bg-surface border-line",
gray: "bg-surface-muted border-line",
blue: "bg-sky-50 dark:bg-sky-950/40 border-sky-200 dark:border-sky-900",
green: "bg-green-50 dark:bg-green-950/40 border-green-200 dark:border-green-900",
red: "bg-red-50 dark:bg-red-950/40 border-red-200 dark:border-red-900",
yellow: "bg-yellow-50 dark:bg-yellow-950/40 border-yellow-200 dark:border-yellow-900",
};
function alertClass(color: AlertColor, extra?: string): string {
return BASE + " " + COLORS[color] + (extra ? " " + extra : "");
}
function makeAlert(color: AlertColor) {
return function Alert(props: AlertProps) {
return (
<div class={alertClass(color, props.class)}>
<Show when={props.header}>
<h3 class="font-semibold mb-2">{props.header}</h3>
</Show>
<p class="text-sm">{props.children}</p>
</div>
);
};
}
export const AlertWhite = makeAlert("white");
export const AlertGray = makeAlert("gray");
export const AlertBlue = makeAlert("blue");
export const AlertGreen = makeAlert("green");
export const AlertRed = makeAlert("red");
export const AlertYellow = makeAlert("yellow");

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
import { JSXElement } from "solid-js";
export const BADGE_GREEN = "green";
export const BADGE_RED = "red";
export const BADGE_BLUE = "blue";
export const BADGE_AMBER = "amber";
export const BADGE_NEUTRAL = "neutral";
export const BADGE_MUTED = "muted";
const BASE = "inline-flex items-center gap-1 text-xs font-semibold py-0.5 px-2 rounded-default whitespace-nowrap";
const COLORS: Record<string, string> = {
"green": "text-white bg-green-700",
"red": "text-white bg-red-700",
"blue": "text-white bg-sky-800",
"amber": "text-white bg-amber-700",
"neutral": "text-white bg-neutral-500",
"muted": "text-ink-faint bg-transparent",
};
interface BadgeProps {
color?: string;
pill?: boolean;
// When provided, the badge renders as a `<button>` with the given
// click handler — same visuals, just interactive.
onclick?: () => void;
disabled?: boolean;
title?: string;
// Extra utility classes appended after the base styling — useful
// for overriding e.g. the default `font-semibold` (use
// `!font-normal`) on specific instances.
class?: string;
children?: JSXElement;
}
export function Badge(props: BadgeProps) {
const cls = () => {
let c = BASE;
if (props.pill) c += " rounded-full";
c += " " + (COLORS[props.color!] || COLORS["neutral"]);
if (props.onclick) c += " cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed border-0";
if (props.class) c += " " + props.class;
return c;
};
if (props.onclick) {
return (
<button type="button" class={cls()} onclick={() => props.onclick!()} disabled={props.disabled} title={props.title}>{props.children}</button>
);
}
return <span class={cls()} title={props.title}>{props.children}</span>;
}

View File

@@ -0,0 +1,195 @@
import { JSX, JSXElement } from "solid-js";
import { Icon } from "./Icons.tsx";
export const BUTTON_COLOR_NEUTRAL = "neutral";
export const BUTTON_COLOR_WHITE = "white";
export const BUTTON_COLOR_LIGHT_NEUTRAL = "light-neutral";
export const BUTTON_COLOR_BLUE = "blue";
export const BUTTON_COLOR_DARK_BLUE = "dark-blue";
export const BUTTON_COLOR_GREEN = "green";
export const BUTTON_COLOR_DARK_GREEN = "dark-green";
export const BUTTON_COLOR_RED = "red";
export const BUTTON_COLOR_DARK_RED = "dark-red";
export const BUTTON_COLOR_YELLOW = "yellow";
export const BUTTON_COLOR_ORANGE = "orange";
export const BUTTON_COLOR_PRIMARY = "primary";
const BASE = "inline-flex items-center justify-center gap-2 cursor-pointer text-sm font-normal rounded-default transition disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-2 focus-visible:outline-current focus-visible:outline-offset-2";
const COLORS: Record<string, string> = {
// The one button that INVERTS with the theme: dark on a light page, light on a
// dark one. It cannot be `bg-ink` with `text-white`, because the moment the fill
// goes pale in dark mode the white label vanishes — the fill and its text have to
// move together, which is what the three fill-neutral tokens are for.
"neutral": "shadow-xs bg-fill-neutral text-on-fill-neutral hover:bg-fill-neutral-hover",
"white": "shadow-xs bg-surface text-ink border border-line-strong hover:bg-surface-muted",
"light-neutral": "shadow-xs bg-surface-muted text-ink border border-line-strong hover:bg-surface-raised",
"blue": "shadow-xs bg-sky-700 text-white hover:bg-sky-800",
"dark-blue": "shadow-xs bg-sky-900 text-white hover:bg-sky-950",
"green": "shadow-xs bg-green-700 text-white hover:bg-green-800",
"dark-green": "shadow-xs bg-green-900 text-white hover:bg-green-950",
"red": "shadow-xs bg-red-700 text-white hover:bg-red-800",
"dark-red": "shadow-xs bg-red-900 text-white hover:bg-red-950",
"yellow": "shadow-xs bg-yellow-700 text-white hover:bg-yellow-800",
"orange": "shadow-xs bg-orange-600 text-white hover:bg-orange-700",
"primary": "shadow-xs bg-primary text-white hover:bg-primary-hover",
"secondary": "shadow-none bg-surface-raised text-ink border border-line-strong hover:bg-surface-strong",
"ghost": "shadow-none bg-transparent text-ink-soft border-none hover:bg-surface-raised",
};
const OUTLINE_COLORS: Record<string, string> = {
"neutral": "text-ink",
"white": "text-ink-faint",
"light-neutral": "text-ink-faint",
"blue": "text-sky-700 dark:text-sky-400",
"dark-blue": "text-sky-900",
"green": "text-green-700 dark:text-green-400",
"dark-green": "text-green-900",
"red": "text-red-700 dark:text-red-400",
"dark-red": "text-red-900",
"yellow": "text-yellow-700 dark:text-yellow-400",
"orange": "text-orange-600",
"primary": "text-primary",
};
const OUTLINE_BASE = "bg-transparent shadow-[inset_0_0_0_1px_currentColor] hover:shadow-[inset_0_0_0_2px_currentColor]";
interface ButtonUIProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
text?: string;
icon?: unknown;
outline?: boolean;
color?: string;
small?: boolean;
}
export function ButtonUI(props: ButtonUIProps) {
const hasText = () => props.text !== undefined ? !!props.text : !props.icon;
const cls = () => {
let c = BASE;
if (props.outline) {
c += " " + OUTLINE_BASE + " " + (OUTLINE_COLORS[props.color!] || OUTLINE_COLORS["neutral"]);
} else {
c += " " + (COLORS[props.color!] || COLORS["neutral"]);
}
if (props.small) {
c += props.icon ? " py-1 px-3" : " py-1 px-4";
} else if (props.icon && !hasText()) {
c += " py-2 px-3";
} else if (props.icon) {
c += " py-2 px-5";
} else {
c += " py-2 px-8";
}
return c;
};
return (
<button
type={props.type || "button"}
onclick={(e) => typeof props.onclick === "function" && props.onclick(e)}
onmousedown={(e) => typeof props.onmousedown === "function" && props.onmousedown(e)}
disabled={props.disabled}
title={props.title}
class={cls()}
>
{props.children}
</button>
);
}
interface ButtonLinkProps {
onclick?: (e: MouseEvent) => void;
children?: JSXElement;
}
export function ButtonLink(props: ButtonLinkProps) {
return (
<button type="button" onclick={(e: MouseEvent) => typeof props.onclick === "function" && props.onclick(e)} class="cursor-pointer bg-transparent border-none p-0 font-[inherit] text-sky-700 dark:text-sky-400 hover:underline">{props.children}</button>
);
}
export function ButtonLinkRed(props: ButtonLinkProps) {
return (
<button type="button" onclick={(e: MouseEvent) => typeof props.onclick === "function" && props.onclick(e)} class="cursor-pointer bg-transparent border-none p-0 font-[inherit] text-red-600 dark:text-red-400 hover:underline">{props.children}</button>
);
}
// SegmentedButtons renders a horizontal group of mutually-exclusive button
// options - one is "selected" at any time. Used for "toggle" patterns like
// the events sidebar's date/class switcher. Pill styling: a neutral track
// holds a raised white chip that marks the selected option; the rest sit
// muted. Buttons stretch to fill the track (flex-1).
type Reactive2<T> = T | (() => T);
export interface SegmentedButtonOption {
value: string;
label: string;
icon?: string;
}
interface SegmentedButtonsProps {
options: Reactive2<SegmentedButtonOption[]>;
value: Reactive2<string>;
onchange: (v: string) => void;
small?: boolean;
class?: string;
}
export function SegmentedButtons(props: SegmentedButtonsProps) {
const resolveR = <T,>(v: Reactive2<T>): T => (typeof v === "function" ? (v as () => T)() : v);
// Concentric corner radii: outer = inner + gap, where the gap is the
// track's p-0.5 (2px) padding -> 6px = 4px + 2px. Reusing one radius for
// both makes the chip corners read as too sharp inside the track.
const innerRadius = "rounded-default"; // chips: 4px
const outerRadius = "rounded-md"; // track: 4px + 2px = 6px
const sizeCls = () => props.small ? "py-0.5 px-2 text-xs" : "py-1 px-3 text-sm";
const baseCls = "inline-flex items-center justify-center gap-1.5 flex-1 cursor-pointer font-medium transition-colors whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed";
const activeCls = "bg-surface text-ink shadow-sm";
const inactiveCls = "text-ink-muted hover:text-ink";
const buttonCls = (v: string) => {
const selected = resolveR(props.value) === v;
return baseCls + " " + innerRadius + " " + sizeCls() + " " + (selected ? activeCls : inactiveCls);
};
return (
<div class={"flex items-center gap-0.5 " + outerRadius + " bg-surface-raised p-0.5 " + (props.class || "")}>
{resolveR(props.options).map((opt) => (
<button
type="button"
class={buttonCls(opt.value)}
onclick={() => props.onchange(opt.value)}
title={opt.label}
>
{opt.icon ? <Icon icon={opt.icon} size={12} /> : ""}
<span>{opt.label}</span>
</button>
))}
</div>
);
}
interface BackLinkProps {
href: string;
text?: string;
onDark?: boolean;
}
// Bare inline-flex anchor — callers control surrounding spacing so
// the link can sit cleanly inside a flex row without throwing off
// vertical alignment (e.g. inside a page header next to a title).
export function BackLink(props: BackLinkProps) {
const cls = () => "inline-flex items-center gap-1 text-sm no-underline "
+ (props.onDark
? "text-text-on-dark-muted hover:text-text-on-dark"
: "text-ink-soft hover:text-ink");
return (
<a href={props.href} class={cls()}>
<Icon icon="chevron-left" size={16}/>
{props.text}
</a>
);
}

View File

@@ -0,0 +1,211 @@
import { createSignal, createMemo, For, createEffect, JSXElement } from "solid-js";
import { Icon } from "./Icons.tsx";
const DAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
const MONTHS = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"
];
// -- Shared Tailwind class constants (also used by DatePicker) --
export const CAL_PICKER_ROOT = "p-2 min-w-[240px]";
export const CAL_MONTH_ROOT = "p-0 min-w-0 w-full bg-surface border border-line rounded-default shadow-sm overflow-hidden";
export const CAL_HEADER_PICKER = "flex items-center justify-between mb-2 gap-1";
export const CAL_HEADER_MONTH = "flex items-center justify-between gap-1 py-3 px-4 border-b border-line bg-surface-muted";
export const CAL_NAV_BTN = "bg-transparent border-0 p-1 cursor-pointer text-ink-muted rounded-sm flex items-center justify-center hover:bg-surface-raised hover:text-ink";
export const CAL_MY_PICKER = "text-sm font-semibold text-ink mx-3 whitespace-nowrap";
export const CAL_MY_MONTH = "text-lg font-heading mx-4 flex-1 text-center font-semibold text-ink whitespace-nowrap";
export const CAL_WEEKDAYS_PICKER = "grid grid-cols-7 gap-[2px] mb-1";
export const CAL_WEEKDAYS_MONTH = "grid grid-cols-7 border-b border-line";
export const CAL_WEEKDAY_PICKER = "text-center text-xs font-semibold text-ink-muted p-1";
export const CAL_WEEKDAY_MONTH = "text-center text-xs font-semibold text-ink-muted p-2 uppercase tracking-wider";
export const CAL_DAYS_PICKER = "grid grid-cols-7 gap-[2px]";
export const CAL_DAYS_MONTH = "grid grid-cols-7";
export const CAL_DAY_PICKER_BASE = "aspect-square flex items-center justify-center text-sm bg-transparent border-0 rounded-sm cursor-pointer text-ink p-0";
export const CAL_DAY_MONTH_BASE = "min-h-[6.5rem] flex flex-col items-stretch justify-start p-1.5 border-r border-b border-line text-left gap-1 text-xs bg-transparent cursor-pointer";
export const CAL_SELECT = "flex-1 py-1 px-2 text-sm font-semibold border border-line rounded-sm bg-surface text-ink cursor-pointer focus:outline-hidden focus:border-primary";
function getDaysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
function getFirstDayOfMonth(year: number, month: number): number {
return new Date(year, month, 1).getDay();
}
function toDateKey(date: Date | null | undefined): string {
if (!date) return "";
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
export type CalendarVariant = "picker" | "month";
interface CalendarProps {
selected?: string | Date;
viewMonth?: string | Date;
onSelect?: (key: string) => void;
variant?: CalendarVariant;
renderDay?: (key: string, date: Date) => JSXElement;
_reset?: unknown;
}
export function Calendar(props: CalendarProps) {
const today = new Date();
const [viewMonth, setViewMonth] = createSignal(new Date(today.getFullYear(), today.getMonth(), 1));
const [key, setKey] = createSignal(0);
createEffect(() => {
if (props.selected) {
const d = new Date(props.selected);
if (!isNaN(d.getTime())) {
setViewMonth(new Date(d.getFullYear(), d.getMonth(), 1));
}
}
});
createEffect(() => {
props._reset;
setKey(k => k + 1);
setViewMonth(new Date(today.getFullYear(), today.getMonth(), 1));
});
// Sync viewMonth from parent only when the parent's value actually changes
// to a different month. Tracking a stamp prevents the parent from clobbering
// the user's local month navigation on unrelated re-renders.
let lastPropStamp: number | null = null;
createEffect(() => {
const vm = props.viewMonth;
if (!vm) return;
const d = vm instanceof Date ? vm : new Date(vm);
if (!(d instanceof Date) || isNaN(d.getTime())) return;
const stamp = d.getFullYear() * 12 + d.getMonth();
if (stamp === lastPropStamp) return;
lastPropStamp = stamp;
setViewMonth(new Date(d.getFullYear(), d.getMonth(), 1));
});
const currentMonth = createMemo(() => viewMonth().getMonth());
const currentYear = createMemo(() => viewMonth().getFullYear());
const days = createMemo(() => {
const year = currentYear();
const month = currentMonth();
const daysInMonth = getDaysInMonth(year, month);
const firstDay = getFirstDayOfMonth(year, month);
const daysArray: (Date | null)[] = [];
for (let i = 0; i < firstDay; i++) {
daysArray.push(null);
}
for (let i = 1; i <= daysInMonth; i++) {
daysArray.push(new Date(year, month, i));
}
return daysArray;
});
const prevMonth = () => setViewMonth(new Date(currentYear(), currentMonth() - 1, 1));
const nextMonth = () => setViewMonth(new Date(currentYear(), currentMonth() + 1, 1));
const isSelected = (date: Date | null): boolean => {
if (!date || !props.selected) return false;
const sel = new Date(props.selected);
if (isNaN(sel.getTime())) return false;
return date.getFullYear() === sel.getFullYear() &&
date.getMonth() === sel.getMonth() &&
date.getDate() === sel.getDate();
};
const isToday = (date: Date | null): boolean => {
if (!date) return false;
return date.getFullYear() === today.getFullYear() &&
date.getMonth() === today.getMonth() &&
date.getDate() === today.getDate();
};
const selectDate = (date: Date | null) => {
if (!date) return;
props.onSelect?.(toDateKey(date));
};
const variant = (): CalendarVariant => props.variant || "picker";
const isMonth = () => variant() === "month";
const rootCls = () => isMonth() ? CAL_MONTH_ROOT : CAL_PICKER_ROOT;
const headerCls = () => isMonth() ? CAL_HEADER_MONTH : CAL_HEADER_PICKER;
const myCls = () => isMonth() ? CAL_MY_MONTH : CAL_MY_PICKER;
const weekdaysCls = () => isMonth() ? CAL_WEEKDAYS_MONTH : CAL_WEEKDAYS_PICKER;
const weekdayCls = () => isMonth() ? CAL_WEEKDAY_MONTH : CAL_WEEKDAY_PICKER;
const daysCls = () => isMonth() ? CAL_DAYS_MONTH : CAL_DAYS_PICKER;
// Day button class. nth-child(7n) (last column) skips right border in
// month variant — we compute it from the array index since the Tailwind
// compiler doesn't support [&:nth-child(7n)] arbitrary variants.
const dayClass = (date: Date | null, idx: number): string => {
const empty = !date;
const selected = isSelected(date);
const today = isToday(date);
if (isMonth()) {
let c = CAL_DAY_MONTH_BASE;
if (idx % 7 === 6) c += " border-r-0";
if (empty) c += " bg-surface-muted cursor-default";
else c += " hover:bg-surface-muted";
if (selected) c += " bg-primary/10 text-ink";
return c;
}
let c = CAL_DAY_PICKER_BASE;
if (empty) c += " cursor-default";
else c += " hover:bg-surface-raised";
if (today) c += " font-bold text-primary";
if (selected) c += " !bg-primary !text-white";
return c;
};
const dayNumberClass = (date: Date | null): string => {
if (isMonth()) {
const base = "text-sm font-semibold text-ink-muted self-end px-1 py-0.5";
if (date && isToday(date)) {
return "bg-primary text-white rounded-full w-6 h-6 inline-flex items-center justify-center p-0 self-end text-sm font-semibold";
}
return base;
}
return "leading-none";
};
return (
<div class={rootCls()} attr:key={key()}>
<div class={headerCls()}>
<button class={CAL_NAV_BTN} onclick={prevMonth}>
<Icon icon="chevron-left" size={16}/>
</button>
<span class={myCls()}>{MONTHS[currentMonth()] + " " + currentYear()}</span>
<button class={CAL_NAV_BTN} onclick={nextMonth}>
<Icon icon="chevron-right" size={16}/>
</button>
</div>
<div class={weekdaysCls()}>
<For each={DAYS}>{(day) => <div class={weekdayCls()}>{day}</div>}</For>
</div>
<div class={daysCls()}>
<For each={days()}>{(date, idx) => (
<button class={dayClass(date, idx())} onclick={() => selectDate(date)} disabled={!date}>
<span class={dayNumberClass(date)}>{date ? date.getDate() : ""}</span>
{date && props.renderDay ? props.renderDay(toDateKey(date), date) : ""}
</button>
)}</For>
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { JSXElement } from "solid-js";
interface CardProps {
class?: string;
children?: JSXElement;
}
// `ui-card` / `no-flex` class names are kept so page-specific CSS (e.g.
// support.css) that targets them can keep overriding. All baseline
// styling is Tailwind.
const CARD_BASE = "ui-card bg-surface shadow-sm rounded-default w-full";
const CARD_WITH_PADDING = CARD_BASE + " p-5 flex-1";
const CARD_NO_FLEX = CARD_BASE + " no-flex p-5";
const CARD_NO_PADDING_NO_FLEX = CARD_BASE + " no-padding no-flex";
const BORDER_CARD = "border border-line-strong rounded-default p-5 w-full";
// Cut-corner card uses two pseudo-elements with clip-paths to create the
// notched corners. Tailwind supports arbitrary clip-path values.
const CUT_CORNER_CARD =
"relative isolate p-5 w-full " +
"before:content-[''] before:absolute before:inset-0 before:bg-surface-strong before:-z-20 " +
"before:[clip-path:polygon(16px_0,100%_0,100%_calc(100%_-_16px),calc(100%_-_16px)_100%,0_100%,0_16px)] " +
"after:content-[''] after:absolute after:inset-[1px] after:bg-surface after:-z-10 " +
"after:[clip-path:polygon(15px_0,100%_0,100%_calc(100%_-_15px),calc(100%_-_15px)_100%,0_100%,0_15px)]";
export function Card(props: CardProps) {
return (
<div class={CARD_WITH_PADDING + " " + (props.class || "")}>
{props.children}
</div>
);
}
export function CardNoPadding(props: CardProps) {
return (
<div class={CARD_NO_PADDING_NO_FLEX + " " + (props.class || "")}>
{props.children}
</div>
);
}
export function CardNoFlexGrow(props: CardProps) {
return (
<div class={CARD_NO_FLEX + " " + (props.class || "")}>
{props.children}
</div>
);
}
export function BorderCard(props: CardProps) {
return (
<div class={BORDER_CARD + " " + (props.class || "")}>
{props.children}
</div>
);
}
export function BorderCutCornerCard(props: CardProps) {
return (
<div class={CUT_CORNER_CARD + " " + (props.class || "")}>
{props.children}
</div>
);
}
const CARD_HEADER = "text-xl tracking-tight text-ink mb-5";
const CARD_HEADER_HR = "text-line mt-1 mb-3";
export function CardHeader(props: CardProps) {
return (
<div class={CARD_HEADER + " " + (props.class || "")}>
{props.children}
<hr class={CARD_HEADER_HR}/>
</div>
);
}
export function CardHeaderTextCenter(props: CardProps) {
return (
<div class={CARD_HEADER + " text-center " + (props.class || "")}>
{props.children}
<hr class={CARD_HEADER_HR}/>
</div>
);
}
export function CardSubheader(props: CardProps) {
return (
<div class={"text-lg tracking-tight text-ink mb-2 " + (props.class || "")}>
{props.children}
</div>
);
}
export function CardSpacer() {
return <div class="mb-6"></div>;
}

View File

@@ -0,0 +1,434 @@
import { createSignal, createMemo, untrack, Show, For, JSXElement } from "solid-js";
import { Icon } from "./Icons.tsx";
export const GRID_HEADER_CLS = "border-b border-r border-line-strong bg-surface-muted px-1.5 py-1.5 text-left text-xs font-bold uppercase text-ink whitespace-nowrap last:border-r-0";
interface SortableHeaderProps {
label: string;
sortKey: string;
width?: string;
minWidth?: string;
current: string | null;
desc: boolean;
onSort?: (key: string) => void;
}
function columnSizeClass(width?: string, minWidth?: string): string {
return width || minWidth || "";
}
export function SortableHeader(props: SortableHeaderProps) {
const isActive = () => {
const cur = typeof props.current === "function" ? (props.current as () => string | null)() : props.current;
return cur === props.sortKey;
};
const descending = () => {
const d = typeof props.desc === "function" ? (props.desc as () => boolean)() : props.desc;
return !!d;
};
const cls = () => GRID_HEADER_CLS + " cursor-pointer select-none hover:bg-surface-strong"
+ (columnSizeClass(props.width, props.minWidth) ? " " + columnSizeClass(props.width, props.minWidth) : "");
return (
<th class={cls()} onclick={() => props.onSort?.(props.sortKey)}>
<div class="flex items-center gap-0.5 min-w-0">
<span class="truncate min-w-0 flex-1">{props.label}</span>
<Show when={isActive()}>
<span class="shrink-0"><Icon icon={descending() ? "caret-down" : "caret-up"} size={10}/></span>
</Show>
</div>
</th>
);
}
export function compareRowsGeneric(a: any, b: any, key: string, sortType?: string): number {
const av = a[key];
const bv = b[key];
const aEmpty = av === "" || av == null;
const bEmpty = bv === "" || bv == null;
if (aEmpty && bEmpty) return 0;
if (aEmpty) return 1;
if (bEmpty) return -1;
if (sortType === "numeric") {
const as = String(av);
const bs = String(bv);
const am = /^(\d+)/.exec(as);
const bm = /^(\d+)/.exec(bs);
const an = am ? parseInt(am[1], 10) : NaN;
const bn = bm ? parseInt(bm[1], 10) : NaN;
if (!isNaN(an) && !isNaN(bn)) {
if (an !== bn) return an - bn;
return as.localeCompare(bs);
}
if (!isNaN(an)) return -1;
if (!isNaN(bn)) return 1;
return as.localeCompare(bs);
}
if (sortType === "money") {
return parseFloat(av) - parseFloat(bv);
}
return String(av).localeCompare(String(bv));
}
export interface CellGridColumn {
key: string;
label: string;
sortKey?: string;
sortType?: string;
sortValue?: (row: any) => unknown;
width?: string;
minWidth?: string;
headerClass?: string;
editable?: boolean;
readOnly?: boolean;
render?: (row: any) => JSXElement;
cellClass?: string | ((row: any) => string);
onclick?: (row: any) => void;
inputMode?: "decimal" | "email" | "none" | "numeric" | "search" | "tel" | "text" | "url" | undefined;
placeholder?: string;
parse?: (value: string) => unknown;
}
export interface CellGridApi {
dirty: () => boolean;
selected: () => { id: unknown; field: string } | null;
focusCell: (id: unknown, field: string) => void;
displayedRows: () => any[];
snapshotRowPositions: () => Map<unknown, DOMRect>;
animateRows: (before: Map<unknown, DOMRect>) => void;
}
interface CellGridProps {
columns: CellGridColumn[];
rows: any[];
initialRows: any[];
idField?: string;
onCellChange: (rowId: unknown, field: string, value: unknown) => void;
sortKey: string | null;
setSortKey: (key: string) => void;
sortDesc: boolean;
setSortDesc: (desc: boolean) => void;
conflictFields?: string[];
dense?: boolean;
ref?: (api: CellGridApi) => void;
}
export function CellGrid(props: CellGridProps) {
const getSortKey = () => typeof props.sortKey === "function" ? (props.sortKey as () => string | null)() : props.sortKey;
const getSortDesc = () => typeof props.sortDesc === "function" ? (props.sortDesc as () => boolean)() : props.sortDesc;
const idField = () => props.idField || "id";
const editableFields = createMemo(() => props.columns.filter((c) => c.editable).map((c) => c.key));
const columnsByKey = createMemo(() => {
const m = new Map<string, CellGridColumn>();
for (const col of props.columns) m.set(col.key, col);
return m;
});
const [selected, setSelected] = createSignal<{ id: unknown; field: string } | null>(null);
const [sortStamp, setSortStamp] = createSignal(0);
const [blurStamp, setBlurStamp] = createSignal(0);
const rowRefs = new Map<unknown, HTMLElement>();
const inputRefs = new Map<string, HTMLInputElement>();
const setRowRef = (id: unknown) => (el: HTMLElement) => {
if (el) rowRefs.set(id, el);
};
const setInputRef = (id: unknown, field: string) => (el: HTMLInputElement) => {
const key = id + "::" + field;
if (el) inputRefs.set(key, el);
else inputRefs.delete(key);
};
const sortColMap = createMemo(() => {
const m = new Map<string, CellGridColumn>();
for (const col of props.columns) {
if (col.sortKey) m.set(col.sortKey, col);
}
return m;
});
const sortedOrder = createMemo(() => {
sortStamp();
props.initialRows;
props.rows.length;
const sk = getSortKey();
const desc = getSortDesc();
const col = sortColMap().get(sk || "");
const st = col?.sortType || "string";
const getVal = typeof col?.sortValue === "function" ? col.sortValue : (r: any) => r[sk || ""];
return untrack(() => {
const idf = idField();
const snap = props.rows.map((r) => ({ id: r[idf], sortVal: getVal(r) }));
snap.sort((a, b) => compareRowsGeneric(a, b, "sortVal", st));
if (desc) snap.reverse();
return snap.map((s) => s.id);
});
});
const displayedRows = createMemo(() => {
const order = sortedOrder();
const idf = idField();
const byId = new Map();
for (let i = 0; i < props.rows.length; i++) {
byId.set(props.rows[i][idf], props.rows[i]);
}
const out: any[] = [];
for (const id of order) {
const r = byId.get(id);
if (r) out.push(r);
}
return out;
});
const dirty = createMemo(() => {
const current = props.rows;
const initial = props.initialRows;
if (!initial || current.length !== initial.length) return true;
const fields = editableFields();
for (let i = 0; i < current.length; i++) {
for (const f of fields) {
if (current[i][f] !== initial[i][f]) return true;
}
}
return false;
});
const conflictSets = createMemo(() => {
blurStamp();
props.initialRows;
return untrack(() => {
const result: Record<string, Set<unknown>> = {};
if (!props.conflictFields) return result;
for (const field of props.conflictFields) {
const counts = new Map<unknown, number>();
for (let i = 0; i < props.rows.length; i++) {
const v = props.rows[i][field];
if (!v) continue;
counts.set(v, (counts.get(v) || 0) + 1);
}
const conflicts = new Set<unknown>();
counts.forEach((c, v) => { if (c > 1) conflicts.add(v); });
result[field] = conflicts;
}
return result;
});
});
const isConflict = (field: string, value: unknown): boolean => {
if (!value) return false;
const sets = conflictSets();
return !!sets[field] && sets[field].has(value);
};
const animateReorder = (prevPositions: Map<unknown, DOMRect>) => {
rowRefs.forEach((el, id) => {
const prev = prevPositions.get(id);
if (!prev || !el.isConnected) return;
const next = el.getBoundingClientRect();
const dy = prev.top - next.top;
if (dy === 0) return;
el.animate(
[{ transform: `translateY(${dy}px)` }, { transform: "translateY(0)" }],
{ duration: 300, easing: "cubic-bezier(0.22, 0.61, 0.36, 1)" }
);
});
};
const handleSort = (key: string) => {
const positions = new Map<unknown, DOMRect>();
rowRefs.forEach((el, id) => {
if (el.isConnected) positions.set(id, el.getBoundingClientRect());
});
if (getSortKey() === key) {
props.setSortDesc(!getSortDesc());
} else {
props.setSortKey(key);
props.setSortDesc(false);
}
setSortStamp((s) => s + 1);
animateReorder(positions);
};
const focusCell = (id: unknown, field: string) => {
const el = inputRefs.get(id + "::" + field);
if (el) {
el.focus();
try { el.select(); } catch {}
}
};
const moveSelection = (dCol: number, dRow: number) => {
const cur = selected();
const rows = displayedRows();
const fields = editableFields();
if (rows.length === 0 || fields.length === 0) return;
const idf = idField();
let rowIdx = cur ? rows.findIndex((r) => r[idf] === cur.id) : 0;
let colIdx = cur ? fields.indexOf(cur.field) : 0;
if (rowIdx < 0) rowIdx = 0;
if (colIdx < 0) colIdx = 0;
const newRowIdx = Math.max(0, Math.min(rows.length - 1, rowIdx + dRow));
const newColIdx = Math.max(0, Math.min(fields.length - 1, colIdx + dCol));
const newId = rows[newRowIdx][idf];
const newField = fields[newColIdx];
setSelected({ id: newId, field: newField });
focusCell(newId, newField);
};
const shouldNavigateHorizontal = (input: HTMLInputElement | null): boolean => {
if (!input) return false;
if (!input.value) return true;
return typeof input.selectionStart === "number" && input.selectionStart !== input.selectionEnd;
};
const handleKeyDown = (e: KeyboardEvent) => {
const key = e.key;
const input = e.target as HTMLInputElement;
if (key === "Enter") {
e.preventDefault();
moveSelection(0, e.shiftKey ? -1 : 1);
return;
}
if (key === "Tab") {
e.preventDefault();
moveSelection(e.shiftKey ? -1 : 1, 0);
return;
}
if (key === "Escape") {
if (input && typeof input.setSelectionRange === "function") {
const pos = input.selectionEnd || 0;
try { input.setSelectionRange(pos, pos); } catch {}
}
e.preventDefault();
return;
}
if (key === "ArrowUp" || key === "ArrowDown") {
e.preventDefault();
moveSelection(0, key === "ArrowDown" ? 1 : -1);
return;
}
if (key === "ArrowLeft" || key === "ArrowRight") {
if (shouldNavigateHorizontal(input)) {
e.preventDefault();
moveSelection(key === "ArrowRight" ? 1 : -1, 0);
}
return;
}
};
const handleCellFocus = (id: unknown, field: string) => {
setSelected({ id, field });
};
const handleCellMouseDown = (id: unknown, field: string) => {
setSelected({ id, field });
};
const snapshotRowPositions = (): Map<unknown, DOMRect> => {
const m = new Map<unknown, DOMRect>();
rowRefs.forEach((el, id) => {
if (el.isConnected) m.set(id, el.getBoundingClientRect());
});
return m;
};
props.ref?.({
dirty,
selected,
focusCell,
displayedRows,
snapshotRowPositions,
animateRows: animateReorder,
});
const dense = () => !!props.dense;
const rowHCls = () => dense() ? "h-6" : "h-8";
const readonlyTdCls = () => "border-b border-r border-line-strong bg-black/5 px-2 text-ink align-middle " + rowHCls();
const editableTdCls = () => "border-b border-r border-line-strong p-0 relative align-middle";
const inputCls = "absolute inset-0 w-full border-none outline-none bg-transparent px-2 placeholder:text-ink-faint focus:bg-red-50 dark:bg-red-950/40 focus:shadow-[inset_0_0_0_2px_var(--color-red-500)]";
const colSizeCls = (col: CellGridColumn) => columnSizeClass(col.width, col.minWidth);
const renderHeader = (col: CellGridColumn) => {
const widthCls = colSizeCls(col) ? " " + colSizeCls(col) : "";
if (col.sortKey) {
return (
<SortableHeader label={col.label} sortKey={col.sortKey} width={col.width} minWidth={col.minWidth} current={getSortKey()} desc={getSortDesc()} onSort={handleSort}/>
);
}
const cls = col.headerClass
? GRID_HEADER_CLS + " " + col.headerClass + widthCls
: GRID_HEADER_CLS + widthCls;
return <th class={cls}>{col.label}</th>;
};
const renderCell = (row: any, col: CellGridColumn) => {
const idf = idField();
const rowId = row[idf];
const sizeCls = colSizeCls(col) ? " " + colSizeCls(col) : "";
if (col.render && !col.editable) {
const cellCls = () => {
if (typeof col.cellClass === "function") return col.cellClass(row) + sizeCls;
return (col.cellClass || readonlyTdCls()) + sizeCls;
};
return <td class={cellCls()} onclick={col.onclick ? (_: MouseEvent) => col.onclick!(row) : undefined}>{col.render!(row)}</td>;
}
if (col.readOnly) {
const roCls = () => {
if (typeof col.cellClass === "function") return col.cellClass(row) + sizeCls;
return (col.cellClass || readonlyTdCls()) + sizeCls;
};
return <td class={roCls()} onclick={col.onclick ? (_: MouseEvent) => col.onclick!(row) : undefined}>{col.render ? col.render(row) : row[col.key]}</td>;
}
const hasConflict = () => isConflict(col.key, row[col.key]);
const tdClass = () => {
let base = editableTdCls() + sizeCls;
if (props.conflictFields && props.conflictFields.includes(col.key)) {
base += " relative";
if (hasConflict()) base += " bg-amber-100 dark:bg-amber-950/50";
}
return base;
};
return (
<td class={tdClass()}>
<input ref={setInputRef(rowId, col.key)} class={inputCls} type="text" inputmode={col.inputMode || "text"} placeholder={col.placeholder || ""} value={row[col.key]} oninput={(e: InputEvent) => {
const target = e.currentTarget as HTMLInputElement;
const val = col.parse ? col.parse(target.value) : target.value;
props.onCellChange(rowId, col.key, val);
}} onFocus={() => handleCellFocus(rowId, col.key)} onBlur={() => setBlurStamp((s) => s + 1)} onMouseDown={() => handleCellMouseDown(rowId, col.key)}/>
<Show when={props.conflictFields && props.conflictFields.includes(col.key) && hasConflict()}>
<span class="pointer-events-none absolute right-0.5 top-1/2 -translate-y-1/2 text-amber-600 dark:text-amber-400" title="Duplicate value">
<Icon icon="triangle-exclamation" size={12}/>
</span>
</Show>
</td>
);
};
const tableCls = () => "min-w-full w-max border-collapse " + (dense() ? "text-xs" : "text-sm");
return (
<div class="relative max-w-full overflow-x-auto border border-line-strong rounded-default bg-surface tabular-nums">
<table class={tableCls()}>
<thead>
<tr>
<For each={props.columns}>{(col) => renderHeader(col)}</For>
</tr>
</thead>
<tbody onKeyDown={handleKeyDown}>
<For each={displayedRows()}>{(row) => (
<tr ref={setRowRef(row[idField()])} class="odd:bg-surface even:bg-surface-raised">
<For each={props.columns}>{(col) => renderCell(row, col)}</For>
</tr>
)}</For>
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,91 @@
import { createEffect, onCleanup, onMount } from "solid-js";
import { Chart, registerables } from "chart.js";
// chart.js v4 is tree-shakeable and ships nothing registered by default; register
// all controllers/elements/scales once so any chart type works (the old UMD shim
// did this implicitly).
Chart.register(...registerables);
type ChartType = "line" | "bar" | "radar" | "doughnut" | "polarArea" | "bubble" | "pie" | "scatter";
interface ReactiveChartProps {
type: ChartType;
data: unknown;
options?: object;
class?: string;
}
interface ChartInstance {
destroy(): void;
update(): void;
data: unknown;
options: object;
}
type ChartCtor = new (ctx: CanvasRenderingContext2D, cfg: object) => ChartInstance;
export default function ReactiveChart(props: ReactiveChartProps) {
let canvasRef: HTMLCanvasElement | undefined;
let chartInstance: ChartInstance | null = null;
onMount(() => {
// Defer initialization until the canvas is connected to the document.
// @solidjs/router creates route components before inserting them into
// the DOM, and Chart.js needs getComputedStyle which requires a
// connected element with ownerDocument.defaultView.
const init = () => {
if (!canvasRef) return;
if (!canvasRef.isConnected) {
requestAnimationFrame(init);
return;
}
const ctx = canvasRef.getContext("2d");
if (!ctx) return;
chartInstance = new (Chart as unknown as ChartCtor)(ctx, {
type: props.type,
data: props.data,
options: {
responsive: true,
maintainAspectRatio: false,
...(props.options ?? {}),
},
});
};
init();
});
onCleanup(() => {
if (chartInstance) {
chartInstance.destroy();
chartInstance = null;
}
});
createEffect(() => {
const data = props.data;
if (chartInstance) {
chartInstance.data = data;
chartInstance.update();
}
});
createEffect(() => {
const options = props.options;
if (chartInstance) {
chartInstance.options = {
responsive: true,
maintainAspectRatio: false,
...(options ?? {}),
};
chartInstance.update();
}
});
return (
<div class={"h-full " + (props.class || "")}>
<canvas ref={(el: HTMLCanvasElement) => canvasRef = el}></canvas>
</div>
);
}

View 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-line-strong transition-colors";
const CRM_TAB_INACTIVE = "border-b text-ink-muted hover:text-ink";
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-line-strong"></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-line-strong overflow-x-auto";
const CRM_SUBTAB_GROUP = "inline-flex items-stretch rounded-md border border-line-strong 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-line-strong";
const CRM_SUBTAB_ACTIVE = "bg-neutral-500 text-white";
const CRM_SUBTAB_INACTIVE = "bg-surface text-ink-soft hover:bg-surface-raised hover:text-ink";
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>;
}

View File

@@ -0,0 +1,542 @@
import { createSignal, createEffect, Show, onMount, onCleanup, For } from "solid-js";
import { Portal } from "solid-js/web";
import { Icon } from "./Icons.tsx";
import { FormInput } from "./Forms.tsx";
import { readAccessor, accessor, type MaybeAccessor } from "../utils/accessors.ts";
import {
CAL_PICKER_ROOT,
CAL_HEADER_PICKER,
CAL_NAV_BTN,
CAL_MY_PICKER,
CAL_WEEKDAYS_PICKER,
CAL_WEEKDAY_PICKER,
CAL_DAYS_PICKER,
CAL_DAY_PICKER_BASE,
CAL_SELECT,
} from "./Calendar.tsx";
const DAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
function getDaysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
function getFirstDayOfMonth(year: number, month: number): number {
return new Date(year, month, 1).getDay();
}
function dayClass(date: Date | null, selected: boolean, today: boolean): string {
let c = CAL_DAY_PICKER_BASE;
if (!date) c += " cursor-default";
else c += " hover:bg-surface-raised";
if (today) c += " font-bold text-primary";
if (selected) c += " !bg-primary !text-white";
return c;
}
/** Parse typed or pasted text into YYYY-MM-DD, or "" if invalid. */
function parseDateInput(text: string): string {
const trimmed = text.trim();
if (!trimmed) return "";
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
const [y, m, d] = trimmed.split("-").map((n) => parseInt(n, 10));
const iso = new Date(y, m - 1, d);
if (!isNaN(iso.getTime()) && iso.getFullYear() === y && iso.getMonth() === m - 1 && iso.getDate() === d) {
return trimmed;
}
}
const parsed = new Date(trimmed);
if (!isNaN(parsed.getTime())) {
const y = parsed.getFullYear();
const m = String(parsed.getMonth() + 1).padStart(2, "0");
const d = String(parsed.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
return "";
}
function formatDisplayDate(iso: string): string {
if (!iso) return "";
const parts = iso.split("-");
if (parts.length < 3) return iso;
const d = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10));
if (isNaN(d.getTime())) return "";
return d.toLocaleDateString();
}
interface CalendarDropdownProps {
selected?: MaybeAccessor<string>;
onSelect?: (key: string) => void;
}
function CalendarDropdown(props: CalendarDropdownProps) {
const today = new Date();
const selected = () => readAccessor(props.selected, "");
const [viewDate, setViewDate] = createSignal(selected() ? new Date(selected()) : today);
const [key, setKey] = createSignal(0);
createEffect(() => {
const v = selected();
if (v) {
const d = new Date(v);
if (!isNaN(d.getTime())) {
setViewDate(d);
}
}
});
const currentMonth = () => viewDate().getMonth();
const currentYear = () => viewDate().getFullYear();
const getDays = () => {
const year = currentYear();
const month = currentMonth();
const daysInMonth = getDaysInMonth(year, month);
const firstDay = getFirstDayOfMonth(year, month);
const daysArray: (Date | null)[] = [];
for (let i = 0; i < firstDay; i++) {
daysArray.push(null);
}
for (let i = 1; i <= daysInMonth; i++) {
daysArray.push(new Date(year, month, i));
}
return daysArray;
};
const isSelected = (date: Date | null): boolean => {
if (!date) return false;
const v = selected();
if (!v) return false;
const sel = new Date(v);
if (isNaN(sel.getTime())) return false;
return date.getFullYear() === sel.getFullYear() &&
date.getMonth() === sel.getMonth() &&
date.getDate() === sel.getDate();
};
const isToday = (date: Date | null): boolean => {
if (!date) return false;
return date.getFullYear() === today.getFullYear() &&
date.getMonth() === today.getMonth() &&
date.getDate() === today.getDate();
};
const selectDate = (date: Date | null) => {
if (!date) return;
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
props.onSelect?.(`${y}-${m}-${d}`);
};
const goPrev = (e: MouseEvent) => {
e.stopPropagation();
setViewDate(new Date(currentYear(), currentMonth() - 1, 1));
setKey(k => k + 1);
};
const goNext = (e: MouseEvent) => {
e.stopPropagation();
setViewDate(new Date(currentYear(), currentMonth() + 1, 1));
setKey(k => k + 1);
};
return (
<div class={CAL_PICKER_ROOT} attr:key={key()}>
<div class={CAL_HEADER_PICKER}>
<button type="button" class={CAL_NAV_BTN} onclick={goPrev}>
<Icon icon="chevron-left" size={16}/>
</button>
<span class={CAL_MY_PICKER}>{MONTHS[currentMonth()]}&nbsp;{currentYear()}</span>
<button type="button" class={CAL_NAV_BTN} onclick={goNext}>
<Icon icon="chevron-right" size={16}/>
</button>
</div>
<div class={CAL_WEEKDAYS_PICKER}>
<For each={DAYS}>{(day) => <div class={CAL_WEEKDAY_PICKER}>{day}</div>}</For>
</div>
<div class={CAL_DAYS_PICKER}>
<For each={getDays()}>{(date) => (
<button type="button" class={dayClass(date, isSelected(date), isToday(date))} onclick={(e: MouseEvent) => { e.stopPropagation(); selectDate(date); }} disabled={!date}>
{date ? date.getDate() : ""}
</button>
)}</For>
</div>
</div>
);
}
const DATE_PICKER_WRAP = "relative w-full min-w-0";
const DATE_PICKER_FIELD = "relative w-full min-w-0 cursor-pointer [&_.ui-form]:m-0 [&_input]:cursor-text";
const DATE_PICKER_DROPDOWN = "bg-surface border border-line rounded-default shadow-[0_4px_12px_rgba(0,0,0,0.15)] p-1 min-w-[16rem]";
const DATE_PICKER_ICON_BTN = "absolute inset-y-0 right-0 z-[1] flex items-center justify-center bg-transparent border-0 px-2 cursor-pointer text-ink-muted leading-none hover:text-ink pointer-events-auto";
const DATE_PICKER_CLEAR_BTN = "absolute inset-y-0 right-8 z-[1] flex items-center justify-center bg-transparent border-0 px-1.5 cursor-pointer text-ink-muted leading-none hover:text-ink pointer-events-auto";
interface DatePickerProps {
value?: MaybeAccessor<string>;
onchange?: (value: string) => void;
placeholder?: string;
small?: boolean;
clearable?: boolean;
}
export function DatePicker(props: DatePickerProps) {
const [open, setOpen] = createSignal(false);
const [localValue, setLocalValue] = createSignal("");
const [editing, setEditing] = createSignal(false);
const [draft, setDraft] = createSignal("");
const [dropdownPos, setDropdownPos] = createSignal({ top: 0, left: 0, width: 0 });
let containerRef: HTMLDivElement | undefined;
let fieldRef: HTMLDivElement | undefined;
let dropdownRef: HTMLDivElement | undefined;
const externalValue = () => readAccessor(props.value, "");
const updateDropdownPos = () => {
if (!fieldRef) return;
const rect = fieldRef.getBoundingClientRect();
setDropdownPos({ top: rect.bottom + 4, left: rect.left, width: rect.width });
};
const dropdownStyle = () => {
const pos = dropdownPos();
const maxW = Math.min(400, window.innerWidth - pos.left - 8);
return `position:fixed;top:${pos.top}px;left:${pos.left}px;min-width:${Math.max(pos.width, 16 * 16)}px;width:max-content;max-width:${maxW}px;z-index:200;`;
};
createEffect(() => {
if (open()) {
updateDropdownPos();
let rafId: number;
const trackPosition = () => {
updateDropdownPos();
rafId = requestAnimationFrame(trackPosition);
};
rafId = requestAnimationFrame(trackPosition);
onCleanup(() => cancelAnimationFrame(rafId));
}
});
createEffect(() => {
if (!editing()) {
setLocalValue(externalValue());
}
});
const hasValue = () => !!localValue();
const displayValue = () => formatDisplayDate(localValue());
const inputValue = () => editing() ? draft() : displayValue();
const commitValue = (raw: string) => {
const parsed = parseDateInput(raw);
setLocalValue(parsed);
setDraft(parsed ? formatDisplayDate(parsed) : "");
if (typeof props.onchange === "function") props.onchange(parsed);
};
const handleSelect = (dateStr: string) => {
setEditing(false);
setLocalValue(dateStr);
setDraft(formatDisplayDate(dateStr));
if (typeof props.onchange === "function") props.onchange(dateStr);
setOpen(false);
};
const handleClear = (e: MouseEvent) => {
e.stopPropagation();
setEditing(false);
setLocalValue("");
setDraft("");
if (typeof props.onchange === "function") props.onchange("");
setOpen(false);
};
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node;
const inContainer = containerRef?.contains(target);
const inDropdown = dropdownRef?.contains(target);
if (!inContainer && !inDropdown) {
if (editing()) {
commitValue(draft());
setEditing(false);
}
setOpen(false);
}
};
onMount(() => {
document.addEventListener("click", handleClickOutside);
});
onCleanup(() => {
document.removeEventListener("click", handleClickOutside);
});
const openCalendar = (_e: MouseEvent) => {
updateDropdownPos();
setOpen(true);
};
const toggleCalendar = (e: MouseEvent) => {
e.stopPropagation();
if (!open()) updateDropdownPos();
setOpen((v) => !v);
};
const handleInputFocus = (_e: FocusEvent) => {
setEditing(true);
setDraft(displayValue());
};
const handleInput = (e: InputEvent & { currentTarget: HTMLInputElement }) => {
setDraft(e.currentTarget.value);
};
const handleInputBlur = (_e: FocusEvent) => {
commitValue(draft());
setEditing(false);
};
const inputCls = () => "w-full" + (props.clearable && hasValue() ? " pr-14" : " pr-9");
return (
<div class={DATE_PICKER_WRAP} ref={(el: HTMLDivElement) => containerRef = el}>
<div class={DATE_PICKER_FIELD} ref={(el: HTMLDivElement) => fieldRef = el} onclick={openCalendar}>
<FormInput
type="text"
small={props.small}
value={inputValue()}
placeholder={props.placeholder || "Select date"}
onfocus={handleInputFocus}
oninput={handleInput}
onblur={handleInputBlur}
class={inputCls()}
/>
<Show when={props.clearable && hasValue()}>
<button type="button" class={DATE_PICKER_CLEAR_BTN} onclick={handleClear} aria-label="Clear date">
<Icon icon="xmark" size={14} class="block leading-none"/>
</button>
</Show>
<button type="button" class={DATE_PICKER_ICON_BTN} onclick={toggleCalendar} aria-label="Open calendar">
<Icon icon="calendar" size={16} class="block leading-none"/>
</button>
</div>
<Show when={open()}>
<Portal>
<div
ref={(el: HTMLDivElement) => dropdownRef = el}
data-floating-content="true"
class={DATE_PICKER_DROPDOWN}
style={dropdownStyle()}
onclick={(e: MouseEvent) => e.stopPropagation()}
>
<CalendarDropdown selected={localValue} onSelect={handleSelect}/>
</div>
</Portal>
</Show>
</div>
);
}
const MONTHS_SHORT = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
function CalendarDropdownDOB(props: CalendarDropdownProps) {
const today = new Date();
const currentYear = today.getFullYear();
const years = Array.from({ length: 120 }, (_, i) => currentYear - i);
const [viewDate, setViewDate] = createSignal(props.selected ? new Date(props.selected as string) : today);
const [key, setKey] = createSignal(0);
createEffect(() => {
if (props.selected) {
const d = new Date(props.selected as string);
if (!isNaN(d.getTime())) {
setViewDate(d);
}
}
});
const currentMonth = () => viewDate().getMonth();
const currentYearView = () => viewDate().getFullYear();
const getDays = () => {
const year = currentYearView();
const month = currentMonth();
const daysInMonth = getDaysInMonth(year, month);
const firstDay = getFirstDayOfMonth(year, month);
const daysArray: (Date | null)[] = [];
for (let i = 0; i < firstDay; i++) {
daysArray.push(null);
}
for (let i = 1; i <= daysInMonth; i++) {
daysArray.push(new Date(year, month, i));
}
return daysArray;
};
const handleMonthChange = (e: Event) => {
const month = parseInt((e.target as HTMLSelectElement).value);
if (!isNaN(month)) {
setViewDate(new Date(currentYearView(), month, 1));
}
};
const handleYearChange = (e: Event) => {
const year = parseInt((e.target as HTMLSelectElement).value);
if (!isNaN(year)) {
setViewDate(new Date(year, currentMonth(), 1));
}
};
const isSelected = (date: Date | null): boolean => {
if (!date || !props.selected) return false;
const sel = new Date(props.selected as string);
if (isNaN(sel.getTime())) return false;
return date.getFullYear() === sel.getFullYear() &&
date.getMonth() === sel.getMonth() &&
date.getDate() === sel.getDate();
};
const isToday = (date: Date | null): boolean => {
if (!date) return false;
return date.getFullYear() === today.getFullYear() &&
date.getMonth() === today.getMonth() &&
date.getDate() === today.getDate();
};
const selectDate = (date: Date | null) => {
if (!date) return;
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
props.onSelect?.(`${y}-${m}-${d}`);
};
const goPrev = (e: MouseEvent) => {
e.stopPropagation();
setViewDate(new Date(currentYearView(), currentMonth() - 1, 1));
setKey(k => k + 1);
};
const goNext = (e: MouseEvent) => {
e.stopPropagation();
setViewDate(new Date(currentYearView(), currentMonth() + 1, 1));
setKey(k => k + 1);
};
return (
<div class={CAL_PICKER_ROOT} attr:key={key()}>
<div class={CAL_HEADER_PICKER}>
<button type="button" class={CAL_NAV_BTN} onclick={goPrev}>
<Icon icon="chevron-left" size={16}/>
</button>
<select class={CAL_SELECT} value={currentMonth()} onchange={handleMonthChange}>
<For each={MONTHS_SHORT}>{(m, i) => <option value={i()}>{m}</option>}</For>
</select>
<select class={CAL_SELECT} value={currentYearView()} onchange={handleYearChange}>
<For each={years}>{(y) => <option value={y}>{y}</option>}</For>
</select>
<button type="button" class={CAL_NAV_BTN} onclick={goNext}>
<Icon icon="chevron-right" size={16}/>
</button>
</div>
<div class={CAL_WEEKDAYS_PICKER}>
<For each={DAYS}>{(day) => <div class={CAL_WEEKDAY_PICKER}>{day}</div>}</For>
</div>
<div class={CAL_DAYS_PICKER}>
<For each={getDays()}>{(date) => (
<button type="button" class={dayClass(date, isSelected(date), isToday(date))} onclick={(e: MouseEvent) => { e.stopPropagation(); selectDate(date); }} disabled={!date}>
{date ? date.getDate() : ""}
</button>
)}</For>
</div>
</div>
);
}
export function DateOfBirthPicker(props: DatePickerProps) {
const [open, setOpen] = createSignal(false);
const [editing, setEditing] = createSignal(false);
const [draft, setDraft] = createSignal("");
let containerRef: HTMLDivElement | undefined;
const isoValue = () => readAccessor(props.value, "");
const displayValue = () => formatDisplayDate(isoValue());
const inputValue = () => editing() ? draft() : displayValue();
const commitValue = (raw: string) => {
const parsed = parseDateInput(raw);
setDraft(parsed ? formatDisplayDate(parsed) : "");
props.onchange?.(parsed);
};
const handleSelect = (dateStr: string) => {
setEditing(false);
setDraft(formatDisplayDate(dateStr));
props.onchange?.(dateStr);
setOpen(false);
};
const handleClickOutside = (e: MouseEvent) => {
if (containerRef && !containerRef.contains(e.target as Node)) {
if (editing()) {
commitValue(draft());
setEditing(false);
}
setOpen(false);
}
};
onMount(() => {
document.addEventListener("click", handleClickOutside);
});
onCleanup(() => {
document.removeEventListener("click", handleClickOutside);
});
const openCalendar = (_e: MouseEvent) => {
setOpen(true);
};
const toggleCalendar = (e: MouseEvent) => {
e.stopPropagation();
setOpen((v) => !v);
};
return (
<div class={DATE_PICKER_WRAP} ref={(el: HTMLDivElement) => containerRef = el}>
<div class={DATE_PICKER_FIELD} onclick={openCalendar}>
<FormInput
type="text"
value={inputValue()}
placeholder={props.placeholder || "Select date of birth"}
onfocus={(_e: FocusEvent) => { setEditing(true); setDraft(displayValue()); }}
oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setDraft(e.currentTarget.value)}
onblur={(_e: FocusEvent) => { commitValue(draft()); setEditing(false); }}
class="w-full pr-9"
/>
<button type="button" class={DATE_PICKER_ICON_BTN} onclick={toggleCalendar} aria-label="Open calendar">
<Icon icon="calendar" size={16} class="block leading-none"/>
</button>
</div>
<Show when={open()}>
<div class={DATE_PICKER_DROPDOWN} onclick={(e: MouseEvent) => e.stopPropagation()}>
<CalendarDropdownDOB selected={isoValue} onSelect={handleSelect}/>
</div>
</Show>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import { ENV_TYPE, isNonProdEnv } from "../env.ts";
// The deployment environment is baked into the bundle at build time (env.ts
// reads esbuild's __ENV_TYPE__ define), so it is a plain module constant here —
// no runtime globalThis read. Re-exported for callers that historically imported
// isNonProdEnv from this module.
export { isNonProdEnv };
// Browser-only: tag <html> so any env-specific styling can hook in. Guarded
// because the SSR DOM shim has no document.documentElement.
(function markEnvOnRoot() {
if (isNonProdEnv() && typeof document !== "undefined" && document.documentElement) {
document.documentElement.dataset.env = ENV_TYPE;
}
})();
const BADGE_BASE = "pointer-events-none select-none absolute top-0 -right-2 z-10 " +
"rounded px-1 py-px text-[0.5rem] font-bold uppercase leading-none tracking-wider shadow-sm";
/**
* Small environment badge pinned to the corner of the app logo. Renders
* nothing in production. Drop it inside a `position: relative` wrapper around
* the logo image so it anchors to the logo's top-right corner.
*/
export function EnvBadge() {
if (!isNonProdEnv()) return null;
const label = ENV_TYPE === "development" ? "DEV"
: ENV_TYPE === "staging" ? "STAGING"
: ENV_TYPE.toUpperCase();
const tone = ENV_TYPE === "development" ? "bg-orange-500 text-white"
: ENV_TYPE === "staging" ? "bg-yellow-400 text-ink"
: "bg-neutral-700 text-white";
return <span class={`${BADGE_BASE} ${tone}`}>{label}</span>;
}

View File

@@ -0,0 +1,397 @@
import { createContext, useContext, createSignal, createEffect, createRenderEffect, onCleanup, Show, getOwner, runWithOwner, JSXElement } from "solid-js";
import { Portal } from "solid-js/web";
export type Placement = "top" | "top-start" | "top-end" | "bottom" | "bottom-start" | "bottom-end" | "left" | "left-start" | "left-end" | "right" | "right-start" | "right-end";
export interface PositionOptions {
placement?: Placement;
offset?: number;
flip?: boolean;
shift?: boolean;
shiftPadding?: number;
}
interface Position {
top: number;
left: number;
placement: string;
}
class FloatingManager {
activeCloseCallback: (() => void) | null = null;
register(closeCallback: () => void) {
if (this.activeCloseCallback && this.activeCloseCallback !== closeCallback) {
this.activeCloseCallback();
}
this.activeCloseCallback = closeCallback;
}
unregister(closeCallback: () => void) {
if (this.activeCloseCallback === closeCallback) {
this.activeCloseCallback = null;
}
}
closeActive() {
if (this.activeCloseCallback) {
this.activeCloseCallback();
this.activeCloseCallback = null;
}
}
}
const floatingManager = new FloatingManager();
// Open floating-content elements in open order, so outside-click handling can tell
// a descendant (opened later, e.g. a menu inside a popover) from an ancestor: a
// floating stays open for clicks inside itself or a later-opened floating, and
// closes for clicks anywhere else (including its parent popover).
const openFloatings: HTMLElement[] = [];
export interface FloatingContextValue {
isOpen: () => boolean;
setIsOpen: (open: boolean) => void;
readonly triggerRef: HTMLElement | undefined;
setTriggerRef: (el: HTMLElement) => void;
readonly floatingRef: HTMLElement | undefined;
setFloatingRef: (el: HTMLElement) => void;
position: () => Position | null;
options: Required<PositionOptions>;
cancelHoverClose: () => void;
scheduleHoverClose: (delay: number) => void;
}
const FloatingContext = createContext<FloatingContextValue | null>(null);
export function useFloatingContext(): FloatingContextValue {
const context = useContext(FloatingContext);
if (!context) {
throw new Error("Floating components must be used within a FloatingRoot");
}
return context;
}
function calculatePosition(triggerRect: DOMRect, floatingRect: DOMRect, options: Required<PositionOptions>): Position {
const { placement, offset, flip, shift, shiftPadding } = options;
const parts = placement.split("-");
const basePlacement = parts[0];
const alignment = parts[1] || "center";
let top = 0;
let left = 0;
let finalPlacement: string = placement;
switch (basePlacement) {
case "top": top = triggerRect.top - floatingRect.height - offset; break;
case "bottom": top = triggerRect.bottom + offset; break;
case "left": left = triggerRect.left - floatingRect.width - offset; break;
case "right": left = triggerRect.right + offset; break;
}
if (basePlacement === "top" || basePlacement === "bottom") {
switch (alignment) {
case "start": left = triggerRect.left; break;
case "end": left = triggerRect.right - floatingRect.width; break;
default: left = triggerRect.left + (triggerRect.width - floatingRect.width) / 2;
}
} else {
switch (alignment) {
case "start": top = triggerRect.top; break;
case "end": top = triggerRect.bottom - floatingRect.height; break;
default: top = triggerRect.top + (triggerRect.height - floatingRect.height) / 2;
}
}
if (flip) {
const vh = window.innerHeight;
const vw = window.innerWidth;
if (basePlacement === "bottom" && top + floatingRect.height > vh - shiftPadding) {
const flippedTop = triggerRect.top - floatingRect.height - offset;
if (flippedTop >= shiftPadding) { top = flippedTop; finalPlacement = placement.replace("bottom", "top"); }
} else if (basePlacement === "top" && top < shiftPadding) {
const flippedTop = triggerRect.bottom + offset;
if (flippedTop + floatingRect.height <= vh - shiftPadding) { top = flippedTop; finalPlacement = placement.replace("top", "bottom"); }
} else if (basePlacement === "right" && left + floatingRect.width > vw - shiftPadding) {
const flippedLeft = triggerRect.left - floatingRect.width - offset;
if (flippedLeft >= shiftPadding) { left = flippedLeft; finalPlacement = placement.replace("right", "left"); }
} else if (basePlacement === "left" && left < shiftPadding) {
const flippedLeft = triggerRect.right + offset;
if (flippedLeft + floatingRect.width <= vw - shiftPadding) { left = flippedLeft; finalPlacement = placement.replace("left", "right"); }
}
}
if (shift) {
const vh = window.innerHeight;
const vw = window.innerWidth;
if (left < shiftPadding) left = shiftPadding;
else if (left + floatingRect.width > vw - shiftPadding) left = vw - floatingRect.width - shiftPadding;
if (top < shiftPadding) top = shiftPadding;
else if (top + floatingRect.height > vh - shiftPadding) top = vh - floatingRect.height - shiftPadding;
}
return { top, left, placement: finalPlacement };
}
interface FloatingRootProps {
// Solid's `h` auto-invokes zero-arg function props on read, so each
// of these is simply the unwrapped value inside the component body.
open?: boolean;
onOpenChange?: (open: boolean) => void;
placement?: Placement;
offset?: number;
flip?: boolean;
shift?: boolean;
shiftPadding?: number;
// Opt out of the global single-open manager. Use for a floating nested
// inside another (e.g. a tooltip inside a popover) so opening it doesn't
// close its ancestor, and so the ancestor opening doesn't close it.
standalone?: boolean;
children?: JSXElement;
}
export function FloatingRoot(props: FloatingRootProps) {
const [internalOpen, setInternalOpen] = createSignal(false);
const isControlled = () => props.open !== undefined;
const isOpen = () => isControlled() ? !!props.open : internalOpen();
let triggerRef: HTMLElement | undefined;
let floatingRef: HTMLElement | undefined;
const [position, setPosition] = createSignal<Position | null>(null);
let hoverCloseTimeout: ReturnType<typeof setTimeout> | null = null;
const options: Required<PositionOptions> = {
placement: props.placement ?? "bottom-start",
offset: props.offset ?? 4,
flip: props.flip ?? true,
shift: props.shift ?? true,
shiftPadding: props.shiftPadding ?? 8,
};
const closeThis = () => {
if (isControlled()) props.onOpenChange?.(false);
else setInternalOpen(false);
};
const setIsOpen = (open: boolean) => {
if (!props.standalone) {
if (open) floatingManager.register(closeThis);
else floatingManager.unregister(closeThis);
}
if (isControlled()) props.onOpenChange?.(open);
else setInternalOpen(open);
if (open) {
requestAnimationFrame(() => updatePosition());
} else {
setPosition(null);
}
};
onCleanup(() => {
floatingManager.unregister(closeThis);
if (hoverCloseTimeout) clearTimeout(hoverCloseTimeout);
});
const cancelHoverClose = () => {
if (hoverCloseTimeout) { clearTimeout(hoverCloseTimeout); hoverCloseTimeout = null; }
};
const scheduleHoverClose = (delay: number) => {
cancelHoverClose();
hoverCloseTimeout = setTimeout(() => setIsOpen(false), delay);
};
const updatePosition = () => {
if (!triggerRef || !floatingRef) return;
const triggerRect = triggerRef.getBoundingClientRect();
const floatingRect = floatingRef.getBoundingClientRect();
setPosition(calculatePosition(triggerRect, floatingRect, options));
};
createRenderEffect(() => {
if (!isOpen()) { setPosition(null); return; }
const rafId = requestAnimationFrame(updatePosition);
const handleUpdate = () => updatePosition();
window.addEventListener("scroll", handleUpdate, true);
window.addEventListener("resize", handleUpdate);
onCleanup(() => {
cancelAnimationFrame(rafId);
window.removeEventListener("scroll", handleUpdate, true);
window.removeEventListener("resize", handleUpdate);
});
});
const value: FloatingContextValue = {
isOpen,
setIsOpen,
get triggerRef() { return triggerRef; },
setTriggerRef: (el: HTMLElement) => { triggerRef = el; },
get floatingRef() { return floatingRef; },
setFloatingRef: (el: HTMLElement) => { floatingRef = el; },
position,
options,
cancelHoverClose,
scheduleHoverClose,
};
return <FloatingContext.Provider value={value}>{props.children}</FloatingContext.Provider>;
}
interface FloatingTriggerProps {
openOnHover?: boolean;
hoverDelay?: number;
hoverCloseDelay?: number;
class?: string;
title?: string;
children?: JSXElement;
}
export function FloatingTrigger(props: FloatingTriggerProps) {
const ctx = useFloatingContext();
let hoverOpenTimeout: ReturnType<typeof setTimeout> | null = null;
const clearOpenTimeout = () => {
if (hoverOpenTimeout) { clearTimeout(hoverOpenTimeout); hoverOpenTimeout = null; }
};
onCleanup(clearOpenTimeout);
const handleMouseEnter = () => {
if (!props.openOnHover) return;
ctx.cancelHoverClose();
clearOpenTimeout();
hoverOpenTimeout = setTimeout(() => ctx.setIsOpen(true), props.hoverDelay ?? 0);
};
const handleMouseLeave = () => {
if (!props.openOnHover) return;
clearOpenTimeout();
ctx.scheduleHoverClose(props.hoverCloseDelay ?? 150);
};
const handleClick = () => {
if (props.openOnHover) return;
ctx.setIsOpen(!ctx.isOpen());
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
ctx.setIsOpen(!ctx.isOpen());
} else if (e.key === "Escape" && ctx.isOpen()) {
ctx.setIsOpen(false);
}
};
return (
<button type="button" class={props.class || ""} title={props.title} ref={(el: HTMLElement) => ctx.setTriggerRef(el)} onclick={handleClick} onKeyDown={handleKeyDown} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} aria-expanded={ctx.isOpen()} aria-haspopup="menu">{props.children}</button>
);
}
interface FloatingContentProps {
class?: string;
style?: Record<string, string | number>;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
children?: JSXElement;
}
export function FloatingContent(props: FloatingContentProps) {
const ctx = useFloatingContext();
// Capture FloatingContent's own owner (which sits inside whatever provider
// wraps us — e.g. Menu's MenuContext). We resolve props.children under this
// owner below instead of letting the <Portal> resolve them in its deferred
// scope: children handed straight to a Portal get instantiated in the
// Portal's owner, dropping the surrounding provider from their owner chain,
// so a MenuItem inside a Menu's Portal throws "must be used within a Menu"
// (this bites when a solid-js/html page like AppLayout.ts feeds children in).
// Using runWithOwner (not the children() helper) keeps the exact same one-shot
// insert behavior the div had before — no extra reactive memo over the content,
// which that "always render" design is sensitive to.
const owner = getOwner();
// Always render the div — toggle visibility via CSS. Avoids the
// Show-based mount/unmount thrash where reactive scope disposal
// was immediately destroying the inner component on open.
const handleClickOutside = (e: MouseEvent) => {
if (!ctx.isOpen()) return;
const el = e.target instanceof Element ? e.target : null;
const self = ctx.floatingRef;
// Inside our own content or trigger → keep open.
if (el && self && self.contains(el)) return;
if (el && ctx.triggerRef && ctx.triggerRef.contains(el)) return;
// Keep open when the click lands in a descendant floating layer: either a
// FloatingContent opened AFTER us (higher in the open stack), or an
// unregistered floating (e.g. a combobox/select dropdown opened from inside
// us — these never join the stack and are always leaf descendants). Only a
// click in an ancestor/sibling FloatingContent, or fully outside, closes us.
const clicked = el && (el.closest("[data-floating-content]") as HTMLElement | null);
if (clicked && self) {
const ci = openFloatings.indexOf(clicked);
if (ci < 0 || ci > openFloatings.indexOf(self)) return;
}
ctx.setIsOpen(false);
};
const handleEscape = (e: KeyboardEvent) => {
if (!ctx.isOpen() || e.key !== "Escape") return;
// Only the topmost open floating closes on Escape, so dismissing a nested
// menu (e.g. an insert menu inside an editor popover) doesn't also close
// its parent. The stack is in open order, so the last entry is innermost.
const self = ctx.floatingRef;
if (self && openFloatings.length > 0 && openFloatings[openFloatings.length - 1] !== self) return;
ctx.setIsOpen(false);
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
// Track open order so descendant vs ancestor can be distinguished above.
createEffect(() => {
const self = ctx.floatingRef;
if (!self) return;
const i = openFloatings.indexOf(self);
if (ctx.isOpen()) { if (i < 0) openFloatings.push(self); }
else if (i >= 0) openFloatings.splice(i, 1);
});
onCleanup(() => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
const self = ctx.floatingRef;
const i = self ? openFloatings.indexOf(self) : -1;
if (i >= 0) openFloatings.splice(i, 1);
});
// Render through a Portal (to document.body) so the popover escapes any
// ancestor that establishes a containing block for `position: fixed` — most
// importantly the Modal's animated `transform`, which would otherwise make
// our viewport-relative top/left resolve relative to the modal instead.
return (
<Portal>
<div ref={(el: HTMLElement) => ctx.setFloatingRef(el)} role="menu" data-floating-content="true" class={props.class || ""} style={{
position: "fixed",
display: ctx.isOpen() ? "block" : "none",
// Kept laid-out-but-invisible until position() is computed (one
// rAF after open) so it never flashes at the top-left 0,0 origin.
visibility: ctx.isOpen() && ctx.position() ? "visible" : "hidden",
top: (ctx.position()?.top ?? 0) + "px",
left: (ctx.position()?.left ?? 0) + "px",
// Above the Modal container (z-[100]) so popovers opened from
// inside a modal aren't hidden behind it now that we portal.
"z-index": 110,
...(props.style || {}),
}} onMouseEnter={() => props.onMouseEnter?.()} onMouseLeave={() => props.onMouseLeave?.()}>{runWithOwner(owner, () => props.children)}</div>
</Portal>
);
}
export function useFloatingHover(openOnHover: boolean, hoverCloseDelay: number = 150) {
const ctx = useFloatingContext();
const handleMouseEnter = () => {
if (!openOnHover) return;
ctx.cancelHoverClose();
};
const handleMouseLeave = () => {
if (!openOnHover) return;
ctx.scheduleHoverClose(hoverCloseDelay);
};
return { onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave };
}

View File

@@ -0,0 +1,120 @@
// Format a number as a US phone number: (XXX) XXX-XXXX.
export function formatPhoneNumber(number: string | number): string {
const digits = String(number).replace(/\D/g, "").padStart(10, "0").slice(0, 10);
const areaCode = digits.slice(0, 3);
const centralOfficeCode = digits.slice(3, 6);
const lineNumber = digits.slice(6, 10);
return "(" + areaCode + ") " + centralOfficeCode + "-" + lineNumber;
}
// Format a number as a US zip code (5 or 9 digits).
export function formatZipCode(number: string | number): string {
const num = typeof number === "string" ? parseInt(number, 10) : number;
if (num <= 99999) {
return String(num).padStart(5, "0");
}
const digits = String(num).padStart(9, "0");
const zipCode = digits.slice(0, 5);
const plus4 = digits.slice(5, 9);
return zipCode + "-" + plus4;
}
// Format a number as a US Tax ID (EIN): XX-XXXXXXX.
export function formatTaxId(number: string | number): string {
const digits = String(number).replace(/\D/g, "").padStart(9, "0").slice(0, 9);
const prefix = digits.slice(0, 2);
const identifier = digits.slice(2, 9);
return prefix + "-" + identifier;
}
export function formatNumber(number: number): string {
return new Intl.NumberFormat("en-US").format(number);
}
export function formatDecimal(number: number, decimalPlaces: number = 2): string {
return new Intl.NumberFormat("en-US", {
minimumFractionDigits: decimalPlaces,
maximumFractionDigits: decimalPlaces,
}).format(number);
}
// State Code Utilities
const stateCodeMap: Record<string, string> = {
"Alabama": "AL", "Alaska": "AK", "Arizona": "AZ", "Arkansas": "AR", "California": "CA",
"Colorado": "CO", "Connecticut": "CT", "Delaware": "DE", "District of Columbia": "DC", "Florida": "FL",
"Georgia": "GA", "Hawaii": "HI", "Idaho": "ID", "Illinois": "IL", "Indiana": "IN",
"Iowa": "IA", "Kansas": "KS", "Kentucky": "KY", "Louisiana": "LA", "Maine": "ME",
"Maryland": "MD", "Massachusetts": "MA", "Michigan": "MI", "Minnesota": "MN", "Mississippi": "MS",
"Missouri": "MO", "Montana": "MT", "Nebraska": "NE", "Nevada": "NV", "New Hampshire": "NH",
"New Jersey": "NJ", "New Mexico": "NM", "New York": "NY", "North Carolina": "NC", "North Dakota": "ND",
"Ohio": "OH", "Oklahoma": "OK", "Oregon": "OR", "Pennsylvania": "PA", "Puerto Rico": "PR",
"Rhode Island": "RI", "South Carolina": "SC", "South Dakota": "SD", "Tennessee": "TN", "Texas": "TX",
"Utah": "UT", "Vermont": "VT", "Virgin Islands": "VI", "Virginia": "VA", "Washington": "WA",
"West Virginia": "WV", "Wisconsin": "WI", "Wyoming": "WY",
};
export function stateToStateCode(state: string): string {
if (stateCodeMap[state]) {
return stateCodeMap[state];
}
const stateLower = state.toLowerCase();
for (const [stateName, code] of Object.entries(stateCodeMap)) {
if (stateName.toLowerCase() === stateLower) {
return code;
}
}
return "";
}
export function stateCodeToState(code: string): string {
for (const [state, stateCode] of Object.entries(stateCodeMap)) {
if (stateCode === code.toUpperCase()) {
return state;
}
}
return "";
}
export function isValidStateCode(code: string): boolean {
return Object.values(stateCodeMap).includes(code.toUpperCase());
}
export function formatDate(date: string | Date): string {
const d = typeof date === "string" ? new Date(date) : date;
return new Intl.DateTimeFormat("en-US", {
month: "2-digit",
day: "2-digit",
year: "numeric",
}).format(d);
}
export function formatDateLong(date: string | Date): string {
const d = typeof date === "string" ? new Date(date) : date;
return new Intl.DateTimeFormat("en-US", {
month: "long",
day: "numeric",
year: "numeric",
}).format(d);
}
export function formatDateTime(date: string | Date): string {
const d = typeof date === "string" ? new Date(date) : date;
return new Intl.DateTimeFormat("en-US", {
month: "2-digit",
day: "2-digit",
year: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
}).format(d);
}
export function formatPercent(value: number, decimalPlaces: number = 2, isDecimal: boolean = false): string {
const percent = isDecimal ? value * 100 : value;
return percent.toFixed(decimalPlaces) + "%";
}

1898
go/jsruntime/uikit/Forms.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,321 @@
import { createSignal, createMemo, createEffect, onCleanup, For, Show } from "solid-js";
import { Portal } from "solid-js/web";
// ============================================================================
// Fuzzy matching (Sublime-style subsequence scoring)
// ============================================================================
// Port of Forrest Smith's fts_fuzzy_match. Every query char must appear in the
// target in order; the match is scored so that word-boundary / acronym hits
// ("nfcu" -> "Navy Federal Credit Union") outrank scattered ones. When a query
// char matches, we also recurse past it in case a later occurrence scores higher.
// The matcher functions are exported so other parts of the UI can rank/highlight
// without mounting the component.
export interface FuzzyMatchResult {
score: number;
positions: number[];
}
export interface FuzzySegment {
text: string;
match: boolean;
}
export interface FuzzyRankedItem {
value: string;
score: number;
segments: FuzzySegment[];
}
const FUZZY_SEQUENTIAL_BONUS = 15;
const FUZZY_SEPARATOR_BONUS = 30;
const FUZZY_CAMEL_BONUS = 30;
const FUZZY_FIRST_LETTER_BONUS = 15;
const FUZZY_LEADING_PENALTY = -5;
const FUZZY_MAX_LEADING_PENALTY = -15;
const FUZZY_UNMATCHED_PENALTY = -1;
const FUZZY_RECURSION_LIMIT = 10;
const FUZZY_TRANSPOSE_PENALTY = -20;
const FUZZY_EXACT_SUBSTRING_BONUS = 100;
const isLower = (c: string) => c >= "a" && c <= "z";
const isUpper = (c: string) => c >= "A" && c <= "Z";
const isSeparator = (c: string) => c === " " || c === "_" || c === "-";
function fuzzyScore(target: string, matches: number[]): number {
let score = 100;
score += Math.max(FUZZY_MAX_LEADING_PENALTY, FUZZY_LEADING_PENALTY * matches[0]);
score += FUZZY_UNMATCHED_PENALTY * (target.length - matches.length);
for (let i = 0; i < matches.length; i++) {
const curr = matches[i];
if (i > 0 && curr === matches[i - 1] + 1) score += FUZZY_SEQUENTIAL_BONUS;
if (curr === 0) {
score += FUZZY_FIRST_LETTER_BONUS;
} else {
const prev = target[curr - 1];
if (isLower(prev) && isUpper(target[curr])) score += FUZZY_CAMEL_BONUS;
if (isSeparator(prev)) score += FUZZY_SEPARATOR_BONUS;
}
}
return score;
}
function fuzzyRecurse(query: string, target: string, qi: number, ti: number, matches: number[], rec: { count: number }): number[] | null {
if (++rec.count >= FUZZY_RECURSION_LIMIT) return null;
let best: number[] | null = null;
while (qi < query.length && ti < target.length) {
if (query[qi].toLowerCase() === target[ti].toLowerCase()) {
const skipped = fuzzyRecurse(query, target, qi, ti + 1, matches.slice(), rec);
if (skipped && (!best || fuzzyScore(target, skipped) > fuzzyScore(target, best))) best = skipped;
matches.push(ti);
qi++;
}
ti++;
}
if (qi < query.length) return best; // query not fully consumed -> this path failed
if (!best || fuzzyScore(target, matches) > fuzzyScore(target, best)) return matches;
return best;
}
export function fuzzyMatch(query: string, target: string): FuzzyMatchResult | null {
if (!query) return null;
const matches = fuzzyRecurse(query, target, 0, 0, [], { count: 0 });
if (!matches) return null;
let score = fuzzyScore(target, matches);
// A contiguous substring hit ("bankof" in "Bankof") should outrank a
// word-boundary match split across tokens ("Bank of America"). The bonus is
// constant per query/target, so it lives here rather than in the per-
// alignment scorer the recursion uses to pick match positions.
if (target.toLowerCase().includes(query.toLowerCase())) score += FUZZY_EXACT_SUBSTRING_BONUS;
return { score, positions: matches };
}
// Subsequence matching can't tolerate a transposed typo ("teh" vs "the") because
// the swapped letters violate ordering. So also try every single adjacent-swap
// variant of the query and keep the best, penalizing transposed hits so exact
// matches still rank first.
export function fuzzyMatchTypoTolerant(query: string, target: string): FuzzyMatchResult | null {
let best = fuzzyMatch(query, target);
for (let i = 0; i < query.length - 1; i++) {
const swapped = query.slice(0, i) + query[i + 1] + query[i] + query.slice(i + 2);
const m = fuzzyMatch(swapped, target);
if (!m) continue;
const score = m.score + FUZZY_TRANSPOSE_PENALTY;
if (!best || score > best.score) best = { score, positions: m.positions };
}
return best;
}
// Split `text` into alternating matched / unmatched runs for highlighting.
export function fuzzySegments(text: string, positions: number[]): FuzzySegment[] {
const matched = new Set(positions);
const segments: FuzzySegment[] = [];
let buf = "";
let bufMatch = matched.has(0);
for (let i = 0; i < text.length; i++) {
const isMatch = matched.has(i);
if (isMatch !== bufMatch) {
if (buf) segments.push({ text: buf, match: bufMatch });
buf = "";
bufMatch = isMatch;
}
buf += text[i];
}
if (buf) segments.push({ text: buf, match: bufMatch });
return segments;
}
// Rank `options` against `query`, best score first, with highlight segments.
// Returns [] for an empty query. This is the headless entry point.
export function rankFuzzyMatches(query: string, options: string[], maxResults?: number): FuzzyRankedItem[] {
const q = query.trim();
if (!q) return [];
const out: FuzzyRankedItem[] = [];
for (const value of options) {
const m = fuzzyMatchTypoTolerant(q, value);
if (m) out.push({ value, score: m.score, segments: fuzzySegments(value, m.positions) });
}
out.sort((a, b) => b.score - a.score);
return maxResults != null ? out.slice(0, maxResults) : out;
}
// ============================================================================
// Component
// ============================================================================
export type FuzzyMatchDisplay = "list" | "dropdown" | "none";
export interface FuzzyMatchProps {
options: string[];
// "list": inline highlighted results below the input (default).
// "dropdown": ComboBox-style autocomplete popover.
// "none": render only the input and emit via onResults (headless).
display?: FuzzyMatchDisplay;
// Show each result's match score. Debug aid — off by default.
showScores?: boolean;
maxResults?: number;
placeholder?: string;
class?: string;
listClass?: string;
// Emit the ranked results on every change so other UI can consume them.
onResults?: (results: FuzzyRankedItem[]) => void;
onSelect?: (value: string, item: FuzzyRankedItem) => void;
onQueryChange?: (query: string) => void;
}
const INPUT_CLS = "bg-surface block w-full border border-line-strong rounded-default shadow-xs text-sm p-2 h-[38px] focus:outline-2 focus:outline-offset-1 focus:outline-sky-500";
// Mirror FormCombobox's dropdown styling (Forms.ts): neutral hover / highlight,
// not a colored one.
const DROPDOWN_CLS = "bg-surface border border-line-strong rounded-default shadow-lg max-h-60 overflow-auto";
const DROPDOWN_OPTION_CLS = "w-full text-left p-2 text-sm cursor-pointer flex items-center justify-between gap-2 bg-transparent border-none hover:bg-surface-raised whitespace-nowrap";
const DROPDOWN_OPTION_HIGHLIGHT_CLS = "bg-surface-raised";
function Highlight(props: { segments: FuzzySegment[] }) {
return <For each={props.segments}>
{(seg) => seg.match
? <span class="text-sky-700 dark:text-sky-400 font-semibold">{seg.text}</span>
: <span>{seg.text}</span>}
</For>;
}
export function FuzzyMatch(props: FuzzyMatchProps) {
const [query, setQuery] = createSignal("");
const [open, setOpen] = createSignal(false);
const [highlighted, setHighlighted] = createSignal(0);
const [pos, setPos] = createSignal({ top: 0, left: 0, width: 0 });
let containerRef: HTMLDivElement | undefined;
let inputRef: HTMLInputElement | undefined;
let dropdownRef: HTMLDivElement | undefined;
const display = () => props.display ?? "list";
const results = createMemo(() => rankFuzzyMatches(query(), props.options, props.maxResults));
// Show all options (unranked) in list mode before the user types anything,
// so the searchable set is visible up front.
const listItems = createMemo<FuzzyRankedItem[]>(() =>
query().trim()
? results()
: props.options.map((value) => ({ value, score: 0, segments: [{ text: value, match: false }] }))
);
// Emit results to the parent whenever they change (headless usage).
createEffect(() => props.onResults?.(results()));
const setQ = (v: string) => {
setQuery(v);
setHighlighted(0);
props.onQueryChange?.(v);
};
const select = (item: FuzzyRankedItem) => {
props.onSelect?.(item.value, item);
if (display() === "dropdown") {
setQ(item.value);
setOpen(false);
}
};
const updatePos = () => {
if (!inputRef) return;
const r = inputRef.getBoundingClientRect();
setPos({ top: r.bottom + 4, left: r.left, width: r.width });
};
// Position tracking + outside-click, only while the dropdown is open.
createEffect(() => {
if (display() !== "dropdown" || !open()) return;
updatePos();
const onDown = (e: MouseEvent) => {
const t = e.target as Node;
if (!containerRef?.contains(t) && !dropdownRef?.contains(t)) setOpen(false);
};
document.addEventListener("mousedown", onDown);
onCleanup(() => document.removeEventListener("mousedown", onDown));
});
const onKeyDown = (e: KeyboardEvent) => {
if (display() !== "dropdown") return;
const list = results();
if (e.key === "ArrowDown") {
e.preventDefault();
setOpen(true);
setHighlighted((i) => Math.min(i + 1, list.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlighted((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
const it = list[highlighted()];
if (it) select(it);
} else if (e.key === "Escape") {
setOpen(false);
}
};
const ScoreBadge = (p: { score: number }) =>
<Show when={props.showScores}>
<span class="ml-3 shrink-0 text-xs text-ink-faint">{p.score}</span>
</Show>;
return <div ref={containerRef} class={"relative " + (props.class ?? "")}>
<input
ref={inputRef}
type="text"
class={INPUT_CLS}
value={query()}
placeholder={props.placeholder ?? "Search..."}
oninput={(e) => { setQ(e.currentTarget.value); if (display() === "dropdown") setOpen(true); }}
onFocus={() => { if (display() === "dropdown" && results().length) setOpen(true); }}
onKeyDown={onKeyDown}
/>
<Show when={display() === "list"}>
<div class={"mt-3 " + (props.listClass ?? "h-72 overflow-y-auto")}>
<Show when={listItems().length > 0} fallback={
<Show when={query().trim()}>
<p class="text-sm text-ink-muted italic">No matches for "{query()}".</p>
</Show>
}>
<ul class="flex flex-col gap-0.5">
<For each={listItems()}>
{(r) => <li
class="flex items-center justify-between gap-3 px-2 py-1 rounded-default cursor-pointer hover:bg-surface-raised"
onclick={() => select(r)}
>
<span class="text-sm text-ink"><Highlight segments={r.segments} /></span>
<ScoreBadge score={r.score} />
</li>}
</For>
</ul>
</Show>
</div>
</Show>
<Show when={display() === "dropdown" && open() && results().length > 0}>
<Portal>
<div
ref={dropdownRef}
data-floating-content="true"
class={DROPDOWN_CLS}
style={`position:fixed;top:${pos().top}px;left:${pos().left}px;width:${pos().width}px;z-index:200;`}
>
<For each={results()}>
{(r, i) => <button
type="button"
onclick={() => select(r)}
onMouseEnter={() => setHighlighted(i())}
class={DROPDOWN_OPTION_CLS + (i() === highlighted() ? " " + DROPDOWN_OPTION_HIGHLIGHT_CLS : "")}
>
<span class="text-ink"><Highlight segments={r.segments} /></span>
<ScoreBadge score={r.score} />
</button>}
</For>
</div>
</Portal>
</Show>
</div>;
}

View File

@@ -0,0 +1,119 @@
import { For, JSXElement, Show } from "solid-js";
import { A } from "@solidjs/router";
import { Icon } from "./Icons.tsx";
interface PageContainerProps {
children?: JSXElement;
}
export function PageContainer(props: PageContainerProps) {
return <div class="admin-page-container">{props.children}</div>;
}
export function Divider() {
return <hr class="text-line mt-1 mb-3"/>;
}
interface CodeBoxProps {
code: string;
class?: string;
}
export function CodeBox(props: CodeBoxProps) {
return (
<div class={"text-xs p-3 bg-neutral-800 text-neutral-100 border border-neutral-700 rounded-default " + (props.class || "")}>
<pre><code>{props.code}</code></pre>
</div>
);
}
interface PageHeaderProps {
text: string;
class?: string;
}
export function PageHeader(props: PageHeaderProps) {
return (
<header class={props.class || ""}>
<div class="mt-1">
<h1 class="text-center text-2xl font-light text-ink mb-2">{props.text}</h1>
<hr class="text-line mb-2"/>
</div>
</header>
);
}
interface PageLinkProps {
href: string;
newTab?: boolean;
class?: string;
children?: JSXElement;
}
export function PageLink(props: PageLinkProps) {
return (
<a
href={props.href}
class={"text-sky-700 dark:text-sky-400 hover:text-sky-800 hover:underline hover:decoration-1 " + (props.class || "")}
target={props.newTab ? "_blank" : undefined} rel={props.newTab ? "noopener noreferrer" : undefined}>{props.children}
</a>
);
}
export function Loader(props: { class?: string; style?: string }) {
return (
<div class={"flex items-center justify-center p-8 " + (props.class || "")} style={props.style}>
<div class="h-8 w-8 border-4 border-sky-700 border-t-transparent rounded-full animate-spin"></div>
</div>
);
}
interface BreadcrumbItem {
url: string;
displayText: string;
}
interface BreadcrumbsProps {
items: BreadcrumbItem[];
}
export function Breadcrumbs(props: BreadcrumbsProps) {
return (
<div class="flex flex-row items-center text-ink-faint text-xs">
<For each={props.items}>{(crumb, index) => (
index() !== props.items.length - 1
? (
<span class="flex items-center">
<A href={crumb.url} class="text-ink-muted cursor-pointer no-underline hover:text-ink hover:underline">{crumb.displayText}</A>
<Icon icon="chevron-right" size={12} class="mx-[0.15rem] opacity-50"/>
</span>
)
: (
<span class="text-ink font-medium">{crumb.displayText}</span>
)
)}</For>
</div>
);
}
interface ManagerPageHeaderProps {
title: string;
description?: string;
action?: JSXElement;
}
export function ManagerPageHeader(props: ManagerPageHeaderProps) {
return (
<div class="page-header">
<div>
<h2 class="page-title">{props.title}</h2>
<Show when={props.description}>
<p class="page-desc">{props.description}</p>
</Show>
</div>
<Show when={props.action}>
{props.action}
</Show>
</div>
);
}

View File

@@ -0,0 +1,137 @@
import { JSXElement } from "solid-js";
import { FA_ICONS } from "@appgen/faIcons";
interface CustomIconDef {
viewBox: [number, number];
content: string;
}
interface IconProps {
icon: string;
size?: number | [number, number];
class?: string;
prefix?: string;
// Per-icon style override: `true` forces solid (fas), `false` forces
// regular (far); when omitted, follows the app-wide FA_DEFAULT_SOLID switch.
// Solid falls back to regular when a solid variant isn't in the bundle.
solid?: boolean;
style?: Partial<CSSStyleProperties>;
}
// The FontAwesome family + default weight are theme-driven so this component
// stays identical across projects. Each project's CSS theme sets `--fa-style`
// (classic → far/fas, sharp → fasr/fass) and `--fa-default-solid` (0/1). Read
// once at load; falls back to classic/regular under SSR (no getComputedStyle).
function readIconTheme(): { regular: string; solid: string; defaultSolid: boolean } {
let sharp = false, defaultSolid = false;
if (typeof document !== "undefined" && typeof getComputedStyle === "function" && document.documentElement) {
const cs = getComputedStyle(document.documentElement);
sharp = cs.getPropertyValue("--fa-style").trim() === "sharp";
defaultSolid = cs.getPropertyValue("--fa-default-solid").trim() === "1";
}
return { regular: sharp ? "fasr" : "far", solid: sharp ? "fass" : "fas", defaultSolid };
}
const _iconTheme = readIconTheme();
const FA_PREFIX_REGULAR = _iconTheme.regular;
const FA_PREFIX_SOLID = _iconTheme.solid;
// Set `--fa-default-solid: 1` in the theme to make the app default to solid icons.
// Individual icons still override per-call with `solid` (true/false) or `prefix`.
const FA_DEFAULT_SOLID: boolean = _iconTheme.defaultSolid;
const FA_PREFIX_DEFAULT = FA_DEFAULT_SOLID ? FA_PREFIX_SOLID : FA_PREFIX_REGULAR;
const ICON_BASE = "shrink-0";
const ICON_INLINE = "inline-block align-middle";
// Custom (non-FontAwesome) icon registry — a registered name overrides any FA
// lookup for the same name. This shared component ships with it EMPTY: each
// project registers its own SVGs from its app entry (see frontend/src/appIcons.ts)
// via registerIcon, so Icons.tsx stays identical across projects.
const customIcons: Record<string, CustomIconDef> = {};
// Register a custom icon under `name`, overriding FontAwesome for that name.
// Call from the app's own icon module (e.g. appIcons.ts), imported for side
// effect at startup so every icon is registered before the first Icon renders.
export function registerIcon(name: string, def: CustomIconDef) {
customIcons[name] = def;
}
// [minX, minY, width, height, svgPath] — viewBox is cropped to the glyph.
type FAEntry = readonly [number, number, number, number, string];
function resolveFAIcon(name: string, prefix: string): FAEntry | undefined {
return FA_ICONS[prefix + ":" + name];
}
export function Icon(props: IconProps) {
const size = () => props.size ?? 16;
const custom = () => customIcons[props.icon];
// Style resolution: an explicit `prefix` wins; then a per-icon `solid`
// override (true→solid, false→regular); otherwise the app-wide default set
// by FA_DEFAULT_SOLID. The fallback chain lets any icon resolve regardless
// of which style it actually ships in, so opting into solid never blanks.
const faDef = (): FAEntry | undefined => {
if (custom()) return undefined;
let want: string;
if (props.prefix) want = props.prefix;
else if (props.solid === true) want = FA_PREFIX_SOLID;
else if (props.solid === false) want = FA_PREFIX_REGULAR;
else want = FA_PREFIX_DEFAULT;
return resolveFAIcon(props.icon, want)
|| resolveFAIcon(props.icon, FA_PREFIX_REGULAR)
|| resolveFAIcon(props.icon, FA_PREFIX_SOLID);
};
// viewBox as [minX, minY, width, height]: custom icons are 0-origin; FA icons
// carry a viewBox cropped to the glyph so they render at their intended size.
const box = (): [number, number, number, number] => {
const c = custom();
if (c) return [0, 0, c.viewBox[0], c.viewBox[1]];
const fa = faDef();
return fa ? [fa[0], fa[1], fa[2], fa[3]] : [0, 0, 512, 512];
};
const vw = () => box()[2];
const vh = () => box()[3];
const svgContent = () => {
const c = custom();
if (c) return c.content;
const fa = faDef();
return fa ? '<path d="' + fa[4] + '"/>' : "";
};
const h = (): number => {
const s = size();
return Array.isArray(s) ? (s[1] ?? s[0]) : s;
};
const w = (): number => {
const s = size();
return Array.isArray(s) ? s[0] : Math.round(h() * (vw() / vh()));
};
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox={box().join(" ")} fill="currentColor" width={w()} height={h()} class={ICON_BASE + " " + (props.class || "")} innerHTML={svgContent()}></svg>
);
}
export function IconInline(props: IconProps) {
return <Icon icon={props.icon} size={props.size} prefix={props.prefix} solid={props.solid} class={ICON_INLINE + " " + (props.class || "")}/>;
}
export function IconSuccess(props: IconProps) {
return <Icon icon={props.icon} size={props.size} prefix={props.prefix} solid={props.solid} class={ICON_INLINE + " text-green-600 dark:text-green-400 " + (props.class || "")}/>;
}
export function IconError(props: IconProps) {
return <Icon icon={props.icon} size={props.size} prefix={props.prefix} solid={props.solid} class={ICON_INLINE + " text-red-600 dark:text-red-400 " + (props.class || "")}/>;
}
interface IconContainerProps {
children?: JSXElement;
}
export function IconContainer(props: IconContainerProps) {
return <span class="flex flex-row items-center gap-2">{props.children}</span>;
}

328
go/jsruntime/uikit/Menu.tsx Normal file
View File

@@ -0,0 +1,328 @@
import { createContext, useContext, createSignal, createEffect, onCleanup, Show, JSXElement } from "solid-js";
import { FloatingRoot, FloatingTrigger, FloatingContent, useFloatingContext, useFloatingHover, Placement } from "./Floating.tsx";
import { Icon } from "./Icons.tsx";
// Tailwind utility class groups for the menu UI — replaces the old
// `.ui-menu` / `.item` / `.divider` / `.section` @scope CSS. These
// are plain utility strings so they compose with any caller-supplied
// classes via simple concatenation.
// Padded container + inset, rounded items (the highlight is a rounded rectangle
// that doesn't reach the menu edges) — matching the popover insert menus.
const MENU_CLS = "bg-surface rounded-default shadow-lg border border-line p-1.5 min-w-48 max-h-96 overflow-y-auto";
const ITEM_CLS = "flex items-center gap-2 w-full py-1.5 px-2 rounded-default text-sm text-left text-ink bg-transparent border-0 cursor-pointer hover:bg-surface-raised hover:text-ink focus:bg-surface-raised focus:outline-hidden disabled:opacity-50 disabled:cursor-not-allowed";
// Divider runs edge-to-edge (negated container padding) for a clean separator.
const DIVIDER_CLS = "my-1 -mx-1.5 border-0 border-t border-line";
const SECTION_CLS = "pt-1.5 pb-0.5 px-2 text-[10px] font-semibold text-ink-faint uppercase tracking-wide text-left";
const SUBMENU_TRIGGER_CLS = "flex items-center justify-between gap-2 w-full py-1.5 px-2 rounded-default text-sm text-left text-ink cursor-pointer hover:bg-surface-raised hover:text-ink";
interface MenuContextValue {
openOnHover: boolean;
hoverCloseDelay: number;
closeMenu: () => void;
cancelParentClose?: () => void;
}
const MenuContext = createContext<MenuContextValue | null>(null);
function useMenuContext(): MenuContextValue {
const context = useContext(MenuContext);
if (!context) {
throw new Error("Menu components must be used within a Menu");
}
return context;
}
interface MenuProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
placement?: Placement;
offset?: number;
openOnHover?: boolean;
hoverCloseDelay?: number;
children?: JSXElement;
}
export function Menu(props: MenuProps) {
return (
<FloatingRoot open={props.open} onOpenChange={(v: boolean) => props.onOpenChange?.(v)} placement={props.placement ?? "bottom-start"} offset={props.offset ?? 4}>
<MenuContextProvider openOnHover={props.openOnHover ?? false} hoverCloseDelay={props.hoverCloseDelay ?? 150}>
{props.children}
</MenuContextProvider>
</FloatingRoot>
);
}
interface MenuContextProviderProps {
openOnHover: boolean;
hoverCloseDelay: number;
children?: JSXElement;
}
function MenuContextProvider(props: MenuContextProviderProps) {
const { setIsOpen, cancelHoverClose } = useFloatingContext();
const closeMenu = () => setIsOpen(false);
return (
<MenuContext.Provider value={{
get openOnHover() { return props.openOnHover; },
get hoverCloseDelay() { return props.hoverCloseDelay; },
closeMenu,
cancelParentClose: cancelHoverClose,
}}>
{props.children}
</MenuContext.Provider>
);
}
interface MenuTriggerProps {
asChild?: boolean;
class?: string;
children?: JSXElement | ((state: { isOpen: boolean }) => JSXElement);
}
export function MenuTrigger(props: MenuTriggerProps) {
const menuCtx = useMenuContext();
const { isOpen } = useFloatingContext();
const resolvedChildren = () => typeof props.children === "function"
? (props.children as (state: { isOpen: boolean }) => JSXElement)({ isOpen: isOpen() })
: props.children;
return (
<FloatingTrigger class={props.class || ""} openOnHover={menuCtx.openOnHover} hoverCloseDelay={menuCtx.hoverCloseDelay}>
{resolvedChildren()}
</FloatingTrigger>
);
}
interface MenuContentProps {
class?: string;
children?: JSXElement;
}
export function MenuContent(props: MenuContentProps) {
const menuCtx = useMenuContext();
const hoverProps = useFloatingHover(menuCtx.openOnHover, menuCtx.hoverCloseDelay);
return (
<FloatingContent class={MENU_CLS + " " + (props.class || "")} onMouseEnter={hoverProps.onMouseEnter} onMouseLeave={hoverProps.onMouseLeave}>
{props.children}
</FloatingContent>
);
}
interface MenuItemProps {
icon?: string;
disabled?: boolean;
onclick?: ((_e: MouseEvent) => Promise<void>) | ((_e: MouseEvent) => void);
closeOnClick?: boolean;
class?: string;
children?: JSXElement;
}
export function MenuItem(props: MenuItemProps) {
const { closeMenu } = useMenuContext();
const handleClick = (e?: MouseEvent) => {
if (props.disabled) return;
props.onclick?.(e);
if (props.closeOnClick !== false) closeMenu();
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleClick();
}
};
return (
<button type="button" role="menuitem" class={ITEM_CLS + " " + (props.class || "")} onclick={handleClick} onKeyDown={handleKeyDown} disabled={props.disabled}>
<Show when={props.icon}>
<Icon icon={props.icon!} size={16} class="shrink-0"/>
</Show>
{props.children}
</button>
);
}
interface MenuLinkProps {
href: string;
icon?: string;
class?: string;
children?: JSXElement;
}
export function MenuLink(props: MenuLinkProps) {
const { closeMenu } = useMenuContext();
return (
<a href={props.href} role="menuitem" class={ITEM_CLS + " " + (props.class || "")} onclick={closeMenu}>
<Show when={props.icon}>
<Icon icon={props.icon!} size={16} class="shrink-0"/>
</Show>
{props.children}
</a>
);
}
interface MenuAnchorProps extends MenuLinkProps {
target?: string;
rel?: string;
}
export function MenuAnchor(props: MenuAnchorProps) {
const { closeMenu } = useMenuContext();
return (
<a href={props.href} target={props.target ?? "_blank"} rel={props.rel ?? "noopener noreferrer"} role="menuitem" class={ITEM_CLS + " " + (props.class || "")} onclick={closeMenu}>
<Show when={props.icon}>
<Icon icon={props.icon!} size={16} class="shrink-0"/>
</Show>
{props.children}
<Show when={props.target === "_blank"}>
<Icon icon="arrow-right" size={12} class="shrink-0 ml-auto text-ink-faint"/>
</Show>
</a>
);
}
export function MenuDivider(props: { class?: string }) {
return <hr class={DIVIDER_CLS + " " + (props.class || "")} role="separator"/>;
}
interface MenuSectionProps {
class?: string;
children?: JSXElement;
}
export function MenuSection(props: MenuSectionProps) {
return (
<div class={SECTION_CLS + " " + (props.class || "")} role="presentation">
{props.children}
</div>
);
}
interface SubmenuProps {
trigger: string;
icon?: string;
class?: string;
children?: JSXElement;
}
interface SubmenuPosition {
top: number;
left: number;
}
export function Submenu(props: SubmenuProps) {
const parentMenu = useMenuContext();
const [isOpen, setIsOpen] = createSignal(false);
const [position, setPosition] = createSignal<SubmenuPosition | null>(null);
let closeTimeoutRef: ReturnType<typeof setTimeout> | null = null;
let triggerRef: HTMLDivElement | undefined;
let contentRef: HTMLDivElement | undefined;
const clearCloseTimeout = () => {
if (closeTimeoutRef) { clearTimeout(closeTimeoutRef); closeTimeoutRef = null; }
};
const scheduleClose = (delay: number) => {
clearCloseTimeout();
closeTimeoutRef = setTimeout(() => setIsOpen(false), delay);
};
const handleTriggerMouseEnter = () => {
clearCloseTimeout();
parentMenu.cancelParentClose?.();
setIsOpen(true);
};
const handleMouseLeave = () => scheduleClose(parentMenu.hoverCloseDelay);
const handleContentMouseEnter = () => {
clearCloseTimeout();
parentMenu.cancelParentClose?.();
};
const handleClick = () => setIsOpen((prev) => !prev);
createEffect(() => {
if (!isOpen() || !triggerRef) {
setPosition(null);
return;
}
const updatePosition = () => {
if (!triggerRef) return;
const triggerRect = triggerRef.getBoundingClientRect();
const contentEl = contentRef;
let top = triggerRect.top;
let left = triggerRect.right;
if (contentEl) {
const contentRect = contentEl.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
if (left + contentRect.width > vw - 8) left = triggerRect.left - contentRect.width;
if (top + contentRect.height > vh - 8) top = vh - contentRect.height - 8;
if (top < 8) top = 8;
}
setPosition({ top, left });
};
updatePosition();
window.addEventListener("scroll", updatePosition, true);
window.addEventListener("resize", updatePosition);
onCleanup(() => {
window.removeEventListener("scroll", updatePosition, true);
window.removeEventListener("resize", updatePosition);
});
});
onCleanup(clearCloseTimeout);
return (
<MenuContext.Provider value={{
openOnHover: parentMenu.openOnHover,
hoverCloseDelay: parentMenu.hoverCloseDelay,
closeMenu: parentMenu.closeMenu,
cancelParentClose: clearCloseTimeout,
}}>
<div ref={(el: HTMLDivElement) => triggerRef = el} role="menuitem" aria-haspopup="menu" aria-expanded={isOpen()} class={SUBMENU_TRIGGER_CLS + " " + (props.class || "")} onMouseEnter={handleTriggerMouseEnter} onMouseLeave={handleMouseLeave} onclick={handleClick}>
<span class="flex items-center gap-2">
<Show when={props.icon}>
<Icon icon={props.icon!} size={16} class="shrink-0"/>
</Show>
{props.trigger}
</span>
<Icon icon="chevron-right" size={16} class="shrink-0 ml-auto text-ink-faint"/>
</div>
<div ref={(el: HTMLDivElement) => contentRef = el} role="menu" class={MENU_CLS} style={{
position: "fixed",
display: isOpen() ? "block" : "none",
top: (position()?.top ?? 0) + "px",
left: (position()?.left ?? 0) + "px",
"z-index": 51,
}} onMouseEnter={handleContentMouseEnter} onMouseLeave={handleMouseLeave}>
{props.children}
</div>
</MenuContext.Provider>
);
}
interface MenuGroupProps {
class?: string;
children?: JSXElement;
}
export function MenuGroup(props: MenuGroupProps) {
return <div role="group" class={props.class || ""}>{props.children}</div>;
}

View File

@@ -0,0 +1,580 @@
import { Portal } from "solid-js/web";
import { createContext, useContext, createSignal, createEffect, onCleanup, createMemo, Show, For, JSXElement } from "solid-js";
import { Icon } from "./Icons.tsx";
export type ModalSize = "small" | "default" | "medium" | "large" | "xlarge" | "2xlarge" | "3xlarge" | "4xlarge" | "5xlarge" | "full";
export const MODAL_SMALL = "small";
export const MODAL_DEFAULT = "default";
export const MODAL_MEDIUM = "medium";
export const MODAL_LARGE = "large";
export const MODAL_XLARGE = "xlarge";
export const MODAL_2XLARGE = "2xlarge";
export const MODAL_3XLARGE = "3xlarge";
export const MODAL_4XLARGE = "4xlarge";
export const MODAL_5XLARGE = "5xlarge";
export const MODAL_FULL = "full";
const ANIMATION_DURATION = 100;
type Reactive<T> = T | (() => T);
interface ModalOptions {
size?: ModalSize;
centerOnScreen?: boolean;
}
interface ModalContextValue {
openModal: (content: JSXElement, options?: ModalOptions) => void;
closeModal: () => void;
isOpen: () => boolean;
}
const ModalContext = createContext<ModalContextValue | null>(null);
const resolve = <T,>(val: Reactive<T>): T => typeof val === "function" ? (val as () => T)() : val;
export function useModal(): ModalContextValue {
const context = useContext(ModalContext);
if (!context) {
throw new Error("useModal must be used within a ModalProvider");
}
return context;
}
// Shared stack of currently-open modals. Each modal pushes a token while open;
// Escape only dismisses the top-most one, so nested modals (a modal opened from
// inside another) close one layer per press instead of all at once.
const openModalStack: object[] = [];
// While `isOpen()` is true, registers this modal on the shared stack and wires
// up an Escape handler that fires `onEscape` only when this modal is on top.
// Must be called inside a component/reactive owner (uses createEffect/onCleanup).
function useModalEscape(isOpen: () => boolean, onEscape: () => void) {
createEffect(() => {
if (!isOpen()) return;
const token = {};
openModalStack.push(token);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
if (openModalStack[openModalStack.length - 1] !== token) return;
e.preventDefault();
onEscape();
};
document.addEventListener("keydown", handleKeyDown);
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown);
const idx = openModalStack.indexOf(token);
if (idx !== -1) openModalStack.splice(idx, 1);
});
});
}
// -- Tailwind class constants --------------------------------------
const CONTAINER_BASE = "fixed inset-0 z-[100] w-full h-dvh m-0 border-0 bg-transparent flex justify-center max-w-screen max-h-dvh";
const CONTAINER_TOP = "items-start pt-10";
const CONTAINER_CENTER = "items-center";
const BACKDROP = "fixed inset-0 bg-black/30";
const MODAL_BASE = "relative bg-surface shadow-md text-sm w-full rounded-default max-h-[calc(100dvh_-_5rem)] flex flex-col overflow-hidden";
const MODAL_SIZES: Record<ModalSize, string> = {
small: "max-w-md",
default: "max-w-xl",
medium: "max-w-2xl",
large: "max-w-3xl",
xlarge: "max-w-4xl",
"2xlarge":"max-w-5xl",
"3xlarge":"max-w-6xl",
"4xlarge":"max-w-7xl",
"5xlarge":"max-w-[90rem]",
full: "max-w-none",
};
const HEADER = "flex items-center justify-between py-5 px-7 pb-4 border-b border-line text-lg font-semibold text-ink";
const HEADER_CLOSE_ONLY = "flex items-center justify-end p-4 pb-1";
const CLOSE_BTN = "cursor-pointer text-ink-muted bg-transparent border-0 p-0 leading-none hover:text-ink";
const BODY = "px-7 py-6 overflow-y-auto flex-1 min-h-0 [background:linear-gradient(var(--color-surface),var(--color-surface))_bottom_/_100%_3rem_no-repeat_local,linear-gradient(to_bottom,transparent,var(--color-surface))_bottom_/_100%_3rem_no-repeat_scroll,var(--color-surface)]";
const FOOTER = "py-4 px-7 pb-[calc(1rem_+_env(safe-area-inset-bottom,0px))] flex flex-row justify-end gap-2 border-t border-line bg-surface-muted rounded-b-default";
const FOOTER_SPACER = "h-2";
const WIZARD_ERROR = "flex items-center gap-2 py-3 px-8 text-sm text-red-700 dark:text-red-400 bg-red-50 dark:bg-red-950/40 border-t border-red-200 dark:border-red-900";
const WIZARD_ERROR_ICON = "shrink-0 text-red-500";
// Confirm modal
const CONFIRM_WRAP = "flex justify-end gap-2";
const CONFIRM_CANCEL = "py-2 px-4 text-sm border border-line-strong rounded-default bg-transparent cursor-pointer hover:bg-surface-muted";
const CONFIRM_OK_BASE = "py-2 px-4 text-sm rounded-default border-0 cursor-pointer text-white";
const CONFIRM_OK_VARIANTS = {
danger: "bg-red-600 hover:bg-red-700",
primary: "bg-primary hover:bg-primary-hover",
};
// Wizard header
const WIZARD_HEADER = "flex flex-col items-center gap-2 flex-1";
const WIZARD_TITLE_ROW = "flex items-center justify-between w-full";
const WIZARD_TITLE = "text-xl";
const WIZARD_STEP_NAME = "text-xs font-semibold text-ink-soft uppercase tracking-wider";
const WIZARD_STEPS = "flex items-center justify-between relative w-full max-w-64";
const WIZARD_TRACK = "absolute top-1/2 left-0 right-0 h-0.5 bg-surface-strong -translate-y-1/2";
const WIZARD_TRACK_FILL = "h-full bg-primary transition-[width] duration-300 ease-in-out";
const WIZARD_STEP_WRAP = "relative z-[1]";
const STEP_INDICATOR_BASE = "w-7 h-7 rounded-full flex items-center justify-center text-xs font-semibold border-2 shrink-0 transition-all duration-300 ease-in-out";
const STEP_INDICATOR_PENDING = "border-line-strong text-ink-faint bg-surface";
const STEP_INDICATOR_ACTIVE = "bg-primary text-white border-primary";
const STEP_INDICATOR_COMPLETED = "bg-primary text-white border-primary";
// Wizard footer
const WIZARD_FOOTER = "flex items-center justify-between w-full gap-2";
const WIZARD_BTN_BASE = "py-2 px-5 text-sm rounded-default cursor-pointer border-0 disabled:opacity-40 disabled:cursor-not-allowed";
const WIZARD_BTN_BACK = "bg-transparent border border-line-strong text-ink enabled:hover:bg-surface-muted";
const WIZARD_BTN_NEXT = "bg-fill-neutral text-on-fill-neutral enabled:hover:bg-fill-neutral-hover";
const WIZARD_BTN_FINISH = "bg-primary text-white enabled:hover:bg-red-700";
interface ModalDisplayProps {
size?: Reactive<ModalSize | undefined>;
centerOnScreen?: Reactive<boolean | undefined>;
onClose?: () => void;
children?: JSXElement;
}
function ModalDisplay(props: ModalDisplayProps) {
const [isVisible, setIsVisible] = createSignal(false);
createEffect(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setIsVisible(true);
});
});
});
const getBackdropStyle = () => ({
opacity: isVisible() ? 1 : 0,
transition: `opacity ${ANIMATION_DURATION}ms ease-out`,
});
const getModalStyle = () => ({
opacity: isVisible() ? 1 : 0,
transform: isVisible() ? "scale(1)" : "scale(0.95)",
transition: `opacity ${ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1), transform ${ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1)`,
});
const getContainerClass = () => {
const centered = resolve(props.centerOnScreen);
return CONTAINER_BASE + " " + (centered ? CONTAINER_CENTER : CONTAINER_TOP);
};
const getModalClass = () => {
const size = resolve(props.size) || MODAL_DEFAULT;
return MODAL_BASE + " " + MODAL_SIZES[size];
};
const handleClose = () => {
const fn = props.onClose;
if (typeof fn === "function") {
fn();
}
};
return (
<dialog open class={getContainerClass()}>
<div class={BACKDROP} style={getBackdropStyle()} onclick={handleClose}></div>
<div class={getModalClass()} style={getModalStyle()}>
{resolve(props.children)}
</div>
</dialog>
);
}
interface ModalProviderProps {
children?: JSXElement;
}
export function ModalProvider(props: ModalProviderProps) {
const [isOpen, setIsOpen] = createSignal(false);
const [content, setContent] = createSignal<JSXElement>(null);
const [options, setOptions] = createSignal<ModalOptions>({});
const openModal = (modalContent: JSXElement, modalOptions: ModalOptions = {}) => {
setContent(() => modalContent);
setOptions(modalOptions);
setIsOpen(true);
};
const closeModal = () => {
setIsOpen(false);
setContent(null);
setOptions({});
};
useModalEscape(isOpen, closeModal);
const value: ModalContextValue = {
openModal,
closeModal,
isOpen,
};
return (
<ModalContext.Provider value={value}>
{props.children}
<Portal>
<Show when={isOpen()}>
<ModalDisplay size={options().size} centerOnScreen={options().centerOnScreen} onClose={closeModal} children={content()}/>
</Show>
</Portal>
</ModalContext.Provider>
);
}
interface ModalContentProps {
header?: JSXElement;
footer?: JSXElement;
onClose?: () => void;
children?: JSXElement;
}
export function ModalContent(props: ModalContentProps) {
const { closeModal } = useModal();
const handleClose = () => {
const fn = props.onClose;
if (typeof fn === "function") {
fn();
} else {
closeModal();
}
};
const header = () => props.header;
const footer = () => props.footer;
return [
<Show when={header() === undefined}>
<div class={HEADER_CLOSE_ONLY}>
<button onclick={handleClose} class={CLOSE_BTN}>
<Icon icon="xmark" size={24}/>
</button>
</div>
</Show>,
<Show when={header() !== undefined && header() !== null}>
<div class={HEADER}>
{header()}
<button onclick={handleClose} class={CLOSE_BTN}>
<Icon icon="xmark" size={24}/>
</button>
</div>
</Show>,
<div class={BODY}>{props.children}</div>,
<Show when={footer() === undefined}>
<div class={FOOTER_SPACER}></div>
</Show>,
<Show when={footer() !== undefined && footer() !== null}>
<div class={FOOTER}>{footer()}</div>
</Show>,
];
}
interface ModalProps {
isOpen: Reactive<boolean>;
onClose: () => void;
size?: ModalSize;
centerOnScreen?: boolean;
header?: JSXElement;
footer?: JSXElement;
children?: JSXElement;
}
export function Modal(props: ModalProps) {
const isOpen = () => {
const val = props.isOpen;
return typeof val === "function" ? (val as () => boolean)() : val;
};
const handleClose = () => {
const fn = props.onClose;
if (typeof fn === "function") {
fn();
}
};
const header = () => props.header;
const footer = () => props.footer;
const size = () => props.size || MODAL_DEFAULT;
const centerOnScreen = () => props.centerOnScreen;
useModalEscape(isOpen, handleClose);
// Render through a Portal (to document.body) so a Modal nested inside
// another Modal's body isn't clipped by the parent panel's overflow or
// trapped by its `transform` (which would make it the containing block for
// our `position: fixed` container). WizardModal/ModalProvider do the same.
return (
<Portal>
<Show when={isOpen()}>
<ModalDisplay size={size()} centerOnScreen={centerOnScreen()} onClose={handleClose}>
<Show when={header() === undefined}>
<div class={HEADER_CLOSE_ONLY}>
<button onclick={handleClose} class={CLOSE_BTN}>
<Icon icon="xmark" size={24}/>
</button>
</div>
</Show>
<Show when={header() !== undefined && header() !== null}>
<div class={HEADER}>
{header()}
<button onclick={handleClose} class={CLOSE_BTN}>
<Icon icon="xmark" size={24}/>
</button>
</div>
</Show>
<div class={BODY}>{props.children}</div>
<Show when={footer() === undefined}>
<div class={FOOTER_SPACER}></div>
</Show>
<Show when={footer() !== undefined && footer() !== null}>
<div class={FOOTER}>{footer()}</div>
</Show>
</ModalDisplay>
</Show>
</Portal>
);
}
interface ConfirmModalProps {
isOpen: Reactive<boolean>;
onClose: () => void;
onConfirm: () => void;
title?: string;
message: string;
confirmText?: string;
cancelText?: string;
confirmStyle?: "danger" | "primary";
}
export function ConfirmModal(props: ConfirmModalProps) {
const isOpen = () => {
const val = props.isOpen;
return typeof val === "function" ? (val as () => boolean)() : val;
};
const handleClose = () => {
const fn = props.onClose;
if (typeof fn === "function") {
fn();
}
};
const title = () => props.title ?? "Confirm";
const message = () => props.message;
const confirmText = () => props.confirmText ?? "Confirm";
const cancelText = () => props.cancelText ?? "Cancel";
const confirmStyle = (): "danger" | "primary" => props.confirmStyle ?? "danger";
const handleConfirm = () => {
const fn = props.onConfirm;
if (typeof fn === "function") {
fn();
}
handleClose();
};
return (
<Modal isOpen={isOpen()} onClose={handleClose} size={MODAL_SMALL} centerOnScreen={true} header={title()} footer={
<div class={CONFIRM_WRAP}>
<button onclick={handleClose} class={CONFIRM_CANCEL}>
{cancelText()}
</button>
<button onclick={handleConfirm} class={CONFIRM_OK_BASE + " " + CONFIRM_OK_VARIANTS[confirmStyle()]}>
{confirmText()}
</button>
</div>
}>
{message()}
</Modal>
);
}
export interface WizardStepContext {
setCanContinue: (complete: boolean) => void;
nextStep: () => void;
prevStep: () => void;
}
export interface WizardStep {
title: string;
content: (stepContext: WizardStepContext) => JSXElement;
}
interface WizardModalProps {
isOpen: Reactive<boolean>;
onClose: () => void;
onComplete: () => void;
steps: WizardStep[];
size?: ModalSize;
centerOnScreen?: boolean;
title?: string;
finishText?: string;
error?: Reactive<string | null | undefined>;
}
export function WizardModal(props: WizardModalProps) {
const isOpen = () => {
const val = props.isOpen;
return typeof val === "function" ? (val as () => boolean)() : val;
};
const handleClose = () => {
const fn = props.onClose;
if (typeof fn === "function") {
fn();
}
};
const steps = () => props.steps || [];
const size = () => props.size || MODAL_LARGE;
const centerOnScreen = () => props.centerOnScreen;
const finishText = () => props.finishText ?? "Finish";
const [currentStep, setCurrentStep] = createSignal(0);
const [stepContinueFlags, setStepContinueFlags] = createSignal<boolean[]>([]);
const [openVersion, setOpenVersion] = createSignal(0);
const totalSteps = () => steps().length;
const isFirstStep = () => currentStep() === 0;
const isLastStep = () => currentStep() === totalSteps() - 1;
const title = () => props.title ?? steps()[currentStep()]?.title ?? "";
const canContinue = () => !!stepContinueFlags()[currentStep()];
const makeSetCanContinue = (stepIndex: number) => (value: boolean) => {
setStepContinueFlags((prev) => {
const next = [...prev];
next[stepIndex] = value;
return next;
});
};
createEffect(() => {
if (isOpen()) {
setCurrentStep(0);
setStepContinueFlags([]);
setOpenVersion(v => v + 1);
}
});
const nextStep = () => {
if (!isLastStep()) {
setCurrentStep((s) => s + 1);
}
};
const prevStep = () => {
if (!isFirstStep()) {
setCurrentStep((s) => s - 1);
}
};
const handleNext = () => {
if (isLastStep()) {
const fn = props.onComplete;
if (typeof fn === "function") {
fn();
}
} else {
nextStep();
}
};
useModalEscape(isOpen, handleClose);
const renderedSteps = createMemo(() => {
openVersion();
return steps().map((step, index) => {
const stepContext: WizardStepContext = {
setCanContinue: makeSetCanContinue(index),
nextStep,
prevStep,
};
return step.content(stepContext);
});
});
const currentStepTitle = () => steps()[currentStep()]?.title ?? "";
const progressPercent = () => totalSteps() <= 1 ? 100 : (currentStep() / (totalSteps() - 1)) * 100;
const stepIndicatorClass = (i: number, cur: number): string => {
let c = STEP_INDICATOR_BASE + " ";
if (i === cur) c += STEP_INDICATOR_ACTIVE;
else if (i < cur) c += STEP_INDICATOR_COMPLETED;
else c += STEP_INDICATOR_PENDING;
return c;
};
const header = () => (
<div class={WIZARD_HEADER}>
<div class={WIZARD_TITLE_ROW}>
<span class={WIZARD_TITLE}>{title()}</span>
<button onclick={handleClose} class={CLOSE_BTN}>
<Icon icon="xmark" size={24}/>
</button>
</div>
<div class={WIZARD_STEP_NAME}>{currentStepTitle()}</div>
<div class={WIZARD_STEPS}>
<div class={WIZARD_TRACK}>
<div class={WIZARD_TRACK_FILL} style={`width:${progressPercent()}%`}/>
</div>
<For each={steps()}>{(_step, index) => (
<div class={WIZARD_STEP_WRAP}>
<div class={stepIndicatorClass(index(), currentStep())}>{index() < currentStep() ? "✓" : index() + 1}</div>
</div>
)}</For>
</div>
</div>
);
const nextBtnClass = () => WIZARD_BTN_BASE + " " + (isLastStep() ? WIZARD_BTN_FINISH : WIZARD_BTN_NEXT);
const footer = () => (
<div class={WIZARD_FOOTER}>
<button onclick={prevStep} class={WIZARD_BTN_BASE + " " + WIZARD_BTN_BACK} disabled={isFirstStep()}>Back</button>
<button onclick={handleNext} class={nextBtnClass()} disabled={!canContinue()}>{isLastStep() ? finishText() : "Next"}</button>
</div>
);
return (
<Portal>
<Show when={isOpen()}>
<ModalDisplay size={size()} centerOnScreen={centerOnScreen()} onClose={handleClose}>
<div class={HEADER}>
{header()}
</div>
<div class={BODY}>
<For each={renderedSteps()}>{(content, index) => (
<div style={index() === currentStep()
? ""
: "display:none"
}>{content}</div>
)}</For>
</div>
<Show when={(() => { const e = props.error; return typeof e === "function" ? (e as () => JSXElement)() : e; })()}>
<div class={WIZARD_ERROR}>
<Icon icon="circle-exclamation" size={16} class={WIZARD_ERROR_ICON}/>
<span>{(() => { const e = props.error; return typeof e === "function" ? (e as () => JSXElement)() : e; })()}</span>
</div>
</Show>
<div class={FOOTER}>
{footer()}
</div>
</ModalDisplay>
</Show>
</Portal>
);
}

View File

@@ -0,0 +1,115 @@
import { createContext, JSXElement, useContext } from "solid-js";
import { FloatingRoot, FloatingTrigger, FloatingContent, useFloatingHover, Placement } from "./Floating.tsx";
const POPOVER_CLS = "bg-surface rounded-default shadow-lg border border-line";
interface PopoverProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
placement?: Placement;
offset?: number;
// Nested floating that shouldn't close (or be closed by) an ancestor popover.
standalone?: boolean;
children?: JSXElement;
}
interface HoverPopoverProps extends PopoverProps {
hoverDelay?: number;
hoverCloseDelay?: number;
}
export function Popover(props: PopoverProps) {
return (
<FloatingRoot open={props.open} onOpenChange={(v: boolean) => props.onOpenChange?.(v)} placement={props.placement ?? "bottom-start"} offset={props.offset ?? 8} flip={true} shift={true} standalone={props.standalone}>
{props.children}
</FloatingRoot>
);
}
interface PopoverTriggerProps {
class?: string;
title?: string;
children?: JSXElement;
}
export function PopoverTrigger(props: PopoverTriggerProps) {
return (
<FloatingTrigger openOnHover={false} class={props.class || ""} title={props.title}>
{props.children}
</FloatingTrigger>
);
}
interface PopoverContentProps {
class?: string;
style?: Record<string, string | number>;
children?: JSXElement;
}
export function PopoverContent(props: PopoverContentProps) {
return (
<FloatingContent class={POPOVER_CLS + " " + (props.class || "")} style={props.style}>
{props.children}
</FloatingContent>
);
}
export function HoverPopover(props: HoverPopoverProps) {
return (
<FloatingRoot open={props.open} onOpenChange={(v: boolean) => props.onOpenChange?.(v)} placement={props.placement ?? "bottom-start"} offset={props.offset ?? 8} flip={true} shift={true} standalone={props.standalone}>
<HoverPopoverInner hoverDelay={props.hoverDelay ?? 0} hoverCloseDelay={props.hoverCloseDelay ?? 150}>
{props.children}
</HoverPopoverInner>
</FloatingRoot>
);
}
interface HoverPopoverContextValue {
hoverDelay: number;
hoverCloseDelay: number;
}
const HoverPopoverContext = createContext<HoverPopoverContextValue | null>(null);
interface HoverPopoverInnerProps {
hoverDelay: number;
hoverCloseDelay: number;
children?: JSXElement;
}
function HoverPopoverInner(props: HoverPopoverInnerProps) {
return (
<HoverPopoverContext.Provider value={{
get hoverDelay() { return props.hoverDelay; },
get hoverCloseDelay() { return props.hoverCloseDelay; },
}}>
{props.children}
</HoverPopoverContext.Provider>
);
}
interface HoverPopoverTriggerProps {
asChild?: boolean;
class?: string;
children?: JSXElement;
}
export function HoverPopoverTrigger(props: HoverPopoverTriggerProps) {
const ctx = useContext(HoverPopoverContext);
return (
<FloatingTrigger openOnHover={true} class={props.class || ""} hoverDelay={ctx?.hoverDelay ?? 0} hoverCloseDelay={ctx?.hoverCloseDelay ?? 150}>
{props.children}
</FloatingTrigger>
);
}
export function HoverPopoverContent(props: PopoverContentProps) {
const ctx = useContext(HoverPopoverContext);
const hoverProps = useFloatingHover(true, ctx?.hoverCloseDelay ?? 150);
return (
<FloatingContent class={POPOVER_CLS + " " + (props.class || "")} style={props.style} onMouseEnter={hoverProps.onMouseEnter} onMouseLeave={hoverProps.onMouseLeave}>
{props.children}
</FloatingContent>
);
}

View File

@@ -0,0 +1,126 @@
import { createMemo, For } from "solid-js";
import {
AUTOTABLE_HEADER_COLOR_BLUE,
AUTOTABLE_HEADER_COLOR_DARK_BLUE,
AUTOTABLE_HEADER_COLOR_DEFAULT,
AUTOTABLE_HEADER_COLOR_GRAY,
AUTOTABLE_HEADER_COLOR_GREEN,
AUTOTABLE_SIZE_DEFAULT,
BODY_PADDING_CLS,
COL_POS_LEFT,
HEADER_COLOR_CLS,
HEADER_CONTENT,
HEADER_INNER_BASE,
HEADER_INNER_POS,
HEADER_PADDING_CLS,
HEADER_TEXT_CLS,
POS_CLS,
TBL_BASE,
TBL_CONTAINER,
TBL_WRAPPER,
type AutoTableHeaderColor,
type AutoTableSize,
type ColumnPosition,
} from "./AutoTable.tsx";
const PT_ROW_HOVER_CLS: Record<AutoTableHeaderColor, string> = {
[AUTOTABLE_HEADER_COLOR_DEFAULT]: "[&_tr:hover]:!bg-surface-strong",
[AUTOTABLE_HEADER_COLOR_BLUE]: "[&_tr:hover]:!bg-sky-100 dark:bg-sky-950/50",
[AUTOTABLE_HEADER_COLOR_GREEN]: "[&_tr:hover]:!bg-green-100 dark:bg-green-950/50",
[AUTOTABLE_HEADER_COLOR_GRAY]: "[&_tr:hover]:!bg-surface-strong",
[AUTOTABLE_HEADER_COLOR_DARK_BLUE]: "[&_tr:hover]:!bg-sky-100 dark:bg-sky-950/50",
};
export interface PrettyTableColumn {
displayName: string;
displayPosition?: ColumnPosition;
headerClasses?: string;
}
export interface PrettyTableOptions {
size?: AutoTableSize;
shadow?: boolean;
hover?: boolean;
alternate?: boolean;
headerBorderY?: boolean;
surroundingBorder?: boolean;
borderX?: boolean;
borderY?: boolean;
color?: AutoTableHeaderColor;
tableLayoutAuto?: boolean;
}
export interface PrettyTableProps {
columns: PrettyTableColumn[];
// Body rows: <tr>s built with AutoTable's TdLeft/TdRight/TdCenter cells.
children?: any;
options?: PrettyTableOptions;
}
export function PrettyTable(props: PrettyTableProps) {
const opts = createMemo(() => ({
size: AUTOTABLE_SIZE_DEFAULT,
shadow: false,
hover: false,
alternate: false,
headerBorderY: false,
surroundingBorder: false,
borderX: false,
borderY: false,
color: AUTOTABLE_HEADER_COLOR_DEFAULT,
tableLayoutAuto: false,
...props.options,
}));
const bodyClass = () => {
let c = BODY_PADDING_CLS[opts().size];
if (opts().borderY) c += " [&_td+td]:border-l [&_td+td]:border-line-strong";
if (opts().alternate) c += " [&_tr:nth-child(even)]:bg-surface-raised";
if (opts().borderX) c += " [&_tr:not(:last-child)]:border-b [&_tr:not(:last-child)]:border-line-strong";
if (opts().hover) c += " " + PT_ROW_HOVER_CLS[opts().color];
return c;
};
return (
<div class={TBL_CONTAINER
+ (opts().surroundingBorder ? " border border-line-strong" : "")
+ (opts().shadow ? " shadow-sm" : "")}>
<div class={TBL_WRAPPER}>
<table class={TBL_BASE + (opts().tableLayoutAuto ? "" : " table-fixed")}>
<thead class="[&_th]:border-b [&_th]:border-line-strong">
<tr>
<For each={props.columns}>
{(col: PrettyTableColumn, displayIdx: () => number) => {
const pos = col.displayPosition ?? COL_POS_LEFT;
const posCls = POS_CLS[pos];
const headerInnerPosCls = HEADER_INNER_POS[pos];
return (
<th
class={HEADER_PADDING_CLS[opts().size] + " " + HEADER_COLOR_CLS[opts().color]
+ (posCls ? " " + posCls : "")
+ (opts().headerBorderY && displayIdx() > 0 ? " border-l border-l-neutral-300" : "")
+ (col.headerClasses ? " " + col.headerClasses : "")}
>
<div class={HEADER_CONTENT}>
<div class={HEADER_INNER_BASE + (headerInnerPosCls ? " " + headerInnerPosCls : "")}>
<div class={"grow text-sm " + HEADER_TEXT_CLS[opts().color]}>
{col.displayName}
</div>
</div>
</div>
</th>
);
}}
</For>
</tr>
</thead>
<tbody class={bodyClass()}>
{props.children}
</tbody>
</table>
</div>
</div>
);
}
export default PrettyTable;

View File

@@ -0,0 +1,38 @@
import { createSignal, Show } from "solid-js";
/**
* Creates a trigger/signal pair for showing a brief "remote update" flash.
* Call `fire()` when a remote WebSocket update arrives; `visible()` goes
* true for `durationMs` then auto-clears.
*/
export function createRemoteFlash(durationMs = 3000) {
const [visible, setVisible] = createSignal(false);
let timer: ReturnType<typeof setTimeout> | null = null;
const fire = () => {
setVisible(true);
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
setVisible(false);
timer = null;
}, durationMs);
};
return { visible, fire };
}
interface RemoteUpdateFlashProps {
when: boolean;
}
/**
* A small pill that briefly shows "Updated".
*/
export function RemoteUpdateFlash(props: RemoteUpdateFlashProps) {
return <Show when={props.when}>
<div class="remote-update-flash inline-flex items-center gap-1 rounded-full bg-emerald-100 dark:bg-emerald-950/50 border border-emerald-300 dark:border-emerald-800 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-widest text-emerald-700 dark:text-emerald-400">
<svg viewBox="0 0 12 12" class="w-2.5 h-2.5 fill-current"><circle cx="6" cy="6" r="6"/></svg>
Updated
</div>
</Show>;
}

View File

@@ -0,0 +1,103 @@
import { For, createSignal, Show, JSXElement } from "solid-js";
const NAV_ROOT = "bg-surface rounded-default shadow-sm border border-line py-2";
const NAV_LIST = "list-none m-0 p-0";
const NAV_BTN = "w-full text-left py-2 pr-3 pl-4 text-sm cursor-pointer bg-transparent text-ink-soft border-0 hover:text-ink hover:bg-surface-muted";
const NAV_SUBBTN = "w-full text-left py-1.5 pr-3 pl-8 text-xs cursor-pointer bg-transparent text-ink-muted border-0 hover:text-ink hover:bg-surface-muted";
const NAV_ICON = "mr-2";
const LAYOUT_ROOT = "grid grid-cols-1 gap-8 min-h-screen items-start lg:grid-cols-12";
const LAYOUT_SIDEBAR = "hidden lg:block lg:col-span-2 lg:self-start lg:h-full";
const LAYOUT_STICKY = "sticky top-20 max-h-[calc(100vh_-_7rem)] overflow-y-auto";
const LAYOUT_STICKY_FULL = "sticky top-0 h-screen overflow-y-auto";
// lg:pr-8 mirrors the grid's gap-8 (the content's left spacing from the sidebar)
// so the content has matching breathing room on the right instead of hugging the
// viewport edge. Scoped to lg, where the sidebar/gap exists.
const LAYOUT_MAIN = "col-span-1 min-w-0 lg:col-span-10 lg:pr-8";
interface SidebarNavItem {
id: string;
label: string;
icon?: JSXElement;
// Optional second-level items that jump to sub-sections within this item.
children?: SidebarNavItem[];
}
interface SidebarNavProps {
items: SidebarNavItem[];
onItemClick?: (id: string) => void;
class?: string;
}
export function SidebarNav(props: SidebarNavProps) {
const handleClick = (id: string) => {
if (props.onItemClick) {
props.onItemClick(id);
}
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "start" });
}
};
return (
<nav class={NAV_ROOT + (props.class ? " " + props.class : "")}>
<ul class={NAV_LIST}>
<For each={props.items}>{(item: SidebarNavItem) => (
<li>
<button onclick={() => handleClick(item.id)} class={NAV_BTN}>
{item.icon ? <span class={NAV_ICON}>{item.icon}</span> : null}
{item.label}
</button>
<Show when={item.children && item.children.length}>
<ul class={NAV_LIST}>
<For each={item.children}>{(sub: SidebarNavItem) => (
<li>
<button onclick={() => handleClick(sub.id)} class={NAV_SUBBTN}>
{sub.icon ? <span class={NAV_ICON}>{sub.icon}</span> : null}
{sub.label}
</button>
</li>
)}</For>
</ul>
</Show>
</li>
)}</For>
</ul>
</nav>
);
}
interface SidebarLayoutProps {
sidebar: JSXElement;
class?: string;
children?: JSXElement;
fullHeight?: boolean;
collapsible?: boolean;
}
export function SidebarLayout(props: SidebarLayoutProps) {
const [collapsed, setCollapsed] = createSignal(false);
const toggleCollapse = () => {
if (props.collapsible) {
setCollapsed(!collapsed());
}
};
return (
<div class={LAYOUT_ROOT + (props.class ? " " + props.class : "")}>
<aside class={LAYOUT_SIDEBAR}>
<div class={props.fullHeight ? LAYOUT_STICKY_FULL : LAYOUT_STICKY}>
{props.sidebar}
</div>
<Show when={props.collapsible}>
<button type="button" onclick={toggleCollapse} aria-label="Toggle sidebar"></button>
</Show>
</aside>
<main class={LAYOUT_MAIN}>
{props.children}
</main>
</div>
);
}

172
go/jsruntime/uikit/Tabs.tsx Normal file
View 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>
);
}

View File

@@ -0,0 +1,146 @@
// The theme controller.
//
// The kit is themed by TOKENS, not by a `dark:` variant on every component (see
// styles/theme.css). Components say bg-surface / text-ink / border-line and never
// name a colour; a `.dark` class on <html> re-points what those tokens mean. So all
// this module does is put a class on an element — the four hundred class strings in
// the kit are none the wiser.
//
// The storage key is deliberately the SAME one the Go/WASM kit uses
// (webui.ThemeBootScript, webui.themeStorageKey). Both halves of kjol-web are served
// from one origin, so they share a localStorage: choose dark in the /wasm section,
// walk over to /js, and it is still dark. Two front-ends, one preference.
import { createSignal, onCleanup } from "solid-js";
import { Icon } from "./Icons.tsx";
export type ThemeMode = "system" | "light" | "dark";
export const THEME_STORAGE_KEY = "kjol-theme";
const prefersDark = (): boolean => {
try {
return window.matchMedia("(prefers-color-scheme: dark)").matches;
} catch {
return false; // SSR: no window. The boot script settles it in the browser.
}
};
const readMode = (): ThemeMode => {
try {
const m = localStorage.getItem(THEME_STORAGE_KEY);
if (m === "light" || m === "dark") return m;
} catch {
// Private mode, or SSR. "system" is the right answer in both.
}
return "system";
};
const [mode, setModeSignal] = createSignal<ThemeMode>(readMode());
// isDark is the RESOLVED answer — what "system" actually means right now — as opposed
// to `mode`, which is what the user asked for. The toggle needs the former; the
// three-way picker needs the latter. They are not the same question.
const [isDark, setIsDark] = createSignal(false);
const resolve = (m: ThemeMode): boolean => m === "dark" || (m === "system" && prefersDark());
function apply(m: ThemeMode) {
const dark = resolve(m);
setIsDark(dark);
try {
document.documentElement.classList.toggle("dark", dark);
} catch {
// SSR. Nothing to toggle; the boot script has already done it for real.
}
}
/** Set the theme and remember it. "system" forgets, rather than storing the word. */
export function setTheme(m: ThemeMode) {
setModeSignal(m);
try {
if (m === "system") localStorage.removeItem(THEME_STORAGE_KEY);
else localStorage.setItem(THEME_STORAGE_KEY, m);
} catch {
// Not fatal — the theme still applies for this page.
}
apply(m);
}
/** Flip between light and dark, resolving "system" to whatever it currently means. */
export function toggleTheme() {
setTheme(isDark() ? "light" : "dark");
}
// initTheme syncs this module's signals with the class the BOOT SCRIPT already put on
// <html>, and starts following the OS while the mode is "system".
//
// It does not cause the first paint — that already happened, correctly, before any of
// this code existed. Calling it late is therefore harmless; not calling it at all just
// means the toggle button starts out showing the wrong icon.
//
// Call it once, from a component (it registers a cleanup).
export function initTheme() {
apply(mode());
try {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const onChange = () => {
// Only while the user has expressed no preference. Once they have picked a
// side, the OS changing its mind is not an instruction.
if (mode() === "system") apply("system");
};
mq.addEventListener("change", onChange);
onCleanup(() => mq.removeEventListener("change", onChange));
} catch {
// SSR, or a browser too old for matchMedia events. Neither is worth a crash.
}
}
export function useTheme() {
return { mode, isDark, setTheme, toggleTheme };
}
interface ThemeToggleProps {
small?: boolean;
class?: string;
}
export function ThemeToggle(props: ThemeToggleProps) {
const size = () => (props.small ? 14 : 16);
return (
<button
type="button"
onclick={toggleTheme}
title={isDark() ? "Switch to light" : "Switch to dark"}
aria-label={isDark() ? "Switch to light theme" : "Switch to dark theme"}
class={
"inline-flex cursor-pointer items-center justify-center rounded-default border border-line bg-surface text-ink-soft transition hover:bg-surface-raised hover:text-ink " +
(props.small ? "h-7 w-7 " : "h-9 w-9 ") +
(props.class || "")
}
>
{/* Show the destination, not the current state: a moon means "go dark". A
button that displays what you already have gives you nothing to press. */}
<Icon icon={isDark() ? "sun" : "moon"} size={size()} />
</button>
);
}
// THEME_BOOT_SCRIPT is the inline script an app puts in its <head>, BEFORE any
// stylesheet or markup.
//
// It exists to prevent the flash. The server cannot read localStorage, so it cannot
// know which theme to render; if the class were applied by this module after the
// bundle loads, every dark-mode user would be shown a white page for as long as the
// JavaScript takes to arrive, and then have it yanked out from under them. This runs
// first, synchronously, and so the first paint is already correct.
//
// It is byte-for-byte equivalent to the Go/WASM kit's webui.ThemeBootScript, and reads
// the same key. A server that already emits that one does not need this.
export const THEME_BOOT_SCRIPT = `<script>(function(){try{
var m = localStorage.getItem("kjol-theme");
var dark = m === "dark" || (!m && matchMedia("(prefers-color-scheme: dark)").matches);
if (dark) document.documentElement.classList.add("dark");
}catch(e){}})();</script>`;

View File

@@ -0,0 +1,216 @@
import { createContext, useContext, createSignal, createEffect, onCleanup, For, Show, JSXElement } from "solid-js";
import { Icon } from "./Icons.tsx";
export type ToastType = "success" | "error" | "warning" | "info" | "generic";
export type ToastPosition = "top-right" | "top-left" | "bottom-right" | "bottom-left" | "top-center" | "bottom-center";
export interface ToastConfig {
message: string;
type?: ToastType;
duration?: number | null;
dismissible?: boolean;
showProgress?: boolean;
}
interface ToastInstance extends ToastConfig {
id: string;
}
interface ToastContextValue {
addToast: (config: ToastConfig) => string;
removeToast: (id: string) => void;
success: (message: string, options?: Partial<ToastConfig>) => string;
error: (message: string, options?: Partial<ToastConfig>) => string;
warning: (message: string, options?: Partial<ToastConfig>) => string;
info: (message: string, options?: Partial<ToastConfig>) => string;
generic: (message: string, options?: Partial<ToastConfig>) => string;
}
interface ToastProviderProps {
position?: ToastPosition;
maxToasts?: number;
children?: JSXElement;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function useToast(): ToastContextValue {
const context = useContext(ToastContext);
if (!context) {
throw new Error("useToast must be used within a ToastProvider");
}
return context;
}
const TOAST_TYPE_ICONS: Record<ToastType, string | null> = {
success: "circle-check",
error: "circle-xmark",
warning: "triangle-exclamation",
info: "circle-info",
generic: null,
};
const CONTAINER_BASE = "fixed z-[200] flex flex-col gap-2";
const CONTAINER_POSITIONS: Record<ToastPosition, string> = {
"top-right": "top-4 right-4",
"top-left": "top-4 left-4",
"bottom-right": "bottom-4 right-4 flex-col-reverse",
"bottom-left": "bottom-4 left-4 flex-col-reverse",
"top-center": "top-4 left-1/2 -translate-x-1/2",
"bottom-center": "bottom-4 left-1/2 -translate-x-1/2 flex-col-reverse",
};
const TOAST_BASE = "relative overflow-hidden rounded-default shadow-lg border border-line border-l-4 bg-surface min-w-72 max-w-md transition-[opacity,transform] duration-150 ease-out";
const TOAST_TYPE_BORDER: Record<ToastType, string> = {
success: "border-l-green-700",
error: "border-l-red-700",
warning: "border-l-yellow-500",
info: "border-l-sky-800",
generic: "border-l-neutral-400",
};
const TOAST_ICON_COLOR: Record<ToastType, string> = {
success: "text-green-600 dark:text-green-400",
error: "text-red-600 dark:text-red-400",
warning: "text-yellow-600",
info: "text-sky-700 dark:text-sky-400",
generic: "",
};
const DEFAULT_DURATION = 5000;
let toastCounter = 0;
function generateId(): string {
return "toast-" + (++toastCounter) + "-" + Date.now();
}
interface ToastItemProps {
toast: ToastInstance;
onDismiss: (id: string) => void;
}
function ToastItem(props: ToastItemProps) {
const type = () => props.toast.type ?? "info";
const duration = () => props.toast.duration ?? DEFAULT_DURATION;
const dismissible = () => props.toast.dismissible !== false;
const showProgress = () => props.toast.showProgress !== false;
let timeoutRef: ReturnType<typeof setTimeout> | null = null;
let animationFrameRef: number | null = null;
let startTime = Date.now();
const [isExiting, setIsExiting] = createSignal(false);
const [progress, setProgress] = createSignal(100);
const handleDismiss = () => {
setIsExiting(true);
setTimeout(() => props.onDismiss(props.toast.id), 150);
};
createEffect(() => {
const dur = duration();
if (dur !== null && dur > 0) {
timeoutRef = setTimeout(handleDismiss, dur);
startTime = Date.now();
const updateProgress = () => {
const elapsed = Date.now() - startTime;
const remaining = Math.max(0, 100 - (elapsed / dur) * 100);
setProgress(remaining);
if (remaining > 0) {
animationFrameRef = requestAnimationFrame(updateProgress);
}
};
animationFrameRef = requestAnimationFrame(updateProgress);
}
onCleanup(() => {
if (timeoutRef) {
clearTimeout(timeoutRef);
}
if (animationFrameRef) {
cancelAnimationFrame(animationFrameRef);
}
});
});
const icon = () => TOAST_TYPE_ICONS[type()];
const shouldShowProgress = () => showProgress() && duration() !== null && duration()! > 0;
const toastClass = () => {
let c = TOAST_BASE + " " + TOAST_TYPE_BORDER[type()];
if (isExiting()) c += " opacity-0 translate-x-2";
return c;
};
return (
<div class={toastClass()} role="alert">
<div class="flex items-start gap-3 p-4">
<Show when={icon()}>
<Icon icon={icon()} size={20} class={"shrink-0 mt-0.5 " + TOAST_ICON_COLOR[type()]}/>
</Show>
<div class="flex-1 text-sm text-ink">{props.toast.message}</div>
<Show when={dismissible()}>
<button onclick={handleDismiss} class="shrink-0 cursor-pointer text-ink-faint hover:text-ink-soft bg-transparent border-0 p-0 transition-colors" aria-label="Dismiss">
<Icon icon="xmark" size={16}/>
</button>
</Show>
</div>
<Show when={shouldShowProgress()}>
<div class="h-1 w-full bg-surface-raised">
<div class="h-full bg-surface-strong" style={{ width: progress() + "%" }}/>
</div>
</Show>
</div>
);
}
export function ToastProvider(props: ToastProviderProps) {
const [toasts, setToasts] = createSignal<ToastInstance[]>([]);
const position = () => props.position ?? "bottom-right";
const maxToasts = () => props.maxToasts ?? 5;
const removeToast = (id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
};
const addToast = (config: ToastConfig): string => {
const id = generateId();
const newToast: ToastInstance = { ...config, id };
setToasts((prev) => {
const updated = [...prev, newToast];
if (updated.length > maxToasts()) {
return updated.slice(-maxToasts());
}
return updated;
});
return id;
};
const success = (message: string, options?: Partial<ToastConfig>) => addToast({ message, type: "success", ...options });
const error = (message: string, options?: Partial<ToastConfig>) => addToast({ message, type: "error", ...options });
const warning = (message: string, options?: Partial<ToastConfig>) => addToast({ message, type: "warning", ...options });
const info = (message: string, options?: Partial<ToastConfig>) => addToast({ message, type: "info", ...options });
const generic = (message: string, options?: Partial<ToastConfig>) => addToast({ message, type: "generic", ...options });
const value: ToastContextValue = {
addToast,
removeToast,
success,
error,
warning,
info,
generic,
};
return (
<ToastContext.Provider value={value}>
{props.children}
<div class={CONTAINER_BASE + " " + CONTAINER_POSITIONS[position()]} aria-live="polite" aria-label="Notifications">
<For each={toasts()}>{(toast) => (
<ToastItem toast={toast} onDismiss={removeToast}/>
)}</For>
</div>
</ToastContext.Provider>
);
}

View File

@@ -0,0 +1,61 @@
// ToggleSwitch is a reusable on/off switch styled with Tailwind. It renders a
// real <button role="switch"> so it stays keyboard- and screen-reader-friendly,
// with an optional inline label/description to its right.
//
// Props accept either plain values or zero-arg accessors (the SegmentedButtons
// convention), so callers can pass a signal directly: checked={mySignal}.
type Reactive<T> = T | (() => T);
interface ToggleSwitchProps {
checked: Reactive<boolean>;
onchange: (next: boolean) => void;
label?: Reactive<string>;
description?: Reactive<string>;
disabled?: Reactive<boolean>;
class?: string;
}
const resolve = <T,>(v: Reactive<T>): T => (typeof v === "function" ? (v as () => T)() : v);
export function ToggleSwitch(props: ToggleSwitchProps) {
const isChecked = () => !!resolve(props.checked);
const isDisabled = () => !!resolve(props.disabled);
const toggle = () => {
if (isDisabled()) return;
props.onchange(!isChecked());
};
const trackCls = () =>
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50 "
+ (isChecked() ? "bg-primary" : "bg-surface-strong");
// Track is w-9 (36px) with a w-4 (16px) knob, so a symmetric 2px gap means
// the knob sits at 2px (translate-x-0.5) when off and 36-16-2=18px when on.
const knobCls = () =>
"inline-block h-4 w-4 transform rounded-full bg-surface shadow-sm transition-transform "
+ (isChecked() ? "translate-x-[18px]" : "translate-x-0.5");
const hasText = () => props.label !== undefined || props.description !== undefined;
return <div class={"flex items-center gap-2 " + (props.class || "")}>
<button
type="button"
role="switch"
aria-checked={isChecked() ? "true" : "false"}
disabled={isDisabled()}
onclick={(_e: MouseEvent) => toggle()}
class={trackCls()}
>
<span class={knobCls()}></span>
</button>
{hasText() && <div class="flex flex-col leading-tight">
{props.label !== undefined && <span
class={"text-sm select-none " + (isDisabled() ? "text-ink-faint" : "text-ink")}
onclick={(_e: MouseEvent) => toggle()}
>{resolve(props.label)}</span>}
{props.description !== undefined && <span class="text-xs text-ink-muted">{resolve(props.description)}</span>}
</div>}
</div>;
}

View File

@@ -0,0 +1,178 @@
import { createSignal, JSXElement, onCleanup, Show } from "solid-js";
import { FloatingRoot, FloatingTrigger, FloatingContent, useFloatingContext, Placement } from "./Floating.tsx";
const TOOLTIP_DEFAULT_OFFSET = 8;
const TOOLTIP_CLS = "bg-neutral-800 text-white text-sm px-2.5 py-1.5 rounded-default shadow-lg max-w-80 relative";
function arrowCls(base: string) {
const common = "absolute w-0 h-0";
switch (base) {
case "top":
return common + " -bottom-[6px] left-1/2 -translate-x-1/2 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-t-[6px] border-t-neutral-800";
case "bottom":
return common + " -top-[6px] left-1/2 -translate-x-1/2 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-b-[6px] border-b-neutral-800";
case "left":
return common + " -right-[6px] top-1/2 -translate-y-1/2 border-t-[6px] border-t-transparent border-b-[6px] border-b-transparent border-l-[6px] border-l-neutral-800";
case "right":
return common + " -left-[6px] top-1/2 -translate-y-1/2 border-t-[6px] border-t-transparent border-b-[6px] border-b-transparent border-r-[6px] border-r-neutral-800";
default:
return common;
}
}
interface TooltipProps {
content: JSXElement;
trigger?: "hover" | "focus";
placement?: "top" | "bottom" | "left" | "right";
offset?: number;
delay?: number;
children?: JSXElement;
}
export function Tooltip(props: TooltipProps) {
return (
<Show when={props.trigger === "focus"} fallback={
<HoverTooltip content={props.content} placement={props.placement ?? "top"} offset={props.offset ?? TOOLTIP_DEFAULT_OFFSET} delay={props.delay ?? 200}>
{props.children}
</HoverTooltip>
}>
<FocusTooltip content={props.content} placement={props.placement ?? "top"} offset={props.offset ?? TOOLTIP_DEFAULT_OFFSET}>
{props.children}
</FocusTooltip>
</Show>
);
}
interface HoverTooltipProps {
content: JSXElement;
placement: Placement;
offset: number;
delay: number;
children?: JSXElement;
}
export function HoverTooltip(props: HoverTooltipProps) {
const [isOpen, setIsOpen] = createSignal(false);
return (
<FloatingRoot open={isOpen()} onOpenChange={setIsOpen} placement={props.placement} offset={props.offset} flip={true} shift={true}>
<HoverTooltipTrigger delay={props.delay} setIsOpen={setIsOpen}>
{props.children}
</HoverTooltipTrigger>
<TooltipContentWithArrow placement={props.placement} setIsOpen={setIsOpen}>
{props.content}
</TooltipContentWithArrow>
</FloatingRoot>
);
}
interface HoverTriggerProps {
delay: number;
setIsOpen: (v: boolean) => void;
children?: JSXElement;
}
function HoverTooltipTrigger(props: HoverTriggerProps) {
const { setTriggerRef } = useFloatingContext();
let hoverOpenTimeoutRef: ReturnType<typeof setTimeout> | null = null;
let hoverCloseTimeoutRef: ReturnType<typeof setTimeout> | null = null;
const clearTimeouts = () => {
if (hoverOpenTimeoutRef) { clearTimeout(hoverOpenTimeoutRef); hoverOpenTimeoutRef = null; }
if (hoverCloseTimeoutRef) { clearTimeout(hoverCloseTimeoutRef); hoverCloseTimeoutRef = null; }
};
const handleMouseEnter = () => {
clearTimeouts();
hoverOpenTimeoutRef = setTimeout(() => props.setIsOpen(true), props.delay);
};
const handleMouseLeave = () => {
clearTimeouts();
hoverCloseTimeoutRef = setTimeout(() => props.setIsOpen(false), 100);
};
onCleanup(clearTimeouts);
return (
<span ref={(el: HTMLElement) => setTriggerRef(el)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} style={{ display: "inline-block" }}>
{props.children}
</span>
);
}
interface FocusTooltipProps {
content: JSXElement;
placement: Placement;
offset: number;
children?: JSXElement;
}
export function FocusTooltip(props: FocusTooltipProps) {
const [isOpen, setIsOpen] = createSignal(false);
return (
<FloatingRoot open={isOpen()} onOpenChange={setIsOpen} placement={props.placement ?? "top"} offset={props.offset ?? TOOLTIP_DEFAULT_OFFSET} flip={true} shift={true}>
<FocusTooltipTrigger setIsOpen={setIsOpen}>
{props.children}
</FocusTooltipTrigger>
<TooltipContentWithArrow placement={props.placement ?? "top"}>
{props.content}
</TooltipContentWithArrow>
</FloatingRoot>
);
}
interface FocusTriggerProps {
setIsOpen: (v: boolean) => void;
children?: JSXElement;
}
function FocusTooltipTrigger(props: FocusTriggerProps) {
const { setTriggerRef } = useFloatingContext();
return (
<div ref={(el: HTMLElement) => setTriggerRef(el)} onFocusIn={() => props.setIsOpen(true)} onFocusOut={() => props.setIsOpen(false)}>
{props.children}
</div>
);
}
interface TooltipContentProps {
placement: string;
setIsOpen?: (v: boolean) => void;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
children?: JSXElement;
}
function TooltipContentWithArrow(props: TooltipContentProps) {
const { position } = useFloatingContext();
let hoverCloseTimeoutRef: ReturnType<typeof setTimeout> | null = null;
const handleMouseEnter = () => {
if (hoverCloseTimeoutRef) { clearTimeout(hoverCloseTimeoutRef); hoverCloseTimeoutRef = null; }
props.onMouseEnter?.();
};
const handleMouseLeave = () => {
if (props.setIsOpen) {
hoverCloseTimeoutRef = setTimeout(() => props.setIsOpen!(false), 50);
}
props.onMouseLeave?.();
};
onCleanup(() => {
if (hoverCloseTimeoutRef) clearTimeout(hoverCloseTimeoutRef);
});
const actualPlacement = () => position()?.placement ?? props.placement;
const basePlacement = () => actualPlacement().split("-")[0];
return (
<FloatingContent class={TOOLTIP_CLS} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
{props.children}
<div class={arrowCls(basePlacement())}/>
</FloatingContent>
);
}

View File

@@ -0,0 +1,685 @@
import { createContext, useContext, createSignal, createEffect, onCleanup, Show, For, JSXElement } from "solid-js";
import { Icon } from "./Icons.tsx";
import { ButtonUI, BUTTON_COLOR_WHITE, BUTTON_COLOR_BLUE } from "./Buttons.tsx";
export interface TutorialStep {
title?: string;
content: JSXElement;
target?: string | (() => HTMLElement | null) | null;
placement?: string;
offset?: number;
onEnter?: () => void;
onLeave?: () => void;
}
interface TutorialProviderProps {
steps: TutorialStep[];
spotlightPadding?: number;
onEnd?: () => void;
children?: JSXElement;
}
interface TutorialContextValue {
isActive: boolean;
currentStepIndex: number;
totalSteps: number;
currentStep: TutorialStep | null;
start: (stepIndex?: number) => void;
end: () => void;
next: () => void;
previous: () => void;
goTo: (stepIndex: number) => void;
}
interface TutorialInternalContextValue {
isActive: () => boolean;
currentStepIndex: () => number;
totalSteps: () => number;
currentStep: () => TutorialStep | null;
targetRect: () => DOMRect | null;
start: (stepIndex?: number) => void;
end: () => void;
next: () => void;
previous: () => void;
goTo: (stepIndex: number) => void;
}
const TutorialContext = createContext<TutorialContextValue | null>(null);
const TutorialInternalContext = createContext<TutorialInternalContextValue | null>(null);
const _TUTORIAL_ANIMATION_DURATION = 100;
export function useTutorial(): TutorialContextValue {
const context = useContext(TutorialContext);
if (!context) {
throw new Error("useTutorial must be used within a TutorialProvider");
}
return context;
}
function useTutorialInternal(): TutorialInternalContextValue {
return useContext(TutorialInternalContext)!;
}
interface PopoverPosition {
top: number;
left: number;
placement: string;
}
function calculatePopoverPosition(targetRect: DOMRect, popoverRect: DOMRect, placement: string, offset: number): PopoverPosition {
const parts = placement.split("-");
const basePlacement = parts[0];
const alignment = parts[1] || "center";
let top = 0;
let left = 0;
let finalPlacement = placement;
const padding = 16;
switch (basePlacement) {
case "top":
top = targetRect.top - popoverRect.height - offset;
break;
case "bottom":
top = targetRect.bottom + offset;
break;
case "left":
left = targetRect.left - popoverRect.width - offset;
break;
case "right":
left = targetRect.right + offset;
break;
}
if (basePlacement === "top" || basePlacement === "bottom") {
switch (alignment) {
case "start":
left = targetRect.left;
break;
case "end":
left = targetRect.right - popoverRect.width;
break;
default:
left = targetRect.left + (targetRect.width - popoverRect.width) / 2;
}
} else {
switch (alignment) {
case "start":
top = targetRect.top;
break;
case "end":
top = targetRect.bottom - popoverRect.height;
break;
default:
top = targetRect.top + (targetRect.height - popoverRect.height) / 2;
}
}
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
if (basePlacement === "bottom" && top + popoverRect.height > viewportHeight - padding) {
const flippedTop = targetRect.top - popoverRect.height - offset;
if (flippedTop >= padding) {
top = flippedTop;
finalPlacement = placement.replace("bottom", "top");
}
} else if (basePlacement === "top" && top < padding) {
const flippedTop = targetRect.bottom + offset;
if (flippedTop + popoverRect.height <= viewportHeight - padding) {
top = flippedTop;
finalPlacement = placement.replace("top", "bottom");
}
} else if (basePlacement === "right" && left + popoverRect.width > viewportWidth - padding) {
const flippedLeft = targetRect.left - popoverRect.width - offset;
if (flippedLeft >= padding) {
left = flippedLeft;
finalPlacement = placement.replace("right", "left");
}
} else if (basePlacement === "left" && left < padding) {
const flippedLeft = targetRect.right + offset;
if (flippedLeft + popoverRect.width <= viewportWidth - padding) {
left = flippedLeft;
finalPlacement = placement.replace("left", "right");
}
}
if (left < padding) {
left = padding;
} else if (left + popoverRect.width > viewportWidth - padding) {
left = viewportWidth - popoverRect.width - padding;
}
if (top < padding) {
top = padding;
} else if (top + popoverRect.height > viewportHeight - padding) {
top = viewportHeight - popoverRect.height - padding;
}
return { top, left, placement: finalPlacement };
}
interface SpotlightOverlayProps {
// Solid's `h` auto-invokes zero-arg function props on read — these
// are plain values inside the component body, not accessors.
targetRect: DOMRect | null;
hasTarget: boolean;
padding: number;
onclick?: () => void;
}
interface AnimatedRect {
left: number;
top: number;
width: number;
height: number;
}
function SpotlightOverlay(props: SpotlightOverlayProps) {
const [borderRadius, setBorderRadius] = createSignal(3.2);
const [animatedRect, setAnimatedRect] = createSignal<AnimatedRect | null>(null);
const [overlayOpacity, setOverlayOpacity] = createSignal(0);
createEffect(() => {
const cssValue = getComputedStyle(document.documentElement).getPropertyValue("--radius-default").trim();
if (cssValue) {
const remValue = parseFloat(cssValue);
if (!isNaN(remValue)) {
const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
setBorderRadius(remValue * rootFontSize);
}
}
});
createEffect(() => {
requestAnimationFrame(() => setOverlayOpacity(1));
});
createEffect(() => {
const target = props.targetRect;
if (target) {
setAnimatedRect({
left: target.left,
top: target.top,
width: target.width,
height: target.height,
});
} else {
setAnimatedRect(null);
}
});
const pad = () => props.padding ?? 8;
const baseTransition = `all ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1)`;
return (
<Show when={props.hasTarget && animatedRect()} fallback={
<div class="fixed inset-0 bg-black/50 z-150" onclick={() => props.onclick?.()} style={{
opacity: overlayOpacity(),
transition: `opacity ${_TUTORIAL_ANIMATION_DURATION}ms ease-out`,
}}/>
}>
<div class="fixed z-150 pointer-events-none rounded-default" style={(() => {
const rect = animatedRect()!;
return {
left: (rect.left - pad()) + "px",
top: (rect.top - pad()) + "px",
width: (rect.width + pad() * 2) + "px",
height: (rect.height + pad() * 2) + "px",
"border-radius": borderRadius() + "px",
"box-shadow": `0 0 0 9999px rgba(0, 0, 0, ${0.5 * overlayOpacity()})`,
transition: baseTransition,
};
})()}>
<div class="fixed inset-0 -z-10 cursor-pointer" onclick={() => props.onclick?.()}/>
</div>
</Show>
);
}
interface PopoverArrowProps {
// Solid's `h` auto-invokes zero-arg function props on read.
placement: string;
}
function PopoverArrow(props: PopoverArrowProps) {
const basePlacement = () => props.placement.split("-")[0];
const arrowStyles: Record<string, object> = {
top: {
bottom: "-8px", left: "50%", transform: "translateX(-50%)",
"border-left": "8px solid transparent", "border-right": "8px solid transparent", "border-top": "8px solid white",
},
bottom: {
top: "-8px", left: "50%", transform: "translateX(-50%)",
"border-left": "8px solid transparent", "border-right": "8px solid transparent", "border-bottom": "8px solid white",
},
left: {
right: "-8px", top: "50%", transform: "translateY(-50%)",
"border-top": "8px solid transparent", "border-bottom": "8px solid transparent", "border-left": "8px solid white",
},
right: {
left: "-8px", top: "50%", transform: "translateY(-50%)",
"border-top": "8px solid transparent", "border-bottom": "8px solid transparent", "border-right": "8px solid white",
},
};
const borderArrowStyles: Record<string, object> = {
top: {
bottom: "-9px", left: "50%", transform: "translateX(-50%)",
"border-left": "9px solid transparent", "border-right": "9px solid transparent", "border-top": "9px solid #e5e5e5",
},
bottom: {
top: "-9px", left: "50%", transform: "translateX(-50%)",
"border-left": "9px solid transparent", "border-right": "9px solid transparent", "border-bottom": "9px solid #e5e5e5",
},
left: {
right: "-9px", top: "50%", transform: "translateY(-50%)",
"border-top": "9px solid transparent", "border-bottom": "9px solid transparent", "border-left": "9px solid #e5e5e5",
},
right: {
left: "-9px", top: "50%", transform: "translateY(-50%)",
"border-top": "9px solid transparent", "border-bottom": "9px solid transparent", "border-right": "9px solid #e5e5e5",
},
};
return [
<div class="absolute w-0 h-0" style={{
...borderArrowStyles[basePlacement()],
width: "0",
height: "0",
transition: `all ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1)`,
}}/>,
<div class="absolute w-0 h-0" style={{
...arrowStyles[basePlacement()],
transition: `all ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1)`,
}}/>,
];
}
function TutorialPopover() {
const ctx = useTutorialInternal();
let popoverRef: HTMLDivElement | undefined;
const [position, setPosition] = createSignal<{ top: number; left: number } | null>(null);
const [currentPlacement, setCurrentPlacement] = createSignal("bottom");
const [displayedPlacement, setDisplayedPlacement] = createSignal("bottom");
const [isVisible, setIsVisible] = createSignal(false);
const [isPositioned, setIsPositioned] = createSignal(false);
const [contentOpacity, setContentOpacity] = createSignal(1);
const [displayedStep, setDisplayedStep] = createSignal<TutorialStep | null>(ctx.currentStep());
const [displayedStepIndex, setDisplayedStepIndex] = createSignal(ctx.currentStepIndex());
const [showArrow, setShowArrow] = createSignal(false);
let prevStepIndex = ctx.currentStepIndex();
let isTransitioning = false;
const placement = () => ctx.currentStep()?.placement ?? "bottom";
const offset = () => ctx.currentStep()?.offset ?? 16;
const hasTarget = () => !!ctx.currentStep()?.target;
const displayHasTarget = () => !!displayedStep()?.target;
const updatePosition = (immediate: boolean = false) => {
if (!popoverRef) return;
const popoverRect = popoverRef.getBoundingClientRect();
let newTop, newLeft;
let newPlacement = "bottom";
if (!hasTarget()) {
newTop = (window.innerHeight - popoverRect.height) / 2;
newLeft = (window.innerWidth - popoverRect.width) / 2;
} else if (ctx.targetRect()) {
const newPosition = calculatePopoverPosition(ctx.targetRect()!, popoverRect, placement(), offset());
newTop = newPosition.top;
newLeft = newPosition.left;
newPlacement = newPosition.placement;
} else {
newTop = (window.innerHeight - popoverRect.height) / 2;
newLeft = (window.innerWidth - popoverRect.width) / 2;
}
setPosition({ top: newTop, left: newLeft });
setCurrentPlacement(newPlacement);
if (immediate || !isPositioned()) {
setDisplayedPlacement(newPlacement);
}
};
createEffect(() => {
if (!popoverRef) return;
requestAnimationFrame(() => {
updatePosition(true);
setIsPositioned(true);
requestAnimationFrame(() => {
setIsVisible(true);
setTimeout(() => setShowArrow(true), _TUTORIAL_ANIMATION_DURATION);
});
});
});
createEffect(() => {
const currentIdx = ctx.currentStepIndex();
if (prevStepIndex === currentIdx) return;
prevStepIndex = currentIdx;
if (isTransitioning) return;
isTransitioning = true;
setIsFirstAppearance(false);
setShowArrow(false);
setContentOpacity(0);
setTimeout(() => {
setDisplayedStep(ctx.currentStep());
setDisplayedStepIndex(currentIdx);
requestAnimationFrame(() => {
updatePosition(true);
setTimeout(() => {
setContentOpacity(1);
setTimeout(() => {
setShowArrow(true);
isTransitioning = false;
}, _TUTORIAL_ANIMATION_DURATION / 2);
}, 50);
});
}, _TUTORIAL_ANIMATION_DURATION / 2);
});
createEffect(() => {
ctx.targetRect();
if (!isTransitioning && isPositioned()) {
updatePosition();
setDisplayedPlacement(currentPlacement());
}
});
createEffect(() => {
if (!isPositioned()) return;
const handleUpdate = () => {
if (!isTransitioning) {
updatePosition();
setDisplayedPlacement(currentPlacement());
}
};
window.addEventListener("scroll", handleUpdate, true);
window.addEventListener("resize", handleUpdate);
onCleanup(() => {
window.removeEventListener("scroll", handleUpdate, true);
window.removeEventListener("resize", handleUpdate);
});
});
const isFirstStep = () => displayedStepIndex() === 0;
const isLastStep = () => displayedStepIndex() === ctx.totalSteps() - 1;
const [isFirstAppearance, setIsFirstAppearance] = createSignal(true);
const getPopoverStyle = () => {
const pos = position();
if (!pos) {
return {
visibility: "hidden" as const,
top: "-9999px",
left: "-9999px",
};
}
if (isFirstAppearance()) {
return {
top: pos.top + "px",
left: pos.left + "px",
opacity: isVisible() ? 1 : 0,
transform: isVisible() ? "scale(1)" : "scale(0.95)",
transition: `opacity ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1), transform ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1)`,
};
}
return {
top: pos.top + "px",
left: pos.left + "px",
opacity: isVisible() ? 1 : 0,
transform: isVisible() ? "scale(1)" : "scale(0.95)",
transition: `opacity ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1), transform ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1), top ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1), left ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1)`,
};
};
const getContentStyle = () => ({
opacity: contentOpacity(),
transition: `opacity ${_TUTORIAL_ANIMATION_DURATION / 2}ms ease-out`,
});
return (
<div ref={(el: HTMLDivElement) => popoverRef = el} class="fixed z-200 bg-surface rounded-default shadow-lg border border-line max-w-sm" style={getPopoverStyle()}>
<Show when={displayHasTarget() && showArrow()}>
<PopoverArrow placement={displayedPlacement()}/>
</Show>
<div style={getContentStyle()}>
<div class="flex items-center justify-between p-4 pb-2">
<div class="flex items-center gap-2">
<Show when={displayedStep()?.title}>
<span class="font-medium text-ink">{displayedStep()?.title}</span>
</Show>
<span class="text-xs text-ink-muted">
{(displayedStepIndex() + 1) + " of " + ctx.totalSteps()}
</span>
</div>
<button onclick={() => ctx.end()} class="cursor-pointer text-ink-faint bg-transparent border-0 p-0 leading-none transition-colors hover:text-ink-soft">
<Icon icon="xmark" size={18}/>
</button>
</div>
<div class="px-4 pb-4 text-sm text-ink">
{displayedStep()?.content}
</div>
<div class="flex items-center justify-between px-4 pb-4 gap-2">
<div>
<Show when={!isFirstStep()}>
<ButtonUI color={BUTTON_COLOR_WHITE} small onclick={() => ctx.previous()}>
Previous
</ButtonUI>
</Show>
</div>
<div class="flex gap-2">
<Show when={isLastStep()} fallback={
<ButtonUI color={BUTTON_COLOR_BLUE} small onclick={() => ctx.next()}>
Next
</ButtonUI>
}>
<ButtonUI color={BUTTON_COLOR_BLUE} small onclick={() => ctx.end()}>
Finish
</ButtonUI>
</Show>
</div>
</div>
<Show when={ctx.totalSteps() > 1}>
<div class="flex justify-center gap-1.5 pb-3">
<For each={Array.from({ length: ctx.totalSteps() })}>{(_, i) => (
<div class={"w-2 h-2 rounded-full transition-all duration-300 ease-in-out " + (i() === displayedStepIndex() ? "bg-sky-600 scale-110" : "bg-surface-strong")}/>
)}</For>
</div>
</Show>
</div>
</div>
);
}
export function TutorialProvider(props: TutorialProviderProps) {
const [isActive, setIsActive] = createSignal(false);
const [currentStepIndex, setCurrentStepIndex] = createSignal(0);
const [targetRect, setTargetRect] = createSignal<DOMRect | null>(null);
const totalSteps = () => props.steps.length;
const currentStep = () => isActive() && props.steps[currentStepIndex()] ? props.steps[currentStepIndex()] : null;
createEffect(() => {
if (!isActive() || !currentStep()) {
setTargetRect(null);
return;
}
const step = currentStep()!;
if (!step.target) {
setTargetRect(null);
return;
}
const findTarget = (): HTMLElement | null => {
if (typeof step.target === "function") {
return step.target();
}
if (typeof step.target === "string") {
return document.querySelector(step.target);
}
return null;
};
const updateTargetRect = () => {
const target = findTarget();
if (target) {
setTargetRect(target.getBoundingClientRect());
target.scrollIntoView({ behavior: "smooth", block: "center" });
} else {
setTargetRect(null);
}
};
const timeoutId = setTimeout(updateTargetRect, 50);
window.addEventListener("scroll", updateTargetRect, true);
window.addEventListener("resize", updateTargetRect);
onCleanup(() => {
clearTimeout(timeoutId);
window.removeEventListener("scroll", updateTargetRect, true);
window.removeEventListener("resize", updateTargetRect);
});
});
createEffect(() => {
if (isActive() && currentStep()?.onEnter) {
currentStep()!.onEnter!();
}
});
const start = (stepIndex: number = 0) => {
setCurrentStepIndex(Math.max(0, Math.min(stepIndex, props.steps.length - 1)));
setIsActive(true);
};
const end = () => {
if (currentStep()?.onLeave) {
currentStep()!.onLeave!();
}
setIsActive(false);
setCurrentStepIndex(0);
props.onEnd?.();
};
const next = () => {
if (currentStep()?.onLeave) {
currentStep()!.onLeave!();
}
if (currentStepIndex() < props.steps.length - 1) {
setCurrentStepIndex(currentStepIndex() + 1);
} else {
end();
}
};
const previous = () => {
if (currentStep()?.onLeave) {
currentStep()!.onLeave!();
}
if (currentStepIndex() > 0) {
setCurrentStepIndex(currentStepIndex() - 1);
}
};
const goTo = (stepIndex: number) => {
if (currentStep()?.onLeave) {
currentStep()!.onLeave!();
}
setCurrentStepIndex(Math.max(0, Math.min(stepIndex, props.steps.length - 1)));
};
createEffect(() => {
if (!isActive()) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
end();
} else if (e.key === "ArrowRight" || e.key === "Enter") {
next();
} else if (e.key === "ArrowLeft") {
previous();
}
};
document.addEventListener("keydown", handleKeyDown);
onCleanup(() => document.removeEventListener("keydown", handleKeyDown));
});
const publicValue = (): TutorialContextValue => ({
isActive: isActive(),
currentStepIndex: currentStepIndex(),
totalSteps: props.steps.length,
currentStep: currentStep(),
start,
end,
next,
previous,
goTo,
});
const internalValue: TutorialInternalContextValue = {
isActive,
currentStepIndex,
totalSteps,
currentStep,
targetRect,
start,
end,
next,
previous,
goTo,
};
return (
<TutorialContext.Provider value={publicValue()}>
<TutorialInternalContext.Provider value={internalValue}>
{props.children}
<Show when={isActive() && currentStep()}>
<SpotlightOverlay targetRect={targetRect()} hasTarget={!!currentStep()?.target} padding={props.spotlightPadding ?? 8} onclick={() => end()}/>
<TutorialPopover/>
</Show>
</TutorialInternalContext.Provider>
</TutorialContext.Provider>
);
}
interface StartTutorialButtonProps {
stepIndex?: number;
class?: string;
children?: JSXElement;
}
export function StartTutorialButton(props: StartTutorialButtonProps) {
const { start } = useTutorial();
return (
<ButtonUI color={BUTTON_COLOR_BLUE} onclick={() => start(props.stepIndex ?? 0)} class={props.class || ""}>
{props.children ?? "Start Tutorial"}
</ButtonUI>
);
}

View File

@@ -0,0 +1,105 @@
import { Accessor, createMemo } from "solid-js";
export interface Validation<T> {
id: string; // snake case identifier that is "touched"
name: string;
required?: boolean;
touched: Accessor<Record<string, boolean>>;
field: Accessor<T>;
fieldBlur?: Accessor<T>;
isValidFunc?: (input: string, ...args: any) => boolean;
invalidMsg?: string;
}
export function createValidation(validation: Validation<string>): Accessor<string> {
const { id, name, required, touched, isValidFunc, invalidMsg } = validation;
const field = () => validation.field().trim();
// When no blur accessor is provided, fall back to the live value so the
// "has value but not blurred yet" guard is always false and validation
// runs against the live value instead.
const fieldBlur = validation.fieldBlur ? () => validation.fieldBlur!().trim() : field;
return createMemo(() => {
if (!touched()[id] || (field() && !fieldBlur()) || (isValidFunc && isValidFunc(field()))) return "";
if (required && !field()) return `${name} is required`;
if (isValidFunc && !isValidFunc(fieldBlur())) return invalidMsg ?? `${name} is invalid`;
return "";
});
}
export function isPhoneNumberValid(phoneNumber: string): boolean {
phoneNumber = phoneNumber.replace(/\D/g, "");
return phoneNumber.length == 10
}
export function isEmailValid(email: string): boolean {
const regex = /^[-!#$%&'*+\/0-9=?A-Z^_a-z`{|}~](\.?[-!#$%&'*+\/0-9=?A-Z^_a-z`{|}~])*@[a-zA-Z0-9](-*\.?[a-zA-Z0-9])*\.[a-zA-Z](-?[a-zA-Z0-9])+$/;
if (!email) return false;
let emailParts = email.split('@');
if (emailParts.length !== 2) return false;
let account = emailParts[0];
let address = emailParts[1];
if (account.length > 64) return false;
else if (address.length > 255) return false;
let domainParts = address.split('.');
if (domainParts.some(function (part) {
return part.length > 63;
})) return false;
return regex.test(email);
}
export function isUrlValid(url: string): boolean {
const regex = /[(http(s)?):\/\/(www\.)?a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/;
return regex.test(url);
}
export function isZipCodeValid(zip: string): boolean {
const rawZip = String(zip).replace(/\D/g, "");
return rawZip.length == 5 || rawZip.length == 9
}
export function isTaxIdValid(id: string): boolean {
const rawId = String(id).replace(/\D/g, "");
return rawId.length == 9;
}
// isAtLeastMinChars checks if the input string is at least "min" characters long
// and returns a boolean, true if valid, false if not.
export function isAtLeastMinChars(input: string, min:number):boolean {
return input.length >= min;
}
// isWithinMaxChars checks if the input string is at most "max" characters long
// and returns a boolean, true if valid, false if not.
export function isWithinMaxChars(input: string, max:number):boolean {
return input.length <= max;
}
// isNameValid checks if the name string consists of only letters, spaces, hyphens, and apostrophes
// and returns a boolean, true if valid, false if not.
export function isNameValid(name: string): boolean {
const regex = /^[\p{L}]*[\p{L} '\-]*[\p{L}]$/u;
return regex.test(name);
}
// isUsernameValid checks if the username contains 5-50 characters and only consists of alphanumeric
// characters. Returns an error message if invalid, empty string if valid.
export function isUsernameValid(username: string): string {
if (username.length < 5 || username.length > 50) return "Username must have 5-50 characters"
const regex = /^[A-Za-z0-9]*$/;
if (!regex.test(username)) return "Username must only contain alphanumeric characters"
return ""; // Valid
}