restructure project, add claudemd

This commit is contained in:
2026-07-08 16:36:17 -04:00
parent a7964f9410
commit 2a5fbffaa2
315 changed files with 81075 additions and 0 deletions

308
web/auth/AuthContext.ts Normal file
View File

@@ -0,0 +1,308 @@
import { createContext, useContext, createSignal, createMemo, onMount, JSXElement } from "solid-js";
import html from "solid-js/html";
import { getToken, setToken, clearToken, isTokenExpired } from "./useAuthFetch.js";
import { apiFetch } from "./checkBundleVersion.js";
export interface Identity {
id: string;
username: string;
email: string;
first_name: string;
last_name: string;
permissions: string[];
org_id: string | null;
org_name: string | null;
org_cert_num: string | null;
}
export interface MemberOrg {
id: string;
name: string;
certnum: string | null;
}
export interface SuperAdminState {
enabled: boolean;
orgName: string | null;
firstMemberOrgId: string | null;
}
export interface LoginResult {
success: boolean;
error?: string;
}
export interface SwitchOrgResult {
success: boolean;
org_id?: string;
org_name?: string;
org_cert_num?: string | null;
error?: string;
}
export interface AuthContextValue {
identity: () => Identity | null;
loading: () => boolean;
error: () => string | null;
isAuthenticated: () => boolean;
superAdminMode: () => SuperAdminState;
memberOrganizations: () => MemberOrg[];
login: (username: string, password: string, rememberMe?: boolean) => Promise<LoginResult>;
logout: () => Promise<void>;
checkAuth: () => Promise<void>;
switchOrganization: (orgId: string) => Promise<SwitchOrgResult>;
disableSuperAdminMode: () => Promise<void>;
}
interface AuthProviderProps {
children?: JSXElement;
}
const AuthContext = createContext<AuthContextValue>();
export function AuthProvider(props: AuthProviderProps) {
const [identity, setIdentity] = createSignal<Identity | null>(null);
const [loading, setLoading] = createSignal(true);
const [error, setError] = createSignal<string | null>(null);
const [memberOrganizations, setMemberOrganizations] = createSignal<MemberOrg[]>([]);
const [isAdmin, setIsAdmin] = createSignal(false);
const isAuthenticated = createMemo(() => !!identity());
const checkAuth = async (): Promise<void> => {
try {
setLoading(true);
setError(null);
const token = getToken();
if (!token) {
setIdentity(null);
setLoading(false);
return;
}
if (isTokenExpired()) {
clearToken();
setIdentity(null);
setLoading(false);
return;
}
// Retry transient network failures a few times so a brief blip
// self-heals instead of dropping the user to an error screen. A
// 401 is a real response and is handled below, not retried. -mta 6/3/26
let response;
for (let attempt = 0; ; attempt++) {
try {
response = await apiFetch("/api/auth/me", {
headers: {
"Authorization": `Bearer ${token}`,
},
});
break;
} catch (err) {
if (attempt >= 2) throw err;
await new Promise((resolve) => setTimeout(resolve, 400 * (attempt + 1)));
}
}
// Only a definitive 401 means the session is actually invalid. A
// network error, an aborted request (e.g. the user refreshed
// mid-flight), or a 5xx must NOT clear the token -- otherwise a
// quick refresh aborts this request and silently logs the user
// out. -mta 6/3/26
if (response.status === 401) {
clearToken();
setIdentity(null);
setMemberOrganizations([]);
setIsAdmin(false);
return;
}
if (!response.ok) {
throw new Error(`Auth check failed: ${response.status}`);
}
const data = await response.json();
setIdentity(data);
const orgsResponse = await apiFetch("/api/auth/my-organizations", {
headers: {
"Authorization": `Bearer ${token}`,
},
});
if (orgsResponse.ok) {
const orgsData = await orgsResponse.json();
setMemberOrganizations(orgsData.member_organizations || []);
setIsAdmin(orgsData.is_admin || false);
}
} catch (err) {
// Transient failure (network/abort/5xx). Preserve the token so the
// next load can retry instead of forcing a logout. -mta 6/3/26
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setLoading(false);
}
};
onMount(() => {
checkAuth();
});
const login = async (username: string, password: string, rememberMe = false): Promise<LoginResult> => {
try {
setLoading(true);
setError(null);
const response = await apiFetch("/api/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username,
password,
remember_me: rememberMe,
}),
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || "Login failed");
}
setToken(data.token, data.expires_at);
setIdentity(data.user);
const orgsResponse = await apiFetch("/api/auth/my-organizations", {
headers: {
"Authorization": `Bearer ${data.token}`,
},
});
if (orgsResponse.ok) {
const orgsData = await orgsResponse.json();
setMemberOrganizations(orgsData.member_organizations || []);
setIsAdmin(orgsData.is_admin || false);
}
return { success: true };
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
setError(message);
return { success: false, error: message };
} finally {
setLoading(false);
}
};
const logout = async (): Promise<void> => {
const token = getToken();
if (token) {
try {
await apiFetch("/api/auth/logout", {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
},
});
} catch {
// Continue with logout even if API call fails
}
}
clearToken();
setIdentity(null);
window.location.href = "/login";
};
const switchOrganization = async (orgId: string): Promise<SwitchOrgResult> => {
try {
setError(null);
const token = getToken();
if (!token) {
throw new Error("Not authenticated");
}
const response = await apiFetch("/api/auth/switch-org", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({ org_id: orgId }),
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || "Failed to switch organization");
}
setIdentity((prev) => prev ? {
...prev,
org_id: data.org_id,
org_name: data.org_name,
org_cert_num: data.org_cert_num,
} : null);
return {
success: true,
org_id: data.org_id,
org_name: data.org_name,
org_cert_num: data.org_cert_num,
};
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
setError(message);
return { success: false, error: message };
}
};
const disableSuperAdminMode = async (): Promise<void> => {
const orgs = memberOrganizations();
if (orgs.length > 0) {
await switchOrganization(orgs[0].id);
}
};
const superAdminMode = (): SuperAdminState => {
const id = identity();
const orgs = memberOrganizations();
return {
enabled: isAdmin() && id?.org_id != null && !orgs.some((org) => org.id === id.org_id),
orgName: id?.org_name || null,
firstMemberOrgId: orgs.length > 0 ? orgs[0].id : null,
};
};
const value: AuthContextValue = {
identity,
loading,
error,
isAuthenticated,
superAdminMode,
memberOrganizations,
login,
logout,
checkAuth,
switchOrganization,
disableSuperAdminMode,
};
// CRITICAL: Use lazy children evaluation
return html`<${AuthContext.Provider} value=${value}>${() => props.children}<//>`;
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}

View File

@@ -0,0 +1,61 @@
import { createEffect, createSignal, createMemo, JSXElement } from "solid-js";
import html from "solid-js/html";
import { useAuth } from "./AuthContext.ts";
import { getToken } from "./useAuthFetch.js";
import { hasPermission } from "./Permissions.js";
import { Loader } from "../ui/General.tsx";
interface ProtectedRouteProps {
permissions?: string[];
children?: JSXElement;
}
export function ProtectedRoute(props: ProtectedRouteProps) {
const auth = useAuth();
const [isRedirecting, setIsRedirecting] = createSignal(false);
createEffect(() => {
// Redirect to login ONLY when there is genuinely no session. A real
// 401 clears the token (so getToken() is null here), but a transient
// check failure preserves it -- in that case we keep the user here and
// show a retry screen instead of logging them out. -mta 6/3/26
if (!auth.loading() && !auth.isAuthenticated() && !getToken() && !isRedirecting()) {
setIsRedirecting(true);
const returnUrl = encodeURIComponent(window.location.pathname + window.location.search);
window.location.replace(`/login?redirect=${returnUrl}`);
}
});
const hasRequiredPermissions = createMemo(() => {
const permissions = props.permissions || [];
if (permissions.length === 0) return true;
const userPermissions = auth.identity()?.permissions || [];
return permissions.every((perm) => hasPermission(userPermissions, perm));
});
const state = createMemo(() => {
if (isRedirecting() || auth.loading()) return 'loading';
if (!auth.isAuthenticated()) {
// Token still present => the check failed transiently => offer a
// retry. No token => the redirect effect is taking over.
return getToken() ? 'error' : 'loading';
}
if (!hasRequiredPermissions()) return 'unauthorized';
return 'ready';
});
return html`<div>
${() => state() === 'loading' && html`<${Loader} />`}
${() => state() === 'error' && html`<div class="flex flex-col items-center justify-center min-h-[50vh] text-center p-8">
<h1 class="text-2xl font-semibold text-neutral-800 mb-2">Connection problem</h1>
<p class="text-neutral-600 mb-4">We couldn't verify your session. You're still signed in -- please try again.</p>
<button onclick=${() => auth.checkAuth()} class="text-sky-600 hover:text-sky-800 hover:underline">Retry</button>
</div>`}
${() => state() === 'unauthorized' && html`<div class="flex flex-col items-center justify-center min-h-[50vh] text-center p-8">
<h1 class="text-2xl font-semibold text-neutral-800 mb-2">Access Denied</h1>
<p class="text-neutral-600 mb-4">You don't have permission to access this page.</p>
<a href="/app/dashboard" class="text-sky-600 hover:text-sky-800 hover:underline">Return to Dashboard</a>
</div>`}
${() => state() === 'ready' && props.children}
</div>`;
}

View File

@@ -0,0 +1,51 @@
let bundleStaleNotified = false;
export function checkBundleVersion(response) {
const serverVersion = response.headers.get("X-Bundle-Version");
const clientVersion = window.__BUNDLE_VERSION__;
if (!serverVersion || !clientVersion || serverVersion === clientVersion) {
return;
}
if (bundleStaleNotified) {
return;
}
bundleStaleNotified = true;
const bar = document.createElement("div");
bar.setAttribute("role", "status");
bar.style.cssText = [
"position:fixed",
"top:0",
"left:0",
"right:0",
"z-index:99999",
"padding:12px 16px",
"background:#141620",
"color:#fff",
"text-align:center",
"font:14px system-ui,sans-serif",
"box-shadow:0 2px 8px rgba(0,0,0,0.2)",
].join(";");
const message = document.createElement("span");
message.textContent = "A new version of National CD Rateline is available.";
const button = document.createElement("button");
button.type = "button";
button.textContent = "Refresh";
button.style.cssText = "margin-left:12px;padding:4px 12px;cursor:pointer;border:0;border-radius:4px;background:#fff;color:#141620;font:inherit";
button.addEventListener("click", () => {
window.location.reload();
});
bar.append(message, button);
document.body.prepend(bar);
}
export async function apiFetch(url, options) {
const response = await fetch(url, options);
checkBundleVersion(response);
return response;
}

106
web/auth/useAuthFetch.js Normal file
View File

@@ -0,0 +1,106 @@
import { apiFetch } from "./checkBundleVersion.js";
const TOKEN_KEY = "session_key";
const TOKEN_EXPIRY_KEY = "session_key_expiry";
export function getToken() {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token, expiresAt) {
localStorage.setItem(TOKEN_KEY, token);
if (expiresAt) {
localStorage.setItem(TOKEN_EXPIRY_KEY, expiresAt.toString());
}
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(TOKEN_EXPIRY_KEY);
}
export function isTokenExpired() {
const expiry = localStorage.getItem(TOKEN_EXPIRY_KEY);
if (!expiry) return true;
return Date.now() >= parseInt(expiry, 10) * 1000;
}
export function useAuthFetch(options = {}) {
const { onUnauthorized } = options;
return async (url, fetchOptions = {}) => {
const fullUrl = url.startsWith("http") ? url : url;
const token = getToken();
const headers = {
"Content-Type": "application/json",
...fetchOptions.headers,
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await apiFetch(fullUrl, {
...fetchOptions,
headers,
});
if (response.status === 401) {
clearToken();
if (onUnauthorized) {
onUnauthorized();
} else {
const returnUrl = encodeURIComponent(window.location.pathname + window.location.search);
window.location.href = `/login?redirect=${returnUrl}`;
}
throw new AuthError("Unauthorized", 401);
}
if (response.status === 403) {
throw new AuthError("Forbidden: insufficient permissions", 403);
}
return response;
};
}
export async function authFetch(url, options = {}) {
const fullUrl = url.startsWith("http") ? url : url;
const token = getToken();
const headers = {
"Content-Type": "application/json",
...options.headers,
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(fullUrl, {
...options,
headers,
});
if (response.status === 401) {
clearToken();
const returnUrl = encodeURIComponent(window.location.pathname + window.location.search);
window.location.href = `/login?redirect=${returnUrl}`;
throw new AuthError("Unauthorized", 401);
}
if (response.status === 403) {
throw new AuthError("Forbidden: insufficient permissions", 403);
}
return response;
}
export class AuthError extends Error {
constructor(message, status) {
super(message);
this.name = "AuthError";
this.status = status;
}
}

56
web/basic.ts Normal file
View File

@@ -0,0 +1,56 @@
export function capitalizeFirstLetter(input: string): string {
return input.charAt(0).toUpperCase() + input.slice(1);
}
export function toSnakeCase(input: string): string {
return input
.replace(/([a-z])([A-Z])/g, "$1_$2")
.replace(/[\s\-]+/g, "_")
.toLowerCase();
}
export function snakeCaseToTitleCase(input: string): string {
return input
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
}
// Strips non-numeric characters from a string including spaces.
export function sanitizeNum(input: string): string {
return input.replaceAll(/[^\d]+/g, "");
}
// Strips non-alphanumeric, non-space characters from a string.
export function sanitizeAlphaNum(input: string): string {
return input.replaceAll(/[^a-zA-Z0-9 ]+/g, "");
}
// Strips non-alphanumeric characters from a string including spaces.
export function sanitizeAlphaNumStrict(input: string): string {
return input.replaceAll(/[^a-zA-Z0-9]+/g, "");
}
export function numberToStringWithCommas(n: number):string {
var str = n.toString();
var negative = false;
if (str.startsWith("-")) {
negative = true;
str = str.slice(1);
}
var result = "";
for (let i=0; i < str.length; i++) {
if (i > 0 && (str.length - i)%3 === 0) {
result += ",";
}
result += str[i]
}
if (negative) {
result = "-" + result;
}
return result;
}

16
web/env.ts Normal file
View File

@@ -0,0 +1,16 @@
// Compile-time deployment environment: "development" | "staging" | "production".
//
// `__ENV_TYPE__` is substituted at bundle time by esbuild's `define`, fed the Go
// compile-time constant `internal/constants.AppEnvironment` (see the
// `esbuildDefine` helper in internal/bundler). The production bundles and the dev
// HMR per-module transform both define it. The build-time SSR render of the
// public pages does NOT, so the `typeof` guard yields "" there — the env badge
// renders only after the client takeover, matching the prior runtime behavior.
declare const __ENV_TYPE__: string;
export const ENV_TYPE: string = typeof __ENV_TYPE__ !== "undefined" ? __ENV_TYPE__ : "";
// True in every environment except production (and the badge-less SSR render).
export function isNonProdEnv(): boolean {
return ENV_TYPE !== "" && ENV_TYPE !== "production";
}

98
web/finance.ts Normal file
View File

@@ -0,0 +1,98 @@
// Format a number as USD currency.
// If `showChangeIfExists` is false, the cents value is only shown if there are leftover cents
// after converting to dollars (i.e. amount=1000 would be shown as $10, but amount=1001 would be
// shown as $10.01). If true, cents are always displayed.
export function formatCurrency(amount: number, showChangeIfExists: boolean = true): string {
const dollars = amount / 100;
return new Intl.NumberFormat("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
trailingZeroDisplay: (showChangeIfExists ? "auto" : "stripIfInteger"),
useGrouping: false,
}).format(dollars);
}
// // Same as formatMoney() but with commas.
export function formatCurrencyWithCommas(amount: number, showChangeIfExists: boolean = true): string {
const dollars = amount / 100;
return new Intl.NumberFormat("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
trailingZeroDisplay: (showChangeIfExists ? "auto" : "stripIfInteger"),
}).format(dollars);
}
// takes a number such as 123456, and outputs (1234, 56) as strings
export function splitCurrency(amount: number): [string, string] {
// Calculate dollars as a string (without cents)
const dollars = Math.floor(amount / 100).toString();
// Calculate cents as a string with leading zero if necessary;
const cents = (amount % 100).toString().padStart(2, "0");
return [dollars, cents];
}
export function moneyToNumber(input: string): number {
if (!input.includes(".")) {
input += ".00";
} else if (input.split(".").length - 1 === 1) {
const digits = input.split(".");
if (digits[1].length === 1) {
input += "0";
}
}
let processed = input
.replaceAll(".", "")
.replaceAll(",", "")
.replaceAll(" ", "");
return parseInt(processed);
}
export function formatRate(rate: number): string {
return (rate / 1000).toFixed(3);
}
export function processDiscount(discount: string): number {
const discountFloat = parseFloat(discount);
if (discountFloat < 0) {
return 0;
} else if (discountFloat > 100) {
return 100;
}
return discountFloat;
}
// Takes days as an input and outputs the string representation of the number
// of days, months, or years in the term based on which unit is the best fit
// for the amount of days along with the corresponding unit string.
export function daysToRateTerm(days: number): [string, string] {
const years = Math.floor(days / 365);
const months = Math.floor((days - years*365) / 30);
const dayRemainder = days - years*365 - months*30;
if (days == 0) { // Return empty string for value
return ["", "days"];
} else if (days <= 270 || dayRemainder > 0) {
return [days.toString(), "days"]
} else if (months > 0) {
return [(months + years*12).toString(), "months"];
} else {
return [years.toString(), "years"];
}
}
export function rateTermToDays(value: string, unit: string): number {
const valueNum = parseInt(value);
switch (unit) {
case "months":
const years = Math.floor(valueNum / 12);
const months = valueNum % 12;
return years*365 + months*30;
case "years":
return valueNum * 365;
default: // case "days":
return valueNum;
}
}

View File

@@ -0,0 +1,20 @@
import { createEffect, onCleanup } from "solid-js";
const SITE_NAME = "National CD Rateline";
export function createDocumentTitle(title) {
createEffect(() => {
const previousTitle = document.title;
const t = title();
document.title = t ? `${t} | ${SITE_NAME}` : SITE_NAME;
onCleanup(() => {
document.title = previousTitle;
});
});
}
export function PageWithTitle(props) {
createDocumentTitle(() => props.route.title || props.route.label);
return props.children;
}

127
web/kit/Accordion.tsx Normal file
View File

@@ -0,0 +1,127 @@
import { createSignal, createEffect, For, Show, JSXElement } from "solid-js";
import { Icon } from "./Icons.tsx";
// Baseline Tailwind for the scoped .ui-accordion stack. The ui-* class
// names are kept so page-specific CSS overrides (e.g. sale.css,
// licensing.css) can continue to layer on top.
const ROOT = "ui-accordion border border-neutral-200 rounded-default overflow-hidden";
const ITEM = "ui-accordion-item border-b border-neutral-200 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-neutral-900 bg-neutral-50 cursor-pointer border-none transition-colors hover:bg-neutral-100 active:bg-neutral-200 focus:outline-hidden disabled:text-neutral-400 disabled:cursor-not-allowed disabled:bg-neutral-50";
const TITLE = "ui-accordion-title flex-1";
const CONTENT = "ui-accordion-content px-4 pb-4 text-neutral-700";
function iconCls(open: boolean): string {
return "ui-accordion-icon text-neutral-500 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
web/kit/Alerts.tsx Normal file
View File

@@ -0,0 +1,44 @@
import { JSXElement, Show } from "solid-js";
type AlertColor = "white" | "gray" | "blue" | "green" | "red" | "yellow";
interface AlertProps {
header?: string;
class?: string;
children: JSXElement;
}
const BASE = "p-4 rounded-default shadow-xs border";
const COLORS: Record<AlertColor, string> = {
white: "bg-white border-neutral-100",
gray: "bg-neutral-50 border-neutral-200",
blue: "bg-sky-50 border-sky-200",
green: "bg-green-50 border-green-200",
red: "bg-red-50 border-red-200",
yellow: "bg-yellow-50 border-yellow-200",
};
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");

4213
web/kit/AutoTable.tsx Normal file

File diff suppressed because it is too large Load Diff

53
web/kit/Badges.tsx Normal file
View File

@@ -0,0 +1,53 @@
import { JSXElement } from "solid-js";
export const BADGE_GREEN = "green";
export const BADGE_RED = "red";
export const BADGE_BLUE = "blue";
export const BADGE_AMBER = "amber";
export const BADGE_NEUTRAL = "neutral";
export const BADGE_MUTED = "muted";
const BASE = "inline-flex items-center gap-1 text-xs font-semibold py-0.5 px-2 rounded-default whitespace-nowrap";
const COLORS: Record<string, string> = {
"green": "text-white bg-green-700",
"red": "text-white bg-red-700",
"blue": "text-white bg-sky-800",
"amber": "text-white bg-amber-700",
"neutral": "text-white bg-neutral-500",
"muted": "text-neutral-400 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>;
}

191
web/kit/Buttons.tsx Normal file
View File

@@ -0,0 +1,191 @@
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> = {
"neutral": "shadow-xs bg-neutral-700 text-white hover:bg-neutral-800",
"white": "shadow-xs bg-white text-black border border-neutral-300 hover:bg-neutral-50",
"light-neutral": "shadow-xs bg-neutral-50 text-black border border-neutral-300 hover:bg-neutral-100",
"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-neutral-100 text-neutral-700 border border-neutral-300 hover:bg-neutral-200",
"ghost": "shadow-none bg-transparent text-neutral-600 border-none hover:bg-neutral-100",
};
const OUTLINE_COLORS: Record<string, string> = {
"neutral": "text-neutral-700",
"white": "text-neutral-300",
"light-neutral": "text-neutral-300",
"blue": "text-sky-700",
"dark-blue": "text-sky-900",
"green": "text-green-700",
"dark-green": "text-green-900",
"red": "text-red-700",
"dark-red": "text-red-900",
"yellow": "text-yellow-700",
"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 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 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-white text-text-heading shadow-sm";
const inactiveCls = "text-neutral-500 hover:text-neutral-700";
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-neutral-100 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-neutral-600 hover:text-neutral-900");
return (
<a href={props.href} class={cls()}>
<Icon icon="chevron-left" size={16}/>
{props.text}
</a>
);
}

211
web/kit/Calendar.tsx Normal file
View File

@@ -0,0 +1,211 @@
import { createSignal, createMemo, For, createEffect, JSXElement } from "solid-js";
import { Icon } from "./Icons.tsx";
const DAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
const MONTHS = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"
];
// -- Shared Tailwind class constants (also used by DatePicker) --
export const CAL_PICKER_ROOT = "p-2 min-w-[240px]";
export const CAL_MONTH_ROOT = "p-0 min-w-0 w-full bg-white border border-neutral-200 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-neutral-200 bg-neutral-50";
export const CAL_NAV_BTN = "bg-transparent border-0 p-1 cursor-pointer text-text-muted rounded-sm flex items-center justify-center hover:bg-neutral-100 hover:text-text-body";
export const CAL_MY_PICKER = "text-sm font-semibold text-text-heading mx-3 whitespace-nowrap";
export const CAL_MY_MONTH = "text-lg font-heading mx-4 flex-1 text-center font-semibold text-text-heading 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-neutral-200";
export const CAL_WEEKDAY_PICKER = "text-center text-xs font-semibold text-text-muted p-1";
export const CAL_WEEKDAY_MONTH = "text-center text-xs font-semibold text-text-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-text-body 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-neutral-200 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-neutral-200 rounded-sm bg-white text-text-heading 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-neutral-50 cursor-default";
else c += " hover:bg-neutral-50";
if (selected) c += " bg-primary/10 text-text-body";
return c;
}
let c = CAL_DAY_PICKER_BASE;
if (empty) c += " cursor-default";
else c += " hover:bg-neutral-100";
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-text-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
web/kit/Cards.tsx Normal file
View File

@@ -0,0 +1,98 @@
import { JSXElement } from "solid-js";
interface CardProps {
class?: string;
children?: JSXElement;
}
// `ui-card` / `no-flex` class names are kept so page-specific CSS (e.g.
// support.css) that targets them can keep overriding. All baseline
// styling is Tailwind.
const CARD_BASE = "ui-card bg-white 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-neutral-300 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-neutral-300 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-white 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-black mb-5";
const CARD_HEADER_HR = "text-neutral-200 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-black mb-2 " + (props.class || "")}>
{props.children}
</div>
);
}
export function CardSpacer() {
return <div class="mb-6"></div>;
}

434
web/kit/CellGrid.tsx Normal file
View File

@@ -0,0 +1,434 @@
import { createSignal, createMemo, untrack, Show, For, JSXElement } from "solid-js";
import { Icon } from "./Icons.tsx";
export const GRID_HEADER_CLS = "border-b border-r border-neutral-300 bg-neutral-50 px-1.5 py-1.5 text-left text-xs font-bold uppercase text-black 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-neutral-200"
+ (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-neutral-300 bg-black/5 px-2 text-neutral-700 align-middle " + rowHCls();
const editableTdCls = () => "border-b border-r border-neutral-300 p-0 relative align-middle";
const inputCls = "absolute inset-0 w-full border-none outline-none bg-transparent px-2 placeholder:text-neutral-400 focus:bg-red-50 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";
}
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" 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-neutral-300 rounded-default bg-white 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-white even:bg-neutral-100">
<For each={props.columns}>{(col) => renderCell(row, col)}</For>
</tr>
)}</For>
</tbody>
</table>
</div>
);
}

91
web/kit/Chart.tsx Normal file
View File

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

166
web/kit/CrmTabs.tsx Normal file
View File

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

542
web/kit/DatePicker.tsx Normal file
View File

@@ -0,0 +1,542 @@
import { createSignal, createEffect, Show, onMount, onCleanup, For } from "solid-js";
import { Portal } from "solid-js/web";
import { Icon } from "./Icons.tsx";
import { FormInput } from "./Forms.tsx";
import { readAccessor, accessor, type MaybeAccessor } from "../utils/accessors.ts";
import {
CAL_PICKER_ROOT,
CAL_HEADER_PICKER,
CAL_NAV_BTN,
CAL_MY_PICKER,
CAL_WEEKDAYS_PICKER,
CAL_WEEKDAY_PICKER,
CAL_DAYS_PICKER,
CAL_DAY_PICKER_BASE,
CAL_SELECT,
} from "./Calendar.tsx";
const DAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
function getDaysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
function getFirstDayOfMonth(year: number, month: number): number {
return new Date(year, month, 1).getDay();
}
function dayClass(date: Date | null, selected: boolean, today: boolean): string {
let c = CAL_DAY_PICKER_BASE;
if (!date) c += " cursor-default";
else c += " hover:bg-neutral-100";
if (today) c += " font-bold text-primary";
if (selected) c += " !bg-primary !text-white";
return c;
}
/** Parse typed or pasted text into YYYY-MM-DD, or "" if invalid. */
function parseDateInput(text: string): string {
const trimmed = text.trim();
if (!trimmed) return "";
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
const [y, m, d] = trimmed.split("-").map((n) => parseInt(n, 10));
const iso = new Date(y, m - 1, d);
if (!isNaN(iso.getTime()) && iso.getFullYear() === y && iso.getMonth() === m - 1 && iso.getDate() === d) {
return trimmed;
}
}
const parsed = new Date(trimmed);
if (!isNaN(parsed.getTime())) {
const y = parsed.getFullYear();
const m = String(parsed.getMonth() + 1).padStart(2, "0");
const d = String(parsed.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
return "";
}
function formatDisplayDate(iso: string): string {
if (!iso) return "";
const parts = iso.split("-");
if (parts.length < 3) return iso;
const d = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10));
if (isNaN(d.getTime())) return "";
return d.toLocaleDateString();
}
interface CalendarDropdownProps {
selected?: MaybeAccessor<string>;
onSelect?: (key: string) => void;
}
function CalendarDropdown(props: CalendarDropdownProps) {
const today = new Date();
const selected = () => readAccessor(props.selected, "");
const [viewDate, setViewDate] = createSignal(selected() ? new Date(selected()) : today);
const [key, setKey] = createSignal(0);
createEffect(() => {
const v = selected();
if (v) {
const d = new Date(v);
if (!isNaN(d.getTime())) {
setViewDate(d);
}
}
});
const currentMonth = () => viewDate().getMonth();
const currentYear = () => viewDate().getFullYear();
const getDays = () => {
const year = currentYear();
const month = currentMonth();
const daysInMonth = getDaysInMonth(year, month);
const firstDay = getFirstDayOfMonth(year, month);
const daysArray: (Date | null)[] = [];
for (let i = 0; i < firstDay; i++) {
daysArray.push(null);
}
for (let i = 1; i <= daysInMonth; i++) {
daysArray.push(new Date(year, month, i));
}
return daysArray;
};
const isSelected = (date: Date | null): boolean => {
if (!date) return false;
const v = selected();
if (!v) return false;
const sel = new Date(v);
if (isNaN(sel.getTime())) return false;
return date.getFullYear() === sel.getFullYear() &&
date.getMonth() === sel.getMonth() &&
date.getDate() === sel.getDate();
};
const isToday = (date: Date | null): boolean => {
if (!date) return false;
return date.getFullYear() === today.getFullYear() &&
date.getMonth() === today.getMonth() &&
date.getDate() === today.getDate();
};
const selectDate = (date: Date | null) => {
if (!date) return;
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
props.onSelect?.(`${y}-${m}-${d}`);
};
const goPrev = (e: MouseEvent) => {
e.stopPropagation();
setViewDate(new Date(currentYear(), currentMonth() - 1, 1));
setKey(k => k + 1);
};
const goNext = (e: MouseEvent) => {
e.stopPropagation();
setViewDate(new Date(currentYear(), currentMonth() + 1, 1));
setKey(k => k + 1);
};
return (
<div class={CAL_PICKER_ROOT} attr:key={key()}>
<div class={CAL_HEADER_PICKER}>
<button type="button" class={CAL_NAV_BTN} onclick={goPrev}>
<Icon icon="chevron-left" size={16}/>
</button>
<span class={CAL_MY_PICKER}>{MONTHS[currentMonth()]}&nbsp;{currentYear()}</span>
<button type="button" class={CAL_NAV_BTN} onclick={goNext}>
<Icon icon="chevron-right" size={16}/>
</button>
</div>
<div class={CAL_WEEKDAYS_PICKER}>
<For each={DAYS}>{(day) => <div class={CAL_WEEKDAY_PICKER}>{day}</div>}</For>
</div>
<div class={CAL_DAYS_PICKER}>
<For each={getDays()}>{(date) => (
<button type="button" class={dayClass(date, isSelected(date), isToday(date))} onclick={(e: MouseEvent) => { e.stopPropagation(); selectDate(date); }} disabled={!date}>
{date ? date.getDate() : ""}
</button>
)}</For>
</div>
</div>
);
}
const DATE_PICKER_WRAP = "relative w-full min-w-0";
const DATE_PICKER_FIELD = "relative w-full min-w-0 cursor-pointer [&_.ui-form]:m-0 [&_input]:cursor-text";
const DATE_PICKER_DROPDOWN = "bg-white border border-neutral-200 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-text-muted leading-none hover:text-text-body 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-text-muted leading-none hover:text-text-body 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
web/kit/EnvBadge.tsx Normal file
View File

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

397
web/kit/Floating.tsx Normal file
View File

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

120
web/kit/Formatters.ts Normal file
View File

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

1898
web/kit/Forms.tsx Normal file

File diff suppressed because it is too large Load Diff

321
web/kit/FuzzyMatch.tsx Normal file
View File

@@ -0,0 +1,321 @@
import { createSignal, createMemo, createEffect, onCleanup, For, Show } from "solid-js";
import { Portal } from "solid-js/web";
// ============================================================================
// Fuzzy matching (Sublime-style subsequence scoring)
// ============================================================================
// Port of Forrest Smith's fts_fuzzy_match. Every query char must appear in the
// target in order; the match is scored so that word-boundary / acronym hits
// ("nfcu" -> "Navy Federal Credit Union") outrank scattered ones. When a query
// char matches, we also recurse past it in case a later occurrence scores higher.
// The matcher functions are exported so other parts of the UI can rank/highlight
// without mounting the component.
export interface FuzzyMatchResult {
score: number;
positions: number[];
}
export interface FuzzySegment {
text: string;
match: boolean;
}
export interface FuzzyRankedItem {
value: string;
score: number;
segments: FuzzySegment[];
}
const FUZZY_SEQUENTIAL_BONUS = 15;
const FUZZY_SEPARATOR_BONUS = 30;
const FUZZY_CAMEL_BONUS = 30;
const FUZZY_FIRST_LETTER_BONUS = 15;
const FUZZY_LEADING_PENALTY = -5;
const FUZZY_MAX_LEADING_PENALTY = -15;
const FUZZY_UNMATCHED_PENALTY = -1;
const FUZZY_RECURSION_LIMIT = 10;
const FUZZY_TRANSPOSE_PENALTY = -20;
const FUZZY_EXACT_SUBSTRING_BONUS = 100;
const isLower = (c: string) => c >= "a" && c <= "z";
const isUpper = (c: string) => c >= "A" && c <= "Z";
const isSeparator = (c: string) => c === " " || c === "_" || c === "-";
function fuzzyScore(target: string, matches: number[]): number {
let score = 100;
score += Math.max(FUZZY_MAX_LEADING_PENALTY, FUZZY_LEADING_PENALTY * matches[0]);
score += FUZZY_UNMATCHED_PENALTY * (target.length - matches.length);
for (let i = 0; i < matches.length; i++) {
const curr = matches[i];
if (i > 0 && curr === matches[i - 1] + 1) score += FUZZY_SEQUENTIAL_BONUS;
if (curr === 0) {
score += FUZZY_FIRST_LETTER_BONUS;
} else {
const prev = target[curr - 1];
if (isLower(prev) && isUpper(target[curr])) score += FUZZY_CAMEL_BONUS;
if (isSeparator(prev)) score += FUZZY_SEPARATOR_BONUS;
}
}
return score;
}
function fuzzyRecurse(query: string, target: string, qi: number, ti: number, matches: number[], rec: { count: number }): number[] | null {
if (++rec.count >= FUZZY_RECURSION_LIMIT) return null;
let best: number[] | null = null;
while (qi < query.length && ti < target.length) {
if (query[qi].toLowerCase() === target[ti].toLowerCase()) {
const skipped = fuzzyRecurse(query, target, qi, ti + 1, matches.slice(), rec);
if (skipped && (!best || fuzzyScore(target, skipped) > fuzzyScore(target, best))) best = skipped;
matches.push(ti);
qi++;
}
ti++;
}
if (qi < query.length) return best; // query not fully consumed -> this path failed
if (!best || fuzzyScore(target, matches) > fuzzyScore(target, best)) return matches;
return best;
}
export function fuzzyMatch(query: string, target: string): FuzzyMatchResult | null {
if (!query) return null;
const matches = fuzzyRecurse(query, target, 0, 0, [], { count: 0 });
if (!matches) return null;
let score = fuzzyScore(target, matches);
// A contiguous substring hit ("bankof" in "Bankof") should outrank a
// word-boundary match split across tokens ("Bank of America"). The bonus is
// constant per query/target, so it lives here rather than in the per-
// alignment scorer the recursion uses to pick match positions.
if (target.toLowerCase().includes(query.toLowerCase())) score += FUZZY_EXACT_SUBSTRING_BONUS;
return { score, positions: matches };
}
// Subsequence matching can't tolerate a transposed typo ("teh" vs "the") because
// the swapped letters violate ordering. So also try every single adjacent-swap
// variant of the query and keep the best, penalizing transposed hits so exact
// matches still rank first.
export function fuzzyMatchTypoTolerant(query: string, target: string): FuzzyMatchResult | null {
let best = fuzzyMatch(query, target);
for (let i = 0; i < query.length - 1; i++) {
const swapped = query.slice(0, i) + query[i + 1] + query[i] + query.slice(i + 2);
const m = fuzzyMatch(swapped, target);
if (!m) continue;
const score = m.score + FUZZY_TRANSPOSE_PENALTY;
if (!best || score > best.score) best = { score, positions: m.positions };
}
return best;
}
// Split `text` into alternating matched / unmatched runs for highlighting.
export function fuzzySegments(text: string, positions: number[]): FuzzySegment[] {
const matched = new Set(positions);
const segments: FuzzySegment[] = [];
let buf = "";
let bufMatch = matched.has(0);
for (let i = 0; i < text.length; i++) {
const isMatch = matched.has(i);
if (isMatch !== bufMatch) {
if (buf) segments.push({ text: buf, match: bufMatch });
buf = "";
bufMatch = isMatch;
}
buf += text[i];
}
if (buf) segments.push({ text: buf, match: bufMatch });
return segments;
}
// Rank `options` against `query`, best score first, with highlight segments.
// Returns [] for an empty query. This is the headless entry point.
export function rankFuzzyMatches(query: string, options: string[], maxResults?: number): FuzzyRankedItem[] {
const q = query.trim();
if (!q) return [];
const out: FuzzyRankedItem[] = [];
for (const value of options) {
const m = fuzzyMatchTypoTolerant(q, value);
if (m) out.push({ value, score: m.score, segments: fuzzySegments(value, m.positions) });
}
out.sort((a, b) => b.score - a.score);
return maxResults != null ? out.slice(0, maxResults) : out;
}
// ============================================================================
// Component
// ============================================================================
export type FuzzyMatchDisplay = "list" | "dropdown" | "none";
export interface FuzzyMatchProps {
options: string[];
// "list": inline highlighted results below the input (default).
// "dropdown": ComboBox-style autocomplete popover.
// "none": render only the input and emit via onResults (headless).
display?: FuzzyMatchDisplay;
// Show each result's match score. Debug aid — off by default.
showScores?: boolean;
maxResults?: number;
placeholder?: string;
class?: string;
listClass?: string;
// Emit the ranked results on every change so other UI can consume them.
onResults?: (results: FuzzyRankedItem[]) => void;
onSelect?: (value: string, item: FuzzyRankedItem) => void;
onQueryChange?: (query: string) => void;
}
const INPUT_CLS = "bg-white block w-full border border-neutral-300 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-white border border-neutral-300 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-neutral-100 whitespace-nowrap";
const DROPDOWN_OPTION_HIGHLIGHT_CLS = "bg-neutral-100";
function Highlight(props: { segments: FuzzySegment[] }) {
return <For each={props.segments}>
{(seg) => seg.match
? <span class="text-sky-700 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-neutral-400">{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-neutral-500 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-neutral-100"
onclick={() => select(r)}
>
<span class="text-sm text-neutral-800"><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-neutral-800"><Highlight segments={r.segments} /></span>
<ScoreBadge score={r.score} />
</button>}
</For>
</div>
</Portal>
</Show>
</div>;
}

119
web/kit/General.tsx Normal file
View File

@@ -0,0 +1,119 @@
import { For, JSXElement, Show } from "solid-js";
import { A } from "@solidjs/router";
import { Icon } from "./Icons.tsx";
interface PageContainerProps {
children?: JSXElement;
}
export function PageContainer(props: PageContainerProps) {
return <div class="admin-page-container">{props.children}</div>;
}
export function Divider() {
return <hr class="text-neutral-200 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-neutral-800 mb-2">{props.text}</h1>
<hr class="text-neutral-200 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 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() {
return (
<div class="flex items-center justify-center p-8">
<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-neutral-400 text-xs">
<For each={props.items}>{(crumb, index) => (
index() !== props.items.length - 1
? (
<span class="flex items-center">
<A href={crumb.url} class="text-neutral-500 cursor-pointer no-underline hover:text-neutral-700 hover:underline">{crumb.displayText}</A>
<Icon icon="chevron-right" size={12} class="mx-[0.15rem] opacity-50"/>
</span>
)
: (
<span class="text-neutral-700 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
web/kit/Icons.tsx Normal file
View File

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

@@ -0,0 +1,328 @@
import { createContext, useContext, createSignal, createEffect, onCleanup, Show, JSXElement } from "solid-js";
import { FloatingRoot, FloatingTrigger, FloatingContent, useFloatingContext, useFloatingHover, Placement } from "./Floating.tsx";
import { Icon } from "./Icons.tsx";
// Tailwind utility class groups for the menu UI — replaces the old
// `.ui-menu` / `.item` / `.divider` / `.section` @scope CSS. These
// are plain utility strings so they compose with any caller-supplied
// classes via simple concatenation.
// Padded container + inset, rounded items (the highlight is a rounded rectangle
// that doesn't reach the menu edges) — matching the popover insert menus.
const MENU_CLS = "bg-white rounded-default shadow-lg border border-neutral-200 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-neutral-700 bg-transparent border-0 cursor-pointer hover:bg-neutral-100 hover:text-neutral-900 focus:bg-neutral-100 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-neutral-200";
const SECTION_CLS = "pt-1.5 pb-0.5 px-2 text-[10px] font-semibold text-neutral-400 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-neutral-700 cursor-pointer hover:bg-neutral-100 hover:text-neutral-900";
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-neutral-400"/>
</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-neutral-400"/>
</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
web/kit/Modal.tsx Normal file
View File

@@ -0,0 +1,580 @@
import { Portal } from "solid-js/web";
import { createContext, useContext, createSignal, createEffect, onCleanup, createMemo, Show, For, JSXElement } from "solid-js";
import { Icon } from "./Icons.tsx";
export type ModalSize = "small" | "default" | "medium" | "large" | "xlarge" | "2xlarge" | "3xlarge" | "4xlarge" | "5xlarge" | "full";
export const MODAL_SMALL = "small";
export const MODAL_DEFAULT = "default";
export const MODAL_MEDIUM = "medium";
export const MODAL_LARGE = "large";
export const MODAL_XLARGE = "xlarge";
export const MODAL_2XLARGE = "2xlarge";
export const MODAL_3XLARGE = "3xlarge";
export const MODAL_4XLARGE = "4xlarge";
export const MODAL_5XLARGE = "5xlarge";
export const MODAL_FULL = "full";
const ANIMATION_DURATION = 100;
type Reactive<T> = T | (() => T);
interface ModalOptions {
size?: ModalSize;
centerOnScreen?: boolean;
}
interface ModalContextValue {
openModal: (content: JSXElement, options?: ModalOptions) => void;
closeModal: () => void;
isOpen: () => boolean;
}
const ModalContext = createContext<ModalContextValue | null>(null);
const resolve = <T,>(val: Reactive<T>): T => typeof val === "function" ? (val as () => T)() : val;
export function useModal(): ModalContextValue {
const context = useContext(ModalContext);
if (!context) {
throw new Error("useModal must be used within a ModalProvider");
}
return context;
}
// Shared stack of currently-open modals. Each modal pushes a token while open;
// Escape only dismisses the top-most one, so nested modals (a modal opened from
// inside another) close one layer per press instead of all at once.
const openModalStack: object[] = [];
// While `isOpen()` is true, registers this modal on the shared stack and wires
// up an Escape handler that fires `onEscape` only when this modal is on top.
// Must be called inside a component/reactive owner (uses createEffect/onCleanup).
function useModalEscape(isOpen: () => boolean, onEscape: () => void) {
createEffect(() => {
if (!isOpen()) return;
const token = {};
openModalStack.push(token);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
if (openModalStack[openModalStack.length - 1] !== token) return;
e.preventDefault();
onEscape();
};
document.addEventListener("keydown", handleKeyDown);
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown);
const idx = openModalStack.indexOf(token);
if (idx !== -1) openModalStack.splice(idx, 1);
});
});
}
// -- Tailwind class constants --------------------------------------
const CONTAINER_BASE = "fixed inset-0 z-[100] w-full h-dvh m-0 border-0 bg-transparent flex justify-center max-w-screen max-h-dvh";
const CONTAINER_TOP = "items-start pt-10";
const CONTAINER_CENTER = "items-center";
const BACKDROP = "fixed inset-0 bg-black/30";
const MODAL_BASE = "relative bg-white 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-neutral-200 text-lg font-semibold text-text-heading";
const HEADER_CLOSE_ONLY = "flex items-center justify-end p-4 pb-1";
const CLOSE_BTN = "cursor-pointer text-neutral-500 bg-transparent border-0 p-0 leading-none hover:text-neutral-700";
const BODY = "px-7 py-6 overflow-y-auto flex-1 min-h-0 [background:linear-gradient(var(--color-white),var(--color-white))_bottom_/_100%_3rem_no-repeat_local,linear-gradient(to_bottom,transparent,var(--color-white))_bottom_/_100%_3rem_no-repeat_scroll,var(--color-white)]";
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-neutral-200 bg-neutral-50 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 bg-red-50 border-t border-red-200";
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-neutral-300 rounded-default bg-transparent cursor-pointer hover:bg-neutral-50";
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-neutral-600 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-neutral-200 -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-neutral-300 text-neutral-400 bg-white";
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-neutral-300 text-neutral-700 enabled:hover:bg-neutral-50";
const WIZARD_BTN_NEXT = "bg-neutral-800 text-white enabled:hover:bg-neutral-900";
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
web/kit/Popovers.tsx Normal file
View File

@@ -0,0 +1,115 @@
import { createContext, JSXElement, useContext } from "solid-js";
import { FloatingRoot, FloatingTrigger, FloatingContent, useFloatingHover, Placement } from "./Floating.tsx";
const POPOVER_CLS = "bg-white rounded-default shadow-lg border border-neutral-200";
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
web/kit/PrettyTable.tsx Normal file
View File

@@ -0,0 +1,126 @@
import { createMemo, For } from "solid-js";
import {
AUTOTABLE_HEADER_COLOR_BLUE,
AUTOTABLE_HEADER_COLOR_DARK_BLUE,
AUTOTABLE_HEADER_COLOR_DEFAULT,
AUTOTABLE_HEADER_COLOR_GRAY,
AUTOTABLE_HEADER_COLOR_GREEN,
AUTOTABLE_SIZE_DEFAULT,
BODY_PADDING_CLS,
COL_POS_LEFT,
HEADER_COLOR_CLS,
HEADER_CONTENT,
HEADER_INNER_BASE,
HEADER_INNER_POS,
HEADER_PADDING_CLS,
HEADER_TEXT_CLS,
POS_CLS,
TBL_BASE,
TBL_CONTAINER,
TBL_WRAPPER,
type AutoTableHeaderColor,
type AutoTableSize,
type ColumnPosition,
} from "./AutoTable.tsx";
const PT_ROW_HOVER_CLS: Record<AutoTableHeaderColor, string> = {
[AUTOTABLE_HEADER_COLOR_DEFAULT]: "[&_tr:hover]:!bg-neutral-200",
[AUTOTABLE_HEADER_COLOR_BLUE]: "[&_tr:hover]:!bg-sky-100",
[AUTOTABLE_HEADER_COLOR_GREEN]: "[&_tr:hover]:!bg-green-100",
[AUTOTABLE_HEADER_COLOR_GRAY]: "[&_tr:hover]:!bg-neutral-200",
[AUTOTABLE_HEADER_COLOR_DARK_BLUE]: "[&_tr:hover]:!bg-sky-100",
};
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-neutral-300";
if (opts().alternate) c += " [&_tr:nth-child(even)]:bg-neutral-100";
if (opts().borderX) c += " [&_tr:not(:last-child)]:border-b [&_tr:not(:last-child)]:border-neutral-300";
if (opts().hover) c += " " + PT_ROW_HOVER_CLS[opts().color];
return c;
};
return (
<div class={TBL_CONTAINER
+ (opts().surroundingBorder ? " border border-neutral-300" : "")
+ (opts().shadow ? " shadow-sm" : "")}>
<div class={TBL_WRAPPER}>
<table class={TBL_BASE + (opts().tableLayoutAuto ? "" : " table-fixed")}>
<thead class="[&_th]:border-b [&_th]:border-neutral-300">
<tr>
<For each={props.columns}>
{(col: PrettyTableColumn, displayIdx: () => number) => {
const pos = col.displayPosition ?? COL_POS_LEFT;
const posCls = POS_CLS[pos];
const headerInnerPosCls = HEADER_INNER_POS[pos];
return (
<th
class={HEADER_PADDING_CLS[opts().size] + " " + HEADER_COLOR_CLS[opts().color]
+ (posCls ? " " + posCls : "")
+ (opts().headerBorderY && displayIdx() > 0 ? " border-l border-l-neutral-300" : "")
+ (col.headerClasses ? " " + col.headerClasses : "")}
>
<div class={HEADER_CONTENT}>
<div class={HEADER_INNER_BASE + (headerInnerPosCls ? " " + headerInnerPosCls : "")}>
<div class={"grow text-sm " + HEADER_TEXT_CLS[opts().color]}>
{col.displayName}
</div>
</div>
</div>
</th>
);
}}
</For>
</tr>
</thead>
<tbody class={bodyClass()}>
{props.children}
</tbody>
</table>
</div>
</div>
);
}
export default PrettyTable;

View File

@@ -0,0 +1,38 @@
import { createSignal, Show } from "solid-js";
/**
* Creates a trigger/signal pair for showing a brief "remote update" flash.
* Call `fire()` when a remote WebSocket update arrives; `visible()` goes
* true for `durationMs` then auto-clears.
*/
export function createRemoteFlash(durationMs = 3000) {
const [visible, setVisible] = createSignal(false);
let timer: ReturnType<typeof setTimeout> | null = null;
const fire = () => {
setVisible(true);
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
setVisible(false);
timer = null;
}, durationMs);
};
return { visible, fire };
}
interface RemoteUpdateFlashProps {
when: boolean;
}
/**
* A small pill that briefly shows "Updated".
*/
export function RemoteUpdateFlash(props: RemoteUpdateFlashProps) {
return <Show when={props.when}>
<div class="remote-update-flash inline-flex items-center gap-1 rounded-full bg-emerald-100 border border-emerald-300 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-widest text-emerald-700">
<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
web/kit/Sidebar.tsx Normal file
View File

@@ -0,0 +1,103 @@
import { For, createSignal, Show, JSXElement } from "solid-js";
const NAV_ROOT = "bg-white rounded-default shadow-sm border border-neutral-200 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-neutral-600 border-0 hover:text-neutral-900 hover:bg-neutral-50";
const NAV_SUBBTN = "w-full text-left py-1.5 pr-3 pl-8 text-xs cursor-pointer bg-transparent text-neutral-500 border-0 hover:text-neutral-900 hover:bg-neutral-50";
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
web/kit/Tabs.tsx Normal file
View File

@@ -0,0 +1,172 @@
import { createSignal, onCleanup, onMount, Show, For, JSXElement } from "solid-js";
interface TabItem {
title: string;
badge?: number;
content?: JSXElement;
}
interface TabGroupProps {
items: TabItem[];
storageKey?: string;
activeIndex?: number;
onTabChange?: (index: number) => void;
defaultIndex?: number;
/** Optional content rendered in the right side of the tab bar (e.g. a PillSelect). */
actions?: JSXElement;
/** Extra classes on the root element (e.g. `ui-tabs` for structured panel CSS). */
class?: string;
/** When true, tabs fill available height and panels scroll internally (mobile POS). */
fill?: boolean;
/** When true, tab buttons share the header row equally below md. Defaults to true when `actions` is omitted. */
stretch?: boolean;
/** Tighter spacing for tabs above sibling panel content (use with PageLayout `tabs`). */
pageTabs?: boolean;
}
const TAB_BASE = "flex items-center gap-1.5 cursor-pointer py-2 px-4 text-sm font-medium bg-transparent border-0 border-b-2 transition-[color,border-color] duration-150";
const TAB_INACTIVE = "text-text-muted border-neutral-200 hover:text-text-body";
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-neutral-200 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>
);
}

216
web/kit/Toast.tsx Normal file
View File

@@ -0,0 +1,216 @@
import { createContext, useContext, createSignal, createEffect, onCleanup, For, Show, JSXElement } from "solid-js";
import { Icon } from "./Icons.tsx";
export type ToastType = "success" | "error" | "warning" | "info" | "generic";
export type ToastPosition = "top-right" | "top-left" | "bottom-right" | "bottom-left" | "top-center" | "bottom-center";
export interface ToastConfig {
message: string;
type?: ToastType;
duration?: number | null;
dismissible?: boolean;
showProgress?: boolean;
}
interface ToastInstance extends ToastConfig {
id: string;
}
interface ToastContextValue {
addToast: (config: ToastConfig) => string;
removeToast: (id: string) => void;
success: (message: string, options?: Partial<ToastConfig>) => string;
error: (message: string, options?: Partial<ToastConfig>) => string;
warning: (message: string, options?: Partial<ToastConfig>) => string;
info: (message: string, options?: Partial<ToastConfig>) => string;
generic: (message: string, options?: Partial<ToastConfig>) => string;
}
interface ToastProviderProps {
position?: ToastPosition;
maxToasts?: number;
children?: JSXElement;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function useToast(): ToastContextValue {
const context = useContext(ToastContext);
if (!context) {
throw new Error("useToast must be used within a ToastProvider");
}
return context;
}
const TOAST_TYPE_ICONS: Record<ToastType, string | null> = {
success: "circle-check",
error: "circle-xmark",
warning: "triangle-exclamation",
info: "circle-info",
generic: null,
};
const CONTAINER_BASE = "fixed z-[200] flex flex-col gap-2";
const CONTAINER_POSITIONS: Record<ToastPosition, string> = {
"top-right": "top-4 right-4",
"top-left": "top-4 left-4",
"bottom-right": "bottom-4 right-4 flex-col-reverse",
"bottom-left": "bottom-4 left-4 flex-col-reverse",
"top-center": "top-4 left-1/2 -translate-x-1/2",
"bottom-center": "bottom-4 left-1/2 -translate-x-1/2 flex-col-reverse",
};
const TOAST_BASE = "relative overflow-hidden rounded-default shadow-lg border border-neutral-200 border-l-4 bg-white 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",
error: "text-red-600",
warning: "text-yellow-600",
info: "text-sky-700",
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-neutral-800">{props.toast.message}</div>
<Show when={dismissible()}>
<button onclick={handleDismiss} class="shrink-0 cursor-pointer text-neutral-400 hover:text-neutral-600 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-neutral-100">
<div class="h-full bg-neutral-300" 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
web/kit/ToggleSwitch.tsx Normal file
View File

@@ -0,0 +1,61 @@
// ToggleSwitch is a reusable on/off switch styled with Tailwind. It renders a
// real <button role="switch"> so it stays keyboard- and screen-reader-friendly,
// with an optional inline label/description to its right.
//
// Props accept either plain values or zero-arg accessors (the SegmentedButtons
// convention), so callers can pass a signal directly: checked={mySignal}.
type Reactive<T> = T | (() => T);
interface ToggleSwitchProps {
checked: Reactive<boolean>;
onchange: (next: boolean) => void;
label?: Reactive<string>;
description?: Reactive<string>;
disabled?: Reactive<boolean>;
class?: string;
}
const resolve = <T,>(v: Reactive<T>): T => (typeof v === "function" ? (v as () => T)() : v);
export function ToggleSwitch(props: ToggleSwitchProps) {
const isChecked = () => !!resolve(props.checked);
const isDisabled = () => !!resolve(props.disabled);
const toggle = () => {
if (isDisabled()) return;
props.onchange(!isChecked());
};
const trackCls = () =>
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50 "
+ (isChecked() ? "bg-primary" : "bg-neutral-300");
// 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-white 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-neutral-400" : "text-neutral-800")}
onclick={(_e: MouseEvent) => toggle()}
>{resolve(props.label)}</span>}
{props.description !== undefined && <span class="text-xs text-neutral-500">{resolve(props.description)}</span>}
</div>}
</div>;
}

178
web/kit/Tooltips.tsx Normal file
View File

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

685
web/kit/Tutorial.tsx Normal file
View File

@@ -0,0 +1,685 @@
import { createContext, useContext, createSignal, createEffect, onCleanup, Show, For, JSXElement } from "solid-js";
import { Icon } from "./Icons.tsx";
import { ButtonUI, BUTTON_COLOR_WHITE, BUTTON_COLOR_BLUE } from "./Buttons.tsx";
export interface TutorialStep {
title?: string;
content: JSXElement;
target?: string | (() => HTMLElement | null) | null;
placement?: string;
offset?: number;
onEnter?: () => void;
onLeave?: () => void;
}
interface TutorialProviderProps {
steps: TutorialStep[];
spotlightPadding?: number;
onEnd?: () => void;
children?: JSXElement;
}
interface TutorialContextValue {
isActive: boolean;
currentStepIndex: number;
totalSteps: number;
currentStep: TutorialStep | null;
start: (stepIndex?: number) => void;
end: () => void;
next: () => void;
previous: () => void;
goTo: (stepIndex: number) => void;
}
interface TutorialInternalContextValue {
isActive: () => boolean;
currentStepIndex: () => number;
totalSteps: () => number;
currentStep: () => TutorialStep | null;
targetRect: () => DOMRect | null;
start: (stepIndex?: number) => void;
end: () => void;
next: () => void;
previous: () => void;
goTo: (stepIndex: number) => void;
}
const TutorialContext = createContext<TutorialContextValue | null>(null);
const TutorialInternalContext = createContext<TutorialInternalContextValue | null>(null);
const _TUTORIAL_ANIMATION_DURATION = 100;
export function useTutorial(): TutorialContextValue {
const context = useContext(TutorialContext);
if (!context) {
throw new Error("useTutorial must be used within a TutorialProvider");
}
return context;
}
function useTutorialInternal(): TutorialInternalContextValue {
return useContext(TutorialInternalContext)!;
}
interface PopoverPosition {
top: number;
left: number;
placement: string;
}
function calculatePopoverPosition(targetRect: DOMRect, popoverRect: DOMRect, placement: string, offset: number): PopoverPosition {
const parts = placement.split("-");
const basePlacement = parts[0];
const alignment = parts[1] || "center";
let top = 0;
let left = 0;
let finalPlacement = placement;
const padding = 16;
switch (basePlacement) {
case "top":
top = targetRect.top - popoverRect.height - offset;
break;
case "bottom":
top = targetRect.bottom + offset;
break;
case "left":
left = targetRect.left - popoverRect.width - offset;
break;
case "right":
left = targetRect.right + offset;
break;
}
if (basePlacement === "top" || basePlacement === "bottom") {
switch (alignment) {
case "start":
left = targetRect.left;
break;
case "end":
left = targetRect.right - popoverRect.width;
break;
default:
left = targetRect.left + (targetRect.width - popoverRect.width) / 2;
}
} else {
switch (alignment) {
case "start":
top = targetRect.top;
break;
case "end":
top = targetRect.bottom - popoverRect.height;
break;
default:
top = targetRect.top + (targetRect.height - popoverRect.height) / 2;
}
}
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
if (basePlacement === "bottom" && top + popoverRect.height > viewportHeight - padding) {
const flippedTop = targetRect.top - popoverRect.height - offset;
if (flippedTop >= padding) {
top = flippedTop;
finalPlacement = placement.replace("bottom", "top");
}
} else if (basePlacement === "top" && top < padding) {
const flippedTop = targetRect.bottom + offset;
if (flippedTop + popoverRect.height <= viewportHeight - padding) {
top = flippedTop;
finalPlacement = placement.replace("top", "bottom");
}
} else if (basePlacement === "right" && left + popoverRect.width > viewportWidth - padding) {
const flippedLeft = targetRect.left - popoverRect.width - offset;
if (flippedLeft >= padding) {
left = flippedLeft;
finalPlacement = placement.replace("right", "left");
}
} else if (basePlacement === "left" && left < padding) {
const flippedLeft = targetRect.right + offset;
if (flippedLeft + popoverRect.width <= viewportWidth - padding) {
left = flippedLeft;
finalPlacement = placement.replace("left", "right");
}
}
if (left < padding) {
left = padding;
} else if (left + popoverRect.width > viewportWidth - padding) {
left = viewportWidth - popoverRect.width - padding;
}
if (top < padding) {
top = padding;
} else if (top + popoverRect.height > viewportHeight - padding) {
top = viewportHeight - popoverRect.height - padding;
}
return { top, left, placement: finalPlacement };
}
interface SpotlightOverlayProps {
// Solid's `h` auto-invokes zero-arg function props on read — these
// are plain values inside the component body, not accessors.
targetRect: DOMRect | null;
hasTarget: boolean;
padding: number;
onclick?: () => void;
}
interface AnimatedRect {
left: number;
top: number;
width: number;
height: number;
}
function SpotlightOverlay(props: SpotlightOverlayProps) {
const [borderRadius, setBorderRadius] = createSignal(3.2);
const [animatedRect, setAnimatedRect] = createSignal<AnimatedRect | null>(null);
const [overlayOpacity, setOverlayOpacity] = createSignal(0);
createEffect(() => {
const cssValue = getComputedStyle(document.documentElement).getPropertyValue("--radius-default").trim();
if (cssValue) {
const remValue = parseFloat(cssValue);
if (!isNaN(remValue)) {
const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
setBorderRadius(remValue * rootFontSize);
}
}
});
createEffect(() => {
requestAnimationFrame(() => setOverlayOpacity(1));
});
createEffect(() => {
const target = props.targetRect;
if (target) {
setAnimatedRect({
left: target.left,
top: target.top,
width: target.width,
height: target.height,
});
} else {
setAnimatedRect(null);
}
});
const pad = () => props.padding ?? 8;
const baseTransition = `all ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1)`;
return (
<Show when={props.hasTarget && animatedRect()} fallback={
<div class="fixed inset-0 bg-black/50 z-150" onclick={() => props.onclick?.()} style={{
opacity: overlayOpacity(),
transition: `opacity ${_TUTORIAL_ANIMATION_DURATION}ms ease-out`,
}}/>
}>
<div class="fixed z-150 pointer-events-none rounded-default" style={(() => {
const rect = animatedRect()!;
return {
left: (rect.left - pad()) + "px",
top: (rect.top - pad()) + "px",
width: (rect.width + pad() * 2) + "px",
height: (rect.height + pad() * 2) + "px",
"border-radius": borderRadius() + "px",
"box-shadow": `0 0 0 9999px rgba(0, 0, 0, ${0.5 * overlayOpacity()})`,
transition: baseTransition,
};
})()}>
<div class="fixed inset-0 -z-10 cursor-pointer" onclick={() => props.onclick?.()}/>
</div>
</Show>
);
}
interface PopoverArrowProps {
// Solid's `h` auto-invokes zero-arg function props on read.
placement: string;
}
function PopoverArrow(props: PopoverArrowProps) {
const basePlacement = () => props.placement.split("-")[0];
const arrowStyles: Record<string, object> = {
top: {
bottom: "-8px", left: "50%", transform: "translateX(-50%)",
"border-left": "8px solid transparent", "border-right": "8px solid transparent", "border-top": "8px solid white",
},
bottom: {
top: "-8px", left: "50%", transform: "translateX(-50%)",
"border-left": "8px solid transparent", "border-right": "8px solid transparent", "border-bottom": "8px solid white",
},
left: {
right: "-8px", top: "50%", transform: "translateY(-50%)",
"border-top": "8px solid transparent", "border-bottom": "8px solid transparent", "border-left": "8px solid white",
},
right: {
left: "-8px", top: "50%", transform: "translateY(-50%)",
"border-top": "8px solid transparent", "border-bottom": "8px solid transparent", "border-right": "8px solid white",
},
};
const borderArrowStyles: Record<string, object> = {
top: {
bottom: "-9px", left: "50%", transform: "translateX(-50%)",
"border-left": "9px solid transparent", "border-right": "9px solid transparent", "border-top": "9px solid #e5e5e5",
},
bottom: {
top: "-9px", left: "50%", transform: "translateX(-50%)",
"border-left": "9px solid transparent", "border-right": "9px solid transparent", "border-bottom": "9px solid #e5e5e5",
},
left: {
right: "-9px", top: "50%", transform: "translateY(-50%)",
"border-top": "9px solid transparent", "border-bottom": "9px solid transparent", "border-left": "9px solid #e5e5e5",
},
right: {
left: "-9px", top: "50%", transform: "translateY(-50%)",
"border-top": "9px solid transparent", "border-bottom": "9px solid transparent", "border-right": "9px solid #e5e5e5",
},
};
return [
<div class="absolute w-0 h-0" style={{
...borderArrowStyles[basePlacement()],
width: "0",
height: "0",
transition: `all ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1)`,
}}/>,
<div class="absolute w-0 h-0" style={{
...arrowStyles[basePlacement()],
transition: `all ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1)`,
}}/>,
];
}
function TutorialPopover() {
const ctx = useTutorialInternal();
let popoverRef: HTMLDivElement | undefined;
const [position, setPosition] = createSignal<{ top: number; left: number } | null>(null);
const [currentPlacement, setCurrentPlacement] = createSignal("bottom");
const [displayedPlacement, setDisplayedPlacement] = createSignal("bottom");
const [isVisible, setIsVisible] = createSignal(false);
const [isPositioned, setIsPositioned] = createSignal(false);
const [contentOpacity, setContentOpacity] = createSignal(1);
const [displayedStep, setDisplayedStep] = createSignal<TutorialStep | null>(ctx.currentStep());
const [displayedStepIndex, setDisplayedStepIndex] = createSignal(ctx.currentStepIndex());
const [showArrow, setShowArrow] = createSignal(false);
let prevStepIndex = ctx.currentStepIndex();
let isTransitioning = false;
const placement = () => ctx.currentStep()?.placement ?? "bottom";
const offset = () => ctx.currentStep()?.offset ?? 16;
const hasTarget = () => !!ctx.currentStep()?.target;
const displayHasTarget = () => !!displayedStep()?.target;
const updatePosition = (immediate: boolean = false) => {
if (!popoverRef) return;
const popoverRect = popoverRef.getBoundingClientRect();
let newTop, newLeft;
let newPlacement = "bottom";
if (!hasTarget()) {
newTop = (window.innerHeight - popoverRect.height) / 2;
newLeft = (window.innerWidth - popoverRect.width) / 2;
} else if (ctx.targetRect()) {
const newPosition = calculatePopoverPosition(ctx.targetRect()!, popoverRect, placement(), offset());
newTop = newPosition.top;
newLeft = newPosition.left;
newPlacement = newPosition.placement;
} else {
newTop = (window.innerHeight - popoverRect.height) / 2;
newLeft = (window.innerWidth - popoverRect.width) / 2;
}
setPosition({ top: newTop, left: newLeft });
setCurrentPlacement(newPlacement);
if (immediate || !isPositioned()) {
setDisplayedPlacement(newPlacement);
}
};
createEffect(() => {
if (!popoverRef) return;
requestAnimationFrame(() => {
updatePosition(true);
setIsPositioned(true);
requestAnimationFrame(() => {
setIsVisible(true);
setTimeout(() => setShowArrow(true), _TUTORIAL_ANIMATION_DURATION);
});
});
});
createEffect(() => {
const currentIdx = ctx.currentStepIndex();
if (prevStepIndex === currentIdx) return;
prevStepIndex = currentIdx;
if (isTransitioning) return;
isTransitioning = true;
setIsFirstAppearance(false);
setShowArrow(false);
setContentOpacity(0);
setTimeout(() => {
setDisplayedStep(ctx.currentStep());
setDisplayedStepIndex(currentIdx);
requestAnimationFrame(() => {
updatePosition(true);
setTimeout(() => {
setContentOpacity(1);
setTimeout(() => {
setShowArrow(true);
isTransitioning = false;
}, _TUTORIAL_ANIMATION_DURATION / 2);
}, 50);
});
}, _TUTORIAL_ANIMATION_DURATION / 2);
});
createEffect(() => {
ctx.targetRect();
if (!isTransitioning && isPositioned()) {
updatePosition();
setDisplayedPlacement(currentPlacement());
}
});
createEffect(() => {
if (!isPositioned()) return;
const handleUpdate = () => {
if (!isTransitioning) {
updatePosition();
setDisplayedPlacement(currentPlacement());
}
};
window.addEventListener("scroll", handleUpdate, true);
window.addEventListener("resize", handleUpdate);
onCleanup(() => {
window.removeEventListener("scroll", handleUpdate, true);
window.removeEventListener("resize", handleUpdate);
});
});
const isFirstStep = () => displayedStepIndex() === 0;
const isLastStep = () => displayedStepIndex() === ctx.totalSteps() - 1;
const [isFirstAppearance, setIsFirstAppearance] = createSignal(true);
const getPopoverStyle = () => {
const pos = position();
if (!pos) {
return {
visibility: "hidden" as const,
top: "-9999px",
left: "-9999px",
};
}
if (isFirstAppearance()) {
return {
top: pos.top + "px",
left: pos.left + "px",
opacity: isVisible() ? 1 : 0,
transform: isVisible() ? "scale(1)" : "scale(0.95)",
transition: `opacity ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1), transform ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1)`,
};
}
return {
top: pos.top + "px",
left: pos.left + "px",
opacity: isVisible() ? 1 : 0,
transform: isVisible() ? "scale(1)" : "scale(0.95)",
transition: `opacity ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1), transform ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1), top ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1), left ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1)`,
};
};
const getContentStyle = () => ({
opacity: contentOpacity(),
transition: `opacity ${_TUTORIAL_ANIMATION_DURATION / 2}ms ease-out`,
});
return (
<div ref={(el: HTMLDivElement) => popoverRef = el} class="fixed z-200 bg-white rounded-default shadow-lg border border-neutral-200 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-neutral-900">{displayedStep()?.title}</span>
</Show>
<span class="text-xs text-neutral-500">
{(displayedStepIndex() + 1) + " of " + ctx.totalSteps()}
</span>
</div>
<button onclick={() => ctx.end()} class="cursor-pointer text-neutral-400 bg-transparent border-0 p-0 leading-none transition-colors hover:text-neutral-600">
<Icon icon="xmark" size={18}/>
</button>
</div>
<div class="px-4 pb-4 text-sm text-neutral-700">
{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-neutral-300")}/>
)}</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
web/kit/Validation.ts Normal file
View File

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

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020-2022 Ryan Carniato
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
import type { JSX } from "solid-js";
import type { Location, Navigator } from "./types.js";
declare module "solid-js" {
namespace JSX {
interface AnchorHTMLAttributes<T> {
state?: string;
noScroll?: boolean;
replace?: boolean;
preload?: boolean;
link?: boolean;
}
}
}
export interface AnchorProps extends Omit<JSX.AnchorHTMLAttributes<HTMLAnchorElement>, "state"> {
href: string;
replace?: boolean | undefined;
noScroll?: boolean | undefined;
state?: unknown | undefined;
inactiveClass?: string | undefined;
activeClass?: string | undefined;
end?: boolean | undefined;
}
export declare function A(props: AnchorProps): JSX.Element;
export interface NavigateProps {
href: ((args: {
navigate: Navigator;
location: Location;
}) => string) | string;
state?: unknown;
}
export declare function Navigate(props: NavigateProps): null;

View File

@@ -0,0 +1,39 @@
import { createMemo, mergeProps, splitProps } from "solid-js";
import { useHref, useLocation, useNavigate, useResolvedPath } from "./routing.js";
import { normalizePath } from "./utils.js";
export function A(props) {
props = mergeProps({ inactiveClass: "inactive", activeClass: "active" }, props);
const [, rest] = splitProps(props, [
"href",
"state",
"class",
"activeClass",
"inactiveClass",
"end"
]);
const to = useResolvedPath(() => props.href);
const href = useHref(to);
const location = useLocation();
const isActive = createMemo(() => {
const to_ = to();
if (to_ === undefined)
return [false, false];
const path = normalizePath(to_.split(/[?#]/, 1)[0]).toLowerCase();
const loc = decodeURI(normalizePath(location.pathname).toLowerCase());
return [props.end ? path === loc : loc.startsWith(path + "/") || loc === path, path === loc];
});
return (<a {...rest} href={href() || props.href} state={JSON.stringify(props.state)} classList={{
...(props.class && { [props.class]: true }),
[props.inactiveClass]: !isActive()[0],
[props.activeClass]: isActive()[0],
...rest.classList
}} link aria-current={isActive()[1] ? "page" : undefined}/>);
}
export function Navigate(props) {
const navigate = useNavigate();
const location = useLocation();
const { href, state } = props;
const path = typeof href === "function" ? href({ navigate, location }) : href;
navigate(path, { replace: true, state });
return null;
}

View File

@@ -0,0 +1,17 @@
import { JSX } from "solid-js";
import type { Submission, SubmissionStub, NarrowResponse } from "../types.js";
export type Action<T extends Array<any>, U, V = T> = (T extends [FormData | URLSearchParams] | [] ? JSX.SerializableAttributeValue : unknown) & ((...vars: T) => Promise<NarrowResponse<U>>) & {
url: string;
with<A extends any[], B extends any[]>(this: (this: any, ...args: [...A, ...B]) => Promise<NarrowResponse<U>>, ...args: A): Action<B, U, V>;
};
export declare const actions: Map<string, Action<any, any, any>>;
export declare function useSubmissions<T extends Array<any>, U, V>(fn: Action<T, U, V>, filter?: (input: V) => boolean): Submission<T, NarrowResponse<U>>[] & {
pending: boolean;
};
export declare function useSubmission<T extends Array<any>, U, V>(fn: Action<T, U, V>, filter?: (input: V) => boolean): Submission<T, NarrowResponse<U>> | SubmissionStub;
export declare function useAction<T extends Array<any>, U, V>(action: Action<T, U, V>): (...args: Parameters<Action<T, U, V>>) => Promise<NarrowResponse<U>>;
export declare function action<T extends Array<any>, U = void>(fn: (...args: T) => Promise<U>, name?: string): Action<T, U>;
export declare function action<T extends Array<any>, U = void>(fn: (...args: T) => Promise<U>, options?: {
name?: string;
onComplete?: (s: Submission<T, U>) => void;
}): Action<T, U>;

View File

@@ -0,0 +1,163 @@
import { $TRACK, createMemo, createSignal, onCleanup, getOwner } from "solid-js";
import { isServer } from "solid-js/web";
import { useRouter } from "../routing.js";
import { mockBase, setFunctionName } from "../utils.js";
import { cacheKeyOp, hashKey, revalidate, query } from "./query.js";
export const actions = /* #__PURE__ */ new Map();
export function useSubmissions(fn, filter) {
const router = useRouter();
const subs = createMemo(() => router.submissions[0]().filter(s => s.url === fn.base && (!filter || filter(s.input))));
return new Proxy([], {
get(_, property) {
if (property === $TRACK)
return subs();
if (property === "pending")
return subs().some(sub => !sub.result);
return subs()[property];
},
has(_, property) {
return property in subs();
}
});
}
export function useSubmission(fn, filter) {
const submissions = useSubmissions(fn, filter);
return new Proxy({}, {
get(_, property) {
if ((submissions.length === 0 && property === "clear") || property === "retry")
return () => { };
return submissions[submissions.length - 1]?.[property];
}
});
}
export function useAction(action) {
const r = useRouter();
return (...args) => action.apply({ r }, args);
}
export function action(fn, options = {}) {
function mutate(...variables) {
const router = this.r;
const form = this.f;
const p = (router.singleFlight && fn.withOptions
? fn.withOptions({ headers: { "X-Single-Flight": "true" } })
: fn)(...variables);
const [result, setResult] = createSignal();
let submission;
function handler(error) {
return async (res) => {
const result = await handleResponse(res, error, router.navigatorFactory());
let retry = null;
o.onComplete?.({
...submission,
result: result?.data,
error: result?.error,
pending: false,
retry() {
return (retry = submission.retry());
}
});
if (retry)
return retry;
if (!result)
return submission.clear();
setResult(result);
if (result.error && !form)
throw result.error;
return result.data;
};
}
router.submissions[1](s => [
...s,
(submission = {
input: variables,
url,
get result() {
return result()?.data;
},
get error() {
return result()?.error;
},
get pending() {
return !result();
},
clear() {
router.submissions[1](v => v.filter(i => i !== submission));
},
retry() {
setResult(undefined);
const p = fn(...variables);
return p.then(handler(), handler(true));
}
})
]);
return p.then(handler(), handler(true));
}
const o = typeof options === "string" ? { name: options } : options;
const name = o.name || (!isServer ? String(hashString(fn.toString())) : undefined);
const url = fn.url || (name && `https://action/${name}`) || "";
mutate.base = url;
if (name)
setFunctionName(mutate, name);
return toAction(mutate, url);
}
function toAction(fn, url) {
fn.toString = () => {
if (!url)
throw new Error("Client Actions need explicit names if server rendered");
return url;
};
fn.with = function (...args) {
const newFn = function (...passedArgs) {
return fn.call(this, ...args, ...passedArgs);
};
newFn.base = fn.base;
const uri = new URL(url, mockBase);
uri.searchParams.set("args", hashKey(args));
return toAction(newFn, (uri.origin === "https://action" ? uri.origin : "") + uri.pathname + uri.search);
};
fn.url = url;
if (!isServer) {
actions.set(url, fn);
getOwner() && onCleanup(() => actions.delete(url));
}
return fn;
}
const hashString = (s) => s.split("").reduce((a, b) => ((a << 5) - a + b.charCodeAt(0)) | 0, 0);
async function handleResponse(response, error, navigate) {
let data;
let custom;
let keys;
let flightKeys;
if (response instanceof Response) {
if (response.headers.has("X-Revalidate"))
keys = response.headers.get("X-Revalidate").split(",");
if (response.customBody) {
data = custom = await response.customBody();
if (response.headers.has("X-Single-Flight")) {
data = data._$value;
delete custom._$value;
flightKeys = Object.keys(custom);
}
}
if (response.headers.has("Location")) {
const locationUrl = response.headers.get("Location") || "/";
if (locationUrl.startsWith("http")) {
window.location.href = locationUrl;
}
else {
navigate(locationUrl);
}
}
}
else if (error)
return { error: response };
else
data = response;
// invalidate
cacheKeyOp(keys, entry => (entry[0] = 0));
// set cache
flightKeys && flightKeys.forEach(k => query.set(k, custom[k]));
// trigger revalidation
await revalidate(keys, false);
return data != null ? { data } : undefined;
}

View File

@@ -0,0 +1,32 @@
import { type ReconcileOptions } from "solid-js/store";
/**
* As `createAsync` and `createAsyncStore` are wrappers for `createResource`,
* this type allows to support `latest` field for these primitives.
* It will be removed in the future.
*/
export type AccessorWithLatest<T> = {
(): T;
latest: T;
};
export declare function createAsync<T>(fn: (prev: T) => Promise<T>, options: {
name?: string;
initialValue: T;
deferStream?: boolean;
}): AccessorWithLatest<T>;
export declare function createAsync<T>(fn: (prev: T | undefined) => Promise<T>, options?: {
name?: string;
initialValue?: T;
deferStream?: boolean;
}): AccessorWithLatest<T | undefined>;
export declare function createAsyncStore<T>(fn: (prev: T) => Promise<T>, options: {
name?: string;
initialValue: T;
deferStream?: boolean;
reconcile?: ReconcileOptions;
}): AccessorWithLatest<T>;
export declare function createAsyncStore<T>(fn: (prev: T | undefined) => Promise<T>, options?: {
name?: string;
initialValue?: T;
deferStream?: boolean;
reconcile?: ReconcileOptions;
}): AccessorWithLatest<T | undefined>;

View File

@@ -0,0 +1,96 @@
/**
* This is mock of the eventual Solid 2.0 primitive. It is not fully featured.
*/
import { createResource, sharedConfig, untrack, catchError } from "solid-js";
import { createStore, reconcile, unwrap } from "solid-js/store";
import { isServer } from "solid-js/web";
import { setFunctionName } from "../utils.js";
export function createAsync(fn, options) {
let resource;
let prev = () => !resource || resource.state === "unresolved" ? undefined : resource.latest;
[resource] = createResource(() => subFetch(fn, catchError(() => untrack(prev), () => undefined)), v => v, options);
const resultAccessor = (() => resource());
if (options?.name)
setFunctionName(resultAccessor, options.name);
Object.defineProperty(resultAccessor, "latest", {
get() {
return resource.latest;
}
});
return resultAccessor;
}
export function createAsyncStore(fn, options = {}) {
let resource;
let prev = () => !resource || resource.state === "unresolved"
? undefined
: unwrap(resource.latest);
[resource] = createResource(() => subFetch(fn, catchError(() => untrack(prev), () => undefined)), v => v, {
...options,
storage: (init) => createDeepSignal(init, options.reconcile)
});
const resultAccessor = (() => resource());
Object.defineProperty(resultAccessor, "latest", {
get() {
return resource.latest;
}
});
return resultAccessor;
}
function createDeepSignal(value, options) {
const [store, setStore] = createStore({
value: structuredClone(value)
});
return [
() => store.value,
(v) => {
typeof v === "function" && (v = v());
setStore("value", reconcile(structuredClone(v), options));
return store.value;
}
];
}
// mock promise while hydrating to prevent fetching
class MockPromise {
static all() {
return new MockPromise();
}
static allSettled() {
return new MockPromise();
}
static any() {
return new MockPromise();
}
static race() {
return new MockPromise();
}
static reject() {
return new MockPromise();
}
static resolve() {
return new MockPromise();
}
catch() {
return new MockPromise();
}
then() {
return new MockPromise();
}
finally() {
return new MockPromise();
}
}
function subFetch(fn, prev) {
if (isServer || !sharedConfig.context)
return fn(prev);
const ogFetch = fetch;
const ogPromise = Promise;
try {
window.fetch = () => new MockPromise();
Promise = MockPromise;
return fn(prev);
}
finally {
window.fetch = ogFetch;
Promise = ogPromise;
}
}

View File

@@ -0,0 +1,9 @@
import type { RouterContext } from "../types.js";
type NativeEventConfig = {
preload?: boolean;
explicitLinks?: boolean;
actionBase?: string;
transformUrl?: (url: string) => string;
};
export declare function setupNativeEvents({ preload, explicitLinks, actionBase, transformUrl }?: NativeEventConfig): (router: RouterContext) => void;
export {};

View File

@@ -0,0 +1,123 @@
import { delegateEvents } from "solid-js/web";
import { onCleanup } from "solid-js";
import { actions } from "./action.js";
import { mockBase } from "../utils.js";
export function setupNativeEvents({ preload = true, explicitLinks = false, actionBase = "/_server", transformUrl } = {}) {
return (router) => {
const basePath = router.base.path();
const navigateFromRoute = router.navigatorFactory(router.base);
let preloadTimeout;
let lastElement;
function isSvg(el) {
return el.namespaceURI === "http://www.w3.org/2000/svg";
}
function handleAnchor(evt) {
if (evt.defaultPrevented ||
evt.button !== 0 ||
evt.metaKey ||
evt.altKey ||
evt.ctrlKey ||
evt.shiftKey)
return;
const a = evt
.composedPath()
.find(el => el instanceof Node && el.nodeName.toUpperCase() === "A");
if (!a || (explicitLinks && !a.hasAttribute("link")))
return;
const svg = isSvg(a);
const href = svg ? a.href.baseVal : a.href;
const target = svg ? a.target.baseVal : a.target;
if (target || (!href && !a.hasAttribute("state")))
return;
const rel = (a.getAttribute("rel") || "").split(/\s+/);
if (a.hasAttribute("download") || (rel && rel.includes("external")))
return;
const url = svg ? new URL(href, document.baseURI) : new URL(href);
if (url.origin !== window.location.origin ||
(basePath && url.pathname && !url.pathname.toLowerCase().startsWith(basePath.toLowerCase())))
return;
return [a, url];
}
function handleAnchorClick(evt) {
const res = handleAnchor(evt);
if (!res)
return;
const [a, url] = res;
const to = router.parsePath(url.pathname + url.search + url.hash);
const state = a.getAttribute("state");
evt.preventDefault();
navigateFromRoute(to, {
resolve: false,
replace: a.hasAttribute("replace"),
scroll: !a.hasAttribute("noscroll"),
state: state ? JSON.parse(state) : undefined
});
}
function handleAnchorPreload(evt) {
const res = handleAnchor(evt);
if (!res)
return;
const [a, url] = res;
transformUrl && (url.pathname = transformUrl(url.pathname));
router.preloadRoute(url, a.getAttribute("preload") !== "false");
}
function handleAnchorMove(evt) {
clearTimeout(preloadTimeout);
const res = handleAnchor(evt);
if (!res)
return (lastElement = null);
const [a, url] = res;
if (lastElement === a)
return;
transformUrl && (url.pathname = transformUrl(url.pathname));
preloadTimeout = setTimeout(() => {
router.preloadRoute(url, a.getAttribute("preload") !== "false");
lastElement = a;
}, 20);
}
function handleFormSubmit(evt) {
if (evt.defaultPrevented)
return;
let actionRef = evt.submitter && evt.submitter.hasAttribute("formaction")
? evt.submitter.getAttribute("formaction")
: evt.target.getAttribute("action");
if (!actionRef)
return;
if (!actionRef.startsWith("https://action/")) {
// normalize server actions
const url = new URL(actionRef, mockBase);
actionRef = router.parsePath(url.pathname + url.search);
if (!actionRef.startsWith(actionBase))
return;
}
if (evt.target.method.toUpperCase() !== "POST")
throw new Error("Only POST forms are supported for Actions");
const handler = actions.get(actionRef);
if (handler) {
evt.preventDefault();
const data = new FormData(evt.target, evt.submitter);
handler.call({ r: router, f: evt.target }, evt.target.enctype === "multipart/form-data"
? data
: new URLSearchParams(data));
}
}
// ensure delegated event run first
delegateEvents(["click", "submit"]);
document.addEventListener("click", handleAnchorClick);
if (preload) {
document.addEventListener("mousemove", handleAnchorMove, { passive: true });
document.addEventListener("focusin", handleAnchorPreload, { passive: true });
document.addEventListener("touchstart", handleAnchorPreload, { passive: true });
}
document.addEventListener("submit", handleFormSubmit);
onCleanup(() => {
document.removeEventListener("click", handleAnchorClick);
if (preload) {
document.removeEventListener("mousemove", handleAnchorMove);
document.removeEventListener("focusin", handleAnchorPreload);
document.removeEventListener("touchstart", handleAnchorPreload);
}
document.removeEventListener("submit", handleFormSubmit);
});
};
}

View File

@@ -0,0 +1,4 @@
export { createAsync, createAsyncStore, type AccessorWithLatest } from "./createAsync.js";
export { action, useSubmission, useSubmissions, useAction, type Action } from "./action.js";
export { query, revalidate, cache, type CachedFunction } from "./query.js";
export { redirect, reload, json } from "./response.js";

View File

@@ -0,0 +1,4 @@
export { createAsync, createAsyncStore } from "./createAsync.js";
export { action, useSubmission, useSubmissions, useAction } from "./action.js";
export { query, revalidate, cache } from "./query.js";
export { redirect, reload, json } from "./response.js";

View File

@@ -0,0 +1,23 @@
import type { CacheEntry, NarrowResponse } from "../types.js";
/**
* Revalidates the given cache entry/entries.
*/
export declare function revalidate(key?: string | string[] | void, force?: boolean): Promise<void>;
export declare function cacheKeyOp(key: string | string[] | void, fn: (cacheEntry: CacheEntry) => void): void;
export type CachedFunction<T extends (...args: any) => any> = T extends (...args: infer A) => infer R ? ([] extends {
[K in keyof A]-?: A[K];
} ? (...args: never[]) => R extends Promise<infer P> ? Promise<NarrowResponse<P>> : NarrowResponse<R> : (...args: A) => R extends Promise<infer P> ? Promise<NarrowResponse<P>> : NarrowResponse<R>) & {
keyFor: (...args: A) => string;
key: string;
} : never;
export declare function query<T extends (...args: any) => any>(fn: T, name: string): CachedFunction<T>;
export declare namespace query {
export var get: (key: string) => any;
export var set: <T>(key: string, value: T extends Promise<any> ? never : T) => void;
var _a: (key: string) => boolean;
export var clear: () => void;
export { _a as delete };
}
/** @deprecated use query instead */
export declare const cache: typeof query;
export declare function hashKey<T extends Array<any>>(args: T): string;

View File

@@ -0,0 +1,232 @@
import { createSignal, getListener, getOwner, onCleanup, sharedConfig, startTransition } from "solid-js";
import { getRequestEvent, isServer } from "solid-js/web";
import { useNavigate, getIntent, getInPreloadFn } from "../routing.js";
const LocationHeader = "Location";
const PRELOAD_TIMEOUT = 5000;
const CACHE_TIMEOUT = 180000;
let cacheMap = new Map();
// cleanup forward/back cache
if (!isServer) {
setInterval(() => {
const now = Date.now();
for (let [k, v] of cacheMap.entries()) {
if (!v[4].count && now - v[0] > CACHE_TIMEOUT) {
cacheMap.delete(k);
}
}
}, 300000);
}
function getCache() {
if (!isServer)
return cacheMap;
const req = getRequestEvent();
if (!req)
throw new Error("Cannot find cache context");
return (req.router || (req.router = {})).cache || (req.router.cache = new Map());
}
/**
* Revalidates the given cache entry/entries.
*/
export function revalidate(key, force = true) {
return startTransition(() => {
const now = Date.now();
cacheKeyOp(key, entry => {
force && (entry[0] = 0); //force cache miss
entry[4][1](now); // retrigger live signals
});
});
}
export function cacheKeyOp(key, fn) {
key && !Array.isArray(key) && (key = [key]);
for (let k of cacheMap.keys()) {
if (key === undefined || matchKey(k, key))
fn(cacheMap.get(k));
}
}
export function query(fn, name) {
// prioritize GET for server functions
if (fn.GET)
fn = fn.GET;
const cachedFn = ((...args) => {
const cache = getCache();
const intent = getIntent();
const inPreloadFn = getInPreloadFn();
const owner = getOwner();
const navigate = owner ? useNavigate() : undefined;
const now = Date.now();
const key = name + hashKey(args);
let cached = cache.get(key);
let tracking;
if (isServer) {
const e = getRequestEvent();
if (e) {
const dataOnly = (e.router || (e.router = {})).dataOnly;
if (dataOnly) {
const data = e && (e.router.data || (e.router.data = {}));
if (data && key in data)
return data[key];
if (Array.isArray(dataOnly) && !matchKey(key, dataOnly)) {
data[key] = undefined;
return Promise.resolve();
}
}
}
}
if (getListener() && !isServer) {
tracking = true;
onCleanup(() => cached[4].count--);
}
if (cached &&
cached[0] &&
(isServer ||
intent === "native" ||
cached[4].count ||
Date.now() - cached[0] < PRELOAD_TIMEOUT)) {
if (tracking) {
cached[4].count++;
cached[4][0](); // track
}
if (cached[3] === "preload" && intent !== "preload") {
cached[0] = now;
}
let res = cached[1];
if (intent !== "preload") {
res =
"then" in cached[1]
? cached[1].then(handleResponse(false), handleResponse(true))
: handleResponse(false)(cached[1]);
!isServer && intent === "navigate" && startTransition(() => cached[4][1](cached[0])); // update version
}
inPreloadFn && "then" in res && res.catch(() => { });
return res;
}
let res;
if (!isServer && sharedConfig.has && sharedConfig.has(key)) {
res = sharedConfig.load(key); // hydrating
// @ts-ignore at least until we add a delete method to sharedConfig
delete globalThis._$HY.r[key];
}
else
res = fn(...args);
if (cached) {
cached[0] = now;
cached[1] = res;
cached[3] = intent;
!isServer && intent === "navigate" && startTransition(() => cached[4][1](cached[0])); // update version
}
else {
cache.set(key, (cached = [now, res, , intent, createSignal(now)]));
cached[4].count = 0;
}
if (tracking) {
cached[4].count++;
cached[4][0](); // track
}
if (isServer) {
const e = getRequestEvent();
if (e && e.router.dataOnly)
return (e.router.data[key] = res);
}
if (intent !== "preload") {
res =
"then" in res
? res.then(handleResponse(false), handleResponse(true))
: handleResponse(false)(res);
}
inPreloadFn && "then" in res && res.catch(() => { });
// serialize on server
if (isServer &&
sharedConfig.context &&
sharedConfig.context.async &&
!sharedConfig.context.noHydrate) {
const e = getRequestEvent();
(!e || !e.serverOnly) && sharedConfig.context.serialize(key, res);
}
return res;
function handleResponse(error) {
return async (v) => {
if (v instanceof Response) {
const e = getRequestEvent();
if (e) {
for (const [key, value] of v.headers) {
if (key == "set-cookie")
e.response.headers.append("set-cookie", value);
else
e.response.headers.set(key, value);
}
}
const url = v.headers.get(LocationHeader);
if (url !== null) {
// client + server relative redirect
if (navigate && url.startsWith("/"))
startTransition(() => {
navigate(url, { replace: true });
});
else if (!isServer)
window.location.href = url;
else if (e)
e.response.status = 302;
return;
}
if (v.customBody)
v = await v.customBody();
}
if (error)
throw v;
cached[2] = v;
return v;
};
}
});
cachedFn.keyFor = (...args) => name + hashKey(args);
cachedFn.key = name;
return cachedFn;
}
query.get = (key) => {
const cached = getCache().get(key);
return cached[2];
};
query.set = (key, value) => {
const cache = getCache();
const now = Date.now();
let cached = cache.get(key);
if (cached) {
cached[0] = now;
cached[1] = Promise.resolve(value);
cached[2] = value;
cached[3] = "preload";
}
else {
cache.set(key, (cached = [now, Promise.resolve(value), value, "preload", createSignal(now)]));
cached[4].count = 0;
}
};
query.delete = (key) => getCache().delete(key);
query.clear = () => getCache().clear();
/** @deprecated use query instead */
export const cache = query;
function matchKey(key, keys) {
for (let k of keys) {
if (k && key.startsWith(k))
return true;
}
return false;
}
// Modified from the amazing Tanstack Query library (MIT)
// https://github.com/TanStack/query/blob/main/packages/query-core/src/utils.ts#L168
export function hashKey(args) {
return JSON.stringify(args, (_, val) => isPlainObject(val)
? Object.keys(val)
.sort()
.reduce((result, key) => {
result[key] = val[key];
return result;
}, {})
: val);
}
function isPlainObject(obj) {
let proto;
return (obj != null &&
typeof obj === "object" &&
(!(proto = Object.getPrototypeOf(obj)) || proto === Object.prototype));
}

View File

@@ -0,0 +1,4 @@
import type { RouterResponseInit, CustomResponse } from "../types.js";
export declare function redirect(url: string, init?: number | RouterResponseInit): CustomResponse<never>;
export declare function reload(init?: RouterResponseInit): CustomResponse<never>;
export declare function json<T>(data: T, init?: RouterResponseInit): CustomResponse<T>;

View File

@@ -0,0 +1,42 @@
export function redirect(url, init = 302) {
let responseInit;
let revalidate;
if (typeof init === "number") {
responseInit = { status: init };
}
else {
({ revalidate, ...responseInit } = init);
if (typeof responseInit.status === "undefined") {
responseInit.status = 302;
}
}
const headers = new Headers(responseInit.headers);
headers.set("Location", url);
revalidate !== undefined && headers.set("X-Revalidate", revalidate.toString());
const response = new Response(null, {
...responseInit,
headers: headers
});
return response;
}
export function reload(init = {}) {
const { revalidate, ...responseInit } = init;
const headers = new Headers(responseInit.headers);
revalidate !== undefined && headers.set("X-Revalidate", revalidate.toString());
return new Response(null, {
...responseInit,
headers
});
}
export function json(data, init = {}) {
const { revalidate, ...responseInit } = init;
const headers = new Headers(responseInit.headers);
revalidate !== undefined && headers.set("X-Revalidate", revalidate.toString());
headers.set("Content-Type", "application/json");
const response = new Response(JSON.stringify(data), {
...responseInit,
headers
});
response.customBody = () => data;
return response;
}

View File

@@ -0,0 +1,7 @@
export * from "./routers/index.js";
export * from "./components.js";
export * from "./lifecycle.js";
export { useHref, useIsRouting, useLocation, useMatch, useCurrentMatches, useNavigate, useParams, useResolvedPath, useSearchParams, useBeforeLeave, usePreloadRoute, RouterContextObj as RouterContext } from "./routing.js";
export { mergeSearchString as _mergeSearchString } from "./utils.js";
export * from "./data/index.js";
export type { Location, LocationChange, SearchParams, MatchFilter, MatchFilters, NavigateOptions, Navigator, OutputMatch, Params, PathMatch, RouteSectionProps, RoutePreloadFunc, RoutePreloadFuncArgs, RouteDefinition, RouteDescription, RouteMatch, RouterIntegration, RouterUtils, SetParams, Submission, BeforeLeaveEventArgs, RouteLoadFunc, RouteLoadFuncArgs, RouterResponseInit, CustomResponse } from "./types.js";

1879
web/runtime/@solidjs/router/dist/index.js vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
export * from "./routers/index.js";
export * from "./components.jsx";
export * from "./lifecycle.js";
export { useHref, useIsRouting, useLocation, useMatch, useCurrentMatches, useNavigate, useParams, useResolvedPath, useSearchParams, useBeforeLeave, usePreloadRoute, RouterContextObj as RouterContext } from "./routing.js";
export { mergeSearchString as _mergeSearchString } from "./utils.js";
export * from "./data/index.js";

View File

@@ -0,0 +1,5 @@
import { BeforeLeaveLifecycle, LocationChange } from "./types.js";
export declare function createBeforeLeave(): BeforeLeaveLifecycle;
export declare function saveCurrentDepth(): void;
export declare function keepDepth(state: any): any;
export declare function notifyIfNotBlocked(notify: (value?: string | LocationChange) => void, block: (delta: number | null) => boolean): () => void;

View File

@@ -0,0 +1,69 @@
import { isServer } from "solid-js/web";
export function createBeforeLeave() {
let listeners = new Set();
function subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
}
let ignore = false;
function confirm(to, options) {
if (ignore)
return !(ignore = false);
const e = {
to,
options,
defaultPrevented: false,
preventDefault: () => (e.defaultPrevented = true)
};
for (const l of listeners)
l.listener({
...e,
from: l.location,
retry: (force) => {
force && (ignore = true);
l.navigate(to, { ...options, resolve: false });
}
});
return !e.defaultPrevented;
}
return {
subscribe,
confirm
};
}
// The following supports browser initiated blocking (eg back/forward)
let depth;
export function saveCurrentDepth() {
if (!window.history.state || window.history.state._depth == null) {
window.history.replaceState({ ...window.history.state, _depth: window.history.length - 1 }, "");
}
depth = window.history.state._depth;
}
if (!isServer) {
saveCurrentDepth();
}
export function keepDepth(state) {
return {
...state,
_depth: window.history.state && window.history.state._depth
};
}
export function notifyIfNotBlocked(notify, block) {
let ignore = false;
return () => {
const prevDepth = depth;
saveCurrentDepth();
const delta = prevDepth == null ? null : depth - prevDepth;
if (ignore) {
ignore = false;
return;
}
if (delta && block(delta)) {
ignore = true;
window.history.go(-delta);
}
else {
notify();
}
};
}

View File

@@ -0,0 +1,9 @@
import type { JSX } from "solid-js";
import type { BaseRouterProps } from "./components.js";
export declare function hashParser(str: string): string;
export type HashRouterProps = BaseRouterProps & {
actionBase?: string;
explicitLinks?: boolean;
preload?: boolean;
};
export declare function HashRouter(props: HashRouterProps): JSX.Element;

View File

@@ -0,0 +1,41 @@
import { setupNativeEvents } from "../data/events.js";
import { createRouter, scrollToHash, bindEvent } from "./createRouter.js";
import { createBeforeLeave, keepDepth, notifyIfNotBlocked, saveCurrentDepth } from "../lifecycle.js";
export function hashParser(str) {
const to = str.replace(/^.*?#/, "");
// Hash-only hrefs like `#foo` from plain anchors will come in as `/#foo` whereas a link to
// `/foo` will be `/#/foo`. Check if the to starts with a `/` and if not append it as a hash
// to the current path so we can handle these in-page anchors correctly.
if (!to.startsWith("/")) {
const [, path = "/"] = window.location.hash.split("#", 2);
return `${path}#${to}`;
}
return to;
}
export function HashRouter(props) {
const getSource = () => window.location.hash.slice(1);
const beforeLeave = createBeforeLeave();
return createRouter({
get: getSource,
set({ value, replace, scroll, state }) {
if (replace) {
window.history.replaceState(keepDepth(state), "", "#" + value);
}
else {
window.history.pushState(state, "", "#" + value);
}
const hashIndex = value.indexOf("#");
const hash = hashIndex >= 0 ? value.slice(hashIndex + 1) : "";
scrollToHash(hash, scroll);
saveCurrentDepth();
},
init: notify => bindEvent(window, "hashchange", notifyIfNotBlocked(notify, delta => !beforeLeave.confirm(delta && delta < 0 ? delta : getSource()))),
create: setupNativeEvents({ preload: props.preload, explicitLinks: props.explicitLinks, actionBase: props.actionBase }),
utils: {
go: delta => window.history.go(delta),
renderPath: path => `#${path}`,
parsePath: hashParser,
beforeLeave
}
})(props);
}

View File

@@ -0,0 +1,24 @@
import type { LocationChange } from "../types.js";
import type { BaseRouterProps } from "./components.js";
import type { JSX } from "solid-js";
export type MemoryHistory = {
get: () => string;
set: (change: LocationChange) => void;
go: (delta: number) => void;
listen: (listener: (value: string) => void) => () => void;
};
export declare function createMemoryHistory(): {
get: () => string;
set: ({ value, scroll, replace }: LocationChange) => void;
back: () => void;
forward: () => void;
go: (n: number) => void;
listen: (listener: (value: string) => void) => () => void;
};
export type MemoryRouterProps = BaseRouterProps & {
history?: MemoryHistory;
actionBase?: string;
explicitLinks?: boolean;
preload?: boolean;
};
export declare function MemoryRouter(props: MemoryRouterProps): JSX.Element;

View File

@@ -0,0 +1,57 @@
import { createRouter, scrollToHash } from "./createRouter.js";
import { setupNativeEvents } from "../data/events.js";
export function createMemoryHistory() {
const entries = ["/"];
let index = 0;
const listeners = [];
const go = (n) => {
// https://github.com/remix-run/react-router/blob/682810ca929d0e3c64a76f8d6e465196b7a2ac58/packages/router/history.ts#L245
index = Math.max(0, Math.min(index + n, entries.length - 1));
const value = entries[index];
listeners.forEach(listener => listener(value));
};
return {
get: () => entries[index],
set: ({ value, scroll, replace }) => {
if (replace) {
entries[index] = value;
}
else {
entries.splice(index + 1, entries.length - index, value);
index++;
}
listeners.forEach(listener => listener(value));
setTimeout(() => {
if (scroll) {
scrollToHash(value.split("#")[1] || "", true);
}
}, 0);
},
back: () => {
go(-1);
},
forward: () => {
go(1);
},
go,
listen: (listener) => {
listeners.push(listener);
return () => {
const index = listeners.indexOf(listener);
listeners.splice(index, 1);
};
}
};
}
export function MemoryRouter(props) {
const memoryHistory = props.history || createMemoryHistory();
return createRouter({
get: memoryHistory.get,
set: memoryHistory.set,
init: memoryHistory.listen,
create: setupNativeEvents({ preload: props.preload, explicitLinks: props.explicitLinks, actionBase: props.actionBase }),
utils: {
go: memoryHistory.go
}
})(props);
}

View File

@@ -0,0 +1,9 @@
import type { BaseRouterProps } from "./components.js";
import type { JSX } from "solid-js";
export type RouterProps = BaseRouterProps & {
url?: string;
actionBase?: string;
explicitLinks?: boolean;
preload?: boolean;
};
export declare function Router(props: RouterProps): JSX.Element;

View File

@@ -0,0 +1,45 @@
import { isServer } from "solid-js/web";
import { createRouter, scrollToHash, bindEvent } from "./createRouter.js";
import { StaticRouter } from "./StaticRouter.js";
import { setupNativeEvents } from "../data/events.js";
import { createBeforeLeave, keepDepth, notifyIfNotBlocked, saveCurrentDepth } from "../lifecycle.js";
export function Router(props) {
if (isServer)
return StaticRouter(props);
const getSource = () => {
const url = window.location.pathname.replace(/^\/+/, "/") + window.location.search;
const state = window.history.state && window.history.state._depth && Object.keys(window.history.state).length === 1 ? undefined : window.history.state;
return {
value: url + window.location.hash,
state
};
};
const beforeLeave = createBeforeLeave();
return createRouter({
get: getSource,
set({ value, replace, scroll, state }) {
if (replace) {
window.history.replaceState(keepDepth(state), "", value);
}
else {
window.history.pushState(state, "", value);
}
scrollToHash(decodeURIComponent(window.location.hash.slice(1)), scroll);
saveCurrentDepth();
},
init: notify => bindEvent(window, "popstate", notifyIfNotBlocked(notify, delta => {
if (delta) {
return !beforeLeave.confirm(delta);
}
else {
const s = getSource();
return !beforeLeave.confirm(s.value, { state: s.state });
}
})),
create: setupNativeEvents({ preload: props.preload, explicitLinks: props.explicitLinks, actionBase: props.actionBase, transformUrl: props.transformUrl }),
utils: {
go: delta => window.history.go(delta),
beforeLeave
}
})(props);
}

View File

@@ -0,0 +1,6 @@
import { type BaseRouterProps } from "./components.js";
import type { JSX } from "solid-js";
export type StaticRouterProps = BaseRouterProps & {
url?: string;
};
export declare function StaticRouter(props: StaticRouterProps): JSX.Element;

View File

@@ -0,0 +1,15 @@
import { getRequestEvent } from "solid-js/web";
import { createRouterComponent } from "./components.js";
function getPath(url) {
const u = new URL(url);
return u.pathname + u.search;
}
export function StaticRouter(props) {
let e;
const obj = {
value: props.url || ((e = getRequestEvent()) && getPath(e.request.url)) || "",
};
return createRouterComponent({
signal: [() => obj, next => Object.assign(obj, next)]
})(props);
}

View File

@@ -0,0 +1,27 @@
import type { Component, JSX } from "solid-js";
import type { MatchFilters, RouteDefinition, RoutePreloadFunc, RouterIntegration, RouteSectionProps } from "../types.js";
export type BaseRouterProps = {
base?: string;
/**
* A component that wraps the content of every route.
*/
root?: Component<RouteSectionProps>;
rootPreload?: RoutePreloadFunc;
singleFlight?: boolean;
children?: JSX.Element | RouteDefinition | RouteDefinition[];
transformUrl?: (url: string) => string;
/** @deprecated use rootPreload */
rootLoad?: RoutePreloadFunc;
};
export declare const createRouterComponent: (router: RouterIntegration) => (props: BaseRouterProps) => JSX.Element;
export type RouteProps<S extends string, T = unknown> = {
path?: S | S[];
children?: JSX.Element;
preload?: RoutePreloadFunc<T>;
matchFilters?: MatchFilters<S>;
component?: Component<RouteSectionProps<T>>;
info?: Record<string, any>;
/** @deprecated use preload */
load?: RoutePreloadFunc<T>;
};
export declare const Route: <S extends string, T = unknown>(props: RouteProps<S, T>) => JSX.Element;

View File

@@ -0,0 +1,118 @@
/*@refresh skip*/
import { children, createMemo, createRoot, getOwner, mergeProps, on, Show, untrack } from "solid-js";
import { getRequestEvent, isServer } from "solid-js/web";
import { createBranches, createRouteContext, createRouterContext, getIntent, getRouteMatches, RouteContextObj, RouterContextObj, setInPreloadFn } from "../routing.js";
export const createRouterComponent = (router) => (props) => {
const { base } = props;
const routeDefs = children(() => props.children);
const branches = createMemo(() => createBranches(routeDefs(), props.base || ""));
let context;
const routerState = createRouterContext(router, branches, () => context, {
base,
singleFlight: props.singleFlight,
transformUrl: props.transformUrl,
});
router.create && router.create(routerState);
return (<RouterContextObj.Provider value={routerState}>
<Root routerState={routerState} root={props.root} preload={props.rootPreload || props.rootLoad}>
{(context = getOwner()) && null}
<Routes routerState={routerState} branches={branches()}/>
</Root>
</RouterContextObj.Provider>);
};
function Root(props) {
const location = props.routerState.location;
const params = props.routerState.params;
const data = createMemo(() => props.preload &&
untrack(() => {
setInPreloadFn(true);
props.preload({ params, location, intent: getIntent() || "initial" });
setInPreloadFn(false);
}));
return (<Show when={props.root} keyed fallback={props.children}>
{Root => (<Root params={params} location={location} data={data()}>
{props.children}
</Root>)}
</Show>);
}
function Routes(props) {
if (isServer) {
const e = getRequestEvent();
if (e && e.router && e.router.dataOnly) {
dataOnly(e, props.routerState, props.branches);
return;
}
e &&
((e.router || (e.router = {})).matches ||
(e.router.matches = props.routerState.matches().map(({ route, path, params }) => ({
path: route.originalPath,
pattern: route.pattern,
match: path,
params,
info: route.info
}))));
}
const disposers = [];
let root;
const routeStates = createMemo(on(props.routerState.matches, (nextMatches, prevMatches, prev) => {
let equal = prevMatches && nextMatches.length === prevMatches.length;
const next = [];
for (let i = 0, len = nextMatches.length; i < len; i++) {
const prevMatch = prevMatches && prevMatches[i];
const nextMatch = nextMatches[i];
if (prev && prevMatch && nextMatch.route.key === prevMatch.route.key) {
next[i] = prev[i];
}
else {
equal = false;
if (disposers[i]) {
disposers[i]();
}
createRoot(dispose => {
disposers[i] = dispose;
next[i] = createRouteContext(props.routerState, next[i - 1] || props.routerState.base, createOutlet(() => routeStates()[i + 1]), () => {
const routeMatches = props.routerState.matches();
return routeMatches[i] ?? routeMatches[0];
});
});
}
}
disposers.splice(nextMatches.length).forEach(dispose => dispose());
if (prev && equal) {
return prev;
}
root = next[0];
return next;
}));
return createOutlet(() => routeStates() && root)();
}
const createOutlet = (child) => {
return () => (<Show when={child()} keyed>
{child => <RouteContextObj.Provider value={child}>{child.outlet()}</RouteContextObj.Provider>}
</Show>);
};
export const Route = (props) => {
const childRoutes = children(() => props.children);
return mergeProps(props, {
get children() {
return childRoutes();
}
});
};
// for data only mode with single flight mutations
function dataOnly(event, routerState, branches) {
const url = new URL(event.request.url);
const prevMatches = getRouteMatches(branches, new URL(event.router.previousUrl || event.request.url).pathname);
const matches = getRouteMatches(branches, url.pathname);
for (let match = 0; match < matches.length; match++) {
if (!prevMatches[match] || matches[match].route !== prevMatches[match].route)
event.router.dataOnly = true;
const { route, params } = matches[match];
route.preload &&
route.preload({
params,
location: routerState.location,
intent: "preload"
});
}
}

View File

@@ -0,0 +1,10 @@
import type { LocationChange, RouterContext, RouterUtils } from "../types.js";
export declare function createRouter(config: {
get: () => string | LocationChange;
set: (next: LocationChange) => void;
init?: (notify: (value?: string | LocationChange) => void) => () => void;
create?: (router: RouterContext) => void;
utils?: Partial<RouterUtils>;
}): (props: import("./components.js").BaseRouterProps) => import("solid-js").JSX.Element;
export declare function bindEvent(target: EventTarget, type: string, handler: EventListener): () => void;
export declare function scrollToHash(hash: string, fallbackTop?: boolean): void;

View File

@@ -0,0 +1,41 @@
import { createSignal, onCleanup, sharedConfig } from "solid-js";
import { createRouterComponent } from "./components.js";
function intercept([value, setValue], get, set) {
return [get ? () => get(value()) : value, set ? (v) => setValue(set(v)) : setValue];
}
export function createRouter(config) {
let ignore = false;
const wrap = (value) => (typeof value === "string" ? { value } : value);
const signal = intercept(createSignal(wrap(config.get()), {
equals: (a, b) => a.value === b.value && a.state === b.state
}), undefined, next => {
!ignore && config.set(next);
if (sharedConfig.registry && !sharedConfig.done)
sharedConfig.done = true;
return next;
});
config.init &&
onCleanup(config.init((value = config.get()) => {
ignore = true;
signal[1](wrap(value));
ignore = false;
}));
return createRouterComponent({
signal,
create: config.create,
utils: config.utils
});
}
export function bindEvent(target, type, handler) {
target.addEventListener(type, handler);
return () => target.removeEventListener(type, handler);
}
export function scrollToHash(hash, fallbackTop) {
const el = hash && document.getElementById(hash);
if (el) {
el.scrollIntoView();
}
else if (fallbackTop) {
window.scrollTo(0, 0);
}
}

View File

@@ -0,0 +1,11 @@
export { Route } from "./components.js";
export type { BaseRouterProps, RouteProps } from "./components.js";
export { createRouter } from "./createRouter.js";
export { Router } from "./Router.js";
export type { RouterProps } from "./Router.js";
export { HashRouter } from "./HashRouter.js";
export type { HashRouterProps } from "./HashRouter.js";
export { MemoryRouter, createMemoryHistory } from "./MemoryRouter.js";
export type { MemoryRouterProps, MemoryHistory } from "./MemoryRouter.js";
export { StaticRouter } from "./StaticRouter.js";
export type { StaticRouterProps } from "./StaticRouter.js";

View File

@@ -0,0 +1,6 @@
export { Route } from "./components.js";
export { createRouter } from "./createRouter.js";
export { Router } from "./Router.js";
export { HashRouter } from "./HashRouter.js";
export { MemoryRouter, createMemoryHistory } from "./MemoryRouter.js";
export { StaticRouter } from "./StaticRouter.js";

View File

@@ -0,0 +1,175 @@
import { JSX, Accessor } from "solid-js";
import type { BeforeLeaveEventArgs, Branch, Intent, Location, MatchFilters, NavigateOptions, Navigator, Params, RouteDescription, RouteContext, RouteDefinition, RouteMatch, RouterContext, RouterIntegration, SearchParams, SetSearchParams } from "./types.js";
/** Consider this API opaque and internal. It is likely to change in the future. */
export declare const RouterContextObj: import("solid-js").Context<RouterContext | undefined>;
export declare const RouteContextObj: import("solid-js").Context<RouteContext | undefined>;
export declare const useRouter: () => RouterContext;
export declare const useRoute: () => RouteContext;
export declare const useResolvedPath: (path: () => string) => Accessor<string | undefined>;
export declare const useHref: <T extends string | undefined>(to: () => T) => () => string | T;
/**
* Retrieves method to do navigation. The method accepts a path to navigate to and an optional object with the following options:
*
* - resolve (*boolean*, default `true`): resolve the path against the current route
* - replace (*boolean*, default `false`): replace the history entry
* - scroll (*boolean*, default `true`): scroll to top after navigation
* - state (*any*, default `undefined`): pass custom state to `location.state`
*
* **Note**: The state is serialized using the structured clone algorithm which does not support all object types.
*
* @example
* ```js
* const navigate = useNavigate();
*
* if (unauthorized) {
* navigate("/login", { replace: true });
* }
* ```
*/
export declare const useNavigate: () => Navigator;
/**
* Retrieves reactive `location` object useful for getting things like `pathname`.
*
* @example
* ```js
* const location = useLocation();
*
* const pathname = createMemo(() => parsePath(location.pathname));
* ```
*/
export declare const useLocation: <S = unknown>() => Location<S>;
/**
* Retrieves signal that indicates whether the route is currently in a *Transition*.
* Useful for showing stale/pending state when the route resolution is *Suspended* during concurrent rendering.
*
* @example
* ```js
* const isRouting = useIsRouting();
*
* return (
* <div classList={{ "grey-out": isRouting() }}>
* <MyAwesomeContent />
* </div>
* );
* ```
*/
export declare const useIsRouting: () => () => boolean;
/**
* usePreloadRoute returns a function that can be used to preload a route manual.
* This is what happens automatically with link hovering and similar focus based behavior, but it is available here as an API.
*
* @example
* ```js
* const preload = usePreloadRoute();
*
* preload(`/users/settings`, { preloadData: true });
* ```
*/
export declare const usePreloadRoute: () => (url: string | URL, options?: {
preloadData?: boolean;
}) => void;
/**
* `useMatch` takes an accessor that returns the path and creates a `Memo` that returns match information if the current path matches the provided path.
* Useful for determining if a given path matches the current route.
*
* @example
* ```js
* const match = useMatch(() => props.href);
*
* return <div classList={{ active: Boolean(match()) }} />;
* ```
*/
export declare const useMatch: <S extends string>(path: () => S, matchFilters?: MatchFilters<S>) => Accessor<import("./types.js").PathMatch | undefined>;
/**
* `useCurrentMatches` returns all the matches for the current matched route.
* Useful for getting all the route information.
*
* @example
* ```js
* const matches = useCurrentMatches();
*
* const breadcrumbs = createMemo(() => matches().map(m => m.route.info.breadcrumb))
* ```
*/
export declare const useCurrentMatches: () => () => RouteMatch[];
/**
* Retrieves a reactive, store-like object containing the current route path parameters as defined in the Route.
*
* @example
* ```js
* const params = useParams();
*
* // fetch user based on the id path parameter
* const [user] = createResource(() => params.id, fetchUser);
* ```
*/
export declare const useParams: <T extends Params>() => T;
/**
* Retrieves a tuple containing a reactive object to read the current location's query parameters and a method to update them.
* The object is a proxy so you must access properties to subscribe to reactive updates.
* **Note** that values will be strings and property names will retain their casing.
*
* The setter method accepts an object whose entries will be merged into the current query string.
* Values `''`, `undefined` and `null` will remove the key from the resulting query string.
* Updates will behave just like a navigation and the setter accepts the same optional second parameter as `navigate` and auto-scrolling is disabled by default.
*
* @examples
* ```js
* const [searchParams, setSearchParams] = useSearchParams();
*
* return (
* <div>
* <span>Page: {searchParams.page}</span>
* <button
* onClick={() =>
* setSearchParams({ page: (parseInt(searchParams.page) || 0) + 1 })
* }
* >
* Next Page
* </button>
* </div>
* );
* ```
*/
export declare const useSearchParams: <T extends SearchParams>() => [Partial<T>, (params: SetSearchParams, options?: Partial<NavigateOptions>) => void];
/**
* useBeforeLeave takes a function that will be called prior to leaving a route.
* The function will be called with:
*
* - from (*Location*): current location (before change).
* - to (*string | number*): path passed to `navigate`.
* - options (*NavigateOptions*): options passed to navigate.
* - preventDefault (*function*): call to block the route change.
* - defaultPrevented (*readonly boolean*): `true` if any previously called leave handlers called `preventDefault`.
* - retry (*function*, force?: boolean ): call to retry the same navigation, perhaps after confirming with the user. Pass `true` to skip running the leave handlers again (i.e. force navigate without confirming).
*
* @example
* ```js
* useBeforeLeave((e: BeforeLeaveEventArgs) => {
* if (form.isDirty && !e.defaultPrevented) {
* // preventDefault to block immediately and prompt user async
* e.preventDefault();
* setTimeout(() => {
* if (window.confirm("Discard unsaved changes - are you sure?")) {
* // user wants to proceed anyway so retry with force=true
* e.retry(true);
* }
* }, 100);
* }
* });
* ```
*/
export declare const useBeforeLeave: (listener: (e: BeforeLeaveEventArgs) => void) => void;
export declare function createRoutes(routeDef: RouteDefinition, base?: string): RouteDescription[];
export declare function createBranch(routes: RouteDescription[], index?: number): Branch;
export declare function createBranches(routeDef: RouteDefinition | RouteDefinition[], base?: string, stack?: RouteDescription[], branches?: Branch[]): Branch[];
export declare function getRouteMatches(branches: Branch[], location: string): RouteMatch[];
export declare function getIntent(): Intent | undefined;
export declare function getInPreloadFn(): boolean;
export declare function setInPreloadFn(value: boolean): void;
export declare function createRouterContext(integration: RouterIntegration, branches: () => Branch[], getContext?: () => any, options?: {
base?: string;
singleFlight?: boolean;
transformUrl?: (url: string) => string;
}): RouterContext;
export declare function createRouteContext(router: RouterContext, parent: RouteContext, outlet: () => JSX.Element, match: () => RouteMatch): RouteContext;

View File

@@ -0,0 +1,560 @@
import { runWithOwner, batch } from "solid-js";
import { createComponent, createContext, createMemo, createRenderEffect, createSignal, on, onCleanup, untrack, useContext, startTransition, resetErrorBoundaries } from "solid-js";
import { isServer, getRequestEvent } from "solid-js/web";
import { createBeforeLeave } from "./lifecycle.js";
import { mockBase, createMemoObject, extractSearchParams, invariant, resolvePath, createMatcher, joinPaths, scoreRoute, mergeSearchString, expandOptionals } from "./utils.js";
const MAX_REDIRECTS = 100;
/** Consider this API opaque and internal. It is likely to change in the future. */
export const RouterContextObj = createContext();
export const RouteContextObj = createContext();
export const useRouter = () => invariant(useContext(RouterContextObj), "<A> and 'use' router primitives can be only used inside a Route.");
let TempRoute;
export const useRoute = () => TempRoute || useContext(RouteContextObj) || useRouter().base;
export const useResolvedPath = (path) => {
const route = useRoute();
return createMemo(() => route.resolvePath(path()));
};
export const useHref = (to) => {
const router = useRouter();
return createMemo(() => {
const to_ = to();
return to_ !== undefined ? router.renderPath(to_) : to_;
});
};
/**
* Retrieves method to do navigation. The method accepts a path to navigate to and an optional object with the following options:
*
* - resolve (*boolean*, default `true`): resolve the path against the current route
* - replace (*boolean*, default `false`): replace the history entry
* - scroll (*boolean*, default `true`): scroll to top after navigation
* - state (*any*, default `undefined`): pass custom state to `location.state`
*
* **Note**: The state is serialized using the structured clone algorithm which does not support all object types.
*
* @example
* ```js
* const navigate = useNavigate();
*
* if (unauthorized) {
* navigate("/login", { replace: true });
* }
* ```
*/
export const useNavigate = () => useRouter().navigatorFactory();
/**
* Retrieves reactive `location` object useful for getting things like `pathname`.
*
* @example
* ```js
* const location = useLocation();
*
* const pathname = createMemo(() => parsePath(location.pathname));
* ```
*/
export const useLocation = () => useRouter().location;
/**
* Retrieves signal that indicates whether the route is currently in a *Transition*.
* Useful for showing stale/pending state when the route resolution is *Suspended* during concurrent rendering.
*
* @example
* ```js
* const isRouting = useIsRouting();
*
* return (
* <div classList={{ "grey-out": isRouting() }}>
* <MyAwesomeContent />
* </div>
* );
* ```
*/
export const useIsRouting = () => useRouter().isRouting;
/**
* usePreloadRoute returns a function that can be used to preload a route manual.
* This is what happens automatically with link hovering and similar focus based behavior, but it is available here as an API.
*
* @example
* ```js
* const preload = usePreloadRoute();
*
* preload(`/users/settings`, { preloadData: true });
* ```
*/
export const usePreloadRoute = () => {
const pre = useRouter().preloadRoute;
return (url, options = {}) => pre(url instanceof URL ? url : new URL(url, mockBase), options.preloadData);
};
/**
* `useMatch` takes an accessor that returns the path and creates a `Memo` that returns match information if the current path matches the provided path.
* Useful for determining if a given path matches the current route.
*
* @example
* ```js
* const match = useMatch(() => props.href);
*
* return <div classList={{ active: Boolean(match()) }} />;
* ```
*/
export const useMatch = (path, matchFilters) => {
const location = useLocation();
const matchers = createMemo(() => expandOptionals(path()).map(path => createMatcher(path, undefined, matchFilters)));
return createMemo(() => {
for (const matcher of matchers()) {
const match = matcher(location.pathname);
if (match)
return match;
}
});
};
/**
* `useCurrentMatches` returns all the matches for the current matched route.
* Useful for getting all the route information.
*
* @example
* ```js
* const matches = useCurrentMatches();
*
* const breadcrumbs = createMemo(() => matches().map(m => m.route.info.breadcrumb))
* ```
*/
export const useCurrentMatches = () => useRouter().matches;
/**
* Retrieves a reactive, store-like object containing the current route path parameters as defined in the Route.
*
* @example
* ```js
* const params = useParams();
*
* // fetch user based on the id path parameter
* const [user] = createResource(() => params.id, fetchUser);
* ```
*/
export const useParams = () => useRouter().params;
/**
* Retrieves a tuple containing a reactive object to read the current location's query parameters and a method to update them.
* The object is a proxy so you must access properties to subscribe to reactive updates.
* **Note** that values will be strings and property names will retain their casing.
*
* The setter method accepts an object whose entries will be merged into the current query string.
* Values `''`, `undefined` and `null` will remove the key from the resulting query string.
* Updates will behave just like a navigation and the setter accepts the same optional second parameter as `navigate` and auto-scrolling is disabled by default.
*
* @examples
* ```js
* const [searchParams, setSearchParams] = useSearchParams();
*
* return (
* <div>
* <span>Page: {searchParams.page}</span>
* <button
* onClick={() =>
* setSearchParams({ page: (parseInt(searchParams.page) || 0) + 1 })
* }
* >
* Next Page
* </button>
* </div>
* );
* ```
*/
export const useSearchParams = () => {
const location = useLocation();
const navigate = useNavigate();
const setSearchParams = (params, options) => {
const searchString = untrack(() => mergeSearchString(location.search, params) + location.hash);
navigate(searchString, {
scroll: false,
resolve: false,
...options
});
};
return [location.query, setSearchParams];
};
/**
* useBeforeLeave takes a function that will be called prior to leaving a route.
* The function will be called with:
*
* - from (*Location*): current location (before change).
* - to (*string | number*): path passed to `navigate`.
* - options (*NavigateOptions*): options passed to navigate.
* - preventDefault (*function*): call to block the route change.
* - defaultPrevented (*readonly boolean*): `true` if any previously called leave handlers called `preventDefault`.
* - retry (*function*, force?: boolean ): call to retry the same navigation, perhaps after confirming with the user. Pass `true` to skip running the leave handlers again (i.e. force navigate without confirming).
*
* @example
* ```js
* useBeforeLeave((e: BeforeLeaveEventArgs) => {
* if (form.isDirty && !e.defaultPrevented) {
* // preventDefault to block immediately and prompt user async
* e.preventDefault();
* setTimeout(() => {
* if (window.confirm("Discard unsaved changes - are you sure?")) {
* // user wants to proceed anyway so retry with force=true
* e.retry(true);
* }
* }, 100);
* }
* });
* ```
*/
export const useBeforeLeave = (listener) => {
const s = useRouter().beforeLeave.subscribe({
listener,
location: useLocation(),
navigate: useNavigate()
});
onCleanup(s);
};
export function createRoutes(routeDef, base = "") {
const { component, preload, load, children, info } = routeDef;
const isLeaf = !children || (Array.isArray(children) && !children.length);
const shared = {
key: routeDef,
component,
preload: preload || load,
info
};
return asArray(routeDef.path).reduce((acc, originalPath) => {
for (const expandedPath of expandOptionals(originalPath)) {
const path = joinPaths(base, expandedPath);
let pattern = isLeaf ? path : path.split("/*", 1)[0];
pattern = pattern
.split("/")
.map((s) => {
return s.startsWith(":") || s.startsWith("*") ? s : encodeURIComponent(s);
})
.join("/");
acc.push({
...shared,
originalPath,
pattern,
matcher: createMatcher(pattern, !isLeaf, routeDef.matchFilters)
});
}
return acc;
}, []);
}
export function createBranch(routes, index = 0) {
return {
routes,
score: scoreRoute(routes[routes.length - 1]) * 10000 - index,
matcher(location) {
const matches = [];
for (let i = routes.length - 1; i >= 0; i--) {
const route = routes[i];
const match = route.matcher(location);
if (!match) {
return null;
}
matches.unshift({
...match,
route
});
}
return matches;
}
};
}
function asArray(value) {
return Array.isArray(value) ? value : [value];
}
export function createBranches(routeDef, base = "", stack = [], branches = []) {
const routeDefs = asArray(routeDef);
for (let i = 0, len = routeDefs.length; i < len; i++) {
const def = routeDefs[i];
if (def && typeof def === "object") {
if (!def.hasOwnProperty("path"))
def.path = "";
const routes = createRoutes(def, base);
for (const route of routes) {
stack.push(route);
const isEmptyArray = Array.isArray(def.children) && def.children.length === 0;
if (def.children && !isEmptyArray) {
createBranches(def.children, route.pattern, stack, branches);
}
else {
const branch = createBranch([...stack], branches.length);
branches.push(branch);
}
stack.pop();
}
}
}
// Stack will be empty on final return
return stack.length ? branches : branches.sort((a, b) => b.score - a.score);
}
export function getRouteMatches(branches, location) {
for (let i = 0, len = branches.length; i < len; i++) {
const match = branches[i].matcher(location);
if (match) {
return match;
}
}
return [];
}
function createLocation(path, state, queryWrapper) {
const origin = new URL(mockBase);
const url = createMemo(prev => {
const path_ = path();
try {
return new URL(path_, origin);
}
catch (err) {
console.error(`Invalid path ${path_}`);
return prev;
}
}, origin, {
equals: (a, b) => a.href === b.href
});
const pathname = createMemo(() => url().pathname);
const search = createMemo(() => url().search, true);
const hash = createMemo(() => url().hash);
const key = () => "";
const queryFn = on(search, () => extractSearchParams(url()));
return {
get pathname() {
return pathname();
},
get search() {
return search();
},
get hash() {
return hash();
},
get state() {
return state();
},
get key() {
return key();
},
query: queryWrapper ? queryWrapper(queryFn) : createMemoObject(queryFn)
};
}
let intent;
export function getIntent() {
return intent;
}
let inPreloadFn = false;
export function getInPreloadFn() {
return inPreloadFn;
}
export function setInPreloadFn(value) {
inPreloadFn = value;
}
export function createRouterContext(integration, branches, getContext, options = {}) {
const { signal: [source, setSource], utils = {} } = integration;
const parsePath = utils.parsePath || (p => p);
const renderPath = utils.renderPath || (p => p);
const beforeLeave = utils.beforeLeave || createBeforeLeave();
const basePath = resolvePath("", options.base || "");
if (basePath === undefined) {
throw new Error(`${basePath} is not a valid base path`);
}
else if (basePath && !source().value) {
setSource({ value: basePath, replace: true, scroll: false });
}
const [isRouting, setIsRouting] = createSignal(false);
// Keep track of last target, so that last call to transition wins
let lastTransitionTarget;
// Transition the location to a new value
const transition = (newIntent, newTarget) => {
if (newTarget.value === reference() && newTarget.state === state())
return;
if (lastTransitionTarget === undefined)
setIsRouting(true);
intent = newIntent;
lastTransitionTarget = newTarget;
startTransition(() => {
if (lastTransitionTarget !== newTarget)
return;
setReference(lastTransitionTarget.value);
setState(lastTransitionTarget.state);
resetErrorBoundaries();
if (!isServer)
submissions[1](subs => subs.filter(s => s.pending));
}).finally(() => {
if (lastTransitionTarget !== newTarget)
return;
// Batch, in order for isRouting and final source update to happen together
batch(() => {
intent = undefined;
if (newIntent === "navigate")
navigateEnd(lastTransitionTarget);
setIsRouting(false);
lastTransitionTarget = undefined;
});
});
};
const [reference, setReference] = createSignal(source().value);
const [state, setState] = createSignal(source().state);
const location = createLocation(reference, state, utils.queryWrapper);
const referrers = [];
const submissions = createSignal(isServer ? initFromFlash() : []);
const matches = createMemo(() => {
if (typeof options.transformUrl === "function") {
return getRouteMatches(branches(), options.transformUrl(location.pathname));
}
return getRouteMatches(branches(), location.pathname);
});
const buildParams = () => {
const m = matches();
const params = {};
for (let i = 0; i < m.length; i++) {
Object.assign(params, m[i].params);
}
return params;
};
const params = utils.paramsWrapper
? utils.paramsWrapper(buildParams, branches)
: createMemoObject(buildParams);
const baseRoute = {
pattern: basePath,
path: () => basePath,
outlet: () => null,
resolvePath(to) {
return resolvePath(basePath, to);
}
};
// Create a native transition, when source updates
createRenderEffect(on(source, source => transition("native", source), { defer: true }));
return {
base: baseRoute,
location,
params,
isRouting,
renderPath,
parsePath,
navigatorFactory,
matches,
beforeLeave,
preloadRoute,
singleFlight: options.singleFlight === undefined ? true : options.singleFlight,
submissions
};
function navigateFromRoute(route, to, options) {
// Untrack in case someone navigates in an effect - don't want to track `reference` or route paths
untrack(() => {
if (typeof to === "number") {
if (!to) {
// A delta of 0 means stay at the current location, so it is ignored
}
else if (utils.go) {
utils.go(to);
}
else {
console.warn("Router integration does not support relative routing");
}
return;
}
const queryOnly = !to || to[0] === "?";
const { replace, resolve, scroll, state: nextState } = {
replace: false,
resolve: !queryOnly,
scroll: true,
...options
};
const resolvedTo = resolve
? route.resolvePath(to)
: resolvePath((queryOnly && location.pathname) || "", to);
if (resolvedTo === undefined) {
throw new Error(`Path '${to}' is not a routable path`);
}
else if (referrers.length >= MAX_REDIRECTS) {
throw new Error("Too many redirects");
}
const current = reference();
if (resolvedTo !== current || nextState !== state()) {
if (isServer) {
const e = getRequestEvent();
e && (e.response = { status: 302, headers: new Headers({ Location: resolvedTo }) });
setSource({ value: resolvedTo, replace, scroll, state: nextState });
}
else if (beforeLeave.confirm(resolvedTo, options)) {
referrers.push({ value: current, replace, scroll, state: state() });
transition("navigate", {
value: resolvedTo,
state: nextState
});
}
}
});
}
function navigatorFactory(route) {
// Workaround for vite issue (https://github.com/vitejs/vite/issues/3803)
route = route || useContext(RouteContextObj) || baseRoute;
return (to, options) => navigateFromRoute(route, to, options);
}
function navigateEnd(next) {
const first = referrers[0];
if (first) {
setSource({
...next,
replace: first.replace,
scroll: first.scroll
});
referrers.length = 0;
}
}
function preloadRoute(url, preloadData) {
const matches = getRouteMatches(branches(), url.pathname);
const prevIntent = intent;
intent = "preload";
for (let match in matches) {
const { route, params } = matches[match];
route.component &&
route.component.preload &&
route.component.preload();
const { preload } = route;
inPreloadFn = true;
preloadData &&
preload &&
runWithOwner(getContext(), () => preload({
params,
location: {
pathname: url.pathname,
search: url.search,
hash: url.hash,
query: extractSearchParams(url),
state: null,
key: ""
},
intent: "preload"
}));
inPreloadFn = false;
}
intent = prevIntent;
}
function initFromFlash() {
const e = getRequestEvent();
return (e && e.router && e.router.submission ? [e.router.submission] : []);
}
}
export function createRouteContext(router, parent, outlet, match) {
const { base, location, params } = router;
const { pattern, component, preload } = match().route;
const path = createMemo(() => match().path);
component &&
component.preload &&
component.preload();
inPreloadFn = true;
const data = preload ? preload({ params, location, intent: intent || "initial" }) : undefined;
inPreloadFn = false;
const route = {
parent,
pattern,
path,
outlet: () => component
? createComponent(component, {
params,
location,
data,
get children() {
return outlet();
}
})
: outlet(),
resolvePath(to) {
return resolvePath(base.path(), to, path());
}
};
return route;
}

View File

@@ -0,0 +1,200 @@
import type { Component, JSX, Signal } from "solid-js";
declare module "solid-js/web" {
interface RequestEvent {
response: {
status?: number;
statusText?: string;
headers: Headers;
};
router?: {
matches?: OutputMatch[];
cache?: Map<string, CacheEntry>;
submission?: {
input: any;
result: any;
url: string;
};
dataOnly?: boolean | string[];
data?: Record<string, any>;
previousUrl?: string;
};
serverOnly?: boolean;
}
}
export type Params = Record<string, string | undefined>;
export type SearchParams = Record<string, string | string[] | undefined>;
export type SetParams = Record<string, string | number | boolean | null | undefined>;
export type SetSearchParams = Record<string, string | string[] | number | number[] | boolean | boolean[] | null | undefined>;
export interface Path {
pathname: string;
search: string;
hash: string;
}
export interface Location<S = unknown> extends Path {
query: SearchParams;
state: Readonly<Partial<S>> | null;
key: string;
}
export interface NavigateOptions<S = unknown> {
resolve: boolean;
replace: boolean;
scroll: boolean;
state: S;
}
export interface Navigator {
(to: string | number, options?: Partial<NavigateOptions>): void;
(delta: number): void;
}
export type NavigatorFactory = (route?: RouteContext) => Navigator;
export interface LocationChange<S = unknown> {
value: string;
replace?: boolean;
scroll?: boolean;
state?: S;
rawPath?: string;
}
export interface RouterIntegration {
signal: Signal<LocationChange>;
create?: (router: RouterContext) => void;
utils?: Partial<RouterUtils>;
}
export type Intent = "initial" | "native" | "navigate" | "preload";
export interface RoutePreloadFuncArgs {
params: Params;
location: Location;
intent: Intent;
}
export type RoutePreloadFunc<T = unknown> = (args: RoutePreloadFuncArgs) => T;
export interface RouteSectionProps<T = unknown> {
params: Params;
location: Location;
data: T;
children?: JSX.Element;
}
export type RouteDefinition<S extends string | string[] = any, T = unknown> = {
path?: S;
matchFilters?: MatchFilters<S>;
preload?: RoutePreloadFunc<T>;
children?: RouteDefinition | RouteDefinition[];
component?: Component<RouteSectionProps<T>>;
info?: Record<string, any>;
/** @deprecated use preload */
load?: RoutePreloadFunc;
};
export type MatchFilter = readonly string[] | RegExp | ((s: string) => boolean);
export type PathParams<P extends string | readonly string[]> = P extends `${infer Head}/${infer Tail}` ? [...PathParams<Head>, ...PathParams<Tail>] : P extends `:${infer S}?` ? [S] : P extends `:${infer S}` ? [S] : P extends `*${infer S}` ? [S] : [];
export type MatchFilters<P extends string | readonly string[] = any> = P extends string ? {
[K in PathParams<P>[number]]?: MatchFilter;
} : Record<string, MatchFilter>;
export interface PathMatch {
params: Params;
path: string;
}
export interface RouteMatch extends PathMatch {
route: RouteDescription;
}
export interface OutputMatch {
path: string;
pattern: string;
match: string;
params: Params;
info?: Record<string, any>;
}
export interface RouteDescription {
key: unknown;
originalPath: string;
pattern: string;
component?: Component<RouteSectionProps>;
preload?: RoutePreloadFunc;
matcher: (location: string) => PathMatch | null;
matchFilters?: MatchFilters;
info?: Record<string, any>;
}
export interface Branch {
routes: RouteDescription[];
score: number;
matcher: (location: string) => RouteMatch[] | null;
}
export interface RouteContext {
parent?: RouteContext;
child?: RouteContext;
pattern: string;
path: () => string;
outlet: () => JSX.Element;
resolvePath(to: string): string | undefined;
}
export interface RouterUtils {
renderPath(path: string): string;
parsePath(str: string): string;
go(delta: number): void;
beforeLeave: BeforeLeaveLifecycle;
paramsWrapper: (getParams: () => Params, branches: () => Branch[]) => Params;
queryWrapper: (getQuery: () => SearchParams) => SearchParams;
}
export interface RouterContext {
base: RouteContext;
location: Location;
params: Params;
navigatorFactory: NavigatorFactory;
isRouting: () => boolean;
matches: () => RouteMatch[];
renderPath(path: string): string;
parsePath(str: string): string;
beforeLeave: BeforeLeaveLifecycle;
preloadRoute: (url: URL, preloadData?: boolean) => void;
singleFlight: boolean;
submissions: Signal<Submission<any, any>[]>;
}
export interface BeforeLeaveEventArgs {
from: Location;
to: string | number;
options?: Partial<NavigateOptions>;
readonly defaultPrevented: boolean;
preventDefault(): void;
retry(force?: boolean): void;
}
export interface BeforeLeaveListener {
listener(e: BeforeLeaveEventArgs): void;
location: Location;
navigate: Navigator;
}
export interface BeforeLeaveLifecycle {
subscribe(listener: BeforeLeaveListener): () => void;
confirm(to: string | number, options?: Partial<NavigateOptions>): boolean;
}
export type Submission<T, U> = {
readonly input: T;
readonly result?: U;
readonly error: any;
readonly pending: boolean;
readonly url: string;
clear: () => void;
retry: () => void;
};
export type SubmissionStub = {
readonly input: undefined;
readonly result: undefined;
readonly error: undefined;
readonly pending: undefined;
readonly url: undefined;
clear: () => void;
retry: () => void;
};
export interface MaybePreloadableComponent extends Component {
preload?: () => void;
}
export type CacheEntry = [number, Promise<any>, any, Intent | undefined, Signal<number> & {
count: number;
}];
export type NarrowResponse<T> = T extends CustomResponse<infer U> ? U : Exclude<T, Response>;
export type RouterResponseInit = Omit<ResponseInit, "body"> & {
revalidate?: string | string[];
};
export type CustomResponse<T> = Omit<Response, "clone"> & {
customBody: () => T;
clone(...args: readonly unknown[]): CustomResponse<T>;
};
/** @deprecated */
export type RouteLoadFunc = RoutePreloadFunc;
/** @deprecated */
export type RouteLoadFuncArgs = RoutePreloadFuncArgs;

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,13 @@
import type { MatchFilters, PathMatch, RouteDescription, SearchParams, SetSearchParams } from "./types.js";
export declare const mockBase = "http://sr";
export declare function normalizePath(path: string, omitSlash?: boolean): string;
export declare function resolvePath(base: string, path: string, from?: string): string | undefined;
export declare function invariant<T>(value: T | null | undefined, message: string): T;
export declare function joinPaths(from: string, to: string): string;
export declare function extractSearchParams(url: URL): SearchParams;
export declare function createMatcher<S extends string>(path: S, partial?: boolean, matchFilters?: MatchFilters<S>): (location: string) => PathMatch | null;
export declare function scoreRoute(route: RouteDescription): number;
export declare function createMemoObject<T extends Record<string | symbol, unknown>>(fn: () => T): T;
export declare function mergeSearchString(search: string, params: SetSearchParams): string;
export declare function expandOptionals(pattern: string): string[];
export declare function setFunctionName<T>(obj: T, value: string): T;

View File

@@ -0,0 +1,185 @@
import { createMemo, getOwner, runWithOwner } from "solid-js";
const hasSchemeRegex = /^(?:[a-z0-9]+:)?\/\//i;
const trimPathRegex = /^\/+|(\/)\/+$/g;
export const mockBase = "http://sr";
export function normalizePath(path, omitSlash = false) {
const s = path.replace(trimPathRegex, "$1");
return s ? (omitSlash || /^[?#]/.test(s) ? s : "/" + s) : "";
}
export function resolvePath(base, path, from) {
if (hasSchemeRegex.test(path)) {
return undefined;
}
const basePath = normalizePath(base);
const fromPath = from && normalizePath(from);
let result = "";
if (!fromPath || path.startsWith("/")) {
result = basePath;
}
else if (fromPath.toLowerCase().indexOf(basePath.toLowerCase()) !== 0) {
result = basePath + fromPath;
}
else {
result = fromPath;
}
return (result || "/") + normalizePath(path, !result);
}
export function invariant(value, message) {
if (value == null) {
throw new Error(message);
}
return value;
}
export function joinPaths(from, to) {
return normalizePath(from).replace(/\/*(\*.*)?$/g, "") + normalizePath(to);
}
export function extractSearchParams(url) {
const params = {};
url.searchParams.forEach((value, key) => {
if (key in params) {
if (Array.isArray(params[key]))
params[key].push(value);
else
params[key] = [params[key], value];
}
else
params[key] = value;
});
return params;
}
export function createMatcher(path, partial, matchFilters) {
const [pattern, splat] = path.split("/*", 2);
const segments = pattern.split("/").filter(Boolean);
const len = segments.length;
return (location) => {
const locSegments = location.split("/").filter(Boolean);
const lenDiff = locSegments.length - len;
if (lenDiff < 0 || (lenDiff > 0 && splat === undefined && !partial)) {
return null;
}
const match = {
path: len ? "" : "/",
params: {}
};
const matchFilter = (s) => matchFilters === undefined ? undefined : matchFilters[s];
for (let i = 0; i < len; i++) {
const segment = segments[i];
const dynamic = segment[0] === ":";
const locSegment = dynamic ? locSegments[i] : locSegments[i].toLowerCase();
const key = dynamic ? segment.slice(1) : segment.toLowerCase();
if (dynamic && matchSegment(locSegment, matchFilter(key))) {
match.params[key] = locSegment;
}
else if (dynamic || !matchSegment(locSegment, key)) {
return null;
}
match.path += `/${locSegment}`;
}
if (splat) {
const remainder = lenDiff ? locSegments.slice(-lenDiff).join("/") : "";
if (matchSegment(remainder, matchFilter(splat))) {
match.params[splat] = remainder;
}
else {
return null;
}
}
return match;
};
}
function matchSegment(input, filter) {
const isEqual = (s) => s === input;
if (filter === undefined) {
return true;
}
else if (typeof filter === "string") {
return isEqual(filter);
}
else if (typeof filter === "function") {
return filter(input);
}
else if (Array.isArray(filter)) {
return filter.some(isEqual);
}
else if (filter instanceof RegExp) {
return filter.test(input);
}
return false;
}
export function scoreRoute(route) {
const [pattern, splat] = route.pattern.split("/*", 2);
const segments = pattern.split("/").filter(Boolean);
return segments.reduce((score, segment) => score + (segment.startsWith(":") ? 2 : 3), segments.length - (splat === undefined ? 0 : 1));
}
export function createMemoObject(fn) {
const map = new Map();
const owner = getOwner();
return new Proxy({}, {
get(_, property) {
if (!map.has(property)) {
runWithOwner(owner, () => map.set(property, createMemo(() => fn()[property])));
}
return map.get(property)();
},
getOwnPropertyDescriptor() {
return {
enumerable: true,
configurable: true
};
},
ownKeys() {
return Reflect.ownKeys(fn());
},
has(_, property) {
return property in fn();
}
});
}
export function mergeSearchString(search, params) {
const merged = new URLSearchParams(search);
Object.entries(params).forEach(([key, value]) => {
if (value == null || value === "" || (value instanceof Array && !value.length)) {
merged.delete(key);
}
else {
if (value instanceof Array) {
// Delete all instances of the key before appending
merged.delete(key);
value.forEach(v => {
merged.append(key, String(v));
});
}
else {
merged.set(key, String(value));
}
}
});
const s = merged.toString();
return s ? `?${s}` : "";
}
export function expandOptionals(pattern) {
let match = /(\/?\:[^\/]+)\?/.exec(pattern);
if (!match)
return [pattern];
let prefix = pattern.slice(0, match.index);
let suffix = pattern.slice(match.index + match[0].length);
const prefixes = [prefix, (prefix += match[1])];
// This section handles adjacent optional params. We don't actually want all permuations since
// that will lead to equivalent routes which have the same number of params. For example
// `/:a?/:b?/:c`? only has the unique expansion: `/`, `/:a`, `/:a/:b`, `/:a/:b/:c` and we can
// discard `/:b`, `/:c`, `/:b/:c` by building them up in order and not recursing. This also helps
// ensure predictability where earlier params have precidence.
while ((match = /^(\/\:[^\/]+)\?/.exec(suffix))) {
prefixes.push((prefix += match[1]));
suffix = suffix.slice(match[0].length);
}
return expandOptionals(suffix).reduce((results, expansion) => [...results, ...prefixes.map(p => p + expansion)], []);
}
export function setFunctionName(obj, value) {
Object.defineProperty(obj, "name", {
value,
writable: false,
configurable: false
});
return obj;
}

View File

@@ -0,0 +1,61 @@
{
"name": "@solidjs/router",
"description": "Universal router for SolidJS",
"author": "Ryan Carniato",
"contributors": [
"Ryan Turnquist"
],
"license": "MIT",
"version": "0.16.1",
"homepage": "https://github.com/solidjs/solid-router#readme",
"repository": {
"type": "git",
"url": "https://github.com/solidjs/solid-router"
},
"publishConfig": {
"access": "public"
},
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"solid": "./dist/index.jsx",
"default": "./dist/index.js"
}
},
"files": [
"dist"
],
"sideEffects": false,
"devDependencies": {
"@babel/core": "^7.26.0",
"@babel/preset-typescript": "^7.26.0",
"@changesets/cli": "^2.27.10",
"@rollup/plugin-babel": "6.0.4",
"@rollup/plugin-node-resolve": "15.3.0",
"@rollup/plugin-terser": "0.4.4",
"@types/jest": "^29.5.14",
"@types/node": "^22.10.0",
"babel-preset-solid": "^1.9.3",
"jsdom": "^25.0.1",
"prettier": "^3.4.1",
"rollup": "^4.27.4",
"solid-js": "^1.9.3",
"typescript": "^5.7.2",
"vite": "^6.0.0",
"vite-plugin-solid": "^2.11.0",
"vitest": "^2.1.6"
},
"peerDependencies": {
"solid-js": "^1.8.6"
},
"scripts": {
"build": "rm -rf dist && tsc && rollup -c",
"test": "vitest run && npm run test:types",
"test:watch": "vitest",
"test:types": "tsc --project tsconfig.test.json",
"pretty": "prettier --write \"{src,test}/**/*.{ts,tsx}\"",
"release": "pnpm build && changeset publish"
}
}

View File

@@ -0,0 +1,19 @@
Copyright (c) 2017-2018 Fredrik Nicol
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,291 @@
# CSSType
[![npm](https://img.shields.io/npm/v/csstype.svg)](https://www.npmjs.com/package/csstype)
TypeScript and Flow definitions for CSS, generated by [data from MDN](https://github.com/mdn/data). It provides autocompletion and type checking for CSS properties and values.
**TypeScript**
```ts
import type * as CSS from 'csstype';
const style: CSS.Properties = {
colour: 'white', // Type error on property
textAlign: 'middle', // Type error on value
};
```
**Flow**
```js
// @flow strict
import * as CSS from 'csstype';
const style: CSS.Properties<> = {
colour: 'white', // Type error on property
textAlign: 'middle', // Type error on value
};
```
_Further examples below will be in TypeScript!_
## Getting started
```sh
$ npm install csstype
```
## Table of content
- [Style types](#style-types)
- [At-rule types](#at-rule-types)
- [Pseudo types](#pseudo-types)
- [Generics](#generics)
- [Usage](#usage)
- [What should I do when I get type errors?](#what-should-i-do-when-i-get-type-errors)
- [Version 3.0](#version-30)
- [Contributing](#contributing)
## Style types
Properties are categorized in different uses and in several technical variations to provide typings that suits as many as possible.
| | Default | `Hyphen` | `Fallback` | `HyphenFallback` |
| -------------- | -------------------- | -------------------------- | ---------------------------- | ---------------------------------- |
| **All** | `Properties` | `PropertiesHyphen` | `PropertiesFallback` | `PropertiesHyphenFallback` |
| **`Standard`** | `StandardProperties` | `StandardPropertiesHyphen` | `StandardPropertiesFallback` | `StandardPropertiesHyphenFallback` |
| **`Vendor`** | `VendorProperties` | `VendorPropertiesHyphen` | `VendorPropertiesFallback` | `VendorPropertiesHyphenFallback` |
| **`Obsolete`** | `ObsoleteProperties` | `ObsoletePropertiesHyphen` | `ObsoletePropertiesFallback` | `ObsoletePropertiesHyphenFallback` |
| **`Svg`** | `SvgProperties` | `SvgPropertiesHyphen` | `SvgPropertiesFallback` | `SvgPropertiesHyphenFallback` |
Categories:
- **All** - Includes `Standard`, `Vendor`, `Obsolete` and `Svg`
- **`Standard`** - Current properties and extends subcategories `StandardLonghand` and `StandardShorthand` _(e.g. `StandardShorthandProperties`)_
- **`Vendor`** - Vendor prefixed properties and extends subcategories `VendorLonghand` and `VendorShorthand` _(e.g. `VendorShorthandProperties`)_
- **`Obsolete`** - Removed or deprecated properties
- **`Svg`** - SVG-specific properties
Variations:
- **Default** - JavaScript (camel) cased property names
- **`Hyphen`** - CSS (kebab) cased property names
- **`Fallback`** - Also accepts array of values e.g. `string | string[]`
## At-rule types
At-rule interfaces with descriptors.
**TypeScript**: These will be found in the `AtRule` namespace, e.g. `AtRule.Viewport`.
**Flow**: These will be prefixed with `AtRule$`, e.g. `AtRule$Viewport`.
| | Default | `Hyphen` | `Fallback` | `HyphenFallback` |
| -------------------- | -------------- | -------------------- | ---------------------- | ---------------------------- |
| **`@counter-style`** | `CounterStyle` | `CounterStyleHyphen` | `CounterStyleFallback` | `CounterStyleHyphenFallback` |
| **`@font-face`** | `FontFace` | `FontFaceHyphen` | `FontFaceFallback` | `FontFaceHyphenFallback` |
| **`@viewport`** | `Viewport` | `ViewportHyphen` | `ViewportFallback` | `ViewportHyphenFallback` |
## Pseudo types
String literals of pseudo classes and pseudo elements
- `Pseudos`
Extends:
- `AdvancedPseudos`
Function-like pseudos e.g. `:not(:first-child)`. The string literal contains the value excluding the parenthesis: `:not`. These are separated because they require an argument that results in infinite number of variations.
- `SimplePseudos`
Plain pseudos e.g. `:hover` that can only be **one** variation.
## Generics
All interfaces has two optional generic argument to define length and time: `CSS.Properties<TLength = string | 0, TTime = string>`
- **Length** is the first generic parameter and defaults to `string | 0` because `0` is the only [length where the unit identifier is optional](https://drafts.csswg.org/css-values-3/#lengths). You can specify this, e.g. `string | number`, for platforms and libraries that accepts any numeric value as length with a specific unit.
```tsx
const style: CSS.Properties<string | number> = {
width: 100,
};
```
- **Time** is the second generic argument and defaults to `string`. You can specify this, e.g. `string | number`, for platforms and libraries that accepts any numeric value as length with a specific unit.
```tsx
const style: CSS.Properties<string | number, number> = {
transitionDuration: 1000,
};
```
## Usage
```ts
import type * as CSS from 'csstype';
const style: CSS.Properties = {
width: '10px',
margin: '1em',
};
```
In some cases, like for CSS-in-JS libraries, an array of values is a way to provide fallback values in CSS. Using `CSS.PropertiesFallback` instead of `CSS.Properties` will add the possibility to use any property value as an array of values.
```ts
import type * as CSS from 'csstype';
const style: CSS.PropertiesFallback = {
display: ['-webkit-flex', 'flex'],
color: 'white',
};
```
There's even string literals for pseudo selectors and elements.
```ts
import type * as CSS from 'csstype';
const pseudos: { [P in CSS.SimplePseudos]?: CSS.Properties } = {
':hover': {
display: 'flex',
},
};
```
Hyphen cased (kebab cased) properties are provided in `CSS.PropertiesHyphen` and `CSS.PropertiesHyphenFallback`. It's not **not** added by default in `CSS.Properties`. To allow both of them, you can simply extend with `CSS.PropertiesHyphen` or/and `CSS.PropertiesHyphenFallback`.
```ts
import type * as CSS from 'csstype';
interface Style extends CSS.Properties, CSS.PropertiesHyphen {}
const style: Style = {
'flex-grow': 1,
'flex-shrink': 0,
'font-weight': 'normal',
backgroundColor: 'white',
};
```
Adding type checked CSS properties to a `HTMLElement`.
```ts
import type * as CSS from 'csstype';
const style: CSS.Properties = {
color: 'red',
margin: '1em',
};
let button = document.createElement('button');
Object.assign(button.style, style);
```
## What should I do when I get type errors?
The goal is to have as perfect types as possible and we're trying to do our best. But with CSS Custom Properties, the CSS specification changing frequently and vendors implementing their own specifications with new releases sometimes causes type errors even if it should work. Here's some steps you could take to get it fixed:
_If you're using CSS Custom Properties you can step directly to step 3._
1. **First of all, make sure you're doing it right.** A type error could also indicate that you're not :wink:
- Some CSS specs that some vendors has implemented could have been officially rejected or haven't yet received any official acceptance and are therefor not included
- If you're using TypeScript, [type widening](https://blog.mariusschulz.com/2017/02/04/TypeScript-2-1-literal-type-widening) could be the reason you get `Type 'string' is not assignable to...` errors
2. **Have a look in [issues](https://github.com/frenic/csstype/issues) to see if an issue already has been filed. If not, create a new one.** To help us out, please refer to any information you have found.
3. Fix the issue locally with **TypeScript** (Flow further down):
- The recommended way is to use **module augmentation**. Here's a few examples:
```ts
// My css.d.ts file
import type * as CSS from 'csstype';
declare module 'csstype' {
interface Properties {
// Add a missing property
WebkitRocketLauncher?: string;
// Add a CSS Custom Property
'--theme-color'?: 'black' | 'white';
// Allow namespaced CSS Custom Properties
[index: `--theme-${string}`]: any;
// Allow any CSS Custom Properties
[index: `--${string}`]: any;
// ...or allow any other property
[index: string]: any;
}
}
```
- The alternative way is to use **type assertion**. Here's a few examples:
```ts
const style: CSS.Properties = {
// Add a missing property
['WebkitRocketLauncher' as any]: 'launching',
// Add a CSS Custom Property
['--theme-color' as any]: 'black',
};
```
Fix the issue locally with **Flow**:
- Use **type assertion**. Here's a few examples:
```js
const style: $Exact<CSS.Properties<*>> = {
// Add a missing property
[('WebkitRocketLauncher': any)]: 'launching',
// Add a CSS Custom Property
[('--theme-color': any)]: 'black',
};
```
## Version 3.2
- **No longer compatible with version 2**
Conflicts may occur when both version ^3.2.0 and ^2.0.0 are installed. Potential fix for Npm would be to force resolution in `package.json`:
```json
{
"overrides": {
"csstype": "^3.2.0"
}
}
```
## Version 3.1
- **Data types are exposed**
TypeScript: `DataType.Color`
Flow: `DataType$Color`
## Version 3.0
- **All property types are exposed with namespace**
TypeScript: `Property.AlignContent` (was `AlignContentProperty` before)
Flow: `Property$AlignContent`
- **All at-rules are exposed with namespace**
TypeScript: `AtRule.FontFace` (was `FontFace` before)
Flow: `AtRule$FontFace`
- **Data types are NOT exposed**
E.g. `Color` and `Box`. Because the generation of data types may suddenly be removed or renamed.
- **TypeScript hack for autocompletion**
Uses `(string & {})` for literal string unions and `(number & {})` for literal number unions ([related issue](https://github.com/microsoft/TypeScript/issues/29729)). Utilize `PropertyValue<T>` to unpack types from e.g. `(string & {})` to `string`.
- **New generic for time**
Read more on the ["Generics"](#generics) section.
- **Flow types improvements**
Flow Strict enabled and exact types are used.
## Contributing
**Never modify `index.d.ts` and `index.js.flow` directly. They are generated automatically and committed so that we can easily follow any change it results in.** Therefor it's important that you run `$ git config merge.ours.driver true` after you've forked and cloned. That setting prevents merge conflicts when doing rebase.
### Commands
- `npm run build` Generates typings and type checks them
- `npm run watch` Runs build on each save
- `npm run test` Runs the tests
- `npm run lazy` Type checks, lints and formats everything

22569
web/runtime/csstype/index.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,70 @@
{
"name": "csstype",
"version": "3.2.3",
"main": "",
"types": "index.d.ts",
"description": "Strict TypeScript and Flow types for style based on MDN data",
"repository": "https://github.com/frenic/csstype",
"author": "Fredrik Nicol <fredrik.nicol@gmail.com>",
"license": "MIT",
"devDependencies": {
"@babel/core": "^7.28.5",
"@babel/preset-env": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@eslint/js": "^9.39.1",
"@mdn/browser-compat-data": "7.1.21",
"@tsconfig/node24": "^24.0.2",
"@types/chokidar": "^2.1.7",
"@types/css-tree": "^2.3.11",
"@types/jest": "^30.0.0",
"@types/jsdom": "^27.0.0",
"@types/node": "^24.10.1",
"@types/prettier": "^3.0.0",
"@types/turndown": "^5.0.6",
"babel-jest": "^30.2.0",
"chalk": "^5.6.2",
"chokidar": "^4.0.3",
"css-tree": "^3.1.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"flow-bin": "^0.291.0",
"jest": "^30.2.0",
"jsdom": "^27.2.0",
"mdn-data": "2.25.0",
"prettier": "^3.6.2",
"release-it": "^19.0.6",
"tsx": "^4.20.6",
"turndown": "^7.2.2",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4"
},
"overrides": {
"js-yaml": ">=4.1.1"
},
"scripts": {
"prepublish": "npm install --no-save --prefix __tests__ && npm install --no-save --prefix __tests__/__fixtures__",
"release": "release-it",
"update": "tsx update.ts",
"build": "tsx --inspect build.ts --start",
"watch": "tsx build.ts --watch",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
"pretty": "prettier --write build.ts **/*.{ts,js,json,md}",
"lazy": "tsc && npm run lint",
"test": "jest --runInBand",
"test:src": "jest src.*.ts",
"test:dist": "jest dist.*.ts --runInBand"
},
"files": [
"index.d.ts",
"index.js.flow"
],
"keywords": [
"css",
"style",
"typescript",
"flow",
"typings",
"types",
"definitions"
]
}

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2016-2025 Ryan Carniato
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,223 @@
<p>
<img src="https://assets.solidjs.com/banner?project=Library&type=core" alt="SolidJS" />
</p>
[![Build Status](https://img.shields.io/github/actions/workflow/status/solidjs/solid/main-ci.yml?branch=main&logo=github&style=for-the-badge)](https://github.com/solidjs/solid/actions/workflows/main-ci.yml)
[![Coverage Status](https://img.shields.io/coveralls/github/solidjs/solid.svg?style=for-the-badge)](https://coveralls.io/github/solidjs/solid?branch=main)
[![NPM Version](https://img.shields.io/npm/v/solid-js.svg?style=for-the-badge)](https://www.npmjs.com/package/solid-js)
[![](https://img.shields.io/npm/dm/solid-js.svg?style=for-the-badge)](https://www.npmjs.com/package/solid-js)
[![Discord](https://img.shields.io/discord/722131463138705510?style=for-the-badge)](https://discord.com/invite/solidjs)
[![Subreddit subscribers](https://img.shields.io/reddit/subreddit-subscribers/solidjs?style=for-the-badge)](https://www.reddit.com/r/solidjs/)
**[Website](https://www.solidjs.com/) • [API Docs](https://docs.solidjs.com/) • [Features Tutorial](https://www.solidjs.com/tutorial/introduction_basics) • [Playground](https://playground.solidjs.com/?version=1.3.13#NobwRAdghgtgpmAXGGUCWEwBowBcCeADgsrgM4Ae2YZA9gK4BOAxiWGjIbY7gAQi9GcCABM4jXgF9eAM0a0YvADo1aAGzQiAtACsyAegDucAEYqA3EogcuPfr2ZCouOAGU0Ac2hqps+YpU6DW09CysrGXoIZlw0WgheAGEGCBdGAAoASn4rXgd4sj5gZhTcLF4yOFxkqNwAXV4AXgcnF3cvKDV0gAZMywT8iELeDEc4eFSm3iymgD4KqprU9JLamYBqXgBGPvCBoVwmBPTcvN4AHhN6XFx43gJiRpUrm-iVXnjEjWYAa0aQUZCCa4SSzU5nfirZaZSTgi76F63CBgga7CCwiBWISicTpGaNebnJZpXj6WblES0Zj0YEAOg8VQAompxsJcAAhfAASREJzAUEIhBUmTRYEkdSAA) • [Discord](https://discord.com/invite/solidjs)**
Solid is a declarative JavaScript library for creating user interfaces. Instead of using a Virtual DOM, it compiles its templates to real DOM nodes and updates them with fine-grained reactions. Declare your state and use it throughout your app, and when a piece of state changes, only the code that depends on it will rerun. Check out our [intro video](https://www.youtube.com/watch?v=cELFZQAMdhQ) or read on!
## Key Features
- Fine-grained updates to the real DOM
- Declarative data: model your state as a system with reactive primitives
- Render-once mental model: your components are regular JavaScript functions that run once to set up your view
- Automatic dependency tracking: accessing your reactive state subscribes to it
- [Small](https://dev.to/this-is-learning/javascript-framework-todomvc-size-comparison-504f) and [fast](https://krausest.github.io/js-framework-benchmark/current.html)
- Simple: learn a few powerful concepts that can be reused, combined, and built on top of
- Provides modern framework features like JSX, fragments, Context, Portals, Suspense, streaming SSR, progressive hydration, Error Boundaries and concurrent rendering.
- Naturally debuggable: A `<div>` is a real div, so you can use your browser's devtools to inspect the rendering
- [Web component friendly](https://github.com/solidjs/solid/tree/main/packages/solid-element#readme) and can author custom elements
- Isomorphic: render your components on the client and the server
- Universal: write [custom renderers](https://github.com/solidjs/solid/releases/tag/v1.2.0) to use Solid anywhere
- A growing community and ecosystem with active core team support
<details>
<summary>Quick Start</summary>
You can get started with a simple app by running the following in your terminal:
```sh
> npx degit solidjs/templates/js my-app
> cd my-app
> npm i # or yarn or pnpm
> npm run dev # or yarn or pnpm
```
Or for TypeScript:
```sh
> npx degit solidjs/templates/ts my-app
> cd my-app
> npm i # or yarn or pnpm
> npm run dev # or yarn or pnpm
```
This will create a minimal, client-rendered application powered by [Vite](https://vitejs.dev/).
Or you can install the dependencies in your own setup. To use Solid with JSX (_recommended_), run:
```sh
> npm i -D babel-preset-solid
> npm i solid-js
```
The easiest way to get set up is to add `babel-preset-solid` to your `.babelrc`, babel config for webpack, or rollup configuration:
```js
"presets": ["solid"]
```
For TypeScript to work, remember to set your `.tsconfig` to handle Solid's JSX:
```js
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "solid-js",
}
```
</details>
## Why Solid?
### Performant
Meticulously engineered for performance and with half a decade of research behind it, Solid's performance is almost indistinguishable from optimized vanilla JavaScript (See Solid on the [JS Framework Benchmark](https://krausest.github.io/js-framework-benchmark/current.html)). Solid is [small](https://bundlephobia.com/package/solid-js@1.3.15) and completely tree-shakable, and [fast](https://levelup.gitconnected.com/how-we-wrote-the-fastest-javascript-ui-framework-again-db097ddd99b6) when rendering on the server, too. Whether you're writing a fully client-rendered SPA or a server-rendered app, your users see it faster than ever. ([Read more about Solid's performance](https://dev.to/ryansolid/thinking-granular-how-is-solidjs-so-performant-4g37) from the library's creator.)
### Powerful
Solid is fully-featured with everything you can expect from a modern framework. Performant state management is built-in with Context and Stores: you don't have to reach for a third party library to manage global state (if you don't want to). With Resources, you can use data loaded from the server like any other piece of state and build a responsive UI for it thanks to Suspense and concurrent rendering. And when you're ready to move to the server, Solid has full SSR and serverless support, with streaming and progressive hydration to get to interactive as quickly as possible. (Check out our full [interactive features walkthrough](https://www.solidjs.com/tutorial/introduction_basics).)
### Pragmatic
Do more with less: use simple, composable primitives without hidden rules and gotchas. In Solid, components are just functions - rendering is determined purely by how your state is used - so you're free to organize your code how you like and you don't have to learn a new rendering system. Solid encourages patterns like declarative code and read-write segregation that help keep your project maintainable, but isn't opinionated enough to get in your way.
### Productive
Solid is built on established tools like JSX and TypeScript and integrates with the Vite ecosystem. Solid's bare-metal, minimal abstractions give you direct access to the DOM, making it easy to use your favorite native JavaScript libraries like D3. And the Solid ecosystem is growing fast, with [custom primitives](https://github.com/solidjs-community/solid-primitives), [component libraries](https://github.com/hope-ui/hope-ui), and build-time utilities that let you [write Solid code in new ways](https://github.com/LXSMNSYC/solid-labels).
<details>
<summary>Show Me!</summary>
```jsx
import { render } from "solid-js/web";
import { createSignal } from "solid-js";
// A component is just a function that (optionally) accepts properties and returns a DOM node
const Counter = props => {
// Create a piece of reactive state, giving us a accessor, count(), and a setter, setCount()
const [count, setCount] = createSignal(props.startingCount || 1);
// The increment function calls the setter
const increment = () => setCount(count() + 1);
console.log(
"The body of the function runs once, like you'd expect from calling any other function, so you only ever see this console log once."
);
// JSX allows us to write HTML within our JavaScript function and include dynamic expressions using the { } syntax
// The only part of this that will ever rerender is the count() text.
return (
<button type="button" onClick={increment}>
Increment {count()}
</button>
);
};
// The render function mounts a component onto your page
render(() => <Counter startingCount={2} />, document.getElementById("app"));
```
See it in action in our interactive [Playground](https://playground.solidjs.com/?hash=-894962706&version=1.3.13)!
Solid compiles our JSX down to efficient real DOM expressions updates, still using the same reactive primitives (`createSignal`) at runtime but making sure there's as little rerendering as possible. Here's what that looks like in this example:
```js
import { render, createComponent, delegateEvents, insert, template } from "solid-js/web";
import { createSignal } from "solid-js";
const _tmpl$ = /*#__PURE__*/ template(`<button type="button">Increment </button>`, 2);
const Counter = props => {
const [count, setCount] = createSignal(props.startingCount || 1);
const increment = () => setCount(count() + 1);
console.log("The body of the function runs once . . .");
return (() => {
//_el$ is a real DOM node!
const _el$ = _tmpl$.cloneNode(true);
_el$.firstChild;
_el$.$$click = increment;
//This inserts the count as a child of the button in a way that allows count to update without rerendering the whole button
insert(_el$, count, null);
return _el$;
})();
};
render(
() =>
createComponent(Counter, {
startingCount: 2
}),
document.getElementById("app")
);
delegateEvents(["click"]);
```
</details>
## More
Check out our official [documentation](https://www.solidjs.com/guide) or browse some [examples](https://github.com/solidjs/solid/blob/main/documentation/resources/examples.md)
## Browser Support
SolidJS Core is committed to supporting the last 2 years of modern browsers including Firefox, Safari, Chrome and Edge (for desktop and mobile devices). We do not support IE or similar sunset browsers. For server environments, we support Node LTS and the latest Deno and Cloudflare Worker runtimes.
<img src="https://saucelabs.github.io/images/opensauce/powered-by-saucelabs-badge-gray.svg?sanitize=true" alt="Testing Powered By SauceLabs" width="300"/>
## Community
Come chat with us on [Discord](https://discord.com/invite/solidjs)! Solid's creator and the rest of the core team are active there, and we're always looking for contributions.
### Contributors
<a href="https://github.com/solidjs/solid/graphs/contributors"><img src="https://contrib.rocks/image?repo=solidjs/solid" style="max-width:100%;"></a>
### Open Collective
Support us with a donation and help us continue our activities. [[Contribute](https://opencollective.com/solid)]
<a href="https://opencollective.com/solid/backer/0/website" target="_blank"><img src="https://opencollective.com/solid/backer/0/avatar.svg"></a>
<a href="https://opencollective.com/solid/backer/1/website" target="_blank"><img src="https://opencollective.com/solid/backer/1/avatar.svg"></a>
<a href="https://opencollective.com/solid/backer/2/website" target="_blank"><img src="https://opencollective.com/solid/backer/2/avatar.svg"></a>
<a href="https://opencollective.com/solid/backer/3/website" target="_blank"><img src="https://opencollective.com/solid/backer/3/avatar.svg"></a>
<a href="https://opencollective.com/solid/backer/4/website" target="_blank"><img src="https://opencollective.com/solid/backer/4/avatar.svg"></a>
<a href="https://opencollective.com/solid/backer/5/website" target="_blank"><img src="https://opencollective.com/solid/backer/5/avatar.svg"></a>
<a href="https://opencollective.com/solid/backer/6/website" target="_blank"><img src="https://opencollective.com/solid/backer/6/avatar.svg"></a>
<a href="https://opencollective.com/solid/backer/7/website" target="_blank"><img src="https://opencollective.com/solid/backer/7/avatar.svg"></a>
<a href="https://opencollective.com/solid/backer/8/website" target="_blank"><img src="https://opencollective.com/solid/backer/8/avatar.svg"></a>
<a href="https://opencollective.com/solid/backer/9/website" target="_blank"><img src="https://opencollective.com/solid/backer/9/avatar.svg"></a>
<a href="https://opencollective.com/solid/backer/10/website" target="_blank"><img src="https://opencollective.com/solid/backer/10/avatar.svg"></a>
### Sponsors
Become a sponsor and get your logo on our README on GitHub with a link to your site. [[Become a sponsor](https://opencollective.com/solid#sponsor)]
<a href="https://opencollective.com/solid/sponsor/0/website" target="_blank"><img src="https://opencollective.com/solid/sponsor/0/avatar.svg"></a>
<a href="https://opencollective.com/solid/sponsor/1/website" target="_blank"><img src="https://opencollective.com/solid/sponsor/1/avatar.svg"></a>
<a href="https://opencollective.com/solid/sponsor/2/website" target="_blank"><img src="https://opencollective.com/solid/sponsor/2/avatar.svg"></a>
<a href="https://opencollective.com/solid/sponsor/3/website" target="_blank"><img src="https://opencollective.com/solid/sponsor/3/avatar.svg"></a>
<a href="https://opencollective.com/solid/sponsor/4/website" target="_blank"><img src="https://opencollective.com/solid/sponsor/4/avatar.svg"></a>
<a href="https://opencollective.com/solid/sponsor/5/website" target="_blank"><img src="https://opencollective.com/solid/sponsor/5/avatar.svg"></a>
<a href="https://opencollective.com/solid/sponsor/6/website" target="_blank"><img src="https://opencollective.com/solid/sponsor/6/avatar.svg"></a>
<a href="https://opencollective.com/solid/sponsor/7/website" target="_blank"><img src="https://opencollective.com/solid/sponsor/7/avatar.svg"></a>
<a href="https://opencollective.com/solid/sponsor/8/website" target="_blank"><img src="https://opencollective.com/solid/sponsor/8/avatar.svg"></a>
<a href="https://opencollective.com/solid/sponsor/9/website" target="_blank"><img src="https://opencollective.com/solid/sponsor/9/avatar.svg"></a>
<a href="https://opencollective.com/solid/sponsor/10/website" target="_blank"><img src="https://opencollective.com/solid/sponsor/10/avatar.svg"></a>
<a href="https://opencollective.com/solid/sponsor/11/website" target="_blank"><img src="https://opencollective.com/solid/sponsor/11/avatar.svg"></a>
<a href="https://opencollective.com/solid/sponsor/12/website" target="_blank"><img src="https://opencollective.com/solid/sponsor/12/avatar.svg"></a>

1863
web/runtime/solid-js/dist/dev.cjs vendored Normal file

File diff suppressed because it is too large Load Diff

1808
web/runtime/solid-js/dist/dev.js vendored Normal file

File diff suppressed because it is too large Load Diff

817
web/runtime/solid-js/dist/server.cjs vendored Normal file
View File

@@ -0,0 +1,817 @@
'use strict';
const equalFn = (a, b) => a === b;
const $PROXY = Symbol("solid-proxy");
const $TRACK = Symbol("solid-track");
const $DEVCOMP = Symbol("solid-dev-component");
const DEV = undefined;
const ERROR = Symbol("error");
function castError(err) {
if (err instanceof Error) return err;
return new Error(typeof err === "string" ? err : "Unknown error", {
cause: err
});
}
function handleError(err, owner = Owner) {
const fns = owner && owner.context && owner.context[ERROR];
const error = castError(err);
if (!fns) throw error;
try {
for (const f of fns) f(error);
} catch (e) {
handleError(e, owner && owner.owner || null);
}
}
const UNOWNED = {
context: null,
owner: null,
owned: null,
cleanups: null
};
let Owner = null;
function createOwner() {
const o = {
owner: Owner,
context: Owner ? Owner.context : null,
owned: null,
cleanups: null
};
if (Owner) {
if (!Owner.owned) Owner.owned = [o];else Owner.owned.push(o);
}
return o;
}
function createRoot(fn, detachedOwner) {
const owner = Owner,
current = detachedOwner === undefined ? owner : detachedOwner,
root = fn.length === 0 ? UNOWNED : {
context: current ? current.context : null,
owner: current,
owned: null,
cleanups: null
};
Owner = root;
let result;
try {
result = fn(fn.length === 0 ? () => {} : () => cleanNode(root));
} catch (err) {
handleError(err);
} finally {
Owner = owner;
}
return result;
}
function createSignal(value, options) {
return [() => value, v => {
return value = typeof v === "function" ? v(value) : v;
}];
}
function createComputed(fn, value) {
Owner = createOwner();
try {
fn(value);
} catch (err) {
handleError(err);
} finally {
Owner = Owner.owner;
}
}
const createRenderEffect = createComputed;
function createEffect(fn, value) {}
function createReaction(fn) {
return fn => {
fn();
};
}
function createMemo(fn, value) {
Owner = createOwner();
let v;
try {
v = fn(value);
} catch (err) {
handleError(err);
} finally {
Owner = Owner.owner;
}
return () => v;
}
function createDeferred(source) {
return source;
}
function createSelector(source, fn = equalFn) {
return k => fn(k, source());
}
function batch(fn) {
return fn();
}
const untrack = batch;
function on(deps, fn, options = {}) {
const isArray = Array.isArray(deps);
const defer = options.defer;
return () => {
if (defer) return undefined;
let value;
if (isArray) {
value = [];
for (let i = 0; i < deps.length; i++) value.push(deps[i]());
} else value = deps();
return fn(value);
};
}
function onMount(fn) {}
function onCleanup(fn) {
if (Owner) {
if (!Owner.cleanups) Owner.cleanups = [fn];else Owner.cleanups.push(fn);
}
return fn;
}
function cleanNode(node) {
if (node.owned) {
for (let i = 0; i < node.owned.length; i++) cleanNode(node.owned[i]);
node.owned = null;
}
if (node.cleanups) {
for (let i = 0; i < node.cleanups.length; i++) node.cleanups[i]();
node.cleanups = null;
}
}
function catchError(fn, handler) {
const owner = createOwner();
owner.context = {
...owner.context,
[ERROR]: [handler]
};
Owner = owner;
try {
return fn();
} catch (err) {
handleError(err);
} finally {
Owner = Owner.owner;
}
}
function getListener() {
return null;
}
function createContext(defaultValue) {
const id = Symbol("context");
return {
id,
Provider: createProvider(id),
defaultValue
};
}
function useContext(context) {
return Owner && Owner.context && Owner.context[context.id] !== undefined ? Owner.context[context.id] : context.defaultValue;
}
function getOwner() {
return Owner;
}
function children(fn) {
const memo = createMemo(() => resolveChildren(fn()));
memo.toArray = () => {
const c = memo();
return Array.isArray(c) ? c : c != null ? [c] : [];
};
return memo;
}
function runWithOwner(o, fn) {
const prev = Owner;
Owner = o;
try {
return fn();
} catch (err) {
handleError(err);
} finally {
Owner = prev;
}
}
function resolveChildren(children) {
if (typeof children === "function" && !children.length) return resolveChildren(children());
if (Array.isArray(children)) {
const results = [];
for (let i = 0; i < children.length; i++) {
const result = resolveChildren(children[i]);
if (Array.isArray(result)) {
if (result.length < 32768) results.push.apply(results, result);else for (let j = 0; j < result.length; j++) results.push(result[j]);
} else {
results.push(result);
}
}
return results;
}
return children;
}
function createProvider(id) {
return function provider(props) {
return createMemo(() => {
Owner.context = {
...Owner.context,
[id]: props.value
};
return children(() => props.children);
});
};
}
function requestCallback(fn, options) {
return {
id: 0,
fn: () => {},
startTime: 0,
expirationTime: 0
};
}
function mapArray(list, mapFn, options = {}) {
const items = list();
let s = [];
if (items && items.length) {
for (let i = 0, len = items.length; i < len; i++) s.push(mapFn(items[i], () => i));
} else if (options.fallback) s = [options.fallback()];
return () => s;
}
function indexArray(list, mapFn, options = {}) {
const items = list();
let s = [];
if (items && items.length) {
for (let i = 0, len = items.length; i < len; i++) s.push(mapFn(() => items[i], i));
} else if (options.fallback) s = [options.fallback()];
return () => s;
}
function observable(input) {
return {
subscribe(observer) {
if (!(observer instanceof Object) || observer == null) {
throw new TypeError("Expected the observer to be an object.");
}
const handler = typeof observer === "function" ? observer : observer.next && observer.next.bind(observer);
if (!handler) {
return {
unsubscribe() {}
};
}
const dispose = createRoot(disposer => {
createEffect(() => {
const v = input();
untrack(() => handler(v));
});
return disposer;
});
if (getOwner()) onCleanup(dispose);
return {
unsubscribe() {
dispose();
}
};
},
[Symbol.observable || "@@observable"]() {
return this;
}
};
}
function from(producer) {
const [s, set] = createSignal(undefined);
if ("subscribe" in producer) {
const unsub = producer.subscribe(v => set(() => v));
onCleanup(() => "unsubscribe" in unsub ? unsub.unsubscribe() : unsub());
} else {
const clean = producer(set);
onCleanup(clean);
}
return s;
}
function enableExternalSource(factory) {}
function onError(fn) {
if (Owner) {
if (Owner.context === null || !Owner.context[ERROR]) {
Owner.context = {
...Owner.context,
[ERROR]: [fn]
};
mutateContext(Owner, ERROR, [fn]);
} else Owner.context[ERROR].push(fn);
}
}
function mutateContext(o, key, value) {
if (o.owned) {
for (let i = 0; i < o.owned.length; i++) {
if (o.owned[i].context === o.context) mutateContext(o.owned[i], key, value);
if (!o.owned[i].context) {
o.owned[i].context = o.context;
mutateContext(o.owned[i], key, value);
} else if (!o.owned[i].context[key]) {
o.owned[i].context[key] = value;
mutateContext(o.owned[i], key, value);
}
}
}
}
function escape(s, attr) {
const t = typeof s;
if (t !== "string") {
if (t === "function") return escape(s());
if (Array.isArray(s)) {
for (let i = 0; i < s.length; i++) s[i] = escape(s[i]);
return s;
}
return s;
}
const delim = "<";
const escDelim = "&lt;";
let iDelim = s.indexOf(delim);
let iAmp = s.indexOf("&");
if (iDelim < 0 && iAmp < 0) return s;
let left = 0,
out = "";
while (iDelim >= 0 && iAmp >= 0) {
if (iDelim < iAmp) {
if (left < iDelim) out += s.substring(left, iDelim);
out += escDelim;
left = iDelim + 1;
iDelim = s.indexOf(delim, left);
} else {
if (left < iAmp) out += s.substring(left, iAmp);
out += "&amp;";
left = iAmp + 1;
iAmp = s.indexOf("&", left);
}
}
if (iDelim >= 0) {
do {
if (left < iDelim) out += s.substring(left, iDelim);
out += escDelim;
left = iDelim + 1;
iDelim = s.indexOf(delim, left);
} while (iDelim >= 0);
} else while (iAmp >= 0) {
if (left < iAmp) out += s.substring(left, iAmp);
out += "&amp;";
left = iAmp + 1;
iAmp = s.indexOf("&", left);
}
return left < s.length ? out + s.substring(left) : out;
}
function resolveSSRNode(node) {
const t = typeof node;
if (t === "string") return node;
if (node == null || t === "boolean") return "";
if (Array.isArray(node)) {
let prev = {};
let mapped = "";
for (let i = 0, len = node.length; i < len; i++) {
if (typeof prev !== "object" && typeof node[i] !== "object") mapped += `<!--!$-->`;
mapped += resolveSSRNode(prev = node[i]);
}
return mapped;
}
if (t === "object") return node.t;
if (t === "function") return resolveSSRNode(node());
return String(node);
}
const sharedConfig = {
context: undefined,
getContextId() {
if (!this.context) throw new Error(`getContextId cannot be used under non-hydrating context`);
return getContextId(this.context.count);
},
getNextContextId() {
if (!this.context) throw new Error(`getNextContextId cannot be used under non-hydrating context`);
return getContextId(this.context.count++);
}
};
function getContextId(count) {
const num = String(count),
len = num.length - 1;
return sharedConfig.context.id + (len ? String.fromCharCode(96 + len) : "") + num;
}
function setHydrateContext(context) {
sharedConfig.context = context;
}
function nextHydrateContext() {
return sharedConfig.context ? {
...sharedConfig.context,
id: sharedConfig.getNextContextId(),
count: 0
} : undefined;
}
function createUniqueId() {
return sharedConfig.getNextContextId();
}
function createComponent(Comp, props) {
if (sharedConfig.context && !sharedConfig.context.noHydrate) {
const c = sharedConfig.context;
setHydrateContext(nextHydrateContext());
const r = Comp(props || {});
setHydrateContext(c);
return r;
}
return Comp(props || {});
}
function mergeProps(...sources) {
const target = {};
for (let i = 0; i < sources.length; i++) {
let source = sources[i];
if (typeof source === "function") source = source();
if (source) {
const descriptors = Object.getOwnPropertyDescriptors(source);
for (const key in descriptors) {
if (key === "__proto__" || key === "constructor" || Object.prototype.hasOwnProperty.call(target, key)) continue;
Object.defineProperty(target, key, {
enumerable: true,
get() {
for (let i = sources.length - 1; i >= 0; i--) {
let v,
s = sources[i];
if (typeof s === "function") s = s();
v = (s || {})[key];
if (v !== undefined) return v;
}
}
});
}
}
}
return target;
}
function splitProps(props, ...keys) {
const descriptors = Object.getOwnPropertyDescriptors(props),
split = k => {
const clone = {};
for (let i = 0; i < k.length; i++) {
const key = k[i];
if (descriptors[key]) {
Object.defineProperty(clone, key, descriptors[key]);
delete descriptors[key];
}
}
return clone;
};
return keys.map(split).concat(split(Object.keys(descriptors)));
}
function simpleMap(props, wrap) {
const list = props.each || [],
len = list.length,
fn = props.children;
if (len) {
let mapped = Array(len);
for (let i = 0; i < len; i++) mapped[i] = wrap(fn, list[i], i);
return mapped;
}
return props.fallback;
}
function For(props) {
return simpleMap(props, (fn, item, i) => fn(item, () => i));
}
function Index(props) {
return simpleMap(props, (fn, item, i) => fn(() => item, i));
}
function Show(props) {
let c;
return props.when ? typeof (c = props.children) === "function" && c.length > 0 ? c(props.keyed ? props.when : () => props.when) : c : props.fallback || "";
}
function Switch(props) {
let conditions = props.children;
Array.isArray(conditions) || (conditions = [conditions]);
for (let i = 0; i < conditions.length; i++) {
const w = conditions[i].when;
if (w) {
const c = conditions[i].children;
return typeof c === "function" && c.length > 0 ? c(conditions[i].keyed ? w : () => w) : c;
}
}
return props.fallback || "";
}
function Match(props) {
return props;
}
function resetErrorBoundaries() {}
function ErrorBoundary(props) {
let error,
res,
clean,
sync = true;
const ctx = sharedConfig.context;
const id = sharedConfig.getContextId();
function displayFallback() {
cleanNode(clean);
ctx.serialize(id, error);
setHydrateContext({
...ctx,
count: 0
});
const f = props.fallback;
return typeof f === "function" && f.length ? f(error, () => {}) : f;
}
createMemo(() => {
clean = Owner;
return catchError(() => res = props.children, err => {
error = err;
!sync && ctx.replace("e" + id, displayFallback);
sync = true;
});
});
if (error) return displayFallback();
sync = false;
return {
t: `<!--!$e${id}-->${resolveSSRNode(escape(res))}<!--!$/e${id}-->`
};
}
const SuspenseContext = createContext();
let resourceContext = null;
function createResource(source, fetcher, options = {}) {
if (typeof fetcher !== "function") {
options = fetcher || {};
fetcher = source;
source = true;
}
const contexts = new Set();
const id = sharedConfig.getNextContextId();
let resource = {};
let value = options.storage ? options.storage(options.initialValue)[0]() : options.initialValue;
let p;
let error;
if (sharedConfig.context.async && options.ssrLoadFrom !== "initial") {
resource = sharedConfig.context.resources[id] || (sharedConfig.context.resources[id] = {});
if (resource.ref) {
if (!resource.data && !resource.ref[0]._loading && !resource.ref[0].error) resource.ref[1].refetch();
return resource.ref;
}
}
const prepareResource = () => {
if (error) throw error;
const resolved = options.ssrLoadFrom !== "initial" && sharedConfig.context.async && "data" in sharedConfig.context.resources[id];
if (!resolved && resourceContext) resourceContext.push(id);
if (!resolved && read._loading) {
const ctx = useContext(SuspenseContext);
if (ctx) {
ctx.resources.set(id, read);
contexts.add(ctx);
}
}
return resolved;
};
const read = () => {
return prepareResource() ? sharedConfig.context.resources[id].data : value;
};
const loading = () => {
prepareResource();
return read._loading;
};
read._loading = false;
read.error = undefined;
read.state = "initialValue" in options ? "ready" : "unresolved";
Object.defineProperties(read, {
latest: {
get() {
return read();
}
},
loading: {
get() {
return loading();
}
}
});
function load() {
const ctx = sharedConfig.context;
if (!ctx.async) return read._loading = !!(typeof source === "function" ? source() : source);
if (ctx.resources && id in ctx.resources && "data" in ctx.resources[id]) {
value = ctx.resources[id].data;
return;
}
let lookup;
try {
resourceContext = [];
lookup = typeof source === "function" ? source() : source;
if (resourceContext.length) return;
} finally {
resourceContext = null;
}
if (!p) {
if (lookup == null || lookup === false) return;
p = fetcher(lookup, {
value
});
}
if (p != undefined && typeof p === "object" && "then" in p) {
read._loading = true;
read.state = "pending";
p = p.then(res => {
read._loading = false;
read.state = "ready";
ctx.resources[id].data = res;
p = null;
notifySuspense(contexts);
return res;
}).catch(err => {
read._loading = false;
read.state = "errored";
read.error = error = castError(err);
p = null;
notifySuspense(contexts);
throw error;
});
if (ctx.serialize) ctx.serialize(id, p, options.deferStream);
return p;
}
ctx.resources[id].data = p;
if (ctx.serialize) ctx.serialize(id, p);
p = null;
return ctx.resources[id].data;
}
if (options.ssrLoadFrom !== "initial") load();
const ref = [read, {
refetch: load,
mutate: v => value = v
}];
if (p) resource.ref = ref;
return ref;
}
function lazy(fn) {
let p;
let load = id => {
if (!p) {
p = fn();
p.then(mod => p.resolved = mod.default);
if (id) sharedConfig.context.lazy[id] = p;
}
return p;
};
const contexts = new Set();
const wrap = props => {
const id = sharedConfig.context.id;
let ref = sharedConfig.context.lazy[id];
if (ref) p = ref;else load(id);
if (p.resolved) return p.resolved(props);
const ctx = useContext(SuspenseContext);
const track = {
_loading: true,
error: undefined
};
if (ctx) {
ctx.resources.set(id, track);
contexts.add(ctx);
}
if (sharedConfig.context.async) {
sharedConfig.context.block(p.then(() => {
track._loading = false;
notifySuspense(contexts);
}));
}
return "";
};
wrap.preload = load;
return wrap;
}
function suspenseComplete(c) {
for (const r of c.resources.values()) {
if (r._loading) return false;
}
return true;
}
function notifySuspense(contexts) {
for (const c of contexts) {
if (!suspenseComplete(c)) {
continue;
}
c.completed();
contexts.delete(c);
}
}
function enableScheduling() {}
function enableHydration() {}
function startTransition(fn) {
fn();
}
function useTransition() {
return [() => false, fn => {
fn();
}];
}
function SuspenseList(props) {
if (sharedConfig.context && !sharedConfig.context.noHydrate) {
const c = sharedConfig.context;
setHydrateContext(nextHydrateContext());
const result = props.children;
setHydrateContext(c);
return result;
}
return props.children;
}
function Suspense(props) {
let done;
const ctx = sharedConfig.context;
const id = sharedConfig.getContextId();
const o = createOwner();
const value = ctx.suspense[id] || (ctx.suspense[id] = {
resources: new Map(),
completed: () => {
const res = runSuspense();
if (suspenseComplete(value)) {
done(resolveSSRNode(escape(res)));
}
}
});
function suspenseError(err) {
if (!done || !done(undefined, err)) {
runWithOwner(o.owner, () => {
throw err;
});
}
}
function runSuspense() {
setHydrateContext({
...ctx,
count: 0
});
cleanNode(o);
return runWithOwner(o, () => createComponent(SuspenseContext.Provider, {
value,
get children() {
return catchError(() => props.children, suspenseError);
}
}));
}
const res = runSuspense();
if (suspenseComplete(value)) {
delete ctx.suspense[id];
return res;
}
done = ctx.async ? ctx.registerFragment(id) : undefined;
return catchError(() => {
if (ctx.async) {
setHydrateContext({
...ctx,
count: 0,
id: ctx.id + "0F",
noHydrate: true
});
const res = {
t: `<template id="pl-${id}"></template>${resolveSSRNode(escape(props.fallback))}<!--pl-${id}-->`
};
setHydrateContext(ctx);
return res;
}
setHydrateContext({
...ctx,
count: 0,
id: ctx.id + "0F"
});
ctx.serialize(id, "$$f");
return props.fallback;
}, suspenseError);
}
exports.$DEVCOMP = $DEVCOMP;
exports.$PROXY = $PROXY;
exports.$TRACK = $TRACK;
exports.DEV = DEV;
exports.ErrorBoundary = ErrorBoundary;
exports.For = For;
exports.Index = Index;
exports.Match = Match;
exports.Show = Show;
exports.Suspense = Suspense;
exports.SuspenseList = SuspenseList;
exports.Switch = Switch;
exports.batch = batch;
exports.catchError = catchError;
exports.children = children;
exports.createComponent = createComponent;
exports.createComputed = createComputed;
exports.createContext = createContext;
exports.createDeferred = createDeferred;
exports.createEffect = createEffect;
exports.createMemo = createMemo;
exports.createReaction = createReaction;
exports.createRenderEffect = createRenderEffect;
exports.createResource = createResource;
exports.createRoot = createRoot;
exports.createSelector = createSelector;
exports.createSignal = createSignal;
exports.createUniqueId = createUniqueId;
exports.enableExternalSource = enableExternalSource;
exports.enableHydration = enableHydration;
exports.enableScheduling = enableScheduling;
exports.equalFn = equalFn;
exports.from = from;
exports.getListener = getListener;
exports.getOwner = getOwner;
exports.indexArray = indexArray;
exports.lazy = lazy;
exports.mapArray = mapArray;
exports.mergeProps = mergeProps;
exports.observable = observable;
exports.on = on;
exports.onCleanup = onCleanup;
exports.onError = onError;
exports.onMount = onMount;
exports.requestCallback = requestCallback;
exports.resetErrorBoundaries = resetErrorBoundaries;
exports.runWithOwner = runWithOwner;
exports.sharedConfig = sharedConfig;
exports.splitProps = splitProps;
exports.startTransition = startTransition;
exports.untrack = untrack;
exports.useContext = useContext;
exports.useTransition = useTransition;

763
web/runtime/solid-js/dist/server.js vendored Normal file
View File

@@ -0,0 +1,763 @@
const equalFn = (a, b) => a === b;
const $PROXY = Symbol("solid-proxy");
const $TRACK = Symbol("solid-track");
const $DEVCOMP = Symbol("solid-dev-component");
const DEV = undefined;
const ERROR = Symbol("error");
function castError(err) {
if (err instanceof Error) return err;
return new Error(typeof err === "string" ? err : "Unknown error", {
cause: err
});
}
function handleError(err, owner = Owner) {
const fns = owner && owner.context && owner.context[ERROR];
const error = castError(err);
if (!fns) throw error;
try {
for (const f of fns) f(error);
} catch (e) {
handleError(e, owner && owner.owner || null);
}
}
const UNOWNED = {
context: null,
owner: null,
owned: null,
cleanups: null
};
let Owner = null;
function createOwner() {
const o = {
owner: Owner,
context: Owner ? Owner.context : null,
owned: null,
cleanups: null
};
if (Owner) {
if (!Owner.owned) Owner.owned = [o];else Owner.owned.push(o);
}
return o;
}
function createRoot(fn, detachedOwner) {
const owner = Owner,
current = detachedOwner === undefined ? owner : detachedOwner,
root = fn.length === 0 ? UNOWNED : {
context: current ? current.context : null,
owner: current,
owned: null,
cleanups: null
};
Owner = root;
let result;
try {
result = fn(fn.length === 0 ? () => {} : () => cleanNode(root));
} catch (err) {
handleError(err);
} finally {
Owner = owner;
}
return result;
}
function createSignal(value, options) {
return [() => value, v => {
return value = typeof v === "function" ? v(value) : v;
}];
}
function createComputed(fn, value) {
Owner = createOwner();
try {
fn(value);
} catch (err) {
handleError(err);
} finally {
Owner = Owner.owner;
}
}
const createRenderEffect = createComputed;
function createEffect(fn, value) {}
function createReaction(fn) {
return fn => {
fn();
};
}
function createMemo(fn, value) {
Owner = createOwner();
let v;
try {
v = fn(value);
} catch (err) {
handleError(err);
} finally {
Owner = Owner.owner;
}
return () => v;
}
function createDeferred(source) {
return source;
}
function createSelector(source, fn = equalFn) {
return k => fn(k, source());
}
function batch(fn) {
return fn();
}
const untrack = batch;
function on(deps, fn, options = {}) {
const isArray = Array.isArray(deps);
const defer = options.defer;
return () => {
if (defer) return undefined;
let value;
if (isArray) {
value = [];
for (let i = 0; i < deps.length; i++) value.push(deps[i]());
} else value = deps();
return fn(value);
};
}
function onMount(fn) {}
function onCleanup(fn) {
if (Owner) {
if (!Owner.cleanups) Owner.cleanups = [fn];else Owner.cleanups.push(fn);
}
return fn;
}
function cleanNode(node) {
if (node.owned) {
for (let i = 0; i < node.owned.length; i++) cleanNode(node.owned[i]);
node.owned = null;
}
if (node.cleanups) {
for (let i = 0; i < node.cleanups.length; i++) node.cleanups[i]();
node.cleanups = null;
}
}
function catchError(fn, handler) {
const owner = createOwner();
owner.context = {
...owner.context,
[ERROR]: [handler]
};
Owner = owner;
try {
return fn();
} catch (err) {
handleError(err);
} finally {
Owner = Owner.owner;
}
}
function getListener() {
return null;
}
function createContext(defaultValue) {
const id = Symbol("context");
return {
id,
Provider: createProvider(id),
defaultValue
};
}
function useContext(context) {
return Owner && Owner.context && Owner.context[context.id] !== undefined ? Owner.context[context.id] : context.defaultValue;
}
function getOwner() {
return Owner;
}
function children(fn) {
const memo = createMemo(() => resolveChildren(fn()));
memo.toArray = () => {
const c = memo();
return Array.isArray(c) ? c : c != null ? [c] : [];
};
return memo;
}
function runWithOwner(o, fn) {
const prev = Owner;
Owner = o;
try {
return fn();
} catch (err) {
handleError(err);
} finally {
Owner = prev;
}
}
function resolveChildren(children) {
if (typeof children === "function" && !children.length) return resolveChildren(children());
if (Array.isArray(children)) {
const results = [];
for (let i = 0; i < children.length; i++) {
const result = resolveChildren(children[i]);
if (Array.isArray(result)) {
if (result.length < 32768) results.push.apply(results, result);else for (let j = 0; j < result.length; j++) results.push(result[j]);
} else {
results.push(result);
}
}
return results;
}
return children;
}
function createProvider(id) {
return function provider(props) {
return createMemo(() => {
Owner.context = {
...Owner.context,
[id]: props.value
};
return children(() => props.children);
});
};
}
function requestCallback(fn, options) {
return {
id: 0,
fn: () => {},
startTime: 0,
expirationTime: 0
};
}
function mapArray(list, mapFn, options = {}) {
const items = list();
let s = [];
if (items && items.length) {
for (let i = 0, len = items.length; i < len; i++) s.push(mapFn(items[i], () => i));
} else if (options.fallback) s = [options.fallback()];
return () => s;
}
function indexArray(list, mapFn, options = {}) {
const items = list();
let s = [];
if (items && items.length) {
for (let i = 0, len = items.length; i < len; i++) s.push(mapFn(() => items[i], i));
} else if (options.fallback) s = [options.fallback()];
return () => s;
}
function observable(input) {
return {
subscribe(observer) {
if (!(observer instanceof Object) || observer == null) {
throw new TypeError("Expected the observer to be an object.");
}
const handler = typeof observer === "function" ? observer : observer.next && observer.next.bind(observer);
if (!handler) {
return {
unsubscribe() {}
};
}
const dispose = createRoot(disposer => {
createEffect(() => {
const v = input();
untrack(() => handler(v));
});
return disposer;
});
if (getOwner()) onCleanup(dispose);
return {
unsubscribe() {
dispose();
}
};
},
[Symbol.observable || "@@observable"]() {
return this;
}
};
}
function from(producer) {
const [s, set] = createSignal(undefined);
if ("subscribe" in producer) {
const unsub = producer.subscribe(v => set(() => v));
onCleanup(() => "unsubscribe" in unsub ? unsub.unsubscribe() : unsub());
} else {
const clean = producer(set);
onCleanup(clean);
}
return s;
}
function enableExternalSource(factory) {}
function onError(fn) {
if (Owner) {
if (Owner.context === null || !Owner.context[ERROR]) {
Owner.context = {
...Owner.context,
[ERROR]: [fn]
};
mutateContext(Owner, ERROR, [fn]);
} else Owner.context[ERROR].push(fn);
}
}
function mutateContext(o, key, value) {
if (o.owned) {
for (let i = 0; i < o.owned.length; i++) {
if (o.owned[i].context === o.context) mutateContext(o.owned[i], key, value);
if (!o.owned[i].context) {
o.owned[i].context = o.context;
mutateContext(o.owned[i], key, value);
} else if (!o.owned[i].context[key]) {
o.owned[i].context[key] = value;
mutateContext(o.owned[i], key, value);
}
}
}
}
function escape(s, attr) {
const t = typeof s;
if (t !== "string") {
if (t === "function") return escape(s());
if (Array.isArray(s)) {
for (let i = 0; i < s.length; i++) s[i] = escape(s[i]);
return s;
}
return s;
}
const delim = "<";
const escDelim = "&lt;";
let iDelim = s.indexOf(delim);
let iAmp = s.indexOf("&");
if (iDelim < 0 && iAmp < 0) return s;
let left = 0,
out = "";
while (iDelim >= 0 && iAmp >= 0) {
if (iDelim < iAmp) {
if (left < iDelim) out += s.substring(left, iDelim);
out += escDelim;
left = iDelim + 1;
iDelim = s.indexOf(delim, left);
} else {
if (left < iAmp) out += s.substring(left, iAmp);
out += "&amp;";
left = iAmp + 1;
iAmp = s.indexOf("&", left);
}
}
if (iDelim >= 0) {
do {
if (left < iDelim) out += s.substring(left, iDelim);
out += escDelim;
left = iDelim + 1;
iDelim = s.indexOf(delim, left);
} while (iDelim >= 0);
} else while (iAmp >= 0) {
if (left < iAmp) out += s.substring(left, iAmp);
out += "&amp;";
left = iAmp + 1;
iAmp = s.indexOf("&", left);
}
return left < s.length ? out + s.substring(left) : out;
}
function resolveSSRNode(node) {
const t = typeof node;
if (t === "string") return node;
if (node == null || t === "boolean") return "";
if (Array.isArray(node)) {
let prev = {};
let mapped = "";
for (let i = 0, len = node.length; i < len; i++) {
if (typeof prev !== "object" && typeof node[i] !== "object") mapped += `<!--!$-->`;
mapped += resolveSSRNode(prev = node[i]);
}
return mapped;
}
if (t === "object") return node.t;
if (t === "function") return resolveSSRNode(node());
return String(node);
}
const sharedConfig = {
context: undefined,
getContextId() {
if (!this.context) throw new Error(`getContextId cannot be used under non-hydrating context`);
return getContextId(this.context.count);
},
getNextContextId() {
if (!this.context) throw new Error(`getNextContextId cannot be used under non-hydrating context`);
return getContextId(this.context.count++);
}
};
function getContextId(count) {
const num = String(count),
len = num.length - 1;
return sharedConfig.context.id + (len ? String.fromCharCode(96 + len) : "") + num;
}
function setHydrateContext(context) {
sharedConfig.context = context;
}
function nextHydrateContext() {
return sharedConfig.context ? {
...sharedConfig.context,
id: sharedConfig.getNextContextId(),
count: 0
} : undefined;
}
function createUniqueId() {
return sharedConfig.getNextContextId();
}
function createComponent(Comp, props) {
if (sharedConfig.context && !sharedConfig.context.noHydrate) {
const c = sharedConfig.context;
setHydrateContext(nextHydrateContext());
const r = Comp(props || {});
setHydrateContext(c);
return r;
}
return Comp(props || {});
}
function mergeProps(...sources) {
const target = {};
for (let i = 0; i < sources.length; i++) {
let source = sources[i];
if (typeof source === "function") source = source();
if (source) {
const descriptors = Object.getOwnPropertyDescriptors(source);
for (const key in descriptors) {
if (key === "__proto__" || key === "constructor" || Object.prototype.hasOwnProperty.call(target, key)) continue;
Object.defineProperty(target, key, {
enumerable: true,
get() {
for (let i = sources.length - 1; i >= 0; i--) {
let v,
s = sources[i];
if (typeof s === "function") s = s();
v = (s || {})[key];
if (v !== undefined) return v;
}
}
});
}
}
}
return target;
}
function splitProps(props, ...keys) {
const descriptors = Object.getOwnPropertyDescriptors(props),
split = k => {
const clone = {};
for (let i = 0; i < k.length; i++) {
const key = k[i];
if (descriptors[key]) {
Object.defineProperty(clone, key, descriptors[key]);
delete descriptors[key];
}
}
return clone;
};
return keys.map(split).concat(split(Object.keys(descriptors)));
}
function simpleMap(props, wrap) {
const list = props.each || [],
len = list.length,
fn = props.children;
if (len) {
let mapped = Array(len);
for (let i = 0; i < len; i++) mapped[i] = wrap(fn, list[i], i);
return mapped;
}
return props.fallback;
}
function For(props) {
return simpleMap(props, (fn, item, i) => fn(item, () => i));
}
function Index(props) {
return simpleMap(props, (fn, item, i) => fn(() => item, i));
}
function Show(props) {
let c;
return props.when ? typeof (c = props.children) === "function" && c.length > 0 ? c(props.keyed ? props.when : () => props.when) : c : props.fallback || "";
}
function Switch(props) {
let conditions = props.children;
Array.isArray(conditions) || (conditions = [conditions]);
for (let i = 0; i < conditions.length; i++) {
const w = conditions[i].when;
if (w) {
const c = conditions[i].children;
return typeof c === "function" && c.length > 0 ? c(conditions[i].keyed ? w : () => w) : c;
}
}
return props.fallback || "";
}
function Match(props) {
return props;
}
function resetErrorBoundaries() {}
function ErrorBoundary(props) {
let error,
res,
clean,
sync = true;
const ctx = sharedConfig.context;
const id = sharedConfig.getContextId();
function displayFallback() {
cleanNode(clean);
ctx.serialize(id, error);
setHydrateContext({
...ctx,
count: 0
});
const f = props.fallback;
return typeof f === "function" && f.length ? f(error, () => {}) : f;
}
createMemo(() => {
clean = Owner;
return catchError(() => res = props.children, err => {
error = err;
!sync && ctx.replace("e" + id, displayFallback);
sync = true;
});
});
if (error) return displayFallback();
sync = false;
return {
t: `<!--!$e${id}-->${resolveSSRNode(escape(res))}<!--!$/e${id}-->`
};
}
const SuspenseContext = createContext();
let resourceContext = null;
function createResource(source, fetcher, options = {}) {
if (typeof fetcher !== "function") {
options = fetcher || {};
fetcher = source;
source = true;
}
const contexts = new Set();
const id = sharedConfig.getNextContextId();
let resource = {};
let value = options.storage ? options.storage(options.initialValue)[0]() : options.initialValue;
let p;
let error;
if (sharedConfig.context.async && options.ssrLoadFrom !== "initial") {
resource = sharedConfig.context.resources[id] || (sharedConfig.context.resources[id] = {});
if (resource.ref) {
if (!resource.data && !resource.ref[0]._loading && !resource.ref[0].error) resource.ref[1].refetch();
return resource.ref;
}
}
const prepareResource = () => {
if (error) throw error;
const resolved = options.ssrLoadFrom !== "initial" && sharedConfig.context.async && "data" in sharedConfig.context.resources[id];
if (!resolved && resourceContext) resourceContext.push(id);
if (!resolved && read._loading) {
const ctx = useContext(SuspenseContext);
if (ctx) {
ctx.resources.set(id, read);
contexts.add(ctx);
}
}
return resolved;
};
const read = () => {
return prepareResource() ? sharedConfig.context.resources[id].data : value;
};
const loading = () => {
prepareResource();
return read._loading;
};
read._loading = false;
read.error = undefined;
read.state = "initialValue" in options ? "ready" : "unresolved";
Object.defineProperties(read, {
latest: {
get() {
return read();
}
},
loading: {
get() {
return loading();
}
}
});
function load() {
const ctx = sharedConfig.context;
if (!ctx.async) return read._loading = !!(typeof source === "function" ? source() : source);
if (ctx.resources && id in ctx.resources && "data" in ctx.resources[id]) {
value = ctx.resources[id].data;
return;
}
let lookup;
try {
resourceContext = [];
lookup = typeof source === "function" ? source() : source;
if (resourceContext.length) return;
} finally {
resourceContext = null;
}
if (!p) {
if (lookup == null || lookup === false) return;
p = fetcher(lookup, {
value
});
}
if (p != undefined && typeof p === "object" && "then" in p) {
read._loading = true;
read.state = "pending";
p = p.then(res => {
read._loading = false;
read.state = "ready";
ctx.resources[id].data = res;
p = null;
notifySuspense(contexts);
return res;
}).catch(err => {
read._loading = false;
read.state = "errored";
read.error = error = castError(err);
p = null;
notifySuspense(contexts);
throw error;
});
if (ctx.serialize) ctx.serialize(id, p, options.deferStream);
return p;
}
ctx.resources[id].data = p;
if (ctx.serialize) ctx.serialize(id, p);
p = null;
return ctx.resources[id].data;
}
if (options.ssrLoadFrom !== "initial") load();
const ref = [read, {
refetch: load,
mutate: v => value = v
}];
if (p) resource.ref = ref;
return ref;
}
function lazy(fn) {
let p;
let load = id => {
if (!p) {
p = fn();
p.then(mod => p.resolved = mod.default);
if (id) sharedConfig.context.lazy[id] = p;
}
return p;
};
const contexts = new Set();
const wrap = props => {
const id = sharedConfig.context.id;
let ref = sharedConfig.context.lazy[id];
if (ref) p = ref;else load(id);
if (p.resolved) return p.resolved(props);
const ctx = useContext(SuspenseContext);
const track = {
_loading: true,
error: undefined
};
if (ctx) {
ctx.resources.set(id, track);
contexts.add(ctx);
}
if (sharedConfig.context.async) {
sharedConfig.context.block(p.then(() => {
track._loading = false;
notifySuspense(contexts);
}));
}
return "";
};
wrap.preload = load;
return wrap;
}
function suspenseComplete(c) {
for (const r of c.resources.values()) {
if (r._loading) return false;
}
return true;
}
function notifySuspense(contexts) {
for (const c of contexts) {
if (!suspenseComplete(c)) {
continue;
}
c.completed();
contexts.delete(c);
}
}
function enableScheduling() {}
function enableHydration() {}
function startTransition(fn) {
fn();
}
function useTransition() {
return [() => false, fn => {
fn();
}];
}
function SuspenseList(props) {
if (sharedConfig.context && !sharedConfig.context.noHydrate) {
const c = sharedConfig.context;
setHydrateContext(nextHydrateContext());
const result = props.children;
setHydrateContext(c);
return result;
}
return props.children;
}
function Suspense(props) {
let done;
const ctx = sharedConfig.context;
const id = sharedConfig.getContextId();
const o = createOwner();
const value = ctx.suspense[id] || (ctx.suspense[id] = {
resources: new Map(),
completed: () => {
const res = runSuspense();
if (suspenseComplete(value)) {
done(resolveSSRNode(escape(res)));
}
}
});
function suspenseError(err) {
if (!done || !done(undefined, err)) {
runWithOwner(o.owner, () => {
throw err;
});
}
}
function runSuspense() {
setHydrateContext({
...ctx,
count: 0
});
cleanNode(o);
return runWithOwner(o, () => createComponent(SuspenseContext.Provider, {
value,
get children() {
return catchError(() => props.children, suspenseError);
}
}));
}
const res = runSuspense();
if (suspenseComplete(value)) {
delete ctx.suspense[id];
return res;
}
done = ctx.async ? ctx.registerFragment(id) : undefined;
return catchError(() => {
if (ctx.async) {
setHydrateContext({
...ctx,
count: 0,
id: ctx.id + "0F",
noHydrate: true
});
const res = {
t: `<template id="pl-${id}"></template>${resolveSSRNode(escape(props.fallback))}<!--pl-${id}-->`
};
setHydrateContext(ctx);
return res;
}
setHydrateContext({
...ctx,
count: 0,
id: ctx.id + "0F"
});
ctx.serialize(id, "$$f");
return props.fallback;
}, suspenseError);
}
export { $DEVCOMP, $PROXY, $TRACK, DEV, ErrorBoundary, For, Index, Match, Show, Suspense, SuspenseList, Switch, batch, catchError, children, createComponent, createComputed, createContext, createDeferred, createEffect, createMemo, createReaction, createRenderEffect, createResource, createRoot, createSelector, createSignal, createUniqueId, enableExternalSource, enableHydration, enableScheduling, equalFn, from, getListener, getOwner, indexArray, lazy, mapArray, mergeProps, observable, on, onCleanup, onError, onMount, requestCallback, resetErrorBoundaries, runWithOwner, sharedConfig, splitProps, startTransition, untrack, useContext, useTransition };

1785
web/runtime/solid-js/dist/solid.cjs vendored Normal file

File diff suppressed because it is too large Load Diff

1730
web/runtime/solid-js/dist/solid.js vendored Normal file

File diff suppressed because it is too large Load Diff

115
web/runtime/solid-js/h/dist/h.cjs vendored Normal file
View File

@@ -0,0 +1,115 @@
'use strict';
var web = require('solid-js/web');
const $ELEMENT = Symbol("hyper-element");
function createHyperScript(r) {
function h() {
let args = [].slice.call(arguments),
e,
classes = [],
multiExpression = false;
while (Array.isArray(args[0])) args = args[0];
if (args[0][$ELEMENT]) args.unshift(h.Fragment);
typeof args[0] === "string" && detectMultiExpression(args);
const ret = () => {
while (args.length) item(args.shift());
if (e instanceof Element && classes.length) e.classList.add(...classes);
return e;
};
ret[$ELEMENT] = true;
return ret;
function item(l) {
const type = typeof l;
if (l == null) ;else if ("string" === type) {
if (!e) parseClass(l);else e.appendChild(document.createTextNode(l));
} else if ("number" === type || "boolean" === type || "bigint" === type || "symbol" === type || l instanceof Date || l instanceof RegExp) {
e.appendChild(document.createTextNode(l.toString()));
} else if (Array.isArray(l)) {
for (let i = 0; i < l.length; i++) item(l[i]);
} else if (l instanceof Element) {
r.insert(e, l, multiExpression ? null : undefined);
} else if ("object" === type) {
let dynamic = false;
const d = Object.getOwnPropertyDescriptors(l);
for (const k in d) {
if (k === "class" && classes.length !== 0) {
const fixedClasses = classes.join(" "),
value = typeof d["class"].value === "function" ? () => fixedClasses + " " + d["class"].value() : fixedClasses + " " + l["class"];
Object.defineProperty(l, "class", {
...d[k],
value
});
classes = [];
}
if (k !== "ref" && k.slice(0, 2) !== "on" && typeof d[k].value === "function") {
r.dynamicProperty(l, k);
dynamic = true;
} else if (d[k].get) dynamic = true;
}
dynamic ? r.spread(e, l, e instanceof SVGElement, !!args.length) : r.assign(e, l, e instanceof SVGElement, !!args.length);
} else if ("function" === type) {
if (!e) {
let props,
next = args[0];
if (next == null || typeof next === "object" && !Array.isArray(next) && !(next instanceof Element)) props = args.shift();
props || (props = {});
if (args.length) {
props.children = args.length > 1 ? args : args[0];
}
const d = Object.getOwnPropertyDescriptors(props);
for (const k in d) {
if (Array.isArray(d[k].value)) {
const list = d[k].value;
props[k] = () => {
for (let i = 0; i < list.length; i++) {
while (list[i][$ELEMENT]) list[i] = list[i]();
}
return list;
};
r.dynamicProperty(props, k);
} else if (typeof d[k].value === "function" && !d[k].value.length) r.dynamicProperty(props, k);
}
e = r.createComponent(l, props);
args = [];
} else {
while (l[$ELEMENT]) l = l();
r.insert(e, l, multiExpression ? null : undefined);
}
}
}
function parseClass(string) {
const m = string.split(/([\.#]?[^\s#.]+)/);
if (/^\.|#/.test(m[1])) e = document.createElement("div");
for (let i = 0; i < m.length; i++) {
const v = m[i],
s = v.substring(1, v.length);
if (!v) continue;
if (!e) e = r.SVGElements.has(v) ? document.createElementNS("http://www.w3.org/2000/svg", v) : document.createElement(v);else if (v[0] === ".") classes.push(s);else if (v[0] === "#") e.setAttribute("id", s);
}
}
function detectMultiExpression(list) {
for (let i = 1; i < list.length; i++) {
if (typeof list[i] === "function") {
multiExpression = true;
return;
} else if (Array.isArray(list[i])) {
detectMultiExpression(list[i]);
}
}
}
}
h.Fragment = props => props.children;
return h;
}
const h = createHyperScript({
spread: web.spread,
assign: web.assign,
insert: web.insert,
createComponent: web.createComponent,
dynamicProperty: web.dynamicProperty,
SVGElements: web.SVGElements
});
module.exports = h;

113
web/runtime/solid-js/h/dist/h.js vendored Normal file
View File

@@ -0,0 +1,113 @@
import { SVGElements, dynamicProperty, createComponent, insert, assign, spread } from 'solid-js/web';
const $ELEMENT = Symbol("hyper-element");
function createHyperScript(r) {
function h() {
let args = [].slice.call(arguments),
e,
classes = [],
multiExpression = false;
while (Array.isArray(args[0])) args = args[0];
if (args[0][$ELEMENT]) args.unshift(h.Fragment);
typeof args[0] === "string" && detectMultiExpression(args);
const ret = () => {
while (args.length) item(args.shift());
if (e instanceof Element && classes.length) e.classList.add(...classes);
return e;
};
ret[$ELEMENT] = true;
return ret;
function item(l) {
const type = typeof l;
if (l == null) ;else if ("string" === type) {
if (!e) parseClass(l);else e.appendChild(document.createTextNode(l));
} else if ("number" === type || "boolean" === type || "bigint" === type || "symbol" === type || l instanceof Date || l instanceof RegExp) {
e.appendChild(document.createTextNode(l.toString()));
} else if (Array.isArray(l)) {
for (let i = 0; i < l.length; i++) item(l[i]);
} else if (l instanceof Element) {
r.insert(e, l, multiExpression ? null : undefined);
} else if ("object" === type) {
let dynamic = false;
const d = Object.getOwnPropertyDescriptors(l);
for (const k in d) {
if (k === "class" && classes.length !== 0) {
const fixedClasses = classes.join(" "),
value = typeof d["class"].value === "function" ? () => fixedClasses + " " + d["class"].value() : fixedClasses + " " + l["class"];
Object.defineProperty(l, "class", {
...d[k],
value
});
classes = [];
}
if (k !== "ref" && k.slice(0, 2) !== "on" && typeof d[k].value === "function") {
r.dynamicProperty(l, k);
dynamic = true;
} else if (d[k].get) dynamic = true;
}
dynamic ? r.spread(e, l, e instanceof SVGElement, !!args.length) : r.assign(e, l, e instanceof SVGElement, !!args.length);
} else if ("function" === type) {
if (!e) {
let props,
next = args[0];
if (next == null || typeof next === "object" && !Array.isArray(next) && !(next instanceof Element)) props = args.shift();
props || (props = {});
if (args.length) {
props.children = args.length > 1 ? args : args[0];
}
const d = Object.getOwnPropertyDescriptors(props);
for (const k in d) {
if (Array.isArray(d[k].value)) {
const list = d[k].value;
props[k] = () => {
for (let i = 0; i < list.length; i++) {
while (list[i][$ELEMENT]) list[i] = list[i]();
}
return list;
};
r.dynamicProperty(props, k);
} else if (typeof d[k].value === "function" && !d[k].value.length) r.dynamicProperty(props, k);
}
e = r.createComponent(l, props);
args = [];
} else {
while (l[$ELEMENT]) l = l();
r.insert(e, l, multiExpression ? null : undefined);
}
}
}
function parseClass(string) {
const m = string.split(/([\.#]?[^\s#.]+)/);
if (/^\.|#/.test(m[1])) e = document.createElement("div");
for (let i = 0; i < m.length; i++) {
const v = m[i],
s = v.substring(1, v.length);
if (!v) continue;
if (!e) e = r.SVGElements.has(v) ? document.createElementNS("http://www.w3.org/2000/svg", v) : document.createElement(v);else if (v[0] === ".") classes.push(s);else if (v[0] === "#") e.setAttribute("id", s);
}
}
function detectMultiExpression(list) {
for (let i = 1; i < list.length; i++) {
if (typeof list[i] === "function") {
multiExpression = true;
return;
} else if (Array.isArray(list[i])) {
detectMultiExpression(list[i]);
}
}
}
}
h.Fragment = props => props.children;
return h;
}
const h = createHyperScript({
spread,
assign,
insert,
createComponent,
dynamicProperty,
SVGElements
});
export { h as default };

View File

@@ -0,0 +1,8 @@
{
"name": "solid-js/h/jsx-dev-runtime",
"main": "../jsx-runtime/dist/jsx.cjs",
"module": "../jsx-runtime/dist/jsx.js",
"types": "../jsx-runtime/types/index.d.ts",
"type": "module",
"sideEffects": false
}

View File

@@ -0,0 +1,15 @@
'use strict';
var h = require('solid-js/h');
function Fragment(props) {
return props.children;
}
function jsx(type, props) {
return h(type, props);
}
exports.Fragment = Fragment;
exports.jsx = jsx;
exports.jsxDEV = jsx;
exports.jsxs = jsx;

View File

@@ -0,0 +1,10 @@
import h from 'solid-js/h';
function Fragment(props) {
return props.children;
}
function jsx(type, props) {
return h(type, props);
}
export { Fragment, jsx, jsx as jsxDEV, jsx as jsxs };

View File

@@ -0,0 +1,8 @@
{
"name": "solid-js/h/jsx-runtime",
"main": "./dist/jsx.cjs",
"module": "./dist/jsx.js",
"types": "./types/index.d.ts",
"type": "module",
"sideEffects": false
}

View File

@@ -0,0 +1,11 @@
export type { JSX } from "./jsx.d.ts";
import type { JSX } from "./jsx.d.ts";
declare function Fragment(props: {
children: JSX.Element;
}): JSX.Element;
declare function jsx(type: any, props: any): () => (Node & {
[key: string]: any;
}) | (Node & {
[key: string]: any;
})[];
export { jsx, jsx as jsxs, jsx as jsxDEV, Fragment };

Some files were not shown because too many files have changed in this diff Show More