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 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} /> {local.error} {local.success} ); } 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 = () => ( 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 ( {local.error} {local.success} } >
{local.prefix}
{local.error} {local.success}
); } 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 ; } 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 ; } 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 ; } 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 ( : null} inputMode="numeric" oninput={handleInput} placeholder="(555) 555-5555" {...inputProps} /> ); } export function FormEmailInput(props: {showIcon?: boolean} & FormInputProps) { const [local, inputProps] = splitProps(props, ["showIcon"]); return ( : null} type="email" inputMode="email" {...inputProps} /> ); } export function FormURLInput(props: {showIcon?: boolean} & FormInputProps) { const [local, inputProps] = splitProps(props, ["showIcon"]); return ( : 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 ; } 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"]); let selectEl; // Set value via deferred effect so it runs AFTER children (