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