diff --git a/ivette/src/dome/src/renderer/layout/form.tsx b/ivette/src/dome/src/renderer/layout/form.tsx index 0a2074c798657e2e0990c66bd6082d79315ba1d8..68c4d4062eb3d441af85bdf20dc44b89a641e0d8 100644 --- a/ivette/src/dome/src/renderer/layout/form.tsx +++ b/ivette/src/dome/src/renderer/layout/form.tsx @@ -102,6 +102,23 @@ export function useDefault<A>( return [value ?? defaultValue, error, setState]; } +export function useRequired<A>( + state: FieldState<A>, +): FieldState<A | undefined> { + const [value, error, setState] = state; + const cache = React.useRef(value); + const update = React.useCallback( + (newValue: A | undefined, newError: Error) => { + if (newValue === undefined) { + setState(cache.current, newError || 'Required field'); + } else { + setState(newValue, newError); + } + }, [cache, setState], + ); + return [value, error, update]; +} + export function useChecker<A>( state: FieldState<A>, checker?: Checker<A>, @@ -114,6 +131,37 @@ export function useChecker<A>( return [value, error, update]; } +export function useFilter<A, B>( + state: FieldState<A>, + input: (value: A) => B, + output: (value: B) => A, + defaultValue: B, +): FieldState<B> { + const [value, error, setState] = state; + const cacheA = React.useRef<A>(value); + const cacheB = React.useRef<B>(defaultValue); + const update = React.useCallback( + (newValue: B, newError: Error) => { + try { + const outValue = output(newValue); + setState(outValue, newError); + } catch (outErr) { + const outError = newError || outErr.toString() || 'Invalid value'; + setState(cacheA.current, outError); + } + }, [cacheA, output, setState], + ); + cacheA.current = value; + try { + const inValue = input(value); + cacheB.current = inValue; + return [inValue, error, update]; + } catch (inErr) { + const inError = error || inErr.toString() || 'Invalid value'; + return [cacheB.current, inError, update]; + } +} + export function useProperty<A, K extends keyof A>( state: FieldState<A>, property: K, @@ -448,19 +496,24 @@ export interface FieldProps<A> extends FilterProps { onError?: string; } -type InputEvent = { target: { value: string } }; -type InputState = [string, Error, (evt: InputEvent) => void]; +type InputEvent<A> = { target: { value: A } }; +type InputState<A> = [string, Error, (evt: InputEvent<A>) => void]; + +function useChangeEvent<A>(setState: Callback<A>) { + return React.useCallback( + (evt: InputEvent<A>) => { setState(evt.target.value, undefined); }, + [setState], + ); +} function useTextInputField( props: FieldTextProps, defaultLatency: number, -): InputState { +): InputState<string> { 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); - }; + const onChange = useChangeEvent(setState); return [value || '', error, onChange]; } @@ -629,4 +682,67 @@ export const FieldCodeArea = (props: FieldTextAreaProps) => { ); }; +/* --------------------------------------------------------------------------*/ +/* --- Number Field ---*/ +/* --------------------------------------------------------------------------*/ + +export interface FieldNumberProps extends FieldProps<number | undefined> { + units?: string; + placeholder?: string; + className?: string; + style?: React.CSSProperties; + latency?: number; +} + +function TEXT_OF_NUMBER(n: number | undefined): string { + if (n === undefined) return ''; + if (Number.isNaN(n)) throw new Error('Invalid number'); + return Number(n).toLocaleString('en'); +} + +function NUMBER_OF_TEXT(s: string): number | undefined { + if (s === '') return undefined; + const n = Number.parseFloat(s.replace(/[ ,]/g, '')); + if (Number.isNaN(n)) throw new Error('Invalid number'); + return n; +} + +/** + Text Field. + @category Form Fields + */ +export const FieldNumber = (props: FieldNumberProps) => { + const { units, latency = 600 } = props; + const { disabled } = useContext(props); + const id = useHtmlFor(); + const css = Utils.classes('dome-xForm-number-field', props.className); + const checked = useChecker(props.state, props.checker); + const filtered = useFilter(checked, TEXT_OF_NUMBER, NUMBER_OF_TEXT, ''); + const [value, error, setState] = useLatency(filtered, latency); + const onChange = useChangeEvent(setState); + const UNITS = units && ( + <label className="dome-text-label dome-xForm-units">{units}</label> + ); + 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} + /> + {UNITS} + </Field> + ); +}; + // --------------------------------------------------------------------------