From f47127b9531d932a5947517b6941669cb16a6ca8 Mon Sep 17 00:00:00 2001 From: Nathan Smith Date: Wed, 20 Mar 2024 11:50:00 +0000 Subject: [PATCH] Convert class components into functional components with hooks. --- bundle-base.tsconfig.json | 2 +- rollup.config.mjs | 13 +- .../content-presentation/details/Details.tsx | 16 +- .../do-and-dont-list/DoAndDontList.tsx | 13 +- .../content-presentation/images/Images.tsx | 4 +- .../inset-text/InsetText.tsx | 4 +- .../summary-list/SummaryList.tsx | 20 +- .../content-presentation/table/Table.tsx | 112 +- .../table/TableHelpers.ts | 4 +- .../table/components/TableBody.tsx | 8 +- .../table/components/TableCaption.tsx | 4 +- .../table/components/TableCell.tsx | 23 +- .../table/components/TableContainer.tsx | 4 +- .../table/components/TableHead.tsx | 8 +- .../table/components/TablePanel.tsx | 4 +- .../table/components/TableRow.tsx | 8 +- .../content-presentation/tabs/Tabs.tsx | 20 +- .../content-presentation/tag/Tag.tsx | 24 +- .../warning-callout/WarningCallout.tsx | 6 +- .../form-elements/button/Button.tsx | 8 +- .../character-count/CharacterCount.tsx | 4 +- .../checkboxes/CheckboxContext.ts | 2 - .../form-elements/checkboxes/Checkboxes.tsx | 144 +-- .../checkboxes/__tests__/Checkboxes.test.tsx | 2 +- .../checkboxes/components/Box.tsx | 11 +- .../checkboxes/components/Divider.tsx | 4 +- .../form-elements/date-input/DateInput.tsx | 211 ++- .../date-input/__tests__/DateInput.test.tsx | 53 +- .../components/IndividualDateInputs.tsx | 10 +- .../error-message/ErrorMessage.tsx | 4 +- .../error-summary/ErrorSummary.tsx | 42 +- .../__tests__/ErrorSummary.test.tsx | 4 +- .../form-elements/fieldset/Fieldset.tsx | 113 +- src/components/form-elements/form/Form.tsx | 4 +- .../form-elements/hint-text/HintText.tsx | 4 +- src/components/form-elements/label/Label.tsx | 6 +- .../form-elements/radios/RadioContext.ts | 4 +- .../form-elements/radios/Radios.tsx | 155 +-- .../radios/components/Divider.tsx | 4 +- .../form-elements/radios/components/Radio.tsx | 20 +- .../form-elements/select/Select.tsx | 8 +- .../select/__tests__/Select.test.tsx | 4 +- .../form-elements/text-input/TextInput.tsx | 8 +- .../text-input/__tests__/TextInput.test.tsx | 4 +- .../form-elements/textarea/Textarea.tsx | 4 +- src/components/hero/Hero.tsx | 14 +- src/components/icons/BaseIcon.tsx | 4 +- src/components/icons/individual/ArrowLeft.tsx | 14 +- .../icons/individual/ArrowRight.tsx | 14 +- .../icons/individual/ArrowRightCircle.tsx | 16 +- .../icons/individual/ChevronDown.tsx | 4 +- .../icons/individual/ChevronLeft.tsx | 14 +- .../icons/individual/ChevronRight.tsx | 14 +- .../icons/individual/ChevronRightCircle.tsx | 4 +- src/components/icons/individual/Close.tsx | 14 +- src/components/icons/individual/Cross.tsx | 28 +- src/components/icons/individual/Emdash.tsx | 14 +- src/components/icons/individual/Minus.tsx | 30 +- src/components/icons/individual/Plus.tsx | 30 +- src/components/icons/individual/Search.tsx | 14 +- .../icons/individual/SmallEmdash.tsx | 14 +- src/components/icons/individual/Tick.tsx | 26 +- src/components/layout/Col.tsx | 4 +- src/components/layout/Container.tsx | 4 +- src/components/layout/Row.tsx | 4 +- .../navigation/action-link/ActionLink.tsx | 6 +- .../navigation/back-link/BackLink.tsx | 6 +- .../navigation/breadcrumb/Breadcrumb.tsx | 12 +- src/components/navigation/card/Card.tsx | 4 +- .../card/components/CardContent.tsx | 4 +- .../card/components/CardDescription.tsx | 4 +- .../navigation/card/components/CardGroup.tsx | 4 +- .../card/components/CardGroupItem.tsx | 4 +- .../card/components/CardHeading.tsx | 6 +- .../navigation/card/components/CardImage.tsx | 4 +- .../navigation/card/components/CardLink.tsx | 6 +- .../navigation/contents-list/ContentsList.tsx | 14 +- src/components/navigation/footer/Footer.tsx | 28 +- src/components/navigation/header/Header.tsx | 151 +-- .../navigation/header/components/Content.tsx | 4 +- .../navigation/header/components/NHSLogo.tsx | 8 +- .../navigation/header/components/Nav.tsx | 4 +- .../header/components/NavDropdownMenu.tsx | 8 +- .../navigation/header/components/NavItem.tsx | 6 +- .../header/components/OrganisationalLogo.tsx | 6 +- .../navigation/header/components/Search.tsx | 4 +- .../components/TransactionalServiceName.tsx | 7 +- .../navigation/pagination/Pagination.tsx | 8 +- .../navigation/skip-link/SkipLink.tsx | 108 +- src/components/typography/BodyText.tsx | 4 +- src/components/typography/LedeText.tsx | 4 +- src/components/utils/Clearfix.tsx | 4 +- src/components/utils/ReadingWidth.tsx | 4 +- src/global.d.ts | 1 + src/patterns/nav-a-z/NavAZ.tsx | 14 +- src/patterns/panel/Panel.tsx | 14 +- src/patterns/review-date/ReviewDate.tsx | 9 +- src/util/HeadingLevel.tsx | 4 +- src/util/LabelBlock.tsx | 8 +- src/util/types/TypeGuards.ts | 4 +- stories/Components/Checkboxes.stories.tsx | 6 +- .../Content Presentation/Table.stories.tsx | 2 +- stories/Form Elements/Checkboxes.stories.tsx | 40 +- stories/Form Elements/Radios.stories.tsx | 36 +- stories/Form Elements/Select.stories.tsx | 6 +- yarn.lock | 1144 +++++++++-------- 106 files changed, 1465 insertions(+), 1669 deletions(-) diff --git a/bundle-base.tsconfig.json b/bundle-base.tsconfig.json index ac896e56..73348d59 100644 --- a/bundle-base.tsconfig.json +++ b/bundle-base.tsconfig.json @@ -20,7 +20,7 @@ "@form-elements/*": ["src/components/form-elements/*"], "@navigation/*": ["src/components/navigation/*"], "@typography/*": ["src/components/typography/*"], - "@utils/*": ["src/utils/*"], + "@util/*": ["src/util/*"], "@patterns/*": ["src/patterns/*"] } }, diff --git a/rollup.config.mjs b/rollup.config.mjs index 5da1703b..d3197c36 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -3,9 +3,10 @@ import commonjs from '@rollup/plugin-commonjs'; import typescript from '@rollup/plugin-typescript'; import terser from '@rollup/plugin-terser'; import external from 'rollup-plugin-peer-deps-external'; -import dts from 'rollup-plugin-dts'; +import { dts } from 'rollup-plugin-dts'; import preserveDirectives from 'rollup-plugin-preserve-directives'; +import tsBuildConfig from './bundle-base.tsconfig.json' assert { type: 'json' }; import packageJson from './package.json' assert { type: 'json' }; // suppresses warnings printed to console as part of bundling components with directives present. @@ -72,9 +73,15 @@ export default [ }, // type bundling { - input: 'dist/esm/index.d.ts', + input: 'src/index.ts', output: [{ file: 'dist/index.d.ts', format: 'esm' }], external: [], - plugins: [dts()], + plugins: [ + dts({ + compilerOptions: { + paths: tsBuildConfig.compilerOptions.paths, + }, + }), + ], }, ]; diff --git a/src/components/content-presentation/details/Details.tsx b/src/components/content-presentation/details/Details.tsx index 64381a4c..64a07302 100644 --- a/src/components/content-presentation/details/Details.tsx +++ b/src/components/content-presentation/details/Details.tsx @@ -1,14 +1,14 @@ -import React, { HTMLProps } from 'react'; +import React, { FC, HTMLProps } from 'react'; import classNames from 'classnames'; interface DetailsProps extends HTMLProps { expander?: boolean; } -interface Details extends React.FC { - Summary: React.FC>; - Text: React.FC>; - ExpanderGroup: React.FC>; +interface Details extends FC { + Summary: FC>; + Text: FC>; + ExpanderGroup: FC>; } // TODO: Check if standard NHS.UK polyfill "details.polyfill.js" is required @@ -19,17 +19,17 @@ const Details: Details = ({ className, expander, ...rest }) => ( /> ); -const DetailsSummary: React.FC> = ({ className, children, ...rest }) => ( +const DetailsSummary: FC> = ({ className, children, ...rest }) => ( {children} ); -const DetailsText: React.FC> = ({ className, ...rest }) => ( +const DetailsText: FC> = ({ className, ...rest }) => (
); -const ExpanderGroup: React.FC> = ({ className, ...rest }) => ( +const ExpanderGroup: FC> = ({ className, ...rest }) => (
); diff --git a/src/components/content-presentation/do-and-dont-list/DoAndDontList.tsx b/src/components/content-presentation/do-and-dont-list/DoAndDontList.tsx index 69e3b7eb..6702ba2c 100644 --- a/src/components/content-presentation/do-and-dont-list/DoAndDontList.tsx +++ b/src/components/content-presentation/do-and-dont-list/DoAndDontList.tsx @@ -1,5 +1,5 @@ 'use client'; -import React, { HTMLProps, createContext, useContext, ReactNode } from 'react'; +import React, { FC, HTMLProps, createContext, useContext, ReactNode } from 'react'; import classNames from 'classnames'; import { Tick, Cross } from '@components/icons'; import HeadingLevel, { HeadingLevelType } from '@util/HeadingLevel'; @@ -12,8 +12,8 @@ interface DoAndDontListProps extends HTMLProps { headingLevel?: HeadingLevelType; } -interface DoAndDontList extends React.FC { - Item: React.FC; +interface DoAndDontList extends FC { + Item: FC; } const DoAndDontListContext = createContext('do'); @@ -49,12 +49,7 @@ interface DoAndDontItemProps extends HTMLProps { prefixText?: ReactNode; } -const DoAndDontItem: React.FC = ({ - prefixText, - listItemType, - children, - ...rest -}) => { +const DoAndDontItem: FC = ({ prefixText, listItemType, children, ...rest }) => { const listItem = useContext(DoAndDontListContext); const defaultPrefix = (listItemType || listItem) === 'do' ? null : 'do not '; const actualPrefix = prefixText === undefined ? defaultPrefix : prefixText; diff --git a/src/components/content-presentation/images/Images.tsx b/src/components/content-presentation/images/Images.tsx index 3e74d21e..83b68ad2 100644 --- a/src/components/content-presentation/images/Images.tsx +++ b/src/components/content-presentation/images/Images.tsx @@ -1,4 +1,4 @@ -import React, { HTMLProps } from 'react'; +import React, { FC, HTMLProps } from 'react'; import classNames from 'classnames'; interface ImageProps extends HTMLProps { @@ -8,7 +8,7 @@ interface ImageProps extends HTMLProps { caption?: string; } -const Images: React.FC = ({ className, caption, ...rest }) => ( +const Images: FC = ({ className, caption, ...rest }) => (
{/* eslint-disable-next-line jsx-a11y/alt-text */} diff --git a/src/components/content-presentation/inset-text/InsetText.tsx b/src/components/content-presentation/inset-text/InsetText.tsx index a8138dce..b61dcb5a 100644 --- a/src/components/content-presentation/inset-text/InsetText.tsx +++ b/src/components/content-presentation/inset-text/InsetText.tsx @@ -1,11 +1,11 @@ -import React, { HTMLProps } from 'react'; +import React, { FC, HTMLProps } from 'react'; import classNames from 'classnames'; interface InsetTextProps extends HTMLProps { visuallyHiddenText?: string | false; } -const InsetText: React.FC = ({ +const InsetText: FC = ({ className, children, visuallyHiddenText = 'Information: ', diff --git a/src/components/content-presentation/summary-list/SummaryList.tsx b/src/components/content-presentation/summary-list/SummaryList.tsx index 02444f8e..7eb4426d 100644 --- a/src/components/content-presentation/summary-list/SummaryList.tsx +++ b/src/components/content-presentation/summary-list/SummaryList.tsx @@ -1,19 +1,19 @@ -import React, { HTMLProps } from 'react'; +import React, { FC, HTMLProps } from 'react'; import classNames from 'classnames'; -const SummaryListRow: React.FC> = ({ className, ...rest }) => ( +const SummaryListRow: FC> = ({ className, ...rest }) => (
); -const SummaryListKey: React.FC> = ({ className, ...rest }) => ( +const SummaryListKey: FC> = ({ className, ...rest }) => (
); -const SummaryListValue: React.FC> = ({ className, ...rest }) => ( +const SummaryListValue: FC> = ({ className, ...rest }) => (
); -const SummaryListActions: React.FC> = ({ className, ...rest }) => ( +const SummaryListActions: FC> = ({ className, ...rest }) => (
); @@ -21,11 +21,11 @@ interface SummaryListProps extends HTMLProps { noBorder?: boolean; } -interface SummaryList extends React.FC { - Row: React.FC>; - Key: React.FC>; - Value: React.FC>; - Actions: React.FC>; +interface SummaryList extends FC { + Row: FC>; + Key: FC>; + Value: FC>; + Actions: FC>; } const SummaryList: SummaryList = ({ className, noBorder, ...rest }) => ( diff --git a/src/components/content-presentation/table/Table.tsx b/src/components/content-presentation/table/Table.tsx index 2721c826..73942891 100644 --- a/src/components/content-presentation/table/Table.tsx +++ b/src/components/content-presentation/table/Table.tsx @@ -1,12 +1,12 @@ -import React, { ComponentProps, HTMLProps, ReactNode } from 'react'; +import React, { ComponentProps, FC, HTMLProps, ReactNode, useMemo, useState } from 'react'; import classNames from 'classnames'; import TableBody from './components/TableBody'; import TableCaption from './components/TableCaption'; -import TableCell from './components/TableCell'; +import TableCell, { TableCellProps } from './components/TableCell'; import TableContainer from './components/TableContainer'; import TableHead from './components/TableHead'; import TableRow from './components/TableRow'; -import TablePanel from './components/TablePanel'; +import TablePanel, { TablePanelProps } from './components/TablePanel'; import TableContext, { ITableContext } from './TableContext'; interface TableProps extends HTMLProps { @@ -15,69 +15,55 @@ interface TableProps extends HTMLProps { captionProps?: ComponentProps; } -interface TableState { - headings: string[]; +interface Table extends FC { + Body: FC>; + Cell: FC; + Container: FC>; + Head: FC>; + Panel: FC; + Row: FC>; } -// TODO - turn into functional component -class Table extends React.PureComponent { - static defaultProps = { - responsive: false, - }; - - static Container = TableContainer; - - static Head = TableHead; - - static Row = TableRow; - - static Cell = TableCell; - - static Body = TableBody; - - static Panel = TablePanel; - - constructor(props: TableProps) { - super(props); - this.state = { - headings: [], - }; - } - - setHeadings = (headings: string[]): void => { - const isEqual = headings.reduce( - (prevValue, heading, index) => prevValue && heading === this.state.headings[index], - true, - ); - - if (!isEqual) this.setState({ headings }); - }; - - render(): JSX.Element { - const { className, responsive, children, caption, captionProps, ...rest } = this.props; - - const contextValue: ITableContext = { +const Table = ({ + caption, + captionProps, + children, + className, + responsive = false, + ...rest +}: TableProps) => { + const [headings, setHeadings] = useState([]); + + const contextValue: ITableContext = useMemo(() => { + return { isResponsive: Boolean(responsive), - headings: this.state.headings, - setHeadings: this.setHeadings, + headings, + setHeadings, }; - - return ( - - - {caption && {caption}} - {children} -
-
- ); - } -} + }, [responsive, headings, setHeadings]); + + return ( + + + {caption && {caption}} + {children} +
+
+ ); +}; + +Table.Body = TableBody; +Table.Cell = TableCell; +Table.Container = TableContainer; +Table.Head = TableHead; +Table.Panel = TablePanel; +Table.Row = TableRow; export default Table; diff --git a/src/components/content-presentation/table/TableHelpers.ts b/src/components/content-presentation/table/TableHelpers.ts index 8d791ab7..58cd1929 100644 --- a/src/components/content-presentation/table/TableHelpers.ts +++ b/src/components/content-presentation/table/TableHelpers.ts @@ -1,4 +1,4 @@ -import React, { isValidElement, ReactElement, ReactNode } from 'react'; +import { Children, isValidElement, ReactElement, ReactNode } from 'react'; import TableCell from './components/TableCell'; export const isTableCell = (child: ReactNode): child is ReactElement => { @@ -7,7 +7,7 @@ export const isTableCell = (child: ReactNode): child is ReactElement => { export const getHeadingsFromChildren = (children: ReactNode): string[] => { const headings: string[] = []; - React.Children.map(children, (child) => { + Children.map(children, (child) => { if (isTableCell(child)) { headings.push(child.props.children.toString()); } diff --git a/src/components/content-presentation/table/components/TableBody.tsx b/src/components/content-presentation/table/components/TableBody.tsx index 110cf9c2..8d6bdf4a 100644 --- a/src/components/content-presentation/table/components/TableBody.tsx +++ b/src/components/content-presentation/table/components/TableBody.tsx @@ -1,12 +1,8 @@ import classNames from 'classnames'; -import React, { HTMLProps } from 'react'; +import React, { FC, HTMLProps } from 'react'; import TableSectionContext, { TableSection } from '../TableSectionContext'; -const TableBody: React.FC> = ({ - className, - children, - ...rest -}) => ( +const TableBody: FC> = ({ className, children, ...rest }) => ( {children} diff --git a/src/components/content-presentation/table/components/TableCaption.tsx b/src/components/content-presentation/table/components/TableCaption.tsx index 0e0427a0..e40a3d98 100644 --- a/src/components/content-presentation/table/components/TableCaption.tsx +++ b/src/components/content-presentation/table/components/TableCaption.tsx @@ -1,7 +1,7 @@ -import React, { HTMLProps } from 'react'; +import React, { FC, HTMLProps } from 'react'; import classNames from 'classnames'; -const TableCaption: React.FC> = ({ className, ...rest }) => ( +const TableCaption: FC> = ({ className, ...rest }) => ( ); TableCaption.displayName = 'Table.Caption'; diff --git a/src/components/content-presentation/table/components/TableCell.tsx b/src/components/content-presentation/table/components/TableCell.tsx index f77e150b..7e675bea 100644 --- a/src/components/content-presentation/table/components/TableCell.tsx +++ b/src/components/content-presentation/table/components/TableCell.tsx @@ -1,6 +1,5 @@ -'use client'; import classNames from 'classnames'; -import React, { HTMLProps, useContext } from 'react'; +import React, { FC, HTMLProps, useContext } from 'react'; import useDevWarning from '@util/hooks/UseDevWarning'; import TableSectionContext, { TableSection } from '../TableSectionContext'; @@ -13,7 +12,7 @@ export interface TableCellProps extends HTMLProps { isNumeric?: boolean; } -const TableCell: React.FC = ({ +const TableCell: FC = ({ className, _responsive = false, _responsiveHeading = '', @@ -27,26 +26,22 @@ const TableCell: React.FC = ({ const cellClass = section === TableSection.HEAD ? 'nhsuk-table__header' : 'nhsuk-table__cell'; const classes = classNames(cellClass, { [`${cellClass}--numeric`]: isNumeric }, className); - switch (section) { - case TableSection.HEAD: - return ( + return ( + <> + {section === TableSection.HEAD ? ( {children} - ); - - case TableSection.BODY: - case TableSection.NONE: - default: - return ( + ) : ( {_responsive && ( {_responsiveHeading} )} {children} - ); - } + )} + + ); }; TableCell.displayName = 'Table.Cell'; diff --git a/src/components/content-presentation/table/components/TableContainer.tsx b/src/components/content-presentation/table/components/TableContainer.tsx index 4254bc2b..f47cb3a1 100644 --- a/src/components/content-presentation/table/components/TableContainer.tsx +++ b/src/components/content-presentation/table/components/TableContainer.tsx @@ -1,7 +1,7 @@ -import React, { HTMLProps } from 'react'; +import React, { FC, HTMLProps } from 'react'; import classNames from 'classnames'; -const TableContainer: React.FC> = ({ className, ...rest }) => ( +const TableContainer: FC> = ({ className, ...rest }) => (
); TableContainer.displayName = 'Table.Container'; diff --git a/src/components/content-presentation/table/components/TableHead.tsx b/src/components/content-presentation/table/components/TableHead.tsx index f3a08131..11f77bf4 100644 --- a/src/components/content-presentation/table/components/TableHead.tsx +++ b/src/components/content-presentation/table/components/TableHead.tsx @@ -1,12 +1,8 @@ -import React, { HTMLProps } from 'react'; +import React, { FC, HTMLProps } from 'react'; import classNames from 'classnames'; import TableSectionContext, { TableSection } from '../TableSectionContext'; -const TableHead: React.FC> = ({ - className, - children, - ...rest -}) => ( +const TableHead: FC> = ({ className, children, ...rest }) => ( {children} diff --git a/src/components/content-presentation/table/components/TablePanel.tsx b/src/components/content-presentation/table/components/TablePanel.tsx index 5446fabb..7b60290b 100644 --- a/src/components/content-presentation/table/components/TablePanel.tsx +++ b/src/components/content-presentation/table/components/TablePanel.tsx @@ -1,4 +1,4 @@ -import React, { ComponentProps, HTMLProps } from 'react'; +import React, { FC, ComponentProps, HTMLProps } from 'react'; import classNames from 'classnames'; import HeadingLevel from '@util/HeadingLevel'; @@ -7,7 +7,7 @@ export interface TablePanelProps extends HTMLProps { headingProps?: ComponentProps; } -const TablePanel: React.FC = ({ +const TablePanel: FC = ({ className, heading, headingProps, diff --git a/src/components/content-presentation/table/components/TableRow.tsx b/src/components/content-presentation/table/components/TableRow.tsx index e7468304..05453cc7 100644 --- a/src/components/content-presentation/table/components/TableRow.tsx +++ b/src/components/content-presentation/table/components/TableRow.tsx @@ -1,11 +1,11 @@ 'use client'; import classNames from 'classnames'; -import React, { HTMLProps, useContext, useEffect } from 'react'; +import React, { Children, cloneElement, FC, HTMLProps, useContext, useEffect } from 'react'; import TableContext from '../TableContext'; import { getHeadingsFromChildren, isTableCell } from '../TableHelpers'; import TableSectionContext, { TableSection } from '../TableSectionContext'; -const TableRow: React.FC> = ({ className, children, ...rest }) => { +const TableRow: FC> = ({ className, children, ...rest }) => { const section = useContext(TableSectionContext); const { isResponsive, headings, setHeadings } = useContext(TableContext); @@ -16,9 +16,9 @@ const TableRow: React.FC> = ({ className, childre }, [isResponsive, section, children]); if (isResponsive && section === TableSection.BODY) { - const tableCells = React.Children.map(children, (child, index) => { + const tableCells = Children.map(children, (child, index) => { if (isTableCell(child)) { - return React.cloneElement(child, { + return cloneElement(child, { _responsive: isResponsive, _responsiveHeading: `${headings[index] || ''} `, }); diff --git a/src/components/content-presentation/tabs/Tabs.tsx b/src/components/content-presentation/tabs/Tabs.tsx index 59ffd92a..4688cd1b 100644 --- a/src/components/content-presentation/tabs/Tabs.tsx +++ b/src/components/content-presentation/tabs/Tabs.tsx @@ -1,6 +1,6 @@ 'use client'; import classNames from 'classnames'; -import React, { HTMLAttributes, useEffect } from 'react'; +import React, { FC, HTMLAttributes, useEffect } from 'react'; import HeadingLevel, { HeadingLevelType } from '@util/HeadingLevel'; import TabsJs from 'nhsuk-frontend/packages/components/tabs/tabs.js'; @@ -22,17 +22,17 @@ type TabContentsProps = { children: React.ReactNode; }; -const TabTitle: React.FC = ({ children, headingLevel = 'h2' }) => ( +const TabTitle: FC = ({ children, headingLevel = 'h2' }) => ( {children} ); -const TabList: React.FC = ({ children }) => ( +const TabList: FC = ({ children }) => (
    {children}
); -const TabListItem: React.FC = ({ id, children }) => ( +const TabListItem: FC = ({ id, children }) => (
  • {children} @@ -40,17 +40,17 @@ const TabListItem: React.FC = ({ id, children }) => (
  • ); -const TabContents: React.FC = ({ id, children }) => ( +const TabContents: FC = ({ id, children }) => (
    {children}
    ); -interface Tabs extends React.FC { - Title: React.FC; - List: React.FC; - ListItem: React.FC; - Contents: React.FC; +interface Tabs extends FC { + Title: FC; + List: FC; + ListItem: FC; + Contents: FC; } const Tabs: Tabs = ({ className, children, ...rest }) => { diff --git a/src/components/content-presentation/tag/Tag.tsx b/src/components/content-presentation/tag/Tag.tsx index b210f2c8..3774ec99 100644 --- a/src/components/content-presentation/tag/Tag.tsx +++ b/src/components/content-presentation/tag/Tag.tsx @@ -1,21 +1,21 @@ -import React, { HTMLProps } from 'react'; +import React, { FC, HTMLProps } from 'react'; import classNames from 'classnames'; interface TagProps extends HTMLProps { color?: - | 'white' - | 'grey' - | 'green' - | 'aqua-green' - | 'blue' - | 'purple' - | 'pink' - | 'red' - | 'orange' - | 'yellow'; + | 'white' + | 'grey' + | 'green' + | 'aqua-green' + | 'blue' + | 'purple' + | 'pink' + | 'red' + | 'orange' + | 'yellow'; } -const Tag: React.FC = ({ className, color, ...rest }) => ( +const Tag: FC = ({ className, color, ...rest }) => ( { visuallyHiddenText?: string | false; } -const WarningCalloutLabel: React.FC = ({ +const WarningCalloutLabel: FC = ({ className, visuallyHiddenText = 'Important: ', children, @@ -22,7 +22,7 @@ const WarningCalloutLabel: React.FC = ({ ); -interface IWarningCallout extends React.FC> { +interface IWarningCallout extends FC> { Label: typeof WarningCalloutLabel; } diff --git a/src/components/form-elements/button/Button.tsx b/src/components/form-elements/button/Button.tsx index b54d6065..333e5c4d 100644 --- a/src/components/form-elements/button/Button.tsx +++ b/src/components/form-elements/button/Button.tsx @@ -1,4 +1,4 @@ -import React, { HTMLProps } from 'react'; +import React, { FC, HTMLProps } from 'react'; import classNames from 'classnames'; export interface ButtonProps extends HTMLProps { @@ -16,7 +16,7 @@ export interface ButtonLinkProps extends HTMLProps { as?: 'a'; } -export const Button: React.FC = ({ +export const Button: FC = ({ className, disabled, secondary, @@ -40,7 +40,7 @@ export const Button: React.FC = ({ /> ); -export const ButtonLink: React.FC = ({ +export const ButtonLink: FC = ({ className, role = 'button', draggable = false, @@ -67,7 +67,7 @@ export const ButtonLink: React.FC = ({
    ); -const ButtonWrapper: React.FC = ({ href, as, ...rest }) => { +const ButtonWrapper: FC = ({ href, as, ...rest }) => { if (as === 'a') { return ; } diff --git a/src/components/form-elements/character-count/CharacterCount.tsx b/src/components/form-elements/character-count/CharacterCount.tsx index 8dd97b61..ac519377 100644 --- a/src/components/form-elements/character-count/CharacterCount.tsx +++ b/src/components/form-elements/character-count/CharacterCount.tsx @@ -1,5 +1,5 @@ 'use client'; -import React, { useEffect } from 'react'; +import React, { FC, useEffect } from 'react'; import CharacterCountJs from 'nhsuk-frontend/packages/components/character-count/character-count.js'; import { HTMLAttributesWithData } from '@util/types/NHSUKTypes'; @@ -16,7 +16,7 @@ type CharacterCountProps = React.HTMLAttributes & { thresholdPercent?: number; }; -const CharacterCount: React.FC = ({ +const CharacterCount: FC = ({ children, maxLength, countType, diff --git a/src/components/form-elements/checkboxes/CheckboxContext.ts b/src/components/form-elements/checkboxes/CheckboxContext.ts index 7770e8af..8e3aec8d 100644 --- a/src/components/form-elements/checkboxes/CheckboxContext.ts +++ b/src/components/form-elements/checkboxes/CheckboxContext.ts @@ -3,7 +3,6 @@ import { createContext } from 'react'; export interface ICheckboxContext { name: string; getBoxId: (reference: string) => string | undefined; - setConditional: (boxReference: string, hasConditional: boolean) => void; leaseReference: () => string; unleaseReference: (reference: string) => void; } @@ -12,7 +11,6 @@ export default createContext({ /* eslint-disable @typescript-eslint/no-empty-function */ name: '', getBoxId: () => undefined, - setConditional: () => {}, leaseReference: () => '', unleaseReference: () => {}, }); diff --git a/src/components/form-elements/checkboxes/Checkboxes.tsx b/src/components/form-elements/checkboxes/Checkboxes.tsx index 96313183..d2f8a338 100644 --- a/src/components/form-elements/checkboxes/Checkboxes.tsx +++ b/src/components/form-elements/checkboxes/Checkboxes.tsx @@ -1,4 +1,6 @@ -import React, { HTMLProps, PureComponent } from 'react'; +'use client'; + +import React, { HTMLProps, useEffect } from 'react'; import classNames from 'classnames'; import { FormElementProps } from '@util/types/FormTypes'; import FormGroup from '@util/FormGroup'; @@ -12,110 +14,66 @@ interface CheckboxesProps extends HTMLProps, FormElementProps { idPrefix?: string; } -type CheckboxesState = { - conditionalBoxes: Array; -}; - -class Checkboxes extends PureComponent { - private boxCount = 0; - - private boxReferences: Array = []; - - private boxIds: Record = {}; +const Checkboxes = ({ children, idPrefix, ...rest }: CheckboxesProps) => { + const _boxReferences: string[] = []; + let _boxCount: number = 0; + let _boxIds: Record = {}; - constructor(props: CheckboxesProps) { - super(props); - this.state = { - conditionalBoxes: [], - }; - } + useEffect(() => { + CheckboxJs(); + }, []); - leaseReference = (): string => { - const reference = generateRandomName(); - if (this.boxReferences.includes(reference)) { - return this.leaseReference(); + const getBoxId = (id: string, reference: string): string => { + if (reference in _boxIds) { + return _boxIds[reference]; } - this.boxReferences.push(reference); - return reference; - }; + _boxCount++; + _boxIds[reference] = `${idPrefix ?? id}-${_boxCount}`; - unleaseReference = (reference: string): void => { - this.boxReferences = this.boxReferences.filter((ref) => ref !== reference); + return _boxIds[reference]; }; - setConditional = (boxReference: string, hasConditional: boolean): void => { - this.setState((state) => { - const currentHasConditional = state.conditionalBoxes.includes(boxReference); - if (currentHasConditional && hasConditional === false) { - return { - ...state, - conditionalBoxes: state.conditionalBoxes.filter((ref) => ref !== boxReference), - }; - } - if (!currentHasConditional && hasConditional === true) { - return { - ...state, - conditionalBoxes: [...state.conditionalBoxes, boxReference], - }; - } - return state; - }); - }; + const leaseReference = (): string => { + let reference: string = ''; + do { + reference = generateRandomName(); + } while (_boxReferences.includes(reference)); - getBoxId = (id: string, reference: string): string => { - const { idPrefix } = this.props; - if (reference in this.boxIds) { - return this.boxIds[reference]; - } - this.boxCount += 1; - this.boxIds[reference] = `${idPrefix || id}-${this.boxCount}`; - return this.boxIds[reference]; + _boxReferences.push(reference); + return reference; }; - resetBoxIds = (): void => { - this.boxCount = 0; - this.boxIds = {}; + const unleaseReference = (reference: string): void => { + _boxReferences.splice(_boxReferences.indexOf(reference), 1); }; - componentDidMount(): void { - CheckboxJs(); - } + const resetCheckboxIds = (): void => { + _boxCount = 0; + _boxIds = {}; + }; - static Box = Box; - static Divider = Divider; + return ( + inputType="checkboxes" {...rest}> + {/* eslint-disable-next-line @typescript-eslint/no-unused-vars */} + {({ className, name, id, idPrefix, error, ...restRenderProps }) => { + resetCheckboxIds(); + const contextValue: ICheckboxContext = { + name, + getBoxId: (reference) => getBoxId(id, reference), + leaseReference, + unleaseReference, + }; + return ( +
    + {children} +
    + ); + }} + + ); +}; - render(): JSX.Element { - const { children, ...rest } = this.props; - return ( - inputType="checkboxes" {...rest}> - {/* eslint-disable-next-line @typescript-eslint/no-unused-vars */} - {({ className, name, id, idPrefix, error, ...restRenderProps }) => { - this.resetBoxIds(); - const containsConditional = this.state.conditionalBoxes.length > 0; - const contextValue: ICheckboxContext = { - name, - getBoxId: (reference) => this.getBoxId(id, reference), - setConditional: this.setConditional, - leaseReference: this.leaseReference, - unleaseReference: this.unleaseReference, - }; - return ( -
    - {children} -
    - ); - }} - - ); - } -} +Checkboxes.Box = Box; +Checkboxes.Divider = Divider; export default Checkboxes; diff --git a/src/components/form-elements/checkboxes/__tests__/Checkboxes.test.tsx b/src/components/form-elements/checkboxes/__tests__/Checkboxes.test.tsx index 7d35a3e3..ced26677 100644 --- a/src/components/form-elements/checkboxes/__tests__/Checkboxes.test.tsx +++ b/src/components/form-elements/checkboxes/__tests__/Checkboxes.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { render } from '@testing-library/react'; -import Checkboxes from '../Checkboxes'; +import Checkboxes from '../'; describe('Checkboxes', () => { it('matches snapshot', () => { diff --git a/src/components/form-elements/checkboxes/components/Box.tsx b/src/components/form-elements/checkboxes/components/Box.tsx index 5ab10a64..3c576c57 100644 --- a/src/components/form-elements/checkboxes/components/Box.tsx +++ b/src/components/form-elements/checkboxes/components/Box.tsx @@ -1,5 +1,7 @@ 'use client'; + import React, { + FC, HTMLProps, useContext, ReactNode, @@ -24,7 +26,7 @@ type BoxProps = Omit, 'label'> & { exclusive?: boolean; }; -const Box: React.FC = ({ +const Box: FC = ({ id, labelProps, children, @@ -41,7 +43,7 @@ const Box: React.FC = ({ type = 'checkbox', ...rest }) => { - const { getBoxId, name, setConditional, unleaseReference, leaseReference } = + const { getBoxId, name, leaseReference, unleaseReference } = useContext(CheckboxContext); const [boxReference] = useState(leaseReference()); @@ -61,11 +63,6 @@ const Box: React.FC = ({ } }, [checked]); - useEffect(() => { - setConditional(boxReference, Boolean(conditional)); - return () => setConditional(boxReference, false); - }, [conditional]); - const inputProps: HTMLAttributesWithData = rest; if (exclusive) { diff --git a/src/components/form-elements/checkboxes/components/Divider.tsx b/src/components/form-elements/checkboxes/components/Divider.tsx index 6ca9079c..547aa302 100644 --- a/src/components/form-elements/checkboxes/components/Divider.tsx +++ b/src/components/form-elements/checkboxes/components/Divider.tsx @@ -1,10 +1,10 @@ -import React from 'react'; +import React, { FC } from 'react'; type DividerProps = { dividerText?: string; }; -const Divider: React.FC = ({ dividerText = 'or' }) => ( +const Divider: FC = ({ dividerText = 'or' }) => (
    {dividerText}
    ); diff --git a/src/components/form-elements/date-input/DateInput.tsx b/src/components/form-elements/date-input/DateInput.tsx index aa96d9f0..a4f8adb3 100644 --- a/src/components/form-elements/date-input/DateInput.tsx +++ b/src/components/form-elements/date-input/DateInput.tsx @@ -1,4 +1,6 @@ -import React, { HTMLProps, PureComponent, ChangeEvent } from 'react'; +'use client'; + +import React, { HTMLProps, ChangeEvent, useEffect, useState } from 'react'; import classNames from 'classnames'; import { DayInput, MonthInput, YearInput } from './components/IndividualDateInputs'; import FormGroup from '@util/FormGroup'; @@ -11,7 +13,7 @@ type DateInputValue = { year: string; }; -type DateInputChangeEvent = ChangeEvent & { +export type DateInputChangeEvent = ChangeEvent & { target: HTMLInputElement & { value: DateInputValue }; currentTarget: HTMLInputElement & { value: DateInputValue }; }; @@ -25,136 +27,101 @@ interface DateInputProps onChange?: (e: DateInputChangeEvent) => void; } -interface DateInputState { - values: { - day: string; - month: string; - year: string; - }; -} - -class DateInput extends PureComponent { - monthRef: null | HTMLInputElement; - yearRef: null | HTMLInputElement; - - static Day = DayInput; - - static Month = MonthInput; - - static Year = YearInput; - - constructor(props: DateInputProps) { - super(props); - this.state = { - values: { - day: props.value?.day || '', - month: props.value?.month || '', - year: props.value?.year || '', - }, - }; - - this.monthRef = null; - this.yearRef = null; - } - - componentDidUpdate(prevProps: DateInputProps): void { - if (this.props.value && prevProps.value !== this.props.value) { - // This is the only way that we can update our internal state - // when the value updates. We check if the value has changed first, - // preventing an infinite loop. - // - // eslint-disable-next-line react/no-did-update-set-state - this.setState((state) => { - if (!this.props.value) return state; - - const newState = { ...state }; - const { day, month, year } = this.props.value; - if (day && day !== state.values.day) newState.values.day = day; - if (month && month !== state.values.month) newState.values.month = month; - if (year && year !== state.values.year) newState.values.year = year; - - return newState; - }); - } - } - - handleSelectNext = (inputType: 'day' | 'month' | 'year', value: string): void => { - if (!this.props.autoSelectNext) return; - if (inputType === 'day' && value.length === 2 && this.monthRef) { - this.monthRef.focus(); - } else if (inputType === 'month' && value.length === 2 && this.yearRef) { - this.yearRef.focus(); +type InputType = 'day' | 'month' | 'year'; + +const DateInput = ({ + autoSelectNext, + children, + onChange, + value, + defaultValue, + ...rest +}: DateInputProps) => { + let monthRef: HTMLInputElement | null = null; + let yearRef: HTMLInputElement | null = null; + const [internalDate, setInternalDate] = useState>({ + day: value?.day ?? '', + month: value?.month ?? '', + year: value?.year ?? '', + }); + + useEffect(() => { + const newState = { ...internalDate }; + const { day, month, year } = value ?? {}; + if (day && day !== internalDate.day) newState.day = day; + if (month && month !== internalDate.month) newState.month = month; + if (year && year !== internalDate.year) newState.year = year; + + return setInternalDate(newState); + }, [value]); + + const handleFocusNextInput = (inputType: InputType, value: string): void => { + if (!autoSelectNext) return; + if (inputType === 'day' && value.length === 2 && monthRef) { + monthRef.focus(); + } else if (inputType === 'month' && value.length === 2 && yearRef) { + yearRef.focus(); } }; - handleChange = ( - inputType: 'day' | 'month' | 'year', - event: ChangeEvent, - ): void => { - this.handleSelectNext(inputType, event.target.value); + const handleChange = (inputType: InputType, event: ChangeEvent): void => { + handleFocusNextInput(inputType, event.target.value); event.stopPropagation(); - this.setState((state) => { + + if (onChange) { const newEventValue = { - ...state.values, + ...internalDate, [inputType]: event.target.value, }; - if (this.props.onChange) { - const newEvent = { - ...event, - target: { ...event.target, value: newEventValue }, - currentTarget: { ...event.currentTarget, value: newEventValue }, - } as DateInputChangeEvent; - this.props.onChange(newEvent); - } - return { values: newEventValue }; - }); + const newEvent = { + ...event, + target: { ...event.target, value: newEventValue }, + currentTarget: { ...event.currentTarget, value: newEventValue }, + } as DateInputChangeEvent; + + onChange(newEvent); + setInternalDate(newEventValue); + } }; - registerRef = (inputType: 'day' | 'month' | 'year', ref: HTMLInputElement | null): void => { - if (inputType === 'month') this.monthRef = ref; - if (inputType === 'year') this.yearRef = ref; + const registerRef = (inputType: InputType, ref: HTMLInputElement | null): void => { + if (inputType === 'month') monthRef = ref; + if (inputType === 'year') yearRef = ref; }; - render(): JSX.Element { - const { - children, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - onChange, - value, - defaultValue, - ...rest - } = this.props; - - return ( - > inputType="dateinput" {...rest}> - {/* eslint-disable-next-line @typescript-eslint/no-unused-vars */} - {({ className, name, id, error, autoSelectNext, ...restRenderProps }) => { - const contextValue: IDateInputContext = { - id, - name, - error, - value, - defaultValue, - handleChange: this.handleChange, - registerRef: this.registerRef, - }; - return ( -
    - - {children || ( - <> - - - - - )} - -
    - ); - }} - - ); - } -} + return ( + > inputType="dateinput" {...rest}> + {/* eslint-disable-next-line @typescript-eslint/no-unused-vars */} + {({ className, name, id, error, autoSelectNext, ...restRenderProps }) => { + const contextValue: IDateInputContext = { + id, + name, + error, + value, + defaultValue, + handleChange, + registerRef, + }; + return ( +
    + + {children || ( + <> + + + + + )} + +
    + ); + }} + + ); +}; + +DateInput.Day = DayInput; +DateInput.Month = MonthInput; +DateInput.Year = YearInput; export default DateInput; diff --git a/src/components/form-elements/date-input/__tests__/DateInput.test.tsx b/src/components/form-elements/date-input/__tests__/DateInput.test.tsx index 807a11e2..83a4d424 100644 --- a/src/components/form-elements/date-input/__tests__/DateInput.test.tsx +++ b/src/components/form-elements/date-input/__tests__/DateInput.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react'; -import DateInput from '../DateInput'; +import DateInput, { DateInputChangeEvent } from '../DateInput'; describe('DateInput', () => { it('matches snapshot', () => { @@ -66,7 +66,8 @@ describe('DateInput', () => { ); it('Invokes the provided onChange function prop if provided', () => { - const onChange = jest.fn(); + let onChangeParam: DateInputChangeEvent | null = null; + const onChange = jest.fn().mockImplementation((val) => (onChangeParam = val)); const { container } = render(); @@ -77,47 +78,29 @@ describe('DateInput', () => { fireEvent.change(dayInput, { target: { value: '21' } }); expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange).toHaveBeenLastCalledWith( - expect.objectContaining({ - currentTarget: { - value: { - day: '21', - month: '', - year: '', - }, - }, - }), - ); + expect(onChangeParam!.currentTarget!.value).toEqual({ + day: '21', + month: '', + year: '', + }); fireEvent.change(monthInput, { target: { value: '03' } }); expect(onChange).toHaveBeenCalledTimes(2); - expect(onChange).toHaveBeenLastCalledWith( - expect.objectContaining({ - currentTarget: { - value: { - day: '21', - month: '03', - year: '', - }, - }, - }), - ); + expect(onChangeParam!.currentTarget!.value).toEqual({ + day: '21', + month: '03', + year: '', + }); fireEvent.change(yearInput, { target: { value: '2024' } }); expect(onChange).toHaveBeenCalledTimes(3); - expect(onChange).toHaveBeenLastCalledWith( - expect.objectContaining({ - currentTarget: { - value: { - day: '21', - month: '03', - year: '2024', - }, - }, - }), - ); + expect(onChangeParam!.currentTarget!.value).toEqual({ + day: '21', + month: '03', + year: '2024', + }); }); it('Renders the specified children instead of date fields if provided', () => { diff --git a/src/components/form-elements/date-input/components/IndividualDateInputs.tsx b/src/components/form-elements/date-input/components/IndividualDateInputs.tsx index a8dfe4cc..7fd2e4b9 100644 --- a/src/components/form-elements/date-input/components/IndividualDateInputs.tsx +++ b/src/components/form-elements/date-input/components/IndividualDateInputs.tsx @@ -1,5 +1,5 @@ 'use client'; -import React, { HTMLProps, useContext, ChangeEvent } from 'react'; +import React, { FC, HTMLProps, useContext, ChangeEvent } from 'react'; import classNames from 'classnames'; import Label, { LabelProps } from '../../label/Label'; import DateInputContext, { IDateInputContext } from '../DateInputContext'; @@ -17,7 +17,7 @@ const labels: Record<'day' | 'month' | 'year', string> = { year: 'Year', }; -const IndividualDateInput: React.FC = ({ +const IndividualDateInput: FC = ({ label, labelProps, inputType, @@ -101,14 +101,14 @@ const IndividualDateInput: React.FC = ({ ); }; -export const DayInput: React.FC> = (props) => ( +export const DayInput: FC> = (props) => ( ); -export const MonthInput: React.FC> = (props) => ( +export const MonthInput: FC> = (props) => ( ); -export const YearInput: React.FC> = (props) => ( +export const YearInput: FC> = (props) => ( ); diff --git a/src/components/form-elements/error-message/ErrorMessage.tsx b/src/components/form-elements/error-message/ErrorMessage.tsx index c46c27ad..cba85000 100644 --- a/src/components/form-elements/error-message/ErrorMessage.tsx +++ b/src/components/form-elements/error-message/ErrorMessage.tsx @@ -1,11 +1,11 @@ -import React, { HTMLProps } from 'react'; +import React, { FC, HTMLProps } from 'react'; import classNames from 'classnames'; export interface ErrorMessageProps extends HTMLProps { visuallyHiddenText?: false | string; } -const ErrorMessage: React.FC = ({ +const ErrorMessage: FC = ({ className, visuallyHiddenText = 'Error: ', children, diff --git a/src/components/form-elements/error-summary/ErrorSummary.tsx b/src/components/form-elements/error-summary/ErrorSummary.tsx index eb82250c..a7fd1c42 100644 --- a/src/components/form-elements/error-summary/ErrorSummary.tsx +++ b/src/components/form-elements/error-summary/ErrorSummary.tsx @@ -1,41 +1,53 @@ -import React, {forwardRef, HTMLProps, PropsWithoutRef, RefAttributes} from 'react'; +import React, { + FC, + ForwardRefExoticComponent, + forwardRef, + HTMLProps, + PropsWithoutRef, + RefAttributes, +} from 'react'; import classNames from 'classnames'; -const ErrorSummaryTitle: React.FC> = ({ className, ...rest }) => ( +const ErrorSummaryTitle: FC> = ({ className, ...rest }) => (

    ); -const ErrorSummaryBody: React.FC> = ({ className, ...rest }) => ( +const ErrorSummaryBody: FC> = ({ className, ...rest }) => (
    ); -const ErrorSummaryList: React.FC> = ({ className, ...rest }) => ( +const ErrorSummaryList: FC> = ({ className, ...rest }) => (