gnunet-svn
[Top][All Lists]
Advanced

[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

[taler-wallet-core] 02/20: more ui stuff, moved forms to util


From: gnunet
Subject: [taler-wallet-core] 02/20: more ui stuff, moved forms to util
Date: Mon, 25 Sep 2023 19:51:06 +0200

This is an automated email from the git hooks/post-receive script.

sebasjm pushed a commit to branch master
in repository wallet-core.

commit fdbe623e1060efc4b074d213a96e8f5a2ab7498b
Author: Sebastian <sebasjm@gmail.com>
AuthorDate: Wed Sep 20 15:16:28 2023 -0300

    more ui stuff, moved forms to util
---
 packages/web-util/package.json                     |   2 +
 packages/web-util/src/forms/Caption.tsx            |  32 +++
 packages/web-util/src/forms/DefaultForm.tsx        |  65 +++++
 packages/web-util/src/forms/FormProvider.tsx       |  99 ++++++++
 packages/web-util/src/forms/Group.tsx              |  41 +++
 packages/web-util/src/forms/InputAmount.tsx        |  34 +++
 packages/web-util/src/forms/InputArray.tsx         | 183 +++++++++++++
 .../web-util/src/forms/InputChoiceHorizontal.tsx   |  82 ++++++
 packages/web-util/src/forms/InputChoiceStacked.tsx | 111 ++++++++
 packages/web-util/src/forms/InputDate.tsx          |  37 +++
 packages/web-util/src/forms/InputFile.tsx          | 101 ++++++++
 packages/web-util/src/forms/InputInteger.tsx       |  23 ++
 packages/web-util/src/forms/InputLine.tsx          | 282 +++++++++++++++++++++
 .../web-util/src/forms/InputSelectMultiple.tsx     | 151 +++++++++++
 packages/web-util/src/forms/InputSelectOne.tsx     | 134 ++++++++++
 packages/web-util/src/forms/InputText.tsx          |   8 +
 packages/web-util/src/forms/InputTextArea.tsx      |   8 +
 packages/web-util/src/forms/forms.ts               | 135 ++++++++++
 packages/web-util/src/forms/index.ts               |  19 ++
 packages/web-util/src/forms/useField.ts            |  93 +++++++
 packages/web-util/src/hooks/index.ts               |   3 +
 packages/web-util/src/hooks/useNotifications.ts    |  29 +--
 packages/web-util/src/index.browser.ts             |   1 +
 23 files changed, 1658 insertions(+), 15 deletions(-)

diff --git a/packages/web-util/package.json b/packages/web-util/package.json
index ac85fe8eb..2c1b697d8 100644
--- a/packages/web-util/package.json
+++ b/packages/web-util/package.json
@@ -35,6 +35,8 @@
     "@babel/preset-react": "^7.22.3",
     "@babel/preset-typescript": "^7.21.5",
     "@gnu-taler/taler-util": "workspace:*",
+    "@heroicons/react": "^2.0.17",
+    "date-fns": "2.29.3",
     "@linaria/babel-preset": "4.4.5",
     "@linaria/core": "4.2.10",
     "@linaria/esbuild": "4.2.11",
diff --git a/packages/web-util/src/forms/Caption.tsx 
b/packages/web-util/src/forms/Caption.tsx
new file mode 100644
index 000000000..8facddec3
--- /dev/null
+++ b/packages/web-util/src/forms/Caption.tsx
@@ -0,0 +1,32 @@
+import { TranslatedString } from "@gnu-taler/taler-util";
+import { VNode, h } from "preact";
+import {
+  LabelWithTooltipMaybeRequired
+} from "./InputLine.js";
+
+interface Props {
+  label: TranslatedString;
+  tooltip?: TranslatedString;
+  help?: TranslatedString;
+  before?: VNode;
+  after?: VNode;
+}
+
+export function Caption({ before, after, label, tooltip, help }: Props): VNode 
{
+  return (
+    <div class="sm:col-span-6 flex">
+      {before !== undefined && (
+        <span class="pointer-events-none flex items-center 
pr-2">{before}</span>
+      )}
+      <LabelWithTooltipMaybeRequired label={label} tooltip={tooltip} />
+      {after !== undefined && (
+        <span class="pointer-events-none flex items-center pl-2">{after}</span>
+      )}
+      {help && (
+        <p class="mt-2 text-sm text-gray-500" id="email-description">
+          {help}
+        </p>
+      )}
+    </div>
+  );
+}
diff --git a/packages/web-util/src/forms/DefaultForm.tsx 
b/packages/web-util/src/forms/DefaultForm.tsx
new file mode 100644
index 000000000..92c379459
--- /dev/null
+++ b/packages/web-util/src/forms/DefaultForm.tsx
@@ -0,0 +1,65 @@
+
+import { ComponentChildren, Fragment, h } from "preact";
+import { FormProvider, FormState } from "./FormProvider.js";
+import { DoubleColumnForm, RenderAllFieldsByUiConfig } from "./forms.js";
+
+
+export interface FlexibleForm<T extends object> {
+  versionId: string;
+  design: DoubleColumnForm;
+  behavior: (form: Partial<T>) => FormState<T>;
+}
+
+export function DefaultForm<T extends object>({
+  initial,
+  onUpdate,
+  form,
+  onSubmit,
+  children,
+}: {
+  children?: ComponentChildren;
+  initial: Partial<T>;
+  onSubmit?: (v: Partial<T>) => void;
+  form: FlexibleForm<T>;
+  onUpdate?: (d: Partial<T>) => void;
+}) {
+  return (
+    <FormProvider
+      initialValue={initial}
+      onUpdate={onUpdate}
+      onSubmit={onSubmit}
+      computeFormState={form.behavior}
+    >
+      <div class="space-y-10 divide-y -mt-5 divide-gray-900/10">
+        {form.design.map((section, i) => {
+          if (!section) return <Fragment />;
+          return (
+            <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3">
+              <div class="px-4 sm:px-0">
+                <h2 class="text-base font-semibold leading-7 text-gray-900">
+                  {section.title}
+                </h2>
+                {section.description && (
+                  <p class="mt-1 text-sm leading-6 text-gray-600">
+                    {section.description}
+                  </p>
+                )}
+              </div>
+              <div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-md 
md:col-span-2">
+                <div class="p-3">
+                  <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 
sm:grid-cols-6">
+                    <RenderAllFieldsByUiConfig
+                      key={i}
+                      fields={section.fields}
+                    />
+                  </div>
+                </div>
+              </div>
+            </div>
+          );
+        })}
+      </div>
+      {children}
+    </FormProvider>
+  );
+}
diff --git a/packages/web-util/src/forms/FormProvider.tsx 
b/packages/web-util/src/forms/FormProvider.tsx
new file mode 100644
index 000000000..3da2a4f07
--- /dev/null
+++ b/packages/web-util/src/forms/FormProvider.tsx
@@ -0,0 +1,99 @@
+import {
+  AbsoluteTime,
+  AmountJson,
+  TranslatedString,
+} from "@gnu-taler/taler-util";
+import { ComponentChildren, VNode, createContext, h } from "preact";
+import {
+  MutableRef,
+  StateUpdater,
+  useEffect,
+  useRef,
+  useState,
+} from "preact/hooks";
+
+export interface FormType<T> {
+  value: MutableRef<Partial<T>>;
+  initialValue?: Partial<T>;
+  onUpdate?: StateUpdater<T>;
+  computeFormState?: (v: T) => FormState<T>;
+}
+
+//@ts-ignore
+export const FormContext = createContext<FormType<any>>({});
+
+export type FormState<T> = {
+  [field in keyof T]?: T[field] extends AbsoluteTime
+    ? Partial<InputFieldState>
+    : T[field] extends AmountJson
+    ? Partial<InputFieldState>
+    : T[field] extends Array<infer P>
+    ? Partial<InputArrayFieldState<P>>
+    : T[field] extends (object | undefined)
+    ? FormState<T[field]>
+    : Partial<InputFieldState>;
+};
+
+export interface InputFieldState {
+  /* should show the error */
+  error?: TranslatedString;
+  /* should not allow to edit */
+  readonly: boolean;
+  /* should show as disable */
+  disabled: boolean;
+  /* should not show */
+  hidden: boolean;
+}
+
+export interface InputArrayFieldState<T> extends InputFieldState {
+  elements: FormState<T>[];
+}
+
+export function FormProvider<T>({
+  children,
+  initialValue,
+  onUpdate: notify,
+  onSubmit,
+  computeFormState,
+}: {
+  initialValue?: Partial<T>;
+  onUpdate?: (v: Partial<T>) => void;
+  onSubmit?: (v: Partial<T>, s: FormState<T> | undefined) => void;
+  computeFormState?: (v: Partial<T>) => FormState<T>;
+  children: ComponentChildren;
+}): VNode {
+  // const value = useRef(initialValue ?? {});
+  // useEffect(() => {
+  //   return function onUnload() {
+  //     value.current = initialValue ?? {};
+  //   };
+  // });
+  // const onUpdate = notify
+  const [state, setState] = useState<Partial<T>>(initialValue ?? {});
+  const value = { current: state };
+  // console.log("RENDER", initialValue, value);
+  const onUpdate = (v: typeof state) => {
+    // console.log("updated");
+    setState(v);
+    if (notify) notify(v);
+  };
+  return (
+    <FormContext.Provider
+      value={{ initialValue, value, onUpdate, computeFormState }}
+    >
+      <form
+        onSubmit={(e) => {
+          e.preventDefault();
+          //@ts-ignore
+          if (onSubmit)
+            onSubmit(
+              value.current,
+              !computeFormState ? undefined : computeFormState(value.current),
+            );
+        }}
+      >
+        {children}
+      </form>
+    </FormContext.Provider>
+  );
+}
diff --git a/packages/web-util/src/forms/Group.tsx 
b/packages/web-util/src/forms/Group.tsx
new file mode 100644
index 000000000..0645f6d97
--- /dev/null
+++ b/packages/web-util/src/forms/Group.tsx
@@ -0,0 +1,41 @@
+import { TranslatedString } from "@gnu-taler/taler-util";
+import { VNode, h } from "preact";
+import { LabelWithTooltipMaybeRequired } from "./InputLine.js";
+import { RenderAllFieldsByUiConfig, UIFormField } from "./forms.js";
+
+interface Props {
+  before?: TranslatedString;
+  after?: TranslatedString;
+  tooltipBefore?: TranslatedString;
+  tooltipAfter?: TranslatedString;
+  fields: UIFormField[];
+}
+
+export function Group({
+  before,
+  after,
+  tooltipAfter,
+  tooltipBefore,
+  fields,
+}: Props): VNode {
+  return (
+    <div class="sm:col-span-6 p-4 rounded-lg border-r-2 border-2 bg-gray-50">
+      <div class="pb-4">
+        {before && (
+          <LabelWithTooltipMaybeRequired
+            label={before}
+            tooltip={tooltipBefore}
+          />
+        )}
+      </div>
+      <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-6">
+        <RenderAllFieldsByUiConfig fields={fields} />
+      </div>
+      <div class="pt-4">
+        {after && (
+          <LabelWithTooltipMaybeRequired label={after} tooltip={tooltipAfter} 
/>
+        )}
+      </div>
+    </div>
+  );
+}
diff --git a/packages/web-util/src/forms/InputAmount.tsx 
b/packages/web-util/src/forms/InputAmount.tsx
new file mode 100644
index 000000000..9be9dd4d0
--- /dev/null
+++ b/packages/web-util/src/forms/InputAmount.tsx
@@ -0,0 +1,34 @@
+import { AmountJson, Amounts, TranslatedString } from "@gnu-taler/taler-util";
+import { VNode, h } from "preact";
+import { InputLine, UIFormProps } from "./InputLine.js";
+import { useField } from "./useField.js";
+
+export function InputAmount<T extends object, K extends keyof T>(
+  props: { currency?: string } & UIFormProps<T, K>,
+): VNode {
+  const { value } = useField<T, K>(props.name);
+  const currency =
+    !value || !(value as any).currency
+      ? props.currency
+      : (value as any).currency;
+  return (
+    <InputLine<T, K>
+      type="text"
+      before={{
+        type: "text",
+        text: currency as TranslatedString,
+      }}
+      converter={{
+        //@ts-ignore
+        fromStringUI: (v): AmountJson => {
+          return Amounts.parseOrThrow(`${currency}:${v}`);
+        },
+        //@ts-ignore
+        toStringUI: (v: AmountJson) => {
+          return v === undefined ? "" : Amounts.stringifyValue(v);
+        },
+      }}
+      {...props}
+    />
+  );
+}
diff --git a/packages/web-util/src/forms/InputArray.tsx 
b/packages/web-util/src/forms/InputArray.tsx
new file mode 100644
index 000000000..00379bed6
--- /dev/null
+++ b/packages/web-util/src/forms/InputArray.tsx
@@ -0,0 +1,183 @@
+import { Fragment, VNode, h } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import { FormProvider, InputArrayFieldState } from "./FormProvider.js";
+import { LabelWithTooltipMaybeRequired, UIFormProps } from "./InputLine.js";
+import { RenderAllFieldsByUiConfig, UIFormField } from "./forms.js";
+import { useField } from "./useField.js";
+import { TranslatedString } from "@gnu-taler/taler-util";
+
+function Option({
+  label,
+  disabled,
+  isFirst,
+  isLast,
+  isSelected,
+  onClick,
+}: {
+  label: TranslatedString;
+  isFirst?: boolean;
+  isLast?: boolean;
+  isSelected?: boolean;
+  disabled?: boolean;
+  onClick: () => void;
+}): VNode {
+  let clazz = "relative flex border p-4 focus:outline-none disabled:text-grey";
+  if (isFirst) {
+    clazz += " rounded-tl-md rounded-tr-md ";
+  }
+  if (isLast) {
+    clazz += " rounded-bl-md rounded-br-md ";
+  }
+  if (isSelected) {
+    clazz += " z-10 border-indigo-200 bg-indigo-50 ";
+  } else {
+    clazz += " border-gray-200";
+  }
+  if (disabled) {
+    clazz +=
+      " cursor-not-allowed bg-gray-50 text-gray-500 ring-gray-200  text-gray";
+  } else {
+    clazz += " cursor-pointer";
+  }
+  return (
+    <label class={clazz}>
+      <input
+        type="radio"
+        name="privacy-setting"
+        checked={isSelected}
+        disabled={disabled}
+        onClick={onClick}
+        class="mt-0.5 h-4 w-4 shrink-0 text-indigo-600 
disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 
disabled:ring-gray-200  focus:ring-indigo-600"
+        aria-labelledby="privacy-setting-0-label"
+        aria-describedby="privacy-setting-0-description"
+      />
+      <span class="ml-3 flex flex-col">
+        <span
+          id="privacy-setting-0-label"
+          disabled
+          class="block text-sm font-medium"
+        >
+          {label}
+        </span>
+        {/* <!-- Checked: "text-indigo-700", Not Checked: "text-gray-500" --> 
*/}
+        {/* <span
+        id="privacy-setting-0-description"
+        class="block text-sm"
+      >
+        This project would be available to anyone who has the link
+      </span> */}
+      </span>
+    </label>
+  );
+}
+
+export function InputArray<T extends object, K extends keyof T>(
+  props: {
+    fields: UIFormField[];
+    labelField: string;
+  } & UIFormProps<T, K>,
+): VNode {
+  const { fields, labelField, name, label, required, tooltip } = props;
+  const { value, onChange, state } = useField<T, K>(name);
+  const list = (value ?? []) as Array<Record<string, string | undefined>>;
+  const [selectedIndex, setSelected] = useState<number | undefined>(undefined);
+  const selected =
+    selectedIndex === undefined ? undefined : list[selectedIndex];
+
+  return (
+    <div class="sm:col-span-6">
+      <LabelWithTooltipMaybeRequired
+        label={label}
+        required={required}
+        tooltip={tooltip}
+      />
+
+      <div class="-space-y-px rounded-md bg-white ">
+        {list.map((v, idx) => {
+          return (
+            <Option
+              label={v[labelField] as TranslatedString}
+              isSelected={selectedIndex === idx}
+              isLast={idx === list.length - 1}
+              disabled={selectedIndex !== undefined && selectedIndex !== idx}
+              isFirst={idx === 0}
+              onClick={() => {
+                setSelected(selectedIndex === idx ? undefined : idx);
+              }}
+            />
+          );
+        })}
+        <div class="pt-2">
+          <Option
+            label={"Add..." as TranslatedString}
+            isSelected={selectedIndex === list.length}
+            isLast
+            isFirst
+            disabled={
+              selectedIndex !== undefined && selectedIndex !== list.length
+            }
+            onClick={() => {
+              setSelected(
+                selectedIndex === list.length ? undefined : list.length,
+              );
+            }}
+          />
+        </div>
+      </div>
+      {selectedIndex !== undefined && (
+        /**
+         * This form provider act as a substate of the parent form
+         * Consider creating an InnerFormProvider since not every feature is 
expected
+         */
+        <FormProvider
+          initialValue={selected}
+          computeFormState={(v) => {
+            // current state is ignored
+            // the state is defined by the parent form
+
+            // elements should be present in the state object since this is 
expected to be an array
+            //@ts-ignore
+            return state.elements[selectedIndex];
+          }}
+          onSubmit={(v) => {
+            const newValue = [...list];
+            newValue.splice(selectedIndex, 1, v);
+            onChange(newValue as T[K]);
+            setSelected(undefined);
+          }}
+          onUpdate={(v) => {
+            const newValue = [...list];
+            newValue.splice(selectedIndex, 1, v);
+            onChange(newValue as T[K]);
+          }}
+        >
+          <div class="px-4 py-6">
+            <div class="grid grid-cols-1 gap-y-8 ">
+              <RenderAllFieldsByUiConfig fields={fields} />
+            </div>
+          </div>
+        </FormProvider>
+      )}
+      {selectedIndex !== undefined && (
+        <div class="flex items-center pt-3">
+          <div class="flex-auto">
+            {selected !== undefined && (
+              <button
+                type="button"
+                onClick={() => {
+                  const newValue = [...list];
+                  newValue.splice(selectedIndex, 1);
+                  onChange(newValue as T[K]);
+                  setSelected(undefined);
+                }}
+                class="block rounded-md bg-red-600 px-3 py-2 text-center 
text-sm  text-white shadow-sm hover:bg-red-500 "
+              >
+                Remove
+              </button>
+            )}
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}
diff --git a/packages/web-util/src/forms/InputChoiceHorizontal.tsx 
b/packages/web-util/src/forms/InputChoiceHorizontal.tsx
new file mode 100644
index 000000000..5c909b5d7
--- /dev/null
+++ b/packages/web-util/src/forms/InputChoiceHorizontal.tsx
@@ -0,0 +1,82 @@
+import { TranslatedString } from "@gnu-taler/taler-util";
+import { Fragment, VNode, h } from "preact";
+import { LabelWithTooltipMaybeRequired, UIFormProps } from "./InputLine.js";
+import { useField } from "./useField.js";
+import { Choice } from "./InputChoiceStacked.js";
+
+export function InputChoiceHorizontal<T extends object, K extends keyof T>(
+  props: {
+    choices: Choice<T[K]>[];
+  } & UIFormProps<T, K>,
+): VNode {
+  const {
+    choices,
+    name,
+    label,
+    tooltip,
+    help,
+    placeholder,
+    required,
+    before,
+    after,
+    converter,
+  } = props;
+  const { value, onChange, state, isDirty } = useField<T, K>(name);
+  if (state.hidden) {
+    return <Fragment />;
+  }
+
+  return (
+    <div class="sm:col-span-6">
+      <LabelWithTooltipMaybeRequired
+        label={label}
+        required={required}
+        tooltip={tooltip}
+      />
+      <fieldset class="mt-2">
+        <div class="isolate inline-flex rounded-md shadow-sm">
+          {choices.map((choice, idx) => {
+            const isFirst = idx === 0;
+            const isLast = idx === choices.length - 1;
+            let clazz =
+              "relative inline-flex items-center px-3 py-2 text-sm 
font-semibold text-gray-900 ring-1 ring-inset ring-gray-300  focus:z-10";
+            if (choice.value === value) {
+              clazz +=
+                " text-white bg-indigo-600 hover:bg-indigo-500 ring-2 
ring-indigo-600 hover:ring-indigo-500";
+            } else {
+              clazz += " hover:bg-gray-100 border-gray-300";
+            }
+            if (isFirst) {
+              clazz += " rounded-l-md";
+            } else {
+              clazz += " -ml-px";
+            }
+            if (isLast) {
+              clazz += " rounded-r-md";
+            }
+            return (
+              <button
+                type="button"
+                class={clazz}
+                onClick={(e) => {
+                  onChange(
+                    (value === choice.value ? undefined : choice.value) as 
T[K],
+                  );
+                }}
+              >
+                {(!converter
+                  ? (choice.value as string)
+                  : converter?.toStringUI(choice.value)) ?? ""}
+              </button>
+            );
+          })}
+        </div>
+      </fieldset>
+      {help && (
+        <p class="mt-2 text-sm text-gray-500" id="email-description">
+          {help}
+        </p>
+      )}
+    </div>
+  );
+}
diff --git a/packages/web-util/src/forms/InputChoiceStacked.tsx 
b/packages/web-util/src/forms/InputChoiceStacked.tsx
new file mode 100644
index 000000000..c37984368
--- /dev/null
+++ b/packages/web-util/src/forms/InputChoiceStacked.tsx
@@ -0,0 +1,111 @@
+import { TranslatedString } from "@gnu-taler/taler-util";
+import { Fragment, VNode, h } from "preact";
+import { LabelWithTooltipMaybeRequired, UIFormProps } from "./InputLine.js";
+import { useField } from "./useField.js";
+
+export interface Choice<V> {
+  label: TranslatedString;
+  description?: TranslatedString;
+  value: V;
+}
+
+export function InputChoiceStacked<T extends object, K extends keyof T>(
+  props: {
+    choices: Choice<T[K]>[];
+  } & UIFormProps<T, K>,
+): VNode {
+  const {
+    choices,
+    name,
+    label,
+    tooltip,
+    help,
+    placeholder,
+    required,
+    before,
+    after,
+    converter,
+  } = props;
+  const { value, onChange, state, isDirty } = useField<T, K>(name);
+  if (state.hidden) {
+    return <Fragment />;
+  }
+
+  return (
+    <div class="sm:col-span-6">
+      <LabelWithTooltipMaybeRequired
+        label={label}
+        required={required}
+        tooltip={tooltip}
+      />
+      <fieldset class="mt-2">
+        <div class="space-y-4">
+          {choices.map((choice) => {
+            // const currentValue = !converter
+            //   ? choice.value
+            //   : converter.fromStringUI(choice.value) ?? "";
+
+            let clazz =
+              "border relative block cursor-pointer rounded-lg bg-white px-6 
py-4 shadow-sm focus:outline-none sm:flex sm:justify-between";
+            if (choice.value === value) {
+              clazz +=
+                " border-transparent border-indigo-600 ring-2 ring-indigo-600";
+            } else {
+              clazz += " border-gray-300";
+            }
+
+            return (
+              <label class={clazz}>
+                <input
+                  type="radio"
+                  name="server-size"
+                  // defaultValue={choice.value}
+                  value={
+                    (!converter
+                      ? (choice.value as string)
+                      : converter?.toStringUI(choice.value)) ?? ""
+                  }
+                  onClick={(e) => {
+                    onChange(
+                      (value === choice.value
+                        ? undefined
+                        : choice.value) as T[K],
+                    );
+                  }}
+                  class="sr-only"
+                  aria-labelledby="server-size-0-label"
+                  aria-describedby="server-size-0-description-0 
server-size-0-description-1"
+                />
+                <span class="flex items-center">
+                  <span class="flex flex-col text-sm">
+                    <span
+                      id="server-size-0-label"
+                      class="font-medium text-gray-900"
+                    >
+                      {choice.label}
+                    </span>
+                    {choice.description !== undefined && (
+                      <span
+                        id="server-size-0-description-0"
+                        class="text-gray-500"
+                      >
+                        <span class="block sm:inline">
+                          {choice.description}
+                        </span>
+                      </span>
+                    )}
+                  </span>
+                </span>
+              </label>
+            );
+          })}
+        </div>
+      </fieldset>
+      {help && (
+        <p class="mt-2 text-sm text-gray-500" id="email-description">
+          {help}
+        </p>
+      )}
+    </div>
+  );
+}
diff --git a/packages/web-util/src/forms/InputDate.tsx 
b/packages/web-util/src/forms/InputDate.tsx
new file mode 100644
index 000000000..1fd81aad9
--- /dev/null
+++ b/packages/web-util/src/forms/InputDate.tsx
@@ -0,0 +1,37 @@
+import { AbsoluteTime } from "@gnu-taler/taler-util";
+import { InputLine, UIFormProps } from "./InputLine.js";
+import { CalendarIcon } from "@heroicons/react/24/outline";
+import { VNode, h } from "preact";
+import { format, parse } from "date-fns";
+
+export function InputDate<T extends object, K extends keyof T>(
+  props: { pattern?: string } & UIFormProps<T, K>,
+): VNode {
+  const pattern = props.pattern ?? "dd/MM/yyyy";
+  return (
+    <InputLine<T, K>
+      type="text"
+      after={{
+        type: "icon",
+        icon: <CalendarIcon class="h-6 w-6" />,
+      }}
+      converter={{
+        //@ts-ignore
+        fromStringUI: (v): AbsoluteTime => {
+          if (!v) return AbsoluteTime.never();
+          const t_ms = parse(v, pattern, Date.now()).getTime();
+          return AbsoluteTime.fromMilliseconds(t_ms);
+        },
+        //@ts-ignore
+        toStringUI: (v: AbsoluteTime) => {
+          return !v || !v.t_ms
+            ? ""
+            : v.t_ms === "never"
+            ? "never"
+            : format(v.t_ms, pattern);
+        },
+      }}
+      {...props}
+    />
+  );
+}
diff --git a/packages/web-util/src/forms/InputFile.tsx 
b/packages/web-util/src/forms/InputFile.tsx
new file mode 100644
index 000000000..0d89a98a3
--- /dev/null
+++ b/packages/web-util/src/forms/InputFile.tsx
@@ -0,0 +1,101 @@
+import { Fragment, VNode, h } from "preact";
+import { LabelWithTooltipMaybeRequired, UIFormProps } from "./InputLine.js";
+import { useField } from "./useField.js";
+
+export function InputFile<T extends object, K extends keyof T>(
+  props: { maxBites: number; accept?: string } & UIFormProps<T, K>,
+): VNode {
+  const {
+    name,
+    label,
+    placeholder,
+    tooltip,
+    required,
+    help,
+    maxBites,
+    accept,
+  } = props;
+  const { value, onChange, state } = useField<T, K>(name);
+
+  if (state.hidden) {
+    return <div />;
+  }
+  return (
+    <div class="col-span-full">
+      <LabelWithTooltipMaybeRequired
+        label={label}
+        tooltip={tooltip}
+        required={required}
+      />
+      {!value || !(value as string).startsWith("data:image/") ? (
+        <div class="mt-2 flex justify-center rounded-lg border border-dashed 
border-gray-900/25 py-1">
+          <div class="text-center">
+            <svg
+              class="mx-auto h-12 w-12 text-gray-300"
+              viewBox="0 0 24 24"
+              fill="currentColor"
+              aria-hidden="true"
+            >
+              <path
+                fill-rule="evenodd"
+                d="M1.5 6a2.25 2.25 0 012.25-2.25h16.5A2.25 2.25 0 0122.5 
6v12a2.25 2.25 0 01-2.25 2.25H3.75A2.25 2.25 0 011.5 18V6zM3 16.06V18c0 
.414.336.75.75.75h16.5A.75.75 0 0021 18v-1.94l-2.69-2.689a1.5 1.5 0 00-2.12 
0l-.88.879.97.97a.75.75 0 11-1.06 1.06l-5.16-5.159a1.5 1.5 0 00-2.12 0L3 
16.061zm10.125-7.81a1.125 1.125 0 112.25 0 1.125 1.125 0 01-2.25 0z"
+                clip-rule="evenodd"
+              />
+            </svg>
+            <div class="my-2 flex text-sm leading-6 text-gray-600">
+              <label
+                for="file-upload"
+                class="relative cursor-pointer rounded-md bg-white 
font-semibold text-indigo-600 focus-within:outline-none focus-within:ring-2 
focus-within:ring-indigo-600 focus-within:ring-offset-2 hover:text-indigo-500"
+              >
+                <span>Upload a file</span>
+                <input
+                  id="file-upload"
+                  name="file-upload"
+                  type="file"
+                  class="sr-only"
+                  accept={accept}
+                  onChange={(e) => {
+                    const f: FileList | null = e.currentTarget.files;
+                    if (!f || f.length != 1) {
+                      return onChange(undefined!);
+                    }
+                    if (f[0].size > maxBites) {
+                      return onChange(undefined!);
+                    }
+                    return f[0].arrayBuffer().then((b) => {
+                      const b64 = window.btoa(
+                        new Uint8Array(b).reduce(
+                          (data, byte) => data + String.fromCharCode(byte),
+                          "",
+                        ),
+                      );
+                      return onChange(`data:${f[0].type};base64,${b64}` as 
any);
+                    });
+                  }}
+                />
+              </label>
+              {/* <p class="pl-1">or drag and drop</p> */}
+            </div>
+          </div>
+        </div>
+      ) : (
+        <div class="mt-2 flex justify-center rounded-lg border border-dashed 
border-gray-900/25 relative">
+          <img
+            src={value as string}
+            class=" h-24 w-full object-cover relative"
+          />
+
+          <div
+            class="opacity-0 hover:opacity-70 duration-300 absolute rounded-lg 
border inset-0 z-10 flex justify-center text-xl items-center bg-black 
text-white cursor-pointer "
+            onClick={() => {
+              onChange(undefined!);
+            }}
+          >
+            Clear
+          </div>
+        </div>
+      )}
+      {help && <p class="text-xs leading-5 text-gray-600 mt-2">{help}</p>}
+    </div>
+  );
+}
diff --git a/packages/web-util/src/forms/InputInteger.tsx 
b/packages/web-util/src/forms/InputInteger.tsx
new file mode 100644
index 000000000..fb04e3852
--- /dev/null
+++ b/packages/web-util/src/forms/InputInteger.tsx
@@ -0,0 +1,23 @@
+import { VNode, h } from "preact";
+import { InputLine, UIFormProps } from "./InputLine.js";
+
+export function InputInteger<T extends object, K extends keyof T>(
+  props: UIFormProps<T, K>,
+): VNode {
+  return (
+    <InputLine
+      type="number"
+      converter={{
+        //@ts-ignore
+        fromStringUI: (v): number => {
+          return !v ? 0 : Number.parseInt(v, 10);
+        },
+        //@ts-ignore
+        toStringUI: (v?: number): string => {
+          return v === undefined ? "" : String(v);
+        },
+      }}
+      {...props}
+    />
+  );
+}
diff --git a/packages/web-util/src/forms/InputLine.tsx 
b/packages/web-util/src/forms/InputLine.tsx
new file mode 100644
index 000000000..9448ef5e4
--- /dev/null
+++ b/packages/web-util/src/forms/InputLine.tsx
@@ -0,0 +1,282 @@
+import { TranslatedString } from "@gnu-taler/taler-util";
+import { ComponentChildren, Fragment, VNode, h } from "preact";
+import { useField } from "./useField.js";
+
+export 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 extends object, K extends keyof T> {
+  name: K;
+  label: TranslatedString;
+  placeholder?: TranslatedString;
+  tooltip?: TranslatedString;
+  help?: TranslatedString;
+  before?: Addon;
+  after?: Addon;
+  required?: boolean;
+  converter?: StringConverter<T[K]>;
+}
+
+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 extends object, K extends keyof T>({
+  children,
+  label,
+  tooltip,
+  before,
+  after,
+  help,
+  error,
+  required,
+}: { error?: string; children: ComponentChildren } & UIFormProps<T, K>): 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;
+}
+
+type InputType = "text" | "text-area" | "password" | "email" | "number";
+
+export function InputLine<T extends object, K extends keyof T>(
+  props: { type: InputType } & UIFormProps<T, K>,
+): VNode {
+  const { name, placeholder, before, after, converter, type } = props;
+  const { value, onChange, state, isDirty } = useField<T, K>(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, K>
+        {...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) ?? ""}
+          // defaultValue={toString(value)}
+          disabled={state.disabled}
+          aria-invalid={showError}
+          // aria-describedby="email-error"
+          class={clazz}
+        />
+      </InputWrapper>
+    );
+  }
+
+  return (
+    <InputWrapper<T, K> {...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) ?? ""}
+        // defaultValue={toString(value)}
+        disabled={state.disabled}
+        aria-invalid={showError}
+        // aria-describedby="email-error"
+        class={clazz}
+      />
+    </InputWrapper>
+  );
+}
diff --git a/packages/web-util/src/forms/InputSelectMultiple.tsx 
b/packages/web-util/src/forms/InputSelectMultiple.tsx
new file mode 100644
index 000000000..8116bdc03
--- /dev/null
+++ b/packages/web-util/src/forms/InputSelectMultiple.tsx
@@ -0,0 +1,151 @@
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { Choice } from "./InputChoiceStacked.js";
+import { LabelWithTooltipMaybeRequired, UIFormProps } from "./InputLine.js";
+import { useField } from "./useField.js";
+
+export function InputSelectMultiple<T extends object, K extends keyof T>(
+  props: {
+    choices: Choice<T[K]>[];
+    unique?: boolean;
+    max?: number;
+  } & UIFormProps<T, K>,
+): VNode {
+  const { name, label, choices, placeholder, tooltip, required, unique, max } =
+    props;
+  const { value, onChange } = useField<T, K>(name);
+
+  const [filter, setFilter] = useState<string | undefined>(undefined);
+  const regex = new RegExp(`.*${filter}.*`, "i");
+  const choiceMap = choices.reduce((prev, curr) => {
+    return { ...prev, [curr.value as string]: curr.label };
+  }, {} as Record<string, string>);
+
+  const list = (value ?? []) as string[];
+  const filteredChoices =
+    filter === undefined
+      ? undefined
+      : choices.filter((v) => {
+          return regex.test(v.label);
+        });
+  return (
+    <div class="sm:col-span-6">
+      <LabelWithTooltipMaybeRequired
+        label={label}
+        required={required}
+        tooltip={tooltip}
+      />
+      {list.map((v, idx) => {
+        return (
+          <span class="inline-flex items-center gap-x-0.5 rounded-md 
bg-gray-100 p-1 mr-2 text-xs font-medium text-gray-600">
+            {choiceMap[v]}
+            <button
+              type="button"
+              onClick={() => {
+                const newValue = [...list];
+                newValue.splice(idx, 1);
+                onChange(newValue as T[K]);
+                setFilter(undefined);
+              }}
+              class="group relative h-5 w-5 rounded-sm hover:bg-gray-500/20"
+            >
+              <span class="sr-only">Remove</span>
+              <svg
+                viewBox="0 0 14 14"
+                class="h-5 w-5 stroke-gray-700/50 
group-hover:stroke-gray-700/75"
+              >
+                <path d="M4 4l6 6m0-6l-6 6" />
+              </svg>
+              <span class="absolute -inset-1"></span>
+            </button>
+          </span>
+        );
+      })}
+
+      <div class="relative mt-2">
+        <input
+          id="combobox"
+          type="text"
+          value={filter ?? ""}
+          onChange={(e) => {
+            setFilter(e.currentTarget.value);
+          }}
+          placeholder={placeholder}
+          class="w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 
text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 
focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+          role="combobox"
+          aria-controls="options"
+          aria-expanded="false"
+        />
+        <button
+          type="button"
+          onClick={() => {
+            setFilter(filter === undefined ? "" : undefined);
+          }}
+          class="absolute inset-y-0 right-0 flex items-center rounded-r-md 
px-2 focus:outline-none"
+        >
+          <svg
+            class="h-5 w-5 text-gray-400"
+            viewBox="0 0 20 20"
+            fill="currentColor"
+            aria-hidden="true"
+          >
+            <path
+              fill-rule="evenodd"
+              d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 
4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 
0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 
0l-3.25-3.5a.75.75 0 01.04-1.06z"
+              clip-rule="evenodd"
+            />
+          </svg>
+        </button>
+
+        {filteredChoices !== undefined && (
+          <ul
+            class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md 
bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 
focus:outline-none sm:text-sm"
+            id="options"
+            role="listbox"
+          >
+            {filteredChoices.map((v, idx) => {
+              return (
+                <li
+                  class="relative cursor-pointer select-none py-2 pl-3 pr-9 
text-gray-900 hover:text-white hover:bg-indigo-600"
+                  id="option-0"
+                  role="option"
+                  onClick={() => {
+                    setFilter(undefined);
+                    if (unique && list.indexOf(v.value as string) !== -1) {
+                      return;
+                    }
+                    if (max !== undefined && list.length >= max) {
+                      return;
+                    }
+                    const newValue = [...list];
+                    newValue.splice(0, 0, v.value as string);
+                    onChange(newValue as T[K]);
+                  }}
+
+                  // tabindex="-1"
+                >
+                  {/* <!-- Selected: "font-semibold" --> */}
+                  <span class="block truncate">{v.label}</span>
+
+                  {/* <!--
+          Checkmark, only display for selected option.
+
+          Active: "text-white", Not Active: "text-indigo-600"
+        --> */}
+                </li>
+              );
+            })}
+
+            {/* <!--
+        Combobox option, manage highlight styles based on 
mouseenter/mouseleave and keyboard navigation.
+
+        Active: "text-white bg-indigo-600", Not Active: "text-gray-900"
+      --> */}
+
+            {/* <!-- More items... --> */}
+          </ul>
+        )}
+      </div>
+    </div>
+  );
+}
diff --git a/packages/web-util/src/forms/InputSelectOne.tsx 
b/packages/web-util/src/forms/InputSelectOne.tsx
new file mode 100644
index 000000000..7bef1058b
--- /dev/null
+++ b/packages/web-util/src/forms/InputSelectOne.tsx
@@ -0,0 +1,134 @@
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { Choice } from "./InputChoiceStacked.js";
+import { LabelWithTooltipMaybeRequired, UIFormProps } from "./InputLine.js";
+import { useField } from "./useField.js";
+
+export function InputSelectOne<T extends object, K extends keyof T>(
+  props: {
+    choices: Choice<T[K]>[];
+  } & UIFormProps<T, K>,
+): VNode {
+  const { name, label, choices, placeholder, tooltip, required } = props;
+  const { value, onChange } = useField<T, K>(name);
+
+  const [filter, setFilter] = useState<string | undefined>(undefined);
+  const regex = new RegExp(`.*${filter}.*`, "i");
+  const choiceMap = choices.reduce((prev, curr) => {
+    return { ...prev, [curr.value as string]: curr.label };
+  }, {} as Record<string, string>);
+
+  const filteredChoices =
+    filter === undefined
+      ? undefined
+      : choices.filter((v) => {
+          return regex.test(v.label);
+        });
+  return (
+    <div class="sm:col-span-6">
+      <LabelWithTooltipMaybeRequired
+        label={label}
+        required={required}
+        tooltip={tooltip}
+      />
+      {value ? (
+        <span class="inline-flex items-center gap-x-0.5 rounded-md bg-gray-100 
p-1 mr-2 font-medium text-gray-600">
+          {choiceMap[value as string]}
+          <button
+            type="button"
+            onClick={() => {
+              onChange(undefined!);
+            }}
+            class="group relative h-5 w-5 rounded-sm hover:bg-gray-500/20"
+          >
+            <span class="sr-only">Remove</span>
+            <svg
+              viewBox="0 0 14 14"
+              class="h-5 w-5 stroke-gray-700/50 group-hover:stroke-gray-700/75"
+            >
+              <path d="M4 4l6 6m0-6l-6 6" />
+            </svg>
+            <span class="absolute -inset-1"></span>
+          </button>
+        </span>
+      ) : (
+        <div class="relative mt-2">
+          <input
+            id="combobox"
+            type="text"
+            value={filter ?? ""}
+            onChange={(e) => {
+              setFilter(e.currentTarget.value);
+            }}
+            placeholder={placeholder}
+            class="w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 
text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 
focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+            role="combobox"
+            aria-controls="options"
+            aria-expanded="false"
+          />
+          <button
+            type="button"
+            onClick={() => {
+              setFilter(filter === undefined ? "" : undefined);
+            }}
+            class="absolute inset-y-0 right-0 flex items-center rounded-r-md 
px-2 focus:outline-none"
+          >
+            <svg
+              class="h-5 w-5 text-gray-400"
+              viewBox="0 0 20 20"
+              fill="currentColor"
+              aria-hidden="true"
+            >
+              <path
+                fill-rule="evenodd"
+                d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 
4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 
0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 
0l-3.25-3.5a.75.75 0 01.04-1.06z"
+                clip-rule="evenodd"
+              />
+            </svg>
+          </button>
+
+          {filteredChoices !== undefined && (
+            <ul
+              class="absolute z-10 mt-1 max-h-60 w-full overflow-auto 
rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 
focus:outline-none sm:text-sm"
+              id="options"
+              role="listbox"
+            >
+              {filteredChoices.map((v, idx) => {
+                return (
+                  <li
+                    class="relative cursor-pointer select-none py-2 pl-3 pr-9 
text-gray-900 hover:text-white hover:bg-indigo-600"
+                    id="option-0"
+                    role="option"
+                    onClick={() => {
+                      setFilter(undefined);
+                      onChange(v.value as T[K]);
+                    }}
+
+                    // tabindex="-1"
+                  >
+                    {/* <!-- Selected: "font-semibold" --> */}
+                    <span class="block truncate">{v.label}</span>
+
+                    {/* <!--
+          Checkmark, only display for selected option.
+
+          Active: "text-white", Not Active: "text-indigo-600"
+        --> */}
+                  </li>
+                );
+              })}
+
+              {/* <!--
+        Combobox option, manage highlight styles based on 
mouseenter/mouseleave and keyboard navigation.
+
+        Active: "text-white bg-indigo-600", Not Active: "text-gray-900"
+      --> */}
+
+              {/* <!-- More items... --> */}
+            </ul>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
diff --git a/packages/web-util/src/forms/InputText.tsx 
b/packages/web-util/src/forms/InputText.tsx
new file mode 100644
index 000000000..1b37ee6fb
--- /dev/null
+++ b/packages/web-util/src/forms/InputText.tsx
@@ -0,0 +1,8 @@
+import { VNode, h } from "preact";
+import { InputLine, UIFormProps } from "./InputLine.js";
+
+export function InputText<T extends object, K extends keyof T>(
+  props: UIFormProps<T, K>,
+): VNode {
+  return <InputLine type="text" {...props} />;
+}
diff --git a/packages/web-util/src/forms/InputTextArea.tsx 
b/packages/web-util/src/forms/InputTextArea.tsx
new file mode 100644
index 000000000..45229951e
--- /dev/null
+++ b/packages/web-util/src/forms/InputTextArea.tsx
@@ -0,0 +1,8 @@
+import { VNode, h } from "preact";
+import { InputLine, UIFormProps } from "./InputLine.js";
+
+export function InputTextArea<T extends object, K extends keyof T>(
+  props: UIFormProps<T, K>,
+): VNode {
+  return <InputLine type="text-area" {...props} />;
+}
diff --git a/packages/web-util/src/forms/forms.ts 
b/packages/web-util/src/forms/forms.ts
new file mode 100644
index 000000000..2c90a69ed
--- /dev/null
+++ b/packages/web-util/src/forms/forms.ts
@@ -0,0 +1,135 @@
+import { TranslatedString } from "@gnu-taler/taler-util";
+import { InputText } from "./InputText.js";
+import { InputDate } from "./InputDate.js";
+import { InputInteger } from "./InputInteger.js";
+import { h as create, Fragment, VNode } from "preact";
+import { InputChoiceStacked } from "./InputChoiceStacked.js";
+import { InputArray } from "./InputArray.js";
+import { InputSelectMultiple } from "./InputSelectMultiple.js";
+import { InputTextArea } from "./InputTextArea.js";
+import { InputFile } from "./InputFile.js";
+import { Caption } from "./Caption.js";
+import { Group } from "./Group.js";
+import { InputSelectOne } from "./InputSelectOne.js";
+import { FormProvider } from "./FormProvider.js";
+import { InputLine } from "./InputLine.js";
+import { InputAmount } from "./InputAmount.js";
+import { InputChoiceHorizontal } from "./InputChoiceHorizontal.js";
+
+export type DoubleColumnForm = Array<DoubleColumnFormSection | undefined>;
+
+export type DoubleColumnFormSection = {
+  title: TranslatedString;
+  description?: TranslatedString;
+  fields: UIFormField[];
+};
+
+/**
+ * Constrain the type with the ui props
+ */
+type FieldType<T extends object = any, K extends keyof T = any> = {
+  group: Parameters<typeof Group>[0];
+  caption: Parameters<typeof Caption>[0];
+  array: Parameters<typeof InputArray<T, K>>[0];
+  file: Parameters<typeof InputFile<T, K>>[0];
+  selectOne: Parameters<typeof InputSelectOne<T, K>>[0];
+  selectMultiple: Parameters<typeof InputSelectMultiple<T, K>>[0];
+  text: Parameters<typeof InputText<T, K>>[0];
+  textArea: Parameters<typeof InputTextArea<T, K>>[0];
+  choiceStacked: Parameters<typeof InputChoiceStacked<T, K>>[0];
+  choiceHorizontal: Parameters<typeof InputChoiceHorizontal<T, K>>[0];
+  date: Parameters<typeof InputDate<T, K>>[0];
+  integer: Parameters<typeof InputInteger<T, K>>[0];
+  amount: Parameters<typeof InputAmount<T, K>>[0];
+};
+
+/**
+ * List all the form fields so typescript can type-check the form instance
+ */
+export type UIFormField =
+  | { type: "group"; props: FieldType["group"] }
+  | { type: "caption"; props: FieldType["caption"] }
+  | { type: "array"; props: FieldType["array"] }
+  | { type: "file"; props: FieldType["file"] }
+  | { type: "amount"; props: FieldType["amount"] }
+  | { type: "selectOne"; props: FieldType["selectOne"] }
+  | { type: "selectMultiple"; props: FieldType["selectMultiple"] }
+  | { type: "text"; props: FieldType["text"] }
+  | { type: "textArea"; props: FieldType["textArea"] }
+  | { type: "choiceStacked"; props: FieldType["choiceStacked"] }
+  | { type: "choiceHorizontal"; props: FieldType["choiceHorizontal"] }
+  | { type: "integer"; props: FieldType["integer"] }
+  | { type: "date"; props: FieldType["date"] };
+
+type FieldComponentFunction<key extends keyof FieldType> = (
+  props: FieldType[key],
+) => VNode;
+
+type UIFormFieldMap = {
+  [key in keyof FieldType]: FieldComponentFunction<key>;
+};
+
+/**
+ * Maps input type with component implementation
+ */
+const UIFormConfiguration: UIFormFieldMap = {
+  group: Group,
+  caption: Caption,
+  //@ts-ignore
+  array: InputArray,
+  text: InputText,
+  //@ts-ignore
+  file: InputFile,
+  textArea: InputTextArea,
+  //@ts-ignore
+  date: InputDate,
+  //@ts-ignore
+  choiceStacked: InputChoiceStacked,
+  //@ts-ignore
+  choiceHorizontal: InputChoiceHorizontal,
+  integer: InputInteger,
+  //@ts-ignore
+  selectOne: InputSelectOne,
+  //@ts-ignore
+  selectMultiple: InputSelectMultiple,
+  //@ts-ignore
+  amount: InputAmount,
+};
+
+export function RenderAllFieldsByUiConfig({
+  fields,
+}: {
+  fields: UIFormField[];
+}): VNode {
+  return create(
+    Fragment,
+    {},
+    fields.map((field, i) => {
+      const Component = UIFormConfiguration[
+        field.type
+      ] as FieldComponentFunction<any>;
+      return Component(field.props);
+    }),
+  );
+}
+
+type FormSet<T extends object> = {
+  Provider: typeof FormProvider<T>;
+  InputLine: <K extends keyof T>() => typeof InputLine<T, K>;
+  InputChoiceHorizontal: <K extends keyof T>() => typeof InputChoiceHorizontal<
+    T,
+    K
+  >;
+};
+export function createNewForm<T extends object>() {
+  const res: FormSet<T> = {
+    Provider: FormProvider,
+    InputLine: () => InputLine,
+    InputChoiceHorizontal: () => InputChoiceHorizontal,
+  };
+  return {
+    Provider: res.Provider,
+    InputLine: res.InputLine(),
+    InputChoiceHorizontal: res.InputChoiceHorizontal(),
+  };
+}
diff --git a/packages/web-util/src/forms/index.ts 
b/packages/web-util/src/forms/index.ts
new file mode 100644
index 000000000..08bb9ee77
--- /dev/null
+++ b/packages/web-util/src/forms/index.ts
@@ -0,0 +1,19 @@
+export * from "./Caption.js"
+export * from "./FormProvider.js"
+export * from "./forms.js"
+export * from "./Group.js"
+export * from "./index.js"
+export * from "./InputAmount.js"
+export * from "./InputArray.js"
+export * from "./InputChoiceHorizontal.js"
+export * from "./InputChoiceStacked.js"
+export * from "./InputDate.js"
+export * from "./InputFile.js"
+export * from "./InputInteger.js"
+export * from "./InputLine.js"
+export * from "./InputSelectMultiple.js"
+export * from "./InputSelectOne.js"
+export * from "./InputTextArea.js"
+export * from "./InputText.js"
+export * from "./useField.js"
+export * from "./DefaultForm.js"
diff --git a/packages/web-util/src/forms/useField.ts 
b/packages/web-util/src/forms/useField.ts
new file mode 100644
index 000000000..bf94d2f5d
--- /dev/null
+++ b/packages/web-util/src/forms/useField.ts
@@ -0,0 +1,93 @@
+import { useContext, useState } from "preact/compat";
+import { FormContext, InputFieldState } from "./FormProvider.js";
+
+export interface InputFieldHandler<Type> {
+  value: Type;
+  onChange: (s: Type) => void;
+  state: InputFieldState;
+  isDirty: boolean;
+}
+
+export function useField<T extends object, K extends keyof T>(
+  name: K,
+): InputFieldHandler<T[K]> {
+  const {
+    initialValue,
+    value: formValue,
+    computeFormState,
+    onUpdate: notifyUpdate,
+  } = useContext(FormContext);
+
+  type P = typeof name;
+  type V = T[P];
+  const formState = computeFormState ? computeFormState(formValue.current) : 
{};
+
+  const fieldValue = readField(formValue.current, String(name)) as V;
+  // console.log("USE FIELD", String(name), formValue.current, fieldValue);
+  const [currentValue, setCurrentValue] = useState<any | 
undefined>(fieldValue);
+  const fieldState =
+    readField<Partial<InputFieldState>>(formState, String(name)) ?? {};
+
+  //compute default state
+  const state = {
+    disabled: fieldState.disabled ?? false,
+    readonly: fieldState.readonly ?? false,
+    hidden: fieldState.hidden ?? false,
+    error: fieldState.error,
+    elements: "elements" in fieldState ? fieldState.elements ?? [] : [],
+  };
+
+  function onChange(value: V): void {
+    setCurrentValue(value);
+    formValue.current = setValueDeeper(
+      formValue.current,
+      String(name).split("."),
+      value,
+    );
+    if (notifyUpdate) {
+      notifyUpdate(formValue.current);
+    }
+  }
+
+  return {
+    value: fieldValue,
+    onChange,
+    isDirty: currentValue !== undefined,
+    state,
+  };
+}
+
+/**
+ * read the field of an object an support accessing it using '.'
+ *
+ * @param object
+ * @param name
+ * @returns
+ */
+function readField<T>(
+  object: any,
+  name: string,
+  debug?: boolean,
+): T | undefined {
+  return name.split(".").reduce((prev, current) => {
+    if (debug) {
+      console.log(
+        "READ",
+        name,
+        prev,
+        current,
+        prev ? prev[current] : undefined,
+      );
+    }
+    return prev ? prev[current] : undefined;
+  }, object);
+}
+
+function setValueDeeper(object: any, names: string[], value: any): any {
+  if (names.length === 0) return value;
+  const [head, ...rest] = names;
+  if (object === undefined) {
+    return { [head]: setValueDeeper({}, rest, value) };
+  }
+  return { ...object, [head]: setValueDeeper(object[head] ?? {}, rest, value) 
};
+}
diff --git a/packages/web-util/src/hooks/index.ts 
b/packages/web-util/src/hooks/index.ts
index a3a2053e6..c29de9023 100644
--- a/packages/web-util/src/hooks/index.ts
+++ b/packages/web-util/src/hooks/index.ts
@@ -5,6 +5,9 @@ export {
   useNotifications,
   notifyError,
   notifyInfo,
+  notify,
+  ErrorNotification,
+  InfoNotification
 } from "./useNotifications.js";
 export {
   useAsyncAsHook,
diff --git a/packages/web-util/src/hooks/useNotifications.ts 
b/packages/web-util/src/hooks/useNotifications.ts
index 733950592..52e626b38 100644
--- a/packages/web-util/src/hooks/useNotifications.ts
+++ b/packages/web-util/src/hooks/useNotifications.ts
@@ -4,13 +4,13 @@ import { memoryMap } from "../index.browser.js";
 
 export type NotificationMessage = ErrorNotification | InfoNotification;
 
-interface ErrorNotification {
+export interface ErrorNotification {
   type: "error";
   title: TranslatedString;
   description?: TranslatedString;
   debug?: string;
 }
-interface InfoNotification {
+export interface InfoNotification {
   type: "info";
   title: TranslatedString;
 }
@@ -18,30 +18,29 @@ interface InfoNotification {
 const storage = memoryMap<Map<string, NotificationMessage>>();
 const NOTIFICATION_KEY = "notification";
 
+export function notify(notif: NotificationMessage): void {
+  const currentState: Map<string, NotificationMessage> =
+    storage.get(NOTIFICATION_KEY) ?? new Map();
+  const newState = currentState.set(hash(notif), notif);
+  storage.set(NOTIFICATION_KEY, newState);
+}
 export function notifyError(
   title: TranslatedString,
   description: TranslatedString | undefined,
   debug?: any,
 ) {
-  const currentState: Map<string, NotificationMessage> =
-    storage.get(NOTIFICATION_KEY) ?? new Map();
-
-  const notif = {
+  notify({
     type: "error" as const,
     title,
     description,
     debug,
-  };
-  const newState = currentState.set(hash(notif), notif);
-  storage.set(NOTIFICATION_KEY, newState);
+  });
 }
 export function notifyInfo(title: TranslatedString) {
-  const currentState: Map<string, NotificationMessage> =
-    storage.get(NOTIFICATION_KEY) ?? new Map();
-
-  const notif = { type: "info" as const, title };
-  const newState = currentState.set(hash(notif), notif);
-  storage.set(NOTIFICATION_KEY, newState);
+  notify({
+    type: "info" as const,
+    title,
+  });
 }
 
 type Notification = {
diff --git a/packages/web-util/src/index.browser.ts 
b/packages/web-util/src/index.browser.ts
index 2a537b405..82c399bfd 100644
--- a/packages/web-util/src/index.browser.ts
+++ b/packages/web-util/src/index.browser.ts
@@ -5,4 +5,5 @@ export * from "./utils/http-impl.sw.js";
 export * from "./utils/observable.js";
 export * from "./context/index.js";
 export * from "./components/index.js";
+export * from "./forms/index.js";
 export { renderStories, parseGroupImport } from "./stories.js";

-- 
To stop receiving notification emails like this one, please contact
gnunet@gnunet.org.



reply via email to

[Prev in Thread] Current Thread [Next in Thread]