import { createSignal, createEffect, createMemo, onCleanup, For, Show, splitProps, JSX, JSXElement } from "solid-js"; import { Portal } from "solid-js/web"; import {IconInline} from "./Icons.tsx"; import { readAccessor } from "../utils/accessors.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_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-line-strong 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-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"; 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 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_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_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_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_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_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_DARK = "bg-white/10"; const DROPDOWN_NO_RESULTS = "p-2 text-sm text-ink-muted text-center"; const DROPDOWN_NO_RESULTS_DARK = "p-2 text-sm text-text-on-dark-muted text-center"; const SELECT_ALL_WRAP = "border-b border-line"; const SELECT_ALL_BTN = "w-full text-left p-2 text-sm cursor-pointer text-ink-soft font-medium bg-transparent border-none hover:bg-surface-raised"; // -- 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 typeof props.ref === "function" && props.ref(el)} class={cls()} type={props.type} value={inputValue()} placeholder={props.placeholder} autocomplete={props.autocomplete || (props.passwordManagerIgnore ? "off" : undefined)} inputmode={props.inputmode} data-1p-ignore={props.passwordManagerIgnore} data-lpignore={props.passwordManagerIgnore ? "other" : undefined} data-form-type={props.passwordManagerIgnore} data-bwignore={props.passwordManagerIgnore} data-protonpass-ignore={props.passwordManagerIgnore} disabled={props.disabled} maxlength={props.maxlength} min={props.min} max={props.max} step={props.step} name={props.name} id={props.id} oninput={props.oninput} onchange={props.onchange} onBlur={props.onblur} onFocus={props.onfocus} onKeyDown={props.onkeydown} /> {props.error} {props.success} ; } export function FormInputGroup(props: FormInputGroupProps) { const pfxCls = () => _prefixCls(props.small, props.error, props.onDark); const inputCls = () => _inputCls(props.small, props.error, props.success, "rounded-l-none" + (props.class ? " " + props.class : ""), props.onDark); const inputValue = () => readAccessor(props.value, ""); return
{props.prefix} typeof props.ref === "function" && props.ref(el)} class={inputCls()} type={props.type} value={inputValue()} placeholder={props.placeholder} autocomplete={props.autocomplete || (props.passwordManagerIgnore ? "off" : undefined)} inputmode={props.inputmode} data-1p-ignore={props.passwordManagerIgnore} data-lpignore={props.passwordManagerIgnore ? "other" : undefined} data-form-type={props.passwordManagerIgnore} data-bwignore={props.passwordManagerIgnore} data-protonpass-ignore={props.passwordManagerIgnore} disabled={props.disabled} maxlength={props.maxlength} name={props.name} id={props.id} oninput={props.oninput} onchange={props.onchange} onBlur={props.onblur} onFocus={props.onfocus} onKeyDown={props.onkeydown} />
{props.error} {props.success}
; } export function FormNumberInput(props: FormNumberInputProps) { const [local, inputProps] = splitProps(props, ["small", "ref", "class", "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 typeof local.ref === "function" && local.ref(el)} type="text" inputMode="decimal" oninput={handleInput} class={local.class} small={local.small} {...inputProps} />; } export function FormCarNumberInput(props: FormInputProps) { const [local, inputProps] = splitProps(props, ["small", "ref", "class", "oninput"]); const handleInput = (e) => { if (!e || !e.currentTarget) return; const input = e.currentTarget; const raw = input.value.toUpperCase().replace(/[^0-9A-Z]/g, ""); const match = raw.match(/^([0-9]{0,2})([A-Z]{0,2})/); input.value = match ? match[1] + match[2] : ""; if (typeof local.oninput === "function") { local.oninput(e); } }; return typeof local.ref === "function" && local.ref(el)} type="text" maxLength="4" oninput={handleInput} class={local.class} small={local.small} placeholder="e.g. 28" {...inputProps} />; } export function FormCurrencyInput(props: {hideSymbol?: boolean} & FormInputProps) { const [local, inputProps] = splitProps(props, ["small", "ref", "class", "oninput", "hideSymbol"]); 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); } }; if (local.hideSymbol) { return typeof local.ref === "function" && local.ref(el)} type="text" inputMode="decimal" oninput={handleInput} class={local.class} small={local.small} {...inputProps} />; } else { return typeof local.ref === "function" && local.ref(el)} prefix="$" type="text" inputMode="decimal" oninput={handleInput} class={local.class} small={local.small} {...inputProps} />; } } export function FormPercentInput(props: FormInputProps) { const [local, inputProps] = splitProps(props, ["small", "ref", "class", "oninput"]); 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 typeof local.ref === "function" && local.ref(el)} prefix="%" type="text" inputMode="decimal" oninput={handleInput} class={local.class} small={local.small} {...inputProps} />; } export function FormPhoneInput(props: FormInputProps) { const [local, inputProps] = splitProps(props, ["small", "ref", "class", "oninput"]); const handleInput = (e) => { if (!e || !e.currentTarget) return; const input = e.currentTarget; let digits = input.value.replace(/\D/g, ""); 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 typeof local.ref === "function" && local.ref(el)} type="text" inputMode="numeric" oninput={handleInput} class={local.class} small={local.small} placeholder="(555) 555-5555" {...inputProps} />; } // @TODO replace this with showIcon prop export function FormPhoneInputWithIcon(props: {icon?: string} & FormInputGroupProps) { const [local, inputProps] = splitProps(props, ["icon", "small", "ref", "class", "oninput"]); const handleInput = (e) => { if (!e || !e.currentTarget) return; const input = e.currentTarget; let digits = input.value.replace(/\D/g, ""); 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); } }; const iconEl = ; return typeof local.ref === "function" && local.ref(el)} type="text" inputMode="numeric" oninput={handleInput} class={local.class} small={local.small} placeholder="(555) 555-5555" {...inputProps} />; } export function FormEmailInput(props: {showIcon?: boolean} & FormInputProps) { const [local, inputProps] = splitProps(props, ["showIcon", "small", "ref", "class", "error", "success", "onDark"]); const showIcon = () => local.showIcon !== false; const pfxCls = () => _prefixCls(local.small, local.error, local.onDark); const inputCls = () => _inputCls(local.small, local.error, local.success, "rounded-l-none" + (local.class ? " " + local.class : ""), local.onDark); return typeof local.ref === "function" && local.ref(el)} type="email" inputMode="email" class={local.class} small={local.small} error={local.error} success={local.success} onDark={local.onDark} {...inputProps} />}>
typeof local.ref === "function" && local.ref(el)} type="email" inputMode="email" class={inputCls()} {...inputProps} />
{local.error} {local.success}
; } export function FormURLInput(props: {showIcon: boolean} & FormInputProps) { const [local, inputProps] = splitProps(props, ["showIcon", "small", "ref", "class", "onDark"]); const showIcon = () => local.showIcon !== false; const pfxCls = () => _prefixCls(local.small, false, local.onDark); const inputCls = () => _inputCls(local.small, false, false, "rounded-l-none" + (local.class ? " " + local.class : ""), local.onDark); return typeof local.ref === "function" && local.ref(el)} type="text" inputMode="url" class={local.class} small={local.small} onDark={local.onDark} {...inputProps} />}>
typeof local.ref === "function" && local.ref(el)} type="text" inputMode="url" class={inputCls()} {...inputProps} />
; } export function FormZipCodeInput(props: FormInputProps) { const [local, inputProps] = splitProps(props, ["small", "ref", "class", "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 props.oninput === "function") { props.oninput(e); } }; return typeof local.ref === "function" && local.ref(el)} type="text" inputMode="numeric" oninput={handleInput} class={local.class} small={local.small} 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 ``; }).filter(Boolean); return `${paths.join("")}`; }; 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
{isEmpty() ? "Sign above" : ""}
; } // @TODO Fix this export function FormSelect(props: FormSelectProps & { children?: JSXElement }) { const [local, selectProps] = splitProps(props, ["small", "ref", "class", "children", "value", "onDark"]); const cls = () => _inputCls(local.small, false, false, local.class, local.onDark); let selectEl; // Set value via deferred effect so it runs AFTER children (