Skip to content

Commit

Permalink
refactor: start to implement TypeScript types for <Form> components (#…
Browse files Browse the repository at this point in the history
…3314)

* refactor: convert FormGroupContext to TypeScript
* refactor: convert <FormGroup> to TypeScript
* refactor: convert <FormLabel> to TypeScript
* refactor: convert <Form> to TypeScript
* fix: add missing <Form.Row>
  • Loading branch information
bradenmacdonald authored Dec 9, 2024
1 parent 9526409 commit 673ce70
Show file tree
Hide file tree
Showing 11 changed files with 156 additions and 115 deletions.
38 changes: 24 additions & 14 deletions src/Form/FormGroup.jsx → src/Form/FormGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,37 @@ import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { FormGroupContextProvider } from './FormGroupContext';
import { FORM_CONTROL_SIZES } from './constants';

function FormGroup({
interface Props<As extends React.ElementType> {
/** Specifies contents of the component. */
children: React.ReactNode;
/** Specifies class name to append to the base element. */
className?: string;
/** Specifies base element for the component. */
as?: As;
/** Specifies id to use in the group, it will be used as `htmlFor` in `FormLabel` and as `id` in input components.
* Will be autogenerated if none is supplied. */
controlId?: string;
/** Specifies whether to display components in invalid state, this affects styling. */
isInvalid?: boolean;
/** Specifies whether to display components in valid state, this affects styling. */
isValid?: boolean;
/** Specifies size for the component. */
size?: typeof FORM_CONTROL_SIZES.SMALL | typeof FORM_CONTROL_SIZES.LARGE;
}

function FormGroup<As extends React.ElementType = 'div'>({
children,
controlId,
isInvalid,
isValid,
isInvalid = false,
isValid = false,
size,
as,
...props
}) {
}: Props<As> & React.ComponentPropsWithoutRef<As>) {
return React.createElement(
as,
as ?? 'div',
{
...props,
className: classNames('pgn__form-group', props.className),
Expand Down Expand Up @@ -50,13 +69,4 @@ FormGroup.propTypes = {
size: PropTypes.oneOf(SIZE_CHOICES),
};

FormGroup.defaultProps = {
as: 'div',
className: undefined,
controlId: undefined,
isInvalid: false,
isValid: false,
size: undefined,
};

export default FormGroup;
56 changes: 29 additions & 27 deletions src/Form/FormGroupContext.jsx → src/Form/FormGroupContext.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
import React, {
useState, useEffect, useMemo, useCallback,
} from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { newId } from '../utils';
import { useIdList, omitUndefinedProperties } from './fieldUtils';
import { FORM_CONTROL_SIZES } from './constants';

const identityFn = props => props;
const identityFn = (props: Record<string, any>) => props;
const noop = () => {};

const FormGroupContext = React.createContext({
interface FormGroupContextData {
getControlProps: (props: Record<string, any>) => Record<string, any>;
getLabelProps: (props: React.ComponentPropsWithoutRef<'label'>) => React.ComponentPropsWithoutRef<'label'>;
getDescriptorProps: (props: Record<string, any>) => Record<string, any>;
useSetIsControlGroupEffect: (isControlGroup: boolean) => void;
isControlGroup?: boolean;
controlId?: string;
isInvalid?: boolean;
isValid?: boolean;
size?: string;
hasFormGroupProvider?: boolean;
}

const FormGroupContext = React.createContext<FormGroupContextData>({
getControlProps: identityFn,
useSetIsControlGroupEffect: noop,
getLabelProps: identityFn,
Expand All @@ -20,20 +32,28 @@ const FormGroupContext = React.createContext({

const useFormGroupContext = () => React.useContext(FormGroupContext);

const useStateEffect = (initialState) => {
function useStateEffect<ValueType extends any>(
initialState: ValueType,
): [value: ValueType, setter: (v: ValueType) => void] {
const [state, setState] = useState(initialState);
const useSetStateEffect = (newState) => {
const useSetStateEffect = (newState: ValueType) => {
useEffect(() => setState(newState), [newState]);
};
return [state, useSetStateEffect];
};
}

function FormGroupContextProvider({
children,
controlId: explicitControlId,
isInvalid,
isValid,
size,
}: {
children: React.ReactNode;
controlId?: string;
isInvalid?: boolean;
isValid?: boolean;
size?: typeof FORM_CONTROL_SIZES.SMALL | typeof FORM_CONTROL_SIZES.LARGE;
}) {
const controlId = useMemo(() => explicitControlId || newId('form-field'), [explicitControlId]);
const [describedByIds, registerDescriptorId] = useIdList(controlId);
Expand Down Expand Up @@ -62,20 +82,20 @@ function FormGroupContextProvider({
controlId,
]);

const getLabelProps = (labelProps) => {
const getLabelProps = (labelProps: React.ComponentPropsWithoutRef<'label'>) => {
const id = registerLabelerId(labelProps?.id);
if (isControlGroup) {
return { ...labelProps, id };
}
return { ...labelProps, htmlFor: controlId };
};

const getDescriptorProps = (descriptorProps) => {
const getDescriptorProps = (descriptorProps: Record<string, any>) => {
const id = registerDescriptorId(descriptorProps?.id);
return { ...descriptorProps, id };
};

const contextValue = {
const contextValue: FormGroupContextData = {
getControlProps,
getLabelProps,
getDescriptorProps,
Expand All @@ -95,24 +115,6 @@ function FormGroupContextProvider({
);
}

FormGroupContextProvider.propTypes = {
children: PropTypes.node.isRequired,
controlId: PropTypes.string,
isInvalid: PropTypes.bool,
isValid: PropTypes.bool,
size: PropTypes.oneOf([
FORM_CONTROL_SIZES.SMALL,
FORM_CONTROL_SIZES.LARGE,
]),
};

FormGroupContextProvider.defaultProps = {
controlId: undefined,
isInvalid: undefined,
isValid: undefined,
size: undefined,
};

export {
FormGroupContext,
FormGroupContextProvider,
Expand Down
19 changes: 8 additions & 11 deletions src/Form/FormLabel.jsx → src/Form/FormLabel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ import classNames from 'classnames';
import { useFormGroupContext } from './FormGroupContext';
import { FORM_CONTROL_SIZES } from './constants';

function FormLabel({ children, isInline, ...props }) {
interface Props {
/** Specifies contents of the component. */
children: React.ReactNode;
/** Specifies whether the component should be displayed with inline styling. */
isInline?: boolean;
}

function FormLabel({ children, isInline = false, ...props }: Props & React.ComponentPropsWithoutRef<'label'>) {
const { size, isControlGroup, getLabelProps } = useFormGroupContext();
const className = classNames(
'pgn__form-label',
Expand All @@ -20,23 +27,13 @@ function FormLabel({ children, isInline, ...props }) {
return React.createElement(componentType, labelProps, children);
}

const SIZE_CHOICES = ['sm', 'lg'];

FormLabel.propTypes = {
/** Specifies class name to append to the base element. */
className: PropTypes.string,
/** Specifies contents of the component. */
children: PropTypes.node.isRequired,
/** Specifies whether the component should be displayed with inline styling. */
isInline: PropTypes.bool,
/** Specifies size of the component. */
size: PropTypes.oneOf(SIZE_CHOICES),
};

FormLabel.defaultProps = {
isInline: false,
size: undefined,
className: undefined,
};

export default FormLabel;
11 changes: 4 additions & 7 deletions src/Form/constants.js → src/Form/constants.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
/* eslint-disable import/prefer-default-export */
const FORM_CONTROL_SIZES = {
export const FORM_CONTROL_SIZES = {
SMALL: 'sm',
LARGE: 'lg',
};
} as const;

const FORM_TEXT_TYPES = {
export const FORM_TEXT_TYPES = {
DEFAULT: 'default',
VALID: 'valid',
INVALID: 'invalid',
WARNING: 'warning',
CRITERIA_EMPTY: 'criteria-empty',
CRITERIA_VALID: 'criteria-valid',
CRITERIA_INVALID: 'criteria-invalid',
};

export { FORM_CONTROL_SIZES, FORM_TEXT_TYPES };
} as const;
25 changes: 14 additions & 11 deletions src/Form/fieldUtils.js → src/Form/fieldUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,45 +8,48 @@ const omitUndefinedProperties = (obj = {}) => Object.entries(obj)
acc[key] = value;
}
return acc;
}, {});
}, {} as Record<string, any>);

const callAllHandlers = (...handlers) => {
const unifiedEventHandler = (event) => {
const callAllHandlers = <EventType extends Object>(...handlers: ((event: EventType) => void)[]) => {
const unifiedEventHandler = (event: EventType) => {
handlers
.filter(handler => typeof handler === 'function')
.forEach(handler => handler(event));
};
return unifiedEventHandler;
};

const useHasValue = ({ defaultValue, value }) => {
const useHasValue = <ValueType>({ defaultValue, value }: { defaultValue?: ValueType, value?: ValueType }) => {
const [hasUncontrolledValue, setHasUncontrolledValue] = useState(!!defaultValue || defaultValue === 0);
const hasValue = !!value || value === 0 || hasUncontrolledValue;
const handleInputEvent = (e) => setHasUncontrolledValue(e.target.value);
const handleInputEvent = (e: React.ChangeEvent<HTMLInputElement>) => setHasUncontrolledValue(!!e.target.value);
return [hasValue, handleInputEvent];
};

const useIdList = (uniqueIdPrefix, initialList) => {
const useIdList = (
uniqueIdPrefix: string,
initialList?: string[],
): [idList: string[], useRegisteredId: (id: string | undefined) => string | undefined] => {
const [idList, setIdList] = useState(initialList || []);
const addId = (idToAdd) => {
const addId = (idToAdd: string) => {
setIdList(oldIdList => [...oldIdList, idToAdd]);
return idToAdd;
};
const getNewId = () => {
const idToAdd = newId(`${uniqueIdPrefix}-`);
return addId(idToAdd);
};
const removeId = (idToRemove) => {
const removeId = (idToRemove: string | undefined) => {
setIdList(oldIdList => oldIdList.filter(id => id !== idToRemove));
};

const useRegisteredId = (explicitlyRegisteredId) => {
const useRegisteredId = (explicitlyRegisteredId: string | undefined) => {
const [registeredId, setRegisteredId] = useState(explicitlyRegisteredId);
useEffect(() => {
if (explicitlyRegisteredId) {
addId(explicitlyRegisteredId);
} else if (!registeredId) {
setRegisteredId(getNewId(uniqueIdPrefix));
setRegisteredId(getNewId());
}
return () => removeId(registeredId);
}, [registeredId, explicitlyRegisteredId]);
Expand All @@ -56,7 +59,7 @@ const useIdList = (uniqueIdPrefix, initialList) => {
return [idList, useRegisteredId];
};

const mergeAttributeValues = (...values) => {
const mergeAttributeValues = (...values: (string | undefined)[]) => {
const mergedValues = classNames(values);
return mergedValues || undefined;
};
Expand Down
34 changes: 33 additions & 1 deletion src/Form/index.jsx → src/Form/index.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,54 @@
import Form from 'react-bootstrap/Form';
import BootstrapForm, { FormProps } from 'react-bootstrap/Form';
import { ComponentWithAsProp } from '../utils/types/bootstrap';
// TODO: add more typing and remove the @ts-ignore directives here
// @ts-ignore
import FormControl from './FormControl';
import FormLabel from './FormLabel';
import FormGroup from './FormGroup';
// @ts-ignore
import FormControlFeedback from './FormControlFeedback';
// @ts-ignore
import FormText from './FormText';
// @ts-ignore
import FormControlDecoratorGroup from './FormControlDecoratorGroup';
// @ts-ignore
import FormRadio, { RadioControl } from './FormRadio';
// @ts-ignore
import FormRadioSet from './FormRadioSet';
// @ts-ignore
import FormRadioSetContext from './FormRadioSetContext';
// @ts-ignore
import FormAutosuggest from './FormAutosuggest';
// @ts-ignore
import FormAutosuggestOption from './FormAutosuggestOption';
// @ts-ignore
import FormCheckbox, { CheckboxControl } from './FormCheckbox';
// @ts-ignore
import FormSwitch, { SwitchControl } from './FormSwitch';
// @ts-ignore
import FormCheckboxSet from './FormCheckboxSet';
// @ts-ignore
import FormSwitchSet from './FormSwitchSet';
// @ts-ignore
import FormCheckboxSetContext from './FormCheckboxSetContext';
// @ts-ignore
import useCheckboxSetValues from './useCheckboxSetValues';

const Form = BootstrapForm as any as ComponentWithAsProp<'form', FormProps> & {
Control: typeof FormControl;
Radio: typeof FormRadio;
RadioSet: typeof FormRadioSet;
Autosuggest: typeof FormAutosuggest;
AutosuggestOption: typeof FormAutosuggestOption;
Checkbox: typeof FormCheckbox;
CheckboxSet: typeof FormCheckboxSet;
Row: typeof BootstrapForm.Row;
Switch: typeof FormSwitch;
SwitchSet: typeof FormSwitchSet;
Label: typeof FormLabel;
Group: typeof FormGroup;
Text: typeof FormText;
};
Form.Control = FormControl;
Form.Radio = FormRadio;
Form.RadioSet = FormRadioSet;
Expand Down
File renamed without changes.
Loading

0 comments on commit 673ce70

Please sign in to comment.