Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Start to implement TypeScript types for <Form> components #3314

Merged
merged 5 commits into from
Dec 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading