Files
kjol/go/jsruntime/uikit/Forms.tsx

1815 lines
72 KiB
TypeScript

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-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]";
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-neutral-300 focus:outline-sky-500";
return error ? "border-red-500 focus:outline-red-500"
: success ? "border-green-500 focus:outline-green-500"
: borderNormal;
}
function _inputCls(small?: boolean, error?: unknown, success?: unknown, extra?: string, onDark?: boolean): string {
const base = onDark ? INPUT_BASE_DARK : INPUT_BASE;
return base + " " + _controlH(small) + (small ? " p-1" : " p-2")
+ " " + _fieldBorder(error, success, onDark)
+ (extra ? " " + extra : "");
}
// Textarea shares the input look but must grow with its content, so it opts out
// of the fixed control height.
function _textareaCls(small?: boolean, error?: unknown, extra?: string, onDark?: boolean): string {
const base = onDark ? INPUT_BASE_DARK : INPUT_BASE;
return base + (small ? " p-1" : " p-2")
+ " " + _fieldBorder(error, false, onDark)
+ (extra ? " " + extra : "");
}
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-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 text-ss mt-1";
const FORM_SUCCESS = "block text-green-600 text-ss mt-1";
const INPUT_GROUP = "flex flex-row items-stretch w-full text-sm";
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-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-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-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-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-neutral-100";
const DROPDOWN_OPTION_HIGHLIGHT_DARK = "bg-white/10";
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-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 =
| "autocomplete"
| "disabled"
| "id"
| "inputmode"
| "maxlength"
| "name"
| "onblur"
| "onchange"
| "onclick"
| "onfocus"
| "oninput"
| "onkeydown"
| "placeholder"
| "readonly"
| "required"
| "value"
// Legacy attributes (camel case)
| "inputMode"
| "maxLength"
| "onBlur"
| "onchange"
| "onclick"
| "onFocus"
| "onInput"
| "onKeyDown";
// Subsets valid for <select> (excludes input-only attrs)
type CommonSelectAttrs = Exclude<CommonInputAttrs, "maxlength" | "inputmode" | "placeholder" | "readonly" | "maxLength" | "inputMode">;
interface FormLabelProps {
inline?: boolean;
for?: string;
title?: string;
class?: string;
onDark?: boolean;
children?: JSXElement;
}
export interface FormInputCommonProps {
passwordManagerIgnore?: boolean;
error?: string;
success?: string;
style?: string | Record<string, string | number>;
}
export interface FormInputProps
extends
FormInputCommonProps,
Pick<JSX.InputHTMLAttributes<HTMLInputElement>, CommonInputAttrs | "type" | "max" | "min" | "step" | "accept"> {
ref?: (el: HTMLInputElement) => void;
prefix?: JSXElement;
small?: boolean;
class?: string;
onDark?: boolean;
}
export interface FormTextareaProps
extends FormInputCommonProps, Pick<JSX.TextareaHTMLAttributes<HTMLTextAreaElement>, CommonInputAttrs | "rows" > {
ref?: (el: HTMLTextAreaElement) => void;
small?: boolean;
class?: string;
onDark?: boolean;
}
export interface FormNumberInputProps extends FormInputProps {
int?: boolean;
unsigned?: boolean;
}
export interface FormSelectProps
extends FormInputCommonProps, Pick<JSX.SelectHTMLAttributes<HTMLSelectElement>, CommonSelectAttrs> {
small?: boolean;
ref?: (el: HTMLSelectElement) => void;
class?: string;
onDark?: boolean;
}
export interface FormSelectOption {
value: string;
label: string;
disabled?: boolean;
}
export type ComboboxFieldWidth = "fill" | "grow" | "narrow" | "default" | "wide" | "auto";
const COMBOBOX_WIDTH_CLS: Record<ComboboxFieldWidth, string> = {
fill: "w-full min-w-0 max-w-full",
grow: "w-full min-w-[8rem] max-w-[16rem]",
narrow: "w-full min-w-[7rem] max-w-[11rem]",
default: "w-full min-w-[9rem] max-w-[14rem]",
wide: "w-full min-w-[10rem] max-w-[20rem]",
auto: "w-auto min-w-[8rem] max-w-full",
};
function _floatingDropdownPos(triggerEl: HTMLElement | undefined, gap = 4): { top: number; left: number; width: number } {
if (!triggerEl) return { top: 0, left: 0, width: 0 };
const rect = triggerEl.getBoundingClientRect();
return { top: rect.bottom + gap, left: rect.left, width: rect.width };
}
function _floatingDropdownStyle(pos: { top: number; left: number; width: number }, maxWidth = 400): string {
const maxW = Math.min(maxWidth, window.innerWidth - pos.left - 8);
return `position:fixed;top:${pos.top}px;left:${pos.left}px;min-width:${pos.width}px;width:max-content;max-width:${maxW}px;z-index:200;`;
}
function _comboboxRootCls(className?: string, fieldWidth: ComboboxFieldWidth = "fill"): string {
return "relative " + COMBOBOX_WIDTH_CLS[fieldWidth] + (className ? " " + className : "");
}
export interface FormComboboxProps extends Pick<JSX.SelectHTMLAttributes<HTMLSelectElement>, CommonSelectAttrs> {
options?: FormSelectOption[];
placeholder?: string;
searchable?: boolean;
searchPlaceholder?: string;
small?: boolean;
renderOption?: (option: FormSelectOption) => JSXElement;
class?: string;
fieldWidth?: ComboboxFieldWidth;
onDark?: boolean;
maxDisplayLength?: number;
}
export interface FormMultiSelectProps {
options: FormSelectOption[];
value?: string[] | (() => string[]);
onchange?: (value: string[]) => void;
placeholder?: string;
searchable?: boolean;
searchPlaceholder?: string;
showSelectAll?: boolean;
maxTagsBeforeCollapse?: number;
disabled?: boolean;
small?: boolean;
class?: string;
fieldWidth?: ComboboxFieldWidth;
}
export interface FormComboboxTriggerProps {
trigger: unknown;
options: FormSelectOption[];
value?: string | (() => string);
onchange?: (value: string) => void;
searchable?: boolean;
searchPlaceholder?: string;
disabled?: boolean | (() => boolean);
small?: boolean;
minWidth?: number;
align?: "left" | "right";
class?: string;
}
export interface FormMultiSelectTriggerProps {
trigger: JSXElement;
options: FormSelectOption[] | (() => FormSelectOption[]);
value?: string[] | (() => string[]);
onchange?: (value: string[]) => void;
searchable?: boolean;
searchPlaceholder?: string;
showSelectAll?: boolean;
disabled?: boolean;
small?: boolean;
minWidth?: number;
align?: "left" | "right";
class?: string;
}
export function FormInputOld(props: FormInputProps) {
const [local, inputProps] = splitProps(props, [
"prefix",
"small",
"error",
"success",
"class",
"onDark",
"ref",
"autocomplete",
"passwordManagerIgnore",
]);
return (
<div class="ui-form">
<input
ref={(el) => typeof local.ref === "function" && local.ref(el)}
class={_inputCls(local.small, local.error, local.success, local.class, local.onDark)}
autocomplete={local.autocomplete || (local.passwordManagerIgnore ? "off" : undefined)}
data-1p-ignore={local.passwordManagerIgnore}
data-lpignore={local.passwordManagerIgnore ? "other" : undefined}
data-form-type={local.passwordManagerIgnore}
data-bwignore={local.passwordManagerIgnore}
data-protonpass-ignore={local.passwordManagerIgnore}
{...inputProps}
/>
<Show when={local.error}>
<span class={FORM_ERROR}>{local.error}</span>
</Show>
<Show when={local.success}>
<span class={FORM_SUCCESS}>{local.success}</span>
</Show>
</div>
);
}
export function FormInput(props: FormInputProps) {
const [local, inputProps] = splitProps(props, [
"prefix",
"small",
"error",
"success",
"class",
"onDark",
"ref",
"autocomplete",
"passwordManagerIgnore",
]);
const cls = local.prefix ? ("rounded-l-none" + (local.class ? " " + local.class : "")) : local.class;
const InputEl = () => (
<input
ref={(el) => typeof local.ref === "function" && local.ref(el)}
class={_inputCls(local.small, local.error, local.success, cls, local.onDark)}
autocomplete={local.autocomplete || (local.passwordManagerIgnore ? "off" : undefined)}
data-1p-ignore={local.passwordManagerIgnore}
data-lpignore={local.passwordManagerIgnore ? "other" : undefined}
data-form-type={local.passwordManagerIgnore}
data-bwignore={local.passwordManagerIgnore}
data-protonpass-ignore={local.passwordManagerIgnore}
{...inputProps}
/>
);
return (
<Show
when={local.prefix}
fallback={
<div class="ui-form">
<InputEl />
<Show when={local.error}>
<span class={FORM_ERROR}>{local.error}</span>
</Show>
<Show when={local.success}>
<span class={FORM_SUCCESS}>{local.success}</span>
</Show>
</div>
}
>
<div class="ui-form">
<div class={INPUT_GROUP}>
<span class={_prefixCls(local.small, local.error, local.onDark)}>{local.prefix}</span>
<span class="grow flex"><InputEl /></span>
</div>
<Show when={local.error}>
<span class={FORM_ERROR}>{local.error}</span>
</Show>
<Show when={local.success}>
<span class={FORM_SUCCESS}>{local.success}</span>
</Show>
</div>
</Show>
);
}
export function FormNumberInput(props: FormNumberInputProps) {
const [local, inputProps] = splitProps(props, ["oninput", "int", "unsigned"]);
var regex: RegExp;
if (local.int) {
regex = local.unsigned ? /[^0-9]/g : /[^-0-9]/g;
} else {
regex = local.unsigned ? /[^0-9.]/g : /[^-0-9.]/g;
}
const handleInput = (e) => {
if (!e || !e.currentTarget) return;
const input = e.currentTarget;
input.value = input.value.replace(regex, "");
// Remove hyphens that aren't at the start of the number
if (!local.unsigned) {
const isNegative = input.value.startsWith("-");
input.value = input.value.replace(/-/g, "");
if (isNegative) input.value = "-" + input.value;
}
const parts = input.value.split(".");
if (parts.length > 2) {
input.value = parts[0] + "." + parts.slice(1).join("");
}
if (typeof local.oninput === "function") {
local.oninput(e);
}
};
return <FormInput inputMode="decimal" oninput={handleInput} {...inputProps} />;
}
export function FormCurrencyInput(props: {showIcon?: boolean} & FormInputProps) {
const [local, inputProps] = splitProps(props, ["oninput", "showIcon"]);
const handleInput = (e) => {
if (!e || !e.currentTarget) return;
const input = e.currentTarget;
let value = input.value.replace(/[^0-9.]/g, "");
const parts = value.split(".");
if (parts.length > 2) {
value = parts[0] + "." + parts.slice(1).join("");
}
if (parts.length === 2 && parts[1].length > 2) {
value = parts[0] + "." + parts[1].slice(0, 2);
}
input.value = value;
if (typeof local.oninput === "function") {
local.oninput(e);
}
};
return <FormInput prefix={local.showIcon === false ? null : "$"} inputMode="decimal" oninput={handleInput} {...inputProps} />;
}
export function FormPercentInput(props: {showIcon?: boolean} & FormInputProps) {
const [local, inputProps] = splitProps(props, ["oninput", "showIcon"]);
const handleInput = (e) => {
if (!e || !e.currentTarget) return;
const input = e.currentTarget;
input.value = input.value.replace(/[^0-9.]/g, "");
const parts = input.value.split(".");
if (parts.length > 2) {
input.value = parts[0] + "." + parts.slice(1).join("");
}
if (typeof local.oninput === "function") {
local.oninput(e);
}
};
return <FormInput prefix={local.showIcon === false ? null : "%"} inputMode="decimal" oninput={handleInput} {...inputProps} />;
}
export function FormPhoneInput(props: {showIcon?: boolean; icon?: string} & FormInputProps) {
const [local, inputProps] = splitProps(props, ["showIcon", "icon", "oninput"]);
const handleInput = (e) => {
if (!e || !e.currentTarget) return;
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) {
formatted = "(" + digits.slice(0, 3);
}
if (digits.length > 3) {
formatted += ") " + digits.slice(3, 6);
}
if (digits.length > 6) {
formatted += "-" + digits.slice(6, 10);
}
input.value = formatted;
if (typeof local.oninput === "function") {
local.oninput(e);
}
};
return (
<FormInput
prefix={local.showIcon !== false ? <IconInline icon={local.icon || "phone"} size={16} /> : null}
inputMode="numeric"
oninput={handleInput}
placeholder="(555) 555-5555"
{...inputProps}
/>
);
}
export function FormEmailInput(props: {showIcon?: boolean} & FormInputProps) {
const [local, inputProps] = splitProps(props, ["showIcon"]);
return (
<FormInput
prefix={local.showIcon !== false ? <IconInline icon="envelope" size={16} /> : null}
type="email"
inputMode="email"
{...inputProps}
/>
);
}
export function FormURLInput(props: {showIcon?: boolean} & FormInputProps) {
const [local, inputProps] = splitProps(props, ["showIcon"]);
return (
<FormInput
prefix={local.showIcon !== false ? <IconInline icon="globe" size={16} /> : null}
type="text"
inputMode="url"
{...inputProps}
/>
);
}
export function FormZipCodeInput(props: FormInputProps) {
const [local, inputProps] = splitProps(props, ["oninput"]);
const handleInput = (e) => {
if (!e || !e.currentTarget) return;
const input = e.currentTarget;
input.value = input.value
.replace(/[^0-9]/g, "")
.substring(0, 9)
.replace(/^(\d{5})(\d{1,4})?$/, function (_, a, b) {
return b ? a + "-" + b : a;
});
if (typeof local.oninput === "function") {
local.oninput(e);
}
};
return <FormInput
type="text"
inputMode="numeric"
oninput={handleInput}
placeholder="12345"
{...inputProps}
/>;
}
export const handleTaxIdInput = (e) => {
if (!e || !e.currentTarget) return;
const input = e.currentTarget;
let digits = input.value.replace(/\D/g, "");
digits = digits.slice(0, 9);
let formatted = digits.slice(0,2);
if (digits.length > 2) {
formatted += "-" + digits.slice(2, 9);
}
input.value = formatted;
}
export const handleRateInput = (e) => {
if (!e || !e.currentTarget) return;
const input = e.currentTarget;
let value = input.value.replace(/[^0-9.]/g, "");
const parts = value.split(".");
if (parts.length > 3) {
value = parts[0] + "." + parts.slice(1).join("");
}
if (value.includes(".")) {
const [intPart, decPart] = value.split(".");
value = intPart + "." + decPart.slice(0, 3);
}
value = value.replace(/^0+(?=\d)/, "");
input.value = value;
}
export function FormSignaturePad(props: { value?: () => string; onchange?: (svg: string) => void; class?: string }) {
let canvasEl;
let ctx;
const CANVAS_WIDTH = 600;
const CANVAS_HEIGHT = 120;
const [isDrawing, setIsDrawing] = createSignal(false);
const [strokes, setStrokes] = createSignal([]);
const [currentStroke, setCurrentStroke] = createSignal([]);
const [isEmpty, setIsEmpty] = createSignal(true);
const sizeCanvas = () => {
if (!canvasEl) return;
canvasEl.width = CANVAS_WIDTH;
canvasEl.height = CANVAS_HEIGHT;
ctx = canvasEl.getContext("2d");
redraw();
};
const getPoint = (e) => {
const rect = canvasEl.getBoundingClientRect();
const scaleX = CANVAS_WIDTH / rect.width;
const scaleY = CANVAS_HEIGHT / rect.height;
if (e.touches) {
return { x: (e.touches[0].clientX - rect.left) * scaleX, y: (e.touches[0].clientY - rect.top) * scaleY };
}
return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY };
};
const redraw = () => {
if (!ctx) return;
ctx.clearRect(0, 0, canvasEl.width, canvasEl.height);
ctx.strokeStyle = "#1a1a2e";
ctx.lineWidth = 2;
ctx.lineCap = "round";
ctx.lineJoin = "round";
const allStrokes = [...strokes(), ...(currentStroke().length > 0 ? [currentStroke()] : [])];
for (const stroke of allStrokes) {
if (stroke.length < 2) continue;
ctx.beginPath();
ctx.moveTo(stroke[0].x, stroke[0].y);
for (let i = 1; i < stroke.length; i++) {
ctx.lineTo(stroke[i].x, stroke[i].y);
}
ctx.stroke();
}
};
const strokesToSvg = (allStrokes) => {
const w = CANVAS_WIDTH;
const h = CANVAS_HEIGHT;
const paths = allStrokes.map((stroke) => {
if (stroke.length < 2) return "";
let d = `M${stroke[0].x.toFixed(1)},${stroke[0].y.toFixed(1)}`;
if (stroke.length === 2) {
d += ` L${stroke[1].x.toFixed(1)},${stroke[1].y.toFixed(1)}`;
} else {
for (let i = 1; i < stroke.length - 1; i++) {
const mx = ((stroke[i].x + stroke[i + 1].x) / 2).toFixed(1);
const my = ((stroke[i].y + stroke[i + 1].y) / 2).toFixed(1);
d += ` Q${stroke[i].x.toFixed(1)},${stroke[i].y.toFixed(1)},${mx},${my}`;
}
const last = stroke[stroke.length - 1];
d += ` L${last.x.toFixed(1)},${last.y.toFixed(1)}`;
}
return `<path d="${d}" fill="none" stroke="#1a1a2e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`;
}).filter(Boolean);
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}">${paths.join("")}</svg>`;
};
const emitChange = (allStrokes) => {
const hasContent = allStrokes.length > 0 && allStrokes.some(s => s.length >= 2);
setIsEmpty(!hasContent);
props.onchange?.(hasContent ? strokesToSvg(allStrokes) : "");
};
const startDrawing = (e) => {
e.preventDefault();
if (!ctx) sizeCanvas();
setIsDrawing(true);
setCurrentStroke([getPoint(e)]);
};
const draw = (e) => {
if (!isDrawing()) return;
e.preventDefault();
const point = getPoint(e);
setCurrentStroke(prev => [...prev, point]);
redraw();
};
const stopDrawing = (e) => {
if (!isDrawing()) return;
e?.preventDefault();
setIsDrawing(false);
const stroke = currentStroke();
if (stroke.length >= 2) {
const newStrokes = [...strokes(), stroke];
setStrokes(newStrokes);
emitChange(newStrokes);
}
setCurrentStroke([]);
redraw();
};
const clearSignature = () => {
setStrokes([]);
setCurrentStroke([]);
setIsEmpty(true);
redraw();
props.onchange?.("");
};
const initCanvas = (el) => {
canvasEl = el;
requestAnimationFrame(() => sizeCanvas());
};
return <div class="ui-form">
<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}
height={CANVAS_HEIGHT}
ref={initCanvas}
onMouseDown={startDrawing}
onMouseMove={draw}
onMouseUp={stopDrawing}
onMouseLeave={stopDrawing}
onTouchStart={startDrawing}
onTouchMove={draw}
onTouchEnd={stopDrawing}
/>
<div class="flex items-center justify-between px-2 py-1 border-t border-neutral-200 bg-neutral-50">
<span class="text-ss text-neutral-400 italic">{isEmpty() ? "Sign above" : ""}</span>
<button type="button" class="text-ss 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>;
}
// @TODO Fix this
export function FormSelect(props: FormSelectProps & { children?: JSXElement }) {
const [local, selectProps] = splitProps(props, ["small", "ref", "class", "children", "value", "onDark"]);
let selectEl;
// Set value via deferred effect so it runs AFTER children (<option>s) are in the DOM.
// The spread's createRenderEffect runs immediately (before children), so select.value
// can't match any option and silently fails. We also track `local.children` so that
// when options arrive after the initial render (e.g. via async fetch), the value is
// re-applied against the now-populated <option> list.
createEffect(() => {
local.children;
if (selectEl && local.value !== undefined) {
selectEl.value = local.value;
}
});
return <div class="ui-form">
<select
ref={(el) => { selectEl = el; if (local.ref) local.ref(el); }}
class={_inputCls(local.small, false, false, local.class, local.onDark)}
{...selectProps}
>{local.children}</select>
</div>;
}
export function FormSearchableSelect(props: {ref: string} & FormComboboxProps) {
const [local] = splitProps(props, ["small", "ref", "class", "options", "value", "onchange", "placeholder", "searchPlaceholder", "fieldWidth"]);
const [searchQuery, setSearchQuery] = createSignal("");
const [isOpen, setIsOpen] = createSignal(false);
const [highlightedIndex, setHighlightedIndex] = createSignal(-1);
const [dropdownPos, setDropdownPos] = createSignal({ top: 0, left: 0, width: 0 });
let containerRef;
let triggerRef;
let searchInputRef;
let dropdownRef;
const filteredOptions = () => {
const q = searchQuery().toLowerCase();
return q
? (local.options || []).filter((opt) => opt.label.toLowerCase().includes(q))
: (local.options || []);
};
const selectedOption = () => (local.options || []).find((opt) => opt.value === local.value);
const updatePos = () => {
if (!triggerRef) return;
const rect = triggerRef.getBoundingClientRect();
setDropdownPos({ top: rect.bottom + 4, left: rect.left, width: rect.width });
};
createEffect(() => {
if (isOpen()) {
updatePos();
const idx = filteredOptions().findIndex((opt) => opt.value === local.value);
setHighlightedIndex(idx >= 0 ? idx : 0);
if (searchInputRef) searchInputRef.focus();
} else {
setHighlightedIndex(-1);
}
});
createEffect(() => {
function onMouseDown(e) {
const inContainer = containerRef?.contains(e.target);
const inDropdown = dropdownRef?.contains(e.target);
if (!inContainer && !inDropdown) { setIsOpen(false); setSearchQuery(""); }
}
document.addEventListener("mousedown", onMouseDown);
onCleanup(() => document.removeEventListener("mousedown", onMouseDown));
});
const handleSelect = (optionValue) => {
if (typeof local.onchange === "function") {
local.onchange(optionValue);
}
setIsOpen(false);
setSearchQuery("");
};
const handleKeyDown = (e) => {
if (e.key === "Escape") { setIsOpen(false); setSearchQuery(""); }
else if (e.key === "Enter") {
e.preventDefault();
const opt = filteredOptions()[highlightedIndex()];
if (opt && !opt.disabled) handleSelect(opt.value);
} else if (e.key === "ArrowDown") {
e.preventDefault();
setHighlightedIndex((prev) => {
let next = prev + 1;
while (next < filteredOptions().length && filteredOptions()[next].disabled) next++;
return next < filteredOptions().length ? next : prev;
});
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlightedIndex((prev) => {
let next = prev - 1;
while (next >= 0 && filteredOptions()[next].disabled) next--;
return next >= 0 ? next : prev;
});
}
};
const cls = () => _comboboxRootCls(local.class, local.fieldWidth);
const dropdownStyle = () => {
const p = dropdownPos();
const maxW = Math.min(400, window.innerWidth - p.left - 8);
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-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-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()}>
<button
ref={(el) => triggerRef = el}
type="button"
onclick={(_e) => { updatePos(); setIsOpen(!isOpen()); }}
class={triggerCls()}
>
<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-white border border-neutral-300 rounded-default shadow-lg overflow-hidden" style={dropdownStyle()}>
<input
ref={(el) => searchInputRef = el}
type="text"
value={searchQuery()}
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"
/>
<div class="max-h-50 overflow-y-auto">
<Show when={filteredOptions().length === 0}>
<div class={DROPDOWN_NO_RESULTS}>No options found</div>
</Show>
<For each={filteredOptions()}>
{(option, index) => <button
type="button"
disabled={option.disabled}
onclick={(_e) => !option.disabled && handleSelect(option.value)}
onMouseEnter={(_e) => setHighlightedIndex(index())}
class={ssBtnCls(index, option.value)}
>{option.label}</button>}
</For>
</div>
</div>
</Portal>
</Show>
</div>;
}
export function FormTextarea(props: FormTextareaProps) {
const [local, inputProps] = splitProps(props, [
"small",
"error",
"class",
"onDark",
"ref",
]);
const cls = () => _textareaCls(local.small, local.error, local.class, local.onDark);
return <div class="ui-form">
<textarea
ref={(el) => typeof local.ref === "function" && local.ref(el)}
class={cls()}
{...inputProps}
/>
<Show when={local.error}>
<span class={FORM_ERROR}>{local.error}</span>
</Show>
</div>;
}
export function FormLabel(props: FormLabelProps) {
return <label
for={props.for}
title={props.title}
class={
(props.inline ? "inline" : "block") +
(props.onDark ? " text-text-on-dark" : " text-neutral-700") +
" text-sm font-medium mb-2" +
(props.class ? " " + props.class : "")}
>
{props.children}
</label>;
}
export function FormFileInput(props: FormInputProps) {
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">
<input
ref={(el) => typeof props.ref === "function" && props.ref(el)}
type="file"
class={cls()}
disabled={props.disabled}
name={props.name}
id={props.id}
accept={props.accept}
onchange={props.onchange}
/>
</div>;
}
export function FormSpacer() {
return <div class="mb-3" />;
}
export function FormFieldset(props: { legend?: string; class?: string; children: JSXElement }) {
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>;
}
export const US_STATES: FormSelectOption[] = [
{ value: "", label: "Please select a state", disabled: true },
{ value: "AL", label: "Alabama" },
{ value: "AK", label: "Alaska" },
{ value: "AZ", label: "Arizona" },
{ value: "AR", label: "Arkansas" },
{ value: "CA", label: "California" },
{ value: "CO", label: "Colorado" },
{ value: "CT", label: "Connecticut" },
{ value: "DE", label: "Delaware" },
{ value: "DC", label: "District of Columbia" },
{ value: "FL", label: "Florida" },
{ value: "GA", label: "Georgia" },
{ value: "HI", label: "Hawaii" },
{ value: "ID", label: "Idaho" },
{ value: "IL", label: "Illinois" },
{ value: "IN", label: "Indiana" },
{ value: "IA", label: "Iowa" },
{ value: "KS", label: "Kansas" },
{ value: "KY", label: "Kentucky" },
{ value: "LA", label: "Louisiana" },
{ value: "ME", label: "Maine" },
{ value: "MD", label: "Maryland" },
{ value: "MA", label: "Massachusetts" },
{ value: "MI", label: "Michigan" },
{ value: "MN", label: "Minnesota" },
{ value: "MS", label: "Mississippi" },
{ value: "MO", label: "Missouri" },
{ value: "MT", label: "Montana" },
{ value: "NE", label: "Nebraska" },
{ value: "NV", label: "Nevada" },
{ value: "NH", label: "New Hampshire" },
{ value: "NJ", label: "New Jersey" },
{ value: "NM", label: "New Mexico" },
{ value: "NY", label: "New York" },
{ value: "NC", label: "North Carolina" },
{ value: "ND", label: "North Dakota" },
{ value: "OH", label: "Ohio" },
{ value: "OK", label: "Oklahoma" },
{ value: "OR", label: "Oregon" },
{ value: "PA", label: "Pennsylvania" },
{ value: "PR", label: "Puerto Rico" },
{ value: "RI", label: "Rhode Island" },
{ value: "SC", label: "South Carolina" },
{ value: "SD", label: "South Dakota" },
{ value: "TN", label: "Tennessee" },
{ value: "TX", label: "Texas" },
{ value: "UT", label: "Utah" },
{ value: "VT", label: "Vermont" },
{ value: "VI", label: "Virgin Islands" },
{ value: "VA", label: "Virginia" },
{ value: "WA", label: "Washington" },
{ value: "WV", label: "West Virginia" },
{ value: "WI", label: "Wisconsin" },
{ value: "WY", label: "Wyoming" },
];
const TIMEZONES: FormSelectOption[] = [
{ value: "", label: "Please select a timezone", disabled: true },
{ value: "America/New_York", label: "Eastern" },
{ value: "America/Chicago", label: "Central" },
{ value: "America/Denver", label: "Mountain" },
{ value: "America/Los_Angeles", label: "Pacific" },
{ value: "America/Anchorage", label: "Alaska" },
{ value: "Pacific/Honolulu", label: "Hawaii" },
];
export function FormStateSelector(props: FormComboboxProps) {
return <FormSelect value={props.value} {...props}>
<For each={US_STATES}>
{(state) => <option value={state.value} disabled={state.disabled}>
{state.label}
</option>}
</For>
</FormSelect>;
}
export function FormTimezoneSelector(props: FormComboboxProps) {
return <FormSelect value={props.value} {...props}>
<For each={TIMEZONES}>
{(tz) => <option value={tz.value} disabled={tz.disabled}>
{tz.label}
</option>}
</For>
</FormSelect>;
}
export function FormCombobox(
props: Omit<FormComboboxProps, "onchange" | "onChange"> & {
onchange?: (value: string) => void;
onChange?: (value: string) => void;
},
) {
const [isOpen, setIsOpen] = createSignal(false);
const [searchQuery, setSearchQuery] = createSignal("");
const [internalValue, setInternalValue] = createSignal(props.value);
const [highlightedIndex, setHighlightedIndex] = createSignal(-1);
const [dropdownPos, setDropdownPos] = createSignal({top: 0, left: 0, width: 0});
let containerRef;
let searchInputRef;
let triggerRef;
let optionsRef;
let dropdownRef;
const value = () => readAccessor(props.value !== undefined ? props.value : internalValue(), "");
const selectedOption = () => props.options.find((opt) => opt.value === value());
const displayValue = () => {
const label = selectedOption()?.label;
if (!label) return props.placeholder || "Select an option";
if (props.maxDisplayLength && label.length > props.maxDisplayLength) {
return label.slice(0, props.maxDisplayLength).trimEnd() + "…";
}
return label;
};
const filteredOptions = () => {
if (props.searchable && searchQuery()) {
return props.options.filter((opt) => opt.label.toLowerCase().includes(searchQuery().toLowerCase()));
}
return props.options;
};
const updateDropdownPos = () => {
if (!triggerRef) return;
const rect = triggerRef.getBoundingClientRect();
setDropdownPos({top: rect.bottom + 4, left: rect.left, width: rect.width});
};
createEffect(() => {
if (isOpen()) {
updateDropdownPos();
const selectedIndex = filteredOptions().findIndex((opt) => opt.value === value());
setHighlightedIndex(selectedIndex >= 0 ? selectedIndex : 0);
let rafId;
const trackPosition = () => {
updateDropdownPos();
rafId = requestAnimationFrame(trackPosition);
};
rafId = requestAnimationFrame(trackPosition);
onCleanup(() => cancelAnimationFrame(rafId));
} else {
setHighlightedIndex(-1);
}
});
createEffect(() => {
function handleClickOutside(event) {
const inContainer = containerRef?.contains(event.target);
const inDropdown = dropdownRef?.contains(event.target);
if (!inContainer && !inDropdown) {
setIsOpen(false);
setSearchQuery("");
}
}
document.addEventListener("mousedown", handleClickOutside);
onCleanup(() => document.removeEventListener("mousedown", handleClickOutside));
});
createEffect(() => {
if (isOpen() && props.searchable && searchInputRef) {
searchInputRef.focus();
}
});
createEffect(() => {
if (isOpen() && highlightedIndex() >= 0 && optionsRef) {
const optionElements = optionsRef.querySelectorAll("button");
const highlightedElement = optionElements[highlightedIndex()];
if (highlightedElement) {
highlightedElement.scrollIntoView({block: "nearest"});
}
}
});
const handleSelect = (optionValue) => {
setInternalValue(optionValue);
props.onchange?.(optionValue);
setIsOpen(false);
setSearchQuery("");
};
const handleKeyDown = (e) => {
if (e.key === "Escape") {
setIsOpen(false);
setSearchQuery("");
triggerRef?.focus();
} else if (e.key === "Enter") {
e.preventDefault();
if (!isOpen()) {
setIsOpen(true);
} else if (highlightedIndex() >= 0 && highlightedIndex() < filteredOptions().length) {
const option = filteredOptions()[highlightedIndex()];
if (!option.disabled) {
handleSelect(option.value);
triggerRef?.focus();
}
}
} else if (e.key === "ArrowDown") {
e.preventDefault();
if (!isOpen()) {
setIsOpen(true);
} else {
setHighlightedIndex((prev) => {
let next = prev + 1;
while (next < filteredOptions().length && filteredOptions()[next].disabled) {
next++;
}
return next < filteredOptions().length ? next : prev;
});
}
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (isOpen()) {
setHighlightedIndex((prev) => {
let next = prev - 1;
while (next >= 0 && filteredOptions()[next].disabled) {
next--;
}
return next >= 0 ? next : prev;
});
}
}
};
const triggerCls = () =>
(props.onDark ? TRIGGER_BASE_DARK : TRIGGER_BASE) +
" " +
_controlH(props.small) +
(props.small ? " p-1" : " p-2");
const placeholderCls = () =>
!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);
const noResultsCls = () => (props.onDark ? DROPDOWN_NO_RESULTS_DARK : DROPDOWN_NO_RESULTS);
const optionCls = (index) =>
(props.onDark ? DROPDOWN_OPTION_DARK : DROPDOWN_OPTION) +
(index() === highlightedIndex()
? " " + (props.onDark ? DROPDOWN_OPTION_HIGHLIGHT_DARK : DROPDOWN_OPTION_HIGHLIGHT)
: "") +
(props.small ? " py-1.5 px-2" : "");
const dropdownStyle = () => {
const pos = dropdownPos();
const maxW = Math.min(400, window.innerWidth - pos.left - 8);
return `position:fixed;top:${pos.top}px;left:${pos.left}px;min-width:${pos.width}px;width:max-content;max-width:${maxW}px;z-index:200;`;
};
return (
<div ref={(el) => (containerRef = el)} class={_comboboxRootCls(props.class, props.fieldWidth)}>
<button
ref={(el) => (triggerRef = el)}
type="button"
disabled={props.disabled}
onclick={() => !props.disabled && setIsOpen(!isOpen())}
onkeydown={handleKeyDown}
class={triggerCls()}
>
<span class={"min-w-0 truncate " + placeholderCls()}>{displayValue()}</span>
<IconInline icon={isOpen() ? "chevron-up" : "chevron-down"} size={16} class={chevronCls()} />
</button>
<Show when={isOpen()}>
<Portal>
<div
ref={(el) => (dropdownRef = el)}
data-floating-content="true"
class={dropdownCls()}
style={dropdownStyle()}
>
<Show when={props.searchable}>
<div class={searchWrapCls()}>
<input
ref={(el) => (searchInputRef = el)}
type="text"
class={searchInputCls()}
value={searchQuery()}
oninput={(e) => setSearchQuery(e.currentTarget.value)}
onkeydown={handleKeyDown}
placeholder={props.searchPlaceholder || "Search..."}
onclick={(e) => e.stopPropagation()}
/>
</div>
</Show>
<div ref={(el) => (optionsRef = el)}>
<Show when={filteredOptions().length === 0}>
<div class={noResultsCls()}>No options found</div>
</Show>
<For each={filteredOptions()}>
{(option, index) => (
<button
type="button"
disabled={option.disabled}
onclick={() => !option.disabled && handleSelect(option.value)}
onMouseEnter={() => setHighlightedIndex(index())}
class={optionCls(index)}
>
{props.renderOption ? props.renderOption(option) : option.label}
</button>
)}
</For>
</div>
</div>
</Portal>
</Show>
</div>
);
}
export function FormAsyncCombobox(
props: FormComboboxProps & {
minChars?: number;
debounce?: number;
onSearch: (q: string) => Promise<FormSelectOption[]> | FormSelectOption[];
onSelect?: (value: string, option: FormSelectOption) => void;
loadOptions?: (q: string) => Promise<FormSelectOption[]>;
},
) {
const [query, setQuery] = createSignal("");
const [results, setResults] = createSignal([]);
const [open, setOpen] = createSignal(false);
const [highlightedIndex, setHighlightedIndex] = createSignal(-1);
const [loading, setLoading] = createSignal(false);
const [dropdownPos, setDropdownPos] = createSignal({top: 0, left: 0, width: 0});
let debounceTimer;
let inputRef;
let containerRef;
let dropdownRef;
let optionsRef;
const minChars = () => props.minChars ?? 2;
const debounceMs = () => props.debounce ?? 200;
const doSearch = (q) => {
if (q.length < minChars()) {
setResults([]);
setOpen(false);
return;
}
setLoading(true);
Promise.resolve(props.onSearch(q))
.then((items) => {
setResults(items || []);
setOpen(true);
setHighlightedIndex(-1);
})
.catch(() => {
setResults([]);
setOpen(false);
})
.finally(() => setLoading(false));
};
const handleInput = (e) => {
const val = e.target.value;
setQuery(val);
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => doSearch(val.trim()), debounceMs());
};
const handleSelect = (option) => {
setQuery("");
setResults([]);
setOpen(false);
props.onSelect?.(option.value, option);
};
const updateDropdownPos = () => {
if (!inputRef) return;
const rect = inputRef.getBoundingClientRect();
setDropdownPos({top: rect.bottom + 4, left: rect.left, width: rect.width});
};
createEffect(() => {
if (open()) {
updateDropdownPos();
let rafId;
const trackPosition = () => {
updateDropdownPos();
rafId = requestAnimationFrame(trackPosition);
};
rafId = requestAnimationFrame(trackPosition);
onCleanup(() => cancelAnimationFrame(rafId));
}
});
createEffect(() => {
if (open() && highlightedIndex() >= 0 && optionsRef) {
const els = optionsRef.querySelectorAll("button");
els[highlightedIndex()]?.scrollIntoView({block: "nearest"});
}
});
createEffect(() => {
function handleClickOutside(event) {
const inContainer = containerRef?.contains(event.target);
const inDropdown = dropdownRef?.contains(event.target);
if (!inContainer && !inDropdown) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
onCleanup(() => document.removeEventListener("mousedown", handleClickOutside));
});
onCleanup(() => clearTimeout(debounceTimer));
const handleKeyDown = (e) => {
const list = results();
if (e.key === "ArrowDown") {
e.preventDefault();
if (!open()) return;
setHighlightedIndex((i) => (i < list.length - 1 ? i + 1 : 0));
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (!open()) return;
setHighlightedIndex((i) => (i > 0 ? i - 1 : list.length - 1));
} else if (e.key === "Enter" && open() && list.length > 0) {
e.preventDefault();
const idx = highlightedIndex() >= 0 ? highlightedIndex() : 0;
handleSelect(list[idx]);
} else if (e.key === "Escape") {
setOpen(false);
}
};
const dropdownStyle = () => {
const pos = dropdownPos();
const maxW = Math.min(400, window.innerWidth - pos.left - 8);
return `position:fixed;top:${pos.top}px;left:${pos.left}px;min-width:${pos.width}px;width:max-content;max-width:${maxW}px;z-index:200;`;
};
return (
<div ref={(el) => (containerRef = el)} class={_comboboxRootCls(props.class, props.fieldWidth)}>
<input
ref={(el) => (inputRef = el)}
type="text"
value={query()}
oninput={handleInput}
onFocus={() => {
if (results().length > 0) setOpen(true);
}}
onKeyDown={handleKeyDown}
placeholder={props.placeholder || "Search..."}
class={_inputCls(props.small, false, false)}
/>
<Show when={open()}>
<Portal>
<div
ref={(el) => (dropdownRef = el)}
data-floating-content="true"
class={DROPDOWN}
style={dropdownStyle()}
>
<div ref={(el) => (optionsRef = el)}>
<Show when={results().length === 0}>
<div class={DROPDOWN_NO_RESULTS}>{loading() ? "Searching..." : "No results"}</div>
</Show>
<For each={results()}>
{(option, index) => (
<button
type="button"
onclick={() => handleSelect(option)}
onMouseEnter={() => setHighlightedIndex(index())}
class={
DROPDOWN_OPTION + (index() === highlightedIndex() ? " bg-neutral-100" : "")
}
>
{props.renderOption ? props.renderOption(option) : option.label}
</button>
)}
</For>
</div>
</div>
</Portal>
</Show>
</div>
);
}
export function FormMultiSelect(props: FormMultiSelectProps) {
const [isOpen, setIsOpen] = createSignal(false);
const [searchQuery, setSearchQuery] = createSignal("");
const [internalValue, setInternalValue] = createSignal(props.value ?? []);
const [highlightedIndex, setHighlightedIndex] = createSignal(-1);
const [isOverflowing, setIsOverflowing] = createSignal(false);
const [dropdownPos, setDropdownPos] = createSignal({ top: 0, left: 0, width: 0 });
let containerRef;
let searchInputRef;
let triggerRef;
let optionsRef;
let tagsContainerRef;
let dropdownRef;
const maxTags = () => props.maxTagsBeforeCollapse ?? 3;
const value = () => readAccessor(props.value !== undefined ? props.value : internalValue(), []);
const selectedOptions = () => props.options.filter((opt) => value().includes(opt.value));
const updateDropdownPos = () => {
setDropdownPos(_floatingDropdownPos(triggerRef));
};
const filteredOptions = () => {
if (props.searchable && searchQuery()) {
return props.options.filter((opt) =>
opt.label.toLowerCase().includes(searchQuery().toLowerCase())
);
}
return props.options;
};
createEffect(() => {
if (tagsContainerRef) {
setIsOverflowing(tagsContainerRef.scrollWidth > tagsContainerRef.clientWidth || selectedOptions().length > maxTags());
}
});
createEffect(() => {
if (isOpen()) { setHighlightedIndex(0); } else { setHighlightedIndex(-1); }
});
createEffect(() => {
if (isOpen()) {
updateDropdownPos();
let rafId;
const trackPosition = () => {
updateDropdownPos();
rafId = requestAnimationFrame(trackPosition);
};
rafId = requestAnimationFrame(trackPosition);
onCleanup(() => cancelAnimationFrame(rafId));
}
});
createEffect(() => {
function handleClickOutside(event) {
const inContainer = containerRef?.contains(event.target);
const inDropdown = dropdownRef?.contains(event.target);
if (!inContainer && !inDropdown) {
setIsOpen(false);
setSearchQuery("");
}
}
document.addEventListener("mousedown", handleClickOutside);
onCleanup(() => document.removeEventListener("mousedown", handleClickOutside));
});
createEffect(() => { if (isOpen() && props.searchable && searchInputRef) { searchInputRef.focus(); } });
createEffect(() => {
if (isOpen() && highlightedIndex() >= 0 && optionsRef) {
const els = optionsRef.querySelectorAll("button");
if (els[highlightedIndex()]) els[highlightedIndex()].scrollIntoView({ block: "nearest" });
}
});
const toggleOption = (optionValue) => {
const newValue = value().includes(optionValue)
? value().filter((v) => v !== optionValue)
: [...value(), optionValue];
setInternalValue(newValue);
props.onchange?.(newValue);
};
const removeOption = (optionValue, e) => {
e.stopPropagation();
const newValue = value().filter((v) => v !== optionValue);
setInternalValue(newValue);
props.onchange?.(newValue);
};
const selectAll = () => {
const selectableValues = filteredOptions().filter((opt) => !opt.disabled).map((opt) => opt.value);
const newValue = [...new Set([...value(), ...selectableValues])];
setInternalValue(newValue);
props.onchange?.(newValue);
};
const deselectAll = () => {
const filteredValues = new Set(filteredOptions().map((opt) => opt.value));
const newValue = value().filter((v) => !filteredValues.has(v));
setInternalValue(newValue);
props.onchange?.(newValue);
};
const allFilteredSelected = () => filteredOptions().filter((opt) => !opt.disabled).every((opt) => value().includes(opt.value));
const handleKeyDown = (e) => {
if (e.key === "Escape") { setIsOpen(false); setSearchQuery(""); triggerRef?.focus(); }
else if (e.key === "Enter") {
e.preventDefault();
if (!isOpen()) { setIsOpen(true); }
else if (highlightedIndex() >= 0 && highlightedIndex() < filteredOptions().length) {
const option = filteredOptions()[highlightedIndex()];
if (!option.disabled) toggleOption(option.value);
}
} else if (e.key === "ArrowDown") {
e.preventDefault();
if (!isOpen()) { setIsOpen(true); }
else {
setHighlightedIndex((prev) => {
let next = prev + 1;
while (next < filteredOptions().length && filteredOptions()[next].disabled) next++;
return next < filteredOptions().length ? next : prev;
});
}
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (isOpen()) {
setHighlightedIndex((prev) => {
let next = prev - 1;
while (next >= 0 && filteredOptions()[next].disabled) next--;
return next >= 0 ? next : prev;
});
}
} else if (e.key === " " && isOpen() && !props.searchable) {
e.preventDefault();
if (highlightedIndex() >= 0 && highlightedIndex() < filteredOptions().length) {
const option = filteredOptions()[highlightedIndex()];
if (!option.disabled) toggleOption(option.value);
}
}
};
const triggerCls = () => TRIGGER_BASE + " overflow-hidden " + _controlH(props.small) + (props.small ? " p-1" : " p-2");
const optionCls = (index) => DROPDOWN_OPTION + (index() === highlightedIndex() ? " bg-neutral-100" : "");
const dropdownStyle = () => _floatingDropdownStyle(dropdownPos());
const renderTriggerContent = () => {
if (selectedOptions().length === 0) {
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-neutral-200 rounded-default whitespace-nowrap leading-none" + (props.small ? " py-0.5 px-1.5 text-ss" : " 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-neutral-900" onclick={(e) => removeOption(option.value, e)}>
<IconInline icon="xmark" size={10} />
</button>
</span>}
</For>
</div>;
};
return <div ref={(el) => containerRef = el} class={_comboboxRootCls(props.class, props.fieldWidth)}>
<button
ref={(el) => triggerRef = el}
type="button"
disabled={props.disabled}
onclick={() => { if (!props.disabled) { updateDropdownPos(); setIsOpen(!isOpen()); } }}
onKeyDown={handleKeyDown}
class={triggerCls()}
>
<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-neutral-400" />
</button>
<Show when={isOpen()}>
<Portal>
<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
ref={(el) => searchInputRef = el}
type="text"
class={DROPDOWN_SEARCH_INPUT}
value={searchQuery()}
oninput={(e) => setSearchQuery(e.currentTarget.value)}
onKeyDown={handleKeyDown}
placeholder={props.searchPlaceholder || "Search..."}
onclick={(e) => e.stopPropagation()}
/>
</div>
</Show>
<Show when={props.showSelectAll && filteredOptions().length > 0}>
<div class={SELECT_ALL_WRAP}>
<button type="button" class={SELECT_ALL_BTN} onclick={() => allFilteredSelected() ? deselectAll() : selectAll()}>
{allFilteredSelected() ? "Deselect All" : "Select All"}
</button>
</div>
</Show>
<div ref={(el) => optionsRef = el}>
<Show when={filteredOptions().length === 0}>
<div class={DROPDOWN_NO_RESULTS}>No options found</div>
</Show>
<For each={filteredOptions()}>
{(option, index) => {
const isSelected = () => value().includes(option.value);
return <button
type="button"
disabled={option.disabled}
onclick={() => !option.disabled && toggleOption(option.value)}
onMouseEnter={() => setHighlightedIndex(index())}
class={optionCls(index)}
>
<input type="checkbox" checked={isSelected()} onchange={() => {}} style="pointer-events:none" />
{option.label}
</button>;
}}
</For>
</div>
</div>
</Portal>
</Show>
</div>;
}
export function FormMultiSelectTrigger(props: FormMultiSelectTriggerProps) {
const [isOpen, setIsOpen] = createSignal(false);
const [searchQuery, setSearchQuery] = createSignal("");
const [internalValue, setInternalValue] = createSignal(props.value ?? []);
const [highlightedIndex, setHighlightedIndex] = createSignal(-1);
let containerRef, searchInputRef, triggerRef, optionsRef;
const value = () => readAccessor(props.value !== undefined ? props.value : internalValue(), []);
const minWidth = () => props.minWidth ?? 200;
const filteredOptions = () => {
const opts = readAccessor(props.options, []);
if (props.searchable && searchQuery()) {
return opts.filter((opt) => opt.label.toLowerCase().includes(searchQuery().toLowerCase()));
}
return opts;
};
createEffect(() => { if (isOpen()) { setHighlightedIndex(0); } else { setHighlightedIndex(-1); } });
createEffect(() => {
function handleClickOutside(event) {
if (containerRef && !containerRef.contains(event.target)) { setIsOpen(false); setSearchQuery(""); }
}
document.addEventListener("mousedown", handleClickOutside);
onCleanup(() => document.removeEventListener("mousedown", handleClickOutside));
});
createEffect(() => { if (isOpen() && props.searchable && searchInputRef) searchInputRef.focus(); });
createEffect(() => {
if (isOpen() && highlightedIndex() >= 0 && optionsRef) {
const els = optionsRef.querySelectorAll("button");
if (els[highlightedIndex()]) els[highlightedIndex()].scrollIntoView({ block: "nearest" });
}
});
const toggleOption = (optionValue) => {
const newValue = value().includes(optionValue) ? value().filter((v) => v !== optionValue) : [...value(), optionValue];
setInternalValue(newValue);
props.onchange?.(newValue);
};
const selectAll = () => {
const vals = filteredOptions().filter((opt) => !opt.disabled).map((opt) => opt.value);
const newValue = [...new Set([...value(), ...vals])];
setInternalValue(newValue);
props.onchange?.(newValue);
};
const deselectAll = () => {
const filteredValues = new Set(filteredOptions().map((opt) => opt.value));
const newValue = value().filter((v) => !filteredValues.has(v));
setInternalValue(newValue);
props.onchange?.(newValue);
};
const allFilteredSelected = () => filteredOptions().filter((opt) => !opt.disabled).every((opt) => value().includes(opt.value));
const handleKeyDown = (e) => {
if (e.key === "Escape") { setIsOpen(false); setSearchQuery(""); triggerRef?.focus(); }
else if (e.key === "Enter") {
e.preventDefault();
if (!isOpen()) { setIsOpen(true); }
else if (highlightedIndex() >= 0 && highlightedIndex() < filteredOptions().length) {
const opt = filteredOptions()[highlightedIndex()];
if (!opt.disabled) toggleOption(opt.value);
}
} else if (e.key === "ArrowDown") {
e.preventDefault();
if (!isOpen()) { setIsOpen(true); }
else { setHighlightedIndex((prev) => { let n = prev + 1; while (n < filteredOptions().length && filteredOptions()[n].disabled) n++; return n < filteredOptions().length ? n : prev; }); }
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (isOpen()) { setHighlightedIndex((prev) => { let n = prev - 1; while (n >= 0 && filteredOptions()[n].disabled) n--; return n >= 0 ? n : prev; }); }
} else if (e.key === " " && isOpen() && !props.searchable) {
e.preventDefault();
if (highlightedIndex() >= 0 && highlightedIndex() < filteredOptions().length) {
const opt = filteredOptions()[highlightedIndex()];
if (!opt.disabled) toggleOption(opt.value);
}
}
};
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-neutral-100" : "") + (props.small ? " py-1.5 px-2" : "");
return <div ref={(el) => containerRef = el} class={"relative inline-block " + (props.class || "")}>
<button
ref={(el) => triggerRef = el}
type="button"
class="cursor-pointer bg-transparent border-none disabled:opacity-50 disabled:cursor-not-allowed"
disabled={props.disabled}
onclick={() => !props.disabled && setIsOpen(!isOpen())}
onKeyDown={handleKeyDown}
>
{props.trigger}
</button>
<Show when={isOpen()}>
<div class={dropdownCls()} style={{ "min-width": `${minWidth()}px` }}>
<Show when={props.searchable}>
<div class={DROPDOWN_SEARCH_WRAP}>
<input
ref={(el) => searchInputRef = el}
type="text"
class={DROPDOWN_SEARCH_INPUT}
value={searchQuery()}
oninput={(e) => setSearchQuery(e.currentTarget.value)}
onKeyDown={handleKeyDown}
placeholder={props.searchPlaceholder || "Search..."}
onclick={(e) => e.stopPropagation()}
/>
</div>
</Show>
<Show when={props.showSelectAll && filteredOptions().length > 0}>
<div class={SELECT_ALL_WRAP}>
<button type="button" class={SELECT_ALL_BTN} onclick={() => allFilteredSelected() ? deselectAll() : selectAll()}>
{allFilteredSelected() ? "Deselect All" : "Select All"}
</button>
</div>
</Show>
<div ref={(el) => optionsRef = el} class="p-1.5">
<Show when={filteredOptions().length === 0}>
<div class={DROPDOWN_NO_RESULTS}>No options found</div>
</Show>
<For each={filteredOptions()}>
{(option, index) => {
const isSelected = () => value().includes(option.value);
return <button
type="button"
disabled={option.disabled}
onclick={() => !option.disabled && toggleOption(option.value)}
onMouseEnter={() => setHighlightedIndex(index())}
class={optionCls(index)}
>
<input type="checkbox" checked={isSelected()} onchange={() => {}} style="pointer-events:none" />
{option.label}
</button>;
}}
</For>
</div>
</div>
</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;
});
}
};
}