Manually merge from 'pre-kjol' into 'master'
This commit is contained in:
@@ -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;
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user