Manually merge from 'pre-kjol' into 'master'
This commit is contained in:
@@ -119,6 +119,15 @@ func generateFAIcons() error {
|
|||||||
}
|
}
|
||||||
b.WriteString("};\n")
|
b.WriteString("};\n")
|
||||||
|
|
||||||
|
// Skip the write when content is unchanged so the file's mtime doesn't
|
||||||
|
// advance on every SPA source edit — the dev watcher treats any mtime bump
|
||||||
|
// as a real change and hot-reloads Icons.tsx (the nearest HMR boundary that
|
||||||
|
// imports this file), which is wasteful when nothing about the icon set
|
||||||
|
// actually changed.
|
||||||
|
if existing, err := os.ReadFile(faOutPath()); err == nil && string(existing) == b.String() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(faOutPath()), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(faOutPath()), 0755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4144,7 +4144,7 @@ export function AutoTable(props: AutoTableProps) {
|
|||||||
</PaginationButton>
|
</PaginationButton>
|
||||||
|
|
||||||
<div class={PAGINATION_PAGE}>
|
<div class={PAGINATION_PAGE}>
|
||||||
Page {displayPagination().CurrentPage} of {displayPagination().TotalPages}
|
Page {displayPagination().CurrentPage} of {displayPagination().TotalPages}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PaginationButton
|
<PaginationButton
|
||||||
|
|||||||
@@ -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 { Portal } from "solid-js/web";
|
||||||
import {IconInline} from "./Icons.tsx";
|
import {IconInline} from "./Icons.tsx";
|
||||||
import { readAccessor } from "../utils/accessors.ts";
|
import { readAccessor } from "../utils/accessors.ts";
|
||||||
|
import { ErorrField } from "./Validation.ts";
|
||||||
|
|
||||||
// --- Tailwind class building helpers ---
|
// --- 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 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]";
|
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);
|
const _controlH = (small?: boolean): string => (small ? CONTROL_H_SM : CONTROL_H);
|
||||||
|
|
||||||
function _fieldBorder(error?: unknown, success?: unknown, onDark?: boolean): string {
|
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"
|
return error ? "border-red-500 focus:outline-red-500"
|
||||||
: success ? "border-green-500 focus:outline-green-500"
|
: success ? "border-green-500 focus:outline-green-500"
|
||||||
: borderNormal;
|
: borderNormal;
|
||||||
@@ -37,33 +38,33 @@ function _textareaCls(small?: boolean, error?: unknown, extra?: string, onDark?:
|
|||||||
function _prefixCls(small?: boolean, error?: unknown, onDark?: boolean): string {
|
function _prefixCls(small?: boolean, error?: unknown, onDark?: boolean): string {
|
||||||
const base = onDark
|
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-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";
|
: "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-line-strong";
|
const borderNormal = onDark ? "border-border-on-dark" : "border-neutral-300";
|
||||||
return base
|
return base
|
||||||
+ (error ? " border-red-500" : " " + borderNormal)
|
+ (error ? " border-red-500" : " " + borderNormal)
|
||||||
+ (small ? " p-1 text-sm" : " p-2");
|
+ (small ? " p-1 text-sm" : " p-2");
|
||||||
}
|
}
|
||||||
|
|
||||||
const FORM_ERROR = "block text-red-600 dark:text-red-400 text-xs mt-1";
|
const FORM_ERROR = "block text-red-600 text-xs mt-1";
|
||||||
const FORM_SUCCESS = "block text-green-600 dark:text-green-400 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 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 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_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_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_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_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_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 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_WRAP = "border-b border-neutral-200";
|
||||||
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_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 --------------------------------------------------
|
// -- Shared types --------------------------------------------------
|
||||||
type CommonInputAttrs =
|
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) {
|
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;
|
const input = e.currentTarget;
|
||||||
|
|
||||||
let digits = input.value.replace(/\D/g, "");
|
let digits = input.value.replace(/\D/g, "");
|
||||||
|
if (digits[0] === "0" || digits[0] === "1") {
|
||||||
|
digits = digits.slice(1);
|
||||||
|
}
|
||||||
digits = digits.slice(0, 10);
|
digits = digits.slice(0, 10);
|
||||||
let formatted = "";
|
let formatted = "";
|
||||||
if (digits.length > 0) {
|
if (digits.length > 0) {
|
||||||
@@ -634,7 +638,7 @@ export function FormSignaturePad(props: { value?: () => string; onchange?: (svg:
|
|||||||
};
|
};
|
||||||
|
|
||||||
return <div class="ui-form">
|
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
|
<canvas
|
||||||
class="block w-full h-auto cursor-crosshair touch-none"
|
class="block w-full h-auto cursor-crosshair touch-none"
|
||||||
width={CANVAS_WIDTH}
|
width={CANVAS_WIDTH}
|
||||||
@@ -648,9 +652,9 @@ export function FormSignaturePad(props: { value?: () => string; onchange?: (svg:
|
|||||||
onTouchMove={draw}
|
onTouchMove={draw}
|
||||||
onTouchEnd={stopDrawing}
|
onTouchEnd={stopDrawing}
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center justify-between px-2 py-1 border-t border-line bg-surface-muted">
|
<div class="flex items-center justify-between px-2 py-1 border-t border-neutral-200 bg-neutral-50">
|
||||||
<span class="text-xs text-ink-faint italic">{isEmpty() ? "Sign above" : ""}</span>
|
<span class="text-xs text-neutral-400 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>
|
<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>
|
</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;`;
|
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 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";
|
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 dark:bg-sky-950/50 font-medium hover:bg-sky-100 dark:bg-sky-950/50";
|
if (optionValue === local.value) return base + " bg-sky-100 font-medium hover:bg-sky-100";
|
||||||
if (index() === highlightedIndex()) return base + " bg-sky-50 dark:bg-sky-950/40";
|
if (index() === highlightedIndex()) return base + " bg-sky-50";
|
||||||
return base + " hover:bg-sky-50 dark:bg-sky-950/40";
|
return base + " hover:bg-sky-50";
|
||||||
};
|
};
|
||||||
|
|
||||||
return <div ref={(el) => containerRef = el} class={cls()}>
|
return <div ref={(el) => containerRef = el} class={cls()}>
|
||||||
@@ -780,14 +784,14 @@ export function FormSearchableSelect(props: {ref: string} & FormComboboxProps) {
|
|||||||
onclick={(_e) => { updatePos(); setIsOpen(!isOpen()); }}
|
onclick={(_e) => { updatePos(); setIsOpen(!isOpen()); }}
|
||||||
class={triggerCls()}
|
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"}
|
{selectedOption()?.label || local.placeholder || "Select an option"}
|
||||||
</span>
|
</span>
|
||||||
<IconInline icon={isOpen() ? "chevron-up" : "chevron-down"} size={16} />
|
<IconInline icon={isOpen() ? "chevron-up" : "chevron-down"} size={16} />
|
||||||
</button>
|
</button>
|
||||||
<Show when={isOpen()}>
|
<Show when={isOpen()}>
|
||||||
<Portal>
|
<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
|
<input
|
||||||
ref={(el) => searchInputRef = el}
|
ref={(el) => searchInputRef = el}
|
||||||
type="text"
|
type="text"
|
||||||
@@ -795,7 +799,7 @@ export function FormSearchableSelect(props: {ref: string} & FormComboboxProps) {
|
|||||||
oninput={(e) => setSearchQuery(e.currentTarget.value)}
|
oninput={(e) => setSearchQuery(e.currentTarget.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={local.searchPlaceholder || "Search..."}
|
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">
|
<div class="max-h-[200px] overflow-y-auto">
|
||||||
<Show when={filteredOptions().length === 0}>
|
<Show when={filteredOptions().length === 0}>
|
||||||
@@ -845,7 +849,7 @@ export function FormLabel(props: {inline?: boolean; for?: string; title?: string
|
|||||||
title={props.title}
|
title={props.title}
|
||||||
class={
|
class={
|
||||||
(props.inline ? "inline" : "block") +
|
(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" +
|
" text-sm font-medium mb-2" +
|
||||||
(props.class ? " " + props.class : "")}
|
(props.class ? " " + props.class : "")}
|
||||||
>
|
>
|
||||||
@@ -854,8 +858,8 @@ export function FormLabel(props: {inline?: boolean; for?: string; title?: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FormFileInput(props: FormInputProps) {
|
export function FormFileInput(props: FormInputProps) {
|
||||||
const cls = () => INPUT_BASE + " p-1 border-line-strong focus:outline-sky-500 cursor-pointer"
|
const cls = () => INPUT_BASE + " p-1 border-neutral-300 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"
|
+ " 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.small ? " file:py-[2px] file:px-3" : " file:py-[3px] file:px-4")
|
||||||
+ (props.class ? " " + props.class : "");
|
+ (props.class ? " " + props.class : "");
|
||||||
return <div class="ui-form">
|
return <div class="ui-form">
|
||||||
@@ -877,8 +881,8 @@ export function FormSpacer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FormFieldset(props: { legend?: string; class?: string; children: JSXElement }) {
|
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 : "")}>
|
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-ink-soft">{props.legend}</legend>
|
<legend class="px-2 text-sm font-medium text-neutral-600">{props.legend}</legend>
|
||||||
{props.children}
|
{props.children}
|
||||||
</fieldset>;
|
</fieldset>;
|
||||||
}
|
}
|
||||||
@@ -1115,8 +1119,8 @@ export function FormCombobox(
|
|||||||
_controlH(props.small) +
|
_controlH(props.small) +
|
||||||
(props.small ? " p-1" : " p-2");
|
(props.small ? " p-1" : " p-2");
|
||||||
const placeholderCls = () =>
|
const placeholderCls = () =>
|
||||||
!selectedOption() ? (props.onDark ? "text-text-on-dark-muted" : "text-ink-muted") : "";
|
!selectedOption() ? (props.onDark ? "text-text-on-dark-muted" : "text-neutral-500") : "";
|
||||||
const chevronCls = () => (props.onDark ? "text-text-on-dark-muted" : "text-ink-faint");
|
const chevronCls = () => (props.onDark ? "text-text-on-dark-muted" : "text-neutral-400");
|
||||||
const dropdownCls = () => (props.onDark ? DROPDOWN_DARK : DROPDOWN);
|
const dropdownCls = () => (props.onDark ? DROPDOWN_DARK : DROPDOWN);
|
||||||
const searchWrapCls = () => (props.onDark ? DROPDOWN_SEARCH_WRAP_DARK : DROPDOWN_SEARCH_WRAP);
|
const searchWrapCls = () => (props.onDark ? DROPDOWN_SEARCH_WRAP_DARK : DROPDOWN_SEARCH_WRAP);
|
||||||
const searchInputCls = () => (props.onDark ? DROPDOWN_SEARCH_INPUT_DARK : DROPDOWN_SEARCH_INPUT);
|
const searchInputCls = () => (props.onDark ? DROPDOWN_SEARCH_INPUT_DARK : DROPDOWN_SEARCH_INPUT);
|
||||||
@@ -1352,7 +1356,7 @@ export function FormAsyncCombobox(
|
|||||||
onclick={() => handleSelect(option)}
|
onclick={() => handleSelect(option)}
|
||||||
onMouseEnter={() => setHighlightedIndex(index())}
|
onMouseEnter={() => setHighlightedIndex(index())}
|
||||||
class={
|
class={
|
||||||
DROPDOWN_OPTION + (index() === highlightedIndex() ? " bg-surface-raised" : "")
|
DROPDOWN_OPTION + (index() === highlightedIndex() ? " bg-neutral-100" : "")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{props.renderOption ? props.renderOption(option) : option.label}
|
{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 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 dropdownStyle = () => _floatingDropdownStyle(dropdownPos());
|
||||||
|
|
||||||
const renderTriggerContent = () => {
|
const renderTriggerContent = () => {
|
||||||
if (selectedOptions().length === 0) {
|
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()) {
|
if (isOverflowing() || selectedOptions().length > maxTags()) {
|
||||||
return <span>{selectedOptions().length + " item" + (selectedOptions().length !== 1 ? "s" : "") + " selected"}</span>;
|
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">
|
return <div ref={(el) => tagsContainerRef = el} class="flex flex-nowrap gap-1 overflow-hidden items-center">
|
||||||
<For each={selectedOptions()}>
|
<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}
|
{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} />
|
<IconInline icon="xmark" size={10} />
|
||||||
</button>
|
</button>
|
||||||
</span>}
|
</span>}
|
||||||
@@ -1546,12 +1550,12 @@ export function FormMultiSelect(props: FormMultiSelectProps) {
|
|||||||
<div class="flex-1 overflow-hidden flex items-center min-w-0">
|
<div class="flex-1 overflow-hidden flex items-center min-w-0">
|
||||||
{renderTriggerContent()}
|
{renderTriggerContent()}
|
||||||
</div>
|
</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>
|
</button>
|
||||||
|
|
||||||
<Show when={isOpen()}>
|
<Show when={isOpen()}>
|
||||||
<Portal>
|
<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}>
|
<Show when={props.searchable}>
|
||||||
<div class={DROPDOWN_SEARCH_WRAP}>
|
<div class={DROPDOWN_SEARCH_WRAP}>
|
||||||
<input
|
<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.
|
// 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 || "")}>
|
return <div ref={(el) => containerRef = el} class={"relative inline-block " + (props.class || "")}>
|
||||||
<button
|
<button
|
||||||
@@ -1746,3 +1750,56 @@ export function FormMultiSelectTrigger(props: FormMultiSelectTriggerProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>;
|
</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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,25 @@ interface IconProps {
|
|||||||
style?: Partial<CSSStyleProperties>;
|
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
|
// The FontAwesome family + default weight are theme-driven so this component
|
||||||
// stays identical across projects. Each project's CSS theme sets `--fa-style`
|
// 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
|
// (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
|
// 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)
|
// project registers its own SVGs from its app entry (see frontend/src/appIcons.ts)
|
||||||
// via registerIcon, so Icons.tsx stays identical across projects.
|
// 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.
|
// 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
|
// Call from the app's own icon module (e.g. appIcons.ts), imported for side
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export type ToastPosition = "top-right" | "top-left" | "bottom-right" | "bottom-
|
|||||||
export interface ToastConfig {
|
export interface ToastConfig {
|
||||||
message: string;
|
message: string;
|
||||||
type?: ToastType;
|
type?: ToastType;
|
||||||
duration?: number | null;
|
duration?: number | null; // in milliseconds
|
||||||
dismissible?: boolean;
|
dismissible?: boolean;
|
||||||
showProgress?: boolean;
|
showProgress?: boolean;
|
||||||
}
|
}
|
||||||
@@ -90,7 +90,7 @@ interface ToastItemProps {
|
|||||||
|
|
||||||
function ToastItem(props: ToastItemProps) {
|
function ToastItem(props: ToastItemProps) {
|
||||||
const type = () => props.toast.type ?? "info";
|
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 dismissible = () => props.toast.dismissible !== false;
|
||||||
const showProgress = () => props.toast.showProgress !== false;
|
const showProgress = () => props.toast.showProgress !== false;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Accessor, createMemo } from "solid-js";
|
import {Accessor, createMemo} from "solid-js";
|
||||||
|
|
||||||
export interface Validation<T> {
|
export interface Validation<T> {
|
||||||
id: string; // snake case identifier that is "touched"
|
id: string; // snake case identifier that is "touched"
|
||||||
@@ -11,8 +11,14 @@ export interface Validation<T> {
|
|||||||
invalidMsg?: string;
|
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> {
|
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();
|
const field = () => validation.field().trim();
|
||||||
// When no blur accessor is provided, fall back to the live value so the
|
// 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
|
// "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 {
|
export function isPhoneNumberValid(phoneNumber: string): boolean {
|
||||||
phoneNumber = phoneNumber.replace(/\D/g, "");
|
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 {
|
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;
|
if (!email) return false;
|
||||||
|
|
||||||
let emailParts = email.split('@');
|
let emailParts = email.split("@");
|
||||||
|
|
||||||
if (emailParts.length !== 2) return false;
|
if (emailParts.length !== 2) return false;
|
||||||
|
|
||||||
@@ -45,27 +58,29 @@ export function isEmailValid(email: string): boolean {
|
|||||||
let address = emailParts[1];
|
let address = emailParts[1];
|
||||||
|
|
||||||
if (account.length > 64) return false;
|
if (account.length > 64) return false;
|
||||||
|
|
||||||
else if (address.length > 255) return false;
|
else if (address.length > 255) return false;
|
||||||
|
|
||||||
let domainParts = address.split('.');
|
let domainParts = address.split(".");
|
||||||
|
|
||||||
if (domainParts.some(function (part) {
|
if (
|
||||||
return part.length > 63;
|
domainParts.some(function (part) {
|
||||||
})) return false;
|
return part.length > 63;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
|
||||||
return regex.test(email);
|
return regex.test(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isUrlValid(url: string): boolean {
|
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);
|
return regex.test(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isZipCodeValid(zip: string): boolean {
|
export function isZipCodeValid(zip: string): boolean {
|
||||||
const rawZip = String(zip).replace(/\D/g, "");
|
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 {
|
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
|
// isAtLeastMinChars checks if the input string is at least "min" characters long
|
||||||
// and returns a boolean, true if valid, false if not.
|
// 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;
|
return input.length >= min;
|
||||||
}
|
}
|
||||||
|
|
||||||
// isWithinMaxChars checks if the input string is at most "max" characters long
|
// isWithinMaxChars checks if the input string is at most "max" characters long
|
||||||
// and returns a boolean, true if valid, false if not.
|
// 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;
|
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.
|
// and returns a boolean, true if valid, false if not.
|
||||||
export function isNameValid(name: string): boolean {
|
export function isNameValid(name: string): boolean {
|
||||||
const regex = /^[\p{L}]*[\p{L} '\-]*[\p{L}]$/u;
|
const regex = /^[\p{L}]*[\p{L} '\-]*[\p{L}]$/u;
|
||||||
|
|
||||||
return regex.test(name);
|
return regex.test(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// isUsernameValid checks if the username contains 5-50 characters and only consists of alphanumeric
|
// 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.
|
// characters. Returns an error message if invalid, empty string if valid.
|
||||||
export function isUsernameValid(username: string): string {
|
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]*$/;
|
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
|
return ""; // Valid
|
||||||
}
|
}
|
||||||
@@ -1261,8 +1261,7 @@ func compileCandidates(rawCandidates []string, ds *DesignSystem, onInvalid func(
|
|||||||
// the candidate list.
|
// the candidate list.
|
||||||
//
|
//
|
||||||
// @INCOMPLETE Only static @utility blocks are supported (no functional
|
// @INCOMPLETE Only static @utility blocks are supported (no functional
|
||||||
// @utility/--value()). @custom-variant IS wired (both the shorthand and block
|
// @utility/--value()); @custom-variant is not yet wired. -mta
|
||||||
// forms) — see parseCustomVariant. -mta
|
|
||||||
|
|
||||||
//go:embed tw_theme.css
|
//go:embed tw_theme.css
|
||||||
var defaultThemeCSS string
|
var defaultThemeCSS string
|
||||||
@@ -1305,7 +1304,8 @@ func twCompile(input, baseDir string, candidates []string) (string, int, error)
|
|||||||
var keyframes []*AstNode
|
var keyframes []*AstNode
|
||||||
var passthrough []*AstNode
|
var passthrough []*AstNode
|
||||||
var customUtilities []*AstNode
|
var customUtilities []*AstNode
|
||||||
var customVariants []*AstNode
|
var safelistAdd []string
|
||||||
|
var safelistRemove []string
|
||||||
hasPreflight := false
|
hasPreflight := false
|
||||||
hasUtilities := false
|
hasUtilities := false
|
||||||
|
|
||||||
@@ -1361,8 +1361,13 @@ func twCompile(input, baseDir string, candidates []string) (string, int, error)
|
|||||||
processTheme(node)
|
processTheme(node)
|
||||||
case node.Kind == nAtRule && node.Name == "@utility":
|
case node.Kind == nAtRule && node.Name == "@utility":
|
||||||
customUtilities = append(customUtilities, node)
|
customUtilities = append(customUtilities, node)
|
||||||
case node.Kind == nAtRule && node.Name == "@custom-variant":
|
case node.Kind == nAtRule && node.Name == "@source":
|
||||||
customVariants = append(customVariants, node)
|
literals, negate := parseSourceDirective(node.Params)
|
||||||
|
if negate {
|
||||||
|
safelistRemove = append(safelistRemove, literals...)
|
||||||
|
} else {
|
||||||
|
safelistAdd = append(safelistAdd, literals...)
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
passthrough = append(passthrough, node)
|
passthrough = append(passthrough, node)
|
||||||
}
|
}
|
||||||
@@ -1375,21 +1380,24 @@ func twCompile(input, baseDir string, candidates []string) (string, int, error)
|
|||||||
}
|
}
|
||||||
processInput(inAst, baseDir)
|
processInput(inAst, baseDir)
|
||||||
|
|
||||||
ds := buildDesignSystem(theme)
|
if len(safelistAdd) > 0 {
|
||||||
|
candidates = append(candidates, safelistAdd...)
|
||||||
// Register @custom-variant blocks. This is how a project defines `dark:` as a CLASS
|
|
||||||
// toggle rather than a media query — the built-in dark variant follows the OS, which
|
|
||||||
// a site with a theme switch cannot use:
|
|
||||||
//
|
|
||||||
// @custom-variant dark (&:where(.dark, .dark *));
|
|
||||||
//
|
|
||||||
// Both of Tailwind's forms are accepted: the shorthand above, and the block form
|
|
||||||
// with an explicit @slot.
|
|
||||||
for _, cv := range customVariants {
|
|
||||||
if name, body, ok := parseCustomVariant(cv); ok {
|
|
||||||
ds.variants.fromAst(name, body, ds)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if len(safelistRemove) > 0 {
|
||||||
|
remove := make(map[string]bool, len(safelistRemove))
|
||||||
|
for _, c := range safelistRemove {
|
||||||
|
remove[c] = true
|
||||||
|
}
|
||||||
|
filtered := candidates[:0:0]
|
||||||
|
for _, c := range candidates {
|
||||||
|
if !remove[c] {
|
||||||
|
filtered = append(filtered, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates = filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
ds := buildDesignSystem(theme)
|
||||||
|
|
||||||
// Register @utility blocks as static utilities.
|
// Register @utility blocks as static utilities.
|
||||||
for _, u := range customUtilities {
|
for _, u := range customUtilities {
|
||||||
@@ -1438,14 +1446,6 @@ func twCompile(input, baseDir string, candidates []string) (string, int, error)
|
|||||||
if e != nil {
|
if e != nil {
|
||||||
return "", 0, e
|
return "", 0, e
|
||||||
}
|
}
|
||||||
// The preflight is written against Tailwind's compile-time CSS functions —
|
|
||||||
// `font-family: --theme(--default-font-family, …)` and five more like it. They
|
|
||||||
// have to be resolved here, exactly as the theme's own declarations are above.
|
|
||||||
// Left in, `--theme(…)` reaches the browser verbatim, which cannot parse it and
|
|
||||||
// so DROPS THE WHOLE DECLARATION: html ends up with no font-family at all and
|
|
||||||
// falls back to the browser default, and no @theme override of --font-sans can
|
|
||||||
// ever take effect.
|
|
||||||
substituteFunctions(pfAst, ds)
|
|
||||||
out = append(out, atRule("@layer", "base", pfAst...))
|
out = append(out, atRule("@layer", "base", pfAst...))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1459,6 +1459,95 @@ func twCompile(input, baseDir string, candidates []string) (string, int, error)
|
|||||||
return toCss(out), len(astNodes), nil
|
return toCss(out), len(astNodes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseSourceDirective parses the params of an `@source` at-rule, supporting
|
||||||
|
// the safelist form `@source inline("<pattern>")` / `@source not inline("<pattern>")`
|
||||||
|
// (mirrors upstream Tailwind v4's inline source safelist). Each quoted string
|
||||||
|
// literal is brace-expanded (e.g. `{text,bg}-{red,blue}-{100,200}`) into the
|
||||||
|
// literal candidate classes it names. File-glob `@source "./path/**/*.html"`
|
||||||
|
// directives are not supported and are ignored.
|
||||||
|
func parseSourceDirective(params string) (literals []string, negate bool) {
|
||||||
|
trimmed := strings.TrimSpace(params)
|
||||||
|
if rest, ok := strings.CutPrefix(trimmed, "not "); ok {
|
||||||
|
negate = true
|
||||||
|
trimmed = strings.TrimSpace(rest)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(trimmed, "inline(") || !strings.HasSuffix(trimmed, ")") {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
inner := trimmed[len("inline(") : len(trimmed)-1]
|
||||||
|
for _, lit := range extractQuotedLiterals(inner) {
|
||||||
|
literals = append(literals, expandBraces(lit)...)
|
||||||
|
}
|
||||||
|
return literals, negate
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractQuotedLiterals returns the contents of every single- or
|
||||||
|
// double-quoted string literal found in s.
|
||||||
|
func extractQuotedLiterals(s string) []string {
|
||||||
|
var out []string
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
quote := s[i]
|
||||||
|
if quote != '\'' && quote != '"' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
j := i + 1
|
||||||
|
for j < len(s) && s[j] != quote {
|
||||||
|
if s[j] == '\\' && j+1 < len(s) {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
out = append(out, s[i+1:min(j, len(s))])
|
||||||
|
i = j
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// expandBraces expands shell-style brace groups in pattern, e.g.
|
||||||
|
// "{text,bg}-red-{100,200}" -> ["text-red-100", "text-red-200",
|
||||||
|
// "bg-red-100", "bg-red-200"]. A brace group may also be a numeric range
|
||||||
|
// ("{1..3}" -> "1", "2", "3"). Groups are expanded left to right; a pattern
|
||||||
|
// with no braces expands to itself.
|
||||||
|
func expandBraces(pattern string) []string {
|
||||||
|
start := strings.IndexByte(pattern, '{')
|
||||||
|
if start == -1 {
|
||||||
|
return []string{pattern}
|
||||||
|
}
|
||||||
|
relEnd := strings.IndexByte(pattern[start:], '}')
|
||||||
|
if relEnd == -1 {
|
||||||
|
return []string{pattern}
|
||||||
|
}
|
||||||
|
end := start + relEnd
|
||||||
|
prefix, inner, suffix := pattern[:start], pattern[start+1:end], pattern[end+1:]
|
||||||
|
|
||||||
|
var parts []string
|
||||||
|
if a, b, ok := strings.Cut(inner, ".."); ok && !strings.Contains(inner, ",") {
|
||||||
|
lo, loErr := strconv.Atoi(strings.TrimSpace(a))
|
||||||
|
hi, hiErr := strconv.Atoi(strings.TrimSpace(b))
|
||||||
|
if loErr == nil && hiErr == nil {
|
||||||
|
step := 1
|
||||||
|
if lo > hi {
|
||||||
|
step = -1
|
||||||
|
}
|
||||||
|
for n := lo; ; n += step {
|
||||||
|
parts = append(parts, strconv.Itoa(n))
|
||||||
|
if n == hi {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if parts == nil {
|
||||||
|
parts = strings.Split(inner, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []string
|
||||||
|
for _, p := range parts {
|
||||||
|
out = append(out, expandBraces(prefix+p+suffix)...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// scanSources scans the given glob/** patterns (relative to baseDir) for
|
// scanSources scans the given glob/** patterns (relative to baseDir) for
|
||||||
// candidate class names using the bundler's scanner.
|
// candidate class names using the bundler's scanner.
|
||||||
func scanSources(baseDir string, patterns []string) []string {
|
func scanSources(baseDir string, patterns []string) []string {
|
||||||
@@ -8831,95 +8920,6 @@ func (v *Variants) compare(a, z *Variant) int {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseCustomVariant reads an @custom-variant at-rule into a name and the AST body that
|
|
||||||
// fromAst expects (a body whose rules contain an @slot where the utility goes).
|
|
||||||
//
|
|
||||||
// Two forms, both from Tailwind:
|
|
||||||
//
|
|
||||||
// @custom-variant dark (&:where(.dark, .dark *)); // shorthand
|
|
||||||
//
|
|
||||||
// @custom-variant dark { // block, explicit slot
|
|
||||||
// &:where(.dark, .dark *) { @slot; }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// In the shorthand, a parenthesised selector starting with '@' is an at-rule
|
|
||||||
// (`@custom-variant any-hover (@media (any-hover: hover))`), and anything else is a
|
|
||||||
// selector. Several may be given, comma-separated at the top level.
|
|
||||||
func parseCustomVariant(node *AstNode) (name string, body []*AstNode, ok bool) {
|
|
||||||
params := strings.TrimSpace(node.Params)
|
|
||||||
if params == "" {
|
|
||||||
return "", nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// The name is the first token; whatever follows is the shorthand's parenthesised part.
|
|
||||||
i := strings.IndexAny(params, " \t(")
|
|
||||||
if i < 0 {
|
|
||||||
// No shorthand: it must be the block form, which carries its own @slot.
|
|
||||||
if len(node.Nodes) == 0 {
|
|
||||||
return "", nil, false
|
|
||||||
}
|
|
||||||
return params, node.Nodes, true
|
|
||||||
}
|
|
||||||
name = strings.TrimSpace(params[:i])
|
|
||||||
rest := strings.TrimSpace(params[i:])
|
|
||||||
|
|
||||||
if rest == "" {
|
|
||||||
if len(node.Nodes) == 0 {
|
|
||||||
return "", nil, false
|
|
||||||
}
|
|
||||||
return name, node.Nodes, true
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(rest, "(") || !strings.HasSuffix(rest, ")") {
|
|
||||||
return "", nil, false
|
|
||||||
}
|
|
||||||
inner := strings.TrimSpace(rest[1 : len(rest)-1])
|
|
||||||
if inner == "" {
|
|
||||||
return "", nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, sel := range splitTopLevel(inner, ',') {
|
|
||||||
sel = strings.TrimSpace(sel)
|
|
||||||
if sel == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
slot := atRule("@slot", "")
|
|
||||||
if strings.HasPrefix(sel, "@") {
|
|
||||||
// "@media (any-hover: hover)" -> name "@media", params "(any-hover: hover)"
|
|
||||||
at, params, _ := strings.Cut(sel, " ")
|
|
||||||
body = append(body, atRule(at, strings.TrimSpace(params), slot))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
body = append(body, styleRule(sel, slot))
|
|
||||||
}
|
|
||||||
if len(body) == 0 {
|
|
||||||
return "", nil, false
|
|
||||||
}
|
|
||||||
return name, body, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// splitTopLevel splits on sep, ignoring separators nested inside brackets — a selector
|
|
||||||
// list like `&:where(.dark, .dark *)` is ONE selector, and splitting it on its inner
|
|
||||||
// comma would produce two broken halves.
|
|
||||||
func splitTopLevel(s string, sep byte) []string {
|
|
||||||
var parts []string
|
|
||||||
depth := 0
|
|
||||||
start := 0
|
|
||||||
for i := 0; i < len(s); i++ {
|
|
||||||
switch s[i] {
|
|
||||||
case '(', '[':
|
|
||||||
depth++
|
|
||||||
case ')', ']':
|
|
||||||
depth--
|
|
||||||
case sep:
|
|
||||||
if depth == 0 {
|
|
||||||
parts = append(parts, s[start:i])
|
|
||||||
start = i + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return append(parts, s[start:])
|
|
||||||
}
|
|
||||||
|
|
||||||
// fromAst registers a variant whose body comes from CSS (@custom-variant).
|
// fromAst registers a variant whose body comes from CSS (@custom-variant).
|
||||||
func (v *Variants) fromAst(name string, ast []*AstNode, ds *DesignSystem) {
|
func (v *Variants) fromAst(name string, ast []*AstNode, ds *DesignSystem) {
|
||||||
var selectors []string
|
var selectors []string
|
||||||
|
|||||||
@@ -42,6 +42,71 @@ func TestEngineEdgeCases(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @source inline(...) safelists candidates that aren't present in the scanned
|
||||||
|
// source files at all (brace expansion covers the color/shade cross product).
|
||||||
|
func TestSourceInlineSafelist(t *testing.T) {
|
||||||
|
css, _, err := twCompile(
|
||||||
|
`@import "tailwindcss";
|
||||||
|
@source inline("{text,bg}-{red,blue}-{100,500}");`,
|
||||||
|
".", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("twCompile error: %v", err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"color: var(--color-red-100)",
|
||||||
|
"color: var(--color-red-500)",
|
||||||
|
"color: var(--color-blue-100)",
|
||||||
|
"color: var(--color-blue-500)",
|
||||||
|
"background-color: var(--color-red-100)",
|
||||||
|
"background-color: var(--color-blue-500)",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(css, want) {
|
||||||
|
t.Errorf("expected safelisted output to contain %q\n---\n%s", want, css)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `not inline(...)` removes a candidate even if the scanner found it in source.
|
||||||
|
func TestSourceNotInlineRemoves(t *testing.T) {
|
||||||
|
css, _, err := twCompile(
|
||||||
|
`@import "tailwindcss";
|
||||||
|
@source not inline("bg-red-500");`,
|
||||||
|
".", []string{"bg-red-500", "bg-blue-500"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("twCompile error: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(css, "background-color: var(--color-red-500)") {
|
||||||
|
t.Errorf("expected bg-red-500 to be excluded by `not inline`\n---\n%s", css)
|
||||||
|
}
|
||||||
|
if !strings.Contains(css, "background-color: var(--color-blue-500)") {
|
||||||
|
t.Errorf("expected bg-blue-500 to remain\n---\n%s", css)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpandBraces(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"flex", []string{"flex"}},
|
||||||
|
{"{a,b}", []string{"a", "b"}},
|
||||||
|
{"{1..3}", []string{"1", "2", "3"}},
|
||||||
|
{"{text,bg}-{red,blue}", []string{"text-red", "text-blue", "bg-red", "bg-blue"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := expandBraces(c.in)
|
||||||
|
if len(got) != len(c.want) {
|
||||||
|
t.Errorf("expandBraces(%q) = %v, want %v", c.in, got, c.want)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != c.want[i] {
|
||||||
|
t.Errorf("expandBraces(%q)[%d] = %q, want %q", c.in, i, got[i], c.want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSegmentTopLevel(t *testing.T) {
|
func TestSegmentTopLevel(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
in string
|
in string
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ import (
|
|||||||
mincss "github.com/tdewolff/minify/v2/css"
|
mincss "github.com/tdewolff/minify/v2/css"
|
||||||
)
|
)
|
||||||
|
|
||||||
var min *minify.M
|
var minifier *minify.M
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
min = minify.New()
|
minifier = minify.New()
|
||||||
min.AddFunc("text/css", mincss.Minify)
|
minifier.AddFunc("text/css", mincss.Minify)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan extracts candidate utility class names from the files matched by patterns
|
// Scan extracts candidate utility class names from the files matched by patterns
|
||||||
@@ -56,4 +56,4 @@ func CompileFiles(entryCSS, baseDir string, sourceGlobs []string) (string, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Minify shrinks compiled CSS.
|
// Minify shrinks compiled CSS.
|
||||||
func Minify(css string) (string, error) { return min.String("text/css", css) }
|
func Minify(css string) (string, error) { return minifier.String("text/css", css) }
|
||||||
|
|||||||
Reference in New Issue
Block a user