From 1f785b8d50523f6b77521ce5395ba67899586d0c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Loi=CC=88c=20Correnson?= <loic.correnson@cea.fr>
Date: Mon, 7 Sep 2020 14:22:48 +0200
Subject: [PATCH] [dome] text fields

---
 ivette/src/dome/src/renderer/layout/form.tsx | 256 +++++++++++++++++--
 1 file changed, 237 insertions(+), 19 deletions(-)

diff --git a/ivette/src/dome/src/renderer/layout/form.tsx b/ivette/src/dome/src/renderer/layout/form.tsx
index 49be971a940..0a2074c7986 100644
--- a/ivette/src/dome/src/renderer/layout/form.tsx
+++ b/ivette/src/dome/src/renderer/layout/form.tsx
@@ -49,7 +49,7 @@ export function validate<A>(
 
 export function isValid(err: Error): boolean { return !err; }
 
-type ObjectError = { [key: string]: Error }
+type ObjectError = { [key: string]: Error };
 
 function isObjectError(err: Error): err is ObjectError {
   return typeof err === 'object' && !Array.isArray(err);
@@ -86,14 +86,22 @@ export function useState<A>(
   const [value, setValue] = React.useState<A>(defaultValue);
   const [error, setError] = React.useState<Error>(undefined);
   const setState = React.useCallback((newValue: A, newError: Error) => {
-    const localError = validate(value, checker) || newError;
+    const localError = validate(newValue, checker) || newError;
     setValue(newValue);
     setError(localError);
     if (onChange) onChange(newValue, localError);
-  }, [setValue, setError, onChange]);
+  }, [checker, setValue, setError, onChange]);
   return [value, error, setState];
 }
 
+export function useDefault<A>(
+  state: FieldState<A | undefined>,
+  defaultValue: A,
+): FieldState<A> {
+  const [value, error, setState] = state;
+  return [value ?? defaultValue, error, setState];
+}
+
 export function useChecker<A>(
   state: FieldState<A>,
   checker?: Checker<A>,
@@ -102,7 +110,7 @@ export function useChecker<A>(
   const update = React.useCallback((newValue: A, newError: Error) => {
     const localError = validate(newValue, checker) || newError;
     setState(newValue, localError);
-  }, [setState]);
+  }, [checker, setState]);
   return [value, error, update];
 }
 
@@ -110,7 +118,6 @@ export function useProperty<A, K extends keyof A>(
   state: FieldState<A>,
   property: K,
   checker?: Checker<A[K]>,
-  onError?: string,
 ): FieldState<A[K]> {
   const [value, error, setState] = state;
   const update = React.useCallback((newProp: A[K], newError: Error) => {
@@ -119,7 +126,7 @@ export function useProperty<A, K extends keyof A>(
     const propError = validate(newProp, checker) || newError;
     const localError = { ...objError, [property]: propError };
     setState(newValue, isValidObject(localError) ? undefined : localError);
-  }, [value, error, setState, property, checker, onError]);
+  }, [value, error, setState, property, checker]);
   return [value[property], error, update];
 }
 
@@ -128,12 +135,12 @@ export function useLatency<A>(
   latency?: number,
 ): FieldState<A> {
   const [initValue, initError, setState] = state;
-  const period = Math.max(latency ?? 0, 0);
+  const period = latency ?? 0;
   const [value, setValue] = React.useState(initValue);
   const [error, setError] = React.useState(initError);
-  const propagate = React.useCallback(
-    debounce(setState, period),
-    [latency, setState],
+  const propagate = React.useMemo(
+    () => (period > 0 ? debounce(setState, period) : setState),
+    [period, setState],
   );
   const update = React.useCallback((newValue, newError) => {
     setValue(newValue);
@@ -147,7 +154,6 @@ export function useIndex<A>(
   state: FieldState<A[]>,
   index: number,
   checker?: Checker<A>,
-  onError?: string,
 ): FieldState<A> {
   const [array, error, setState] = state;
   const update = React.useCallback((newValue: A, newError: Error) => {
@@ -157,7 +163,7 @@ export function useIndex<A>(
     const valueError = validate(newValue, checker) || newError;
     localError[index] = valueError;
     setState(newArray, isValidArray(localError) ? undefined : localError);
-  }, [array, error, setState, index, checker, onError]);
+  }, [array, error, setState, index, checker]);
   const itemError = isArrayError(error) ? error[index] : undefined;
   return [array[index], itemError, update];
 }
@@ -284,9 +290,7 @@ export function Warning(props: WarningProps) {
 // --------------------------------------------------------------------------
 
 /**
-   Layout its contents inside a full-width block.
-   The children are _not_ supposed to contain `<Field />` like elements,
-   only custom controls that fits a full-width containter.
+   Layout its contents inside a full-width container.
    @category Form Containers
  */
 export function Block(props: FilterProps & Children) {
@@ -304,6 +308,7 @@ export function Block(props: FilterProps & Children) {
 // --- Section Container
 // --------------------------------------------------------------------------
 
+/** @category Form Fields */
 export interface SectionProps extends FilterProps, Children {
   /** Section name. */
   label: string;
@@ -319,7 +324,7 @@ export interface SectionProps extends FilterProps, Children {
   unfold?: boolean;
 }
 
-/** Form Section. */
+/** @category Form Fields */
 export function Section(props: SectionProps) {
   const { label, title, children, warning, error, ...filter } = props;
   const { disabled, hidden } = useContext(filter);
@@ -352,10 +357,11 @@ export function Section(props: SectionProps) {
 }
 
 /* --------------------------------------------------------------------------*/
-/* --- Value Filter                                                      --- */
+/* --- Generic Field                                                     --- */
 /* --------------------------------------------------------------------------*/
 
-export interface FieldProps extends FilterProps, Children {
+/** @category Form Fields */
+export interface GenericFieldProps extends FilterProps, Children {
   /** Field label. */
   label: string;
   /** Field tooltip text. */
@@ -364,10 +370,15 @@ export interface FieldProps extends FilterProps, Children {
   offset?: number;
   /** Html tag `<input />` element. */
   htmlFor?: string;
+  /** Warning message (in case of error). */
+  onError?: string;
+  /** Error (if any). */
+  error?: Error;
 }
 
 let FIELDID = 0;
 
+/** Generates a unique, stable identifier. */
 export function useHtmlFor() {
   return React.useMemo(() => `dome-field ${FIELDID++}`, []);
 }
@@ -375,8 +386,9 @@ export function useHtmlFor() {
 /**
    Generic Field.
    Layout its content in a top-left aligned box on the right of the label.
+   @category Form Fields
  */
-export function Field(props: FieldProps) {
+export function Field(props: GenericFieldProps) {
   const { hidden, disabled } = useContext(props);
 
   if (hidden) return null;
@@ -393,6 +405,12 @@ export function Field(props: FieldProps) {
     disabled && 'dome-disabled',
   );
 
+  const { onError, error } = props;
+
+  const WARNING = error ? (
+    <Warning offset={offset} warning={onError} error={error} />
+  ) : null;
+
   return (
     <>
       <label
@@ -405,10 +423,210 @@ export function Field(props: FieldProps) {
       </label>
       <div className={cssField}>
         {children}
+        {WARNING}
       </div>
     </>
   );
 
 }
 
+/* --------------------------------------------------------------------------*/
+/* --- Input Fields                                                       ---*/
+/* --------------------------------------------------------------------------*/
+
+/** @category Form Fields */
+export interface FieldProps<A> extends FilterProps {
+  /** Field label. */
+  label: string;
+  /** Field tooltip text. */
+  title?: string;
+  /** Field state. */
+  state: FieldState<A>;
+  /** Checker. */
+  checker?: Checker<A>;
+  /** Alternative error message (in case of error). */
+  onError?: string;
+}
+
+type InputEvent = { target: { value: string } };
+type InputState = [string, Error, (evt: InputEvent) => void];
+
+function useTextInputField(
+  props: FieldTextProps,
+  defaultLatency: number,
+): InputState {
+  const checked = useChecker(props.state, props.checker);
+  const period = props.latency ?? defaultLatency;
+  const [value, error, setState] = useLatency(checked, period);
+  const onChange = (evt: InputEvent) => {
+    setState(evt.target.value, undefined);
+  };
+  return [value || '', error, onChange];
+}
+
+/* --------------------------------------------------------------------------*/
+/* --- Text Fields                                                        ---*/
+/* --------------------------------------------------------------------------*/
+
+/** @category Form Fields */
+export interface FieldTextProps extends FieldProps<string | undefined> {
+  placeholder?: string;
+  className?: string;
+  style?: React.CSSProperties;
+  latency?: number;
+}
+
+/**
+   Text Field.
+   @category Form Fields
+ */
+export const FieldText = (props: FieldTextProps) => {
+  const { disabled } = useContext(props);
+  const id = useHtmlFor();
+  const css = Utils.classes('dome-xForm-text-field', props.className);
+  const [value, error, onChange] = useTextInputField(props, 600);
+  return (
+    <Field
+      {...props}
+      offset={4}
+      htmlFor={id}
+      error={error}
+    >
+      <input
+        id={id}
+        type="text"
+        value={value}
+        className={css}
+        style={props.style}
+        disabled={disabled}
+        placeholder={props.placeholder}
+        onChange={onChange}
+      />
+    </Field>
+  );
+};
+
+/**
+   Monospaced Text Field.
+   @category Form Fields
+ */
+export const FieldCode = (props: FieldTextProps) => {
+  const { disabled } = useContext(props);
+  const id = useHtmlFor();
+  const [value, error, onChange] = useTextInputField(props, 600);
+  const css = Utils.classes(
+    'dome-xForm-text-field',
+    'dome-text-code',
+    props.className,
+  );
+  return (
+    <Field
+      {...props}
+      offset={4}
+      htmlFor={id}
+      error={error}
+    >
+      <input
+        id={id}
+        type="text"
+        value={value}
+        className={css}
+        style={props.style}
+        disabled={disabled}
+        placeholder={props.placeholder}
+        onChange={onChange}
+      />
+    </Field>
+  );
+};
+
+/* --------------------------------------------------------------------------*/
+/* --- Text Area Fields                                                   ---*/
+/* --------------------------------------------------------------------------*/
+
+/** @category Form Fields */
+export interface FieldTextAreaProps extends FieldTextProps {
+  /** Number of columns (default 35, min 5). */
+  cols?: number;
+  /** Number of rows (default 5, min 2). */
+  rows?: number;
+}
+
+/**
+   Text Field Area.
+   @category Form Fields
+ */
+export const FieldTextArea = (props: FieldTextAreaProps) => {
+  const { disabled } = useContext(props);
+  const id = useHtmlFor();
+  const [value, error, onChange] = useTextInputField(props, 900);
+  const cols = Math.max(5, props.cols ?? 35);
+  const rows = Math.max(2, props.rows ?? 5);
+  const css = Utils.classes(
+    'dome-xForm-textarea-field',
+    props.className,
+  );
+  return (
+    <Field
+      {...props}
+      offset={4}
+      htmlFor={id}
+      error={error}
+    >
+      <textarea
+        id={id}
+        wrap="hard"
+        spellCheck
+        value={value}
+        cols={cols}
+        rows={rows - 1}
+        className={css}
+        style={props.style}
+        disabled={disabled}
+        placeholder={props.placeholder}
+        onChange={onChange}
+      />
+    </Field>
+  );
+};
+
+/**
+   Monospaced Text Field Area.
+   @category Form Fields
+ */
+export const FieldCodeArea = (props: FieldTextAreaProps) => {
+  const { disabled } = useContext(props);
+  const id = useHtmlFor();
+  const [value, error, onChange] = useTextInputField(props, 900);
+  const cols = Math.max(5, props.cols ?? 35);
+  const rows = Math.max(2, props.rows ?? 5);
+  const css = Utils.classes(
+    'dome-xForm-textarea-field',
+    'dome-text-code',
+    props.className,
+  );
+  return (
+    <Field
+      {...props}
+      offset={4}
+      htmlFor={id}
+      error={error}
+    >
+      <textarea
+        id={id}
+        wrap="off"
+        spellCheck={false}
+        value={value}
+        cols={cols}
+        rows={rows}
+        className={css}
+        style={props.style}
+        disabled={disabled}
+        placeholder={props.placeholder}
+        onChange={onChange}
+      />
+    </Field>
+  );
+};
+
 // --------------------------------------------------------------------------
-- 
GitLab