Manually merge from 'pre-kjol' into 'master'
This commit is contained in:
@@ -119,6 +119,15 @@ func generateFAIcons() error {
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4144,7 +4144,7 @@ export function AutoTable(props: AutoTableProps) {
|
||||
</PaginationButton>
|
||||
|
||||
<div class={PAGINATION_PAGE}>
|
||||
Page {displayPagination().CurrentPage} of {displayPagination().TotalPages}
|
||||
Page {displayPagination().CurrentPage} of {displayPagination().TotalPages}
|
||||
</div>
|
||||
|
||||
<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 {IconInline} from "./Icons.tsx";
|
||||
import { readAccessor } from "../utils/accessors.ts";
|
||||
import { ErorrField } from "./Validation.ts";
|
||||
|
||||
// --- Tailwind class building helpers ---
|
||||
const INPUT_BASE = "bg-surface block w-full border rounded-default shadow-xs text-sm focus:outline-2 focus:outline-offset-1 disabled:bg-surface-raised disabled:cursor-not-allowed";
|
||||
const INPUT_BASE = "bg-white block w-full border rounded-default shadow-xs text-sm focus:outline-2 focus:outline-offset-1 disabled:bg-neutral-100 disabled:cursor-not-allowed";
|
||||
const INPUT_BASE_DARK = "bg-dark text-text-on-dark placeholder:text-text-on-dark-faint block w-full border rounded-default shadow-xs text-sm focus:outline-2 focus:outline-offset-1 disabled:opacity-50 disabled:cursor-not-allowed";
|
||||
|
||||
const CONTROL_H = "h-[38px]";
|
||||
@@ -12,7 +13,7 @@ const CONTROL_H_SM = "h-[30px]";
|
||||
const _controlH = (small?: boolean): string => (small ? CONTROL_H_SM : CONTROL_H);
|
||||
|
||||
function _fieldBorder(error?: unknown, success?: unknown, onDark?: boolean): string {
|
||||
const borderNormal = onDark ? "border-border-on-dark focus:outline-sky-500" : "border-line-strong focus:outline-sky-500";
|
||||
const borderNormal = onDark ? "border-border-on-dark focus:outline-sky-500" : "border-neutral-300 focus:outline-sky-500";
|
||||
return error ? "border-red-500 focus:outline-red-500"
|
||||
: success ? "border-green-500 focus:outline-green-500"
|
||||
: borderNormal;
|
||||
@@ -37,33 +38,33 @@ function _textareaCls(small?: boolean, error?: unknown, extra?: string, onDark?:
|
||||
function _prefixCls(small?: boolean, error?: unknown, onDark?: boolean): string {
|
||||
const base = onDark
|
||||
? "bg-dark-raised text-text-on-dark-muted border shadow-xs border-r-0 flex items-center shrink-0 rounded-l-default"
|
||||
: "bg-surface-raised border shadow-xs border-r-0 flex items-center shrink-0 rounded-l-default";
|
||||
const borderNormal = onDark ? "border-border-on-dark" : "border-line-strong";
|
||||
: "bg-neutral-100 border shadow-xs border-r-0 flex items-center shrink-0 rounded-l-default";
|
||||
const borderNormal = onDark ? "border-border-on-dark" : "border-neutral-300";
|
||||
return base
|
||||
+ (error ? " border-red-500" : " " + borderNormal)
|
||||
+ (small ? " p-1 text-sm" : " p-2");
|
||||
}
|
||||
|
||||
const FORM_ERROR = "block text-red-600 dark:text-red-400 text-xs mt-1";
|
||||
const FORM_SUCCESS = "block text-green-600 dark:text-green-400 text-xs mt-1";
|
||||
const FORM_ERROR = "block text-red-600 text-xs mt-1";
|
||||
const FORM_SUCCESS = "block text-green-600 text-xs mt-1";
|
||||
const INPUT_GROUP = "flex flex-row items-stretch w-full text-sm";
|
||||
|
||||
const TRIGGER_BASE = "bg-surface border border-line-strong rounded-default shadow-xs text-sm w-full text-left flex items-center justify-between gap-2 cursor-pointer disabled:bg-surface-raised disabled:cursor-not-allowed";
|
||||
const TRIGGER_BASE = "bg-white border border-neutral-300 rounded-default shadow-xs text-sm w-full text-left flex items-center justify-between gap-2 cursor-pointer disabled:bg-neutral-100 disabled:cursor-not-allowed";
|
||||
const TRIGGER_BASE_DARK = "bg-dark-raised border border-border-on-dark text-text-on-dark rounded-default shadow-xs text-sm w-full text-left flex items-center justify-between gap-2 cursor-pointer hover:border-border-on-dark-hover disabled:opacity-50 disabled:cursor-not-allowed";
|
||||
const DROPDOWN = "bg-surface border border-line-strong rounded-default shadow-lg max-h-60 overflow-auto";
|
||||
const DROPDOWN = "bg-white border border-neutral-300 rounded-default shadow-lg max-h-60 overflow-auto";
|
||||
const DROPDOWN_DARK = "bg-dark-raised border border-border-on-dark rounded-default shadow-lg max-h-60 overflow-auto";
|
||||
const DROPDOWN_SEARCH_WRAP = "sticky top-0 bg-surface border-b border-line p-2";
|
||||
const DROPDOWN_SEARCH_WRAP = "sticky top-0 bg-white border-b border-neutral-200 p-2";
|
||||
const DROPDOWN_SEARCH_WRAP_DARK = "sticky top-0 bg-dark-raised border-b border-border-on-dark p-2";
|
||||
const DROPDOWN_SEARCH_INPUT = "w-full bg-surface border border-line-strong rounded-default shadow-xs text-sm p-1 focus:outline-2 focus:outline-sky-500 focus:outline-offset-1";
|
||||
const DROPDOWN_SEARCH_INPUT = "w-full bg-white border border-neutral-300 rounded-default shadow-xs text-sm p-1 focus:outline-2 focus:outline-sky-500 focus:outline-offset-1";
|
||||
const DROPDOWN_SEARCH_INPUT_DARK = "w-full bg-dark border border-border-on-dark text-text-on-dark placeholder:text-text-on-dark-faint rounded-default shadow-xs text-sm p-1 focus:outline-2 focus:outline-sky-500 focus:outline-offset-1";
|
||||
const DROPDOWN_OPTION = "w-full text-left p-2 text-sm cursor-pointer flex items-center gap-2 bg-transparent border-none hover:bg-surface-raised disabled:text-ink-faint disabled:cursor-not-allowed whitespace-nowrap";
|
||||
const DROPDOWN_OPTION = "w-full text-left p-2 text-sm cursor-pointer flex items-center gap-2 bg-transparent border-none hover:bg-neutral-100 disabled:text-neutral-400 disabled:cursor-not-allowed whitespace-nowrap";
|
||||
const DROPDOWN_OPTION_DARK = "w-full text-left p-2 text-sm cursor-pointer flex items-center gap-2 bg-transparent border-none text-text-on-dark hover:bg-white/5 disabled:text-text-on-dark-faint disabled:cursor-not-allowed whitespace-nowrap";
|
||||
const DROPDOWN_OPTION_HIGHLIGHT = "bg-surface-raised";
|
||||
const DROPDOWN_OPTION_HIGHLIGHT = "bg-neutral-100";
|
||||
const DROPDOWN_OPTION_HIGHLIGHT_DARK = "bg-white/10";
|
||||
const DROPDOWN_NO_RESULTS = "p-2 text-sm text-ink-muted text-center";
|
||||
const DROPDOWN_NO_RESULTS = "p-2 text-sm text-neutral-500 text-center";
|
||||
const DROPDOWN_NO_RESULTS_DARK = "p-2 text-sm text-text-on-dark-muted text-center";
|
||||
const SELECT_ALL_WRAP = "border-b border-line";
|
||||
const SELECT_ALL_BTN = "w-full text-left p-2 text-sm cursor-pointer text-ink-soft font-medium bg-transparent border-none hover:bg-surface-raised";
|
||||
const SELECT_ALL_WRAP = "border-b border-neutral-200";
|
||||
const SELECT_ALL_BTN = "w-full text-left p-2 text-sm cursor-pointer text-neutral-600 font-medium bg-transparent border-none hover:bg-neutral-100";
|
||||
|
||||
// -- Shared types --------------------------------------------------
|
||||
type CommonInputAttrs =
|
||||
@@ -373,7 +374,7 @@ export function FormCurrencyInput(props: {showIcon?: boolean} & FormInputProps)
|
||||
}
|
||||
};
|
||||
|
||||
return <FormInput prefix={local.showIcon ? "$" : null} inputMode="decimal" oninput={handleInput} {...inputProps} />;
|
||||
return <FormInput prefix={local.showIcon === false ? null : "$"} inputMode="decimal" oninput={handleInput} {...inputProps} />;
|
||||
|
||||
}
|
||||
|
||||
@@ -393,7 +394,7 @@ export function FormPercentInput(props: {showIcon?: boolean} & FormInputProps) {
|
||||
}
|
||||
};
|
||||
|
||||
return <FormInput prefix={local.showIcon ? "%" : null} inputMode="decimal" oninput={handleInput} {...inputProps} />;
|
||||
return <FormInput prefix={local.showIcon === false ? null : "%"} inputMode="decimal" oninput={handleInput} {...inputProps} />;
|
||||
}
|
||||
|
||||
export function FormPhoneInput(props: {showIcon?: boolean; icon?: string} & FormInputProps) {
|
||||
@@ -404,6 +405,9 @@ export function FormPhoneInput(props: {showIcon?: boolean; icon?: string} & Form
|
||||
const input = e.currentTarget;
|
||||
|
||||
let digits = input.value.replace(/\D/g, "");
|
||||
if (digits[0] === "0" || digits[0] === "1") {
|
||||
digits = digits.slice(1);
|
||||
}
|
||||
digits = digits.slice(0, 10);
|
||||
let formatted = "";
|
||||
if (digits.length > 0) {
|
||||
@@ -634,7 +638,7 @@ export function FormSignaturePad(props: { value?: () => string; onchange?: (svg:
|
||||
};
|
||||
|
||||
return <div class="ui-form">
|
||||
<div class="border border-line-strong rounded-default bg-surface overflow-hidden">
|
||||
<div class="border border-neutral-300 rounded-default bg-white overflow-hidden">
|
||||
<canvas
|
||||
class="block w-full h-auto cursor-crosshair touch-none"
|
||||
width={CANVAS_WIDTH}
|
||||
@@ -648,9 +652,9 @@ export function FormSignaturePad(props: { value?: () => string; onchange?: (svg:
|
||||
onTouchMove={draw}
|
||||
onTouchEnd={stopDrawing}
|
||||
/>
|
||||
<div class="flex items-center justify-between px-2 py-1 border-t border-line bg-surface-muted">
|
||||
<span class="text-xs text-ink-faint italic">{isEmpty() ? "Sign above" : ""}</span>
|
||||
<button type="button" class="text-xs font-medium text-ink-muted bg-transparent border-none cursor-pointer px-1 py-0.5 rounded-default hover:text-red-600 dark:text-red-400 hover:bg-red-50 dark:bg-red-950/40 disabled:opacity-40 disabled:cursor-not-allowed" onclick={clearSignature} disabled={isEmpty()}>Clear</button>
|
||||
<div class="flex items-center justify-between px-2 py-1 border-t border-neutral-200 bg-neutral-50">
|
||||
<span class="text-xs text-neutral-400 italic">{isEmpty() ? "Sign above" : ""}</span>
|
||||
<button type="button" class="text-xs font-medium text-neutral-500 bg-transparent border-none cursor-pointer px-1 py-0.5 rounded-default hover:text-red-600 hover:bg-red-50 disabled:opacity-40 disabled:cursor-not-allowed" onclick={clearSignature} disabled={isEmpty()}>Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
@@ -765,12 +769,12 @@ export function FormSearchableSelect(props: {ref: string} & FormComboboxProps) {
|
||||
return `position:fixed;top:${p.top}px;left:${p.left}px;min-width:${p.width}px;width:max-content;max-width:${maxW}px;z-index:200;`;
|
||||
};
|
||||
|
||||
const triggerCls = () => "w-full flex items-center justify-between gap-2 bg-surface border border-line-strong rounded-default shadow-xs text-sm cursor-pointer text-left focus:outline-2 focus:outline-sky-500 focus:outline-offset-1" + " " + _controlH(local.small) + (local.small ? " p-1" : " p-2");
|
||||
const triggerCls = () => "w-full flex items-center justify-between gap-2 bg-white border border-neutral-300 rounded-default shadow-xs text-sm cursor-pointer text-left focus:outline-2 focus:outline-sky-500 focus:outline-offset-1" + " " + _controlH(local.small) + (local.small ? " p-1" : " p-2");
|
||||
const ssBtnCls = (index, optionValue) => {
|
||||
const base = "w-full p-2 text-left text-sm border-none cursor-pointer disabled:text-ink-faint disabled:cursor-not-allowed";
|
||||
if (optionValue === local.value) return base + " bg-sky-100 dark:bg-sky-950/50 font-medium hover:bg-sky-100 dark:bg-sky-950/50";
|
||||
if (index() === highlightedIndex()) return base + " bg-sky-50 dark:bg-sky-950/40";
|
||||
return base + " hover:bg-sky-50 dark:bg-sky-950/40";
|
||||
const base = "w-full p-2 text-left text-sm border-none cursor-pointer disabled:text-neutral-400 disabled:cursor-not-allowed";
|
||||
if (optionValue === local.value) return base + " bg-sky-100 font-medium hover:bg-sky-100";
|
||||
if (index() === highlightedIndex()) return base + " bg-sky-50";
|
||||
return base + " hover:bg-sky-50";
|
||||
};
|
||||
|
||||
return <div ref={(el) => containerRef = el} class={cls()}>
|
||||
@@ -780,14 +784,14 @@ export function FormSearchableSelect(props: {ref: string} & FormComboboxProps) {
|
||||
onclick={(_e) => { updatePos(); setIsOpen(!isOpen()); }}
|
||||
class={triggerCls()}
|
||||
>
|
||||
<span class={"min-w-0 truncate" + (!selectedOption() ? " text-ink-faint" : "")}>
|
||||
<span class={"min-w-0 truncate" + (!selectedOption() ? " text-neutral-400" : "")}>
|
||||
{selectedOption()?.label || local.placeholder || "Select an option"}
|
||||
</span>
|
||||
<IconInline icon={isOpen() ? "chevron-up" : "chevron-down"} size={16} />
|
||||
</button>
|
||||
<Show when={isOpen()}>
|
||||
<Portal>
|
||||
<div ref={(el) => dropdownRef = el} data-floating-content="true" class="bg-surface border border-line-strong rounded-default shadow-lg overflow-hidden" style={dropdownStyle()}>
|
||||
<div ref={(el) => dropdownRef = el} data-floating-content="true" class="bg-white border border-neutral-300 rounded-default shadow-lg overflow-hidden" style={dropdownStyle()}>
|
||||
<input
|
||||
ref={(el) => searchInputRef = el}
|
||||
type="text"
|
||||
@@ -795,7 +799,7 @@ export function FormSearchableSelect(props: {ref: string} & FormComboboxProps) {
|
||||
oninput={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={local.searchPlaceholder || "Search..."}
|
||||
class="w-full p-2 border-0 border-b border-b-neutral-200 text-sm outline-hidden focus:bg-sky-50 dark:bg-sky-950/40"
|
||||
class="w-full p-2 border-0 border-b border-b-neutral-200 text-sm outline-hidden focus:bg-sky-50"
|
||||
/>
|
||||
<div class="max-h-[200px] overflow-y-auto">
|
||||
<Show when={filteredOptions().length === 0}>
|
||||
@@ -845,7 +849,7 @@ export function FormLabel(props: {inline?: boolean; for?: string; title?: string
|
||||
title={props.title}
|
||||
class={
|
||||
(props.inline ? "inline" : "block") +
|
||||
(props.onDark ? " text-text-on-dark" : " text-ink") +
|
||||
(props.onDark ? " text-text-on-dark" : " text-neutral-700") +
|
||||
" text-sm font-medium mb-2" +
|
||||
(props.class ? " " + props.class : "")}
|
||||
>
|
||||
@@ -854,8 +858,8 @@ export function FormLabel(props: {inline?: boolean; for?: string; title?: string
|
||||
}
|
||||
|
||||
export function FormFileInput(props: FormInputProps) {
|
||||
const cls = () => INPUT_BASE + " p-1 border-line-strong focus:outline-sky-500 cursor-pointer"
|
||||
+ " file:ml-1 file:mr-2 file:bg-surface-raised file:border file:border-line-strong file:rounded-default file:shadow-xs file:text-sm file:cursor-pointer file:hover:bg-surface-strong"
|
||||
const cls = () => INPUT_BASE + " p-1 border-neutral-300 focus:outline-sky-500 cursor-pointer"
|
||||
+ " file:ml-1 file:mr-2 file:bg-neutral-100 file:border file:border-neutral-300 file:rounded-default file:shadow-xs file:text-sm file:cursor-pointer file:hover:bg-neutral-200"
|
||||
+ (props.small ? " file:py-[2px] file:px-3" : " file:py-[3px] file:px-4")
|
||||
+ (props.class ? " " + props.class : "");
|
||||
return <div class="ui-form">
|
||||
@@ -877,8 +881,8 @@ export function FormSpacer() {
|
||||
}
|
||||
|
||||
export function FormFieldset(props: { legend?: string; class?: string; children: JSXElement }) {
|
||||
return <fieldset class={"border border-line-strong rounded-default py-3 px-4" + (props.class ? " " + props.class : "")}>
|
||||
<legend class="px-2 text-sm font-medium text-ink-soft">{props.legend}</legend>
|
||||
return <fieldset class={"border border-neutral-300 rounded-default py-3 px-4" + (props.class ? " " + props.class : "")}>
|
||||
<legend class="px-2 text-sm font-medium text-neutral-600">{props.legend}</legend>
|
||||
{props.children}
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -1115,8 +1119,8 @@ export function FormCombobox(
|
||||
_controlH(props.small) +
|
||||
(props.small ? " p-1" : " p-2");
|
||||
const placeholderCls = () =>
|
||||
!selectedOption() ? (props.onDark ? "text-text-on-dark-muted" : "text-ink-muted") : "";
|
||||
const chevronCls = () => (props.onDark ? "text-text-on-dark-muted" : "text-ink-faint");
|
||||
!selectedOption() ? (props.onDark ? "text-text-on-dark-muted" : "text-neutral-500") : "";
|
||||
const chevronCls = () => (props.onDark ? "text-text-on-dark-muted" : "text-neutral-400");
|
||||
const dropdownCls = () => (props.onDark ? DROPDOWN_DARK : DROPDOWN);
|
||||
const searchWrapCls = () => (props.onDark ? DROPDOWN_SEARCH_WRAP_DARK : DROPDOWN_SEARCH_WRAP);
|
||||
const searchInputCls = () => (props.onDark ? DROPDOWN_SEARCH_INPUT_DARK : DROPDOWN_SEARCH_INPUT);
|
||||
@@ -1352,7 +1356,7 @@ export function FormAsyncCombobox(
|
||||
onclick={() => handleSelect(option)}
|
||||
onMouseEnter={() => setHighlightedIndex(index())}
|
||||
class={
|
||||
DROPDOWN_OPTION + (index() === highlightedIndex() ? " bg-surface-raised" : "")
|
||||
DROPDOWN_OPTION + (index() === highlightedIndex() ? " bg-neutral-100" : "")
|
||||
}
|
||||
>
|
||||
{props.renderOption ? props.renderOption(option) : option.label}
|
||||
@@ -1512,21 +1516,21 @@ export function FormMultiSelect(props: FormMultiSelectProps) {
|
||||
};
|
||||
|
||||
const triggerCls = () => TRIGGER_BASE + " overflow-hidden " + _controlH(props.small) + (props.small ? " p-1" : " p-2");
|
||||
const optionCls = (index) => DROPDOWN_OPTION + (index() === highlightedIndex() ? " bg-surface-raised" : "");
|
||||
const optionCls = (index) => DROPDOWN_OPTION + (index() === highlightedIndex() ? " bg-neutral-100" : "");
|
||||
const dropdownStyle = () => _floatingDropdownStyle(dropdownPos());
|
||||
|
||||
const renderTriggerContent = () => {
|
||||
if (selectedOptions().length === 0) {
|
||||
return <span class="text-ink-muted">{props.placeholder || "Select options"}</span>;
|
||||
return <span class="text-neutral-500">{props.placeholder || "Select options"}</span>;
|
||||
}
|
||||
if (isOverflowing() || selectedOptions().length > maxTags()) {
|
||||
return <span>{selectedOptions().length + " item" + (selectedOptions().length !== 1 ? "s" : "") + " selected"}</span>;
|
||||
}
|
||||
return <div ref={(el) => tagsContainerRef = el} class="flex flex-nowrap gap-1 overflow-hidden items-center">
|
||||
<For each={selectedOptions()}>
|
||||
{(option) => <span class={"inline-flex items-center gap-0.5 bg-surface-strong rounded-default whitespace-nowrap leading-none" + (props.small ? " py-0.5 px-1.5 text-xs" : " py-0.5 px-2 text-sm")}>
|
||||
{(option) => <span class={"inline-flex items-center gap-0.5 bg-neutral-200 rounded-default whitespace-nowrap leading-none" + (props.small ? " py-0.5 px-1.5 text-xs" : " py-0.5 px-2 text-sm")}>
|
||||
{option.label}
|
||||
<button type="button" class="bg-transparent border-none p-0 cursor-pointer flex items-center hover:text-ink" onclick={(e) => removeOption(option.value, e)}>
|
||||
<button type="button" class="bg-transparent border-none p-0 cursor-pointer flex items-center hover:text-neutral-900" onclick={(e) => removeOption(option.value, e)}>
|
||||
<IconInline icon="xmark" size={10} />
|
||||
</button>
|
||||
</span>}
|
||||
@@ -1546,12 +1550,12 @@ export function FormMultiSelect(props: FormMultiSelectProps) {
|
||||
<div class="flex-1 overflow-hidden flex items-center min-w-0">
|
||||
{renderTriggerContent()}
|
||||
</div>
|
||||
<IconInline icon={isOpen() ? "chevron-up" : "chevron-down"} size={16} class="text-ink-faint" />
|
||||
<IconInline icon={isOpen() ? "chevron-up" : "chevron-down"} size={16} class="text-neutral-400" />
|
||||
</button>
|
||||
|
||||
<Show when={isOpen()}>
|
||||
<Portal>
|
||||
<div ref={(el) => dropdownRef = el} data-floating-content="true" class="bg-surface border border-line-strong rounded-default shadow-lg max-h-60 overflow-auto" style={dropdownStyle()}>
|
||||
<div ref={(el) => dropdownRef = el} data-floating-content="true" class="bg-white border border-neutral-300 rounded-default shadow-lg max-h-60 overflow-auto" style={dropdownStyle()}>
|
||||
<Show when={props.searchable}>
|
||||
<div class={DROPDOWN_SEARCH_WRAP}>
|
||||
<input
|
||||
@@ -1683,9 +1687,9 @@ export function FormMultiSelectTrigger(props: FormMultiSelectTriggerProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const dropdownCls = () => "absolute z-50 mt-1 bg-surface border border-line-strong rounded-default shadow-lg max-h-60 overflow-auto " + (props.align === "right" ? "right-0" : "left-0");
|
||||
const dropdownCls = () => "absolute z-50 mt-1 bg-white border border-neutral-300 rounded-default shadow-lg max-h-60 overflow-auto " + (props.align === "right" ? "right-0" : "left-0");
|
||||
// Rounded, inset items (within the p-1.5 list below) to match the Menu component.
|
||||
const optionCls = (index) => DROPDOWN_OPTION + " rounded-default" + (index() === highlightedIndex() ? " bg-surface-raised" : "") + (props.small ? " py-1.5 px-2" : "");
|
||||
const optionCls = (index) => DROPDOWN_OPTION + " rounded-default" + (index() === highlightedIndex() ? " bg-neutral-100" : "") + (props.small ? " py-1.5 px-2" : "");
|
||||
|
||||
return <div ref={(el) => containerRef = el} class={"relative inline-block " + (props.class || "")}>
|
||||
<button
|
||||
@@ -1746,3 +1750,56 @@ export function FormMultiSelectTrigger(props: FormMultiSelectTriggerProps) {
|
||||
</Show>
|
||||
</div>;
|
||||
}
|
||||
|
||||
// Builds a <form onSubmit> handler for the common "validate on submit" flow: touch every
|
||||
// validated field (so a never-blurred required field surfaces its error too), report +
|
||||
// scroll/focus the first one with an error, otherwise run `save`. Fields that were only
|
||||
// force-touched to check them (and turned out fine) are un-touched again afterward, so a field
|
||||
// the user never actually interacted with doesn't keep showing state once submission is handled
|
||||
// (e.g. a field hidden again by an unrelated toggle).
|
||||
export function makeFormSubmitHandler(opts: {
|
||||
errorFields: ErorrField[];
|
||||
touched: Accessor<Record<string, boolean>>;
|
||||
setTouched: Setter<Record<string, boolean>>;
|
||||
onInvalid: (msg: string) => void;
|
||||
save: () => Promise<void>;
|
||||
}): (e: Event) => Promise<void> {
|
||||
return async (e: Event) => {
|
||||
e.preventDefault();
|
||||
const forcedKeys = opts.errorFields
|
||||
.map((field) => field.touchKey ?? field.id)
|
||||
.filter((key) => !opts.touched()[key]);
|
||||
opts.setTouched((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const key of forcedKeys) next[key] = true;
|
||||
return next;
|
||||
});
|
||||
|
||||
try {
|
||||
for (const field of opts.errorFields) {
|
||||
const msg = field.error();
|
||||
if (msg) {
|
||||
opts.onInvalid(msg);
|
||||
const el = document.getElementById(field.id);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
(el as HTMLElement).focus({ preventScroll: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
await opts.save();
|
||||
} finally {
|
||||
opts.setTouched((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const field of opts.errorFields) {
|
||||
const key = field.touchKey ?? field.id;
|
||||
if (forcedKeys.includes(key) && !field.error()) {
|
||||
delete next[key];
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,6 +18,25 @@ interface IconProps {
|
||||
style?: Partial<CSSStyleProperties>;
|
||||
}
|
||||
|
||||
// Vite-style HMR API the dev bundler injects into every module (see
|
||||
// internal/bundler/hmr_server.go + docs/hmr.md); undefined in production.
|
||||
// `declare global` is required here — a bare `interface ImportMeta` in a
|
||||
// module file wouldn't merge with the ambient one `import.meta`'s type
|
||||
// actually resolves against.
|
||||
declare global {
|
||||
interface ImportMeta {
|
||||
hot?: {
|
||||
readonly data: Record<string, any>;
|
||||
accept(cb?: (mod: any) => void): void;
|
||||
accept(deps: string | string[], cb?: (mod: any) => void): void;
|
||||
dispose(cb: (data: Record<string, any>) => void): void;
|
||||
prune(cb: (data: Record<string, any>) => void): void;
|
||||
invalidate(): void;
|
||||
decline(): void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// The FontAwesome family + default weight are theme-driven so this component
|
||||
// stays identical across projects. Each project's CSS theme sets `--fa-style`
|
||||
// (classic → far/fas, sharp → fasr/fass) and `--fa-default-solid` (0/1). Read
|
||||
@@ -49,7 +68,19 @@ const ICON_INLINE = "inline-block align-middle";
|
||||
// lookup for the same name. This shared component ships with it EMPTY: each
|
||||
// project registers its own SVGs from its app entry (see frontend/src/appIcons.ts)
|
||||
// via registerIcon, so Icons.tsx stays identical across projects.
|
||||
const customIcons: Record<string, CustomIconDef> = {};
|
||||
// Persisted across this module's own HMR reloads (see docs/hmr.md). Icons.tsx
|
||||
// is an HMR boundary, and its FA icon subset import churns on every SPA source
|
||||
// edit (generateFAIcons regenerates it each time), so this module reloads far
|
||||
// more often than one would expect from editing Icons.tsx itself. A fresh
|
||||
// module instance would otherwise start with an empty registry, since
|
||||
// registerIcon() only runs once — from appIcons.ts's side-effect import at
|
||||
// initial page load — blanking custom icons (playground, e-cd, ...) until a
|
||||
// full page refresh. import.meta.hot.data survives across reloads of this
|
||||
// module, so stash the registry there instead of a plain module-scoped const.
|
||||
const customIcons: Record<string, CustomIconDef> =
|
||||
(import.meta.hot?.data.customIcons as Record<string, CustomIconDef> | undefined) ?? {};
|
||||
if (import.meta.hot) import.meta.hot.data.customIcons = customIcons;
|
||||
|
||||
|
||||
// Register a custom icon under `name`, overriding FontAwesome for that name.
|
||||
// Call from the app's own icon module (e.g. appIcons.ts), imported for side
|
||||
|
||||
@@ -7,7 +7,7 @@ export type ToastPosition = "top-right" | "top-left" | "bottom-right" | "bottom-
|
||||
export interface ToastConfig {
|
||||
message: string;
|
||||
type?: ToastType;
|
||||
duration?: number | null;
|
||||
duration?: number | null; // in milliseconds
|
||||
dismissible?: boolean;
|
||||
showProgress?: boolean;
|
||||
}
|
||||
@@ -90,7 +90,7 @@ interface ToastItemProps {
|
||||
|
||||
function ToastItem(props: ToastItemProps) {
|
||||
const type = () => props.toast.type ?? "info";
|
||||
const duration = () => props.toast.duration ?? DEFAULT_DURATION;
|
||||
const duration = () => (props.toast.duration !== undefined ? props.toast.duration : DEFAULT_DURATION);
|
||||
const dismissible = () => props.toast.dismissible !== false;
|
||||
const showProgress = () => props.toast.showProgress !== false;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Accessor, createMemo } from "solid-js";
|
||||
import {Accessor, createMemo} from "solid-js";
|
||||
|
||||
export interface Validation<T> {
|
||||
id: string; // snake case identifier that is "touched"
|
||||
@@ -11,8 +11,14 @@ export interface Validation<T> {
|
||||
invalidMsg?: string;
|
||||
}
|
||||
|
||||
export interface ErorrField {
|
||||
error: Accessor<string>;
|
||||
id: string;
|
||||
touchKey?: string; // specify if different from id
|
||||
}
|
||||
|
||||
export function createValidation(validation: Validation<string>): Accessor<string> {
|
||||
const { id, name, required, touched, isValidFunc, invalidMsg } = validation;
|
||||
const {id, name, required, touched, isValidFunc, invalidMsg} = validation;
|
||||
const field = () => validation.field().trim();
|
||||
// When no blur accessor is provided, fall back to the live value so the
|
||||
// "has value but not blurred yet" guard is always false and validation
|
||||
@@ -29,15 +35,22 @@ export function createValidation(validation: Validation<string>): Accessor<strin
|
||||
|
||||
export function isPhoneNumberValid(phoneNumber: string): boolean {
|
||||
phoneNumber = phoneNumber.replace(/\D/g, "");
|
||||
return phoneNumber.length == 10
|
||||
|
||||
if (phoneNumber.length != 10) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const areaCode = Number(phoneNumber.substring(0, 3));
|
||||
return areaCode >= 200;
|
||||
}
|
||||
|
||||
export function isEmailValid(email: string): boolean {
|
||||
const regex = /^[-!#$%&'*+\/0-9=?A-Z^_a-z`{|}~](\.?[-!#$%&'*+\/0-9=?A-Z^_a-z`{|}~])*@[a-zA-Z0-9](-*\.?[a-zA-Z0-9])*\.[a-zA-Z](-?[a-zA-Z0-9])+$/;
|
||||
const regex =
|
||||
/^[-!#$%&'*+\/0-9=?A-Z^_a-z`{|}~](\.?[-!#$%&'*+\/0-9=?A-Z^_a-z`{|}~])*@[a-zA-Z0-9](-*\.?[a-zA-Z0-9])*\.[a-zA-Z](-?[a-zA-Z0-9])+$/;
|
||||
|
||||
if (!email) return false;
|
||||
|
||||
let emailParts = email.split('@');
|
||||
let emailParts = email.split("@");
|
||||
|
||||
if (emailParts.length !== 2) return false;
|
||||
|
||||
@@ -45,27 +58,29 @@ export function isEmailValid(email: string): boolean {
|
||||
let address = emailParts[1];
|
||||
|
||||
if (account.length > 64) return false;
|
||||
|
||||
else if (address.length > 255) return false;
|
||||
|
||||
let domainParts = address.split('.');
|
||||
let domainParts = address.split(".");
|
||||
|
||||
if (domainParts.some(function (part) {
|
||||
return part.length > 63;
|
||||
})) return false;
|
||||
if (
|
||||
domainParts.some(function (part) {
|
||||
return part.length > 63;
|
||||
})
|
||||
)
|
||||
return false;
|
||||
|
||||
return regex.test(email);
|
||||
}
|
||||
|
||||
export function isUrlValid(url: string): boolean {
|
||||
const regex = /[(http(s)?):\/\/(www\.)?a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/;
|
||||
const regex = /^(https?:\/\/)?(www\.)?[a-zA-Z0-9@:%._\+~#-]{1,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&\/=]*)$/;
|
||||
|
||||
return regex.test(url);
|
||||
}
|
||||
|
||||
export function isZipCodeValid(zip: string): boolean {
|
||||
const rawZip = String(zip).replace(/\D/g, "");
|
||||
return rawZip.length == 5 || rawZip.length == 9
|
||||
return rawZip.length == 5 || rawZip.length == 9;
|
||||
}
|
||||
|
||||
export function isTaxIdValid(id: string): boolean {
|
||||
@@ -75,13 +90,13 @@ export function isTaxIdValid(id: string): boolean {
|
||||
|
||||
// isAtLeastMinChars checks if the input string is at least "min" characters long
|
||||
// and returns a boolean, true if valid, false if not.
|
||||
export function isAtLeastMinChars(input: string, min:number):boolean {
|
||||
export function isAtLeastMinChars(input: string, min: number): boolean {
|
||||
return input.length >= min;
|
||||
}
|
||||
|
||||
// isWithinMaxChars checks if the input string is at most "max" characters long
|
||||
// and returns a boolean, true if valid, false if not.
|
||||
export function isWithinMaxChars(input: string, max:number):boolean {
|
||||
export function isWithinMaxChars(input: string, max: number): boolean {
|
||||
return input.length <= max;
|
||||
}
|
||||
|
||||
@@ -89,17 +104,17 @@ export function isWithinMaxChars(input: string, max:number):boolean {
|
||||
// and returns a boolean, true if valid, false if not.
|
||||
export function isNameValid(name: string): boolean {
|
||||
const regex = /^[\p{L}]*[\p{L} '\-]*[\p{L}]$/u;
|
||||
|
||||
|
||||
return regex.test(name);
|
||||
}
|
||||
|
||||
// isUsernameValid checks if the username contains 5-50 characters and only consists of alphanumeric
|
||||
// characters. Returns an error message if invalid, empty string if valid.
|
||||
export function isUsernameValid(username: string): string {
|
||||
if (username.length < 5 || username.length > 50) return "Username must have 5-50 characters"
|
||||
|
||||
if (username.length < 5 || username.length > 50) return "Username must have 5-50 characters";
|
||||
|
||||
const regex = /^[A-Za-z0-9]*$/;
|
||||
if (!regex.test(username)) return "Username must only contain alphanumeric characters"
|
||||
if (!regex.test(username)) return "Username must only contain alphanumeric characters";
|
||||
|
||||
return ""; // Valid
|
||||
}
|
||||
@@ -1261,8 +1261,7 @@ func compileCandidates(rawCandidates []string, ds *DesignSystem, onInvalid func(
|
||||
// the candidate list.
|
||||
//
|
||||
// @INCOMPLETE Only static @utility blocks are supported (no functional
|
||||
// @utility/--value()). @custom-variant IS wired (both the shorthand and block
|
||||
// forms) — see parseCustomVariant. -mta
|
||||
// @utility/--value()); @custom-variant is not yet wired. -mta
|
||||
|
||||
//go:embed tw_theme.css
|
||||
var defaultThemeCSS string
|
||||
@@ -1305,7 +1304,8 @@ func twCompile(input, baseDir string, candidates []string) (string, int, error)
|
||||
var keyframes []*AstNode
|
||||
var passthrough []*AstNode
|
||||
var customUtilities []*AstNode
|
||||
var customVariants []*AstNode
|
||||
var safelistAdd []string
|
||||
var safelistRemove []string
|
||||
hasPreflight := false
|
||||
hasUtilities := false
|
||||
|
||||
@@ -1361,8 +1361,13 @@ func twCompile(input, baseDir string, candidates []string) (string, int, error)
|
||||
processTheme(node)
|
||||
case node.Kind == nAtRule && node.Name == "@utility":
|
||||
customUtilities = append(customUtilities, node)
|
||||
case node.Kind == nAtRule && node.Name == "@custom-variant":
|
||||
customVariants = append(customVariants, node)
|
||||
case node.Kind == nAtRule && node.Name == "@source":
|
||||
literals, negate := parseSourceDirective(node.Params)
|
||||
if negate {
|
||||
safelistRemove = append(safelistRemove, literals...)
|
||||
} else {
|
||||
safelistAdd = append(safelistAdd, literals...)
|
||||
}
|
||||
default:
|
||||
passthrough = append(passthrough, node)
|
||||
}
|
||||
@@ -1375,21 +1380,24 @@ func twCompile(input, baseDir string, candidates []string) (string, int, error)
|
||||
}
|
||||
processInput(inAst, baseDir)
|
||||
|
||||
ds := buildDesignSystem(theme)
|
||||
|
||||
// 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(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
|
||||
}
|
||||
|
||||
ds := buildDesignSystem(theme)
|
||||
|
||||
// Register @utility blocks as static utilities.
|
||||
for _, u := range customUtilities {
|
||||
@@ -1438,14 +1446,6 @@ func twCompile(input, baseDir string, candidates []string) (string, int, error)
|
||||
if e != nil {
|
||||
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...))
|
||||
}
|
||||
|
||||
@@ -1459,6 +1459,95 @@ func twCompile(input, baseDir string, candidates []string) (string, int, error)
|
||||
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
|
||||
// candidate class names using the bundler's scanner.
|
||||
func scanSources(baseDir string, patterns []string) []string {
|
||||
@@ -8831,95 +8920,6 @@ func (v *Variants) compare(a, z *Variant) int {
|
||||
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).
|
||||
func (v *Variants) fromAst(name string, ast []*AstNode, ds *DesignSystem) {
|
||||
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) {
|
||||
cases := []struct {
|
||||
in string
|
||||
|
||||
@@ -20,11 +20,11 @@ import (
|
||||
mincss "github.com/tdewolff/minify/v2/css"
|
||||
)
|
||||
|
||||
var min *minify.M
|
||||
var minifier *minify.M
|
||||
|
||||
func init() {
|
||||
min = minify.New()
|
||||
min.AddFunc("text/css", mincss.Minify)
|
||||
minifier = minify.New()
|
||||
minifier.AddFunc("text/css", mincss.Minify)
|
||||
}
|
||||
|
||||
// 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.
|
||||
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