diff --git a/CHANGELOG.md b/CHANGELOG.md index 98f0c8589..1573d819e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # NHS.UK React components +## 6.0.0-beta.3 - 27 October 2025 + +This version provides support for NHS.UK frontend v10.1 and includes: + +- [Smaller radios](https://service-manual.nhs.uk/design-system/components/radios#smaller-radios) and [smaller checkboxes](https://service-manual.nhs.uk/design-system/components/checkboxes#smaller-checkboxes) +- [Numbered pagination](https://service-manual.nhs.uk/design-system/components/pagination#for-navigating-between-pages-of-items) +- React strict mode support + +For a full list of changes in this release please refer to the [migration doc](https://github.com/NHSDigital/nhsuk-react-components/blob/main/docs/upgrade-to-6.0.md). + ## 6.0.0-beta.2 - 13 October 2025 This version provides support for NHS.UK frontend v10.x, React Server Components (RSC) and fixes a Rollup `'use client'` directive issue. diff --git a/package.json b/package.json index e8598639e..27a6b858c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nhsuk-react-components", - "version": "6.0.0-beta.2", + "version": "6.0.0-beta.3", "license": "MIT", "author": { "name": "NHS England" diff --git a/src/components/content-presentation/notification-banner/NotificationBanner.tsx b/src/components/content-presentation/notification-banner/NotificationBanner.tsx index 33eb003ff..d035612f7 100644 --- a/src/components/content-presentation/notification-banner/NotificationBanner.tsx +++ b/src/components/content-presentation/notification-banner/NotificationBanner.tsx @@ -2,9 +2,10 @@ import { Children, - createRef, forwardRef, useEffect, + useImperativeHandle, + useRef, useState, type ComponentPropsWithoutRef, } from 'react'; @@ -27,19 +28,22 @@ const NotificationBannerComponent = forwardRef { const { children, className, title, titleId, success, role, disableAutoFocus, ...rest } = props; - const [moduleRef] = useState(() => forwardedRef || createRef()); + const moduleRef = useRef(null); + const importRef = useRef>(null); const [instanceError, setInstanceError] = useState(); const [instance, setInstance] = useState(); + useImperativeHandle(forwardedRef, () => moduleRef.current!, [moduleRef]); + useEffect(() => { - if (!('current' in moduleRef) || !moduleRef.current || instance) { + if (!moduleRef.current || importRef.current || instance) { return; } - import('nhsuk-frontend') + importRef.current = import('nhsuk-frontend') .then(({ NotificationBanner }) => setInstance(new NotificationBanner(moduleRef.current))) .catch(setInstanceError); - }, [moduleRef, instance]); + }, [moduleRef, importRef, instance]); const items = Children.toArray(children); 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 284950430..5a9c101be 100644 --- a/src/components/content-presentation/table/components/__tests__/TableCell.test.tsx +++ b/src/components/content-presentation/table/components/__tests__/TableCell.test.tsx @@ -29,7 +29,7 @@ describe('Table.Cell', () => { , ); - expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalled(); expect(console.warn).toHaveBeenLastCalledWith( 'Table.Cell used outside of a Table.Head or Table.Body component. Unable to determine section type from context.', ); diff --git a/src/components/content-presentation/tabs/Tabs.tsx b/src/components/content-presentation/tabs/Tabs.tsx index 131274e1d..e31c07b02 100644 --- a/src/components/content-presentation/tabs/Tabs.tsx +++ b/src/components/content-presentation/tabs/Tabs.tsx @@ -3,9 +3,10 @@ import classNames from 'classnames'; import { type Tabs as TabsModule } from 'nhsuk-frontend'; import { - createRef, forwardRef, useEffect, + useImperativeHandle, + useRef, useState, type ComponentPropsWithoutRef, type FC, @@ -55,19 +56,22 @@ export const TabsContents: FC = ({ children, id, ...rest }) = const TabsComponent = forwardRef((props, forwardedRef) => { const { children, className, ...rest } = props; - const [moduleRef] = useState(() => forwardedRef || createRef()); + const moduleRef = useRef(null); + const importRef = useRef>(null); const [instanceError, setInstanceError] = useState(); const [instance, setInstance] = useState(); + useImperativeHandle(forwardedRef, () => moduleRef.current!, [moduleRef]); + useEffect(() => { - if (!('current' in moduleRef) || !moduleRef.current || instance) { + if (!moduleRef.current || importRef.current || instance) { return; } - import('nhsuk-frontend') + importRef.current = import('nhsuk-frontend') .then(({ Tabs }) => setInstance(new Tabs(moduleRef.current))) .catch(setInstanceError); - }, [moduleRef, instance]); + }, [moduleRef, importRef, instance]); if (instanceError) { throw instanceError; diff --git a/src/components/form-elements/button/Button.tsx b/src/components/form-elements/button/Button.tsx index 39a20ae67..aaf861849 100644 --- a/src/components/form-elements/button/Button.tsx +++ b/src/components/form-elements/button/Button.tsx @@ -3,9 +3,10 @@ import classNames from 'classnames'; import { type Button as ButtonModule } from 'nhsuk-frontend'; import { - createRef, forwardRef, useEffect, + useImperativeHandle, + useRef, useState, type ForwardedRef, type MouseEvent, @@ -45,19 +46,22 @@ const ButtonComponent = forwardRef((props, forwa ...rest } = props; - const [moduleRef] = useState(() => forwardedRef || createRef()); + const moduleRef = useRef(null); + const importRef = useRef>(null); const [instanceError, setInstanceError] = useState(); const [instance, setInstance] = useState(); + useImperativeHandle(forwardedRef, () => moduleRef.current!, [moduleRef]); + useEffect(() => { - if (!('current' in moduleRef) || !moduleRef.current || instance) { + if (!moduleRef.current || importRef.current || instance) { return; } - import('nhsuk-frontend') + importRef.current = import('nhsuk-frontend') .then(({ Button }) => setInstance(new Button(moduleRef.current))) .catch(setInstanceError); - }, [moduleRef, instance]); + }, [moduleRef, importRef, instance]); if (instanceError) { throw instanceError; @@ -104,19 +108,22 @@ const ButtonLinkComponent = forwardRef( ...rest } = props; - const [moduleRef] = useState(() => forwardedRef || createRef()); + const moduleRef = useRef(null); + const importRef = useRef>(null); const [instanceError, setInstanceError] = useState(); const [instance, setInstance] = useState(); + useImperativeHandle(forwardedRef, () => moduleRef.current!, [moduleRef]); + useEffect(() => { - if (!('current' in moduleRef) || !moduleRef.current || instance) { + if (!moduleRef.current || importRef.current || instance) { return; } - import('nhsuk-frontend') + importRef.current = import('nhsuk-frontend') .then(({ Button }) => setInstance(new Button(moduleRef.current))) .catch(setInstanceError); - }, [moduleRef, instance]); + }, [moduleRef, importRef, instance]); if (instanceError) { throw instanceError; diff --git a/src/components/form-elements/character-count/CharacterCount.tsx b/src/components/form-elements/character-count/CharacterCount.tsx index 10c7bcd56..c939fe642 100644 --- a/src/components/form-elements/character-count/CharacterCount.tsx +++ b/src/components/form-elements/character-count/CharacterCount.tsx @@ -2,7 +2,14 @@ import classNames from 'classnames'; import { type CharacterCount as CharacterCountModule } from 'nhsuk-frontend'; -import { createRef, forwardRef, useEffect, useState, type ComponentPropsWithoutRef } from 'react'; +import { + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState, + type ComponentPropsWithoutRef, +} from 'react'; import { FormGroup } from '#components/utils/index.js'; import { type FormElementProps } from '#util/types/FormTypes.js'; @@ -16,19 +23,22 @@ export interface CharacterCountProps export const CharacterCount = forwardRef( ({ maxLength, maxWords, threshold, formGroupProps, ...rest }, forwardedRef) => { - const [moduleRef] = useState(() => formGroupProps?.ref || createRef()); + const moduleRef = useRef(null); + const importRef = useRef>(null); const [instanceError, setInstanceError] = useState(); const [instance, setInstance] = useState(); + useImperativeHandle(formGroupProps?.ref, () => moduleRef.current!, [moduleRef]); + useEffect(() => { - if (!('current' in moduleRef) || !moduleRef.current || instance) { + if (!moduleRef.current || importRef.current || instance) { return; } - import('nhsuk-frontend') + importRef.current = import('nhsuk-frontend') .then(({ CharacterCount }) => setInstance(new CharacterCount(moduleRef.current))) .catch(setInstanceError); - }, [moduleRef, instance]); + }, [moduleRef, importRef, instance]); if (instanceError) { throw instanceError; diff --git a/src/components/form-elements/checkboxes/Checkboxes.tsx b/src/components/form-elements/checkboxes/Checkboxes.tsx index 9b2264876..ff1527b3c 100644 --- a/src/components/form-elements/checkboxes/Checkboxes.tsx +++ b/src/components/form-elements/checkboxes/Checkboxes.tsx @@ -2,7 +2,14 @@ import classNames from 'classnames'; import { type Checkboxes as CheckboxesModule } from 'nhsuk-frontend'; -import { createRef, forwardRef, useEffect, useState, type ComponentPropsWithoutRef } from 'react'; +import { + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState, + type ComponentPropsWithoutRef, +} from 'react'; import { CheckboxesDivider, CheckboxesItem } from './components/index.js'; import { CheckboxesContext, type ICheckboxesContext } from './CheckboxesContext.js'; import { FormGroup } from '#components/utils/index.js'; @@ -19,7 +26,8 @@ export interface CheckboxesProps const CheckboxesComponent = forwardRef((props, forwardedRef) => { const { children, idPrefix, ...rest } = props; - const [moduleRef] = useState(() => forwardedRef || createRef()); + const moduleRef = useRef(null); + const importRef = useRef>(null); const [instanceError, setInstanceError] = useState(); const [instance, setInstance] = useState(); @@ -27,15 +35,17 @@ const CheckboxesComponent = forwardRef((props, let _boxCount: number = 0; let _boxIds: Record = {}; + useImperativeHandle(forwardedRef, () => moduleRef.current!, [moduleRef]); + useEffect(() => { - if (!('current' in moduleRef) || !moduleRef.current || instance) { + if (!moduleRef.current || importRef.current || instance) { return; } - import('nhsuk-frontend') + importRef.current = import('nhsuk-frontend') .then(({ Checkboxes }) => setInstance(new Checkboxes(moduleRef.current))) .catch(setInstanceError); - }, [moduleRef, instance]); + }, [moduleRef, importRef, instance]); const getBoxId = (id: string, reference: string): string => { if (reference in _boxIds) { diff --git a/src/components/form-elements/date-input/DateInput.tsx b/src/components/form-elements/date-input/DateInput.tsx index e523eb67c..cf1b6f9ce 100644 --- a/src/components/form-elements/date-input/DateInput.tsx +++ b/src/components/form-elements/date-input/DateInput.tsx @@ -2,8 +2,9 @@ import classNames from 'classnames'; import { - createRef, forwardRef, + useImperativeHandle, + useRef, useState, type ChangeEvent, type ComponentPropsWithoutRef, @@ -43,7 +44,9 @@ export type DateInputType = 'day' | 'month' | 'year'; const DateInputComponent = forwardRef( ({ children, onChange, value, defaultValue, formGroupProps, ...rest }, forwardedRef) => { - const [moduleRef] = useState(() => formGroupProps?.ref || createRef()); + const moduleRef = useRef(null); + + useImperativeHandle(formGroupProps?.ref, () => moduleRef.current!, [moduleRef]); const [internalDate, setInternalDate] = useState({ day: value?.day ?? '', diff --git a/src/components/form-elements/error-summary/ErrorSummary.tsx b/src/components/form-elements/error-summary/ErrorSummary.tsx index 1af81d44d..f077ff963 100644 --- a/src/components/form-elements/error-summary/ErrorSummary.tsx +++ b/src/components/form-elements/error-summary/ErrorSummary.tsx @@ -4,9 +4,10 @@ import classNames from 'classnames'; import { type ErrorSummary as ErrorSummaryModule } from 'nhsuk-frontend'; import { Children, - createRef, forwardRef, useEffect, + useImperativeHandle, + useRef, useState, type ComponentPropsWithoutRef, } from 'react'; @@ -19,19 +20,22 @@ export interface ErrorSummaryProps extends ComponentPropsWithoutRef<'div'> { const ErrorSummaryComponent = forwardRef( ({ children, className, disableAutoFocus, ...rest }, forwardedRef) => { - const [moduleRef] = useState(() => forwardedRef || createRef()); + const moduleRef = useRef(null); + const importRef = useRef>(null); const [instanceError, setInstanceError] = useState(); const [instance, setInstance] = useState(); + useImperativeHandle(forwardedRef, () => moduleRef.current!, [moduleRef]); + useEffect(() => { - if (!('current' in moduleRef) || !moduleRef.current || instance) { + if (!moduleRef.current || importRef.current || instance) { return; } - import('nhsuk-frontend') + importRef.current = import('nhsuk-frontend') .then(({ ErrorSummary }) => setInstance(new ErrorSummary(moduleRef.current))) .catch(setInstanceError); - }, [moduleRef, instance]); + }, [moduleRef, importRef, instance]); const items = Children.toArray(children); diff --git a/src/components/form-elements/radios/Radios.tsx b/src/components/form-elements/radios/Radios.tsx index a56dfd342..c4d6db6dd 100644 --- a/src/components/form-elements/radios/Radios.tsx +++ b/src/components/form-elements/radios/Radios.tsx @@ -2,7 +2,14 @@ import classNames from 'classnames'; import { type Radios as RadiosModule } from 'nhsuk-frontend'; -import { createRef, forwardRef, useEffect, useState, type ComponentPropsWithoutRef } from 'react'; +import { + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState, + type ComponentPropsWithoutRef, +} from 'react'; import { RadiosDivider, RadiosItem } from './components/index.js'; import { RadiosContext, type IRadiosContext } from './RadiosContext.js'; import { FormGroup } from '#components/utils/index.js'; @@ -20,7 +27,8 @@ export interface RadiosProps const RadiosComponent = forwardRef((props, forwardedRef) => { const { children, idPrefix, ...rest } = props; - const [moduleRef] = useState(() => forwardedRef || createRef()); + const moduleRef = useRef(null); + const importRef = useRef>(null); const [instanceError, setInstanceError] = useState(); const [instance, setInstance] = useState(); const [selectedRadio, setSelectedRadio] = useState(); @@ -29,15 +37,17 @@ const RadiosComponent = forwardRef((props, forwarde let _radioCount = 0; let _radioIds: Record = {}; + useImperativeHandle(forwardedRef, () => moduleRef.current!, [moduleRef]); + useEffect(() => { - if (!('current' in moduleRef) || !moduleRef.current || instance) { + if (!moduleRef.current || importRef.current || instance) { return; } - import('nhsuk-frontend') + importRef.current = import('nhsuk-frontend') .then(({ Radios }) => setInstance(new Radios(moduleRef.current))) .catch(setInstanceError); - }, [moduleRef, instance]); + }, [moduleRef, importRef, instance]); const getRadioId = (id: string, reference: string): string => { if (reference in _radioIds) { diff --git a/src/components/navigation/header/Header.tsx b/src/components/navigation/header/Header.tsx index 462358434..60b4fb45e 100644 --- a/src/components/navigation/header/Header.tsx +++ b/src/components/navigation/header/Header.tsx @@ -4,10 +4,11 @@ import classNames from 'classnames'; import { type Header as HeaderModule } from 'nhsuk-frontend'; import { Children, - createRef, forwardRef, useEffect, + useImperativeHandle, useMemo, + useRef, useState, type ComponentPropsWithoutRef, } from 'react'; @@ -34,13 +35,16 @@ const HeaderComponent = forwardRef((props, forwardedRe const { className, containerClasses, children, logo, service, organisation, white, ...rest } = props; - const [moduleRef] = useState(() => forwardedRef || createRef()); + const moduleRef = useRef(null); + const importRef = useRef>(null); const [instanceError, setInstanceError] = useState(); const [instance, setInstance] = useState(); const [menuOpen, setMenuOpen] = useState(false); + useImperativeHandle(forwardedRef, () => moduleRef.current!, [moduleRef]); + useEffect(() => { - if (!('current' in moduleRef) || !moduleRef.current || instance) { + if (!moduleRef.current || importRef.current || instance) { if (!instance) { return; } @@ -58,10 +62,10 @@ const HeaderComponent = forwardRef((props, forwardedRe return; } - import('nhsuk-frontend') + importRef.current = import('nhsuk-frontend') .then(({ Header }) => setInstance(new Header(moduleRef.current))) .catch(setInstanceError); - }, [moduleRef, instance, menuOpen]); + }, [moduleRef, importRef, instance, menuOpen]); const contextValue: IHeaderContext = useMemo(() => { return { menuOpen, setMenuOpen }; diff --git a/src/components/navigation/skip-link/SkipLink.tsx b/src/components/navigation/skip-link/SkipLink.tsx index 164753778..a0e5009c7 100644 --- a/src/components/navigation/skip-link/SkipLink.tsx +++ b/src/components/navigation/skip-link/SkipLink.tsx @@ -2,26 +2,36 @@ import classNames from 'classnames'; import { type SkipLink as SkipLinkModule } from 'nhsuk-frontend'; -import { createRef, forwardRef, useEffect, useState, type ComponentPropsWithoutRef } from 'react'; +import { + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState, + type ComponentPropsWithoutRef, +} from 'react'; export type SkipLinkProps = ComponentPropsWithoutRef<'a'>; export const SkipLink = forwardRef((props, forwardedRef) => { const { children = 'Skip to main content', className, href = '#maincontent', ...rest } = props; - const [moduleRef] = useState(() => forwardedRef || createRef()); + const moduleRef = useRef(null); + const importRef = useRef>(null); const [instanceError, setInstanceError] = useState(); const [instance, setInstance] = useState(); + useImperativeHandle(forwardedRef, () => moduleRef.current!, [moduleRef]); + useEffect(() => { - if (!('current' in moduleRef) || !moduleRef.current || instance) { + if (!moduleRef.current || importRef.current || instance) { return; } - import('nhsuk-frontend') + importRef.current = import('nhsuk-frontend') .then(({ SkipLink }) => setInstance(new SkipLink(moduleRef.current))) .catch(setInstanceError); - }, [moduleRef, instance]); + }, [moduleRef, importRef, instance]); if (instanceError) { throw instanceError; diff --git a/src/components/utils/__tests__/HeadingLevel.test.tsx b/src/components/utils/__tests__/HeadingLevel.test.tsx index 9c5e48447..d200820d0 100644 --- a/src/components/utils/__tests__/HeadingLevel.test.tsx +++ b/src/components/utils/__tests__/HeadingLevel.test.tsx @@ -22,8 +22,8 @@ describe('HeadingLevel', () => { // @ts-expect-error - testing invalid prop render(); - expect(consoleSpy).toHaveBeenCalledTimes(1); - expect(consoleSpy).toHaveBeenCalledWith('HeadingLevel: Invalid headingLevel prop: h7'); + expect(consoleSpy).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenLastCalledWith('HeadingLevel: Invalid headingLevel prop: h7'); consoleSpy.mockRestore(); }); diff --git a/src/setupTests.ts b/src/setupTests.ts index 2047807ed..6f2db2bab 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -1,7 +1,10 @@ import { TextDecoder, TextEncoder } from 'node:util'; import '@testing-library/jest-dom'; +import { configure } from '@testing-library/react'; import { outdent } from 'outdent'; +configure({ reactStrictMode: true }); + /** * Polyfill TextEncoder/TextDecoder for ReactDOM * diff --git a/src/util/components/index.ts b/src/util/components/index.tsx similarity index 92% rename from src/util/components/index.ts rename to src/util/components/index.tsx index 2c1cd0cd0..f65c1a54d 100644 --- a/src/util/components/index.ts +++ b/src/util/components/index.tsx @@ -1,5 +1,5 @@ import { render, type RenderOptions as ClientOptions } from '@testing-library/react'; -import { act, type JSX } from 'react'; +import { act, StrictMode, type JSX } from 'react'; import { renderToString, type ServerOptions } from 'react-dom/server'; type RenderOptions = @@ -18,7 +18,7 @@ export async function renderServer(element: JSX.Element, options: RenderOptions document.body.appendChild(container); // Render using React DOM - container.innerHTML = renderToString(element, serverOptions); + container.innerHTML = renderToString({element}, serverOptions); // Find rendered modules const modules = Array.from(container.querySelectorAll(selector));