diff --git a/src/Form/FormGroup.jsx b/src/Form/FormGroup.tsx similarity index 58% rename from src/Form/FormGroup.jsx rename to src/Form/FormGroup.tsx index 8dc0ff2632..87c2403f14 100644 --- a/src/Form/FormGroup.jsx +++ b/src/Form/FormGroup.tsx @@ -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 { + /** 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({ children, controlId, - isInvalid, - isValid, + isInvalid = false, + isValid = false, size, as, ...props -}) { +}: Props & React.ComponentPropsWithoutRef) { return React.createElement( - as, + as ?? 'div', { ...props, className: classNames('pgn__form-group', props.className), @@ -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; diff --git a/src/Form/FormGroupContext.jsx b/src/Form/FormGroupContext.tsx similarity index 70% rename from src/Form/FormGroupContext.jsx rename to src/Form/FormGroupContext.tsx index 002cc710f3..bbe7051e89 100644 --- a/src/Form/FormGroupContext.jsx +++ b/src/Form/FormGroupContext.tsx @@ -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) => props; const noop = () => {}; -const FormGroupContext = React.createContext({ +interface FormGroupContextData { + getControlProps: (props: Record) => Record; + getLabelProps: (props: React.ComponentPropsWithoutRef<'label'>) => React.ComponentPropsWithoutRef<'label'>; + getDescriptorProps: (props: Record) => Record; + useSetIsControlGroupEffect: (isControlGroup: boolean) => void; + isControlGroup?: boolean; + controlId?: string; + isInvalid?: boolean; + isValid?: boolean; + size?: string; + hasFormGroupProvider?: boolean; +} + +const FormGroupContext = React.createContext({ getControlProps: identityFn, useSetIsControlGroupEffect: noop, getLabelProps: identityFn, @@ -20,13 +32,15 @@ const FormGroupContext = React.createContext({ const useFormGroupContext = () => React.useContext(FormGroupContext); -const useStateEffect = (initialState) => { +function useStateEffect( + 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, @@ -34,6 +48,12 @@ function FormGroupContextProvider({ 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); @@ -62,7 +82,7 @@ function FormGroupContextProvider({ controlId, ]); - const getLabelProps = (labelProps) => { + const getLabelProps = (labelProps: React.ComponentPropsWithoutRef<'label'>) => { const id = registerLabelerId(labelProps?.id); if (isControlGroup) { return { ...labelProps, id }; @@ -70,12 +90,12 @@ function FormGroupContextProvider({ return { ...labelProps, htmlFor: controlId }; }; - const getDescriptorProps = (descriptorProps) => { + const getDescriptorProps = (descriptorProps: Record) => { const id = registerDescriptorId(descriptorProps?.id); return { ...descriptorProps, id }; }; - const contextValue = { + const contextValue: FormGroupContextData = { getControlProps, getLabelProps, getDescriptorProps, @@ -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, diff --git a/src/Form/FormLabel.jsx b/src/Form/FormLabel.tsx similarity index 77% rename from src/Form/FormLabel.jsx rename to src/Form/FormLabel.tsx index 9aa4f3ac7c..dd862456f6 100644 --- a/src/Form/FormLabel.jsx +++ b/src/Form/FormLabel.tsx @@ -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', @@ -20,8 +27,6 @@ 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, @@ -29,14 +34,6 @@ FormLabel.propTypes = { 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; diff --git a/src/Form/constants.js b/src/Form/constants.ts similarity index 58% rename from src/Form/constants.js rename to src/Form/constants.ts index 68abdda931..2de24a3fc9 100644 --- a/src/Form/constants.js +++ b/src/Form/constants.ts @@ -1,10 +1,9 @@ -/* 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', @@ -12,6 +11,4 @@ const FORM_TEXT_TYPES = { CRITERIA_EMPTY: 'criteria-empty', CRITERIA_VALID: 'criteria-valid', CRITERIA_INVALID: 'criteria-invalid', -}; - -export { FORM_CONTROL_SIZES, FORM_TEXT_TYPES }; +} as const; diff --git a/src/Form/fieldUtils.js b/src/Form/fieldUtils.ts similarity index 64% rename from src/Form/fieldUtils.js rename to src/Form/fieldUtils.ts index 4e0b3cc1f3..2e08f2d74f 100644 --- a/src/Form/fieldUtils.js +++ b/src/Form/fieldUtils.ts @@ -8,10 +8,10 @@ const omitUndefinedProperties = (obj = {}) => Object.entries(obj) acc[key] = value; } return acc; - }, {}); + }, {} as Record); -const callAllHandlers = (...handlers) => { - const unifiedEventHandler = (event) => { +const callAllHandlers = (...handlers: ((event: EventType) => void)[]) => { + const unifiedEventHandler = (event: EventType) => { handlers .filter(handler => typeof handler === 'function') .forEach(handler => handler(event)); @@ -19,16 +19,19 @@ const callAllHandlers = (...handlers) => { return unifiedEventHandler; }; -const useHasValue = ({ defaultValue, value }) => { +const useHasValue = ({ 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) => 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; }; @@ -36,17 +39,17 @@ const useIdList = (uniqueIdPrefix, initialList) => { 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]); @@ -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; }; diff --git a/src/Form/index.jsx b/src/Form/index.tsx similarity index 66% rename from src/Form/index.jsx rename to src/Form/index.tsx index 42cd662ff4..b01095cd47 100644 --- a/src/Form/index.jsx +++ b/src/Form/index.tsx @@ -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; diff --git a/src/Form/messages.js b/src/Form/messages.ts similarity index 100% rename from src/Form/messages.js rename to src/Form/messages.ts diff --git a/src/index.d.ts b/src/index.d.ts index 217ee12a89..b72210599c 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -9,6 +9,28 @@ export { default as Button, ButtonGroup, ButtonToolbar } from './Button'; export { default as Chip, CHIP_PGN_CLASS } from './Chip'; export { default as ChipCarousel } from './ChipCarousel'; export { default as Container, ContainerSize } from './Container'; +export { + default as Form, + RadioControl, + CheckboxControl, + SwitchControl, + FormSwitchSet, + FormControl, + FormControlDecoratorGroup, + FormControlFeedback, + FormCheck, + FormFile, + FormRadio, + FormRadioSet, + FormRadioSetContext, + FormGroup, + FormLabel, + useCheckboxSetValues, + FormText, + FormAutosuggest, + FormAutosuggestOption, + InputGroup, +} from './Form'; export { default as Hyperlink, HYPER_LINK_EXTERNAL_LINK_ALT_TEXT, HYPER_LINK_EXTERNAL_LINK_TITLE } from './Hyperlink'; export { default as Icon } from './Icon'; export { default as IconButton, IconButtonWithTooltip } from './IconButton'; @@ -61,28 +83,6 @@ export const export const Fade: any; // from './Fade'; /** @deprecated */ export const Fieldset: any; // from './Fieldset'; -export const - Form: any, - RadioControl: any, - CheckboxControl: any, - SwitchControl: any, - FormSwitchSet: any, - FormControl: any, - FormControlDecoratorGroup: any, - FormControlFeedback: any, - FormCheck: any, - FormFile: any, - FormRadio: any, - FormRadioSet: any, - FormRadioSetContext: any, - FormGroup: any, - FormLabel: any, - useCheckboxSetValues: any, - FormText: any, - FormAutosuggest: any, - FormAutosuggestOption: any, - InputGroup: any; -// from './Form'; export const IconButtonToggle: any; // from './IconButtonToggle'; /** @deprecated Replaced by `Form.Control`. */ export const Input: any; // from './Input'; diff --git a/src/index.js b/src/index.js index 59b0b28cd2..f9d846ad87 100644 --- a/src/index.js +++ b/src/index.js @@ -9,6 +9,28 @@ export { default as Button, ButtonGroup, ButtonToolbar } from './Button'; export { default as Chip, CHIP_PGN_CLASS } from './Chip'; export { default as ChipCarousel } from './ChipCarousel'; export { default as Container } from './Container'; +export { + default as Form, + RadioControl, + CheckboxControl, + SwitchControl, + FormSwitchSet, + FormControl, + FormControlDecoratorGroup, + FormControlFeedback, + FormCheck, + FormFile, + FormRadio, + FormRadioSet, + FormRadioSetContext, + FormGroup, + FormLabel, + useCheckboxSetValues, + FormText, + FormAutosuggest, + FormAutosuggestOption, + InputGroup, +} from './Form'; export { default as Hyperlink, HYPER_LINK_EXTERNAL_LINK_ALT_TEXT, HYPER_LINK_EXTERNAL_LINK_TITLE } from './Hyperlink'; export { default as Icon } from './Icon'; export { default as IconButton, IconButtonWithTooltip } from './IconButton'; @@ -61,28 +83,6 @@ export { export { default as Fade } from './Fade'; /** @deprecated */ export { default as Fieldset } from './Fieldset'; -export { - default as Form, - RadioControl, - CheckboxControl, - SwitchControl, - FormSwitchSet, - FormControl, - FormControlDecoratorGroup, - FormControlFeedback, - FormCheck, - FormFile, - FormRadio, - FormRadioSet, - FormRadioSetContext, - FormGroup, - FormLabel, - useCheckboxSetValues, - FormText, - FormAutosuggest, - FormAutosuggestOption, - InputGroup, -} from './Form'; export { default as IconButtonToggle } from './IconButtonToggle'; /** @deprecated Replaced by `Form.Control`. */ export { default as Input } from './Input'; diff --git a/src/utils/index.js b/src/utils/index.ts similarity index 100% rename from src/utils/index.js rename to src/utils/index.ts diff --git a/src/utils/newId.js b/src/utils/newId.ts similarity index 100% rename from src/utils/newId.js rename to src/utils/newId.ts