summaryrefslogtreecommitdiff
path: root/packages/exchange-backoffice-ui/src/forms/InputLine.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/exchange-backoffice-ui/src/forms/InputLine.tsx')
-rw-r--r--packages/exchange-backoffice-ui/src/forms/InputLine.tsx273
1 files changed, 273 insertions, 0 deletions
diff --git a/packages/exchange-backoffice-ui/src/forms/InputLine.tsx b/packages/exchange-backoffice-ui/src/forms/InputLine.tsx
new file mode 100644
index 000000000..0870e885a
--- /dev/null
+++ b/packages/exchange-backoffice-ui/src/forms/InputLine.tsx
@@ -0,0 +1,273 @@
+import { TranslatedString } from "@gnu-taler/taler-util";
+import { ComponentChildren, Fragment, VNode, h } from "preact";
+import { useField } from "./useField.js";
+
+interface IconAddon {
+ type: "icon";
+ icon: VNode;
+}
+interface ButtonAddon {
+ type: "button";
+ onClick: () => void;
+ children: ComponentChildren;
+}
+interface TextAddon {
+ type: "text";
+ text: TranslatedString;
+}
+type Addon = IconAddon | ButtonAddon | TextAddon;
+
+interface StringConverter<T> {
+ toStringUI: (v?: T) => string;
+ fromStringUI: (v?: string) => T;
+}
+
+export interface UIFormProps<T> {
+ name: string;
+ label: TranslatedString;
+ placeholder?: TranslatedString;
+ tooltip?: TranslatedString;
+ help?: TranslatedString;
+ before?: Addon;
+ after?: Addon;
+ required?: boolean;
+ converter?: StringConverter<T>;
+}
+
+export type FormErrors<T> = {
+ [P in keyof T]?: string | FormErrors<T[P]>;
+};
+
+//@ts-ignore
+const TooltipIcon = (
+ <svg
+ class="w-5 h-5"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z"
+ clip-rule="evenodd"
+ />
+ </svg>
+);
+
+export function LabelWithTooltipMaybeRequired({
+ label,
+ required,
+ tooltip,
+}: {
+ label: TranslatedString;
+ required?: boolean;
+ tooltip?: TranslatedString;
+}): VNode {
+ const Label = (
+ <Fragment>
+ <div class="flex justify-between">
+ <label
+ htmlFor="email"
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >
+ {label}
+ </label>
+ </div>
+ </Fragment>
+ );
+ const WithTooltip = tooltip ? (
+ <div class="relative flex flex-grow items-stretch focus-within:z-10">
+ {Label}
+ <span class="relative flex items-center group pl-2">
+ {TooltipIcon}
+ <div class="absolute bottom-0 flex flex-col items-center hidden mb-6 group-hover:flex">
+ <span class="relative z-10 p-2 text-xs leading-none text-white whitespace-no-wrap bg-black shadow-lg">
+ {tooltip}
+ </span>
+ <div class="w-3 h-3 -mt-2 rotate-45 bg-black"></div>
+ </div>
+ </span>
+ </div>
+ ) : (
+ Label
+ );
+ if (required) {
+ return (
+ <div class="flex justify-between">
+ {WithTooltip}
+ <span class="text-sm leading-6 text-red-600">*</span>
+ </div>
+ );
+ }
+ return WithTooltip;
+}
+
+function InputWrapper<T>({
+ children,
+ label,
+ tooltip,
+ before,
+ after,
+ help,
+ error,
+ required,
+}: { error?: string; children: ComponentChildren } & UIFormProps<T>): VNode {
+ return (
+ <div class="sm:col-span-6">
+ <LabelWithTooltipMaybeRequired
+ label={label}
+ required={required}
+ tooltip={tooltip}
+ />
+ <div class="relative mt-2 flex rounded-md shadow-sm">
+ {before &&
+ (before.type === "text" ? (
+ <span class="inline-flex items-center rounded-l-md border border-r-0 border-gray-300 px-3 text-gray-500 sm:text-sm">
+ {before.text}
+ </span>
+ ) : before.type === "icon" ? (
+ <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
+ {before.icon}
+ </div>
+ ) : before.type === "button" ? (
+ <button
+ type="button"
+ onClick={before.onClick}
+ class="relative -ml-px inline-flex items-center gap-x-1.5 rounded-l-md px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
+ >
+ {before.children}
+ </button>
+ ) : undefined)}
+
+ {children}
+
+ {after &&
+ (after.type === "text" ? (
+ <span class="inline-flex items-center rounded-r-md border border-l-0 border-gray-300 px-3 text-gray-500 sm:text-sm">
+ {after.text}
+ </span>
+ ) : after.type === "icon" ? (
+ <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
+ {after.icon}
+ </div>
+ ) : after.type === "button" ? (
+ <button
+ type="button"
+ onClick={after.onClick}
+ class="relative -ml-px inline-flex items-center gap-x-1.5 rounded-r-md px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
+ >
+ {after.children}
+ </button>
+ ) : undefined)}
+ </div>
+ {error && (
+ <p class="mt-2 text-sm text-red-600" id="email-error">
+ {error}
+ </p>
+ )}
+ {help && (
+ <p class="mt-2 text-sm text-gray-500" id="email-description">
+ {help}
+ </p>
+ )}
+ </div>
+ );
+}
+
+function defaultToString(v: unknown) {
+ return v === undefined ? "" : typeof v !== "object" ? String(v) : "";
+}
+function defaultFromString(v: string) {
+ return v;
+}
+
+export function InputLine<T>(props: { type: string } & UIFormProps<T>): VNode {
+ const { name, placeholder, before, after, converter, type } = props;
+ const { value, onChange, state, isDirty } = useField(name);
+
+ if (state.hidden) return <Fragment />;
+
+ let clazz =
+ "block w-full rounded-md border-0 py-1.5 shadow-sm ring-1 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 disabled:ring-gray-200";
+ if (before) {
+ switch (before.type) {
+ case "icon": {
+ clazz += " pl-10";
+ break;
+ }
+ case "button": {
+ clazz += " rounded-none rounded-r-md ";
+ break;
+ }
+ case "text": {
+ clazz += " min-w-0 flex-1 rounded-r-md rounded-none ";
+ break;
+ }
+ }
+ }
+ if (after) {
+ switch (after.type) {
+ case "icon": {
+ clazz += " pr-10";
+ break;
+ }
+ case "button": {
+ clazz += " rounded-none rounded-l-md";
+ break;
+ }
+ case "text": {
+ clazz += " min-w-0 flex-1 rounded-l-md rounded-none ";
+ break;
+ }
+ }
+ }
+ const showError = isDirty && state.error;
+ if (showError) {
+ clazz +=
+ " text-red-900 ring-red-300 placeholder:text-red-300 focus:ring-red-500";
+ } else {
+ clazz +=
+ " text-gray-900 ring-gray-300 placeholder:text-gray-400 focus:ring-indigo-600";
+ }
+ const fromString: (s: string) => any =
+ converter?.fromStringUI ?? defaultFromString;
+ const toString: (s: any) => string = converter?.toStringUI ?? defaultToString;
+
+ if (type === "text-area") {
+ return (
+ <InputWrapper<T> {...props} error={showError ? state.error : undefined}>
+ <textarea
+ rows={4}
+ name={String(name)}
+ onChange={(e) => {
+ onChange(fromString(e.currentTarget.value));
+ }}
+ placeholder={placeholder ? placeholder : undefined}
+ value={toString(value)}
+ disabled={state.disabled}
+ aria-invalid={showError}
+ // aria-describedby="email-error"
+ class={clazz}
+ />
+ </InputWrapper>
+ );
+ }
+
+ return (
+ <InputWrapper<T> {...props} error={showError ? state.error : undefined}>
+ <input
+ name={String(name)}
+ type={type}
+ onChange={(e) => {
+ onChange(fromString(e.currentTarget.value));
+ }}
+ placeholder={placeholder ? placeholder : undefined}
+ value={toString(value)}
+ disabled={state.disabled}
+ aria-invalid={showError}
+ // aria-describedby="email-error"
+ class={clazz}
+ />
+ </InputWrapper>
+ );
+}