diff options
Diffstat (limited to 'packages/exchange-backoffice-ui/src/handlers/InputLine.tsx')
-rw-r--r-- | packages/exchange-backoffice-ui/src/handlers/InputLine.tsx | 273 |
1 files changed, 273 insertions, 0 deletions
diff --git a/packages/exchange-backoffice-ui/src/handlers/InputLine.tsx b/packages/exchange-backoffice-ui/src/handlers/InputLine.tsx new file mode 100644 index 000000000..b3a5f8e98 --- /dev/null +++ b/packages/exchange-backoffice-ui/src/handlers/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 <div />; + + 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} + defaultValue={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} + defaultValue={toString(value)} + disabled={state.disabled} + aria-invalid={showError} + // aria-describedby="email-error" + class={clazz} + /> + </InputWrapper> + ); +} |