Manually merge from 'pre-kjol' into 'master'

This commit is contained in:
2026-07-15 15:11:29 -04:00
parent f2e0d22255
commit 342de9e18a
9 changed files with 365 additions and 188 deletions

View File

@@ -4144,7 +4144,7 @@ export function AutoTable(props: AutoTableProps) {
</PaginationButton>
<div class={PAGINATION_PAGE}>
Page {displayPagination().CurrentPage} of {displayPagination().TotalPages}
Page&nbsp;{displayPagination().CurrentPage} of {displayPagination().TotalPages}
</div>
<PaginationButton

View File

@@ -1,10 +1,11 @@
import { createSignal, createEffect, createMemo, onCleanup, For, Show, splitProps, JSX, JSXElement } from "solid-js";
import { createSignal, createEffect, createMemo, onCleanup, For, Show, splitProps, JSX, JSXElement, Accessor, Setter } from "solid-js";
import { Portal } from "solid-js/web";
import {IconInline} from "./Icons.tsx";
import { readAccessor } from "../utils/accessors.ts";
import { ErorrField } from "./Validation.ts";
// --- Tailwind class building helpers ---
const INPUT_BASE = "bg-surface block w-full border rounded-default shadow-xs text-sm focus:outline-2 focus:outline-offset-1 disabled:bg-surface-raised disabled:cursor-not-allowed";
const INPUT_BASE = "bg-white block w-full border rounded-default shadow-xs text-sm focus:outline-2 focus:outline-offset-1 disabled:bg-neutral-100 disabled:cursor-not-allowed";
const INPUT_BASE_DARK = "bg-dark text-text-on-dark placeholder:text-text-on-dark-faint block w-full border rounded-default shadow-xs text-sm focus:outline-2 focus:outline-offset-1 disabled:opacity-50 disabled:cursor-not-allowed";
const CONTROL_H = "h-[38px]";
@@ -12,7 +13,7 @@ const CONTROL_H_SM = "h-[30px]";
const _controlH = (small?: boolean): string => (small ? CONTROL_H_SM : CONTROL_H);
function _fieldBorder(error?: unknown, success?: unknown, onDark?: boolean): string {
const borderNormal = onDark ? "border-border-on-dark focus:outline-sky-500" : "border-line-strong focus:outline-sky-500";
const borderNormal = onDark ? "border-border-on-dark focus:outline-sky-500" : "border-neutral-300 focus:outline-sky-500";
return error ? "border-red-500 focus:outline-red-500"
: success ? "border-green-500 focus:outline-green-500"
: borderNormal;
@@ -37,33 +38,33 @@ function _textareaCls(small?: boolean, error?: unknown, extra?: string, onDark?:
function _prefixCls(small?: boolean, error?: unknown, onDark?: boolean): string {
const base = onDark
? "bg-dark-raised text-text-on-dark-muted border shadow-xs border-r-0 flex items-center shrink-0 rounded-l-default"
: "bg-surface-raised border shadow-xs border-r-0 flex items-center shrink-0 rounded-l-default";
const borderNormal = onDark ? "border-border-on-dark" : "border-line-strong";
: "bg-neutral-100 border shadow-xs border-r-0 flex items-center shrink-0 rounded-l-default";
const borderNormal = onDark ? "border-border-on-dark" : "border-neutral-300";
return base
+ (error ? " border-red-500" : " " + borderNormal)
+ (small ? " p-1 text-sm" : " p-2");
}
const FORM_ERROR = "block text-red-600 dark:text-red-400 text-xs mt-1";
const FORM_SUCCESS = "block text-green-600 dark:text-green-400 text-xs mt-1";
const FORM_ERROR = "block text-red-600 text-xs mt-1";
const FORM_SUCCESS = "block text-green-600 text-xs mt-1";
const INPUT_GROUP = "flex flex-row items-stretch w-full text-sm";
const TRIGGER_BASE = "bg-surface border border-line-strong rounded-default shadow-xs text-sm w-full text-left flex items-center justify-between gap-2 cursor-pointer disabled:bg-surface-raised disabled:cursor-not-allowed";
const TRIGGER_BASE = "bg-white border border-neutral-300 rounded-default shadow-xs text-sm w-full text-left flex items-center justify-between gap-2 cursor-pointer disabled:bg-neutral-100 disabled:cursor-not-allowed";
const TRIGGER_BASE_DARK = "bg-dark-raised border border-border-on-dark text-text-on-dark rounded-default shadow-xs text-sm w-full text-left flex items-center justify-between gap-2 cursor-pointer hover:border-border-on-dark-hover disabled:opacity-50 disabled:cursor-not-allowed";
const DROPDOWN = "bg-surface border border-line-strong rounded-default shadow-lg max-h-60 overflow-auto";
const DROPDOWN = "bg-white border border-neutral-300 rounded-default shadow-lg max-h-60 overflow-auto";
const DROPDOWN_DARK = "bg-dark-raised border border-border-on-dark rounded-default shadow-lg max-h-60 overflow-auto";
const DROPDOWN_SEARCH_WRAP = "sticky top-0 bg-surface border-b border-line p-2";
const DROPDOWN_SEARCH_WRAP = "sticky top-0 bg-white border-b border-neutral-200 p-2";
const DROPDOWN_SEARCH_WRAP_DARK = "sticky top-0 bg-dark-raised border-b border-border-on-dark p-2";
const DROPDOWN_SEARCH_INPUT = "w-full bg-surface border border-line-strong rounded-default shadow-xs text-sm p-1 focus:outline-2 focus:outline-sky-500 focus:outline-offset-1";
const DROPDOWN_SEARCH_INPUT = "w-full bg-white border border-neutral-300 rounded-default shadow-xs text-sm p-1 focus:outline-2 focus:outline-sky-500 focus:outline-offset-1";
const DROPDOWN_SEARCH_INPUT_DARK = "w-full bg-dark border border-border-on-dark text-text-on-dark placeholder:text-text-on-dark-faint rounded-default shadow-xs text-sm p-1 focus:outline-2 focus:outline-sky-500 focus:outline-offset-1";
const DROPDOWN_OPTION = "w-full text-left p-2 text-sm cursor-pointer flex items-center gap-2 bg-transparent border-none hover:bg-surface-raised disabled:text-ink-faint disabled:cursor-not-allowed whitespace-nowrap";
const DROPDOWN_OPTION = "w-full text-left p-2 text-sm cursor-pointer flex items-center gap-2 bg-transparent border-none hover:bg-neutral-100 disabled:text-neutral-400 disabled:cursor-not-allowed whitespace-nowrap";
const DROPDOWN_OPTION_DARK = "w-full text-left p-2 text-sm cursor-pointer flex items-center gap-2 bg-transparent border-none text-text-on-dark hover:bg-white/5 disabled:text-text-on-dark-faint disabled:cursor-not-allowed whitespace-nowrap";
const DROPDOWN_OPTION_HIGHLIGHT = "bg-surface-raised";
const DROPDOWN_OPTION_HIGHLIGHT = "bg-neutral-100";
const DROPDOWN_OPTION_HIGHLIGHT_DARK = "bg-white/10";
const DROPDOWN_NO_RESULTS = "p-2 text-sm text-ink-muted text-center";
const DROPDOWN_NO_RESULTS = "p-2 text-sm text-neutral-500 text-center";
const DROPDOWN_NO_RESULTS_DARK = "p-2 text-sm text-text-on-dark-muted text-center";
const SELECT_ALL_WRAP = "border-b border-line";
const SELECT_ALL_BTN = "w-full text-left p-2 text-sm cursor-pointer text-ink-soft font-medium bg-transparent border-none hover:bg-surface-raised";
const SELECT_ALL_WRAP = "border-b border-neutral-200";
const SELECT_ALL_BTN = "w-full text-left p-2 text-sm cursor-pointer text-neutral-600 font-medium bg-transparent border-none hover:bg-neutral-100";
// -- Shared types --------------------------------------------------
type CommonInputAttrs =
@@ -373,7 +374,7 @@ export function FormCurrencyInput(props: {showIcon?: boolean} & FormInputProps)
}
};
return <FormInput prefix={local.showIcon ? "$" : null} inputMode="decimal" oninput={handleInput} {...inputProps} />;
return <FormInput prefix={local.showIcon === false ? null : "$"} inputMode="decimal" oninput={handleInput} {...inputProps} />;
}
@@ -393,7 +394,7 @@ export function FormPercentInput(props: {showIcon?: boolean} & FormInputProps) {
}
};
return <FormInput prefix={local.showIcon ? "%" : null} inputMode="decimal" oninput={handleInput} {...inputProps} />;
return <FormInput prefix={local.showIcon === false ? null : "%"} inputMode="decimal" oninput={handleInput} {...inputProps} />;
}
export function FormPhoneInput(props: {showIcon?: boolean; icon?: string} & FormInputProps) {
@@ -404,6 +405,9 @@ export function FormPhoneInput(props: {showIcon?: boolean; icon?: string} & Form
const input = e.currentTarget;
let digits = input.value.replace(/\D/g, "");
if (digits[0] === "0" || digits[0] === "1") {
digits = digits.slice(1);
}
digits = digits.slice(0, 10);
let formatted = "";
if (digits.length > 0) {
@@ -634,7 +638,7 @@ export function FormSignaturePad(props: { value?: () => string; onchange?: (svg:
};
return <div class="ui-form">
<div class="border border-line-strong rounded-default bg-surface overflow-hidden">
<div class="border border-neutral-300 rounded-default bg-white overflow-hidden">
<canvas
class="block w-full h-auto cursor-crosshair touch-none"
width={CANVAS_WIDTH}
@@ -648,9 +652,9 @@ export function FormSignaturePad(props: { value?: () => string; onchange?: (svg:
onTouchMove={draw}
onTouchEnd={stopDrawing}
/>
<div class="flex items-center justify-between px-2 py-1 border-t border-line bg-surface-muted">
<span class="text-xs text-ink-faint italic">{isEmpty() ? "Sign above" : ""}</span>
<button type="button" class="text-xs font-medium text-ink-muted bg-transparent border-none cursor-pointer px-1 py-0.5 rounded-default hover:text-red-600 dark:text-red-400 hover:bg-red-50 dark:bg-red-950/40 disabled:opacity-40 disabled:cursor-not-allowed" onclick={clearSignature} disabled={isEmpty()}>Clear</button>
<div class="flex items-center justify-between px-2 py-1 border-t border-neutral-200 bg-neutral-50">
<span class="text-xs text-neutral-400 italic">{isEmpty() ? "Sign above" : ""}</span>
<button type="button" class="text-xs font-medium text-neutral-500 bg-transparent border-none cursor-pointer px-1 py-0.5 rounded-default hover:text-red-600 hover:bg-red-50 disabled:opacity-40 disabled:cursor-not-allowed" onclick={clearSignature} disabled={isEmpty()}>Clear</button>
</div>
</div>
</div>;
@@ -765,12 +769,12 @@ export function FormSearchableSelect(props: {ref: string} & FormComboboxProps) {
return `position:fixed;top:${p.top}px;left:${p.left}px;min-width:${p.width}px;width:max-content;max-width:${maxW}px;z-index:200;`;
};
const triggerCls = () => "w-full flex items-center justify-between gap-2 bg-surface border border-line-strong rounded-default shadow-xs text-sm cursor-pointer text-left focus:outline-2 focus:outline-sky-500 focus:outline-offset-1" + " " + _controlH(local.small) + (local.small ? " p-1" : " p-2");
const triggerCls = () => "w-full flex items-center justify-between gap-2 bg-white border border-neutral-300 rounded-default shadow-xs text-sm cursor-pointer text-left focus:outline-2 focus:outline-sky-500 focus:outline-offset-1" + " " + _controlH(local.small) + (local.small ? " p-1" : " p-2");
const ssBtnCls = (index, optionValue) => {
const base = "w-full p-2 text-left text-sm border-none cursor-pointer disabled:text-ink-faint disabled:cursor-not-allowed";
if (optionValue === local.value) return base + " bg-sky-100 dark:bg-sky-950/50 font-medium hover:bg-sky-100 dark:bg-sky-950/50";
if (index() === highlightedIndex()) return base + " bg-sky-50 dark:bg-sky-950/40";
return base + " hover:bg-sky-50 dark:bg-sky-950/40";
const base = "w-full p-2 text-left text-sm border-none cursor-pointer disabled:text-neutral-400 disabled:cursor-not-allowed";
if (optionValue === local.value) return base + " bg-sky-100 font-medium hover:bg-sky-100";
if (index() === highlightedIndex()) return base + " bg-sky-50";
return base + " hover:bg-sky-50";
};
return <div ref={(el) => containerRef = el} class={cls()}>
@@ -780,14 +784,14 @@ export function FormSearchableSelect(props: {ref: string} & FormComboboxProps) {
onclick={(_e) => { updatePos(); setIsOpen(!isOpen()); }}
class={triggerCls()}
>
<span class={"min-w-0 truncate" + (!selectedOption() ? " text-ink-faint" : "")}>
<span class={"min-w-0 truncate" + (!selectedOption() ? " text-neutral-400" : "")}>
{selectedOption()?.label || local.placeholder || "Select an option"}
</span>
<IconInline icon={isOpen() ? "chevron-up" : "chevron-down"} size={16} />
</button>
<Show when={isOpen()}>
<Portal>
<div ref={(el) => dropdownRef = el} data-floating-content="true" class="bg-surface border border-line-strong rounded-default shadow-lg overflow-hidden" style={dropdownStyle()}>
<div ref={(el) => dropdownRef = el} data-floating-content="true" class="bg-white border border-neutral-300 rounded-default shadow-lg overflow-hidden" style={dropdownStyle()}>
<input
ref={(el) => searchInputRef = el}
type="text"
@@ -795,7 +799,7 @@ export function FormSearchableSelect(props: {ref: string} & FormComboboxProps) {
oninput={(e) => setSearchQuery(e.currentTarget.value)}
onKeyDown={handleKeyDown}
placeholder={local.searchPlaceholder || "Search..."}
class="w-full p-2 border-0 border-b border-b-neutral-200 text-sm outline-hidden focus:bg-sky-50 dark:bg-sky-950/40"
class="w-full p-2 border-0 border-b border-b-neutral-200 text-sm outline-hidden focus:bg-sky-50"
/>
<div class="max-h-[200px] overflow-y-auto">
<Show when={filteredOptions().length === 0}>
@@ -845,7 +849,7 @@ export function FormLabel(props: {inline?: boolean; for?: string; title?: string
title={props.title}
class={
(props.inline ? "inline" : "block") +
(props.onDark ? " text-text-on-dark" : " text-ink") +
(props.onDark ? " text-text-on-dark" : " text-neutral-700") +
" text-sm font-medium mb-2" +
(props.class ? " " + props.class : "")}
>
@@ -854,8 +858,8 @@ export function FormLabel(props: {inline?: boolean; for?: string; title?: string
}
export function FormFileInput(props: FormInputProps) {
const cls = () => INPUT_BASE + " p-1 border-line-strong focus:outline-sky-500 cursor-pointer"
+ " file:ml-1 file:mr-2 file:bg-surface-raised file:border file:border-line-strong file:rounded-default file:shadow-xs file:text-sm file:cursor-pointer file:hover:bg-surface-strong"
const cls = () => INPUT_BASE + " p-1 border-neutral-300 focus:outline-sky-500 cursor-pointer"
+ " file:ml-1 file:mr-2 file:bg-neutral-100 file:border file:border-neutral-300 file:rounded-default file:shadow-xs file:text-sm file:cursor-pointer file:hover:bg-neutral-200"
+ (props.small ? " file:py-[2px] file:px-3" : " file:py-[3px] file:px-4")
+ (props.class ? " " + props.class : "");
return <div class="ui-form">
@@ -877,8 +881,8 @@ export function FormSpacer() {
}
export function FormFieldset(props: { legend?: string; class?: string; children: JSXElement }) {
return <fieldset class={"border border-line-strong rounded-default py-3 px-4" + (props.class ? " " + props.class : "")}>
<legend class="px-2 text-sm font-medium text-ink-soft">{props.legend}</legend>
return <fieldset class={"border border-neutral-300 rounded-default py-3 px-4" + (props.class ? " " + props.class : "")}>
<legend class="px-2 text-sm font-medium text-neutral-600">{props.legend}</legend>
{props.children}
</fieldset>;
}
@@ -1115,8 +1119,8 @@ export function FormCombobox(
_controlH(props.small) +
(props.small ? " p-1" : " p-2");
const placeholderCls = () =>
!selectedOption() ? (props.onDark ? "text-text-on-dark-muted" : "text-ink-muted") : "";
const chevronCls = () => (props.onDark ? "text-text-on-dark-muted" : "text-ink-faint");
!selectedOption() ? (props.onDark ? "text-text-on-dark-muted" : "text-neutral-500") : "";
const chevronCls = () => (props.onDark ? "text-text-on-dark-muted" : "text-neutral-400");
const dropdownCls = () => (props.onDark ? DROPDOWN_DARK : DROPDOWN);
const searchWrapCls = () => (props.onDark ? DROPDOWN_SEARCH_WRAP_DARK : DROPDOWN_SEARCH_WRAP);
const searchInputCls = () => (props.onDark ? DROPDOWN_SEARCH_INPUT_DARK : DROPDOWN_SEARCH_INPUT);
@@ -1352,7 +1356,7 @@ export function FormAsyncCombobox(
onclick={() => handleSelect(option)}
onMouseEnter={() => setHighlightedIndex(index())}
class={
DROPDOWN_OPTION + (index() === highlightedIndex() ? " bg-surface-raised" : "")
DROPDOWN_OPTION + (index() === highlightedIndex() ? " bg-neutral-100" : "")
}
>
{props.renderOption ? props.renderOption(option) : option.label}
@@ -1512,21 +1516,21 @@ export function FormMultiSelect(props: FormMultiSelectProps) {
};
const triggerCls = () => TRIGGER_BASE + " overflow-hidden " + _controlH(props.small) + (props.small ? " p-1" : " p-2");
const optionCls = (index) => DROPDOWN_OPTION + (index() === highlightedIndex() ? " bg-surface-raised" : "");
const optionCls = (index) => DROPDOWN_OPTION + (index() === highlightedIndex() ? " bg-neutral-100" : "");
const dropdownStyle = () => _floatingDropdownStyle(dropdownPos());
const renderTriggerContent = () => {
if (selectedOptions().length === 0) {
return <span class="text-ink-muted">{props.placeholder || "Select options"}</span>;
return <span class="text-neutral-500">{props.placeholder || "Select options"}</span>;
}
if (isOverflowing() || selectedOptions().length > maxTags()) {
return <span>{selectedOptions().length + " item" + (selectedOptions().length !== 1 ? "s" : "") + " selected"}</span>;
}
return <div ref={(el) => tagsContainerRef = el} class="flex flex-nowrap gap-1 overflow-hidden items-center">
<For each={selectedOptions()}>
{(option) => <span class={"inline-flex items-center gap-0.5 bg-surface-strong rounded-default whitespace-nowrap leading-none" + (props.small ? " py-0.5 px-1.5 text-xs" : " py-0.5 px-2 text-sm")}>
{(option) => <span class={"inline-flex items-center gap-0.5 bg-neutral-200 rounded-default whitespace-nowrap leading-none" + (props.small ? " py-0.5 px-1.5 text-xs" : " py-0.5 px-2 text-sm")}>
{option.label}
<button type="button" class="bg-transparent border-none p-0 cursor-pointer flex items-center hover:text-ink" onclick={(e) => removeOption(option.value, e)}>
<button type="button" class="bg-transparent border-none p-0 cursor-pointer flex items-center hover:text-neutral-900" onclick={(e) => removeOption(option.value, e)}>
<IconInline icon="xmark" size={10} />
</button>
</span>}
@@ -1546,12 +1550,12 @@ export function FormMultiSelect(props: FormMultiSelectProps) {
<div class="flex-1 overflow-hidden flex items-center min-w-0">
{renderTriggerContent()}
</div>
<IconInline icon={isOpen() ? "chevron-up" : "chevron-down"} size={16} class="text-ink-faint" />
<IconInline icon={isOpen() ? "chevron-up" : "chevron-down"} size={16} class="text-neutral-400" />
</button>
<Show when={isOpen()}>
<Portal>
<div ref={(el) => dropdownRef = el} data-floating-content="true" class="bg-surface border border-line-strong rounded-default shadow-lg max-h-60 overflow-auto" style={dropdownStyle()}>
<div ref={(el) => dropdownRef = el} data-floating-content="true" class="bg-white border border-neutral-300 rounded-default shadow-lg max-h-60 overflow-auto" style={dropdownStyle()}>
<Show when={props.searchable}>
<div class={DROPDOWN_SEARCH_WRAP}>
<input
@@ -1683,9 +1687,9 @@ export function FormMultiSelectTrigger(props: FormMultiSelectTriggerProps) {
}
};
const dropdownCls = () => "absolute z-50 mt-1 bg-surface border border-line-strong rounded-default shadow-lg max-h-60 overflow-auto " + (props.align === "right" ? "right-0" : "left-0");
const dropdownCls = () => "absolute z-50 mt-1 bg-white border border-neutral-300 rounded-default shadow-lg max-h-60 overflow-auto " + (props.align === "right" ? "right-0" : "left-0");
// Rounded, inset items (within the p-1.5 list below) to match the Menu component.
const optionCls = (index) => DROPDOWN_OPTION + " rounded-default" + (index() === highlightedIndex() ? " bg-surface-raised" : "") + (props.small ? " py-1.5 px-2" : "");
const optionCls = (index) => DROPDOWN_OPTION + " rounded-default" + (index() === highlightedIndex() ? " bg-neutral-100" : "") + (props.small ? " py-1.5 px-2" : "");
return <div ref={(el) => containerRef = el} class={"relative inline-block " + (props.class || "")}>
<button
@@ -1746,3 +1750,56 @@ export function FormMultiSelectTrigger(props: FormMultiSelectTriggerProps) {
</Show>
</div>;
}
// Builds a <form onSubmit> handler for the common "validate on submit" flow: touch every
// validated field (so a never-blurred required field surfaces its error too), report +
// scroll/focus the first one with an error, otherwise run `save`. Fields that were only
// force-touched to check them (and turned out fine) are un-touched again afterward, so a field
// the user never actually interacted with doesn't keep showing state once submission is handled
// (e.g. a field hidden again by an unrelated toggle).
export function makeFormSubmitHandler(opts: {
errorFields: ErorrField[];
touched: Accessor<Record<string, boolean>>;
setTouched: Setter<Record<string, boolean>>;
onInvalid: (msg: string) => void;
save: () => Promise<void>;
}): (e: Event) => Promise<void> {
return async (e: Event) => {
e.preventDefault();
const forcedKeys = opts.errorFields
.map((field) => field.touchKey ?? field.id)
.filter((key) => !opts.touched()[key]);
opts.setTouched((prev) => {
const next = { ...prev };
for (const key of forcedKeys) next[key] = true;
return next;
});
try {
for (const field of opts.errorFields) {
const msg = field.error();
if (msg) {
opts.onInvalid(msg);
const el = document.getElementById(field.id);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
(el as HTMLElement).focus({ preventScroll: true });
}
return;
}
}
await opts.save();
} finally {
opts.setTouched((prev) => {
const next = { ...prev };
for (const field of opts.errorFields) {
const key = field.touchKey ?? field.id;
if (forcedKeys.includes(key) && !field.error()) {
delete next[key];
}
}
return next;
});
}
};
}

View File

@@ -18,6 +18,25 @@ interface IconProps {
style?: Partial<CSSStyleProperties>;
}
// Vite-style HMR API the dev bundler injects into every module (see
// internal/bundler/hmr_server.go + docs/hmr.md); undefined in production.
// `declare global` is required here — a bare `interface ImportMeta` in a
// module file wouldn't merge with the ambient one `import.meta`'s type
// actually resolves against.
declare global {
interface ImportMeta {
hot?: {
readonly data: Record<string, any>;
accept(cb?: (mod: any) => void): void;
accept(deps: string | string[], cb?: (mod: any) => void): void;
dispose(cb: (data: Record<string, any>) => void): void;
prune(cb: (data: Record<string, any>) => void): void;
invalidate(): void;
decline(): void;
};
}
}
// 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
@@ -49,7 +68,19 @@ const ICON_INLINE = "inline-block align-middle";
// 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> = {};
// Persisted across this module's own HMR reloads (see docs/hmr.md). Icons.tsx
// is an HMR boundary, and its FA icon subset import churns on every SPA source
// edit (generateFAIcons regenerates it each time), so this module reloads far
// more often than one would expect from editing Icons.tsx itself. A fresh
// module instance would otherwise start with an empty registry, since
// registerIcon() only runs once — from appIcons.ts's side-effect import at
// initial page load — blanking custom icons (playground, e-cd, ...) until a
// full page refresh. import.meta.hot.data survives across reloads of this
// module, so stash the registry there instead of a plain module-scoped const.
const customIcons: Record<string, CustomIconDef> =
(import.meta.hot?.data.customIcons as Record<string, CustomIconDef> | undefined) ?? {};
if (import.meta.hot) import.meta.hot.data.customIcons = customIcons;
// 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

View File

@@ -7,7 +7,7 @@ export type ToastPosition = "top-right" | "top-left" | "bottom-right" | "bottom-
export interface ToastConfig {
message: string;
type?: ToastType;
duration?: number | null;
duration?: number | null; // in milliseconds
dismissible?: boolean;
showProgress?: boolean;
}
@@ -90,7 +90,7 @@ interface ToastItemProps {
function ToastItem(props: ToastItemProps) {
const type = () => props.toast.type ?? "info";
const duration = () => props.toast.duration ?? DEFAULT_DURATION;
const duration = () => (props.toast.duration !== undefined ? props.toast.duration : DEFAULT_DURATION);
const dismissible = () => props.toast.dismissible !== false;
const showProgress = () => props.toast.showProgress !== false;

View File

@@ -1,4 +1,4 @@
import { Accessor, createMemo } from "solid-js";
import {Accessor, createMemo} from "solid-js";
export interface Validation<T> {
id: string; // snake case identifier that is "touched"
@@ -11,8 +11,14 @@ export interface Validation<T> {
invalidMsg?: string;
}
export interface ErorrField {
error: Accessor<string>;
id: string;
touchKey?: string; // specify if different from id
}
export function createValidation(validation: Validation<string>): Accessor<string> {
const { id, name, required, touched, isValidFunc, invalidMsg } = validation;
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
@@ -29,15 +35,22 @@ export function createValidation(validation: Validation<string>): Accessor<strin
export function isPhoneNumberValid(phoneNumber: string): boolean {
phoneNumber = phoneNumber.replace(/\D/g, "");
return phoneNumber.length == 10
if (phoneNumber.length != 10) {
return false;
}
const areaCode = Number(phoneNumber.substring(0, 3));
return areaCode >= 200;
}
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])+$/;
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('@');
let emailParts = email.split("@");
if (emailParts.length !== 2) return false;
@@ -45,27 +58,29 @@ export function isEmailValid(email: string): boolean {
let address = emailParts[1];
if (account.length > 64) return false;
else if (address.length > 255) return false;
let domainParts = address.split('.');
let domainParts = address.split(".");
if (domainParts.some(function (part) {
return part.length > 63;
})) return false;
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@:%_\+.~#?&//=]*)/;
const regex = /^(https?:\/\/)?(www\.)?[a-zA-Z0-9@:%._\+~#-]{1,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
return rawZip.length == 5 || rawZip.length == 9;
}
export function isTaxIdValid(id: string): boolean {
@@ -75,13 +90,13 @@ export function isTaxIdValid(id: string): boolean {
// 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 {
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 {
export function isWithinMaxChars(input: string, max: number): boolean {
return input.length <= max;
}
@@ -89,17 +104,17 @@ export function isWithinMaxChars(input: string, max:number):boolean {
// 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"
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"
if (!regex.test(username)) return "Username must only contain alphanumeric characters";
return ""; // Valid
}