/** * Some notes for InputGroup: * This is one of the most complicated components in this project. * Probably because the feature is complex and involved user inputs. * * I hope it contains following features: * - Input features * - Supports a wide range of input types. * - Validator to validate user inputs. * - Can set initial values. * - Dirty, aka, has user touched this input. * - Developer friendly * - Easy to use APIs. * - Type check as much as possible. * - UI * - Configurable appearance. * - Can display helper and error messages. * - Easy to extend, like new input types. * * So here is some design decisions: * Inputs are identified by its _key_. * `InputGroup` component takes care of only UI and no logic. * `useInputs` hook takes care of logic and generate props for `InputGroup`. */ import { useState, Ref } from "react"; import classNames from "classnames"; import { useC, Text, ThemeColor } from "../common"; import "./InputGroup.css"; export interface InputBase { key: string; label: Text; helper?: Text; disabled?: boolean; error?: Text; } export interface TextInput extends InputBase { type: "text"; value: string; password?: boolean; } export interface BoolInput extends InputBase { type: "bool"; value: boolean; } export interface SelectInputOption { value: string; label: Text; icon?: string; } export interface SelectInput extends InputBase { type: "select"; value: string; options: SelectInputOption[]; } export type Input = TextInput | BoolInput | SelectInput; interface InputInitialValueMap { text: string; bool: boolean; select: string; } type Dirties = { [Index in keyof Inputs]: boolean; }; type ExtendInputForComponent = I & {}; type ExtendInputsForComponent = { [Index in keyof Inputs]: ExtendInputForComponent; }; type InitialValueTransformer = ( input: I, value: InputInitialValueMap[I["type"]] | null | undefined, ) => InputValueMap[I["type"]]; type InitialValueTransformers = { [I in Input as I["type"]]: InitialValueTransformer; }; const defaultInitialValueTransformer: InitialValueTransformers = { text: (input, value) => value ?? "", bool: (input, value) => value ?? false, select: (input, value) => value ?? input.options[0].value, }; export type InputErrors = { index: number; message: Text; }[]; export interface InputGroupProps { color?: ThemeColor; containerClassName?: string; containerRef?: Ref; inputs: ExtendInputsForComponent; onChange: ( index: Index, value: InputValueMap[Inputs[Index]["type"]], ) => void; } export type ExtendInputForHook = I & { initialValue?: InputInitialValueMap[I["type"]] | null; }; export type ExtendInputsForHook = { [Index in keyof Inputs]: ExtendInputForHook; }; export type Validator = ( values: { [Index in keyof Inputs]: InputValueMap[Inputs[Index]["type"]] }, inputs: Inputs, ) => InputErrors; export function useInputs( inputs: ExtendInputsForHook, options: { validator?: Validator; disabled?: boolean; }, ): { inputGroupProps: ExtendInputsForComponent; confirm: (values: Values) => void; } { const { validator, disabled } = options; const [values, setValues] = useState>(() => inputs.map((input) => defaultInitialValueTransformer[input.type](input, input.initialValue), ), ); const [errors, setErrors] = useState([]); const [dirties, setDirties] = useState>(); const componentInputs: ExtendInputForComponent[] = []; for (let i = 0; i < inputs.length; i++) { const input = { ...inputs[i] }; delete input.initialValue; // No use. const error = dirties[i] ? errors.find((e) => e.index === i)?.message : undefined; const componentInput: ExtendInputForComponent = { ...input, value: values[i], disabled, error, }; componentInputs.push(componentInput); } const dirtyAll = () => { if (dirties != null) { setDirties(new Array(dirties.length).fill(true) as Dirties); } }; return { inputGroupProps: {}, }; } export function InputGroup({ color, inputs, onChange, containerRef, containerClassName, }: InputGroupProps>) { const c = useC(); return (
{inputs.map((item, index) => { const { type, value, label, error, helper, disabled } = item; const getContainerClassName = ( ...additionalClassNames: classNames.ArgumentArray ) => classNames( `cru-input-container cru-input-${type}`, error && "error", ...additionalClassNames, ); const changeValue = (value: InputValueMap[keyof InputValueMap]) => { // `map` makes every type info lost, so we let ts do _not_ do type check here. onChange(index, value as never); }; if (type === "text") { const { password } = item; return (
{label && } { const v = event.target.value; changeValue(v); }} disabled={disabled} /> {error &&
{c(error)}
} {helper &&
{c(helper)}
}
); } else if (type === "bool") { return (
{ const v = event.currentTarget.checked; changeValue(v); }} disabled={disabled} /> {error &&
{c(error)}
} {helper &&
{c(helper)}
}
); } else if (type === "select") { return (
); } })}
); }