Add js web stuff to landing page + documentation
This commit is contained in:
127
go/jsruntime/uikit/Accordion.tsx
Normal file
127
go/jsruntime/uikit/Accordion.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
go/jsruntime/uikit/Alerts.tsx
Normal file
44
go/jsruntime/uikit/Alerts.tsx
Normal 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");
|
||||
4214
go/jsruntime/uikit/AutoTable.tsx
Normal file
4214
go/jsruntime/uikit/AutoTable.tsx
Normal file
File diff suppressed because it is too large
Load Diff
53
go/jsruntime/uikit/Badges.tsx
Normal file
53
go/jsruntime/uikit/Badges.tsx
Normal 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>;
|
||||
}
|
||||
195
go/jsruntime/uikit/Buttons.tsx
Normal file
195
go/jsruntime/uikit/Buttons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
211
go/jsruntime/uikit/Calendar.tsx
Normal file
211
go/jsruntime/uikit/Calendar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
go/jsruntime/uikit/Cards.tsx
Normal file
98
go/jsruntime/uikit/Cards.tsx
Normal 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>;
|
||||
}
|
||||
434
go/jsruntime/uikit/CellGrid.tsx
Normal file
434
go/jsruntime/uikit/CellGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
go/jsruntime/uikit/Chart.tsx
Normal file
91
go/jsruntime/uikit/Chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
166
go/jsruntime/uikit/CrmTabs.tsx
Normal file
166
go/jsruntime/uikit/CrmTabs.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { createSignal, onCleanup, onMount, Show, For, JSXElement } from "solid-js";
|
||||
|
||||
// CrmTabGroup / CrmSubTabGroup — drop-in, behaviourally identical siblings of
|
||||
// TabGroup (same props, storageKey syncing, controlled/uncontrolled index) with
|
||||
// a different look:
|
||||
// - CrmTabGroup → boxed tabs with a sky-blue top accent (ported from the old cdrl_2.0 tabs)
|
||||
// - CrmSubTabGroup → interlocking right-pointing arrows (a process-flow strip)
|
||||
|
||||
interface CrmTabItem {
|
||||
title: string;
|
||||
badge?: number;
|
||||
content: JSXElement;
|
||||
}
|
||||
|
||||
interface CrmTabGroupProps {
|
||||
items: CrmTabItem[];
|
||||
storageKey?: string;
|
||||
activeIndex?: number;
|
||||
onTabChange?: (index: number) => void;
|
||||
defaultIndex?: number;
|
||||
}
|
||||
|
||||
function resolveCrmBadge(badge: number | undefined): number | undefined {
|
||||
return typeof badge === "function" ? (badge as () => number)() : badge;
|
||||
}
|
||||
|
||||
// Shared active-index state: mirrors TabGroup exactly (localStorage persistence,
|
||||
// cross-component sync via synthetic storage events, optional controlled index).
|
||||
function createCrmTabState(props: CrmTabGroupProps) {
|
||||
const getInitialIndex = () => {
|
||||
if (props.storageKey) {
|
||||
const stored = localStorage.getItem(props.storageKey);
|
||||
if (stored !== null) {
|
||||
const parsed = parseInt(stored, 10);
|
||||
if (!isNaN(parsed) && parsed >= 0 && parsed < props.items.length) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return props.defaultIndex ?? 0;
|
||||
};
|
||||
|
||||
const [_activeIndex, _setActiveIndex] = createSignal(getInitialIndex());
|
||||
|
||||
const activeIndex = (): number => {
|
||||
const controlled = props.activeIndex;
|
||||
if (controlled != null) {
|
||||
return typeof controlled === "function" ? (controlled as () => number)() : controlled;
|
||||
}
|
||||
return _activeIndex();
|
||||
};
|
||||
|
||||
const setActiveIndex = (i: number) => {
|
||||
_setActiveIndex(i);
|
||||
if (props.storageKey) {
|
||||
const v = String(i);
|
||||
localStorage.setItem(props.storageKey, v);
|
||||
window.dispatchEvent(new StorageEvent("storage", { key: props.storageKey, newValue: v }));
|
||||
}
|
||||
props.onTabChange && props.onTabChange(i);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
if (!props.storageKey) return;
|
||||
const handler = (e: StorageEvent) => {
|
||||
if (e.key !== props.storageKey || e.newValue == null) return;
|
||||
const n = parseInt(e.newValue, 10);
|
||||
if (!isNaN(n) && n >= 0 && n < props.items.length && n !== _activeIndex()) {
|
||||
_setActiveIndex(n);
|
||||
props.onTabChange && props.onTabChange(n);
|
||||
}
|
||||
};
|
||||
window.addEventListener("storage", handler);
|
||||
onCleanup(() => window.removeEventListener("storage", handler));
|
||||
});
|
||||
|
||||
return { activeIndex, setActiveIndex };
|
||||
}
|
||||
|
||||
function crmTabContent(items: CrmTabItem[], activeIndex: () => number) {
|
||||
return <For each={items}>{(item: CrmTabItem, index: () => number) => (
|
||||
<div class={index() === activeIndex() ? "" : "hidden"}>
|
||||
{item.content}
|
||||
</div>
|
||||
)}</For>;
|
||||
}
|
||||
|
||||
// --- CrmTabGroup: boxed top-accent tabs -------------------------------------
|
||||
|
||||
// Ported from the old cdrl_2.0 TabGroup. Inactive tabs are flat with only a
|
||||
// bottom border (neutral-300) that forms the baseline; a trailing flex-1 filler
|
||||
// extends that baseline past the last tab to the right edge. The active tab
|
||||
// drops its bottom border and gains 1px left/right borders plus a 2px sky-700
|
||||
// top edge, so it reads as a raised box connected to the content below. No body
|
||||
// panel — content sits flat beneath the row, as in the old design.
|
||||
//
|
||||
// Per-tab borders (no negative-margin overlap) mean the row's overflow-x-auto
|
||||
// can't clip anything: the baseline simply has a gap under the active tab.
|
||||
const CRM_TAB_ROW = "flex w-full overflow-x-auto text-sm";
|
||||
const CRM_TAB_BASE = "flex items-center gap-1.5 cursor-pointer p-4 font-medium border-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>;
|
||||
}
|
||||
542
go/jsruntime/uikit/DatePicker.tsx
Normal file
542
go/jsruntime/uikit/DatePicker.tsx
Normal 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()]} {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>
|
||||
);
|
||||
}
|
||||
37
go/jsruntime/uikit/EnvBadge.tsx
Normal file
37
go/jsruntime/uikit/EnvBadge.tsx
Normal 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>;
|
||||
}
|
||||
397
go/jsruntime/uikit/Floating.tsx
Normal file
397
go/jsruntime/uikit/Floating.tsx
Normal 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 };
|
||||
}
|
||||
120
go/jsruntime/uikit/Formatters.ts
Normal file
120
go/jsruntime/uikit/Formatters.ts
Normal 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
1898
go/jsruntime/uikit/Forms.tsx
Normal file
File diff suppressed because it is too large
Load Diff
321
go/jsruntime/uikit/FuzzyMatch.tsx
Normal file
321
go/jsruntime/uikit/FuzzyMatch.tsx
Normal 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>;
|
||||
}
|
||||
119
go/jsruntime/uikit/General.tsx
Normal file
119
go/jsruntime/uikit/General.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
go/jsruntime/uikit/Icons.tsx
Normal file
137
go/jsruntime/uikit/Icons.tsx
Normal 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
328
go/jsruntime/uikit/Menu.tsx
Normal 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>;
|
||||
}
|
||||
580
go/jsruntime/uikit/Modal.tsx
Normal file
580
go/jsruntime/uikit/Modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
go/jsruntime/uikit/Popovers.tsx
Normal file
115
go/jsruntime/uikit/Popovers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
go/jsruntime/uikit/PrettyTable.tsx
Normal file
126
go/jsruntime/uikit/PrettyTable.tsx
Normal 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;
|
||||
38
go/jsruntime/uikit/RemoteUpdateFlash.tsx
Normal file
38
go/jsruntime/uikit/RemoteUpdateFlash.tsx
Normal 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>;
|
||||
}
|
||||
103
go/jsruntime/uikit/Sidebar.tsx
Normal file
103
go/jsruntime/uikit/Sidebar.tsx
Normal 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
172
go/jsruntime/uikit/Tabs.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { createSignal, onCleanup, onMount, Show, For, JSXElement } from "solid-js";
|
||||
|
||||
interface TabItem {
|
||||
title: string;
|
||||
badge?: number;
|
||||
content?: JSXElement;
|
||||
}
|
||||
|
||||
interface TabGroupProps {
|
||||
items: TabItem[];
|
||||
storageKey?: string;
|
||||
activeIndex?: number;
|
||||
onTabChange?: (index: number) => void;
|
||||
defaultIndex?: number;
|
||||
/** Optional content rendered in the right side of the tab bar (e.g. a PillSelect). */
|
||||
actions?: JSXElement;
|
||||
/** Extra classes on the root element (e.g. `ui-tabs` for structured panel CSS). */
|
||||
class?: string;
|
||||
/** When true, tabs fill available height and panels scroll internally (mobile POS). */
|
||||
fill?: boolean;
|
||||
/** When true, tab buttons share the header row equally below md. Defaults to true when `actions` is omitted. */
|
||||
stretch?: boolean;
|
||||
/** Tighter spacing for tabs above sibling panel content (use with PageLayout `tabs`). */
|
||||
pageTabs?: boolean;
|
||||
}
|
||||
|
||||
const TAB_BASE = "flex items-center gap-1.5 cursor-pointer py-2 px-4 text-sm font-medium bg-transparent border-0 border-b-2 transition-[color,border-color] duration-150";
|
||||
const TAB_INACTIVE = "text-ink-muted border-line hover:text-ink";
|
||||
const TAB_ACTIVE = "text-primary border-primary";
|
||||
|
||||
function resolveBadge(badge: number | undefined): number | undefined {
|
||||
return typeof badge === "function" ? (badge as () => number)() : badge;
|
||||
}
|
||||
|
||||
export function TabGroup(props: TabGroupProps) {
|
||||
const getInitialIndex = () => {
|
||||
if (props.storageKey) {
|
||||
const stored = localStorage.getItem(props.storageKey);
|
||||
if (stored !== null) {
|
||||
const parsed = parseInt(stored, 10);
|
||||
if (!isNaN(parsed) && parsed >= 0 && parsed < props.items.length) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return props.defaultIndex ?? 0;
|
||||
};
|
||||
|
||||
const [_activeIndex, _setActiveIndex] = createSignal(getInitialIndex());
|
||||
|
||||
const activeIndex = (): number => {
|
||||
const controlled = props.activeIndex;
|
||||
if (controlled != null) {
|
||||
return typeof controlled === "function" ? (controlled as () => number)() : controlled;
|
||||
}
|
||||
return _activeIndex();
|
||||
};
|
||||
|
||||
const setActiveIndex = (i: number) => {
|
||||
_setActiveIndex(i);
|
||||
if (props.storageKey) {
|
||||
const v = String(i);
|
||||
localStorage.setItem(props.storageKey, v);
|
||||
// localStorage writes don't fire `storage` events in the same
|
||||
// tab; synthesize one so other components syncing on this key
|
||||
// (e.g. an EventsSidebar tracking the active session) update.
|
||||
window.dispatchEvent(new StorageEvent("storage", { key: props.storageKey, newValue: v }));
|
||||
}
|
||||
props.onTabChange && props.onTabChange(i);
|
||||
};
|
||||
|
||||
// Sync uncontrolled tabs across components that share a storageKey: when
|
||||
// another part of the UI writes to it (and dispatches a synthetic storage
|
||||
// event), pick up the new value here too.
|
||||
onMount(() => {
|
||||
if (!props.storageKey) return;
|
||||
const handler = (e: StorageEvent) => {
|
||||
if (e.key !== props.storageKey || e.newValue == null) return;
|
||||
const n = parseInt(e.newValue, 10);
|
||||
if (!isNaN(n) && n >= 0 && n < props.items.length && n !== _activeIndex()) {
|
||||
_setActiveIndex(n);
|
||||
props.onTabChange && props.onTabChange(n);
|
||||
}
|
||||
};
|
||||
window.addEventListener("storage", handler);
|
||||
onCleanup(() => window.removeEventListener("storage", handler));
|
||||
});
|
||||
|
||||
const structured = () => props.fill || (props.class || "").includes("ui-tabs");
|
||||
|
||||
const pageTabs = () => props.pageTabs || (props.class || "").includes("page-tabs");
|
||||
|
||||
const rootCls = () => {
|
||||
const parts = props.fill
|
||||
? ["ui-tabs flex w-full min-h-0 flex-1 flex-col overflow-hidden"]
|
||||
: pageTabs()
|
||||
? ["w-full page-tabs"]
|
||||
: ["w-full pb-4"];
|
||||
if (props.class) parts.push(props.class);
|
||||
return parts.join(" ");
|
||||
};
|
||||
|
||||
const headerCls = () => structured()
|
||||
? "header overflow-x-auto flex flex-row w-full shrink-0 text-sm"
|
||||
: "overflow-x-auto flex flex-row w-full text-sm";
|
||||
|
||||
const panelCls = (index: number) => {
|
||||
const active = index === activeIndex();
|
||||
if (!structured()) return active ? "" : "hidden";
|
||||
if (!active) return "panel hidden";
|
||||
return props.fill
|
||||
? "panel flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||
: "panel";
|
||||
};
|
||||
|
||||
const stretchTabs = () => {
|
||||
if (props.actions != null) return props.stretch === true;
|
||||
return props.stretch !== false;
|
||||
};
|
||||
|
||||
const stretchCls = () => {
|
||||
if (!stretchTabs()) return "";
|
||||
return " flex-1 justify-center md:flex-initial md:justify-start";
|
||||
};
|
||||
|
||||
const tabBtnCls = (index: number) => () =>
|
||||
TAB_BASE
|
||||
+ stretchCls()
|
||||
+ " "
|
||||
+ (index === activeIndex() ? TAB_ACTIVE : TAB_INACTIVE);
|
||||
|
||||
const hasInlinePanels = () => props.items.some((item) => item.content != null);
|
||||
|
||||
const actionsContent = () => {
|
||||
if (props.actions == null) return null;
|
||||
return typeof props.actions === "function" ? (props.actions as () => JSXElement)() : props.actions;
|
||||
};
|
||||
|
||||
return (
|
||||
<div class={rootCls()}>
|
||||
<div class={headerCls()}>
|
||||
<For each={props.items}>{(item, index) => (
|
||||
<button type="button" onclick={() => setActiveIndex(index())} class={tabBtnCls(index())()}>
|
||||
{item.title}
|
||||
<Show when={(() => {
|
||||
const b = resolveBadge(item.badge);
|
||||
return b != null && b > 0;
|
||||
})()}>
|
||||
<span class="inline-flex items-center justify-center min-w-5 h-5 px-1 text-xs font-semibold bg-primary text-white rounded-full">{resolveBadge(item.badge)}</span>
|
||||
</Show>
|
||||
</button>
|
||||
)}</For>
|
||||
<Show when={props.actions != null && !!actionsContent()}>
|
||||
<div class="tab-actions flex-1 self-end border-b-2 border-line flex items-center justify-end pb-1">
|
||||
<div class="flex items-center min-w-0">
|
||||
{actionsContent()}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={hasInlinePanels()}>
|
||||
<For each={props.items}>{(item, index) => (
|
||||
<Show when={item.content != null}>
|
||||
<div class={panelCls(index())}>
|
||||
{item.content}
|
||||
</div>
|
||||
</Show>
|
||||
)}</For>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
go/jsruntime/uikit/Theme.tsx
Normal file
146
go/jsruntime/uikit/Theme.tsx
Normal 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>`;
|
||||
216
go/jsruntime/uikit/Toast.tsx
Normal file
216
go/jsruntime/uikit/Toast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
go/jsruntime/uikit/ToggleSwitch.tsx
Normal file
61
go/jsruntime/uikit/ToggleSwitch.tsx
Normal 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>;
|
||||
}
|
||||
178
go/jsruntime/uikit/Tooltips.tsx
Normal file
178
go/jsruntime/uikit/Tooltips.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
685
go/jsruntime/uikit/Tutorial.tsx
Normal file
685
go/jsruntime/uikit/Tutorial.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
go/jsruntime/uikit/Validation.ts
Normal file
105
go/jsruntime/uikit/Validation.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user