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

@@ -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
} }

View File

@@ -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&nbsp;{displayPagination().CurrentPage} of {displayPagination().TotalPages}
</div> </div>
<PaginationButton <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 { 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;
});
}
};
}

View File

@@ -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

View File

@@ -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;

View File

@@ -11,6 +11,12 @@ 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();
@@ -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 (
domainParts.some(function (part) {
return part.length > 63; return part.length > 63;
})) return false; })
)
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 {
@@ -96,10 +111,10 @@ export function isNameValid(name: string): boolean {
// 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
} }

View File

@@ -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...)
}
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
}
// Register @custom-variant blocks. This is how a project defines `dark:` as a CLASS ds := buildDesignSystem(theme)
// 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)
}
}
// 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

View File

@@ -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

View File

@@ -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) }