You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
rappli/src/components/Form.tsx

301 lines
7.1 KiB
TypeScript

import { formatISO9075, fromUnixTime, getUnixTime } from "date-fns";
import {
Component,
Show,
mergeProps,
splitProps,
FlowComponent,
createSignal,
createEffect,
For,
} from "solid-js";
import { validateInput } from "~/hooks/validation";
import AsteriskIcon from "~icons/ph/asterisk-bold";
import MaximizeIcon from "~icons/carbon/maximize";
import MinimizeIcon from "~icons/carbon/minimize";
import autosize from "autosize";
import type { JSX } from "solid-js";
import {
createNativeInputValue,
createOptionalNumberInputHandler,
} from "~/util";
export const TextInput: Component<
{
label: string;
placeholder?: string;
labelMinWidth?: string;
suffix?: string | JSX.Element;
size?: string;
vertical?: boolean;
} & JSX.InputHTMLAttributes<HTMLInputElement>
> = (p) => {
p = mergeProps(
{
placeholder: p.label,
labelMinWidth: "95px",
size: "sm",
vertical: false,
},
p
);
const [props, rest] = splitProps(p, [
"name",
"size",
"class",
"label",
"placeholder",
"suffix",
"vertical",
"labelMinWidth",
]);
const sizes: Record<string, string> = {
xs: "input-xs",
sm: "input-sm",
lg: "input-lg",
};
const [validate, vState] = validateInput({ value: () => rest.value });
return (
<div class="shrink form-control">
<label
classList={{
"input-group": true,
"input-group-vertical": props.vertical,
}}
>
<span
style={{
"min-width":
!props.vertical && props.labelMinWidth
? props.labelMinWidth
: undefined,
}}
classList={{
"gap-1": true,
"h-8": props.vertical,
"font-bold": rest.required,
"bg-slate-200/70": vState().valid,
"bg-red-100": !vState().valid,
}}
>
{props.label}
<Show when={rest.required}>
<AsteriskIcon class="text-primary text-[0.5rem]" />
</Show>
</span>
<input
name={props.name || props.label}
use:validate
classList={{
"peer flex-1 input invalid:input-error input-bordered": true,
"w-0": !props.vertical,
[(props.size && sizes[props.size]) || ""]: true,
[props.class || ""]: true,
}}
type="text"
placeholder={props.placeholder}
{...rest}
/>
<Show when={props.suffix}>
<span
classList={{
"bg-slate-200/70": vState().valid,
"bg-red-100": !vState().valid,
}}
>
{props.suffix}
</span>
</Show>
</label>
</div>
);
};
export const Checkbox: FlowComponent<
JSX.InputHTMLAttributes<HTMLInputElement>
> = (p) => {
const [props, rest] = splitProps(p, ["children"]);
return (
<div class="form-control">
<label class="label py-0 cursor-pointer">
<span class="label-text">{props.children}</span>
<input type="checkbox" class="checkbox" {...rest} />
</label>
</div>
);
};
export const TextArea: Component<
{
label: string;
labelSuffixJsx?: JSX.Element;
value?: string;
placeholder?: string;
} & JSX.TextareaHTMLAttributes<HTMLTextAreaElement>
> = (p) => {
p = mergeProps({ rows: 3, placeholder: p.label }, p);
const [props, rest] = splitProps(p, ["label", "labelSuffixJsx", "value"]);
const [autosizeEnabled, setAutosize] = createSignal(false);
let textareaEl: HTMLTextAreaElement = undefined!;
createEffect(function () {
if (autosizeEnabled()) {
autosize(textareaEl);
} else {
autosize.destroy(textareaEl);
}
});
return (
<div class="form-control relative">
<button
type="button"
onClick={() => setAutosize(!autosizeEnabled())}
class="absolute right-3 top-2 hover:text-accent transition-all hover:scale-110"
>
<Show when={!autosizeEnabled()} fallback={<MinimizeIcon />}>
<MaximizeIcon />
</Show>
</button>
<label class="input-group input-group-vertical">
<span class="h-8 bg-slate-200/70 flex gap-2 justify-between pr-14">
{props.label}
{props.labelSuffixJsx}
</span>
<textarea
autocomplete="off"
ref={textareaEl}
classList={{
"textarea h-auto py-2 textarea-bordered leading-normal": true,
"min-h-[150px]": autosizeEnabled(),
}}
{...rest}
>
{props.value || ""}
</textarea>
</label>
</div>
);
};
export const UnixDateInput: Component<
{
required?: boolean;
label: string;
value?: number;
onInput: (v: number) => void;
} & Parameters<typeof TextInput>[0]
> = (p) => {
const [props, rest] = splitProps(p, [
"required",
"label",
"value",
"onInput",
]);
return (
<TextInput
required={props.required}
type="date"
label={props.label}
max="9999-12-31"
value={
props.value != null
? formatISO9075(fromUnixTime(props.value), {
representation: "date",
})
: (null as any)
}
onInput={(evt) => {
if (!evt.currentTarget.valueAsDate) {
return;
}
if (evt.currentTarget.valueAsDate.getFullYear() > 9999) {
return;
}
props.onInput(getUnixTime(evt.currentTarget.valueAsDate));
}}
{...rest}
/>
);
};
export const NumberInput: Component<
{ value?: number; onInput: (v: number | undefined) => void } & Omit<
Parameters<typeof TextInput>[0],
"onInput"
>
> = (p) => {
let el: HTMLInputElement = undefined!;
const [props, rest] = splitProps(p, ["value", "onInput"]);
const value = createNativeInputValue(
() => el,
() => props.value
);
return (
<TextInput
ref={el}
value={value()}
maxLength={9}
onInput={createOptionalNumberInputHandler(props.onInput)}
{...rest}
/>
);
};
// TODO: Move input-group into separate component
export const Select: Component<
{
value: string | number;
options: [string | number, string][];
label: string;
labelMinWidth?: string;
onChange: (v: any) => void;
} & JSX.InputHTMLAttributes<HTMLSelectElement>
> = (p) => {
p = mergeProps(
{
labelMinWidth: "95px",
},
p
);
const [props, rest] = splitProps(p, [
"value",
"options",
"label",
"labelMinWidth",
"onChange",
]);
return (
<div class="shrink form-control">
<label class="input-group">
<span
class="bg-slate-200/70"
style={{ "min-width": props.labelMinWidth }}
>
{props.label}
</span>
<select
class="flex-1 select select-sm select-bordered"
onChange={(e) => props.onChange(e.currentTarget.value)}
{...rest}
>
<For each={props.options}>
{([type, label]) => (
<option selected={type === props.value} value={type}>
{label}
</option>
)}
</For>
</select>
</label>
</div>
);
};