1815 lines
72 KiB
TypeScript
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-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-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-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>;
|
|
}
|
|
|
|
// @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-[200px] 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-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-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;
|
|
});
|
|
}
|
|
};
|
|
}
|