(TableSectionContext);
+
useDevWarning(CellOutsideOfSectionWarning, () => section === TableSection.NONE);
- const cellClass = section === TableSection.HEAD ? 'nhsuk-table__header' : 'nhsuk-table__cell';
- const classes = classNames(cellClass, { [`${cellClass}--numeric`]: isNumeric }, className);
+ const isColHeader = section === TableSection.HEAD;
+ const isRowHeader = section === TableSection.BODY && firstCellIsHeader && index === 0;
return (
<>
- {section === TableSection.HEAD ? (
-
+ {isColHeader || isRowHeader ? (
+
{children}
) : (
-
- {_responsive && (
+
+ {responsive && !!headings[index] && (
- {_responsiveHeading}
+ {headings[index]}{' '}
)}
{children}
diff --git a/src/components/content-presentation/table/components/TableContainer.tsx b/src/components/content-presentation/table/components/TableContainer.tsx
index f47cb3a1..a538062d 100644
--- a/src/components/content-presentation/table/components/TableContainer.tsx
+++ b/src/components/content-presentation/table/components/TableContainer.tsx
@@ -1,9 +1,14 @@
-import React, { FC, HTMLProps } from 'react';
+import React, { ComponentPropsWithoutRef, forwardRef } from 'react';
import classNames from 'classnames';
-const TableContainer: FC> = ({ className, ...rest }) => (
-
+export type TableContainerProps = ComponentPropsWithoutRef<'div'>;
+
+const TableContainer = forwardRef(
+ ({ className, ...rest }, forwardedRef) => (
+
+ ),
);
+
TableContainer.displayName = 'Table.Container';
export default TableContainer;
diff --git a/src/components/content-presentation/table/components/TableHead.tsx b/src/components/content-presentation/table/components/TableHead.tsx
index 11f77bf4..40ea8c42 100644
--- a/src/components/content-presentation/table/components/TableHead.tsx
+++ b/src/components/content-presentation/table/components/TableHead.tsx
@@ -1,14 +1,23 @@
-import React, { FC, HTMLProps } from 'react';
+import React, { ComponentPropsWithoutRef, FC, useContext } from 'react';
import classNames from 'classnames';
+import TableContext, { ITableContext } from '../TableContext';
import TableSectionContext, { TableSection } from '../TableSectionContext';
-const TableHead: FC> = ({ className, children, ...rest }) => (
-
-
- {children}
-
-
-);
+const TableHead: FC> = ({ children, className, ...rest }) => {
+ const { responsive } = useContext(TableContext);
+
+ return (
+
+
+ {children}
+
+
+ );
+};
TableHead.displayName = 'Table.Head';
diff --git a/src/components/content-presentation/table/components/TablePanel.tsx b/src/components/content-presentation/table/components/TablePanel.tsx
index 7542a14e..9484e4a9 100644
--- a/src/components/content-presentation/table/components/TablePanel.tsx
+++ b/src/components/content-presentation/table/components/TablePanel.tsx
@@ -1,10 +1,10 @@
-import React, { FC, ComponentProps, HTMLProps } from 'react';
+import React, { ComponentPropsWithoutRef, FC } from 'react';
import classNames from 'classnames';
-import HeadingLevel from '@components/utils/HeadingLevel';
+import HeadingLevel, { HeadingLevelProps } from '@components/utils/HeadingLevel';
-export interface TablePanelProps extends HTMLProps {
+export interface TablePanelProps extends ComponentPropsWithoutRef<'div'> {
heading?: string;
- headingProps?: ComponentProps;
+ headingProps?: HeadingLevelProps;
}
const TablePanel: FC = ({
@@ -17,7 +17,6 @@ const TablePanel: FC = ({
{heading && (
@@ -28,4 +27,6 @@ const TablePanel: FC = ({
);
+TablePanel.displayName = 'Table.Panel';
+
export default TablePanel;
diff --git a/src/components/content-presentation/table/components/TableRow.tsx b/src/components/content-presentation/table/components/TableRow.tsx
index 05453cc7..4567fb6d 100644
--- a/src/components/content-presentation/table/components/TableRow.tsx
+++ b/src/components/content-presentation/table/components/TableRow.tsx
@@ -1,44 +1,43 @@
-'use client';
import classNames from 'classnames';
-import React, { Children, cloneElement, FC, HTMLProps, useContext, useEffect } from 'react';
+import React, {
+ Children,
+ ComponentPropsWithoutRef,
+ FC,
+ cloneElement,
+ useContext,
+ useEffect,
+} from 'react';
import TableContext from '../TableContext';
import { getHeadingsFromChildren, isTableCell } from '../TableHelpers';
import TableSectionContext, { TableSection } from '../TableSectionContext';
-const TableRow: FC> = ({ className, children, ...rest }) => {
+const TableRow: FC> = ({ children, className, ...rest }) => {
const section = useContext(TableSectionContext);
- const { isResponsive, headings, setHeadings } = useContext(TableContext);
+ const { responsive, setHeadings } = useContext(TableContext);
useEffect(() => {
- if (isResponsive && section === TableSection.HEAD) {
+ if (responsive && section === TableSection.HEAD) {
setHeadings(getHeadingsFromChildren(children));
}
- }, [isResponsive, section, children]);
+ }, [responsive, section, children]);
- if (isResponsive && section === TableSection.BODY) {
- const tableCells = Children.map(children, (child, index) => {
- if (isTableCell(child)) {
- return cloneElement(child, {
- _responsive: isResponsive,
- _responsiveHeading: `${headings[index] || ''} `,
- });
- }
- return child;
- });
-
- return (
-
- {tableCells}
-
- );
- }
+ const tableCells = Children.map(children, (child, index) => {
+ return section === TableSection.BODY && isTableCell(child)
+ ? cloneElement(child, { index })
+ : child;
+ });
return (
-
- {children}
+
+ {tableCells}
);
};
+
TableRow.displayName = 'Table.Row';
export default TableRow;
diff --git a/src/components/content-presentation/table/components/__tests__/Table.test.tsx b/src/components/content-presentation/table/components/__tests__/Table.test.tsx
new file mode 100644
index 00000000..68c2bc76
--- /dev/null
+++ b/src/components/content-presentation/table/components/__tests__/Table.test.tsx
@@ -0,0 +1,83 @@
+import React, { createRef } from 'react';
+import { renderClient, renderServer } from '@util/components';
+import Table from '../..';
+
+describe('Table', () => {
+ const Example = (props: Parameters[0]) => (
+
+
+
+ Skin Symptoms
+ Possible cause
+
+
+
+
+ Blisters on lips or around the mouth
+ cold sores
+
+
+ Itchy, dry, cracked, sore
+ eczema
+
+
+ Itchy blisters
+ shingles, chickenpox
+
+
+
+ );
+
+ it('matches snapshot', async () => {
+ const { container } = await renderClient( , {
+ className: 'nhsuk-table',
+ });
+
+ expect(container).toMatchSnapshot();
+ });
+
+ it('matches snapshot when responsive', async () => {
+ const { container } = await renderClient( , {
+ className: 'nhsuk-table-responsive',
+ });
+
+ expect(container).toMatchSnapshot();
+ });
+
+ it('matches snapshot when first cell is header', async () => {
+ const { container } = await renderClient( , {
+ className: 'nhsuk-table',
+ });
+
+ expect(container).toMatchSnapshot();
+ });
+
+ it('matches snapshot (via server)', async () => {
+ const { container, element } = await renderServer( , {
+ className: 'nhsuk-table',
+ });
+
+ expect(container).toMatchSnapshot('server');
+
+ await renderClient(element, {
+ className: 'nhsuk-table',
+ hydrate: true,
+ container,
+ });
+
+ expect(container).toMatchSnapshot('client');
+ });
+
+ it('forwards refs', async () => {
+ const ref = createRef();
+
+ const { container } = await renderClient(, {
+ className: 'nhsuk-table',
+ });
+
+ const tableEl = container.querySelector('table');
+
+ expect(ref.current).toBe(tableEl);
+ expect(ref.current).toHaveClass('nhsuk-table');
+ });
+});
diff --git a/src/components/content-presentation/table/components/__tests__/TableCaption.test.tsx b/src/components/content-presentation/table/components/__tests__/TableCaption.test.tsx
index f74f1ff5..2fc87820 100644
--- a/src/components/content-presentation/table/components/__tests__/TableCaption.test.tsx
+++ b/src/components/content-presentation/table/components/__tests__/TableCaption.test.tsx
@@ -1,10 +1,31 @@
import React from 'react';
import { render } from '@testing-library/react';
+import { NHSUKSize } from '@util/types/NHSUKTypes';
+import Table from '../..';
import TableCaption from '../TableCaption';
describe('TableCaption', () => {
it('matches snapshot', () => {
- const { container } = render( );
+ const { container } = render(
+ ,
+ );
+
+ expect(container).toMatchSnapshot();
+ });
+
+ it.each(['s', 'm', 'l', 'xl'])('renders with custom size %s', (size) => {
+ const { container } = render(
+ ,
+ );
+
+ const captionEl = container.querySelector('caption');
+
+ expect(captionEl).toHaveTextContent('Caption');
+ expect(captionEl).toHaveClass(`nhsuk-table__caption--${size}`);
expect(container).toMatchSnapshot();
});
diff --git a/src/components/content-presentation/table/components/__tests__/TableCell.test.tsx b/src/components/content-presentation/table/components/__tests__/TableCell.test.tsx
index b249ba5d..7df71ca3 100644
--- a/src/components/content-presentation/table/components/__tests__/TableCell.test.tsx
+++ b/src/components/content-presentation/table/components/__tests__/TableCell.test.tsx
@@ -1,26 +1,30 @@
import React from 'react';
import { render } from '@testing-library/react';
-
import Table from '../../Table';
-import TableBody from '../TableBody';
-import TableCell from '../TableCell';
-import TableHead from '../TableHead';
-import TableRow from '../TableRow';
describe('Table.Cell', () => {
it('matches snapshot', () => {
- const { container } = render( );
+ const { container } = render(
+ ,
+ );
expect(container).toMatchSnapshot();
});
it('prints dev warning when used outside of a head or body', () => {
jest.spyOn(console, 'warn').mockImplementation();
+
render(
,
@@ -37,11 +41,11 @@ describe('Table.Cell', () => {
it('returns th element when inside a Table.Head', () => {
const { container } = render(
,
);
const cellWrapper = container.querySelector('th.nhsuk-table__header');
@@ -52,11 +56,11 @@ describe('Table.Cell', () => {
it('returns td element when inside a Table.Body', () => {
const { container } = render(
,
);
const cellWrapper = container.querySelector('td.nhsuk-table__cell');
@@ -64,19 +68,19 @@ describe('Table.Cell', () => {
expect(cellWrapper).toBeTruthy();
});
- it('adds responsive heading when _responsive=True', () => {
+ it('adds responsive heading when responsive', () => {
const { container } = render(
-
-
- TestHeading
-
-
-
-
-
-
-
+
+
+ TestHeading
+
+
+
+
+
+
+
,
);
const cellElement = container.querySelector('td');
@@ -86,12 +90,12 @@ describe('Table.Cell', () => {
expect(spanWrapper?.textContent).toBe('TestHeading ');
});
- it('adds the numeric class when isNumeric is true', () => {
+ it('adds the numeric class when `format: numeric` is set', () => {
const { container } = render(
,
@@ -101,14 +105,14 @@ describe('Table.Cell', () => {
expect(cell).toBeTruthy();
});
- it('adds the numeric header class when isNumeric is true', () => {
+ it('adds the numeric header class when `format: numeric` is set', () => {
const { container } = render(
,
);
const cell = container.querySelector('th[data-test="cell"].nhsuk-table__header--numeric');
@@ -116,17 +120,17 @@ describe('Table.Cell', () => {
expect(cell).toBeTruthy();
});
- it('does not add the numeric header when isNumeric is false', () => {
+ it('does not add the numeric header when `format: numeric` is omitted', () => {
const { container } = render(
,
diff --git a/src/components/content-presentation/table/components/__tests__/TableContainer.test.tsx b/src/components/content-presentation/table/components/__tests__/TableContainer.test.tsx
index 6a2ad68d..26536195 100644
--- a/src/components/content-presentation/table/components/__tests__/TableContainer.test.tsx
+++ b/src/components/content-presentation/table/components/__tests__/TableContainer.test.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { createRef } from 'react';
import { render } from '@testing-library/react';
import TableContainer from '../TableContainer';
@@ -8,4 +8,15 @@ describe('TableContainer', () => {
expect(container).toMatchSnapshot();
});
+
+ it('forwards refs', () => {
+ const ref = createRef();
+
+ const { container } = render( );
+
+ const containerEl = container.querySelector('div');
+
+ expect(ref.current).toBe(containerEl);
+ expect(ref.current).toHaveClass('nhsuk-table-container');
+ });
});
diff --git a/src/components/content-presentation/table/components/__tests__/TableHead.test.tsx b/src/components/content-presentation/table/components/__tests__/TableHead.test.tsx
index 616fa724..430692e5 100644
--- a/src/components/content-presentation/table/components/__tests__/TableHead.test.tsx
+++ b/src/components/content-presentation/table/components/__tests__/TableHead.test.tsx
@@ -6,7 +6,11 @@ import TableHead from '../TableHead';
describe('Table.Head', () => {
it('matches snapshot', () => {
- const { container } = render( );
+ const { container } = render(
+ ,
+ );
expect(container).toMatchSnapshot();
});
diff --git a/src/components/content-presentation/table/components/__tests__/TableRow.test.tsx b/src/components/content-presentation/table/components/__tests__/TableRow.test.tsx
index 5c16e634..f40695c9 100644
--- a/src/components/content-presentation/table/components/__tests__/TableRow.test.tsx
+++ b/src/components/content-presentation/table/components/__tests__/TableRow.test.tsx
@@ -6,7 +6,7 @@ import TableCell from '../TableCell';
import TableRow from '../TableRow';
const assertCellText = (container: HTMLElement, cellNumber: number, text: string) => {
- expect(container.querySelector(`[data-test="cell-${cellNumber}"]`)?.textContent).toEqual(text);
+ expect(container.querySelector(`[data-test="cell-${cellNumber}"]`)).toHaveTextContent(text);
};
describe('Table.Row', () => {
@@ -24,9 +24,16 @@ describe('Table.Row', () => {
it('renders headers in the first column if responsive', () => {
const contextValue: ITableContext = {
- isResponsive: true,
- headings: ['a', 'b', 'c'],
+ firstCellIsHeader: false,
+ headings: [
+ 'A',
+ 'B',
+ <>
+ C description
+ >,
+ ],
setHeadings: jest.fn(),
+ responsive: true,
};
const { container } = render(
@@ -44,17 +51,18 @@ describe('Table.Row', () => {
,
);
- assertCellText(container, 1, 'a 1');
- assertCellText(container, 2, 'b 2');
- assertCellText(container, 3, 'c 3');
+ assertCellText(container, 1, 'A 1');
+ assertCellText(container, 2, 'B 2');
+ assertCellText(container, 3, 'C description 3');
expect(container.querySelectorAll('.nhsuk-table-responsive__heading').length).toBe(3);
});
it('renders row contents without headers in responsive mode if they are not cells', () => {
const contextValue: ITableContext = {
- isResponsive: true,
- headings: ['a', 'b', 'c'],
+ firstCellIsHeader: false,
+ headings: ['A', 'B', 'C'],
setHeadings: jest.fn(),
+ responsive: true,
};
const { container } = render(
@@ -81,9 +89,10 @@ describe('Table.Row', () => {
it('renders row contents as headers in head section in responsive mode', () => {
const setHeadings = jest.fn();
const contextValue: ITableContext = {
- isResponsive: true,
- headings: ['a', 'b', 'c'],
+ firstCellIsHeader: false,
+ headings: ['A', 'B', 'C'],
setHeadings,
+ responsive: true,
};
render(
@@ -107,9 +116,10 @@ describe('Table.Row', () => {
it('sets headers, skipping contents outside of table cells in responsive mode', () => {
const setHeadings = jest.fn();
const contextValue: ITableContext = {
- isResponsive: true,
- headings: ['a', 'b', 'c'],
+ firstCellIsHeader: false,
+ headings: ['A', 'B', 'C'],
setHeadings,
+ responsive: true,
};
render(
@@ -132,9 +142,10 @@ describe('Table.Row', () => {
it('does not render row contents as headers in head section in normal mode', () => {
const contextValue: ITableContext = {
- isResponsive: false,
- headings: ['a', 'b', 'c'],
+ firstCellIsHeader: false,
+ headings: ['A', 'B', 'C'],
setHeadings: jest.fn(),
+ responsive: false,
};
const { container } = render(
diff --git a/src/components/content-presentation/table/components/__tests__/__snapshots__/Table.test.tsx.snap b/src/components/content-presentation/table/components/__tests__/__snapshots__/Table.test.tsx.snap
new file mode 100644
index 00000000..bc88b88a
--- /dev/null
+++ b/src/components/content-presentation/table/components/__tests__/__snapshots__/Table.test.tsx.snap
@@ -0,0 +1,463 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Table matches snapshot (via server): client 1`] = `
+
+
+
+ Skin symptoms and possible causes
+
+
+
+
+
+
+
+
+
+
+ Blisters on lips or around the mouth
+
+
+ cold sores
+
+
+
+
+ Itchy, dry, cracked, sore
+
+
+ eczema
+
+
+
+
+ Itchy blisters
+
+
+ shingles, chickenpox
+
+
+
+
+
+`;
+
+exports[`Table matches snapshot (via server): server 1`] = `
+
+
+
+ Skin symptoms and possible causes
+
+
+
+
+
+
+
+
+
+
+ Blisters on lips or around the mouth
+
+
+ cold sores
+
+
+
+
+ Itchy, dry, cracked, sore
+
+
+ eczema
+
+
+
+
+ Itchy blisters
+
+
+ shingles, chickenpox
+
+
+
+
+
+`;
+
+exports[`Table matches snapshot 1`] = `
+
+
+
+ Skin symptoms and possible causes
+
+
+
+
+
+
+
+
+
+
+ Blisters on lips or around the mouth
+
+
+ cold sores
+
+
+
+
+ Itchy, dry, cracked, sore
+
+
+ eczema
+
+
+
+
+ Itchy blisters
+
+
+ shingles, chickenpox
+
+
+
+
+
+`;
+
+exports[`Table matches snapshot when first cell is header 1`] = `
+
+
+
+ Skin symptoms and possible causes
+
+
+
+
+
+
+
+
+
+
+
+ cold sores
+
+
+
+
+
+ eczema
+
+
+
+
+
+ shingles, chickenpox
+
+
+
+
+
+`;
+
+exports[`Table matches snapshot when responsive 1`] = `
+
+
+
+ Skin symptoms and possible causes
+
+
+
+
+
+
+
+
+
+
+
+ Skin Symptoms
+
+
+ Blisters on lips or around the mouth
+
+
+
+ Possible cause
+
+
+ cold sores
+
+
+
+
+
+ Skin Symptoms
+
+
+ Itchy, dry, cracked, sore
+
+
+
+ Possible cause
+
+
+ eczema
+
+
+
+
+
+ Skin Symptoms
+
+
+ Itchy blisters
+
+
+
+ Possible cause
+
+
+ shingles, chickenpox
+
+
+
+
+
+`;
diff --git a/src/components/content-presentation/table/components/__tests__/__snapshots__/TableCaption.test.tsx.snap b/src/components/content-presentation/table/components/__tests__/__snapshots__/TableCaption.test.tsx.snap
index 35ac72d9..d175dfb3 100644
--- a/src/components/content-presentation/table/components/__tests__/__snapshots__/TableCaption.test.tsx.snap
+++ b/src/components/content-presentation/table/components/__tests__/__snapshots__/TableCaption.test.tsx.snap
@@ -2,8 +2,70 @@
exports[`TableCaption matches snapshot 1`] = `
+`;
+
+exports[`TableCaption renders with custom size l 1`] = `
+
+`;
+
+exports[`TableCaption renders with custom size m 1`] = `
+
+`;
+
+exports[`TableCaption renders with custom size s 1`] = `
+
+`;
+
+exports[`TableCaption renders with custom size xl 1`] = `
+
`;
diff --git a/src/components/content-presentation/table/components/__tests__/__snapshots__/TableCell.test.tsx.snap b/src/components/content-presentation/table/components/__tests__/__snapshots__/TableCell.test.tsx.snap
index 0d21a21f..d636c4f2 100644
--- a/src/components/content-presentation/table/components/__tests__/__snapshots__/TableCell.test.tsx.snap
+++ b/src/components/content-presentation/table/components/__tests__/__snapshots__/TableCell.test.tsx.snap
@@ -2,8 +2,22 @@
exports[`Table.Cell matches snapshot 1`] = `
`;
diff --git a/src/components/content-presentation/table/components/__tests__/__snapshots__/TableHead.test.tsx.snap b/src/components/content-presentation/table/components/__tests__/__snapshots__/TableHead.test.tsx.snap
index 97024eca..90e2ca0b 100644
--- a/src/components/content-presentation/table/components/__tests__/__snapshots__/TableHead.test.tsx.snap
+++ b/src/components/content-presentation/table/components/__tests__/__snapshots__/TableHead.test.tsx.snap
@@ -2,8 +2,12 @@
exports[`Table.Head matches snapshot 1`] = `
`;
diff --git a/src/components/content-presentation/table/index.ts b/src/components/content-presentation/table/index.ts
index de4c7d5e..ae769447 100644
--- a/src/components/content-presentation/table/index.ts
+++ b/src/components/content-presentation/table/index.ts
@@ -1,3 +1 @@
-import Table from './Table';
-
-export default Table;
+export { default } from './Table';
diff --git a/src/components/content-presentation/tabs/Tabs.tsx b/src/components/content-presentation/tabs/Tabs.tsx
index edfcf250..b5fcf045 100644
--- a/src/components/content-presentation/tabs/Tabs.tsx
+++ b/src/components/content-presentation/tabs/Tabs.tsx
@@ -1,73 +1,94 @@
-'use client';
import classNames from 'classnames';
-import React, { FC, HTMLAttributes, useEffect } from 'react';
-import HeadingLevel, { HeadingLevelType } from '@components/utils/HeadingLevel';
-import TabsJs from '@resources/tabs';
+import React, {
+ ComponentPropsWithoutRef,
+ FC,
+ createRef,
+ forwardRef,
+ useEffect,
+ useState,
+} from 'react';
+import HeadingLevel, { HeadingLevelProps } from '@components/utils/HeadingLevel';
+import { type Tabs } from 'nhsuk-frontend';
-type TabsProps = HTMLAttributes;
+export type TabsProps = ComponentPropsWithoutRef<'div'>;
-type TabTitleProps = { children: React.ReactNode; headingLevel?: HeadingLevelType };
+export type TabTitleProps = HeadingLevelProps;
-type TabListProps = {
- children: React.ReactNode;
-};
+export type TabListProps = ComponentPropsWithoutRef<'ul'>;
-type TabListItemProps = {
+export interface TabListItemProps extends ComponentPropsWithoutRef<'a'> {
id: string;
- children: React.ReactNode;
-};
+}
-type TabContentsProps = {
+export interface TabContentsProps extends ComponentPropsWithoutRef<'div'> {
id: string;
- children: React.ReactNode;
-};
+}
-const TabTitle: FC = ({ children, headingLevel = 'h2' }) => (
-
+const TabTitle: FC = ({ children, headingLevel = 'h2', ...rest }) => (
+
{children}
);
-const TabList: FC = ({ children }) => (
-
+const TabList: FC = ({ children, ...rest }) => (
+
);
-const TabListItem: FC = ({ id, children }) => (
+const TabListItem: FC = ({ children, id, ...rest }) => (
-
+
{children}
);
-const TabContents: FC = ({ id, children }) => (
-
+const TabContents: FC
= ({ children, id, ...rest }) => (
+
{children}
);
-interface Tabs extends FC {
- Title: FC;
- List: FC;
- ListItem: FC;
- Contents: FC;
-}
+const TabsComponent = forwardRef((props, forwardedRef) => {
+ const { children, className, ...rest } = props;
+
+ const [moduleRef] = useState(() => forwardedRef || createRef());
+ const [instance, setInstance] = useState();
-const Tabs: Tabs = ({ className, children, ...rest }) => {
useEffect(() => {
- TabsJs();
- }, []);
+ if (!('current' in moduleRef) || !moduleRef.current || instance) {
+ return;
+ }
+
+ const { current: $root } = moduleRef;
+
+ import('nhsuk-frontend').then(({ Tabs }) => {
+ setInstance(new Tabs($root));
+ });
+ }, [moduleRef, instance]);
return (
-
+
{children}
);
-};
+});
-Tabs.Title = TabTitle;
-Tabs.List = TabList;
-Tabs.ListItem = TabListItem;
-Tabs.Contents = TabContents;
+TabsComponent.displayName = 'Tabs';
+TabTitle.displayName = 'Tabs.Title';
+TabList.displayName = 'Tabs.List';
+TabListItem.displayName = 'Tabs.ListItem';
+TabContents.displayName = 'Tabs.Contents';
-export default Tabs;
+export default Object.assign(TabsComponent, {
+ Title: TabTitle,
+ List: TabList,
+ ListItem: TabListItem,
+ Contents: TabContents,
+});
diff --git a/src/components/content-presentation/tabs/__tests__/Tabs.test.tsx b/src/components/content-presentation/tabs/__tests__/Tabs.test.tsx
index 7f332f2a..c9508666 100644
--- a/src/components/content-presentation/tabs/__tests__/Tabs.test.tsx
+++ b/src/components/content-presentation/tabs/__tests__/Tabs.test.tsx
@@ -1,11 +1,11 @@
-import React from 'react';
-import { fireEvent, render } from '@testing-library/react';
-import Tabs from '../Tabs';
-import { HeadingLevelType } from '@components/utils/HeadingLevel';
-
-describe('The tabs component', () => {
- it('Matches the snapshot', () => {
- const { container } = render(
+import React, { createRef } from 'react';
+import { render } from '@testing-library/react';
+import { renderClient, renderServer } from '@util/components';
+import Tabs, { TabTitleProps } from '../Tabs';
+
+describe('Tabs', () => {
+ it('matches snapshot', async () => {
+ const { container } = await renderClient(
Contents
@@ -26,13 +26,69 @@ describe('The tabs component', () => {
Past month contents go here
,
+ { moduleName: 'nhsuk-tabs' },
);
expect(container).toMatchSnapshot();
});
- it('Switches the visibility of tabs when clicked', () => {
- const { container } = render(
+ it('matches snapshot (via server)', async () => {
+ const { container, element } = await renderServer(
+
+ Contents
+
+ Past day
+ Past week
+ Past month
+
+
+
+ Past day contents go here
+
+
+
+ Past week contents go here
+
+
+
+ Past month contents go here
+
+ ,
+ { moduleName: 'nhsuk-tabs' },
+ );
+
+ expect(container).toMatchSnapshot('server');
+
+ await renderClient(element, {
+ moduleName: 'nhsuk-tabs',
+ hydrate: true,
+ container,
+ });
+
+ expect(container).toMatchSnapshot('client');
+ });
+
+ it('forwards refs', async () => {
+ const ref = createRef
();
+
+ const { container } = await renderClient(
+
+
+ Tab One
+ Tab Two
+
+ ,
+ { moduleName: 'nhsuk-tabs' },
+ );
+
+ const tabsEl = container.querySelector('div');
+
+ expect(ref.current).toBe(tabsEl);
+ expect(ref.current).toHaveClass('nhsuk-tabs');
+ });
+
+ it('switches visibility of tabs when clicked', async () => {
+ const { container } = await renderClient(
Contents
@@ -48,52 +104,39 @@ describe('The tabs component', () => {
Tab two contents go here
,
+ { moduleName: 'nhsuk-tabs' },
);
- const firstTabLink = container.querySelector('#tab_tab-one');
- const secondTabLink = container.querySelector('#tab_tab-two');
+ const firstTabLink = container.querySelector('#tab_tab-one');
+ const secondTabLink = container.querySelector('#tab_tab-two');
- expect(
- firstTabLink?.parentElement?.classList.contains('nhsuk-tabs__list-item--selected'),
- ).toEqual(true);
- expect(
- secondTabLink?.parentElement?.classList.contains('nhsuk-tabs__list-item--selected'),
- ).toEqual(false);
+ expect(firstTabLink?.parentElement).toHaveClass('nhsuk-tabs__list-item--selected');
+ expect(secondTabLink?.parentElement).not.toHaveClass('nhsuk-tabs__list-item--selected');
- fireEvent.click(secondTabLink!);
+ secondTabLink?.click();
- expect(
- firstTabLink?.parentElement?.classList.contains('nhsuk-tabs__list-item--selected'),
- ).toEqual(false);
- expect(
- secondTabLink?.parentElement?.classList.contains('nhsuk-tabs__list-item--selected'),
- ).toEqual(true);
+ expect(firstTabLink?.parentElement).not.toHaveClass('nhsuk-tabs__list-item--selected');
+ expect(secondTabLink?.parentElement).toHaveClass('nhsuk-tabs__list-item--selected');
});
- describe('The tabs title', () => {
- it.each`
- headingLevel
- ${undefined}
- ${'H1'}
- ${'H2'}
- ${'H3'}
- ${'H4'}
- `(
- 'Renders the chosen heading level $headingLevel if specified',
- ({ headingLevel }: { headingLevel: HeadingLevelType }) => {
- const { container } = render(
- Test title ,
- );
-
- const title = container.querySelector('.nhsuk-tabs__title');
-
- expect(title?.nodeName).toEqual(headingLevel ?? 'H2');
- },
- );
+ describe('Tabs.Title', () => {
+ it.each([
+ undefined,
+ { headingLevel: 'h1' },
+ { headingLevel: 'h2' },
+ { headingLevel: 'h3' },
+ { headingLevel: 'h4' },
+ ])('renders heading level $headingLevel if specified', (props) => {
+ const { container } = render(Test title );
+
+ const title = container.querySelector('.nhsuk-tabs__title');
+
+ expect(title).toHaveProperty('tagName', props?.headingLevel?.toUpperCase() ?? 'H2');
+ });
});
- describe('The tab list', () => {
- it('Renders the expected children', () => {
+ describe('Tabs.List', () => {
+ it('renders expected children', () => {
const { container } = render(
@@ -106,8 +149,8 @@ describe('The tabs component', () => {
});
});
- describe('The tab list item', () => {
- it('Sets the href to be the passed in id prop', () => {
+ describe('Tabs.ListItem', () => {
+ it('sets the href to be the passed in id prop', () => {
const { container } = render(
@@ -117,30 +160,34 @@ describe('The tabs component', () => {
expect(container.querySelector('.nhsuk-tabs__tab')?.getAttribute('href')).toBe('#test-id');
});
- it('Renders the expected children', () => {
+ it('renders expected children', () => {
const { container } = render(
,
);
- const tabElement = container.querySelector('.nhsuk-tabs__tab');
+ const tabsItemEl = container.querySelector('.nhsuk-tabs__list-item a');
+ const tabsItemContentsEl = container.querySelector('#list-item-contents');
- expect(tabElement?.querySelector('#list-item-contents')).toBeTruthy();
+ expect(tabsItemEl).toHaveAttribute('href', '#test-id');
+ expect(tabsItemEl).toContainElement(tabsItemContentsEl);
});
});
- describe('The tab contents', () => {
- it('Renders the expected children', () => {
+ describe('Tab.Contents', () => {
+ it('renders expected children', () => {
const { container } = render(
,
);
- const tabElement = container.querySelector('#test-contents');
+ const tabsPanelEl = container.querySelector('.nhsuk-tabs__panel');
+ const tabsPanelContentsEl = container.querySelector('#tab-contents');
- expect(tabElement?.querySelector('#tab-contents')).toBeTruthy();
+ expect(tabsPanelEl).toHaveAttribute('id', 'test-contents');
+ expect(tabsPanelEl).toContainElement(tabsPanelContentsEl);
});
});
});
diff --git a/src/components/content-presentation/tabs/__tests__/__snapshots__/Tabs.test.tsx.snap b/src/components/content-presentation/tabs/__tests__/__snapshots__/Tabs.test.tsx.snap
index 01685569..6455e409 100644
--- a/src/components/content-presentation/tabs/__tests__/__snapshots__/Tabs.test.tsx.snap
+++ b/src/components/content-presentation/tabs/__tests__/__snapshots__/Tabs.test.tsx.snap
@@ -1,10 +1,183 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`The tabs component Matches the snapshot 1`] = `
+exports[`Tabs matches snapshot (via server): client 1`] = `
+
+ Contents
+
+
+
+
+ Past day contents go here
+
+
+
+
+ Past week contents go here
+
+
+
+
+ Past month contents go here
+
+
+
+
+`;
+
+exports[`Tabs matches snapshot (via server): server 1`] = `
+
+
+
+ Contents
+
+
+
+
+ Past day contents go here
+
+
+
+
+ Past week contents go here
+
+
+
+
+ Past month contents go here
+
+
+
+
+`;
+
+exports[`Tabs matches snapshot 1`] = `
+
+
{
- color?:
+export interface TagProps extends ComponentPropsWithoutRef<'strong'> {
+ modifier?:
| 'white'
| 'grey'
| 'green'
@@ -13,13 +13,20 @@ interface TagProps extends HTMLProps {
| 'red'
| 'orange'
| 'yellow';
+
+ /**
+ * @deprecated Use `modifier` instead.
+ */
+ color?: TagProps['modifier'];
}
-const Tag: FC = ({ className, color, ...rest }) => (
+const TagComponent: FC = ({ className, color, modifier = color, ...rest }) => (
);
-export default Tag;
+TagComponent.displayName = 'Tag';
+
+export default TagComponent;
diff --git a/src/components/content-presentation/tag/__tests__/Tag.test.tsx b/src/components/content-presentation/tag/__tests__/Tag.test.tsx
index 87202886..419e2a96 100644
--- a/src/components/content-presentation/tag/__tests__/Tag.test.tsx
+++ b/src/components/content-presentation/tag/__tests__/Tag.test.tsx
@@ -1,4 +1,4 @@
-import React, { ComponentProps } from 'react';
+import React, { ComponentPropsWithoutRef } from 'react';
import { render } from '@testing-library/react';
import Tag from '../Tag';
@@ -15,7 +15,7 @@ describe('Tag', () => {
expect(container.querySelector('strong.nhsuk-tag')).toBeTruthy();
});
- it.each['color']>([
+ it.each['modifier']>([
'white',
'grey',
'green',
@@ -27,7 +27,7 @@ describe('Tag', () => {
'orange',
'yellow',
])('adds colour class %s ', (colour) => {
- const { container } = render( );
+ const { container } = render( );
expect(container.querySelector(`strong.nhsuk-tag.nhsuk-tag--${colour}`)).toBeTruthy();
});
diff --git a/src/components/content-presentation/tag/index.ts b/src/components/content-presentation/tag/index.ts
index efd8e04b..15774bff 100644
--- a/src/components/content-presentation/tag/index.ts
+++ b/src/components/content-presentation/tag/index.ts
@@ -1,3 +1 @@
-import Tag from './Tag';
-
-export default Tag;
+export { default } from './Tag';
diff --git a/src/components/content-presentation/warning-callout/WarningCallout.tsx b/src/components/content-presentation/warning-callout/WarningCallout.tsx
index e908c6ee..47d36608 100644
--- a/src/components/content-presentation/warning-callout/WarningCallout.tsx
+++ b/src/components/content-presentation/warning-callout/WarningCallout.tsx
@@ -1,35 +1,37 @@
-import React, { FC, HTMLProps } from 'react';
+import React, { ComponentPropsWithoutRef, FC, forwardRef } from 'react';
import classNames from 'classnames';
-import HeadingLevel, { HeadingLevelType } from '@components/utils/HeadingLevel';
+import HeadingLevel, { HeadingLevelProps } from '@components/utils/HeadingLevel';
-interface WarningCalloutLabelProps extends HTMLProps {
- headingLevel?: HeadingLevelType;
- visuallyHiddenText?: string | false;
-}
-
-const WarningCalloutLabel: FC = ({
- className,
- visuallyHiddenText = 'Important: ',
- children,
- ...rest
-}) => (
+const WarningCalloutHeading: FC = ({ children, className, ...rest }) => (
- {/* eslint-disable-next-line jsx-a11y/aria-role */}
-
- {visuallyHiddenText && {visuallyHiddenText} }
- {children}
-
+ {children?.toString().toLowerCase().includes('important') ? (
+ <>
+ {children}
+ :
+ >
+ ) : (
+ <>
+ {/* eslint-disable-next-line jsx-a11y/aria-role */}
+
+ Important:
+ {children}
+
+ >
+ )}
);
-interface IWarningCallout extends FC> {
- Label: typeof WarningCalloutLabel;
-}
+type WarningCalloutProps = ComponentPropsWithoutRef<'div'>;
-const WarningCallout: IWarningCallout = ({ className, ...rest }) => (
-
+const WarningCalloutComponent = forwardRef(
+ ({ className, ...rest }, forwardedRef) => (
+
+ ),
);
-WarningCallout.Label = WarningCalloutLabel;
+WarningCalloutComponent.displayName = 'WarningCallout';
+WarningCalloutHeading.displayName = 'WarningCallout.Heading';
-export default WarningCallout;
+export default Object.assign(WarningCalloutComponent, {
+ Heading: WarningCalloutHeading,
+});
diff --git a/src/components/content-presentation/warning-callout/__tests__/WarningCallout.test.tsx b/src/components/content-presentation/warning-callout/__tests__/WarningCallout.test.tsx
index 90ea5950..1c2b8109 100644
--- a/src/components/content-presentation/warning-callout/__tests__/WarningCallout.test.tsx
+++ b/src/components/content-presentation/warning-callout/__tests__/WarningCallout.test.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { createRef } from 'react';
import { render } from '@testing-library/react';
import WarningCallout from '../WarningCallout';
@@ -6,57 +6,48 @@ describe('WarningCallout', () => {
it('matches snapshot', () => {
const { container } = render(
- School, nursery or work
+ Important
- Stay away from school, nursery or work until all the spots have crusted over. This is
- usually 5 days after the spots first appeared.
+ For safety, tell your doctor or pharmacist if you're taking any other medicines,
+ including herbal medicines, vitamins or supplements.
,
);
- expect(container).toMatchSnapshot();
+ expect(container).toMatchSnapshot('WarningCallout');
});
- it('adds default visually hidden text', () => {
- const { container } = render(
-
- School, nursery or work
-
- Stay away from school, nursery or work until all the spots have crusted over. This is
- usually 5 days after the spots first appeared.
-
- ,
- );
+ it('forwards refs', () => {
+ const ref = createRef();
- expect(container.querySelector('.nhsuk-warning-callout__label')?.textContent).toBe(
- 'Important: School, nursery or work',
- );
+ const { container } = render( );
+
+ const warningCalloutEl = container.querySelector('div');
+
+ expect(ref.current).toBe(warningCalloutEl);
+ expect(ref.current).toHaveClass('nhsuk-warning-callout');
});
- it('adds custom visually hidden text', () => {
+ it('omits visually hidden text when unnecessary', () => {
const { container } = render(
-
- School, nursery or work
-
+ Important
- Stay away from school, nursery or work until all the spots have crusted over. This is
- usually 5 days after the spots first appeared.
+ For safety, tell your doctor or pharmacist if you're taking any other medicines,
+ including herbal medicines, vitamins or supplements.
,
);
expect(container.querySelector('.nhsuk-warning-callout__label')?.textContent).toBe(
- 'Not Very Important: School, nursery or work',
+ 'Important:',
);
});
- it('can disable visually hidden text', () => {
+ it('adds visually hidden text when necessary', () => {
const { container } = render(
-
- School, nursery or work
-
+ School, nursery or work
Stay away from school, nursery or work until all the spots have crusted over. This is
usually 5 days after the spots first appeared.
@@ -64,8 +55,10 @@ describe('WarningCallout', () => {
,
);
+ expect(container).toMatchSnapshot('WarningCalloutWithTextRole');
+
expect(container.querySelector('.nhsuk-warning-callout__label')?.textContent).toBe(
- 'School, nursery or work',
+ 'Important: School, nursery or work',
);
});
});
diff --git a/src/components/content-presentation/warning-callout/__tests__/__snapshots__/WarningCallout.test.tsx.snap b/src/components/content-presentation/warning-callout/__tests__/__snapshots__/WarningCallout.test.tsx.snap
index 5678f501..d9aad134 100644
--- a/src/components/content-presentation/warning-callout/__tests__/__snapshots__/WarningCallout.test.tsx.snap
+++ b/src/components/content-presentation/warning-callout/__tests__/__snapshots__/WarningCallout.test.tsx.snap
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`WarningCallout matches snapshot 1`] = `
+exports[`WarningCallout adds visually hidden text when necessary: WarningCalloutWithTextRole 1`] = `
`;
+
+exports[`WarningCallout matches snapshot: WarningCallout 1`] = `
+
+
+
+ Important
+
+ :
+
+
+
+ For safety, tell your doctor or pharmacist if you're taking any other medicines, including herbal medicines, vitamins or supplements.
+
+
+
+`;
diff --git a/src/components/content-presentation/warning-callout/index.ts b/src/components/content-presentation/warning-callout/index.ts
index ab0690ea..8886011c 100644
--- a/src/components/content-presentation/warning-callout/index.ts
+++ b/src/components/content-presentation/warning-callout/index.ts
@@ -1,3 +1 @@
-import WarningCallout from './WarningCallout';
-
-export default WarningCallout;
+export { default } from './WarningCallout';
diff --git a/src/components/form-elements/button/Button.tsx b/src/components/form-elements/button/Button.tsx
index 11cd64c0..6f3ce2a0 100644
--- a/src/components/form-elements/button/Button.tsx
+++ b/src/components/form-elements/button/Button.tsx
@@ -1,164 +1,153 @@
-import React, {
- EventHandler,
- FC,
- HTMLProps,
- KeyboardEvent,
- SyntheticEvent,
- useCallback,
- useRef,
-} from 'react';
+import React, { ForwardedRef, MouseEvent, useEffect, useState, forwardRef, createRef } from 'react';
+import { AsElementLink } from '@util/types/LinkTypes';
+import { type Button } from 'nhsuk-frontend';
import classNames from 'classnames';
-// Debounce timeout - default 1 second
-export const DefaultButtonDebounceTimeout = 1000;
-
-export interface ButtonProps extends HTMLProps
{
- type?: 'button' | 'submit' | 'reset';
- disabled?: boolean;
+export interface ButtonProps extends AsElementLink {
+ href?: never;
secondary?: boolean;
reverse?: boolean;
warning?: boolean;
as?: 'button';
preventDoubleClick?: boolean;
- debounceTimeout?: number;
}
-export interface ButtonLinkProps extends HTMLProps {
- disabled?: boolean;
+export interface ButtonLinkProps extends AsElementLink {
+ href: string;
+ type?: never;
secondary?: boolean;
reverse?: boolean;
warning?: boolean;
as?: 'a';
preventDoubleClick?: boolean;
- debounceTimeout?: number;
}
-const useDebounceTimeout = (
- fn?: EventHandler,
- timeout: number = DefaultButtonDebounceTimeout,
-) => {
- const timeoutRef = useRef();
-
- if (!fn) return undefined;
-
- const handler: EventHandler = (event) => {
- event.persist();
-
- if (timeoutRef.current) {
- event.preventDefault();
- event.stopPropagation();
+const ButtonComponent = forwardRef((props, forwardedRef) => {
+ const {
+ className,
+ asElement: Element = 'button',
+ disabled,
+ secondary,
+ reverse,
+ warning,
+ type = 'submit',
+ preventDoubleClick,
+ onClick,
+ ...rest
+ } = props;
+
+ const [moduleRef] = useState(() => forwardedRef || createRef());
+ const [instance, setInstance] = useState();
+
+ useEffect(() => {
+ if (!('current' in moduleRef) || !moduleRef.current || instance) {
return;
}
- fn(event);
-
- timeoutRef.current = window.setTimeout(() => {
- timeoutRef.current = undefined;
- }, timeout);
- };
-
- return handler;
-};
-
-export const Button: FC = ({
- className,
- disabled,
- secondary,
- reverse,
- warning,
- type = 'submit',
- preventDoubleClick = false,
- debounceTimeout = DefaultButtonDebounceTimeout,
- onClick,
- ...rest
-}) => {
- const debouncedHandleClick = useDebounceTimeout(onClick, debounceTimeout);
+ const { current: $root } = moduleRef;
+
+ import('nhsuk-frontend').then(({ Button }) => {
+ setInstance(new Button($root));
+ });
+ }, [moduleRef, instance]);
return (
- // eslint-disable-next-line react/button-has-type
- ) => {
+ if (event.nativeEvent.defaultPrevented) {
+ event.preventDefault();
+ return;
+ }
+
+ onClick?.(event);
+ }}
+ ref={moduleRef}
{...rest}
/>
);
-};
-export const ButtonLink: FC = ({
- className,
- role = 'button',
- draggable = false,
- children,
- disabled,
- secondary,
- reverse,
- warning,
- preventDoubleClick = false,
- debounceTimeout = DefaultButtonDebounceTimeout,
- onClick,
- ...rest
-}) => {
- const debouncedHandleClick = useDebounceTimeout(onClick, debounceTimeout);
-
- /**
- * Recreate the shim behaviour from NHS.UK/GOV.UK Frontend
- * https://github.com/alphagov/govuk-frontend/blob/main/packages/govuk-frontend/src/govuk/components/button/button.mjs
- * https://github.com/nhsuk/nhsuk-frontend/blob/main/packages/components/button/button.js
- */
- const handleKeyDown = useCallback(
- (event: KeyboardEvent) => {
- const { currentTarget } = event;
-
- if (role === 'button' && event.key === ' ') {
- event.preventDefault();
- currentTarget.click();
+});
+
+const ButtonLinkComponent = forwardRef(
+ (props, forwardedRef) => {
+ const {
+ className,
+ asElement: Element = 'a',
+ secondary,
+ reverse,
+ warning,
+ preventDoubleClick,
+ onClick,
+ ...rest
+ } = props;
+
+ const [moduleRef] = useState(() => forwardedRef || createRef());
+ const [instance, setInstance] = useState();
+
+ useEffect(() => {
+ if (!('current' in moduleRef) || !moduleRef.current || instance) {
+ return;
}
- },
- [role],
- );
- return (
-
- {children}
-
+ const { current: $root } = moduleRef;
+
+ import('nhsuk-frontend').then(({ Button }) => {
+ setInstance(new Button($root));
+ });
+ }, [moduleRef, instance]);
+
+ return (
+ ) => {
+ if (event.nativeEvent.defaultPrevented) {
+ event.preventDefault();
+ return;
+ }
+
+ onClick?.(event);
+ }}
+ ref={moduleRef}
+ {...rest}
+ />
+ );
+ },
+);
+
+const ButtonWrapper = forwardRef<
+ HTMLAnchorElement | HTMLButtonElement,
+ ButtonLinkProps | ButtonProps
+>((props, forwardedRef) => {
+ return props.as === 'a' || ('href' in props && typeof props.href === 'string') ? (
+ } {...props} />
+ ) : (
+ } {...props} />
);
-};
-
-const ButtonWrapper: FC = ({ href, as, ...rest }) => {
- if (as === 'a') {
- return ;
- }
- if (as === 'button') {
- return ;
- }
- if (href) {
- return ;
- }
- return ;
-};
+});
+
+ButtonLinkComponent.displayName = 'Button.Link';
+ButtonComponent.displayName = 'Button';
+ButtonWrapper.displayName = 'Button';
export default ButtonWrapper;
diff --git a/src/components/form-elements/button/__tests__/Button.test.tsx b/src/components/form-elements/button/__tests__/Button.test.tsx
index 3b1660ae..f5213020 100644
--- a/src/components/form-elements/button/__tests__/Button.test.tsx
+++ b/src/components/form-elements/button/__tests__/Button.test.tsx
@@ -1,256 +1,314 @@
-import React from 'react';
-import { render } from '@testing-library/react';
-import ButtonWrapper, { ButtonLink, Button } from '../Button';
+import React, { ComponentProps, Ref, createRef, forwardRef } from 'react';
+import { renderClient, renderServer } from '@util/components';
+import Button from '../Button';
describe('Button', () => {
- it('matches snapshot', () => {
- const { container } = render(Submit );
+ it('matches snapshot', async () => {
+ const { container } = await renderClient(Save and continue , {
+ moduleName: 'nhsuk-button',
+ });
+
+ expect(container).toMatchSnapshot();
+ });
+
+ it('matches snapshot (via server)', async () => {
+ const { container, element } = await renderServer(
+ Save and continue ,
+ { moduleName: 'nhsuk-button' },
+ );
+
+ expect(container).toMatchSnapshot('server');
+
+ await renderClient(element, {
+ moduleName: 'nhsuk-button',
+ hydrate: true,
+ container,
+ });
+
+ expect(container).toMatchSnapshot('client');
+ });
+
+ it('forwards refs', async () => {
+ const ref1 = createRef();
+ const ref2 = createRef();
+
+ const { modules } = await renderClient(
+ <>
+
+ Save and continue
+
+
+ Cancel
+
+ >,
+ { moduleName: 'nhsuk-button' },
+ );
+
+ const [buttonEl1, buttonEl2] = modules;
+
+ expect(ref1.current).toBe(buttonEl1);
+ expect(ref1.current).toHaveClass('nhsuk-button');
- expect(container).toMatchSnapshot('PlainButton');
+ expect(ref2.current).toBe(buttonEl2);
+ expect(ref2.current).toHaveClass('nhsuk-button');
});
- it('renders child text as expected', () => {
- const { container } = render(Submit );
+ it('renders child text as expected', async () => {
+ const { modules } = await renderClient(Save and continue , {
+ moduleName: 'nhsuk-button',
+ });
- expect(container.querySelector('button')?.textContent).toEqual('Submit');
+ const [buttonEl] = modules;
+ expect(buttonEl).toHaveTextContent('Save and continue');
+ });
+
+ it('renders as custom element', async () => {
+ function CustomLink(
+ { children, href, ...rest }: ComponentProps<'a'>,
+ ref: Ref,
+ ) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ function CustomButton(props: ComponentProps<'button'>, ref: Ref) {
+ return ;
+ }
+
+ const { modules } = await renderClient(
+ <>
+ Save and continue
+
+ Cancel
+
+ >,
+ { moduleName: 'nhsuk-button' },
+ );
+
+ const [buttonEl1, buttonEl2] = modules;
+
+ expect(buttonEl1).toHaveTextContent('Save and continue');
+ expect(buttonEl1.dataset).toHaveProperty('customButton', 'true');
+
+ expect(buttonEl2).toHaveTextContent('Cancel');
+ expect(buttonEl2.dataset).toHaveProperty('customLink', 'true');
});
describe('disabled', () => {
- it('matches snapshot', () => {
- const { container } = render(Submit );
+ it('matches snapshot', async () => {
+ const { container } = await renderClient(Save and continue , {
+ moduleName: 'nhsuk-button',
+ });
expect(container).toMatchSnapshot('DisabledButton');
});
- it('adds correct classes for button type', () => {
- const { container } = render(Submit );
+ it('adds correct attributes for button type', async () => {
+ const { modules } = await renderClient(Save and continue , {
+ moduleName: 'nhsuk-button',
+ });
- expect(container.querySelector('.nhsuk-button--disabled')).toBeTruthy();
+ const [buttonEl] = modules;
+ expect(buttonEl).toBeDisabled();
});
});
describe('secondary', () => {
- it('matches snapshot', () => {
- const { container } = render(Submit );
+ it('matches snapshot', async () => {
+ const { container } = await renderClient(Save and continue , {
+ moduleName: 'nhsuk-button',
+ });
expect(container).toMatchSnapshot('SecondaryButton');
});
- it('adds correct classes for button type', () => {
- const { container } = render(Submit );
+ it('adds correct classes for button type', async () => {
+ const { modules } = await renderClient(Save and continue , {
+ moduleName: 'nhsuk-button',
+ });
- expect(container.querySelector('.nhsuk-button--secondary')).toBeTruthy();
+ const [buttonEl] = modules;
+ expect(buttonEl).toHaveClass('nhsuk-button--secondary');
});
});
describe('reverse', () => {
- it('matches snapshot', () => {
- const { container } = render(Submit );
+ it('matches snapshot', async () => {
+ const { container } = await renderClient(Save and continue , {
+ moduleName: 'nhsuk-button',
+ });
expect(container).toMatchSnapshot('ReverseButton');
});
- it('adds correct classes for button type', () => {
- const { container } = render(Submit );
+ it('adds correct classes for button type', async () => {
+ const { modules } = await renderClient(Save and continue , {
+ moduleName: 'nhsuk-button',
+ });
- expect(container.querySelector('.nhsuk-button--reverse')).toBeTruthy();
+ const [buttonEl] = modules;
+ expect(buttonEl).toHaveClass('nhsuk-button--reverse');
});
});
- it('adds aria props and disabled to disabled button', () => {
- const { container } = render(Submit );
-
- expect(
- container.querySelector('button.nhsuk-button.nhsuk-button--disabled')?.getAttribute('type'),
- ).toBe('submit');
- expect(
- container
- .querySelector('button.nhsuk-button.nhsuk-button--disabled')
- ?.getAttribute('aria-disabled'),
- ).toBe('true');
- expect(container.querySelector('button.nhsuk-button.nhsuk-button--disabled')).toBeDisabled();
+ it('adds aria props and disabled to disabled button', async () => {
+ const { modules } = await renderClient(Save and continue , {
+ moduleName: 'nhsuk-button',
+ });
+
+ const [buttonEl] = modules;
+
+ expect(buttonEl).toBeDisabled();
+ expect(buttonEl).toHaveAttribute('type', 'submit');
+ expect(buttonEl).toHaveAttribute('aria-disabled', 'true');
});
- it('preventDoubleClick calls debounced function', () => {
+ it('preventDoubleClick calls debounced function', async () => {
jest.useFakeTimers();
const clickHandler = jest.fn();
- const { container } = render(
+ const { modules } = await renderClient(
- Submit
+ Save and continue
,
+ { moduleName: 'nhsuk-button' },
);
-
- const button = container.querySelector('button');
-
- button?.click();
+
+ const [buttonEl] = modules;
+
+ buttonEl.click();
expect(clickHandler).toHaveBeenCalledTimes(1);
- button?.click();
+ buttonEl.click();
expect(clickHandler).toHaveBeenCalledTimes(1);
jest.runAllTimers();
- button?.click();
- expect(clickHandler).toHaveBeenCalledTimes(2);
- });
-
- it('preventDoubleClick=false calls original function', () => {
- const clickHandler = jest.fn();
- const { container } = render(
-
- Submit
- ,
- );
-
- const button = container.querySelector('button');
- button?.click();
- expect(clickHandler).toHaveBeenCalledTimes(1);
-
- button?.click();
+ buttonEl.click();
expect(clickHandler).toHaveBeenCalledTimes(2);
-
- button?.click();
- expect(clickHandler).toHaveBeenCalledTimes(3);
});
- it('uses custom debounce timeout', () => {
- jest.useFakeTimers();
-
+ it('preventDoubleClick=false calls original function', async () => {
const clickHandler = jest.fn();
- const { container } = render(
-
- Submit
+ const { modules } = await renderClient(
+
+ Save and continue
,
+ { moduleName: 'nhsuk-button' },
);
- const button = container.querySelector('button');
- button?.click();
- expect(clickHandler).toHaveBeenCalledTimes(1);
-
- button?.click();
- expect(clickHandler).toHaveBeenCalledTimes(1);
+ const [buttonEl] = modules;
- jest.advanceTimersByTime(4999);
- button?.click();
+ buttonEl.click();
expect(clickHandler).toHaveBeenCalledTimes(1);
- jest.advanceTimersByTime(1);
- button?.click();
+ buttonEl.click();
expect(clickHandler).toHaveBeenCalledTimes(2);
+
+ buttonEl.click();
+ expect(clickHandler).toHaveBeenCalledTimes(3);
});
});
-describe('ButtonLink', () => {
- it('matches snapshot', () => {
- const { container } = render(Submit );
+describe('Button as a link', () => {
+ it('matches snapshot', async () => {
+ const { container } = await renderClient(Find my location , {
+ moduleName: 'nhsuk-button',
+ });
- expect(container).toMatchSnapshot('PlainButton');
+ expect(container).toMatchSnapshot('LinkButton');
});
- it('renders child text as expected', () => {
- const { container } = render(Submit );
+ it('renders child text as expected', async () => {
+ const { modules } = await renderClient(Find my location , {
+ moduleName: 'nhsuk-button',
+ });
- expect(container.querySelector('a')?.textContent).toEqual('Submit');
+ const [buttonEl] = modules;
+ expect(buttonEl).toHaveTextContent('Find my location');
});
describe('button types', () => {
- describe('disabled', () => {
- it('matches snapshot', () => {
- const { container } = render(
-
- Submit
- ,
- );
-
- expect(container).toMatchSnapshot('DisabledButton');
- });
-
- it('adds correct classes for type - disabled', () => {
- const { container } = render(
-
- Submit
- ,
- );
-
- expect(container.querySelector('.nhsuk-button--disabled')).toBeTruthy();
- });
- });
-
describe('secondary', () => {
- it('matches snapshot', () => {
- const { container } = render(
-
- Submit
- ,
+ it('matches snapshot', async () => {
+ const { container } = await renderClient(
+
+ Find my location
+ ,
+ { moduleName: 'nhsuk-button' },
);
expect(container).toMatchSnapshot('SecondaryButton');
});
- it('adds correct classes for type - secondary', () => {
- const { container } = render(
-
- Submit
- ,
+ it('adds correct classes for type - secondary', async () => {
+ const { modules } = await renderClient(
+
+ Find my location
+ ,
+ { moduleName: 'nhsuk-button' },
);
- expect(container.querySelector('.nhsuk-button--secondary')).toBeTruthy();
+ const [buttonEl] = modules;
+ expect(buttonEl).toHaveClass('nhsuk-button--secondary');
});
});
describe('reverse', () => {
- it('matches snapshot', () => {
- const { container } = render(
-
- Submit
- ,
+ it('matches snapshot', async () => {
+ const { container } = await renderClient(
+
+ Log out
+ ,
+ { moduleName: 'nhsuk-button' },
);
expect(container).toMatchSnapshot('ReverseButton');
});
- it('adds correct classes for type - reverse', () => {
- const { container } = render(
-
- Submit
- ,
+ it('adds correct classes for type - reverse', async () => {
+ const { modules } = await renderClient(
+
+ Log out
+ ,
+ { moduleName: 'nhsuk-button' },
);
- expect(container.querySelector('.nhsuk-button--reverse')).toBeTruthy();
+ const [buttonEl] = modules;
+ expect(buttonEl).toHaveClass('nhsuk-button--reverse');
});
});
});
-
- it('adds aria disabled props to disabled button', () => {
- const { container } = render(
-
- Submit
- ,
- );
-
- expect(
- container.querySelector('a.nhsuk-button.nhsuk-button--disabled')?.getAttribute('role'),
- ).toBe('button');
- expect(
- container
- .querySelector('a.nhsuk-button.nhsuk-button--disabled')
- ?.getAttribute('aria-disabled'),
- ).toBe('true');
- });
});
-describe('ButtonWrapper', () => {
- it('renders a button when not given a href', () => {
- const { container } = render(Submit );
+describe('Button as a button', () => {
+ it('renders a button when not given a href', async () => {
+ const { modules } = await renderClient(Save and continue , {
+ moduleName: 'nhsuk-button',
+ });
+
+ const [buttonEl] = modules;
- expect(container.querySelector('button.nhsuk-button')?.textContent).toBe('Submit');
+ expect(buttonEl.tagName).toBe('BUTTON');
+ expect(buttonEl).toHaveClass('nhsuk-button');
+ expect(buttonEl).toHaveTextContent('Save and continue');
+ expect(buttonEl).not.toHaveAttribute('href');
});
- it('renders an anchor when given a href', () => {
- const { container } = render(Submit );
+ it('renders an anchor when given a href', async () => {
+ const { modules } = await renderClient(Find my location , {
+ moduleName: 'nhsuk-button',
+ });
+
+ const [buttonEl] = modules;
- expect(container.querySelector('a.nhsuk-button')?.textContent).toBe('Submit');
+ expect(buttonEl.tagName).toBe('A');
+ expect(buttonEl).toHaveClass('nhsuk-button');
+ expect(buttonEl).toHaveTextContent('Find my location');
+ expect(buttonEl).toHaveAttribute('href', '/');
});
});
diff --git a/src/components/form-elements/button/__tests__/__snapshots__/Button.test.tsx.snap b/src/components/form-elements/button/__tests__/__snapshots__/Button.test.tsx.snap
index 4afeb8e1..c4e823d1 100644
--- a/src/components/form-elements/button/__tests__/__snapshots__/Button.test.tsx.snap
+++ b/src/components/form-elements/button/__tests__/__snapshots__/Button.test.tsx.snap
@@ -1,106 +1,125 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
+exports[`Button as a link button types reverse matches snapshot: ReverseButton 1`] = `
+
+`;
+
+exports[`Button as a link button types secondary matches snapshot: SecondaryButton 1`] = `
+
+`;
+
+exports[`Button as a link matches snapshot: LinkButton 1`] = `
+
+`;
+
exports[`Button disabled matches snapshot: DisabledButton 1`] = `
- Submit
+ Save and continue
`;
-exports[`Button matches snapshot: PlainButton 1`] = `
+exports[`Button matches snapshot (via server): client 1`] = `
- Submit
+ Save and continue
`;
-exports[`Button reverse matches snapshot: ReverseButton 1`] = `
+exports[`Button matches snapshot (via server): server 1`] = `
- Submit
+ Save and continue
`;
-exports[`Button secondary matches snapshot: SecondaryButton 1`] = `
+exports[`Button matches snapshot 1`] = `
- Submit
+ Save and continue
`;
-exports[`ButtonLink button types disabled matches snapshot: DisabledButton 1`] = `
-
-`;
-
-exports[`ButtonLink button types reverse matches snapshot: ReverseButton 1`] = `
+exports[`Button reverse matches snapshot: ReverseButton 1`] = `
`;
-exports[`ButtonLink button types secondary matches snapshot: SecondaryButton 1`] = `
+exports[`Button secondary matches snapshot: SecondaryButton 1`] = `
-`;
-
-exports[`ButtonLink matches snapshot: PlainButton 1`] = `
-
`;
diff --git a/src/components/form-elements/button/index.ts b/src/components/form-elements/button/index.ts
index 6af3b1cf..efe8c800 100644
--- a/src/components/form-elements/button/index.ts
+++ b/src/components/form-elements/button/index.ts
@@ -1,4 +1 @@
-import ButtonElement from './Button';
-
-export default ButtonElement;
-export { Button, ButtonLink } from './Button';
+export { default } from './Button';
diff --git a/src/components/form-elements/character-count/CharacterCount.tsx b/src/components/form-elements/character-count/CharacterCount.tsx
index e19ca0e9..19dc8758 100644
--- a/src/components/form-elements/character-count/CharacterCount.tsx
+++ b/src/components/form-elements/character-count/CharacterCount.tsx
@@ -1,55 +1,74 @@
-'use client';
-import React, { FC, useEffect } from 'react';
-import CharacterCountJs from '@resources/character-count';
-import { HTMLAttributesWithData } from '@util/types/NHSUKTypes';
-
-export enum CharacterCountType {
- Characters,
- Words,
+import React, { ComponentPropsWithoutRef, createRef, forwardRef, useEffect, useState } from 'react';
+import { type CharacterCount } from 'nhsuk-frontend';
+import classNames from 'classnames';
+import FormGroup from '@components/utils/FormGroup';
+import { FormElementProps } from '@util/types/FormTypes';
+
+export interface CharacterCountProps
+ extends ComponentPropsWithoutRef<'textarea'>,
+ Omit {
+ maxLength?: number;
+ maxWords?: number;
+ threshold?: number;
}
-type CharacterCountProps = React.HTMLAttributes & {
- children: React.ReactNode;
- maxLength: number;
- countType: CharacterCountType;
- textAreaId: string;
- thresholdPercent?: number;
-};
-
-const CharacterCount: FC = ({
- children,
- maxLength,
- countType,
- textAreaId,
- thresholdPercent,
- ...rest
-}) => {
- useEffect(() => {
- CharacterCountJs();
- }, []);
-
- const characterCountProps: HTMLAttributesWithData =
- countType === CharacterCountType.Characters
- ? { ...rest, ['data-maxlength']: maxLength }
- : { ...rest, ['data-maxwords']: maxLength };
-
- if (thresholdPercent) {
- characterCountProps['data-threshold'] = thresholdPercent;
- }
-
- return (
-
-
{children}
-
-
- You can enter up to {maxLength} characters
-
-
- );
-};
-
-export default CharacterCount;
+const CharacterCountComponent = forwardRef(
+ ({ maxLength, maxWords, threshold, formGroupProps, ...rest }, forwardedRef) => {
+ const [moduleRef] = useState(() => formGroupProps?.ref || createRef());
+ const [instance, setInstance] = useState();
+
+ useEffect(() => {
+ if (!('current' in moduleRef) || !moduleRef.current || instance) {
+ return;
+ }
+
+ const { current: $root } = moduleRef;
+
+ import('nhsuk-frontend').then(({ CharacterCount }) => {
+ setInstance(new CharacterCount($root));
+ });
+ }, [moduleRef, instance]);
+
+ return (
+
+ inputType="textarea"
+ formGroupProps={{
+ ...formGroupProps,
+ className: classNames('nhsuk-character-count', formGroupProps?.className),
+ 'data-module': 'nhsuk-character-count',
+ 'data-maxlength': maxLength,
+ 'data-maxwords': maxWords,
+ 'data-threshold': threshold,
+ ref: moduleRef,
+ }}
+ {...rest}
+ >
+ {({ className, id, error, 'aria-describedby': ariaDescribedBy, ...rest }) => (
+ <>
+
+
+ {maxWords
+ ? `You can enter up to ${maxWords} words`
+ : `You can enter up to ${maxLength} characters`}
+
+ >
+ )}
+
+ );
+ },
+);
+
+CharacterCountComponent.displayName = 'CharacterCount';
+
+export default CharacterCountComponent;
diff --git a/src/components/form-elements/character-count/__tests__/CharacterCount.test.tsx b/src/components/form-elements/character-count/__tests__/CharacterCount.test.tsx
index a6773583..95004665 100644
--- a/src/components/form-elements/character-count/__tests__/CharacterCount.test.tsx
+++ b/src/components/form-elements/character-count/__tests__/CharacterCount.test.tsx
@@ -1,89 +1,130 @@
-import React from 'react';
-import { render } from '@testing-library/react';
-import CharacterCount, { CharacterCountType } from '../CharacterCount';
-import Label from '@components/form-elements/label/Label';
-import HintText from '@components/form-elements/hint-text/HintText';
-import Textarea from '@components/form-elements/textarea/Textarea';
+import React, { createRef } from 'react';
+import CharacterCount from '../CharacterCount';
+import { renderClient, renderServer } from '@util/components';
describe('Character Count', () => {
- it('Matches snapshot', () => {
- const { container } = render(
+ it('matches snapshot', async () => {
+ const { container } = await renderClient(
- Can you provide more detail?
-
- Do not include personal information like your name, date of birth or NHS number.
-
-
- ,
+ rows={5}
+ />,
+ { moduleName: 'nhsuk-character-count' },
);
expect(container).toMatchSnapshot();
});
- it('Sets the data-maxlength attribute when counting characters', () => {
- const { container } = render(
+ it('matches snapshot (via server)', async () => {
+ const { container, element } = await renderServer(
-
- ,
+ rows={5}
+ />,
+ { moduleName: 'nhsuk-character-count' },
);
- expect(container.querySelector('.nhsuk-character-count')?.getAttribute('data-maxlength')).toBe(
- '200',
+ expect(container).toMatchSnapshot('server');
+
+ await renderClient(element, {
+ moduleName: 'nhsuk-character-count',
+ hydrate: true,
+ container,
+ });
+
+ expect(container).toMatchSnapshot('client');
+ });
+
+ it('forwards refs', async () => {
+ const groupRef = createRef();
+ const fieldRef = createRef();
+
+ const { container } = await renderClient(
+ ,
+ { moduleName: 'nhsuk-character-count' },
);
- expect(
- container.querySelector('.nhsuk-character-count')?.getAttribute('data-maxwords'),
- ).toBeNull();
- expect(
- container.querySelector('.nhsuk-character-count')?.getAttribute('data-threshold'),
- ).toBeNull();
+
+ const groupEl = container.querySelector('div');
+ const textareaEl = container.querySelector('textarea');
+
+ expect(groupRef.current).toBe(groupEl);
+ expect(groupRef.current).toHaveClass('nhsuk-form-group', 'nhsuk-character-count');
+
+ expect(fieldRef.current).toBe(textareaEl);
+ expect(fieldRef.current).toHaveClass('nhsuk-textarea');
});
- it('Sets the data-maxwords attribute when counting words', () => {
- const { container } = render(
-
-
- ,
+ it('sets data-maxlength attribute when counting characters', async () => {
+ const { modules } = await renderClient(
+ ,
+ { moduleName: 'nhsuk-character-count' },
);
- expect(container.querySelector('.nhsuk-character-count')?.getAttribute('data-maxwords')).toBe(
- '200',
+ const [characterCountEl] = modules;
+
+ expect(characterCountEl).toHaveAttribute('data-maxlength', '200');
+ expect(characterCountEl).not.toHaveAttribute('data-maxwords');
+ expect(characterCountEl).not.toHaveAttribute('data-threshold');
+ });
+
+ it('sets data-maxwords attribute when counting words', async () => {
+ const { modules } = await renderClient(
+ ,
+ { moduleName: 'nhsuk-character-count' },
);
- expect(
- container.querySelector('.nhsuk-character-count')?.getAttribute('data-maxlength'),
- ).toBeNull();
- expect(
- container.querySelector('.nhsuk-character-count')?.getAttribute('data-threshold'),
- ).toBeNull();
+
+ const [characterCountEl] = modules;
+
+ expect(characterCountEl).not.toHaveAttribute('data-maxlength');
+ expect(characterCountEl).toHaveAttribute('data-maxwords', '200');
+ expect(characterCountEl).not.toHaveAttribute('data-threshold');
});
- it('Sets the data-threshold attribute when threshold is specified', () => {
- const { container } = render(
+ it('sets data-threshold attribute when threshold is specified', async () => {
+ const { modules } = await renderClient(
-
- ,
+ threshold={50}
+ rows={5}
+ />,
+ { moduleName: 'nhsuk-character-count' },
);
- expect(container.querySelector('.nhsuk-character-count')?.getAttribute('data-threshold')).toBe(
- '50',
- );
+ const [characterCountEl] = modules;
+
+ expect(characterCountEl).toHaveAttribute('data-maxlength', '200');
+ expect(characterCountEl).not.toHaveAttribute('data-maxwords');
+ expect(characterCountEl).toHaveAttribute('data-threshold', '50');
});
});
diff --git a/src/components/form-elements/character-count/__tests__/__snapshots__/CharacterCount.test.tsx.snap b/src/components/form-elements/character-count/__tests__/__snapshots__/CharacterCount.test.tsx.snap
index 2356a676..99a1b4d1 100644
--- a/src/components/form-elements/character-count/__tests__/__snapshots__/CharacterCount.test.tsx.snap
+++ b/src/components/form-elements/character-count/__tests__/__snapshots__/CharacterCount.test.tsx.snap
@@ -1,58 +1,149 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`Character Count Matches snapshot 1`] = `
+exports[`Character Count matches snapshot (via server): client 1`] = `
-
+`;
+
+exports[`Character Count matches snapshot (via server): server 1`] = `
+
+
+
+
- Do not include personal information like your name, date of birth or NHS number.
-
-
+
+`;
+
+exports[`Character Count matches snapshot 1`] = `
+
+
+ Can you provide more detail?
+
+
+
+ Do not include personal information like your name, date of birth or NHS number
+
+
+
+ You can enter up to 200 characters
+
+
+ You have 200 characters remaining
+
+
+ You have 200 characters remaining
diff --git a/src/components/form-elements/character-count/index.ts b/src/components/form-elements/character-count/index.ts
index a4cba558..ca1d1747 100644
--- a/src/components/form-elements/character-count/index.ts
+++ b/src/components/form-elements/character-count/index.ts
@@ -1,4 +1 @@
-import CharacterCount, { CharacterCountType } from './CharacterCount';
-
-export { CharacterCountType };
-export default CharacterCount;
+export { default } from './CharacterCount';
diff --git a/src/components/form-elements/checkboxes/Checkboxes.tsx b/src/components/form-elements/checkboxes/Checkboxes.tsx
index 4b10e37b..d7c1c80b 100644
--- a/src/components/form-elements/checkboxes/Checkboxes.tsx
+++ b/src/components/form-elements/checkboxes/Checkboxes.tsx
@@ -1,27 +1,40 @@
-'use client';
-
-import React, { HTMLProps, useEffect } from 'react';
+import React, { ComponentPropsWithoutRef, createRef, forwardRef, useEffect, useState } from 'react';
import classNames from 'classnames';
import { FormElementProps } from '@util/types/FormTypes';
-import SingleInputFormGroup from '@components/utils/SingleInputFormGroup';
+import FormGroup from '@components/utils/FormGroup';
import CheckboxContext, { ICheckboxContext } from './CheckboxContext';
-import Box from './components/Box';
-import Divider from './components/Divider';
+import CheckboxesItem from './components/Item';
+import CheckboxesDivider from './components/Divider';
import { generateRandomName } from '@util/RandomID';
-import CheckboxJs from '@resources/checkboxes';
+import { type Checkboxes } from 'nhsuk-frontend';
-interface CheckboxesProps extends HTMLProps
, FormElementProps {
+export interface CheckboxesProps
+ extends ComponentPropsWithoutRef<'div'>,
+ Omit {
idPrefix?: string;
}
-const Checkboxes = ({ children, idPrefix, ...rest }: CheckboxesProps) => {
+const CheckboxesComponent = forwardRef((props, forwardedRef) => {
+ const { children, idPrefix, ...rest } = props;
+
+ const [moduleRef] = useState(() => forwardedRef || createRef());
+ const [instance, setInstance] = useState();
+
const _boxReferences: string[] = [];
let _boxCount: number = 0;
let _boxIds: Record = {};
useEffect(() => {
- CheckboxJs();
- }, []);
+ if (!('current' in moduleRef) || !moduleRef.current || instance) {
+ return;
+ }
+
+ const { current: $root } = moduleRef;
+
+ import('nhsuk-frontend').then(({ Checkboxes }) => {
+ setInstance(new Checkboxes($root));
+ });
+ }, [moduleRef, instance]);
const getBoxId = (id: string, reference: string): string => {
if (reference in _boxIds) {
@@ -53,7 +66,7 @@ const Checkboxes = ({ children, idPrefix, ...rest }: CheckboxesProps) => {
};
return (
- inputType="checkboxes" {...rest}>
+ inputType="checkboxes" {...rest}>
{/* eslint-disable-next-line @typescript-eslint/no-unused-vars */}
{({ className, name, id, idPrefix, error, ...restRenderProps }) => {
resetCheckboxIds();
@@ -64,16 +77,24 @@ const Checkboxes = ({ children, idPrefix, ...rest }: CheckboxesProps) => {
unleaseReference,
};
return (
-
+
{children}
);
}}
-
+
);
-};
+});
-Checkboxes.Box = Box;
-Checkboxes.Divider = Divider;
+CheckboxesComponent.displayName = 'Checkboxes';
-export default Checkboxes;
+export default Object.assign(CheckboxesComponent, {
+ Item: CheckboxesItem,
+ Divider: CheckboxesDivider,
+});
diff --git a/src/components/form-elements/checkboxes/__tests__/Checkboxes.test.tsx b/src/components/form-elements/checkboxes/__tests__/Checkboxes.test.tsx
index ced26677..d34861c5 100644
--- a/src/components/form-elements/checkboxes/__tests__/Checkboxes.test.tsx
+++ b/src/components/form-elements/checkboxes/__tests__/Checkboxes.test.tsx
@@ -1,82 +1,113 @@
-import React from 'react';
-import { render } from '@testing-library/react';
+import React, { createRef } from 'react';
import Checkboxes from '../';
+import { renderClient, renderServer } from '@util/components';
describe('Checkboxes', () => {
- it('matches snapshot', () => {
- const { container } = render(
-
,
+ it('matches snapshot', async () => {
+ const { container } = await renderClient(
+
+ Waste from animal carcasses
+ Waste from mines or quarries
+ Farm or agricultural waste
+ ,
+ { moduleName: 'nhsuk-checkboxes' },
);
expect(container).toMatchSnapshot();
});
- it('matches snapshot with string error', () => {
- const { container } = render(
-
,
+ it('matches snapshot with error message', async () => {
+ const { container } = await renderClient(
+
+ Waste from animal carcasses
+ Waste from mines or quarries
+ Farm or agricultural waste
+ ,
+ { moduleName: 'nhsuk-checkboxes' },
);
expect(container).toMatchSnapshot();
});
- it('matches snapshot with boolean error', () => {
- const { container } = render(
-
,
+ it('matches snapshot with an exclusive checkbox', async () => {
+ const { container } = await renderClient(
+
+ Waste from animal carcasses
+ Waste from mines or quarries
+ Farm or agricultural waste
+
+
+ None
+
+ ,
+ { moduleName: 'nhsuk-checkboxes' },
);
expect(container).toMatchSnapshot();
});
- it('Matches the snapshot with an exclusive checkbox', () => {
- const { container } = render(
-
,
+ it('matches snapshot (via server)', async () => {
+ const { container, element } = await renderServer(
+
+ Waste from animal carcasses
+ Waste from mines or quarries
+ Farm or agricultural waste
+ ,
+ { moduleName: 'nhsuk-checkboxes' },
);
- expect(container).toMatchSnapshot();
+ expect(container).toMatchSnapshot('server');
+
+ await renderClient(element, {
+ moduleName: 'nhsuk-checkboxes',
+ hydrate: true,
+ container,
+ });
+
+ expect(container).toMatchSnapshot('client');
+ });
+
+ it('forwards refs', async () => {
+ const groupRef = createRef
();
+ const moduleRef = createRef();
+ const fieldRef = createRef();
+
+ const { container } = await renderClient(
+
+ Yes
+ ,
+ { moduleName: 'nhsuk-checkboxes' },
+ );
+
+ const groupEl = container.querySelectorAll('div')[0];
+ const moduleEl = container.querySelectorAll('div')[1];
+ const inputEl = container.querySelector('input');
+
+ expect(groupRef.current).toBe(groupEl);
+ expect(groupRef.current).toHaveClass('nhsuk-form-group');
+
+ expect(moduleRef.current).toBe(moduleEl);
+ expect(moduleRef.current).toHaveClass('nhsuk-checkboxes');
+
+ expect(fieldRef.current).toBe(inputEl);
+ expect(fieldRef.current).toHaveClass('nhsuk-checkboxes__input');
});
- it('Sets a data-exclusive attribute when exclusive is true for a box', () => {
- const { container } = render(
- ,
+ it('sets data-exclusive attribute when exclusive is true for a checkbox', async () => {
+ const { container } = await renderClient(
+
+ Waste from animal carcasses
+ Waste from mines or quarries
+ Farm or agricultural waste
+
+ None
+
+ ,
+ { moduleName: 'nhsuk-checkboxes' },
);
- expect(container.querySelector('#none')?.getAttribute('data-checkbox-exclusive')).toBe('true');
+ const inputEl = container.querySelector('#none');
+
+ expect(inputEl?.dataset).toHaveProperty('checkboxExclusive', 'true');
});
});
diff --git a/src/components/form-elements/checkboxes/__tests__/__snapshots__/Checkboxes.test.tsx.snap b/src/components/form-elements/checkboxes/__tests__/__snapshots__/Checkboxes.test.tsx.snap
index 2a800007..299194ee 100644
--- a/src/components/form-elements/checkboxes/__tests__/__snapshots__/Checkboxes.test.tsx.snap
+++ b/src/components/form-elements/checkboxes/__tests__/__snapshots__/Checkboxes.test.tsx.snap
@@ -1,331 +1,403 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`Checkboxes Matches the snapshot with an exclusive checkbox 1`] = `
+exports[`Checkboxes matches snapshot (via server): client 1`] = `
+`;
+
+exports[`Checkboxes matches snapshot (via server): server 1`] = `
+
`;
exports[`Checkboxes matches snapshot 1`] = `
`;
-exports[`Checkboxes matches snapshot with boolean error 1`] = `
+exports[`Checkboxes matches snapshot with an exclusive checkbox 1`] = `
`;
-exports[`Checkboxes matches snapshot with string error 1`] = `
+exports[`Checkboxes matches snapshot with error message 1`] = `
`;
diff --git a/src/components/form-elements/checkboxes/components/Box.tsx b/src/components/form-elements/checkboxes/components/Box.tsx
deleted file mode 100644
index 3c576c57..00000000
--- a/src/components/form-elements/checkboxes/components/Box.tsx
+++ /dev/null
@@ -1,122 +0,0 @@
-'use client';
-
-import React, {
- FC,
- HTMLProps,
- useContext,
- ReactNode,
- useEffect,
- useState,
- MutableRefObject,
-} from 'react';
-import classNames from 'classnames';
-import CheckboxContext, { ICheckboxContext } from '../CheckboxContext';
-import Label, { LabelProps } from '../../label/Label';
-import HintText, { HintTextProps } from '../../hint-text/HintText';
-import { HTMLAttributesWithData } from '@util/types/NHSUKTypes';
-
-type BoxProps = Omit
, 'label'> & {
- labelProps?: LabelProps;
- hint?: string;
- hintProps?: HintTextProps;
- conditional?: ReactNode;
- forceShowConditional?: boolean;
- conditionalWrapperProps?: HTMLProps;
- inputRef?: MutableRefObject;
- exclusive?: boolean;
-};
-
-const Box: FC = ({
- id,
- labelProps,
- children,
- hint,
- hintProps,
- conditional,
- defaultChecked,
- checked,
- onChange,
- inputRef,
- forceShowConditional,
- conditionalWrapperProps,
- exclusive = false,
- type = 'checkbox',
- ...rest
-}) => {
- const { getBoxId, name, leaseReference, unleaseReference } =
- useContext(CheckboxContext);
-
- const [boxReference] = useState(leaseReference());
- const [showConditional, setShowConditional] = useState(!!(checked || defaultChecked));
- const inputID = id || getBoxId(boxReference);
-
- const { className: labelClassName, ...restLabelProps } = labelProps || {};
- const { className: hintClassName, ...restHintProps } = hintProps || {};
- const { className: conditionalClassName, ...restConditionalProps } =
- conditionalWrapperProps || {};
-
- useEffect(() => () => unleaseReference(boxReference), []);
-
- useEffect(() => {
- if (checked !== undefined) {
- setShowConditional(checked);
- }
- }, [checked]);
-
- const inputProps: HTMLAttributesWithData = rest;
-
- if (exclusive) {
- inputProps['data-checkbox-exclusive'] = true;
- }
-
- return (
- <>
-
- {
- if (checked === undefined) setShowConditional(e.target.checked);
- if (onChange) onChange(e);
- }}
- name={name}
- id={inputID}
- checked={checked}
- defaultChecked={defaultChecked}
- ref={inputRef}
- type={type}
- data-checkbox-exclusive-group={name}
- {...inputProps}
- />
- {children ? (
-
- {children}
-
- ) : null}
- {hint ? (
-
- {hint}
-
- ) : null}
-
- {conditional && (showConditional || forceShowConditional) ? (
-
- {conditional}
-
- ) : null}
- >
- );
-};
-
-export default Box;
diff --git a/src/components/form-elements/checkboxes/components/Divider.tsx b/src/components/form-elements/checkboxes/components/Divider.tsx
index 547aa302..0d0a4c00 100644
--- a/src/components/form-elements/checkboxes/components/Divider.tsx
+++ b/src/components/form-elements/checkboxes/components/Divider.tsx
@@ -4,8 +4,10 @@ type DividerProps = {
dividerText?: string;
};
-const Divider: FC = ({ dividerText = 'or' }) => (
+const CheckboxesDivider: FC = ({ dividerText = 'or' }) => (
{dividerText}
);
-export default Divider;
+CheckboxesDivider.displayName = 'Checkboxes.Divider';
+
+export default CheckboxesDivider;
diff --git a/src/components/form-elements/checkboxes/components/Item.tsx b/src/components/form-elements/checkboxes/components/Item.tsx
new file mode 100644
index 00000000..38a8929d
--- /dev/null
+++ b/src/components/form-elements/checkboxes/components/Item.tsx
@@ -0,0 +1,116 @@
+import React, {
+ ComponentPropsWithRef,
+ ComponentPropsWithoutRef,
+ ReactNode,
+ forwardRef,
+ useContext,
+ useEffect,
+ useState,
+} from 'react';
+import classNames from 'classnames';
+import { FormElementProps } from '@util/types/FormTypes';
+import CheckboxContext, { ICheckboxContext } from '../CheckboxContext';
+import Label from '../../label/Label';
+import HintText from '../../hint-text/HintText';
+import { HTMLAttributesWithData } from '@util/types/NHSUKTypes';
+
+export interface CheckboxesItemProps
+ extends ComponentPropsWithoutRef<'input'>,
+ Pick {
+ conditional?: ReactNode;
+ forceShowConditional?: boolean;
+ conditionalProps?: ComponentPropsWithRef<'div'>;
+ exclusive?: boolean;
+}
+
+const CheckboxesItem = forwardRef((props, forwardedRef) => {
+ const {
+ id,
+ labelProps,
+ children,
+ hint,
+ hintProps,
+ conditional,
+ defaultChecked,
+ checked,
+ forceShowConditional,
+ conditionalProps,
+ exclusive = false,
+ ...rest
+ } = props;
+
+ const { getBoxId, name, leaseReference, unleaseReference } =
+ useContext(CheckboxContext);
+
+ const [boxReference] = useState(leaseReference());
+ const inputID = id || getBoxId(boxReference);
+ const shouldShowConditional = !!(checked || defaultChecked);
+
+ const { className: labelClassName, ...restLabelProps } = labelProps || {};
+ const { className: hintClassName, ...restHintProps } = hintProps || {};
+ const { className: conditionalClassName, ...restConditionalProps } = conditionalProps || {};
+
+ useEffect(() => () => unleaseReference(boxReference), []);
+
+ const inputProps: HTMLAttributesWithData = rest;
+
+ if (exclusive) {
+ inputProps['data-checkbox-exclusive'] = true;
+ }
+
+ return (
+ <>
+
+
+
+ {children}
+
+
+ {hint}
+
+
+ {conditional && (
+
+ {conditional}
+
+ )}
+ >
+ );
+});
+
+CheckboxesItem.displayName = 'Checkboxes.Item';
+
+export default CheckboxesItem;
diff --git a/src/components/form-elements/checkboxes/index.ts b/src/components/form-elements/checkboxes/index.ts
index 22c6e492..d537b67e 100644
--- a/src/components/form-elements/checkboxes/index.ts
+++ b/src/components/form-elements/checkboxes/index.ts
@@ -1,3 +1 @@
-import Checkboxes from './Checkboxes';
-
-export default Checkboxes;
+export { default } from './Checkboxes';
diff --git a/src/components/form-elements/date-input/DateInput.tsx b/src/components/form-elements/date-input/DateInput.tsx
index 50f2040b..ac57ae31 100644
--- a/src/components/form-elements/date-input/DateInput.tsx
+++ b/src/components/form-elements/date-input/DateInput.tsx
@@ -1,9 +1,15 @@
-'use client';
-
-import React, { HTMLProps, ChangeEvent, useEffect, useState } from 'react';
+import React, {
+ ChangeEvent,
+ ComponentPropsWithoutRef,
+ EventHandler,
+ useEffect,
+ useState,
+ createRef,
+ forwardRef,
+} from 'react';
import classNames from 'classnames';
import { DayInput, MonthInput, YearInput } from './components/IndividualDateInputs';
-import SingleInputFormGroup from '@components/utils/SingleInputFormGroup';
+import FormGroup from '@components/utils/FormGroup';
import DateInputContext, { IDateInputContext } from './DateInputContext';
import { FormElementProps } from '@util/types/FormTypes';
@@ -13,118 +19,110 @@ type DateInputValue = {
year: string;
};
-export type DateInputChangeEvent = ChangeEvent & {
- target: HTMLInputElement & { value: DateInputValue };
- currentTarget: HTMLInputElement & { value: DateInputValue };
-};
+export interface DateInputChangeEvent
+ extends Omit, 'target' | 'currentTarget'> {
+ target: DateInputElement;
+ currentTarget: DateInputElement;
+}
+
+interface DateInputElement extends Omit {
+ value?: Partial;
+ onChange?: EventHandler;
+}
interface DateInputProps
- extends Omit, 'value' | 'defaultValue'>,
- FormElementProps {
- autoSelectNext?: boolean;
+ extends Omit, 'defaultValue' | 'onChange'>,
+ Omit {
value?: Partial;
defaultValue?: Partial;
- onChange?: (e: DateInputChangeEvent) => void;
+ onChange?: EventHandler;
}
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();
- }
- };
-
- const handleChange = (inputType: InputType, event: ChangeEvent): void => {
- handleFocusNextInput(inputType, event.target.value);
- event.stopPropagation();
-
- if (onChange) {
- const newEventValue = {
+const DateInputComponent = forwardRef(
+ ({ children, onChange, value, defaultValue, formGroupProps, ...rest }, forwardedRef) => {
+ const [moduleRef] = useState(() => formGroupProps?.ref || createRef());
+
+ 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 handleChange = (inputType: InputType, event: ChangeEvent): void => {
+ event.stopPropagation();
+
+ const newEventValue: DateInputValue = {
...internalDate,
[inputType]: event.target.value,
};
- const newEvent = {
+
+ const newEvent: ChangeEvent = {
...event,
target: { ...event.target, value: newEventValue },
currentTarget: { ...event.currentTarget, value: newEventValue },
- } as DateInputChangeEvent;
+ };
- onChange(newEvent);
+ onChange?.(newEvent);
setInternalDate(newEventValue);
- }
- };
-
- const registerRef = (inputType: InputType, ref: HTMLInputElement | null): void => {
- if (inputType === 'month') monthRef = ref;
- if (inputType === 'year') yearRef = ref;
- };
-
- 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;
+ };
+
+ return (
+ >
+ formGroupProps={{ ...formGroupProps, ref: moduleRef }}
+ fieldsetProps={{ role: 'group' }}
+ inputType="dateinput"
+ {...rest}
+ >
+ {/* eslint-disable-next-line @typescript-eslint/no-unused-vars */}
+ {({ className, name, id, error, ...restRenderProps }) => {
+ const contextValue: IDateInputContext = {
+ id,
+ name,
+ error,
+ value,
+ defaultValue,
+ handleChange,
+ };
+ return (
+
+
+ {children || (
+ <>
+
+
+
+ >
+ )}
+
+
+ );
+ }}
+
+ );
+ },
+);
+
+DateInputComponent.displayName = 'DateInput';
+
+export default Object.assign(DateInputComponent, {
+ Day: DayInput,
+ Month: MonthInput,
+ Year: YearInput,
+});
diff --git a/src/components/form-elements/date-input/DateInputContext.ts b/src/components/form-elements/date-input/DateInputContext.ts
index cc212430..b3021694 100644
--- a/src/components/form-elements/date-input/DateInputContext.ts
+++ b/src/components/form-elements/date-input/DateInputContext.ts
@@ -3,10 +3,9 @@ import { createContext, ChangeEvent } from 'react';
export type IDateInputContext = {
id: string;
name: string;
- error: string | boolean | undefined;
+ error: string | undefined;
value?: { day?: string; month?: string; year?: string };
defaultValue?: { day?: string; month?: string; year?: string };
- registerRef: (inputType: 'day' | 'month' | 'year', ref: null | HTMLInputElement) => void;
handleChange: (inputType: 'day' | 'month' | 'year', event: ChangeEvent) => void;
};
@@ -14,7 +13,6 @@ const DateInputContext = createContext({
/* eslint-disable @typescript-eslint/no-empty-function */
id: '',
name: '',
- registerRef: () => {},
handleChange: () => {},
error: undefined,
});
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 83a4d424..8bc06bd2 100644
--- a/src/components/form-elements/date-input/__tests__/DateInput.test.tsx
+++ b/src/components/form-elements/date-input/__tests__/DateInput.test.tsx
@@ -1,119 +1,194 @@
-import React from 'react';
-import { render, fireEvent } from '@testing-library/react';
+import React, { createRef } from 'react';
+import { fireEvent } from '@testing-library/react';
import DateInput, { DateInputChangeEvent } from '../DateInput';
+import { renderClient, renderServer } from '@util/components';
describe('DateInput', () => {
- it('matches snapshot', () => {
- const { container } = render( );
+ it('matches snapshot', async () => {
+ const { container } = await renderClient(
+ ,
+ { className: 'nhsuk-date-input' },
+ );
expect(container).toMatchSnapshot();
});
- it.each`
- autoSelectNext | inputValue | monthFocusExpected
- ${false} | ${'1'} | ${false}
- ${false} | ${'11'} | ${false}
- ${true} | ${'1'} | ${false}
- ${true} | ${'11'} | ${true}
- `(
- 'When autoSelectNext is $autoSelectNext, the day input value is $inputValue, then month focus is expected to be $monthFocusExpected',
- ({ autoSelectNext, inputValue, monthFocusExpected }) => {
- const { container } = render(
- ,
- );
-
- const dayInput = container.querySelector('#testInput-day')!;
- const monthInput = container.querySelector('#testInput-month')!;
-
- expect(monthInput).not.toHaveFocus();
-
- fireEvent.change(dayInput, { target: { value: inputValue } });
-
- if (monthFocusExpected) {
- expect(monthInput).toHaveFocus();
- } else {
- expect(monthInput).not.toHaveFocus();
- }
- },
- );
-
- it.each`
- autoSelectNext | inputValue | yearFocusExpected
- ${false} | ${'1'} | ${false}
- ${false} | ${'11'} | ${false}
- ${true} | ${'1'} | ${false}
- ${true} | ${'11'} | ${true}
- `(
- 'When autoSelectNext is $autoSelectNext, the day input value is $inputValue, then year focus is expected to be $yearFocusExpected',
- ({ autoSelectNext, inputValue, yearFocusExpected }) => {
- const { container } = render(
- ,
- );
-
- const monthInput = container.querySelector('#testInput-month')!;
- const yearInput = container.querySelector('#testInput-year')!;
-
- expect(yearInput).not.toHaveFocus();
-
- fireEvent.change(monthInput, { target: { value: inputValue } });
-
- if (yearFocusExpected) {
- expect(yearInput).toHaveFocus();
- } else {
- expect(yearInput).not.toHaveFocus();
- }
- },
- );
-
- it('Invokes the provided onChange function prop if provided', () => {
- let onChangeParam: DateInputChangeEvent | null = null;
- const onChange = jest.fn().mockImplementation((val) => (onChangeParam = val));
-
- const { container } = render( );
-
- const dayInput = container.querySelector('#testInput-day')!;
- const monthInput = container.querySelector('#testInput-month')!;
- const yearInput = container.querySelector('#testInput-year')!;
+ it('matches snapshot with error message', async () => {
+ const { container } = await renderClient(
+ ,
+ { className: 'nhsuk-date-input' },
+ );
- fireEvent.change(dayInput, { target: { value: '21' } });
+ expect(container).toMatchSnapshot();
+ });
- expect(onChange).toHaveBeenCalledTimes(1);
- expect(onChangeParam!.currentTarget!.value).toEqual({
- day: '21',
- month: '',
- year: '',
- });
+ it('matches snapshot with custom date fields', async () => {
+ const { container } = await renderClient(
+
+
+
+
+ ,
+ { className: 'nhsuk-date-input' },
+ );
- fireEvent.change(monthInput, { target: { value: '03' } });
+ expect(container).toMatchSnapshot();
+ });
- expect(onChange).toHaveBeenCalledTimes(2);
- expect(onChangeParam!.currentTarget!.value).toEqual({
- day: '21',
- month: '03',
- year: '',
- });
+ it('matches snapshot with custom date fields and error message', async () => {
+ const { container } = await renderClient(
+
+
+
+
+ ,
+ { className: 'nhsuk-date-input' },
+ );
- fireEvent.change(yearInput, { target: { value: '2024' } });
+ expect(container).toMatchSnapshot();
+ });
- expect(onChange).toHaveBeenCalledTimes(3);
- expect(onChangeParam!.currentTarget!.value).toEqual({
- day: '21',
- month: '03',
- year: '2024',
+ it('matches snapshot (via server)', async () => {
+ const { container, element } = await renderServer(
+ ,
+ { className: 'nhsuk-date-input' },
+ );
+
+ expect(container).toMatchSnapshot('server');
+
+ await renderClient(element, {
+ className: 'nhsuk-date-input',
+ hydrate: true,
+ container,
});
+
+ expect(container).toMatchSnapshot('client');
});
- it('Renders the specified children instead of date fields if provided', () => {
- const { container } = render(
-
-
+ it('forwards refs', async () => {
+ const groupRef = createRef();
+ const moduleRef = createRef();
+ const fieldRefs = [
+ createRef(),
+ createRef(),
+ createRef(),
+ ];
+
+ const { container, modules } = await renderClient(
+
+
+
+
,
+ { className: 'nhsuk-date-input' },
);
- expect(container.querySelector('#testInput-day')).toBeNull();
- expect(container.querySelector('#testInput-month')).toBeNull();
- expect(container.querySelector('#testInput-year')).toBeNull();
+ const [moduleEl] = modules;
+
+ const groupEl = container.querySelectorAll('div')[0];
+ const inputEls = container.querySelectorAll('input');
- expect(container.querySelector('#testDiv')).not.toBeNull();
+ expect(groupRef.current).toBe(groupEl);
+ expect(groupRef.current).toHaveClass('nhsuk-form-group');
+
+ expect(moduleRef.current).toBe(moduleEl);
+ expect(moduleRef.current).toHaveClass('nhsuk-date-input');
+
+ expect(fieldRefs[0].current).toBe(inputEls[0]);
+ expect(fieldRefs[0].current).toHaveClass('nhsuk-date-input__input');
+
+ expect(fieldRefs[1].current).toBe(inputEls[1]);
+ expect(fieldRefs[1].current).toHaveClass('nhsuk-date-input__input');
+
+ expect(fieldRefs[2].current).toBe(inputEls[2]);
+ expect(fieldRefs[2].current).toHaveClass('nhsuk-date-input__input');
+ });
+
+ it('invokes the provided onChange function prop if provided', async () => {
+ const onChange = jest.fn();
+
+ const { modules } = await renderClient( , {
+ className: 'nhsuk-input',
+ });
+
+ const [dayInput, monthInput, yearInput] = modules;
+
+ fireEvent.change(dayInput, { target: { value: '21' } });
+
+ expect(onChange).toHaveBeenCalledTimes(1);
+ expect(onChange).toHaveBeenCalledWith(
+ expect.objectContaining>({
+ target: expect.objectContaining({
+ value: {
+ day: '21',
+ month: '',
+ year: '',
+ },
+ }),
+ }),
+ );
+
+ fireEvent.change(monthInput, { target: { value: '3' } });
+
+ expect(onChange).toHaveBeenCalledTimes(2);
+ expect(onChange).toHaveBeenCalledWith(
+ expect.objectContaining>({
+ target: expect.objectContaining({
+ value: {
+ day: '21',
+ month: '3',
+ year: '',
+ },
+ }),
+ }),
+ );
+
+ fireEvent.change(yearInput, { target: { value: '2024' } });
+
+ expect(onChange).toHaveBeenCalledTimes(3);
+ expect(onChange).toHaveBeenCalledWith(
+ expect.objectContaining>({
+ target: expect.objectContaining({
+ value: {
+ day: '21',
+ month: '3',
+ year: '2024',
+ },
+ }),
+ }),
+ );
});
});
diff --git a/src/components/form-elements/date-input/__tests__/__snapshots__/DateInput.test.tsx.snap b/src/components/form-elements/date-input/__tests__/__snapshots__/DateInput.test.tsx.snap
index 35d7d457..c5aa9cc0 100644
--- a/src/components/form-elements/date-input/__tests__/__snapshots__/DateInput.test.tsx.snap
+++ b/src/components/form-elements/date-input/__tests__/__snapshots__/DateInput.test.tsx.snap
@@ -1,87 +1,622 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
+exports[`DateInput matches snapshot (via server): client 1`] = `
+
+`;
+
+exports[`DateInput matches snapshot (via server): server 1`] = `
+
+`;
+
exports[`DateInput matches snapshot 1`] = `
+`;
+
+exports[`DateInput matches snapshot with custom date fields 1`] = `
+
+
+`;
+
+exports[`DateInput matches snapshot with custom date fields and error message 1`] = `
+
+
+`;
+
+exports[`DateInput matches snapshot with error message 1`] = `
+
+
`;
diff --git a/src/components/form-elements/date-input/components/IndividualDateInputs.tsx b/src/components/form-elements/date-input/components/IndividualDateInputs.tsx
index 441caa88..a9be9d74 100644
--- a/src/components/form-elements/date-input/components/IndividualDateInputs.tsx
+++ b/src/components/form-elements/date-input/components/IndividualDateInputs.tsx
@@ -1,14 +1,14 @@
-'use client';
-import React, { FC, HTMLProps, useContext, ChangeEvent } from 'react';
+import React, { ComponentPropsWithoutRef, useContext, ChangeEvent, forwardRef } from 'react';
import classNames from 'classnames';
-import Label, { LabelProps } from '../../label/Label';
+import { FormElementProps } from '@util/types/FormTypes';
+import Label from '../../label/Label';
import DateInputContext, { IDateInputContext } from '../DateInputContext';
-export interface IndividualDateInputProps extends HTMLProps {
- labelProps?: LabelProps;
+export interface IndividualDateInputProps
+ extends ComponentPropsWithoutRef<'input'>,
+ Pick {
+ error?: string | false;
inputType: 'day' | 'month' | 'year';
- inputRef?: (ref: HTMLInputElement | null) => void;
- error?: boolean;
}
const labels: Record<'day' | 'month' | 'year', string> = {
@@ -17,97 +17,95 @@ const labels: Record<'day' | 'month' | 'year', string> = {
year: 'Year',
};
-const IndividualDateInput: FC = ({
- label,
- labelProps,
- inputType,
- className,
- id,
- name,
- onChange,
- inputRef,
- error,
- value,
- defaultValue,
- pattern = '[0-9]*',
- inputMode = 'numeric',
- type = 'text',
- ...rest
-}) => {
- const {
- id: ctxId,
- name: ctxName,
- error: ctxError,
- value: ctxValue,
- defaultValue: ctxDefaultValue,
- handleChange: ctxHandleChange,
- registerRef,
- } = useContext(DateInputContext);
+const IndividualDateInput = forwardRef(
+ (props, forwardedRef) => {
+ const {
+ label,
+ labelProps,
+ inputType,
+ className,
+ id,
+ name,
+ onChange,
+ error,
+ value,
+ defaultValue,
+ ...rest
+ } = props;
- const { className: labelClassName, ...restLabelProps } = labelProps || {};
+ const {
+ id: ctxId,
+ name: ctxName,
+ error: ctxError,
+ value: ctxValue,
+ defaultValue: ctxDefaultValue,
+ handleChange: ctxHandleChange,
+ } = useContext(DateInputContext);
- const inputID = id || `${ctxId}-${inputType}`;
- const inputName = name || `${ctxName}-${inputType}`;
- const inputValue = value !== undefined ? value : ctxValue?.[inputType];
- const inputDefaultValue =
- defaultValue !== undefined ? defaultValue : ctxDefaultValue?.[inputType];
+ const { className: labelClassName, ...restLabelProps } = labelProps || {};
- const handleChange = (e: ChangeEvent) => {
- e.persist();
- if (onChange) onChange(e);
- if (!e.isPropagationStopped()) {
- ctxHandleChange(inputType, e);
- }
- };
+ const inputID = id || `${ctxId}-${inputType}`;
+ const inputName = name || `${ctxName}-${inputType}`;
+ const inputValue = value === undefined ? ctxValue?.[inputType] : value;
+ const inputDefaultValue =
+ defaultValue === undefined ? ctxDefaultValue?.[inputType] : defaultValue;
- const refCallback = (ref: HTMLInputElement | null) => {
- registerRef(inputType, ref);
- if (inputRef) inputRef(ref);
- };
+ const handleChange = (e: ChangeEvent) => {
+ e.persist();
+ if (onChange) onChange(e);
+ if (!e.isPropagationStopped()) {
+ ctxHandleChange(inputType, e);
+ }
+ };
- return (
-
-
-
- {label || labels[inputType]}
-
-
+ return (
+
-
- );
-};
+ );
+ },
+);
-export const DayInput: FC
> = (props) => (
-
+export const DayInput = forwardRef>(
+ (props, forwardedRef) => ,
);
-export const MonthInput: FC> = (props) => (
-
+export const MonthInput = forwardRef>(
+ (props, forwardedRef) => ,
);
-export const YearInput: FC> = (props) => (
-
+export const YearInput = forwardRef>(
+ (props, forwardedRef) => ,
);
+
+IndividualDateInput.displayName = 'DateInput.Field';
+DayInput.displayName = 'DateInput.Day';
+MonthInput.displayName = 'DateInput.Month';
+YearInput.displayName = 'DateInput.Year';
diff --git a/src/components/form-elements/date-input/index.ts b/src/components/form-elements/date-input/index.ts
index 278d4da8..a8bd7bc4 100644
--- a/src/components/form-elements/date-input/index.ts
+++ b/src/components/form-elements/date-input/index.ts
@@ -1,3 +1 @@
-import DateInput from './DateInput';
-
-export default DateInput;
+export { default } from './DateInput';
diff --git a/src/components/form-elements/error-message/ErrorMessage.tsx b/src/components/form-elements/error-message/ErrorMessage.tsx
index 4bc89ead..8d24b116 100644
--- a/src/components/form-elements/error-message/ErrorMessage.tsx
+++ b/src/components/form-elements/error-message/ErrorMessage.tsx
@@ -1,22 +1,33 @@
-import React, { FC, HTMLProps } from 'react';
+import React, { ComponentPropsWithoutRef, FC } from 'react';
import classNames from 'classnames';
-export interface ErrorMessageProps extends HTMLProps {
- visuallyHiddenText?: false | string;
+export interface ErrorMessageProps extends ComponentPropsWithoutRef<'span'> {
+ visuallyHiddenText?: string;
}
-const ErrorMessage: FC = ({
+const ErrorMessageComponent: FC = ({
className,
- visuallyHiddenText = 'Error: ',
+ visuallyHiddenText = 'Error',
children,
...rest
-}) => (
-
- {visuallyHiddenText !== false ? (
- {visuallyHiddenText}
- ) : null}
- {children}
-
-);
+}) => {
+ if (!children || typeof children !== 'string') {
+ return null;
+ }
-export default ErrorMessage;
+ return (
+
+ {visuallyHiddenText ? (
+ <>
+ {`${visuallyHiddenText}:`} {children}
+ >
+ ) : (
+ <>{children}>
+ )}
+
+ );
+};
+
+ErrorMessageComponent.displayName = 'ErrorMessage';
+
+export default ErrorMessageComponent;
diff --git a/src/components/form-elements/error-message/__tests__/ErrorMessage.test.tsx b/src/components/form-elements/error-message/__tests__/ErrorMessage.test.tsx
index 21425052..e4a33884 100644
--- a/src/components/form-elements/error-message/__tests__/ErrorMessage.test.tsx
+++ b/src/components/form-elements/error-message/__tests__/ErrorMessage.test.tsx
@@ -12,18 +12,18 @@ describe('ErrorMessage', () => {
it('has default visuallyHiddenText', () => {
const { container } = render(Error );
- expect(container.querySelector('.nhsuk-u-visually-hidden')?.textContent).toBe('Error: ');
+ expect(container.querySelector('.nhsuk-u-visually-hidden')?.textContent).toBe('Error:');
});
- it('has disabled visuallyHiddenText', () => {
- const { container } = render(Error );
+ it('has custom visuallyHiddenText', () => {
+ const { container } = render(Error );
- expect(container.querySelector('.nhsuk-u-visually-hidden')).toBeFalsy();
+ expect(container.querySelector('.nhsuk-u-visually-hidden')?.textContent).toBe('Custom:');
});
- it('has custom visuallyHiddenText', () => {
- const { container } = render(Error );
+ it('has empty visuallyHiddenText', () => {
+ const { container } = render(Error );
- expect(container.querySelector('.nhsuk-u-visually-hidden')?.textContent).toBe('Custom');
+ expect(container.querySelector('.nhsuk-u-visually-hidden')).toBeFalsy();
});
});
diff --git a/src/components/form-elements/error-message/__tests__/__snapshots__/ErrorMessage.test.tsx.snap b/src/components/form-elements/error-message/__tests__/__snapshots__/ErrorMessage.test.tsx.snap
index d8424a42..35874a3f 100644
--- a/src/components/form-elements/error-message/__tests__/__snapshots__/ErrorMessage.test.tsx.snap
+++ b/src/components/form-elements/error-message/__tests__/__snapshots__/ErrorMessage.test.tsx.snap
@@ -8,8 +8,9 @@ exports[`ErrorMessage matches snapshot: ErrorMessage 1`] = `
- Error:
+ Error:
+
Error
diff --git a/src/components/form-elements/error-message/index.ts b/src/components/form-elements/error-message/index.ts
index 282b401d..43c8c9cc 100644
--- a/src/components/form-elements/error-message/index.ts
+++ b/src/components/form-elements/error-message/index.ts
@@ -1,3 +1 @@
-import ErrorMessage from './ErrorMessage';
-
-export default ErrorMessage;
+export { default } from './ErrorMessage';
diff --git a/src/components/form-elements/error-summary/ErrorSummary.tsx b/src/components/form-elements/error-summary/ErrorSummary.tsx
index 83fceb49..e4761b5b 100644
--- a/src/components/form-elements/error-summary/ErrorSummary.tsx
+++ b/src/components/form-elements/error-summary/ErrorSummary.tsx
@@ -1,82 +1,114 @@
import React, {
+ Children,
+ ComponentPropsWithoutRef,
FC,
- ForwardRefExoticComponent,
+ createRef,
forwardRef,
- HTMLProps,
- PropsWithoutRef,
- RefAttributes,
+ useState,
+ useEffect,
} from 'react';
import classNames from 'classnames';
-import useDevWarning from '@util/hooks/UseDevWarning';
+import { AsElementLink } from '@util/types/LinkTypes';
+import { childIsOfComponentType } from '@util/types/TypeGuards';
+import { type ErrorSummary } from 'nhsuk-frontend';
-const DefaultErrorSummaryTitleID = 'error-summary-title';
+export type TitleProps = ComponentPropsWithoutRef<'h2'>;
-const ErrorSummaryTitle: FC> = ({
- className,
- id = DefaultErrorSummaryTitleID,
- ...rest
-}) => (
-
-);
+const Title: FC = ({ children, className, ...rest }) => {
+ return (
+
+ {children}
+
+ );
+};
+type ListProps = ComponentPropsWithoutRef<'ul'>;
-const ErrorSummaryBody: FC> = ({ className, ...rest }) => (
-
-);
+const List: FC = ({ children, className, ...rest }) => {
+ if (!children) {
+ return null;
+ }
-const ErrorSummaryList: FC> = ({ className, ...rest }) => (
-
-);
+ return (
+
+ );
+};
-const ErrorSummaryListItem: FC> = (props) => (
-
-
-
-);
+type ListItemProps = AsElementLink;
+
+const ListItem = forwardRef((props, forwardedRef) => {
+ const { children, asElement: Element = 'a', ...rest } = props;
-interface ErrorSummary
- extends ForwardRefExoticComponent<
- PropsWithoutRef> & RefAttributes
- > {
- Title: FC>;
- Body: FC>;
- List: FC>;
- Item: FC>;
+ if (!children) {
+ return null;
+ }
+
+ return (
+
+ {(props.asElement ?? props.href) ? (
+
+ {children}
+
+ ) : (
+ <>{children}>
+ )}
+
+ );
+});
+
+export interface ErrorSummaryProps extends ComponentPropsWithoutRef<'div'> {
+ disableAutoFocus?: boolean;
}
-const ErrorSummaryDiv = forwardRef>(
- ({
- className,
- tabIndex = -1,
- role = 'alert',
- 'aria-labelledby': ariaLabelledBy = DefaultErrorSummaryTitleID,
- ...rest
- },
- ref
-) => {
- useDevWarning('The ErrorSummary component should always have a tabIndex of -1', () => tabIndex !== -1)
- useDevWarning('The ErrorSummary component should always have a role of alert', () => role !== 'alert')
-
+const ErrorSummaryComponent = forwardRef(
+ ({ children, className, disableAutoFocus, ...rest }, forwardedRef) => {
+ const [moduleRef] = useState(() => forwardedRef || createRef());
+ const [instance, setInstance] = useState();
+
+ useEffect(() => {
+ if (!('current' in moduleRef) || !moduleRef.current || instance) {
+ return;
+ }
+
+ const { current: $root } = moduleRef;
+
+ import('nhsuk-frontend').then(({ ErrorSummary }) => {
+ setInstance(new ErrorSummary($root));
+ });
+ }, [moduleRef, instance]);
+
+ const items = Children.toArray(children);
+ const title = items.find((child) => childIsOfComponentType(child, Title));
+ const bodyItems = items.filter((child) => !childIsOfComponentType(child, Title));
+
return (
- )
-});
-
+ >
+ {/* Keep the role="alert" in a seperate child container to prevent a race condition between
+ the focusing js at the alert, resulting in information getting missed in screen reader announcements */}
+
+ {title}
+
{bodyItems}
+
+
+ );
+ },
+);
-ErrorSummaryDiv.displayName = 'ErrorSummary';
+ErrorSummaryComponent.displayName = 'ErrorSummary';
+Title.displayName = 'ErrorSummary.Title';
+List.displayName = 'ErrorSummary.List';
+ListItem.displayName = 'ErrorSummary.ListItem';
-const ErrorSummary: ErrorSummary = Object.assign(ErrorSummaryDiv, {
- Title: ErrorSummaryTitle,
- Body: ErrorSummaryBody,
- List: ErrorSummaryList,
- Item: ErrorSummaryListItem,
+export default Object.assign(ErrorSummaryComponent, {
+ Title,
+ List,
+ ListItem,
});
-
-export default ErrorSummary;
diff --git a/src/components/form-elements/error-summary/__tests__/ErrorSummary.test.tsx b/src/components/form-elements/error-summary/__tests__/ErrorSummary.test.tsx
index 097142d7..504774a5 100644
--- a/src/components/form-elements/error-summary/__tests__/ErrorSummary.test.tsx
+++ b/src/components/form-elements/error-summary/__tests__/ErrorSummary.test.tsx
@@ -1,40 +1,88 @@
import React, { createRef } from 'react';
import { render } from '@testing-library/react';
+import { renderClient, renderServer } from '@util/components';
import ErrorSummary from '../';
describe('ErrorSummary', () => {
- it('matches snapshot', () => {
- const { container } = render( );
+ it('matches snapshot', async () => {
+ const { container } = await renderClient( , {
+ moduleName: 'nhsuk-error-summary',
+ });
+
+ expect(container).toMatchSnapshot();
+ });
+
+ it('matches snapshot (via server)', async () => {
+ const { container, element } = await renderServer( , {
+ moduleName: 'nhsuk-error-summary',
+ });
+
+ expect(container).toMatchSnapshot('server');
+
+ await renderClient(element, {
+ moduleName: 'nhsuk-error-summary',
+ hydrate: true,
+ container,
+ });
+
+ expect(container).toMatchSnapshot('client');
+ });
+
+ it('forwards refs', async () => {
+ const ref = createRef();
+
+ const { modules } = await renderClient( , {
+ moduleName: 'nhsuk-error-summary',
+ });
- expect(container).toMatchSnapshot('ErrorSummary');
+ const [errorSummaryEl] = modules;
+
+ expect(ref.current).toBe(errorSummaryEl);
+ expect(ref.current).toHaveClass('nhsuk-error-summary');
});
- it('forwards refs', () => {
+ it('is focused automatically', async () => {
+ const { modules } = await renderClient( , {
+ moduleName: 'nhsuk-error-summary',
+ });
+
+ const [errorSummaryEl] = modules;
+ expect(document.activeElement).toBe(errorSummaryEl);
+ });
+
+ it('is focused automatically with forwarded ref', async () => {
const ref = createRef();
- render( );
- expect(ref.current).not.toBeNull();
+ const { modules } = await renderClient( , {
+ moduleName: 'nhsuk-error-summary',
+ });
+
+ const [errorSummaryEl] = modules;
+
+ expect(document.activeElement).toBe(errorSummaryEl);
});
- it('has default props', () => {
- const { container } = render( );
+ it('has default props', async () => {
+ const { modules } = await renderClient( , {
+ moduleName: 'nhsuk-error-summary',
+ });
+
+ const [errorSummaryEl] = modules;
+
+ expect(errorSummaryEl?.getAttribute('tabindex')).toBe('-1');
+ expect(errorSummaryEl?.firstElementChild?.getAttribute('role')).toBe('alert');
+ });
- expect(container.querySelector('div')?.getAttribute('tabindex')).toBe('-1');
- expect(container.querySelector('div')?.getAttribute('role')).toBe('alert');
- expect(container.querySelector('div')?.getAttribute('aria-labelledby')).toBe('error-summary-title');
- })
+ it('has default props with forwarded ref', async () => {
+ const { modules } = await renderClient( , {
+ moduleName: 'nhsuk-error-summary',
+ });
- it('throws a dev warning if tabIndex is not -1', () => {
- jest.spyOn(console, 'warn').mockImplementation(() => {});
- render( );
- expect(console.warn).toHaveBeenCalledWith('The ErrorSummary component should always have a tabIndex of -1');
- })
+ const [errorSummaryEl] = modules;
- it('throws a dev warning if role is not alert', () => {
- jest.spyOn(console, 'warn').mockImplementation(() => {});
- render( );
- expect(console.warn).toHaveBeenCalledWith('The ErrorSummary component should always have a role of alert');
- })
+ expect(errorSummaryEl?.getAttribute('tabindex')).toBe('-1');
+ expect(errorSummaryEl?.firstElementChild?.getAttribute('role')).toBe('alert');
+ });
describe('ErrorSummary.Title', () => {
it('matches snapshot', () => {
@@ -46,16 +94,7 @@ describe('ErrorSummary', () => {
it('renders a title', () => {
const { container } = render(Title );
- expect(container.textContent).toBe('Title');
- });
- });
-
- describe('ErrorSummary.Body', () => {
- it('matches snapshot', () => {
- const { container } = render(Body );
-
- expect(container.textContent).toBe('Body');
- expect(container).toMatchSnapshot('ErrorSummary.Body');
+ expect(container).toHaveTextContent('Title');
});
});
@@ -69,21 +108,48 @@ describe('ErrorSummary', () => {
it('renders children', () => {
const { container } = render(List );
- expect(container.textContent).toBe('List');
+ expect(container).toHaveTextContent('List');
+ });
+
+ it('renders null with no children', () => {
+ const { container } = render( );
+
+ expect(container.querySelector('ul')).toBeNull();
});
});
describe('ErrorSummary.ListItem', () => {
- it('matches snapshot', () => {
- const { container } = render(ListItem );
+ it('matches snapshot for items without links', () => {
+ const { container } = render(
+ List item without link ,
+ );
- expect(container).toMatchSnapshot('ErrorSummary.ListItem');
+ expect(container).toMatchSnapshot();
+ });
+
+ it('matches snapshot for items with links', () => {
+ const { container } = render(
+ List item with link ,
+ );
+
+ expect(container).toMatchSnapshot();
});
it('renders children', () => {
- const { container } = render(ListItem );
+ const { container } = render(
+ List item with link ,
+ );
+
+ const errorLinkEls = container.querySelectorAll('a');
+
+ expect(errorLinkEls?.[0]).toHaveTextContent('List item with link');
+ expect(errorLinkEls?.[0]).toHaveAttribute('href', '#example-error-1');
+ });
+
+ it('renders null with no children', () => {
+ const { container } = render( );
- expect(container.querySelector('a')?.textContent).toBe('ListItem');
+ expect(container.querySelector('li')).toBeNull();
});
});
});
diff --git a/src/components/form-elements/error-summary/__tests__/__snapshots__/ErrorSummary.test.tsx.snap b/src/components/form-elements/error-summary/__tests__/__snapshots__/ErrorSummary.test.tsx.snap
index 7e3f5481..80b2b61f 100644
--- a/src/components/form-elements/error-summary/__tests__/__snapshots__/ErrorSummary.test.tsx.snap
+++ b/src/components/form-elements/error-summary/__tests__/__snapshots__/ErrorSummary.test.tsx.snap
@@ -1,15 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`ErrorSummary ErrorSummary.Body matches snapshot: ErrorSummary.Body 1`] = `
-
-`;
-
exports[`ErrorSummary ErrorSummary.List matches snapshot: ErrorSummary.List 1`] = `
`;
diff --git a/src/components/form-elements/error-summary/index.ts b/src/components/form-elements/error-summary/index.ts
index 95d155c2..20fb46a1 100644
--- a/src/components/form-elements/error-summary/index.ts
+++ b/src/components/form-elements/error-summary/index.ts
@@ -1,3 +1 @@
-import ErrorSummary from './ErrorSummary';
-
-export default ErrorSummary;
+export { default } from './ErrorSummary';
diff --git a/src/components/form-elements/fieldset/Fieldset.tsx b/src/components/form-elements/fieldset/Fieldset.tsx
index 4e7445e5..3d3e0d3a 100644
--- a/src/components/form-elements/fieldset/Fieldset.tsx
+++ b/src/components/form-elements/fieldset/Fieldset.tsx
@@ -1,57 +1,23 @@
-import React, { FC, HTMLProps, MutableRefObject } from 'react';
+import React, { ComponentPropsWithoutRef, FC } from 'react';
import classNames from 'classnames';
-import { NHSUKSize } from '@util/types/NHSUKTypes';
-import HeadingLevel, { HeadingLevelType } from '@components/utils/HeadingLevel';
-import FormGroup from '@components/utils/FormGroup';
+import Legend from '../legend/Legend';
-interface LegendProps extends Omit, 'size'> {
- isPageHeading?: boolean;
- headingLevel?: HeadingLevelType;
- size?: NHSUKSize;
-}
+export type FieldsetProps = ComponentPropsWithoutRef<'fieldset'>;
-const Legend: FC = ({
- className,
- children,
- isPageHeading,
- headingLevel = 'h1',
- size,
- ...rest
-}) => (
-
- {isPageHeading ? (
-
- {children}
-
- ) : (
- children
- )}
-
-);
+const FieldsetComponent: FC = ({ children, className, ...rest }) => {
+ if (!children) {
+ return null;
+ }
-interface FieldsetProps extends HTMLProps {
- fieldsetRef?: MutableRefObject;
- disableErrorLine?: boolean;
-}
-
-const FieldSet = ({ className, fieldsetRef, disableErrorLine, ...rest }: FieldsetProps) => {
return (
-
-
-
+
+ {children}
+
);
};
-FieldSet.Legend = Legend;
+FieldsetComponent.displayName = 'Fieldset';
-export default FieldSet;
+export default Object.assign(FieldsetComponent, {
+ Legend,
+});
diff --git a/src/components/form-elements/fieldset/__tests__/Fieldset.test.tsx b/src/components/form-elements/fieldset/__tests__/Fieldset.test.tsx
index 4dbb6d52..220a91f1 100644
--- a/src/components/form-elements/fieldset/__tests__/Fieldset.test.tsx
+++ b/src/components/form-elements/fieldset/__tests__/Fieldset.test.tsx
@@ -20,28 +20,9 @@ describe('Fieldset', () => {
expect(container.textContent).toBe('Text');
});
- it('Wraps children in form group if the fieldset contains form elements', () => {
- const { container } = render(
-
-
- ,
- );
-
- expect(container.firstChild).toHaveClass('nhsuk-form-group');
- });
-
- describe('Fieldset.Legend', () => {
- it('matches snapshot', () => {
- const { container } = render(Text );
-
- expect(container).toMatchSnapshot('FieldsetLegend');
- });
-
- it('renders as page heading', () => {
- const { container } = render(Text );
+ it('renders null with no children', () => {
+ const { container } = render( );
- expect(container.querySelector('.nhsuk-fieldset__legend--xl')).toBeTruthy();
- expect(container.querySelector('.nhsuk-fieldset__heading')?.textContent).toBe('Text');
- });
+ expect(container.querySelector('fieldset')).toBeNull();
});
});
diff --git a/src/components/form-elements/fieldset/__tests__/__snapshots__/Fieldset.test.tsx.snap b/src/components/form-elements/fieldset/__tests__/__snapshots__/Fieldset.test.tsx.snap
index 8914084f..97e8f3fe 100644
--- a/src/components/form-elements/fieldset/__tests__/__snapshots__/Fieldset.test.tsx.snap
+++ b/src/components/form-elements/fieldset/__tests__/__snapshots__/Fieldset.test.tsx.snap
@@ -1,34 +1,20 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`Fieldset Fieldset.Legend matches snapshot: FieldsetLegend 1`] = `
-
-
- Text
-
-
-`;
-
exports[`Fieldset matches snapshot: Fieldset 1`] = `
+
`;
diff --git a/src/components/form-elements/fieldset/index.ts b/src/components/form-elements/fieldset/index.ts
index e888581d..e5d92007 100644
--- a/src/components/form-elements/fieldset/index.ts
+++ b/src/components/form-elements/fieldset/index.ts
@@ -1,3 +1 @@
-import Fieldset from './Fieldset';
-
-export default Fieldset;
+export { default } from './Fieldset';
diff --git a/src/components/form-elements/form/Form.tsx b/src/components/form-elements/form/Form.tsx
index 2e97664d..0bf2b67e 100644
--- a/src/components/form-elements/form/Form.tsx
+++ b/src/components/form-elements/form/Form.tsx
@@ -1,14 +1,16 @@
-import React, { FC, HTMLProps } from 'react';
+import React, { ComponentPropsWithoutRef, FC } from 'react';
import FormContext from './FormContext';
-type FormProps = HTMLProps & {
+type FormProps = ComponentPropsWithoutRef<'form'> & {
disableErrorFromComponents?: boolean;
};
-const Form: FC = ({ disableErrorFromComponents, ...rest }) => (
+const FormComponent: FC = ({ disableErrorFromComponents, ...rest }) => (
);
-export default Form;
+FormComponent.displayName = 'Form';
+
+export default FormComponent;
diff --git a/src/components/form-elements/form/FormContext.ts b/src/components/form-elements/form/FormContext.ts
index cd6e50a6..68b12f70 100644
--- a/src/components/form-elements/form/FormContext.ts
+++ b/src/components/form-elements/form/FormContext.ts
@@ -1,4 +1,3 @@
-'use client';
import { createContext, useContext } from 'react';
export interface IFormContext {
diff --git a/src/components/form-elements/form/index.ts b/src/components/form-elements/form/index.ts
index 8f5e7e88..84bc0aca 100644
--- a/src/components/form-elements/form/index.ts
+++ b/src/components/form-elements/form/index.ts
@@ -1,5 +1 @@
-import Form from './Form';
-
-export { useFormContext } from './FormContext';
-
-export default Form;
+export { default, useFormContext } from './FormContext';
diff --git a/src/components/form-elements/hint-text/HintText.tsx b/src/components/form-elements/hint-text/HintText.tsx
index 17f7e5f4..34f1c5f2 100644
--- a/src/components/form-elements/hint-text/HintText.tsx
+++ b/src/components/form-elements/hint-text/HintText.tsx
@@ -1,10 +1,20 @@
-import React, { FC, HTMLProps } from 'react';
+import React, { ComponentPropsWithoutRef, FC } from 'react';
import classNames from 'classnames';
-export type HintTextProps = HTMLProps;
+export type HintTextProps = ComponentPropsWithoutRef<'div'>;
-const HintText: FC = ({ className, ...rest }) => (
-
-);
+const HintTextComponent: FC = ({ children, className, ...rest }) => {
+ if (!children) {
+ return null;
+ }
-export default HintText;
+ return (
+
+ {children}
+
+ );
+};
+
+HintTextComponent.displayName = 'HintText';
+
+export default HintTextComponent;
diff --git a/src/components/form-elements/hint-text/__tests__/Hint.test.tsx b/src/components/form-elements/hint-text/__tests__/Hint.test.tsx
index 4fe23001..f5787c9c 100644
--- a/src/components/form-elements/hint-text/__tests__/Hint.test.tsx
+++ b/src/components/form-elements/hint-text/__tests__/Hint.test.tsx
@@ -1,17 +1,23 @@
import React from 'react';
import { render } from '@testing-library/react';
-import Hint from '../';
+import HintText from '../';
describe('Hint', () => {
it('matches snapshot', () => {
- const { container } = render(Text );
+ const { container } = render(Text );
expect(container).toMatchSnapshot('Hint');
});
it('renders children', () => {
- const { container } = render(Text );
+ const { container } = render(Text );
expect(container.textContent).toBe('Text');
});
+
+ it('renders null with no children', () => {
+ const { container } = render( );
+
+ expect(container.querySelector('div')).toBeNull();
+ });
});
diff --git a/src/components/form-elements/hint-text/index.ts b/src/components/form-elements/hint-text/index.ts
index 1af2f426..708441db 100644
--- a/src/components/form-elements/hint-text/index.ts
+++ b/src/components/form-elements/hint-text/index.ts
@@ -1,3 +1 @@
-import HintText from './HintText';
-
-export default HintText;
+export { default } from './HintText';
diff --git a/src/components/form-elements/label/Label.tsx b/src/components/form-elements/label/Label.tsx
index af0eff1b..734503c2 100644
--- a/src/components/form-elements/label/Label.tsx
+++ b/src/components/form-elements/label/Label.tsx
@@ -1,36 +1,36 @@
-import React, { FC, HTMLProps } from 'react';
+import React, { ComponentPropsWithoutRef, FC } from 'react';
import classNames from 'classnames';
import { NHSUKSize } from '@util/types/NHSUKTypes';
-export interface LabelProps extends Omit, 'size'> {
- bold?: boolean;
+export interface LabelProps extends ComponentPropsWithoutRef<'label'> {
isPageHeading?: boolean;
size?: NHSUKSize;
}
-const BaseLabel: FC = ({ className, bold, size, isPageHeading, ...rest }) => (
+const Label: FC> = ({ className, size, ...rest }) => (
// eslint-disable-next-line jsx-a11y/label-has-associated-control
);
-const Label: FC = ({ isPageHeading, ...rest }) => {
+const LabelComponent: FC = ({ isPageHeading, children, ...rest }) => {
+ if (!children) {
+ return null;
+ }
+
if (isPageHeading) {
return (
-
+ {children}
);
}
- return ;
+
+ return {children} ;
};
-export default Label;
+LabelComponent.displayName = 'Label';
+
+export default LabelComponent;
diff --git a/src/components/form-elements/label/__tests__/Label.test.tsx b/src/components/form-elements/label/__tests__/Label.test.tsx
index beb5dab2..70e6ca62 100644
--- a/src/components/form-elements/label/__tests__/Label.test.tsx
+++ b/src/components/form-elements/label/__tests__/Label.test.tsx
@@ -1,5 +1,6 @@
import React from 'react';
import { render } from '@testing-library/react';
+import type { NHSUKSize } from '@util/types/NHSUKTypes';
import Label from '../Label';
describe('Label', () => {
@@ -10,19 +11,47 @@ describe('Label', () => {
expect(container.innerHTML).toBe('Text ');
});
- it('can render with size m', () => {
- const { container } = render(Text );
+ it.each(['s', 'm', 'l', 'xl'])('renders with custom size %s', (size) => {
+ const { container } = render(Text );
- expect(container.textContent).toBe('Text');
- expect(container.innerHTML).toBe('Text ');
+ const labelEl = container.querySelector('.nhsuk-label');
+
+ expect(labelEl).toHaveTextContent('Text');
+ expect(labelEl).toHaveClass(`nhsuk-label--${size}`);
});
- it('can render with heading prop', () => {
+ it('renders as page heading', () => {
const { container } = render(Text );
- expect(container.querySelector('.nhsuk-label.nhsuk-label--xl')?.textContent).toBe('Text');
- expect(container.innerHTML).toBe(
- 'Text ',
- );
+ const headingEl = container.querySelector('.nhsuk-label-wrapper');
+ const labelEl = headingEl?.querySelector('.nhsuk-label');
+
+ expect(headingEl?.tagName).toBe('H1');
+ expect(labelEl).toHaveTextContent('Text');
+ expect(labelEl).not.toHaveClass(`nhsuk-label--xl`);
+ });
+
+ it.each(['s', 'm', 'l', 'xl'])(
+ 'renders as page heading with custom size %s',
+ (size) => {
+ const { container } = render(
+
+ Text
+ ,
+ );
+
+ const headingEl = container.querySelector('.nhsuk-label-wrapper');
+ const labelEl = headingEl?.querySelector('.nhsuk-label');
+
+ expect(headingEl?.tagName).toBe('H1');
+ expect(labelEl).toHaveTextContent('Text');
+ expect(labelEl).toHaveClass(`nhsuk-label--${size}`);
+ },
+ );
+
+ it('renders null with no children', () => {
+ const { container } = render( );
+
+ expect(container.querySelector('label')).toBeNull();
});
});
diff --git a/src/components/form-elements/label/index.ts b/src/components/form-elements/label/index.ts
index 2b0c401b..a1e5f51f 100644
--- a/src/components/form-elements/label/index.ts
+++ b/src/components/form-elements/label/index.ts
@@ -1,3 +1 @@
-import Label from './Label';
-
-export default Label;
+export { default } from './Label';
diff --git a/src/components/form-elements/legend/Legend.tsx b/src/components/form-elements/legend/Legend.tsx
new file mode 100644
index 00000000..d56f4a64
--- /dev/null
+++ b/src/components/form-elements/legend/Legend.tsx
@@ -0,0 +1,47 @@
+import React, { ComponentPropsWithoutRef, FC } from 'react';
+import classNames from 'classnames';
+import { NHSUKSize } from '@util/types/NHSUKTypes';
+import HeadingLevel, { HeadingLevelProps } from '@components/utils/HeadingLevel';
+
+export interface LegendProps
+ extends ComponentPropsWithoutRef<'legend'>,
+ Pick {
+ isPageHeading?: boolean;
+ size?: NHSUKSize;
+}
+
+const LegendComponent: FC = ({
+ className,
+ children,
+ isPageHeading,
+ headingLevel = 'h1',
+ size,
+ ...rest
+}) => {
+ if (!children) {
+ return null;
+ }
+
+ return (
+
+ {isPageHeading ? (
+
+ {children}
+
+ ) : (
+ children
+ )}
+
+ );
+};
+
+LegendComponent.displayName = 'Fieldset.Legend';
+
+export default LegendComponent;
diff --git a/src/components/form-elements/legend/__tests__/Legend.test.tsx b/src/components/form-elements/legend/__tests__/Legend.test.tsx
new file mode 100644
index 00000000..d92a64c3
--- /dev/null
+++ b/src/components/form-elements/legend/__tests__/Legend.test.tsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import { render } from '@testing-library/react';
+import { NHSUKSize } from '@util/types/NHSUKTypes';
+import Legend from '..';
+
+describe('Legend', () => {
+ it('matches snapshot', () => {
+ const { container } = render(Text );
+
+ expect(container).toMatchSnapshot('Legend');
+ });
+
+ it('renders as page heading', () => {
+ const { container } = render(Text );
+
+ const legendEl = container.querySelector('.nhsuk-fieldset__legend');
+ const headingEl = legendEl?.querySelector('.nhsuk-fieldset__heading');
+
+ expect(legendEl).toHaveTextContent('Text');
+ expect(legendEl).not.toHaveClass('nhsuk-fieldset__legend--xl');
+ expect(headingEl?.tagName).toBe('H1');
+ });
+
+ it.each(['s', 'm', 'l', 'xl'])(
+ 'renders as page heading with custom size %s',
+ (size) => {
+ const { container } = render(
+
+ Text
+ ,
+ );
+
+ const legendEl = container.querySelector('.nhsuk-fieldset__legend');
+ const headingEl = legendEl?.querySelector('.nhsuk-fieldset__heading');
+
+ expect(legendEl).toHaveTextContent('Text');
+ expect(legendEl).toHaveClass(`nhsuk-fieldset__legend--${size}`);
+ expect(headingEl?.tagName).toBe('H1');
+ },
+ );
+
+ it('renders null with no children', () => {
+ const { container } = render( );
+
+ expect(container.querySelector('legend')).toBeNull();
+ });
+});
diff --git a/src/components/form-elements/legend/__tests__/__snapshots__/Legend.test.tsx.snap b/src/components/form-elements/legend/__tests__/__snapshots__/Legend.test.tsx.snap
new file mode 100644
index 00000000..0518aede
--- /dev/null
+++ b/src/components/form-elements/legend/__tests__/__snapshots__/Legend.test.tsx.snap
@@ -0,0 +1,11 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Legend matches snapshot: Legend 1`] = `
+
+
+ Text
+
+
+`;
diff --git a/src/components/form-elements/legend/index.ts b/src/components/form-elements/legend/index.ts
new file mode 100644
index 00000000..d817d2f5
--- /dev/null
+++ b/src/components/form-elements/legend/index.ts
@@ -0,0 +1 @@
+export { default } from './Legend';
diff --git a/src/components/form-elements/radios/Radios.tsx b/src/components/form-elements/radios/Radios.tsx
index a2e59370..b219d662 100644
--- a/src/components/form-elements/radios/Radios.tsx
+++ b/src/components/form-elements/radios/Radios.tsx
@@ -1,22 +1,42 @@
-import React, { HTMLProps, useState } from 'react';
+import React, { ComponentPropsWithoutRef, createRef, forwardRef, useEffect, useState } from 'react';
import classNames from 'classnames';
import { FormElementProps } from '@util/types/FormTypes';
import { RadiosContext, IRadiosContext } from './RadioContext';
-import SingleInputFormGroup from '@components/utils/SingleInputFormGroup';
-import Divider from './components/Divider';
-import Radio from './components/Radio';
+import FormGroup from '@components/utils/FormGroup';
+import RadiosDivider from './components/Divider';
+import RadiosItem from './components/Item';
import { generateRandomName } from '@util/RandomID';
+import { type Radios } from 'nhsuk-frontend';
-interface RadiosProps extends HTMLProps, FormElementProps {
+export interface RadiosProps
+ extends ComponentPropsWithoutRef<'div'>,
+ Omit {
inline?: boolean;
idPrefix?: string;
}
-const Radios = ({ children, idPrefix, ...rest }: RadiosProps) => {
+const RadiosComponent = forwardRef((props, forwardedRef) => {
+ const { children, idPrefix, ...rest } = props;
+
+ const [moduleRef] = useState(() => forwardedRef || createRef());
+ const [instance, setInstance] = useState();
+ const [selectedRadio, setSelectedRadio] = useState();
+
const _radioReferences: Array = [];
let _radioCount = 0;
let _radioIds: Record = {};
- const [selectedRadio, setSelectedRadio] = useState();
+
+ useEffect(() => {
+ if (!('current' in moduleRef) || !moduleRef.current || instance) {
+ return;
+ }
+
+ const { current: $root } = moduleRef;
+
+ import('nhsuk-frontend').then(({ Radios }) => {
+ setInstance(new Radios($root));
+ });
+ }, [moduleRef, instance]);
const getRadioId = (id: string, reference: string): string => {
if (reference in _radioIds) {
@@ -53,7 +73,7 @@ const Radios = ({ children, idPrefix, ...rest }: RadiosProps) => {
};
return (
- inputType="radios" {...rest}>
+ inputType="radios" {...rest}>
{/* eslint-disable-next-line @typescript-eslint/no-unused-vars */}
{({ className, inline, name, id, error, ...restRenderProps }) => {
resetRadioIds();
@@ -69,18 +89,22 @@ const Radios = ({ children, idPrefix, ...rest }: RadiosProps) => {
return (
{children}
);
}}
-
+
);
-};
+});
-Radios.Divider = Divider;
-Radios.Radio = Radio;
+RadiosComponent.displayName = 'Radios';
-export default Radios;
+export default Object.assign(RadiosComponent, {
+ Item: RadiosItem,
+ Divider: RadiosDivider,
+});
diff --git a/src/components/form-elements/radios/__tests__/Radios.test.tsx b/src/components/form-elements/radios/__tests__/Radios.test.tsx
index cdca8935..b94c284b 100644
--- a/src/components/form-elements/radios/__tests__/Radios.test.tsx
+++ b/src/components/form-elements/radios/__tests__/Radios.test.tsx
@@ -1,61 +1,115 @@
-import { render } from '@testing-library/react';
-import React from 'react';
+import React, { createRef } from 'react';
import Radios from '../Radios';
+import { renderClient, renderServer } from '@util/components';
describe('Radios', () => {
- it('matches snapshot', () => {
- const { container } = render(
+ it('matches snapshot', async () => {
+ const { container } = await renderClient(
-
+
Yes
-
-
+
+
No
-
+
,
+ { moduleName: 'nhsuk-radios' },
);
expect(container).toMatchSnapshot();
});
- it('does not render the conditional content if checked is false', () => {
- const { container } = render(
+ it('matches snapshot (via server)', async () => {
+ const { container, element } = await renderServer(
-
+ Yes
+
+
+ No
+
+ ,
+ { moduleName: 'nhsuk-radios' },
+ );
+
+ expect(container).toMatchSnapshot('server');
+
+ await renderClient(element, {
+ moduleName: 'nhsuk-radios',
+ hydrate: true,
+ container,
+ });
+
+ expect(container).toMatchSnapshot('client');
+ });
+
+ it('forwards refs', async () => {
+ const groupRef = createRef();
+ const moduleRef = createRef();
+ const fieldRef = createRef();
+
+ const { container } = await renderClient(
+
+ Yes
+ ,
+ { moduleName: 'nhsuk-radios' },
+ );
+
+ const groupEl = container.querySelectorAll('div')[0];
+ const moduleEl = container.querySelectorAll('div')[1];
+ const inputEl = container.querySelector('input');
+
+ expect(groupRef.current).toBe(groupEl);
+ expect(groupRef.current).toHaveClass('nhsuk-form-group');
+
+ expect(moduleRef.current).toBe(moduleEl);
+ expect(moduleRef.current).toHaveClass('nhsuk-radios');
+
+ expect(fieldRef.current).toBe(inputEl);
+ expect(fieldRef.current).toHaveClass('nhsuk-radios__input');
+ });
+
+ it('does not render the conditional content if checked is false', async () => {
+ const { container } = await renderClient(
+
+ Test}
>
Yes
-
-
+
+
No
-
+
,
+ { moduleName: 'nhsuk-radios' },
);
- expect(container.querySelector('.conditional-test')).toBeFalsy();
+ const conditionalElement = container.querySelector('.conditional-test');
+ expect(conditionalElement?.parentElement).toHaveClass('nhsuk-radios__conditional--hidden');
});
- it('renders the conditional content if the radio reference = selected radio', () => {
- const { container } = render(
+ it('renders the conditional content if the radio reference = selected radio', async () => {
+ const { container } = await renderClient(
- Test}
>
Yes
-
-
+
+
No
-
+
,
+ { moduleName: 'nhsuk-radios' },
);
const conditionalElement = container.querySelector('.conditional-test');
- expect(conditionalElement?.textContent).toBe('Test');
+ expect(conditionalElement).toHaveTextContent('Test');
});
});
diff --git a/src/components/form-elements/radios/__tests__/__snapshots__/Radios.test.tsx.snap b/src/components/form-elements/radios/__tests__/__snapshots__/Radios.test.tsx.snap
index 3df7d5d6..f9a119dd 100644
--- a/src/components/form-elements/radios/__tests__/__snapshots__/Radios.test.tsx.snap
+++ b/src/components/form-elements/radios/__tests__/__snapshots__/Radios.test.tsx.snap
@@ -1,5 +1,108 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
+exports[`Radios matches snapshot (via server): client 1`] = `
+
+`;
+
+exports[`Radios matches snapshot (via server): server 1`] = `
+
+`;
+
exports[`Radios matches snapshot 1`] = `
> = ({ className, ...rest }) => (
+const RadiosDivider: FC
> = ({ className, ...rest }) => (
);
-export default Divider;
+RadiosDivider.displayName = 'Radios.Divider';
+
+export default RadiosDivider;
diff --git a/src/components/form-elements/radios/components/Item.tsx b/src/components/form-elements/radios/components/Item.tsx
new file mode 100644
index 00000000..b6ee1303
--- /dev/null
+++ b/src/components/form-elements/radios/components/Item.tsx
@@ -0,0 +1,105 @@
+import React, {
+ ComponentPropsWithRef,
+ ComponentPropsWithoutRef,
+ useContext,
+ ReactNode,
+ useEffect,
+ useState,
+ forwardRef,
+} from 'react';
+import classNames from 'classnames';
+import { FormElementProps } from '@util/types/FormTypes';
+import { RadiosContext, IRadiosContext } from '../RadioContext';
+import HintText from '../../hint-text/HintText';
+import Label from '../../label/Label';
+
+export interface RadiosItemProps
+ extends ComponentPropsWithoutRef<'input'>,
+ Pick {
+ conditional?: ReactNode;
+ forceShowConditional?: boolean;
+ conditionalProps?: ComponentPropsWithRef<'div'>;
+}
+
+const RadiosItem = forwardRef((props, forwardedRef) => {
+ const {
+ className,
+ children,
+ id,
+ hint,
+ hintProps,
+ labelProps,
+ conditional,
+ forceShowConditional,
+ conditionalProps,
+ checked,
+ defaultChecked,
+ onChange,
+ ...rest
+ } = props;
+
+ const { name, getRadioId, setSelected, selectedRadio, leaseReference, unleaseReference } =
+ useContext(RadiosContext);
+ const [radioReference] = useState(leaseReference());
+ const inputID = id || getRadioId(radioReference);
+ const shouldShowConditional = selectedRadio === radioReference && checked !== false;
+
+ useEffect(() => () => unleaseReference(radioReference));
+
+ useEffect(() => {
+ if (defaultChecked) setSelected(radioReference);
+ }, []);
+
+ useEffect(() => {
+ if (checked) setSelected(radioReference);
+ }, [checked]);
+
+ return (
+ <>
+
+ {
+ setSelected(radioReference);
+ if (onChange) onChange(e);
+ }}
+ className={classNames('nhsuk-radios__input', className)}
+ id={inputID}
+ name={name}
+ type="radio"
+ aria-controls={conditional ? `${inputID}--conditional` : undefined}
+ aria-describedby={hint ? `${inputID}--hint` : undefined}
+ checked={checked}
+ defaultChecked={defaultChecked}
+ ref={forwardedRef}
+ {...rest}
+ />
+
+ {children}
+
+
+ {hint}
+
+
+ {conditional && (
+
+ {conditional}
+
+ )}
+ >
+ );
+});
+
+RadiosItem.displayName = 'Radios.Item';
+
+export default RadiosItem;
diff --git a/src/components/form-elements/radios/components/Radio.tsx b/src/components/form-elements/radios/components/Radio.tsx
deleted file mode 100644
index b2f315cd..00000000
--- a/src/components/form-elements/radios/components/Radio.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-'use client';
-import React, { FC, HTMLProps, useContext, ReactNode, useEffect, useState } from 'react';
-import classNames from 'classnames';
-import { RadiosContext, IRadiosContext } from '../RadioContext';
-import HintText, { HintTextProps } from '../../hint-text/HintText';
-import Label, { LabelProps } from '../../label/Label';
-
-export interface RadioProps extends HTMLProps {
- hint?: string;
- hintProps?: HintTextProps;
- labelProps?: LabelProps;
- conditional?: ReactNode;
- forceShowConditional?: boolean;
- conditionalWrapperProps?: HTMLProps;
- inputRef?: (inputRef: HTMLInputElement | null) => void;
-}
-
-const Radio: FC = ({
- className,
- children,
- id,
- hint,
- hintProps,
- labelProps,
- conditional,
- forceShowConditional,
- conditionalWrapperProps,
- checked,
- defaultChecked,
- onChange,
- inputRef,
- type = 'radio',
- ...rest
-}) => {
- const { name, getRadioId, setSelected, selectedRadio, leaseReference, unleaseReference } =
- useContext(RadiosContext);
- const [radioReference] = useState(leaseReference());
- const inputID = id || getRadioId(radioReference);
- const shouldShowConditional = selectedRadio === radioReference && checked !== false;
-
- useEffect(() => () => unleaseReference(radioReference));
-
- useEffect(() => {
- if (defaultChecked) setSelected(radioReference);
- }, []);
-
- useEffect(() => {
- if (checked) setSelected(radioReference);
- }, [checked]);
-
- return (
- <>
-
- {
- setSelected(radioReference);
- if (onChange) onChange(e);
- }}
- className={classNames('nhsuk-radios__input', className)}
- id={inputID}
- name={name}
- aria-describedby={hint ? `${inputID}--hint` : undefined}
- checked={checked}
- defaultChecked={defaultChecked}
- ref={inputRef}
- type={type}
- {...rest}
- />
- {children ? (
-
- {children}
-
- ) : null}
- {hint ? (
-
- {hint}
-
- ) : null}
-
- {conditional && (shouldShowConditional || forceShowConditional) ? (
-
- {conditional}
-
- ) : null}
- >
- );
-};
-
-export default Radio;
diff --git a/src/components/form-elements/radios/index.ts b/src/components/form-elements/radios/index.ts
index 40c279ab..441fd619 100644
--- a/src/components/form-elements/radios/index.ts
+++ b/src/components/form-elements/radios/index.ts
@@ -1,3 +1 @@
-import Radios from './Radios';
-
-export default Radios;
+export { default } from './Radios';
diff --git a/src/components/form-elements/select/Select.tsx b/src/components/form-elements/select/Select.tsx
index daac61e5..8660ab60 100644
--- a/src/components/form-elements/select/Select.tsx
+++ b/src/components/form-elements/select/Select.tsx
@@ -1,34 +1,35 @@
-import React, { FC, HTMLProps, MutableRefObject } from 'react';
+import React, { ComponentPropsWithoutRef, forwardRef } from 'react';
import classNames from 'classnames';
import { FormElementProps } from '@util/types/FormTypes';
-import SingleInputFormGroup from '@components/utils/SingleInputFormGroup';
+import FormGroup from '@components/utils/FormGroup';
-// SelectProps = HTMLProps & FormElementProps;
-interface ISelectProps extends HTMLProps, FormElementProps {
- selectRef?: MutableRefObject;
-}
+export type SelectProps = ComponentPropsWithoutRef<'select'> &
+ Omit;
-interface ISelect extends FC {
- Option: FC>;
-}
-
-const Select: ISelect = ({ children, ...rest }) => (
- inputType="select" {...rest}>
- {({ className, error, selectRef, ...restRenderProps }) => (
-
- {children}
-
- )}
-
+const SelectComponent = forwardRef(
+ ({ children, ...rest }, forwardedRef) => (
+ inputType="select" {...rest}>
+ {({ className, error, ...restRenderProps }) => (
+
+ {children}
+
+ )}
+
+ ),
);
-const Option: FC> = (props) => ;
+const Option = forwardRef>(
+ (props, forwardedRef) => ,
+);
-Select.Option = Option;
+SelectComponent.displayName = 'Select';
+Option.displayName = 'Select.Option';
-export default Select;
+export default Object.assign(SelectComponent, {
+ Option,
+});
diff --git a/src/components/form-elements/select/__tests__/Select.test.tsx b/src/components/form-elements/select/__tests__/Select.test.tsx
index 6b0d03fc..34a60517 100644
--- a/src/components/form-elements/select/__tests__/Select.test.tsx
+++ b/src/components/form-elements/select/__tests__/Select.test.tsx
@@ -1,6 +1,7 @@
-import React, { useRef } from 'react';
-import { render, fireEvent } from '@testing-library/react';
+import React, { createRef, useRef } from 'react';
+import { render } from '@testing-library/react';
import Select from '../Select';
+import { renderClient, renderServer } from '@util/components';
describe('Select', () => {
afterEach(() => {
@@ -14,28 +15,73 @@ describe('Select', () => {
onHandle();
};
- return ;
+ return ;
};
- it('Matches the snapshot', () => {
- const { container } = render(
+ it('matches snapshot', async () => {
+ const { container } = await renderClient(
Option 1
Option 2
,
+ { className: 'nhsuk-select' },
);
expect(container).toMatchSnapshot();
});
- it.each([true, false])('Adds the appropriate class if error is specified as %s', (error) => {
- const { container } = render( );
+ it('matches snapshot (via server)', async () => {
+ const { container, element } = await renderServer(
+
+ Option 1
+ Option 2
+ ,
+ { className: 'nhsuk-select' },
+ );
+
+ expect(container).toMatchSnapshot('server');
+
+ await renderClient(element, {
+ className: 'nhsuk-select',
+ hydrate: true,
+ container,
+ });
+
+ expect(container).toMatchSnapshot('client');
+ });
+
+ it('forwards refs', async () => {
+ const groupRef = createRef();
+ const fieldRef = createRef();
+
+ const { container } = await renderClient(
+ ,
+ { className: 'nhsuk-select' },
+ );
+
+ const groupEl = container.querySelector('div');
+ const selectEl = container.querySelector('select');
+
+ expect(groupRef.current).toBe(groupEl);
+ expect(groupRef.current).toHaveClass('nhsuk-form-group');
+
+ expect(fieldRef.current).toBe(selectEl);
+ expect(fieldRef.current).toHaveClass('nhsuk-select');
+ });
+
+ it('sets the error class when error message is provided', async () => {
+ const { modules } = await renderClient(
+ <>
+
+
+ >,
+ { className: 'nhsuk-select' },
+ );
+
+ const [selectEl1, selectEl2] = modules;
- if (error) {
- expect(container.querySelector('#test-select')).toHaveClass('nhsuk-select--error');
- } else {
- expect(container.querySelector('#test-select')).not.toHaveClass('nhsuk-select--error');
- }
+ expect(selectEl1).not.toHaveClass('nhsuk-select--error');
+ expect(selectEl2).toHaveClass('nhsuk-select--error');
});
it('should handle DOM events where ref Exists', () => {
@@ -46,7 +92,7 @@ describe('Select', () => {
const { container } = render( );
const selectEl = container.querySelector('select')!;
- fireEvent.click(selectEl);
+ selectEl.click();
expect(useRefSpy).toBeCalledWith(null);
expect(mock).toBeCalledTimes(1);
diff --git a/src/components/form-elements/select/__tests__/__snapshots__/Select.test.tsx.snap b/src/components/form-elements/select/__tests__/__snapshots__/Select.test.tsx.snap
index 975562e3..003e2068 100644
--- a/src/components/form-elements/select/__tests__/__snapshots__/Select.test.tsx.snap
+++ b/src/components/form-elements/select/__tests__/__snapshots__/Select.test.tsx.snap
@@ -1,6 +1,56 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`Select Matches the snapshot 1`] = `
+exports[`Select matches snapshot (via server): client 1`] = `
+
+
+
+
+ Option 1
+
+
+ Option 2
+
+
+
+
+`;
+
+exports[`Select matches snapshot (via server): server 1`] = `
+
+
+
+
+ Option 1
+
+
+ Option 2
+
+
+
+
+`;
+
+exports[`Select matches snapshot 1`] = `