From 0edac7848eefc8c2d2b36c549d289f85a6148b50 Mon Sep 17 00:00:00 2001 From: Colin Rotherham Date: Thu, 9 Oct 2025 12:59:15 +0100 Subject: [PATCH 01/24] Fix Rollup duplicate `'use client'` directives --- .storybook/main.ts | 2 ++ package.json | 2 +- rollup.config.js | 4 +++- yarn.lock | 13 ++++++------- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/.storybook/main.ts b/.storybook/main.ts index 193423c1..a9c212e8 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,4 +1,5 @@ import { type StorybookConfig } from '@storybook/react-vite'; +import preserveDirectives from 'rollup-preserve-directives'; import { mergeConfig, type InlineConfig } from 'vite'; import tsConfigPaths from 'vite-tsconfig-paths'; import { isLogIgnored } from '../rollup.config.js'; @@ -26,6 +27,7 @@ const config: StorybookConfig = { handler(warning); }, + plugins: [preserveDirectives()], }, }, css: { diff --git a/package.json b/package.json index e5c94eef..6e8c5b5d 100644 --- a/package.json +++ b/package.json @@ -113,7 +113,7 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "rollup": "^4.52.4", - "rollup-plugin-preserve-directives": "^0.4.0", + "rollup-preserve-directives": "^1.1.3", "sass-embedded": "^1.93.2", "storybook": "^9.1.10", "tslib": "^2.8.1", diff --git a/rollup.config.js b/rollup.config.js index 3de4a7c5..f7c846a6 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,9 +1,10 @@ import { join } from 'node:path'; +import { DEFAULT_EXTENSIONS as extensions } from '@babel/core'; import { babel } from '@rollup/plugin-babel'; import commonjs from '@rollup/plugin-commonjs'; import { nodeResolve } from '@rollup/plugin-node-resolve'; import typescript from '@rollup/plugin-typescript'; -import preserveDirectives from 'rollup-plugin-preserve-directives'; +import preserveDirectives from 'rollup-preserve-directives'; import { defineConfig } from 'rollup'; import packageJson from './package.json' with { type: 'json' }; import tsBuildConfig from './tsconfig.build.json' with { type: 'json' }; @@ -58,6 +59,7 @@ export default defineConfig( babel({ babelHelpers: 'bundled', exclude: 'node_modules/**', + extensions: [...extensions, '.ts', '.tsx'], }), ], diff --git a/yarn.lock b/yarn.lock index 4e2079e0..621f5740 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8510,7 +8510,7 @@ __metadata: react: "npm:^19.2.0" react-dom: "npm:^19.2.0" rollup: "npm:^4.52.4" - rollup-plugin-preserve-directives: "npm:^0.4.0" + rollup-preserve-directives: "npm:^1.1.3" sass-embedded: "npm:^1.93.2" storybook: "npm:^9.1.10" tslib: "npm:^2.8.1" @@ -9519,15 +9519,14 @@ __metadata: languageName: node linkType: hard -"rollup-plugin-preserve-directives@npm:^0.4.0": - version: 0.4.0 - resolution: "rollup-plugin-preserve-directives@npm:0.4.0" +"rollup-preserve-directives@npm:^1.1.3": + version: 1.1.3 + resolution: "rollup-preserve-directives@npm:1.1.3" dependencies: - "@rollup/pluginutils": "npm:^5.1.0" magic-string: "npm:^0.30.5" peerDependencies: - rollup: 2.x || 3.x || 4.x - checksum: 10c0/83e27b6cefe5d2185a39b79fa860644e60b53ed0c32942e1829023e10aa75a2fdd9c0d57e9a46eec4b60804d3fdb1ae51e847d5762f372b0f22fafcaca8c6a0e + rollup: ^2.0.0 || ^3.0.0 || ^4.0.0 + checksum: 10c0/a3d1ecb0672f5dd15c8350d6c741345dfbc267e33d9f1c805a22af264d58fc7abb407bd57b8fceee7d522bd57948898c2f55361572e6735085e69d842055b680 languageName: node linkType: hard From cbf3136d99965aab8d367238728e68d928a0095d Mon Sep 17 00:00:00 2001 From: Colin Rotherham Date: Thu, 9 Oct 2025 12:59:43 +0100 Subject: [PATCH 02/24] Remove unnecessary log suppression --- .storybook/main.ts | 8 -------- rollup.config.js | 22 +--------------------- 2 files changed, 1 insertion(+), 29 deletions(-) diff --git a/.storybook/main.ts b/.storybook/main.ts index a9c212e8..cc55d51a 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -2,7 +2,6 @@ import { type StorybookConfig } from '@storybook/react-vite'; import preserveDirectives from 'rollup-preserve-directives'; import { mergeConfig, type InlineConfig } from 'vite'; import tsConfigPaths from 'vite-tsconfig-paths'; -import { isLogIgnored } from '../rollup.config.js'; const config: StorybookConfig = { stories: ['../stories/**/*.stories.@(ts|tsx)', '../stories/**/*.mdx'], @@ -20,13 +19,6 @@ const config: StorybookConfig = { return mergeConfig(config, { build: { rollupOptions: { - onwarn(warning, handler) { - if (isLogIgnored(warning)) { - return; - } - - handler(warning); - }, plugins: [preserveDirectives()], }, }, diff --git a/rollup.config.js b/rollup.config.js index f7c846a6..7a98d9b5 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -65,10 +65,6 @@ export default defineConfig( // Handle warnings as errors onwarn(warning) { - if (isLogIgnored(warning)) { - return; - } - throw new Error(warning.message, { cause: warning }); }, }), @@ -76,21 +72,5 @@ export default defineConfig( ); /** - * Whether to ignore Rollup log messages - * - * @param {RollupLog} warning - */ -export function isLogIgnored(warning) { - const { code, message } = warning; - - // Skip warnings related to "use client" directives including - // source map issues when directives are bundled by Storybook - return ( - code === 'SOURCEMAP_ERROR' || - (code === 'MODULE_LEVEL_DIRECTIVE' && message.includes('"use client"')) - ); -} - -/** - * @import { OutputOptions, RollupLog, RollupOptions } from 'rollup' + * @import { OutputOptions, RollupOptions } from 'rollup' */ From 8f9ad2dd576fa563f9afe8b5e08df757fe46cb6f Mon Sep 17 00:00:00 2001 From: Colin Rotherham Date: Thu, 9 Oct 2025 14:25:32 +0100 Subject: [PATCH 03/24] Sync changelog entries with release notes --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c08600f..14ef737d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,19 +2,19 @@ ## 6.0.0-beta.1 - 8 October 2025 -This version adds the panel component from NHS.UK frontend v9.3.0 and supports React v19. +This version provides support for NHS.UK frontend v10.x and adds the [panel component](https://service-manual.nhs.uk/design-system/components/panel) from the NHS.UK design system. Support for React v19 is also included. 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.0 - 30 September 2025 -This version provides support for nhsuk-frontend version 10. +This version provides support for NHS.UK frontend v10.x. 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). ## 5.0.0 - 4 November 2024 -This version provides support for nhsuk-frontend version 9. +This version provides support for NHS.UK frontend v9.x. 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-5.0.md). From 4b7c3dffb0d4898732479f54a219305fe90a472b Mon Sep 17 00:00:00 2001 From: Colin Rotherham Date: Thu, 9 Oct 2025 14:26:51 +0100 Subject: [PATCH 04/24] Add changelog entry --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14ef737d..f43096c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # NHS.UK React components +## Unreleased + +This version provides support for NHS.UK frontend v10.x and fixes a Rollup `'use client'` directive issue. + +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.1 - 8 October 2025 This version provides support for NHS.UK frontend v10.x and adds the [panel component](https://service-manual.nhs.uk/design-system/components/panel) from the NHS.UK design system. Support for React v19 is also included. From dbc22c28261eafb03506b67888610cf3b691cf15 Mon Sep 17 00:00:00 2001 From: Colin Rotherham Date: Mon, 13 Oct 2025 08:32:34 +0100 Subject: [PATCH 05/24] Add missing `'use client'` directives --- .../content-presentation/table/components/TableBody.tsx | 2 ++ src/components/form-elements/form/Form.tsx | 2 ++ src/components/form-elements/select/Select.tsx | 2 ++ src/components/form-elements/text-input/TextInput.tsx | 2 ++ src/components/form-elements/textarea/Textarea.tsx | 2 ++ src/components/navigation/card/Card.tsx | 2 ++ src/util/hooks/UseDevWarning.tsx | 2 ++ 7 files changed, 14 insertions(+) diff --git a/src/components/content-presentation/table/components/TableBody.tsx b/src/components/content-presentation/table/components/TableBody.tsx index 606465c9..ae16155d 100644 --- a/src/components/content-presentation/table/components/TableBody.tsx +++ b/src/components/content-presentation/table/components/TableBody.tsx @@ -1,3 +1,5 @@ +'use client'; + import classNames from 'classnames'; import { type ComponentPropsWithoutRef, type FC } from 'react'; import { TableSection, TableSectionContext } from '../TableSectionContext.js'; diff --git a/src/components/form-elements/form/Form.tsx b/src/components/form-elements/form/Form.tsx index b438f77d..4ca40d32 100644 --- a/src/components/form-elements/form/Form.tsx +++ b/src/components/form-elements/form/Form.tsx @@ -1,3 +1,5 @@ +'use client'; + import { type ComponentPropsWithoutRef, type FC } from 'react'; import { FormContext } from './FormContext.js'; diff --git a/src/components/form-elements/select/Select.tsx b/src/components/form-elements/select/Select.tsx index 9d28e8f3..a877c33e 100644 --- a/src/components/form-elements/select/Select.tsx +++ b/src/components/form-elements/select/Select.tsx @@ -1,3 +1,5 @@ +'use client'; + import classNames from 'classnames'; import { forwardRef, type ComponentPropsWithoutRef } from 'react'; import { FormGroup } from '#components/utils/index.js'; diff --git a/src/components/form-elements/text-input/TextInput.tsx b/src/components/form-elements/text-input/TextInput.tsx index c1be63d8..6e0034a1 100644 --- a/src/components/form-elements/text-input/TextInput.tsx +++ b/src/components/form-elements/text-input/TextInput.tsx @@ -1,3 +1,5 @@ +'use client'; + import classNames from 'classnames'; import { forwardRef, type ComponentPropsWithoutRef, type FC } from 'react'; import { FormGroup } from '#components/utils/index.js'; diff --git a/src/components/form-elements/textarea/Textarea.tsx b/src/components/form-elements/textarea/Textarea.tsx index b83db547..9228fd08 100644 --- a/src/components/form-elements/textarea/Textarea.tsx +++ b/src/components/form-elements/textarea/Textarea.tsx @@ -1,3 +1,5 @@ +'use client'; + import classNames from 'classnames'; import { forwardRef, type ComponentPropsWithoutRef } from 'react'; import { FormGroup } from '#components/utils/index.js'; diff --git a/src/components/navigation/card/Card.tsx b/src/components/navigation/card/Card.tsx index 2196f230..c57ee208 100644 --- a/src/components/navigation/card/Card.tsx +++ b/src/components/navigation/card/Card.tsx @@ -1,3 +1,5 @@ +'use client'; + import classNames from 'classnames'; import { forwardRef, type ComponentPropsWithoutRef } from 'react'; import { diff --git a/src/util/hooks/UseDevWarning.tsx b/src/util/hooks/UseDevWarning.tsx index 67892e3c..c13657f5 100644 --- a/src/util/hooks/UseDevWarning.tsx +++ b/src/util/hooks/UseDevWarning.tsx @@ -1,3 +1,5 @@ +'use client'; + import { useEffect } from 'react'; import { isDev } from '#util/tools/index.js'; From 9e1cafdf32bd6cb2bc0f4dca44f346d55608e54d Mon Sep 17 00:00:00 2001 From: Colin Rotherham Date: Mon, 13 Oct 2025 08:32:23 +0100 Subject: [PATCH 06/24] Export all multipart namespace components separately --- src/__tests__/index.test.ts | 61 ++++++++++++++++++ .../content-presentation/details/Details.tsx | 15 +++-- .../do-and-dont-list/DoAndDontList.tsx | 48 ++------------ .../do-and-dont-list/DoAndDontListContext.ts | 7 ++ .../components/DoAndDontListItem.tsx | 39 +++++++++++ .../do-and-dont-list/components/index.ts | 1 + .../do-and-dont-list/index.ts | 2 + .../content-presentation/hero/Hero.tsx | 6 +- .../content-presentation/panel/Panel.tsx | 13 +--- .../panel/components/PanelTitle.tsx | 12 ++++ .../panel/components/index.ts | 1 + .../content-presentation/panel/index.ts | 1 + .../summary-list/SummaryList.tsx | 8 +-- .../content-presentation/table/index.ts | 1 + .../content-presentation/tabs/Tabs.tsx | 32 +++++----- .../tabs/__tests__/Tabs.test.tsx | 4 +- .../warning-callout/WarningCallout.tsx | 2 +- .../{Divider.tsx => CheckboxesDivider.tsx} | 4 +- .../{Item.tsx => CheckboxesItem.tsx} | 0 .../checkboxes/components/index.ts | 4 +- .../form-elements/checkboxes/index.ts | 1 + .../form-elements/date-input/DateInput.tsx | 22 +++---- ...idualDateInputs.tsx => DateInputField.tsx} | 27 ++++---- .../date-input/components/index.ts | 2 +- .../form-elements/date-input/index.ts | 1 + .../error-summary/ErrorSummary.tsx | 62 ++---------------- .../components/ErrorSummaryList.tsx | 18 ++++++ .../components/ErrorSummaryListItem.tsx | 29 +++++++++ .../components/ErrorSummaryTitle.tsx | 12 ++++ .../error-summary/components/index.ts | 3 + .../form-elements/error-summary/index.ts | 1 + .../form-elements/legend/Legend.tsx | 2 +- .../{Divider.tsx => RadiosDivider.tsx} | 0 .../components/{Item.tsx => RadiosItem.tsx} | 0 .../form-elements/radios/components/index.ts | 4 +- src/components/form-elements/radios/index.ts | 1 + .../form-elements/select/Select.tsx | 6 +- .../navigation/breadcrumb/Breadcrumb.tsx | 35 ++-------- .../breadcrumb/components/BreadcrumbBack.tsx | 17 +++++ .../breadcrumb/components/BreadcrumbItem.tsx | 19 ++++++ .../navigation/breadcrumb/components/index.ts | 2 + src/components/navigation/breadcrumb/index.ts | 1 + src/components/navigation/card/index.ts | 1 + .../navigation/contents-list/ContentsList.tsx | 2 +- src/components/navigation/footer/Footer.tsx | 64 +------------------ .../footer/components/FooterCopyright.tsx | 14 ++++ .../footer/components/FooterList.tsx | 12 ++++ .../footer/components/FooterListItem.tsx | 19 ++++++ .../footer/components/FooterMeta.tsx | 30 +++++++++ .../navigation/footer/components/index.ts | 4 ++ src/components/navigation/footer/index.ts | 1 + src/components/navigation/header/Header.tsx | 36 +++++------ .../{Account.tsx => HeaderAccount.tsx} | 4 +- ...{AccountItem.tsx => HeaderAccountItem.tsx} | 26 ++++---- .../components/{Logo.tsx => HeaderLogo.tsx} | 6 +- .../{MenuToggle.tsx => HeaderMenuToggle.tsx} | 4 +- .../{Navigation.tsx => HeaderNavigation.tsx} | 10 +-- ...ationItem.tsx => HeaderNavigationItem.tsx} | 14 ++-- .../{Search.tsx => HeaderSearch.tsx} | 6 +- ...{ServiceName.tsx => HeaderServiceName.tsx} | 16 +++-- .../navigation/header/components/index.ts | 16 ++--- src/components/navigation/header/index.ts | 1 + .../navigation/pagination/Pagination.tsx | 4 +- src/components/utils/HeadingLevel.tsx | 2 +- src/patterns/nav-a-z/NavAZ.tsx | 18 +++--- 65 files changed, 489 insertions(+), 347 deletions(-) create mode 100644 src/components/content-presentation/do-and-dont-list/DoAndDontListContext.ts create mode 100644 src/components/content-presentation/do-and-dont-list/components/DoAndDontListItem.tsx create mode 100644 src/components/content-presentation/do-and-dont-list/components/index.ts create mode 100644 src/components/content-presentation/panel/components/PanelTitle.tsx create mode 100644 src/components/content-presentation/panel/components/index.ts rename src/components/form-elements/checkboxes/components/{Divider.tsx => CheckboxesDivider.tsx} (58%) rename src/components/form-elements/checkboxes/components/{Item.tsx => CheckboxesItem.tsx} (100%) rename src/components/form-elements/date-input/components/{IndividualDateInputs.tsx => DateInputField.tsx} (79%) create mode 100644 src/components/form-elements/error-summary/components/ErrorSummaryList.tsx create mode 100644 src/components/form-elements/error-summary/components/ErrorSummaryListItem.tsx create mode 100644 src/components/form-elements/error-summary/components/ErrorSummaryTitle.tsx create mode 100644 src/components/form-elements/error-summary/components/index.ts rename src/components/form-elements/radios/components/{Divider.tsx => RadiosDivider.tsx} (100%) rename src/components/form-elements/radios/components/{Item.tsx => RadiosItem.tsx} (100%) create mode 100644 src/components/navigation/breadcrumb/components/BreadcrumbBack.tsx create mode 100644 src/components/navigation/breadcrumb/components/BreadcrumbItem.tsx create mode 100644 src/components/navigation/breadcrumb/components/index.ts create mode 100644 src/components/navigation/footer/components/FooterCopyright.tsx create mode 100644 src/components/navigation/footer/components/FooterList.tsx create mode 100644 src/components/navigation/footer/components/FooterListItem.tsx create mode 100644 src/components/navigation/footer/components/FooterMeta.tsx create mode 100644 src/components/navigation/footer/components/index.ts rename src/components/navigation/header/components/{Account.tsx => HeaderAccount.tsx} (82%) rename src/components/navigation/header/components/{AccountItem.tsx => HeaderAccountItem.tsx} (64%) rename src/components/navigation/header/components/{Logo.tsx => HeaderLogo.tsx} (91%) rename src/components/navigation/header/components/{MenuToggle.tsx => HeaderMenuToggle.tsx} (71%) rename src/components/navigation/header/components/{Navigation.tsx => HeaderNavigation.tsx} (78%) rename src/components/navigation/header/components/{NavigationItem.tsx => HeaderNavigationItem.tsx} (71%) rename src/components/navigation/header/components/{Search.tsx => HeaderSearch.tsx} (86%) rename src/components/navigation/header/components/{ServiceName.tsx => HeaderServiceName.tsx} (70%) diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index f2061729..ebc22f7d 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -13,36 +13,74 @@ describe('Index', () => { 'BackLink', 'BodyText', 'Breadcrumb', + 'BreadcrumbBack', + 'BreadcrumbItem', 'Button', 'Card', + 'CardContent', 'CardContext', + 'CardDescription', + 'CardGroup', + 'CardGroupItem', + 'CardHeading', + 'CardImage', + 'CardLink', 'cardTypeIsCareCard', 'CharacterCount', 'Checkboxes', 'CheckboxesContext', + 'CheckboxesDivider', + 'CheckboxesItem', 'ChevronRightCircleIcon', 'childIsOfComponentType', 'Clearfix', 'Col', 'Container', 'ContentsList', + 'ContentsListItem', 'CrossIcon', 'DateInput', 'DateInputContext', + 'DateInputDay', + 'DateInputMonth', + 'DateInputYear', 'Details', + 'DetailsExpanderGroup', + 'DetailsSummary', + 'DetailsText', 'DoAndDontList', + 'DoAndDontListContext', + 'DoAndDontListItem', 'ErrorMessage', 'ErrorSummary', + 'ErrorSummaryList', + 'ErrorSummaryListItem', + 'ErrorSummaryTitle', 'Fieldset', 'Footer', + 'FooterCopyright', + 'FooterList', + 'FooterListItem', + 'FooterMeta', 'Form', 'FormContext', 'FormGroup', 'FormGroupContext', 'Header', + 'HeaderAccount', + 'HeaderAccountItem', 'HeaderContext', + 'HeaderLogo', + 'HeaderMenuToggle', + 'HeaderNavigation', + 'HeaderNavigationItem', + 'HeaderSearch', + 'HeaderServiceName', 'HeadingLevel', 'Hero', + 'HeroContent', + 'HeroHeading', + 'HeroText', 'HintText', 'Icon', 'Images', @@ -51,22 +89,44 @@ describe('Index', () => { 'LedeText', 'Legend', 'NavAZ', + 'NavAZDisabledItem', + 'NavAZLinkItem', 'Pagination', + 'PaginationLink', 'Panel', + 'PanelTitle', 'Radios', 'RadiosContext', + 'RadiosDivider', + 'RadiosItem', 'ReadingWidth', 'ReviewDate', 'Row', 'SearchIcon', 'Select', + 'SelectOption', 'SkipLink', 'SummaryList', + 'SummaryListActions', + 'SummaryListKey', + 'SummaryListRow', + 'SummaryListValue', 'Table', + 'TableBody', + 'TableCaption', + 'TableCell', + 'TableContainer', 'TableContext', + 'TableHead', + 'TablePanel', + 'TableRow', 'TableSection', 'TableSectionContext', 'Tabs', + 'TabsContents', + 'TabsList', + 'TabsListItem', + 'TabsTitle', 'Tag', 'Textarea', 'TextInput', @@ -74,6 +134,7 @@ describe('Index', () => { 'useFormContext', 'UserIcon', 'WarningCallout', + 'WarningCalloutHeading', ]); }); }); diff --git a/src/components/content-presentation/details/Details.tsx b/src/components/content-presentation/details/Details.tsx index 12c169d6..5fd74d94 100644 --- a/src/components/content-presentation/details/Details.tsx +++ b/src/components/content-presentation/details/Details.tsx @@ -15,7 +15,7 @@ const DetailsComponent = forwardRef( ), ); -const DetailsSummary = forwardRef>( +export const DetailsSummary = forwardRef>( ({ children, className, ...rest }, forwardedRef) => ( >( ), ); -const DetailsText: FC> = ({ className, ...rest }) => ( +export const DetailsText: FC> = ({ className, ...rest }) => (
); -const ExpanderGroup: FC> = ({ className, ...rest }) => ( -
-); +export const DetailsExpanderGroup: FC> = ({ + className, + ...rest +}) =>
; DetailsComponent.displayName = 'Details'; DetailsSummary.displayName = 'Details.Summary'; DetailsText.displayName = 'Details.Text'; -ExpanderGroup.displayName = 'Details.ExpanderGroup'; +DetailsExpanderGroup.displayName = 'Details.ExpanderGroup'; export const Details = Object.assign(DetailsComponent, { Summary: DetailsSummary, Text: DetailsText, - ExpanderGroup, + ExpanderGroup: DetailsExpanderGroup, }); diff --git a/src/components/content-presentation/do-and-dont-list/DoAndDontList.tsx b/src/components/content-presentation/do-and-dont-list/DoAndDontList.tsx index 7c2a12db..d8b6502a 100644 --- a/src/components/content-presentation/do-and-dont-list/DoAndDontList.tsx +++ b/src/components/content-presentation/do-and-dont-list/DoAndDontList.tsx @@ -1,28 +1,18 @@ 'use client'; import classNames from 'classnames'; -import { - createContext, - forwardRef, - useContext, - type ComponentPropsWithoutRef, - type FC, - type ReactNode, -} from 'react'; -import { CrossIcon, TickIcon } from '../icons/index.js'; +import { forwardRef, type ComponentPropsWithoutRef } from 'react'; import { HeadingLevel, type HeadingLevelProps } from '#components/utils/HeadingLevel.js'; - -type ListType = 'do' | 'dont'; +import { DoAndDontListContext, type DoAndDontListType } from './DoAndDontListContext.js'; +import { DoAndDontListItem } from './components/index.js'; export interface DoAndDontListProps extends ComponentPropsWithoutRef<'div'>, Pick { - listType: ListType; + listType: DoAndDontListType; heading?: string; } -const DoAndDontListContext = createContext('do'); - const DoAndDontListComponent = forwardRef( ({ className, listType, children, heading, headingLevel, ...rest }, forwardedRef) => (
@@ -42,36 +32,8 @@ const DoAndDontListComponent = forwardRef( ), ); -export interface DoAndDontItemProps extends ComponentPropsWithoutRef<'li'> { - listItemType?: ListType; - prefixText?: ReactNode; -} - -const DoAndDontItem: FC = ({ prefixText, listItemType, children, ...rest }) => { - const listItem = useContext(DoAndDontListContext); - const defaultPrefix = (listItemType || listItem) === 'do' ? null : 'do not '; - const actualPrefix = prefixText === undefined ? defaultPrefix : prefixText; - return ( -
  • - {(listItemType || listItem) === 'do' ? ( - <> - - {actualPrefix} - - ) : ( - <> - - {actualPrefix} - - )} - {children} -
  • - ); -}; - DoAndDontListComponent.displayName = 'DoAndDontList'; -DoAndDontItem.displayName = 'DoAndDontList.Item'; export const DoAndDontList = Object.assign(DoAndDontListComponent, { - Item: DoAndDontItem, + Item: DoAndDontListItem, }); diff --git a/src/components/content-presentation/do-and-dont-list/DoAndDontListContext.ts b/src/components/content-presentation/do-and-dont-list/DoAndDontListContext.ts new file mode 100644 index 00000000..6056faa7 --- /dev/null +++ b/src/components/content-presentation/do-and-dont-list/DoAndDontListContext.ts @@ -0,0 +1,7 @@ +'use client'; + +import { createContext } from 'react'; + +export type DoAndDontListType = 'do' | 'dont'; + +export const DoAndDontListContext = createContext('do'); diff --git a/src/components/content-presentation/do-and-dont-list/components/DoAndDontListItem.tsx b/src/components/content-presentation/do-and-dont-list/components/DoAndDontListItem.tsx new file mode 100644 index 00000000..bdf5e35b --- /dev/null +++ b/src/components/content-presentation/do-and-dont-list/components/DoAndDontListItem.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { useContext, type ComponentPropsWithoutRef, type FC, type ReactNode } from 'react'; +import { CrossIcon, TickIcon } from '../../icons/index.js'; +import { DoAndDontListContext, type DoAndDontListType } from '../DoAndDontListContext.js'; + +export interface DoAndDontListItemProps extends ComponentPropsWithoutRef<'li'> { + listItemType?: DoAndDontListType; + prefixText?: ReactNode; +} + +export const DoAndDontListItem: FC = ({ + prefixText, + listItemType, + children, + ...rest +}) => { + const listItem = useContext(DoAndDontListContext); + const defaultPrefix = (listItemType || listItem) === 'do' ? null : 'do not '; + const actualPrefix = prefixText === undefined ? defaultPrefix : prefixText; + return ( +
  • + {(listItemType || listItem) === 'do' ? ( + <> + + {actualPrefix} + + ) : ( + <> + + {actualPrefix} + + )} + {children} +
  • + ); +}; + +DoAndDontListItem.displayName = 'DoAndDontList.Item'; diff --git a/src/components/content-presentation/do-and-dont-list/components/index.ts b/src/components/content-presentation/do-and-dont-list/components/index.ts new file mode 100644 index 00000000..e9bea06f --- /dev/null +++ b/src/components/content-presentation/do-and-dont-list/components/index.ts @@ -0,0 +1 @@ +export * from './DoAndDontListItem.js'; diff --git a/src/components/content-presentation/do-and-dont-list/index.ts b/src/components/content-presentation/do-and-dont-list/index.ts index bd8c9192..f4bca1e7 100644 --- a/src/components/content-presentation/do-and-dont-list/index.ts +++ b/src/components/content-presentation/do-and-dont-list/index.ts @@ -1 +1,3 @@ +export * from './components/index.js'; +export * from './DoAndDontListContext.js'; export * from './DoAndDontList.js'; diff --git a/src/components/content-presentation/hero/Hero.tsx b/src/components/content-presentation/hero/Hero.tsx index 1020ac53..69a21843 100644 --- a/src/components/content-presentation/hero/Hero.tsx +++ b/src/components/content-presentation/hero/Hero.tsx @@ -7,7 +7,7 @@ export interface HeroContentProps extends ComponentPropsWithoutRef<'div'> { hasImage: boolean; } -const HeroContent: FC = ({ children, hasImage }) => { +export const HeroContent: FC = ({ children, hasImage }) => { if (!children) { return null; } @@ -29,7 +29,7 @@ const HeroContent: FC = ({ children, hasImage }) => { ); }; -const HeroHeading: FC = ({ className, headingLevel = 'h1', ...rest }) => ( +export const HeroHeading: FC = ({ className, headingLevel = 'h1', ...rest }) => ( = ({ className, headingLevel = 'h1', .. /> ); -const HeroText: FC> = ({ className, ...rest }) => ( +export const HeroText: FC> = ({ className, ...rest }) => (

    ); diff --git a/src/components/content-presentation/panel/Panel.tsx b/src/components/content-presentation/panel/Panel.tsx index ce2d1f35..2dcfb3a4 100644 --- a/src/components/content-presentation/panel/Panel.tsx +++ b/src/components/content-presentation/panel/Panel.tsx @@ -1,15 +1,7 @@ import classNames from 'classnames'; -import { Children, forwardRef, type ComponentPropsWithoutRef, type FC } from 'react'; -import { HeadingLevel, type HeadingLevelProps } from '#components/utils/HeadingLevel.js'; +import { Children, forwardRef, type ComponentPropsWithoutRef } from 'react'; import { childIsOfComponentType } from '#util/types/TypeGuards.js'; - -export type PanelTitleProps = HeadingLevelProps; - -const PanelTitle: FC = ({ children, headingLevel = 'h1', ...rest }) => ( - - {children} - -); +import { PanelTitle } from './components/index.js'; export type PanelProps = ComponentPropsWithoutRef<'div'>; @@ -29,7 +21,6 @@ const PanelComponent = forwardRef( ); PanelComponent.displayName = 'Panel'; -PanelComponent.displayName = 'Panel.Title'; export const Panel = Object.assign(PanelComponent, { Title: PanelTitle, diff --git a/src/components/content-presentation/panel/components/PanelTitle.tsx b/src/components/content-presentation/panel/components/PanelTitle.tsx new file mode 100644 index 00000000..a24263eb --- /dev/null +++ b/src/components/content-presentation/panel/components/PanelTitle.tsx @@ -0,0 +1,12 @@ +import { type FC } from 'react'; +import { HeadingLevel, type HeadingLevelProps } from '#components/utils/HeadingLevel.js'; + +export type PanelTitleProps = HeadingLevelProps; + +export const PanelTitle: FC = ({ children, headingLevel = 'h1', ...rest }) => ( + + {children} + +); + +PanelTitle.displayName = 'Panel.Title'; diff --git a/src/components/content-presentation/panel/components/index.ts b/src/components/content-presentation/panel/components/index.ts new file mode 100644 index 00000000..89bd345c --- /dev/null +++ b/src/components/content-presentation/panel/components/index.ts @@ -0,0 +1 @@ +export * from './PanelTitle.js'; diff --git a/src/components/content-presentation/panel/index.ts b/src/components/content-presentation/panel/index.ts index 81c82d41..ab24f7a8 100644 --- a/src/components/content-presentation/panel/index.ts +++ b/src/components/content-presentation/panel/index.ts @@ -1 +1,2 @@ +export * from './components/index.js'; export * from './Panel.js'; diff --git a/src/components/content-presentation/summary-list/SummaryList.tsx b/src/components/content-presentation/summary-list/SummaryList.tsx index 97e2e678..c2cd6e70 100644 --- a/src/components/content-presentation/summary-list/SummaryList.tsx +++ b/src/components/content-presentation/summary-list/SummaryList.tsx @@ -1,19 +1,19 @@ import classNames from 'classnames'; import { forwardRef, type ComponentPropsWithoutRef, type FC } from 'react'; -const SummaryListRow: FC> = ({ className, ...rest }) => ( +export const SummaryListRow: FC> = ({ className, ...rest }) => (

    ); -const SummaryListKey: FC> = ({ className, ...rest }) => ( +export const SummaryListKey: FC> = ({ className, ...rest }) => (
    ); -const SummaryListValue: FC> = ({ className, ...rest }) => ( +export const SummaryListValue: FC> = ({ className, ...rest }) => (
    ); -const SummaryListActions: FC> = ({ className, ...rest }) => ( +export const SummaryListActions: FC> = ({ className, ...rest }) => (
    ); diff --git a/src/components/content-presentation/table/index.ts b/src/components/content-presentation/table/index.ts index f90a825e..ebef4ffd 100644 --- a/src/components/content-presentation/table/index.ts +++ b/src/components/content-presentation/table/index.ts @@ -1,3 +1,4 @@ +export * from './components/index.js'; export * from './TableContext.js'; export * from './TableSectionContext.js'; export * from './Table.js'; diff --git a/src/components/content-presentation/tabs/Tabs.tsx b/src/components/content-presentation/tabs/Tabs.tsx index 9fbcaf56..dd93dbdf 100644 --- a/src/components/content-presentation/tabs/Tabs.tsx +++ b/src/components/content-presentation/tabs/Tabs.tsx @@ -14,31 +14,31 @@ import { HeadingLevel, type HeadingLevelProps } from '#components/utils/HeadingL export type TabsProps = ComponentPropsWithoutRef<'div'>; -export type TabTitleProps = HeadingLevelProps; +export type TabsTitleProps = HeadingLevelProps; -export type TabListProps = ComponentPropsWithoutRef<'ul'>; +export type TabsListProps = ComponentPropsWithoutRef<'ul'>; -export interface TabListItemProps extends ComponentPropsWithoutRef<'a'> { +export interface TabsListItemProps extends ComponentPropsWithoutRef<'a'> { id: string; } -export interface TabContentsProps extends ComponentPropsWithoutRef<'div'> { +export interface TabsContentsProps extends ComponentPropsWithoutRef<'div'> { id: string; } -const TabTitle: FC = ({ children, headingLevel = 'h2', ...rest }) => ( +export const TabsTitle: FC = ({ children, headingLevel = 'h2', ...rest }) => ( {children} ); -const TabList: FC = ({ children, ...rest }) => ( +export const TabsList: FC = ({ children, ...rest }) => (
      {children}
    ); -const TabListItem: FC = ({ children, id, ...rest }) => ( +export const TabsListItem: FC = ({ children, id, ...rest }) => (
  • {children} @@ -46,7 +46,7 @@ const TabListItem: FC = ({ children, id, ...rest }) => (
  • ); -const TabContents: FC = ({ children, id, ...rest }) => ( +export const TabsContents: FC = ({ children, id, ...rest }) => (
    {children}
    @@ -83,14 +83,14 @@ const TabsComponent = forwardRef((props, forwardedRef }); TabsComponent.displayName = 'Tabs'; -TabTitle.displayName = 'Tabs.Title'; -TabList.displayName = 'Tabs.List'; -TabListItem.displayName = 'Tabs.ListItem'; -TabContents.displayName = 'Tabs.Contents'; +TabsTitle.displayName = 'Tabs.Title'; +TabsList.displayName = 'Tabs.List'; +TabsListItem.displayName = 'Tabs.ListItem'; +TabsContents.displayName = 'Tabs.Contents'; export const Tabs = Object.assign(TabsComponent, { - Title: TabTitle, - List: TabList, - ListItem: TabListItem, - Contents: TabContents, + Title: TabsTitle, + List: TabsList, + ListItem: TabsListItem, + Contents: TabsContents, }); diff --git a/src/components/content-presentation/tabs/__tests__/Tabs.test.tsx b/src/components/content-presentation/tabs/__tests__/Tabs.test.tsx index 552b6937..597a1cda 100644 --- a/src/components/content-presentation/tabs/__tests__/Tabs.test.tsx +++ b/src/components/content-presentation/tabs/__tests__/Tabs.test.tsx @@ -1,6 +1,6 @@ import { render } from '@testing-library/react'; import { createRef } from 'react'; -import { Tabs, type TabTitleProps } from '..'; +import { Tabs, type TabsTitleProps } from '..'; import { renderClient, renderServer } from '#util/components'; describe('Tabs', () => { @@ -120,7 +120,7 @@ describe('Tabs', () => { }); describe('Tabs.Title', () => { - it.each([ + it.each([ undefined, { headingLevel: 'h1' }, { headingLevel: 'h2' }, diff --git a/src/components/content-presentation/warning-callout/WarningCallout.tsx b/src/components/content-presentation/warning-callout/WarningCallout.tsx index 80b238bf..c3db83ea 100644 --- a/src/components/content-presentation/warning-callout/WarningCallout.tsx +++ b/src/components/content-presentation/warning-callout/WarningCallout.tsx @@ -2,7 +2,7 @@ import classNames from 'classnames'; import { forwardRef, type ComponentPropsWithoutRef, type FC } from 'react'; import { HeadingLevel, type HeadingLevelProps } from '#components/utils/HeadingLevel.js'; -const WarningCalloutHeading: FC = ({ children, className, ...rest }) => ( +export const WarningCalloutHeading: FC = ({ children, className, ...rest }) => ( {children?.toString().toLowerCase().includes('important') ? ( <> diff --git a/src/components/form-elements/checkboxes/components/Divider.tsx b/src/components/form-elements/checkboxes/components/CheckboxesDivider.tsx similarity index 58% rename from src/components/form-elements/checkboxes/components/Divider.tsx rename to src/components/form-elements/checkboxes/components/CheckboxesDivider.tsx index 7cf06e33..9021f5f7 100644 --- a/src/components/form-elements/checkboxes/components/Divider.tsx +++ b/src/components/form-elements/checkboxes/components/CheckboxesDivider.tsx @@ -1,10 +1,10 @@ import { type FC } from 'react'; -type DividerProps = { +export type CheckboxesDividerProps = { dividerText?: string; }; -export const CheckboxesDivider: FC = ({ dividerText = 'or' }) => ( +export const CheckboxesDivider: FC = ({ dividerText = 'or' }) => (
    {dividerText}
    ); diff --git a/src/components/form-elements/checkboxes/components/Item.tsx b/src/components/form-elements/checkboxes/components/CheckboxesItem.tsx similarity index 100% rename from src/components/form-elements/checkboxes/components/Item.tsx rename to src/components/form-elements/checkboxes/components/CheckboxesItem.tsx diff --git a/src/components/form-elements/checkboxes/components/index.ts b/src/components/form-elements/checkboxes/components/index.ts index 2f41fba0..6bf4750e 100644 --- a/src/components/form-elements/checkboxes/components/index.ts +++ b/src/components/form-elements/checkboxes/components/index.ts @@ -1,2 +1,2 @@ -export * from './Item.js'; -export * from './Divider.js'; +export * from './CheckboxesDivider.js'; +export * from './CheckboxesItem.js'; diff --git a/src/components/form-elements/checkboxes/index.ts b/src/components/form-elements/checkboxes/index.ts index aa81dc25..49db9b53 100644 --- a/src/components/form-elements/checkboxes/index.ts +++ b/src/components/form-elements/checkboxes/index.ts @@ -1,2 +1,3 @@ +export * from './components/index.js'; export * from './CheckboxesContext.js'; export * from './Checkboxes.js'; diff --git a/src/components/form-elements/date-input/DateInput.tsx b/src/components/form-elements/date-input/DateInput.tsx index 03f93627..e523eb67 100644 --- a/src/components/form-elements/date-input/DateInput.tsx +++ b/src/components/form-elements/date-input/DateInput.tsx @@ -9,7 +9,7 @@ import { type ComponentPropsWithoutRef, type EventHandler, } from 'react'; -import { DayInput, MonthInput, YearInput } from './components/index.js'; +import { DateInputDay, DateInputMonth, DateInputYear } from './components/index.js'; import { DateInputContext, type IDateInputContext } from './DateInputContext.js'; import { FormGroup } from '#components/utils/index.js'; import { type FormElementProps } from '#util/types/FormTypes.js'; @@ -26,12 +26,12 @@ export interface DateInputChangeEvent currentTarget: DateInputElement; } -interface DateInputElement extends Omit { +export interface DateInputElement extends Omit { value?: Partial; onChange?: EventHandler; } -interface DateInputProps +export interface DateInputProps extends Omit, 'defaultValue' | 'onChange'>, Omit { value?: Partial; @@ -39,7 +39,7 @@ interface DateInputProps onChange?: EventHandler; } -type InputType = 'day' | 'month' | 'year'; +export type DateInputType = 'day' | 'month' | 'year'; const DateInputComponent = forwardRef( ({ children, onChange, value, defaultValue, formGroupProps, ...rest }, forwardedRef) => { @@ -51,7 +51,7 @@ const DateInputComponent = forwardRef( year: value?.year ?? '', }); - const handleChange = (inputType: InputType, event: ChangeEvent): void => { + const handleChange = (inputType: DateInputType, event: ChangeEvent): void => { event.stopPropagation(); const newEventValue: DateInputValue = { @@ -95,9 +95,9 @@ const DateInputComponent = forwardRef( {children || ( <> - - - + + + )} @@ -112,7 +112,7 @@ const DateInputComponent = forwardRef( DateInputComponent.displayName = 'DateInput'; export const DateInput = Object.assign(DateInputComponent, { - Day: DayInput, - Month: MonthInput, - Year: YearInput, + Day: DateInputDay, + Month: DateInputMonth, + Year: DateInputYear, }); diff --git a/src/components/form-elements/date-input/components/IndividualDateInputs.tsx b/src/components/form-elements/date-input/components/DateInputField.tsx similarity index 79% rename from src/components/form-elements/date-input/components/IndividualDateInputs.tsx rename to src/components/form-elements/date-input/components/DateInputField.tsx index 176436d6..2d71e2c0 100644 --- a/src/components/form-elements/date-input/components/IndividualDateInputs.tsx +++ b/src/components/form-elements/date-input/components/DateInputField.tsx @@ -95,19 +95,22 @@ const IndividualDateInput = forwardRef>( - (props, forwardedRef) => , -); +export const DateInputDay = forwardRef< + HTMLInputElement, + Omit +>((props, forwardedRef) => ); -export const MonthInput = forwardRef>( - (props, forwardedRef) => , -); +export const DateInputMonth = forwardRef< + HTMLInputElement, + Omit +>((props, forwardedRef) => ); -export const YearInput = forwardRef>( - (props, forwardedRef) => , -); +export const DateInputYear = forwardRef< + HTMLInputElement, + Omit +>((props, forwardedRef) => ); IndividualDateInput.displayName = 'DateInput.Field'; -DayInput.displayName = 'DateInput.Day'; -MonthInput.displayName = 'DateInput.Month'; -YearInput.displayName = 'DateInput.Year'; +DateInputDay.displayName = 'DateInput.Day'; +DateInputMonth.displayName = 'DateInput.Month'; +DateInputYear.displayName = 'DateInput.Year'; diff --git a/src/components/form-elements/date-input/components/index.ts b/src/components/form-elements/date-input/components/index.ts index 18eb91bb..eab8a236 100644 --- a/src/components/form-elements/date-input/components/index.ts +++ b/src/components/form-elements/date-input/components/index.ts @@ -1 +1 @@ -export * from './IndividualDateInputs.js'; +export * from './DateInputField.js'; diff --git a/src/components/form-elements/date-input/index.ts b/src/components/form-elements/date-input/index.ts index 8d099bb3..78fbf7b8 100644 --- a/src/components/form-elements/date-input/index.ts +++ b/src/components/form-elements/date-input/index.ts @@ -1,2 +1,3 @@ +export * from './components/index.js'; export * from './DateInputContext.js'; export * from './DateInput.js'; diff --git a/src/components/form-elements/error-summary/ErrorSummary.tsx b/src/components/form-elements/error-summary/ErrorSummary.tsx index 0ba53b5d..8ea09af3 100644 --- a/src/components/form-elements/error-summary/ErrorSummary.tsx +++ b/src/components/form-elements/error-summary/ErrorSummary.tsx @@ -9,56 +9,9 @@ import { useEffect, useState, type ComponentPropsWithoutRef, - type FC, } from 'react'; import { childIsOfComponentType } from '#util/types/TypeGuards.js'; -import { type AsElementLink } from '#util/types/LinkTypes.js'; - -export type TitleProps = ComponentPropsWithoutRef<'h2'>; - -const Title: FC = ({ children, className, ...rest }) => { - return ( -

    - {children} -

    - ); -}; - -type ListProps = ComponentPropsWithoutRef<'ul'>; - -const List: FC = ({ children, className, ...rest }) => { - if (!children) { - return null; - } - - return ( -
      - {children} -
    - ); -}; - -type ListItemProps = AsElementLink; - -const ListItem = forwardRef((props, forwardedRef) => { - const { children, asElement: Element = 'a', ...rest } = props; - - if (!children) { - return null; - } - - return ( -
  • - {(props.asElement ?? props.href) ? ( - - {children} - - ) : ( - <>{children} - )} -
  • - ); -}); +import { ErrorSummaryList, ErrorSummaryListItem, ErrorSummaryTitle } from './components/index.js'; export interface ErrorSummaryProps extends ComponentPropsWithoutRef<'div'> { disableAutoFocus?: boolean; @@ -82,8 +35,8 @@ const ErrorSummaryComponent = forwardRef( }, [moduleRef, instance]); const items = Children.toArray(children); - const title = items.find((child) => childIsOfComponentType(child, Title)); - const bodyItems = items.filter((child) => !childIsOfComponentType(child, Title)); + const title = items.find((child) => childIsOfComponentType(child, ErrorSummaryTitle)); + const bodyItems = items.filter((child) => !childIsOfComponentType(child, ErrorSummaryTitle)); return (
    ( ); ErrorSummaryComponent.displayName = 'ErrorSummary'; -Title.displayName = 'ErrorSummary.Title'; -List.displayName = 'ErrorSummary.List'; -ListItem.displayName = 'ErrorSummary.ListItem'; export const ErrorSummary = Object.assign(ErrorSummaryComponent, { - Title, - List, - ListItem, + Title: ErrorSummaryTitle, + List: ErrorSummaryList, + ListItem: ErrorSummaryListItem, }); diff --git a/src/components/form-elements/error-summary/components/ErrorSummaryList.tsx b/src/components/form-elements/error-summary/components/ErrorSummaryList.tsx new file mode 100644 index 00000000..a2ad651f --- /dev/null +++ b/src/components/form-elements/error-summary/components/ErrorSummaryList.tsx @@ -0,0 +1,18 @@ +import classNames from 'classnames'; +import { type ComponentPropsWithoutRef, type FC } from 'react'; + +export type ErrorSummaryListProps = ComponentPropsWithoutRef<'ul'>; + +export const ErrorSummaryList: FC = ({ children, className, ...rest }) => { + if (!children) { + return null; + } + + return ( +
      + {children} +
    + ); +}; + +ErrorSummaryList.displayName = 'ErrorSummary.List'; diff --git a/src/components/form-elements/error-summary/components/ErrorSummaryListItem.tsx b/src/components/form-elements/error-summary/components/ErrorSummaryListItem.tsx new file mode 100644 index 00000000..3108f0ec --- /dev/null +++ b/src/components/form-elements/error-summary/components/ErrorSummaryListItem.tsx @@ -0,0 +1,29 @@ +import { forwardRef } from 'react'; + +import { type AsElementLink } from '#util/types/LinkTypes.js'; + +type ErrorSummaryListItemProps = AsElementLink; + +export const ErrorSummaryListItem = forwardRef( + (props, forwardedRef) => { + const { children, asElement: Element = 'a', ...rest } = props; + + if (!children) { + return null; + } + + return ( +
  • + {(props.asElement ?? props.href) ? ( + + {children} + + ) : ( + <>{children} + )} +
  • + ); + }, +); + +ErrorSummaryListItem.displayName = 'ErrorSummary.ListItem'; diff --git a/src/components/form-elements/error-summary/components/ErrorSummaryTitle.tsx b/src/components/form-elements/error-summary/components/ErrorSummaryTitle.tsx new file mode 100644 index 00000000..ee2c905c --- /dev/null +++ b/src/components/form-elements/error-summary/components/ErrorSummaryTitle.tsx @@ -0,0 +1,12 @@ +import classNames from 'classnames'; +import { type ComponentPropsWithoutRef, type FC } from 'react'; + +export type ErrorSummaryTitleProps = ComponentPropsWithoutRef<'h2'>; + +export const ErrorSummaryTitle: FC = ({ children, className, ...rest }) => ( +

    + {children} +

    +); + +ErrorSummaryTitle.displayName = 'ErrorSummary.Title'; diff --git a/src/components/form-elements/error-summary/components/index.ts b/src/components/form-elements/error-summary/components/index.ts new file mode 100644 index 00000000..d917f17a --- /dev/null +++ b/src/components/form-elements/error-summary/components/index.ts @@ -0,0 +1,3 @@ +export * from './ErrorSummaryList.js'; +export * from './ErrorSummaryListItem.js'; +export * from './ErrorSummaryTitle.js'; diff --git a/src/components/form-elements/error-summary/index.ts b/src/components/form-elements/error-summary/index.ts index 49eaf1ac..f8907a0f 100644 --- a/src/components/form-elements/error-summary/index.ts +++ b/src/components/form-elements/error-summary/index.ts @@ -1 +1,2 @@ +export * from './components/index.js'; export * from './ErrorSummary.js'; diff --git a/src/components/form-elements/legend/Legend.tsx b/src/components/form-elements/legend/Legend.tsx index 76ba3abd..448fc384 100644 --- a/src/components/form-elements/legend/Legend.tsx +++ b/src/components/form-elements/legend/Legend.tsx @@ -42,4 +42,4 @@ export const Legend: FC = ({ ); }; -Legend.displayName = 'Fieldset.Legend'; +Legend.displayName = 'Legend'; diff --git a/src/components/form-elements/radios/components/Divider.tsx b/src/components/form-elements/radios/components/RadiosDivider.tsx similarity index 100% rename from src/components/form-elements/radios/components/Divider.tsx rename to src/components/form-elements/radios/components/RadiosDivider.tsx diff --git a/src/components/form-elements/radios/components/Item.tsx b/src/components/form-elements/radios/components/RadiosItem.tsx similarity index 100% rename from src/components/form-elements/radios/components/Item.tsx rename to src/components/form-elements/radios/components/RadiosItem.tsx diff --git a/src/components/form-elements/radios/components/index.ts b/src/components/form-elements/radios/components/index.ts index a0de48d7..0b514306 100644 --- a/src/components/form-elements/radios/components/index.ts +++ b/src/components/form-elements/radios/components/index.ts @@ -1,2 +1,2 @@ -export * from './Divider.js'; -export * from './Item.js'; +export * from './RadiosDivider.js'; +export * from './RadiosItem.js'; diff --git a/src/components/form-elements/radios/index.ts b/src/components/form-elements/radios/index.ts index 06895bdf..ec9ed5bb 100644 --- a/src/components/form-elements/radios/index.ts +++ b/src/components/form-elements/radios/index.ts @@ -1,2 +1,3 @@ +export * from './components/index.js'; export * from './RadiosContext.js'; export * from './Radios.js'; diff --git a/src/components/form-elements/select/Select.tsx b/src/components/form-elements/select/Select.tsx index a877c33e..25c91ed1 100644 --- a/src/components/form-elements/select/Select.tsx +++ b/src/components/form-elements/select/Select.tsx @@ -24,13 +24,13 @@ const SelectComponent = forwardRef( ), ); -const Option = forwardRef>( +export const SelectOption = forwardRef>( (props, forwardedRef) =>
    ((props, forwardedRe > - {childLogo} + {childLogo} {childSearch} {childAccount} @@ -144,10 +144,10 @@ const HeaderComponent = forwardRef((props, forwardedRe HeaderComponent.displayName = 'Header'; export const Header = Object.assign(HeaderComponent, { - Account, - AccountItem, - Logo, - Search, - Navigation, - NavigationItem, + Account: HeaderAccount, + AccountItem: HeaderAccountItem, + Logo: HeaderLogo, + Search: HeaderSearch, + Navigation: HeaderNavigation, + NavigationItem: HeaderNavigationItem, }); diff --git a/src/components/navigation/header/components/Account.tsx b/src/components/navigation/header/components/HeaderAccount.tsx similarity index 82% rename from src/components/navigation/header/components/Account.tsx rename to src/components/navigation/header/components/HeaderAccount.tsx index fdba7236..0815a242 100644 --- a/src/components/navigation/header/components/Account.tsx +++ b/src/components/navigation/header/components/HeaderAccount.tsx @@ -6,7 +6,7 @@ export type AccountProps = Pick< 'aria-label' | 'children' | 'className' >; -export const Account: FC = ({ +export const HeaderAccount: FC = ({ className, children, 'aria-label': ariaLabel = 'Account', @@ -16,4 +16,4 @@ export const Account: FC = ({ ); -Account.displayName = 'Header.Account'; +HeaderAccount.displayName = 'Header.Account'; diff --git a/src/components/navigation/header/components/AccountItem.tsx b/src/components/navigation/header/components/HeaderAccountItem.tsx similarity index 64% rename from src/components/navigation/header/components/AccountItem.tsx rename to src/components/navigation/header/components/HeaderAccountItem.tsx index 452f174a..1308d6f2 100644 --- a/src/components/navigation/header/components/AccountItem.tsx +++ b/src/components/navigation/header/components/HeaderAccountItem.tsx @@ -3,19 +3,19 @@ import { forwardRef, type ComponentPropsWithRef, type ForwardedRef, type ReactNo import { UserIcon } from '#components/content-presentation/index.js'; import { type AsElementLink } from '#util/types/LinkTypes.js'; -export interface AccountItemButtonProps extends AsElementLink { +export interface HeaderAccountItemButtonProps extends AsElementLink { as?: 'button'; formProps?: ComponentPropsWithRef<'form'>; icon?: boolean; } -export interface AccountItemLinkProps extends AsElementLink { +export interface HeaderAccountItemLinkProps extends AsElementLink { href: string; as?: 'a'; icon?: boolean; } -const AccountItemButton = forwardRef( +const HeaderAccountItemButton = forwardRef( (props, forwardedRef) => { const { className, as, asElement: Element = as ?? 'button', ...rest } = props; const { formProps, ...buttonRest } = rest; @@ -32,7 +32,7 @@ const AccountItemButton = forwardRef( }, ); -const AccountItemLink = forwardRef( +const HeaderAccountItemLink = forwardRef( ({ className, as, asElement: Element = as ?? 'a', ...rest }, forwardedRef) => ( ( ), ); -export const AccountItem = forwardRef< +export const HeaderAccountItem = forwardRef< HTMLAnchorElement | HTMLButtonElement, - AccountItemButtonProps | AccountItemLinkProps + HeaderAccountItemButtonProps | HeaderAccountItemLinkProps >(({ children, className, icon, ...rest }, forwardedRef) => { let element: ReactNode; if (rest.as === 'a' || 'href' in rest) { element = ( - } {...rest}> + } {...rest}> {icon ? : null} {children} - + ); } else if (rest.as === 'button' || 'formProps' in rest) { element = ( - } {...rest}> + } {...rest}> {icon ? : null} {children} - + ); } else { element = ( @@ -74,6 +74,6 @@ export const AccountItem = forwardRef< return
  • {element}
  • ; }); -AccountItem.displayName = 'Header.AccountItem'; -AccountItemLink.displayName = 'Header.AccountItemLink'; -AccountItemButton.displayName = 'Header.AccountItemButton'; +HeaderAccountItem.displayName = 'Header.AccountItem'; +HeaderAccountItemLink.displayName = 'Header.AccountItemLink'; +HeaderAccountItemButton.displayName = 'Header.AccountItemButton'; diff --git a/src/components/navigation/header/components/Logo.tsx b/src/components/navigation/header/components/HeaderLogo.tsx similarity index 91% rename from src/components/navigation/header/components/Logo.tsx rename to src/components/navigation/header/components/HeaderLogo.tsx index b9b508f8..cffb6b11 100644 --- a/src/components/navigation/header/components/Logo.tsx +++ b/src/components/navigation/header/components/HeaderLogo.tsx @@ -3,9 +3,9 @@ import { useContext, useEffect, type FC } from 'react'; import { HeaderContext, type IHeaderContext } from '../HeaderContext.js'; -export type LogoProps = NonNullable; +export type HeaderLogoProps = NonNullable; -export const Logo: FC = (logo) => { +export const HeaderLogo: FC = (logo) => { const { organisationProps: organisation, setLogoProps } = useContext(HeaderContext); @@ -61,4 +61,4 @@ export const Logo: FC = (logo) => { ); }; -Logo.displayName = 'Header.Logo'; +HeaderLogo.displayName = 'Header.Logo'; diff --git a/src/components/navigation/header/components/MenuToggle.tsx b/src/components/navigation/header/components/HeaderMenuToggle.tsx similarity index 71% rename from src/components/navigation/header/components/MenuToggle.tsx rename to src/components/navigation/header/components/HeaderMenuToggle.tsx index ab2db3d4..e7900c24 100644 --- a/src/components/navigation/header/components/MenuToggle.tsx +++ b/src/components/navigation/header/components/HeaderMenuToggle.tsx @@ -1,8 +1,8 @@ import { type ComponentPropsWithoutRef, type FC } from 'react'; -export type MenuToggleProps = ComponentPropsWithoutRef<'button'>; +export type HeaderMenuToggleProps = ComponentPropsWithoutRef<'button'>; -export const MenuToggle: FC = (props) => ( +export const HeaderMenuToggle: FC = (props) => ( ); }; -NavigationItem.displayName = 'Header.NavigationItem'; +HeaderNavigationItem.displayName = 'Header.NavigationItem'; diff --git a/src/components/navigation/header/components/Search.tsx b/src/components/navigation/header/components/HeaderSearch.tsx similarity index 86% rename from src/components/navigation/header/components/Search.tsx rename to src/components/navigation/header/components/HeaderSearch.tsx index 54512fe7..23ec21ed 100644 --- a/src/components/navigation/header/components/Search.tsx +++ b/src/components/navigation/header/components/HeaderSearch.tsx @@ -1,14 +1,14 @@ import { type ComponentPropsWithoutRef, type FC } from 'react'; import { SearchIcon } from '#components/content-presentation/index.js'; -export interface SearchProps extends ComponentPropsWithoutRef<'form'> { +export interface HeaderSearchProps extends ComponentPropsWithoutRef<'form'> { name?: string; placeholder?: string; visuallyHiddenLabel?: string; visuallyHiddenButton?: string; } -export const Search: FC = ({ +export const HeaderSearch: FC = ({ action = 'https://www.nhs.uk/search/', method = 'get', id = 'search', @@ -40,4 +40,4 @@ export const Search: FC = ({ ); }; -Search.displayName = 'Header.Search'; +HeaderSearch.displayName = 'Header.Search'; diff --git a/src/components/navigation/header/components/ServiceName.tsx b/src/components/navigation/header/components/HeaderServiceName.tsx similarity index 70% rename from src/components/navigation/header/components/ServiceName.tsx rename to src/components/navigation/header/components/HeaderServiceName.tsx index 826772f2..c33abf08 100644 --- a/src/components/navigation/header/components/ServiceName.tsx +++ b/src/components/navigation/header/components/HeaderServiceName.tsx @@ -3,10 +3,10 @@ import { useContext, type ComponentPropsWithoutRef, type FC } from 'react'; import { HeaderContext, type IHeaderContext } from '../HeaderContext.js'; -export type ServiceNameInnerProps = NonNullable; -export type ServiceNameProps = Pick, 'children'>; +export type HeaderServiceNameInnerProps = NonNullable; +export type HeaderServiceNameProps = Pick, 'children'>; -const ServiceNameInner: FC = (service) => +const HeaderServiceNameInner: FC = (service) => service.href ? (
    {service.text} @@ -15,7 +15,7 @@ const ServiceNameInner: FC = (service) => {service.text} ); -export const ServiceName: FC = ({ children }) => { +export const HeaderServiceName: FC = ({ children }) => { const { logoProps: logo, organisationProps: organisation, @@ -48,15 +48,17 @@ export const ServiceName: FC = ({ children }) => { {logoHref ? ( {children} - {combineLogoAndServiceNameLinks ? : null} + {combineLogoAndServiceNameLinks ? : null} ) : ( <> {children} - {combineLogoAndServiceNameLinks ? : null} + {combineLogoAndServiceNameLinks ? : null} )} - {service?.text && !combineLogoAndServiceNameLinks ? : null} + {service?.text && !combineLogoAndServiceNameLinks ? ( + + ) : null}
    ); }; diff --git a/src/components/navigation/header/components/index.ts b/src/components/navigation/header/components/index.ts index 9a4ba769..2a280742 100644 --- a/src/components/navigation/header/components/index.ts +++ b/src/components/navigation/header/components/index.ts @@ -1,8 +1,8 @@ -export * from './Account.js'; -export * from './AccountItem.js'; -export * from './Logo.js'; -export * from './MenuToggle.js'; -export * from './Navigation.js'; -export * from './NavigationItem.js'; -export * from './Search.js'; -export * from './ServiceName.js'; +export * from './HeaderAccount.js'; +export * from './HeaderAccountItem.js'; +export * from './HeaderLogo.js'; +export * from './HeaderMenuToggle.js'; +export * from './HeaderNavigation.js'; +export * from './HeaderNavigationItem.js'; +export * from './HeaderSearch.js'; +export * from './HeaderServiceName.js'; diff --git a/src/components/navigation/header/index.ts b/src/components/navigation/header/index.ts index d5c1f758..354a52c1 100644 --- a/src/components/navigation/header/index.ts +++ b/src/components/navigation/header/index.ts @@ -1,2 +1,3 @@ +export * from './components/index.js'; export * from './HeaderContext.js'; export * from './Header.js'; diff --git a/src/components/navigation/pagination/Pagination.tsx b/src/components/navigation/pagination/Pagination.tsx index 0a6fde86..a202b432 100644 --- a/src/components/navigation/pagination/Pagination.tsx +++ b/src/components/navigation/pagination/Pagination.tsx @@ -8,7 +8,7 @@ export interface PaginationLinkProps extends AsElementLink { next?: boolean; } -const PaginationLink = forwardRef( +export const PaginationLink = forwardRef( ({ className, children, asElement: Element = 'a', previous, next, ...rest }, forwardedRef) => (
  • ( ), ); -type PaginationProps = ComponentPropsWithoutRef<'nav'>; +export type PaginationProps = ComponentPropsWithoutRef<'nav'>; const PaginationComponent = forwardRef( ({ className, children, 'aria-label': ariaLabel = 'Pagination', ...rest }, forwardedRef) => ( diff --git a/src/components/utils/HeadingLevel.tsx b/src/components/utils/HeadingLevel.tsx index 3d45135c..7a740bb5 100644 --- a/src/components/utils/HeadingLevel.tsx +++ b/src/components/utils/HeadingLevel.tsx @@ -22,4 +22,4 @@ export const HeadingLevel = forwardRef( }, ); -HeadingLevel.displayName = 'HeadingLevel'; +HeadingLevel.displayName = 'Heading.Level'; diff --git a/src/patterns/nav-a-z/NavAZ.tsx b/src/patterns/nav-a-z/NavAZ.tsx index 8c131e29..fc9e7917 100644 --- a/src/patterns/nav-a-z/NavAZ.tsx +++ b/src/patterns/nav-a-z/NavAZ.tsx @@ -16,12 +16,12 @@ const processLetters = ( return null; } if (disabledLetters && disabledLetters.includes(letter)) { - return {letter}; + return {letter}; } return ( - + {letter} - + ); }; if (children) { @@ -70,7 +70,7 @@ const NavAZComponent = forwardRef((props, forwardedRef) ); }); -const LinkItem: FC> = ({ +export const NavAZLinkItem: FC> = ({ className, asElement: Element = 'a', ...rest @@ -88,7 +88,7 @@ const LinkItem: FC> = ({
  • ); -const DisabledItem: FC> = ({ className, ...rest }) => ( +export const NavAZDisabledItem: FC> = ({ className, ...rest }) => (
  • > = ({ className, ...rest ); NavAZComponent.displayName = 'NavAZ'; -LinkItem.displayName = 'NavAZ.LinkItem'; -DisabledItem.displayName = 'NavAZ.DisabledItem'; +NavAZLinkItem.displayName = 'NavAZ.LinkItem'; +NavAZDisabledItem.displayName = 'NavAZ.DisabledItem'; export const NavAZ = Object.assign(NavAZComponent, { - LinkItem, - DisabledItem, + LinkItem: NavAZLinkItem, + DisabledItem: NavAZDisabledItem, }); From f1871abaf6f5e019b4bac75896110a2e8b24c7ea Mon Sep 17 00:00:00 2001 From: Colin Rotherham Date: Mon, 13 Oct 2025 08:34:54 +0100 Subject: [PATCH 07/24] Allow NHS.UK frontend errors to be caught --- .../content-presentation/tabs/Tabs.tsx | 13 ++++++---- .../form-elements/button/Button.tsx | 26 ++++++++++++------- .../character-count/CharacterCount.tsx | 13 ++++++---- .../form-elements/checkboxes/Checkboxes.tsx | 13 ++++++---- .../error-summary/ErrorSummary.tsx | 13 ++++++---- .../form-elements/radios/Radios.tsx | 13 ++++++---- src/components/navigation/header/Header.tsx | 13 ++++++---- .../navigation/skip-link/SkipLink.tsx | 13 ++++++---- 8 files changed, 72 insertions(+), 45 deletions(-) diff --git a/src/components/content-presentation/tabs/Tabs.tsx b/src/components/content-presentation/tabs/Tabs.tsx index dd93dbdf..131274e1 100644 --- a/src/components/content-presentation/tabs/Tabs.tsx +++ b/src/components/content-presentation/tabs/Tabs.tsx @@ -56,6 +56,7 @@ const TabsComponent = forwardRef((props, forwardedRef const { children, className, ...rest } = props; const [moduleRef] = useState(() => forwardedRef || createRef()); + const [instanceError, setInstanceError] = useState(); const [instance, setInstance] = useState(); useEffect(() => { @@ -63,13 +64,15 @@ const TabsComponent = forwardRef((props, forwardedRef return; } - const { current: $root } = moduleRef; - - import('nhsuk-frontend').then(({ Tabs }) => { - setInstance(new Tabs($root)); - }); + import('nhsuk-frontend') + .then(({ Tabs }) => setInstance(new Tabs(moduleRef.current))) + .catch(setInstanceError); }, [moduleRef, instance]); + if (instanceError) { + throw instanceError; + } + return (
    ((props, forwa } = props; const [moduleRef] = useState(() => forwardedRef || createRef()); + const [instanceError, setInstanceError] = useState(); const [instance, setInstance] = useState(); useEffect(() => { @@ -53,13 +54,15 @@ const ButtonComponent = forwardRef((props, forwa return; } - const { current: $root } = moduleRef; - - import('nhsuk-frontend').then(({ Button }) => { - setInstance(new Button($root)); - }); + import('nhsuk-frontend') + .then(({ Button }) => setInstance(new Button(moduleRef.current))) + .catch(setInstanceError); }, [moduleRef, instance]); + if (instanceError) { + throw instanceError; + } + return ( ( } = props; const [moduleRef] = useState(() => forwardedRef || createRef()); + const [instanceError, setInstanceError] = useState(); const [instance, setInstance] = useState(); useEffect(() => { @@ -109,13 +113,15 @@ const ButtonLinkComponent = forwardRef( return; } - const { current: $root } = moduleRef; - - import('nhsuk-frontend').then(({ Button }) => { - setInstance(new Button($root)); - }); + import('nhsuk-frontend') + .then(({ Button }) => setInstance(new Button(moduleRef.current))) + .catch(setInstanceError); }, [moduleRef, instance]); + if (instanceError) { + throw instanceError; + } + return ( ( ({ maxLength, maxWords, threshold, formGroupProps, ...rest }, forwardedRef) => { const [moduleRef] = useState(() => formGroupProps?.ref || createRef()); + const [instanceError, setInstanceError] = useState(); const [instance, setInstance] = useState(); useEffect(() => { @@ -24,13 +25,15 @@ export const CharacterCount = forwardRef { - setInstance(new CharacterCount($root)); - }); + import('nhsuk-frontend') + .then(({ CharacterCount }) => setInstance(new CharacterCount(moduleRef.current))) + .catch(setInstanceError); }, [moduleRef, instance]); + if (instanceError) { + throw instanceError; + } + return ( inputType="textarea" diff --git a/src/components/form-elements/checkboxes/Checkboxes.tsx b/src/components/form-elements/checkboxes/Checkboxes.tsx index c83c9806..1e17e60c 100644 --- a/src/components/form-elements/checkboxes/Checkboxes.tsx +++ b/src/components/form-elements/checkboxes/Checkboxes.tsx @@ -19,6 +19,7 @@ const CheckboxesComponent = forwardRef((props, const { children, idPrefix, ...rest } = props; const [moduleRef] = useState(() => forwardedRef || createRef()); + const [instanceError, setInstanceError] = useState(); const [instance, setInstance] = useState(); const _boxReferences: string[] = []; @@ -30,11 +31,9 @@ const CheckboxesComponent = forwardRef((props, return; } - const { current: $root } = moduleRef; - - import('nhsuk-frontend').then(({ Checkboxes }) => { - setInstance(new Checkboxes($root)); - }); + import('nhsuk-frontend') + .then(({ Checkboxes }) => setInstance(new Checkboxes(moduleRef.current))) + .catch(setInstanceError); }, [moduleRef, instance]); const getBoxId = (id: string, reference: string): string => { @@ -66,6 +65,10 @@ const CheckboxesComponent = forwardRef((props, _boxIds = {}; }; + if (instanceError) { + throw instanceError; + } + return ( inputType="checkboxes" {...rest}> {/* eslint-disable-next-line @typescript-eslint/no-unused-vars */} diff --git a/src/components/form-elements/error-summary/ErrorSummary.tsx b/src/components/form-elements/error-summary/ErrorSummary.tsx index 8ea09af3..75e572fc 100644 --- a/src/components/form-elements/error-summary/ErrorSummary.tsx +++ b/src/components/form-elements/error-summary/ErrorSummary.tsx @@ -20,6 +20,7 @@ export interface ErrorSummaryProps extends ComponentPropsWithoutRef<'div'> { const ErrorSummaryComponent = forwardRef( ({ children, className, disableAutoFocus, ...rest }, forwardedRef) => { const [moduleRef] = useState(() => forwardedRef || createRef()); + const [instanceError, setInstanceError] = useState(); const [instance, setInstance] = useState(); useEffect(() => { @@ -27,17 +28,19 @@ const ErrorSummaryComponent = forwardRef( return; } - const { current: $root } = moduleRef; - - import('nhsuk-frontend').then(({ ErrorSummary }) => { - setInstance(new ErrorSummary($root)); - }); + import('nhsuk-frontend') + .then(({ ErrorSummary }) => setInstance(new ErrorSummary(moduleRef.current))) + .catch(setInstanceError); }, [moduleRef, instance]); const items = Children.toArray(children); const title = items.find((child) => childIsOfComponentType(child, ErrorSummaryTitle)); const bodyItems = items.filter((child) => !childIsOfComponentType(child, ErrorSummaryTitle)); + if (instanceError) { + throw instanceError; + } + return (
    ((props, forwarde const { children, idPrefix, ...rest } = props; const [moduleRef] = useState(() => forwardedRef || createRef()); + const [instanceError, setInstanceError] = useState(); const [instance, setInstance] = useState(); const [selectedRadio, setSelectedRadio] = useState(); @@ -32,11 +33,9 @@ const RadiosComponent = forwardRef((props, forwarde return; } - const { current: $root } = moduleRef; - - import('nhsuk-frontend').then(({ Radios }) => { - setInstance(new Radios($root)); - }); + import('nhsuk-frontend') + .then(({ Radios }) => setInstance(new Radios(moduleRef.current))) + .catch(setInstanceError); }, [moduleRef, instance]); const getRadioId = (id: string, reference: string): string => { @@ -73,6 +72,10 @@ const RadiosComponent = forwardRef((props, forwarde _radioIds = {}; }; + if (instanceError) { + throw instanceError; + } + return ( inputType="radios" {...rest}> {/* eslint-disable-next-line @typescript-eslint/no-unused-vars */} diff --git a/src/components/navigation/header/Header.tsx b/src/components/navigation/header/Header.tsx index 016bee14..e73ccad5 100644 --- a/src/components/navigation/header/Header.tsx +++ b/src/components/navigation/header/Header.tsx @@ -41,6 +41,7 @@ const HeaderComponent = forwardRef((props, forwardedRe const [logoProps, setLogoProps] = useState(logo); const [serviceProps, setServiceProps] = useState(service); const [organisationProps, setOrganisationProps] = useState(organisation); + const [instanceError, setInstanceError] = useState(); const [instance, setInstance] = useState(); const [menuOpen, setMenuOpen] = useState(false); @@ -90,11 +91,9 @@ const HeaderComponent = forwardRef((props, forwardedRe return; } - const { current: $root } = moduleRef; - - import('nhsuk-frontend').then(({ Header }) => { - setInstance(new Header($root)); - }); + import('nhsuk-frontend') + .then(({ Header }) => setInstance(new Header(moduleRef.current))) + .catch(setInstanceError); }, [moduleRef, instance, menuOpen]); const contextValue: IHeaderContext = useMemo(() => { @@ -116,6 +115,10 @@ const HeaderComponent = forwardRef((props, forwardedRe const childNavigation = items.find((child) => childIsOfComponentType(child, HeaderNavigation)); const childAccount = items.find((child) => childIsOfComponentType(child, HeaderAccount)); + if (instanceError) { + throw instanceError; + } + return (
    ((props, for const { children = 'Skip to main content', className, href = '#maincontent', ...rest } = props; const [moduleRef] = useState(() => forwardedRef || createRef()); + const [instanceError, setInstanceError] = useState(); const [instance, setInstance] = useState(); useEffect(() => { @@ -17,13 +18,15 @@ export const SkipLink = forwardRef((props, for return; } - const { current: $root } = moduleRef; - - import('nhsuk-frontend').then(({ SkipLink }) => { - setInstance(new SkipLink($root)); - }); + import('nhsuk-frontend') + .then(({ SkipLink }) => setInstance(new SkipLink(moduleRef.current))) + .catch(setInstanceError); }, [moduleRef, instance]); + if (instanceError) { + throw instanceError; + } + return ( Date: Fri, 10 Oct 2025 17:34:08 +0100 Subject: [PATCH 08/24] Remove unnnecessary header context --- docs/upgrade-to-6.0.md | 8 ++- src/components/navigation/header/Header.tsx | 58 +++--------------- .../navigation/header/HeaderContext.ts | 24 -------- .../header/__tests__/Header.test.tsx | 60 +++++++------------ .../__snapshots__/Header.test.tsx.snap | 40 +++++++------ .../header/components/HeaderLogo.tsx | 37 ++++++------ .../header/components/HeaderServiceName.tsx | 27 +++++---- stories/Navigation/Header.stories.tsx | 27 +++------ 8 files changed, 97 insertions(+), 184 deletions(-) diff --git a/docs/upgrade-to-6.0.md b/docs/upgrade-to-6.0.md index 8c09dc05..90a245e8 100644 --- a/docs/upgrade-to-6.0.md +++ b/docs/upgrade-to-6.0.md @@ -239,22 +239,26 @@ The custom `autoSelectNext` prop is no longer supported. The updated header component from NHS.UK frontend v10.x has been added. You will need to make the following changes: +- move `Header.Logo` props to `
    ` +- move `Header.ServiceName` props to `
    ` - remove the wrapping `Header.Container` component - remove the wrapping `Header.Content` component +- remove the automatically created `Header.Logo` component - remove the automatically created `Header.ServiceName` component - remove the automatically created `Header.NavDropdownMenu` component - rename the `Header.Nav` component to `Header.Navigation` - rename the `Header.NavItem` component to `Header.NavigationItem` ```patch -
    +-
    ++
    - - +- Manage patients - - - - -+ + - - Example 1 diff --git a/src/components/navigation/header/Header.tsx b/src/components/navigation/header/Header.tsx index e73ccad5..1026276b 100644 --- a/src/components/navigation/header/Header.tsx +++ b/src/components/navigation/header/Header.tsx @@ -19,16 +19,14 @@ import { HeaderNavigationItem, HeaderSearch, HeaderServiceName, + type HeaderServiceNameProps, } from './components/index.js'; import { HeaderContext, type IHeaderContext } from './HeaderContext.js'; import { Container } from '#components/layout/index.js'; import { childIsOfComponentType } from '#util/types/index.js'; -export interface HeaderProps extends ComponentPropsWithoutRef<'div'> { +export interface HeaderProps extends ComponentPropsWithoutRef<'div'>, HeaderServiceNameProps { containerClasses?: string; - logo?: IHeaderContext['logoProps']; - service?: IHeaderContext['serviceProps']; - organisation?: IHeaderContext['organisationProps']; white?: boolean; } @@ -37,41 +35,10 @@ const HeaderComponent = forwardRef((props, forwardedRe props; const [moduleRef] = useState(() => forwardedRef || createRef()); - - const [logoProps, setLogoProps] = useState(logo); - const [serviceProps, setServiceProps] = useState(service); - const [organisationProps, setOrganisationProps] = useState(organisation); const [instanceError, setInstanceError] = useState(); const [instance, setInstance] = useState(); const [menuOpen, setMenuOpen] = useState(false); - useEffect(() => { - if (!logo) { - return; - } - - setLogoProps(logo); - return () => setLogoProps(undefined); - }, [logo]); - - useEffect(() => { - if (!service) { - return; - } - - setServiceProps(service); - return () => setServiceProps(undefined); - }, [service]); - - useEffect(() => { - if (!organisation) { - return; - } - - setOrganisationProps(organisation); - return () => setOrganisationProps(undefined); - }, [organisation]); - useEffect(() => { if (!('current' in moduleRef) || !moduleRef.current || instance) { if (!instance) { @@ -97,20 +64,10 @@ const HeaderComponent = forwardRef((props, forwardedRe }, [moduleRef, instance, menuOpen]); const contextValue: IHeaderContext = useMemo(() => { - return { - logoProps, - serviceProps, - organisationProps, - menuOpen, - setMenuOpen, - setLogoProps, - setServiceProps, - setOrganisationProps, - }; - }, [logoProps, serviceProps, organisationProps, menuOpen]); + return { menuOpen, setMenuOpen }; + }, [menuOpen]); const items = Children.toArray(children); - const childLogo = items.find((child) => childIsOfComponentType(child, HeaderLogo)); const childSearch = items.find((child) => childIsOfComponentType(child, HeaderSearch)); const childNavigation = items.find((child) => childIsOfComponentType(child, HeaderNavigation)); const childAccount = items.find((child) => childIsOfComponentType(child, HeaderAccount)); @@ -123,7 +80,7 @@ const HeaderComponent = forwardRef((props, forwardedRe
    ((props, forwardedRe > - {childLogo} + + + {childSearch} {childAccount} @@ -149,7 +108,6 @@ HeaderComponent.displayName = 'Header'; export const Header = Object.assign(HeaderComponent, { Account: HeaderAccount, AccountItem: HeaderAccountItem, - Logo: HeaderLogo, Search: HeaderSearch, Navigation: HeaderNavigation, NavigationItem: HeaderNavigationItem, diff --git a/src/components/navigation/header/HeaderContext.ts b/src/components/navigation/header/HeaderContext.ts index 72a3b01d..3c686f37 100644 --- a/src/components/navigation/header/HeaderContext.ts +++ b/src/components/navigation/header/HeaderContext.ts @@ -3,35 +3,11 @@ import { createContext, type Dispatch, type SetStateAction } from 'react'; export interface IHeaderContext { - logoProps?: { - 'href'?: string; - 'src'?: string; - 'alt'?: string; - 'aria-label'?: string; - }; - serviceProps?: { - href?: string; - text?: string; - }; - organisationProps?: { - name?: string; - split?: string; - descriptor?: string; - }; menuOpen: boolean; setMenuOpen: Dispatch>; - setLogoProps: Dispatch>; - setServiceProps: Dispatch>; - setOrganisationProps: Dispatch>; } export const HeaderContext = createContext({ - logoProps: undefined, - serviceProps: undefined, - organisationProps: undefined, menuOpen: false, setMenuOpen: () => {}, - setLogoProps: () => {}, - setServiceProps: () => {}, - setOrganisationProps: () => {}, }); diff --git a/src/components/navigation/header/__tests__/Header.test.tsx b/src/components/navigation/header/__tests__/Header.test.tsx index e1891ca9..52d97f9a 100644 --- a/src/components/navigation/header/__tests__/Header.test.tsx +++ b/src/components/navigation/header/__tests__/Header.test.tsx @@ -5,8 +5,7 @@ import { renderClient, renderServer } from '#util/components'; describe('Header', () => { it('matches snapshot', async () => { const { container } = await renderClient( -
    - +
    Health A-Z @@ -27,8 +26,7 @@ describe('Header', () => { it('matches snapshot (via server)', async () => { const { container, element } = await renderServer( -
    - +
    Health A-Z @@ -58,12 +56,9 @@ describe('Header', () => { it('forwards refs', async () => { const ref = createRef(); - const { modules } = await renderClient( -
    - -
    , - { moduleName: 'nhsuk-header' }, - ); + const { modules } = await renderClient(
    , { + moduleName: 'nhsuk-header', + }); const [headerEl] = modules; @@ -106,12 +101,9 @@ describe('Header', () => { describe('Header.Logo', () => { it('renders logo only', async () => { - const { container } = await renderClient( -
    - -
    , - { moduleName: 'nhsuk-header' }, - ); + const { container } = await renderClient(
    , { + moduleName: 'nhsuk-header', + }); const linkEl = container.querySelector('.nhsuk-header__service a'); const logoEl = container.querySelector('.nhsuk-header__logo'); @@ -121,12 +113,9 @@ describe('Header', () => { }); it('renders logo only (with link)', async () => { - const { container } = await renderClient( -
    - -
    , - { moduleName: 'nhsuk-header' }, - ); + const { container } = await renderClient(
    , { + moduleName: 'nhsuk-header', + }); const linkEl = container.querySelector('.nhsuk-header__service a'); const logoEl = container.querySelector('.nhsuk-header__logo'); @@ -138,9 +127,7 @@ describe('Header', () => { it('renders logo and organisation name', async () => { const { container } = await renderClient( -
    - -
    , +
    , { moduleName: 'nhsuk-header' }, ); @@ -157,9 +144,7 @@ describe('Header', () => { it('renders logo (with link) and organisation name', async () => { const { container } = await renderClient( -
    - -
    , +
    , { moduleName: 'nhsuk-header' }, ); @@ -177,9 +162,7 @@ describe('Header', () => { it('renders logo (custom src) and organisation name', async () => { const { container } = await renderClient( -
    - -
    , +
    , { moduleName: 'nhsuk-header' }, ); @@ -196,9 +179,10 @@ describe('Header', () => { it('renders logo (with link, custom src) and organisation name', async () => { const { container } = await renderClient( -
    - -
    , +
    , { moduleName: 'nhsuk-header' }, ); @@ -217,14 +201,13 @@ describe('Header', () => { it('renders logo (with link) and organisation name (split, with descriptor)', async () => { const { container } = await renderClient(
    - -
    , + />, { moduleName: 'nhsuk-header' }, ); @@ -245,7 +228,6 @@ describe('Header', () => { it('matches snapshot', async () => { const { container } = await renderClient(
    - florence.nightingale@nhs.net @@ -267,7 +249,6 @@ describe('Header', () => { const { container } = await renderClient(
    - florence.nightingale@nhs.net @@ -305,7 +286,6 @@ describe('Header', () => { const { container } = await renderClient(
    - florence.nightingale@nhs.net diff --git a/src/components/navigation/header/__tests__/__snapshots__/Header.test.tsx.snap b/src/components/navigation/header/__tests__/__snapshots__/Header.test.tsx.snap index 1db8ce3a..210560d4 100644 --- a/src/components/navigation/header/__tests__/__snapshots__/Header.test.tsx.snap +++ b/src/components/navigation/header/__tests__/__snapshots__/Header.test.tsx.snap @@ -276,24 +276,30 @@ exports[`Header matches snapshot (via server): server 1`] = `
    - + +
    ; - -export const HeaderLogo: FC = (logo) => { - const { organisationProps: organisation, setLogoProps } = - useContext(HeaderContext); - - useEffect(() => { - if (!logo) { - return; - } - - setLogoProps(logo); - return () => setLogoProps(undefined); - }, [logo, setLogoProps]); - +import { type FC } from 'react'; + +export interface HeaderLogoProps { + logo?: { + 'href'?: string; + 'src'?: string; + 'alt'?: string; + 'aria-label'?: string; + }; + organisation?: { + name?: string; + split?: string; + descriptor?: string; + }; +} + +export const HeaderLogo: FC = ({ logo = {}, organisation }) => { const { alt = 'NHS' } = logo; return ( diff --git a/src/components/navigation/header/components/HeaderServiceName.tsx b/src/components/navigation/header/components/HeaderServiceName.tsx index c33abf08..d6b3c4bb 100644 --- a/src/components/navigation/header/components/HeaderServiceName.tsx +++ b/src/components/navigation/header/components/HeaderServiceName.tsx @@ -1,10 +1,14 @@ -'use client'; +import { type FC, type PropsWithChildren } from 'react'; +import { type HeaderLogoProps } from './HeaderLogo.js'; -import { useContext, type ComponentPropsWithoutRef, type FC } from 'react'; -import { HeaderContext, type IHeaderContext } from '../HeaderContext.js'; +export type HeaderServiceNameInnerProps = NonNullable; -export type HeaderServiceNameInnerProps = NonNullable; -export type HeaderServiceNameProps = Pick, 'children'>; +export interface HeaderServiceNameProps extends PropsWithChildren, HeaderLogoProps { + service?: { + href?: string; + text?: string; + }; +} const HeaderServiceNameInner: FC = (service) => service.href ? ( @@ -15,13 +19,12 @@ const HeaderServiceNameInner: FC = (service) => {service.text} ); -export const HeaderServiceName: FC = ({ children }) => { - const { - logoProps: logo, - organisationProps: organisation, - serviceProps: service, - } = useContext(HeaderContext); - +export const HeaderServiceName: FC = ({ + children, + logo, + organisation, + service, +}) => { // The NHS logo and service name are combined into a single link if either // the logo doesn’t have an `href` set but the service name does, or if both // have an exact `href`. This avoids having 2 links to same destination. diff --git a/stories/Navigation/Header.stories.tsx b/stories/Navigation/Header.stories.tsx index b934b43a..e5e58e69 100644 --- a/stories/Navigation/Header.stories.tsx +++ b/stories/Navigation/Header.stories.tsx @@ -17,7 +17,6 @@ export const HeaderDefault: Story = { href: '/', }} > - ( -
    - -
    - ), + render: (args) =>
    , }; export const HeaderWithServiceName: Story = { @@ -48,16 +43,13 @@ export const HeaderWithServiceName: Story = { text: 'Manage patients', href: '/', }} - > - -
    + /> ), }; export const HeaderWithSearch: Story = { render: (args) => ( -
    - +
    ), @@ -65,8 +57,7 @@ export const HeaderWithSearch: Story = { export const HeaderWithNavigation: Story = { render: (args) => ( -
    - +
    NHS service standard @@ -88,7 +79,6 @@ export const HeaderWithAccount: Story = { href: '/', }} > - florence.nightingale@nhs.net @@ -109,7 +99,6 @@ export const HeaderWithAccountComplex: Story = { href: '/', }} > - Florence Nightingale (Regional Manager) @@ -131,13 +120,13 @@ export const HeaderWithAccountComplex: Story = { export const HeaderOrganisationalBlueWithNavigation: Story = { render: (args) => (
    - Your hospital visit @@ -156,13 +145,13 @@ export const HeaderOrganisationalWhiteWithNavigation: Story = { render: (args) => (
    - Your hospital visit @@ -181,13 +170,13 @@ export const HeaderOrganisationalWhiteWithNavigationWhite: Story = { render: (args) => (
    - Your hospital visit @@ -214,13 +203,13 @@ export const HeaderWithCustomNavItemComponent: Story = { return (
    - From ee6a0850f02dae007878998fb5814478d7a49873 Mon Sep 17 00:00:00 2001 From: Colin Rotherham Date: Mon, 13 Oct 2025 10:22:59 +0100 Subject: [PATCH 09/24] Make type guards compatible with server components --- CHANGELOG.md | 2 +- .../content-presentation/panel/Panel.tsx | 2 +- .../error-summary/ErrorSummary.tsx | 8 +++-- src/components/navigation/header/Header.tsx | 13 ++++++-- src/util/types/TypeGuards.ts | 30 +++++++++++++++++-- 5 files changed, 46 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f43096c1..3f271ed7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -This version provides support for NHS.UK frontend v10.x and fixes a Rollup `'use client'` directive issue. +This version provides support for NHS.UK frontend v10.x, React Server Components (RSC) and fixes a Rollup `'use client'` directive issue. 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). diff --git a/src/components/content-presentation/panel/Panel.tsx b/src/components/content-presentation/panel/Panel.tsx index 2dcfb3a4..0bc49de5 100644 --- a/src/components/content-presentation/panel/Panel.tsx +++ b/src/components/content-presentation/panel/Panel.tsx @@ -9,7 +9,7 @@ const PanelComponent = forwardRef( ({ children, className, ...rest }, forwardedRef) => { const items = Children.toArray(children); const title = items.find((child) => childIsOfComponentType(child, PanelTitle)); - const bodyItems = items.filter((child) => !childIsOfComponentType(child, PanelTitle)); + const bodyItems = items.filter((child) => child !== title); return (
    diff --git a/src/components/form-elements/error-summary/ErrorSummary.tsx b/src/components/form-elements/error-summary/ErrorSummary.tsx index 75e572fc..1af81d44 100644 --- a/src/components/form-elements/error-summary/ErrorSummary.tsx +++ b/src/components/form-elements/error-summary/ErrorSummary.tsx @@ -34,8 +34,12 @@ const ErrorSummaryComponent = forwardRef( }, [moduleRef, instance]); const items = Children.toArray(children); - const title = items.find((child) => childIsOfComponentType(child, ErrorSummaryTitle)); - const bodyItems = items.filter((child) => !childIsOfComponentType(child, ErrorSummaryTitle)); + + const title = items.find((child) => + childIsOfComponentType(child, ErrorSummaryTitle, { className: 'nhsuk-error-summary__title' }), + ); + + const bodyItems = items.filter((child) => child !== title); if (instanceError) { throw instanceError; diff --git a/src/components/navigation/header/Header.tsx b/src/components/navigation/header/Header.tsx index 1026276b..46235843 100644 --- a/src/components/navigation/header/Header.tsx +++ b/src/components/navigation/header/Header.tsx @@ -68,9 +68,16 @@ const HeaderComponent = forwardRef((props, forwardedRe }, [menuOpen]); const items = Children.toArray(children); - const childSearch = items.find((child) => childIsOfComponentType(child, HeaderSearch)); - const childNavigation = items.find((child) => childIsOfComponentType(child, HeaderNavigation)); - const childAccount = items.find((child) => childIsOfComponentType(child, HeaderAccount)); + + const childSearch = items.find((child) => + childIsOfComponentType(child, HeaderSearch, { className: 'nhsuk-header__search' }), + ); + + const childAccount = items.find((child) => + childIsOfComponentType(child, HeaderAccount, { className: 'nhsuk-header__account' }), + ); + + const childNavigation = items.find((child) => child !== childSearch && child !== childAccount); if (instanceError) { throw instanceError; diff --git a/src/util/types/TypeGuards.ts b/src/util/types/TypeGuards.ts index acbe45b8..58080be5 100644 --- a/src/util/types/TypeGuards.ts +++ b/src/util/types/TypeGuards.ts @@ -7,14 +7,40 @@ import { } from 'react'; import { type CardType, type CareCardType } from './NHSUKTypes.js'; +type WithProps = T & { + props: HTMLAttributes; +}; + /** - * Assert that a child item is of the given component type. + * Assert that a child item is a valid component with props. + */ +const isValidComponent = ( + child: T, +): child is WithProps> => + isValidElement(child) && !!child.props && typeof child.props === 'object'; + +/** + * Assert that a child item is of the given component type, optionally + * checking via props for lazy or deferred server components. */ export const childIsOfComponentType = >( child: ReactNode, component: FC

    , + fallback?: Required>, ): child is ReactElement => { - return isValidElement(child) && child.type === component; + if (!isValidComponent(child)) { + return false; + } + + // Check type for client only components + if (child.type === component) { + return true; + } + + // Check props for lazy or deferred server components + return child.props.className && fallback?.className + ? child.props.className.split(' ').includes(fallback.className) + : false; }; /** From bfb5a67df8f3eb51889528c637e2db7b2d5fca76 Mon Sep 17 00:00:00 2001 From: Colin Rotherham Date: Mon, 13 Oct 2025 08:02:15 +0100 Subject: [PATCH 10/24] Update migration guide --- docs/upgrade-to-6.0.md | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/docs/upgrade-to-6.0.md b/docs/upgrade-to-6.0.md index 90a245e8..5cc03627 100644 --- a/docs/upgrade-to-6.0.md +++ b/docs/upgrade-to-6.0.md @@ -31,15 +31,34 @@ The [panel](https://service-manual.nhs.uk/design-system/components/panel) compon This replaces the [list panel component](#list-panel) which was removed in NHS.UK frontend v6.0.0. +### Support for React Server Components (RSC) + +All components have been tested as React Server Components (RSC) but due to [multipart namespace component limitations](https://ivicabatinic.from.hr/posts/multipart-namespace-components-addressing-rsc-and-dot-notation-issues) an alternative syntax (without dot notation) can be used as a workaround: + +```patch + +- ++ + Treatments +- ++ +- ++ + Symptoms +- ++ + +``` + ## Breaking changes ### Update the JavaScript supported script snippet -You must now use the NHS.UK frontend v10.x feature detection snippet to check for ` ``` From 9737c3c3f8c8cf4bcf15dad04c0905b932631fc5 Mon Sep 17 00:00:00 2001 From: Colin Rotherham Date: Mon, 13 Oct 2025 08:34:26 +0100 Subject: [PATCH 11/24] Update package version to v6.0.0-beta.2 --- CHANGELOG.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f271ed7..98f0c858 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # NHS.UK React components -## Unreleased +## 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 6e8c5b5d..42bcb762 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nhsuk-react-components", - "version": "6.0.0-beta.1", + "version": "6.0.0-beta.2", "license": "MIT", "author": { "name": "NHS England" From a00d653c7a0fb0b31fd9d495879eafcf5ac2df4f Mon Sep 17 00:00:00 2001 From: Rob Kerry Date: Thu, 9 Oct 2025 09:14:47 +0100 Subject: [PATCH 12/24] Add the Notification Banner introduced in NHS Frontend v10 (#283) --- docs/upgrade-to-6.0.md | 11 + src/__tests__/index.test.ts | 1 + src/components/content-presentation/index.ts | 1 + .../NotificationBanner.tsx | 44 +++ .../__tests__/NotificationBanner.test.tsx | 130 +++++++ .../NotificationBanner.test.tsx.snap | 341 ++++++++++++++++++ .../components/NotificationBannerHeading.tsx | 22 ++ .../components/NotificationBannerLink.tsx | 21 ++ .../notification-banner/components/index.ts | 2 + .../notification-banner/index.ts | 1 + .../NotificationBanner.stories.tsx | 87 +++++ 11 files changed, 661 insertions(+) create mode 100644 src/components/content-presentation/notification-banner/NotificationBanner.tsx create mode 100644 src/components/content-presentation/notification-banner/__tests__/NotificationBanner.test.tsx create mode 100644 src/components/content-presentation/notification-banner/__tests__/__snapshots__/NotificationBanner.test.tsx.snap create mode 100644 src/components/content-presentation/notification-banner/components/NotificationBannerHeading.tsx create mode 100644 src/components/content-presentation/notification-banner/components/NotificationBannerLink.tsx create mode 100644 src/components/content-presentation/notification-banner/components/index.ts create mode 100644 src/components/content-presentation/notification-banner/index.ts create mode 100644 stories/Content Presentation/NotificationBanner.stories.tsx diff --git a/docs/upgrade-to-6.0.md b/docs/upgrade-to-6.0.md index 5cc03627..dba1425c 100644 --- a/docs/upgrade-to-6.0.md +++ b/docs/upgrade-to-6.0.md @@ -31,6 +31,17 @@ The [panel](https://service-manual.nhs.uk/design-system/components/panel) compon This replaces the [list panel component](#list-panel) which was removed in NHS.UK frontend v6.0.0. +### Notification banner component + +The [notification banner](https://service-manual.nhs.uk/design-system/components/notification-banner) component from NHS.UK frontend v10 has been added: + +```jsx + + Upcoming Maintenance +

    The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025.

    + +``` + ### Support for React Server Components (RSC) All components have been tested as React Server Components (RSC) but due to [multipart namespace component limitations](https://ivicabatinic.from.hr/posts/multipart-namespace-components-addressing-rsc-and-dot-notation-issues) an alternative syntax (without dot notation) can be used as a workaround: diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index ebc22f7d..d6e3badd 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -91,6 +91,7 @@ describe('Index', () => { 'NavAZ', 'NavAZDisabledItem', 'NavAZLinkItem', + 'NotificationBanner', 'Pagination', 'PaginationLink', 'Panel', diff --git a/src/components/content-presentation/index.ts b/src/components/content-presentation/index.ts index 55a38c07..e410fcc9 100644 --- a/src/components/content-presentation/index.ts +++ b/src/components/content-presentation/index.ts @@ -4,6 +4,7 @@ export * from './hero/index.js'; export * from './icons/index.js'; export * from './images/index.js'; export * from './inset-text/index.js'; +export * from './notification-banner/index.js'; export * from './panel/index.js'; export * from './summary-list/index.js'; export * from './table/index.js'; diff --git a/src/components/content-presentation/notification-banner/NotificationBanner.tsx b/src/components/content-presentation/notification-banner/NotificationBanner.tsx new file mode 100644 index 00000000..c7eaf4e4 --- /dev/null +++ b/src/components/content-presentation/notification-banner/NotificationBanner.tsx @@ -0,0 +1,44 @@ +import { type ComponentPropsWithoutRef, forwardRef } from 'react'; +import classNames from 'classnames'; +import { HeadingLevel } from '#components'; +import { NotificationBannerHeading, NotificationBannerLink } from './components/index.js'; + +export interface NotificationBannerProps extends ComponentPropsWithoutRef<'div'> { + success?: boolean; +} + +const NotificationBannerComponent = forwardRef( + ({ children, className, title, success, ...rest }, forwardedRef) => { + return ( +
    +
    + + {title || (success ? 'Success' : 'Important')} + +
    +
    + {children} +
    +
    + ); + }, +); + +NotificationBannerComponent.displayName = 'NotificationBanner'; + +export const NotificationBanner = Object.assign(NotificationBannerComponent, { + Heading: NotificationBannerHeading, + Link: NotificationBannerLink, +}); diff --git a/src/components/content-presentation/notification-banner/__tests__/NotificationBanner.test.tsx b/src/components/content-presentation/notification-banner/__tests__/NotificationBanner.test.tsx new file mode 100644 index 00000000..b4c70a73 --- /dev/null +++ b/src/components/content-presentation/notification-banner/__tests__/NotificationBanner.test.tsx @@ -0,0 +1,130 @@ +import { renderClient, renderServer } from '#util/components'; +import { NotificationBanner } from '#components/content-presentation/notification-banner'; + +describe('NotificationBanner', () => { + it('matches snapshot', async () => { + const { container } = await renderClient( + + + The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025. + + , + { className: 'nhsuk-notification-banner' }, + ); + + expect(container).toMatchSnapshot(); + }); + it('matches snapshot success', async () => { + const { container } = await renderClient( + + Patient record updated + , + { className: 'nhsuk-notification-banner' }, + ); + + expect(container).toMatchSnapshot(); + }); + it('matches snapshot custom title', async () => { + const { container } = await renderClient( + + + The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025. + + , + { className: 'nhsuk-notification-banner' }, + ); + + expect(container).toMatchSnapshot(); + }); + it('matches snapshot with link in heading', async () => { + const { container } = await renderClient( + + + You have 7 days left to send your application.{' '} + View application. + + , + { className: 'nhsuk-notification-banner' }, + ); + + expect(container).toMatchSnapshot(); + }); + it('matches snapshot with link in body', async () => { + const { container } = await renderClient( + + Patient record updated +

    + Contact{' '} + example@department.nhs.uk if + you think there's a problem. +

    +
    , + { className: 'nhsuk-notification-banner' }, + ); + + expect(container).toMatchSnapshot(); + }); + + it('matches snapshot (via server)', async () => { + const { container } = await renderServer( + + + The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025. + + , + { className: 'nhsuk-notification-banner' }, + ); + + expect(container).toMatchSnapshot(); + }); + it('matches snapshot success (via server)', async () => { + const { container } = await renderServer( + + Patient record updated + , + { className: 'nhsuk-notification-banner' }, + ); + + expect(container).toMatchSnapshot(); + }); + it('matches snapshot custom title (via server)', async () => { + const { container } = await renderServer( + + + The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025. + + , + { className: 'nhsuk-notification-banner' }, + ); + + expect(container).toMatchSnapshot(); + }); + it('matches snapshot with link in heading (via server)', async () => { + const { container } = await renderServer( + + + You have 7 days left to send your application.{' '} + View application. + + , + { className: 'nhsuk-notification-banner' }, + ); + + expect(container).toMatchSnapshot(); + }); + it('matches snapshot with link in body (via server)', async () => { + const { container } = await renderServer( + + Patient record updated +

    + Contact{' '} + example@department.nhs.uk if + you think there's a problem. +

    +
    , + { className: 'nhsuk-notification-banner' }, + ); + + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/components/content-presentation/notification-banner/__tests__/__snapshots__/NotificationBanner.test.tsx.snap b/src/components/content-presentation/notification-banner/__tests__/__snapshots__/NotificationBanner.test.tsx.snap new file mode 100644 index 00000000..336146b5 --- /dev/null +++ b/src/components/content-presentation/notification-banner/__tests__/__snapshots__/NotificationBanner.test.tsx.snap @@ -0,0 +1,341 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`NotificationBanner matches snapshot (via server) 1`] = ` +
    +
    +
    +

    + Important +

    +
    +
    +

    + The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025. +

    +
    +
    +
    +`; + +exports[`NotificationBanner matches snapshot 1`] = ` +
    +
    +
    +

    + Important +

    +
    +
    +

    + The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025. +

    +
    +
    +
    +`; + +exports[`NotificationBanner matches snapshot custom title (via server) 1`] = ` +
    +
    +
    +

    + Upcoming Maintenance +

    +
    +
    +

    + The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025. +

    +
    +
    +
    +`; + +exports[`NotificationBanner matches snapshot custom title 1`] = ` +
    +
    +
    +

    + Upcoming Maintenance +

    +
    +
    +

    + The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025. +

    +
    +
    +
    +`; + +exports[`NotificationBanner matches snapshot success (via server) 1`] = ` +
    +
    +
    +

    + Success +

    +
    +
    +

    + Patient record updated +

    +
    +
    +
    +`; + +exports[`NotificationBanner matches snapshot success 1`] = ` +
    +
    +
    +

    + Success +

    +
    +
    +

    + Patient record updated +

    +
    +
    +
    +`; + +exports[`NotificationBanner matches snapshot with link in body (via server) 1`] = ` +
    +
    +
    +

    + Important +

    +
    +
    +

    + Patient record updated +

    +

    + Contact + + + + example@department.nhs.uk + + if you think there's a problem. +

    +
    +
    +
    +`; + +exports[`NotificationBanner matches snapshot with link in body 1`] = ` +
    +
    +
    +

    + Important +

    +
    +
    +

    + Patient record updated +

    +

    + Contact + + + example@department.nhs.uk + + if you think there's a problem. +

    +
    +
    +
    +`; + +exports[`NotificationBanner matches snapshot with link in heading (via server) 1`] = ` +
    +`; + +exports[`NotificationBanner matches snapshot with link in heading 1`] = ` + +`; diff --git a/src/components/content-presentation/notification-banner/components/NotificationBannerHeading.tsx b/src/components/content-presentation/notification-banner/components/NotificationBannerHeading.tsx new file mode 100644 index 00000000..b1878cdc --- /dev/null +++ b/src/components/content-presentation/notification-banner/components/NotificationBannerHeading.tsx @@ -0,0 +1,22 @@ +import { HeadingLevel, type HeadingLevelProps } from '#components'; +import type { FC } from 'react'; + +export type NotificationBannerHeadingProps = HeadingLevelProps; + +const NotificationBannerHeadingComponent: FC = ({ + children, + headingLevel = 'h3', + ...rest +}) => ( + + {children} + +); + +NotificationBannerHeadingComponent.displayName = 'NotificationBanner.Heading'; + +export const NotificationBannerHeading = Object.assign(NotificationBannerHeadingComponent); diff --git a/src/components/content-presentation/notification-banner/components/NotificationBannerLink.tsx b/src/components/content-presentation/notification-banner/components/NotificationBannerLink.tsx new file mode 100644 index 00000000..4b0873a1 --- /dev/null +++ b/src/components/content-presentation/notification-banner/components/NotificationBannerLink.tsx @@ -0,0 +1,21 @@ +import type { AsElementLink } from '#util/types/index.js'; +import { forwardRef } from 'react'; +import classNames from 'classnames'; + +export type NotificationBannerLinkProps = AsElementLink; + +const NotificationBannerLinkComponent = forwardRef( + ({ children, className, asElement: Element = 'a', ...rest }, forwardedRef) => ( + + {children} + + ), +); + +NotificationBannerLinkComponent.displayName = 'NotificationBanner.Link'; + +export const NotificationBannerLink = Object.assign(NotificationBannerLinkComponent); diff --git a/src/components/content-presentation/notification-banner/components/index.ts b/src/components/content-presentation/notification-banner/components/index.ts new file mode 100644 index 00000000..a2edb989 --- /dev/null +++ b/src/components/content-presentation/notification-banner/components/index.ts @@ -0,0 +1,2 @@ +export * from './NotificationBannerHeading.js'; +export * from './NotificationBannerLink.js'; diff --git a/src/components/content-presentation/notification-banner/index.ts b/src/components/content-presentation/notification-banner/index.ts new file mode 100644 index 00000000..9da940f0 --- /dev/null +++ b/src/components/content-presentation/notification-banner/index.ts @@ -0,0 +1 @@ +export * from './NotificationBanner.js'; diff --git a/stories/Content Presentation/NotificationBanner.stories.tsx b/stories/Content Presentation/NotificationBanner.stories.tsx new file mode 100644 index 00000000..91ce6d79 --- /dev/null +++ b/stories/Content Presentation/NotificationBanner.stories.tsx @@ -0,0 +1,87 @@ +import { type Meta, type StoryObj } from '@storybook/react-vite'; +import { NotificationBanner } from '#components'; + +/** + * This component can be found in the `nhsuk-frontend` repository here. + * + * ## Implementation Notes + * + * The `NotificationBanner` component has two subcomponents: + * + * - `NotificationBanner.Heading` + * - `NotificationBanner.Link` + * + * ## Usage + * + * ### Standard + * + * ```jsx + * import { NotificationBanner } from "nhsuk-react-components"; + * + * const Element = () => { + * return ( + * + * Patient record updated + *

    + * Contact example@department.nhs.uk if you think there's a problem. + *

    + *
    + * ); + * } + * ``` + */ +const meta: Meta = { + title: 'Content Presentation/Notification Banner', + component: NotificationBanner, +}; +export default meta; +type Story = StoryObj; + +export const StandardPanel: Story = { + args: {}, + render: (args) => ( + + + The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025. + + + ), +}; + +export const SuccessPanel: Story = { + args: {}, + render: (args) => ( + + Patient record updated +

    + Contact{' '} + example@department.nhs.uk if + you think there's a problem. +

    +
    + ), +}; + +export const StandardPanelWithLink: Story = { + args: {}, + render: (args) => ( + + + You have 7 days left to send your application. + View application. + + + ), +}; + +export const StandardPanelWithCustomTitle: Story = { + args: { + title: 'Important Message', + }, + render: (args) => ( + + Upcoming Maintenance +

    The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025.

    +
    + ), +}; From 161cb572c58594f01ab905282e12b7df3c118004 Mon Sep 17 00:00:00 2001 From: Rob Kerry Date: Thu, 9 Oct 2025 13:55:08 +0100 Subject: [PATCH 13/24] Fix circular dependency --- .../notification-banner/NotificationBanner.tsx | 2 +- .../components/NotificationBannerHeading.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/content-presentation/notification-banner/NotificationBanner.tsx b/src/components/content-presentation/notification-banner/NotificationBanner.tsx index c7eaf4e4..5e4d0e1d 100644 --- a/src/components/content-presentation/notification-banner/NotificationBanner.tsx +++ b/src/components/content-presentation/notification-banner/NotificationBanner.tsx @@ -1,6 +1,6 @@ import { type ComponentPropsWithoutRef, forwardRef } from 'react'; import classNames from 'classnames'; -import { HeadingLevel } from '#components'; +import { HeadingLevel } from '#components/utils/HeadingLevel.js'; import { NotificationBannerHeading, NotificationBannerLink } from './components/index.js'; export interface NotificationBannerProps extends ComponentPropsWithoutRef<'div'> { diff --git a/src/components/content-presentation/notification-banner/components/NotificationBannerHeading.tsx b/src/components/content-presentation/notification-banner/components/NotificationBannerHeading.tsx index b1878cdc..4adf4704 100644 --- a/src/components/content-presentation/notification-banner/components/NotificationBannerHeading.tsx +++ b/src/components/content-presentation/notification-banner/components/NotificationBannerHeading.tsx @@ -1,4 +1,4 @@ -import { HeadingLevel, type HeadingLevelProps } from '#components'; +import { HeadingLevel, type HeadingLevelProps } from '#components/utils/HeadingLevel.js'; import type { FC } from 'react'; export type NotificationBannerHeadingProps = HeadingLevelProps; From 737c284249c7e78d6f76608e883fa8be9860c6c3 Mon Sep 17 00:00:00 2001 From: Rob Kerry Date: Fri, 10 Oct 2025 11:56:52 +0100 Subject: [PATCH 14/24] Address review comments --- docs/upgrade-to-6.0.md | 4 +- .../NotificationBanner.tsx | 74 ++- .../__tests__/NotificationBanner.test.tsx | 260 ++++++++++ .../NotificationBanner.test.tsx.snap | 463 +++++++++++++++++- .../components/NotificationBannerHeading.tsx | 6 +- .../components/NotificationBannerLink.tsx | 6 +- .../components/NotificationBannerTitle.tsx | 27 + .../notification-banner/components/index.ts | 1 + .../NotificationBanner.stories.tsx | 69 ++- 9 files changed, 860 insertions(+), 50 deletions(-) create mode 100644 src/components/content-presentation/notification-banner/components/NotificationBannerTitle.tsx diff --git a/docs/upgrade-to-6.0.md b/docs/upgrade-to-6.0.md index dba1425c..381e5107 100644 --- a/docs/upgrade-to-6.0.md +++ b/docs/upgrade-to-6.0.md @@ -36,7 +36,7 @@ This replaces the [list panel component](#list-panel) which was removed in NHS.U The [notification banner](https://service-manual.nhs.uk/design-system/components/notification-banner) component from NHS.UK frontend v10 has been added: ```jsx - + Upcoming Maintenance

    The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025.

    @@ -451,7 +451,7 @@ You must rename the `Select` prop `selectRef` to `ref` for consistency with othe To align with NHS.UK frontend, the skip link component focuses the main content rather than the first heading on the page: ```html -
    +
    ``` diff --git a/src/components/content-presentation/notification-banner/NotificationBanner.tsx b/src/components/content-presentation/notification-banner/NotificationBanner.tsx index 5e4d0e1d..c02df791 100644 --- a/src/components/content-presentation/notification-banner/NotificationBanner.tsx +++ b/src/components/content-presentation/notification-banner/NotificationBanner.tsx @@ -1,37 +1,74 @@ -import { type ComponentPropsWithoutRef, forwardRef } from 'react'; +import { + Children, + type ComponentPropsWithoutRef, + createRef, + forwardRef, + useEffect, + useState, +} from 'react'; import classNames from 'classnames'; -import { HeadingLevel } from '#components/utils/HeadingLevel.js'; -import { NotificationBannerHeading, NotificationBannerLink } from './components/index.js'; +import { + NotificationBannerHeading, + NotificationBannerLink, + NotificationBannerTitle, +} from './components/index.js'; +import { type NotificationBanner as NotificationBannerModule } from 'nhsuk-frontend'; +import { childIsOfComponentType } from '#util/types/TypeGuards.js'; export interface NotificationBannerProps extends ComponentPropsWithoutRef<'div'> { success?: boolean; + disableAutoFocus?: boolean; } const NotificationBannerComponent = forwardRef( - ({ children, className, title, success, ...rest }, forwardedRef) => { + ({ children, className, title, success, role, 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(({ NotificationBanner }) => { + setInstance(new NotificationBanner($root)); + }); + }, [moduleRef, instance]); + + const items = Children.toArray(children); + const titleElement = items.find((child) => + childIsOfComponentType(child, NotificationBannerTitle), + ) || {title}; + const nonTitleItems = items.filter( + (child) => !childIsOfComponentType(child, NotificationBannerTitle), + ); + const headerElement = nonTitleItems.find((child) => + childIsOfComponentType(child, NotificationBannerHeading), + ); + const bodyItems = nonTitleItems.filter( + (child) => !childIsOfComponentType(child, NotificationBannerHeading), + ); return ( -
    -
    - - {title || (success ? 'Success' : 'Important')} - -
    -
    - {children} + {titleElement} +
    + {headerElement} + {bodyItems}
    -
    +
    ); }, ); @@ -39,6 +76,7 @@ const NotificationBannerComponent = forwardRef { it('matches snapshot', async () => { @@ -14,6 +16,7 @@ describe('NotificationBanner', () => { expect(container).toMatchSnapshot(); }); + it('matches snapshot success', async () => { const { container } = await renderClient( @@ -24,6 +27,7 @@ describe('NotificationBanner', () => { expect(container).toMatchSnapshot(); }); + it('matches snapshot custom title', async () => { const { container } = await renderClient( @@ -36,6 +40,23 @@ describe('NotificationBanner', () => { expect(container).toMatchSnapshot(); }); + + it('matches snapshot custom title html', async () => { + const { container } = await renderClient( + + + Very important information + + + The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025. + + , + { className: 'nhsuk-notification-banner' }, + ); + + expect(container).toMatchSnapshot(); + }); + it('matches snapshot with link in heading', async () => { const { container } = await renderClient( @@ -49,6 +70,7 @@ describe('NotificationBanner', () => { expect(container).toMatchSnapshot(); }); + it('matches snapshot with link in body', async () => { const { container } = await renderClient( @@ -65,6 +87,87 @@ describe('NotificationBanner', () => { expect(container).toMatchSnapshot(); }); + it('matches snapshot with no sub elements', async () => { + const { container } = await renderClient( + + The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025. + , + { className: 'nhsuk-notification-banner' }, + ); + + expect(container).toMatchSnapshot(); + }); + + it('matches snapshot with list', async () => { + const { container } = await renderClient( + + 4 files uploaded +
      +
    • + government-strategy.pdf +
    • +
    • + government-strategy-v2.pdf +
    • +
    • + government-strategy-v3-FINAL.pdf +
    • +
    • + government-strategy-v4-FINAL-v2.pdf +
    • +
    +
    , + { className: 'nhsuk-notification-banner' }, + ); + + expect(container).toMatchSnapshot(); + }); + + it('matches snapshot with long heading', async () => { + const { container } = await renderClient( + + + The patient record was withdrawn on 7 March 2014, before being sent in, sent back, + queried, lost, found, subjected to public inquiry, lost again, and finally buried in soft + peat for three months and recycled as firelighters. + + , + { className: 'nhsuk-notification-banner' }, + ); + + expect(container).toMatchSnapshot(); + }); + + it('matches snapshot with lots of content', async () => { + const { container } = await renderClient( + + + Check if you need to apply the reverse charge to this application + +

    + You will have to apply the{' '} + reverse charge if the applicant + supplies any of these services: +

    +
      +
    • + constructing, altering, repairing, extending, demolishing or dismantling buildings or + structures (whether permanent or not), including offshore installation services +
    • +
    • + constructing, altering, repairing, extending, demolishing of any works forming, or + planned to form, part of the land, including (in particular) walls, roadworks, power + lines, electronic communications equipment, aircraft runways, railways, inland + waterways, docks and harbours +
    • +
    +
    , + { className: 'nhsuk-notification-banner' }, + ); + + expect(container).toMatchSnapshot(); + }); + it('matches snapshot (via server)', async () => { const { container } = await renderServer( @@ -77,6 +180,7 @@ describe('NotificationBanner', () => { expect(container).toMatchSnapshot(); }); + it('matches snapshot success (via server)', async () => { const { container } = await renderServer( @@ -87,6 +191,7 @@ describe('NotificationBanner', () => { expect(container).toMatchSnapshot(); }); + it('matches snapshot custom title (via server)', async () => { const { container } = await renderServer( @@ -99,6 +204,23 @@ describe('NotificationBanner', () => { expect(container).toMatchSnapshot(); }); + + it('matches snapshot custom title html (via server)', async () => { + const { container } = await renderServer( + + + Very important information + + + The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025. + + , + { className: 'nhsuk-notification-banner' }, + ); + + expect(container).toMatchSnapshot(); + }); + it('matches snapshot with link in heading (via server)', async () => { const { container } = await renderServer( @@ -112,6 +234,7 @@ describe('NotificationBanner', () => { expect(container).toMatchSnapshot(); }); + it('matches snapshot with link in body (via server)', async () => { const { container } = await renderServer( @@ -127,4 +250,141 @@ describe('NotificationBanner', () => { expect(container).toMatchSnapshot(); }); + + it('matches snapshot with no sub elements (via server)', async () => { + const { container } = await renderServer( + + The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025. + , + { className: 'nhsuk-notification-banner' }, + ); + + expect(container).toMatchSnapshot(); + }); + + it('matches snapshot with list (via server)', async () => { + const { container } = await renderServer( + + 4 files uploaded +
      +
    • + government-strategy.pdf +
    • +
    • + government-strategy-v2.pdf +
    • +
    • + government-strategy-v3-FINAL.pdf +
    • +
    • + government-strategy-v4-FINAL-v2.pdf +
    • +
    +
    , + { className: 'nhsuk-notification-banner' }, + ); + + expect(container).toMatchSnapshot(); + }); + + it('matches snapshot with long heading (via server)', async () => { + const { container } = await renderServer( + + + The patient record was withdrawn on 7 March 2014, before being sent in, sent back, + queried, lost, found, subjected to public inquiry, lost again, and finally buried in soft + peat for three months and recycled as firelighters. + + , + { className: 'nhsuk-notification-banner' }, + ); + + expect(container).toMatchSnapshot(); + }); + + it('matches snapshot with lots of content (via server)', async () => { + const { container } = await renderServer( + + + Check if you need to apply the reverse charge to this application + +

    + You will have to apply the{' '} + reverse charge if the applicant + supplies any of these services: +

    +
      +
    • + constructing, altering, repairing, extending, demolishing or dismantling buildings or + structures (whether permanent or not), including offshore installation services +
    • +
    • + constructing, altering, repairing, extending, demolishing of any works forming, or + planned to form, part of the land, including (in particular) walls, roadworks, power + lines, electronic communications equipment, aircraft runways, railways, inland + waterways, docks and harbours +
    • +
    +
    , + { className: 'nhsuk-notification-banner' }, + ); + + expect(container).toMatchSnapshot(); + }); + + it('forwards refs', async () => { + const ref = createRef(); + + const { modules } = await renderClient( + + The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025. + , + { className: 'nhsuk-notification-banner' }, + ); + + const [notificationBannerEl] = modules; + + expect(ref.current).toBe(notificationBannerEl); + expect(ref.current).toHaveClass('nhsuk-notification-banner'); + }); + + it('has default role and autofocus', async () => { + const { modules } = await renderClient( + + The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025. + , + { className: 'nhsuk-notification-banner' }, + ); + + const [notificationBannerEl] = modules; + + expect(notificationBannerEl?.getAttribute('role')).toBe('region'); + expect(notificationBannerEl?.getAttribute('data-disable-auto-focus')).toBe(null); + }); + + it('has alert role', async () => { + const { modules } = await renderClient( + + The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025. + , + { className: 'nhsuk-notification-banner' }, + ); + + const [notificationBannerEl] = modules; + + expect(notificationBannerEl?.getAttribute('role')).toBe('alert'); + }); + + it('has disabled autofocus', async () => { + const { modules } = await renderClient( + + The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025. + , + { className: 'nhsuk-notification-banner' }, + ); + + const [notificationBannerEl] = modules; + + expect(notificationBannerEl?.getAttribute('data-disable-auto-focus')).toBe('true'); + }); }); diff --git a/src/components/content-presentation/notification-banner/__tests__/__snapshots__/NotificationBanner.test.tsx.snap b/src/components/content-presentation/notification-banner/__tests__/__snapshots__/NotificationBanner.test.tsx.snap index 336146b5..28577703 100644 --- a/src/components/content-presentation/notification-banner/__tests__/__snapshots__/NotificationBanner.test.tsx.snap +++ b/src/components/content-presentation/notification-banner/__tests__/__snapshots__/NotificationBanner.test.tsx.snap @@ -2,10 +2,11 @@ exports[`NotificationBanner matches snapshot (via server) 1`] = `
    -
    -
    +
    `; exports[`NotificationBanner matches snapshot 1`] = `
    -
    -
    +
    `; exports[`NotificationBanner matches snapshot custom title (via server) 1`] = `
    -
    -
    +
  • `; exports[`NotificationBanner matches snapshot custom title 1`] = `
    -
    -
    +
    +
    +`; + +exports[`NotificationBanner matches snapshot custom title html (via server) 1`] = ` +
    +
    +
    +

    + + Very + + important information +

    +
    +
    +

    + The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025. +

    +
    +
    +
    +`; + +exports[`NotificationBanner matches snapshot custom title html 1`] = ` +
    +
    +
    +

    + + Very + + important information +

    +
    +
    +

    + The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025. +

    +
    +
    `; exports[`NotificationBanner matches snapshot success (via server) 1`] = `
    - +
    `; exports[`NotificationBanner matches snapshot success 1`] = `
    - +
    `; exports[`NotificationBanner matches snapshot with link in body (via server) 1`] = `
    -
    -
    +
    `; exports[`NotificationBanner matches snapshot with link in body 1`] = `
    -
    -
    +
    `; exports[`NotificationBanner matches snapshot with link in heading (via server) 1`] = `
    -
    -
    +
    `; exports[`NotificationBanner matches snapshot with link in heading 1`] = `
    -
    -
    +
    + +`; + +exports[`NotificationBanner matches snapshot with list (via server) 1`] = ` + +`; + +exports[`NotificationBanner matches snapshot with list 1`] = ` + +`; + +exports[`NotificationBanner matches snapshot with long heading (via server) 1`] = ` +
    +
    +
    +

    + Important +

    +
    +
    +

    + The patient record was withdrawn on 7 March 2014, before being sent in, sent back, queried, lost, found, subjected to public inquiry, lost again, and finally buried in soft peat for three months and recycled as firelighters. +

    +
    +
    +
    +`; + +exports[`NotificationBanner matches snapshot with long heading 1`] = ` +
    +
    +
    +

    + Important +

    +
    +
    +

    + The patient record was withdrawn on 7 March 2014, before being sent in, sent back, queried, lost, found, subjected to public inquiry, lost again, and finally buried in soft peat for three months and recycled as firelighters. +

    +
    +
    +
    +`; + +exports[`NotificationBanner matches snapshot with lots of content (via server) 1`] = ` +
    +
    +
    +

    + Important +

    +
    +
    +

    + Check if you need to apply the reverse charge to this application +

    +

    + You will have to apply the + + reverse charge + + if the applicant supplies any of these services: +

    +
      +
    • + constructing, altering, repairing, extending, demolishing or dismantling buildings or structures (whether permanent or not), including offshore installation services +
    • +
    • + constructing, altering, repairing, extending, demolishing of any works forming, or planned to form, part of the land, including (in particular) walls, roadworks, power lines, electronic communications equipment, aircraft runways, railways, inland waterways, docks and harbours +
    • +
    +
    +
    +
    +`; + +exports[`NotificationBanner matches snapshot with lots of content 1`] = ` +
    +
    +
    +

    + Important +

    +
    +
    +

    + Check if you need to apply the reverse charge to this application +

    +

    + You will have to apply the + + reverse charge + + if the applicant supplies any of these services: +

    +
      +
    • + constructing, altering, repairing, extending, demolishing or dismantling buildings or structures (whether permanent or not), including offshore installation services +
    • +
    • + constructing, altering, repairing, extending, demolishing of any works forming, or planned to form, part of the land, including (in particular) walls, roadworks, power lines, electronic communications equipment, aircraft runways, railways, inland waterways, docks and harbours +
    • +
    +
    +
    +
    +`; + +exports[`NotificationBanner matches snapshot with no sub elements (via server) 1`] = ` +
    +
    +
    +

    + Important +

    +
    +
    + The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025. +
    +
    +
    +`; + +exports[`NotificationBanner matches snapshot with no sub elements 1`] = ` +
    +
    +
    +

    + Important +

    +
    +
    + The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025. +
    +
    `; diff --git a/src/components/content-presentation/notification-banner/components/NotificationBannerHeading.tsx b/src/components/content-presentation/notification-banner/components/NotificationBannerHeading.tsx index 4adf4704..5b0bd4fe 100644 --- a/src/components/content-presentation/notification-banner/components/NotificationBannerHeading.tsx +++ b/src/components/content-presentation/notification-banner/components/NotificationBannerHeading.tsx @@ -3,7 +3,7 @@ import type { FC } from 'react'; export type NotificationBannerHeadingProps = HeadingLevelProps; -const NotificationBannerHeadingComponent: FC = ({ +export const NotificationBannerHeading: FC = ({ children, headingLevel = 'h3', ...rest @@ -17,6 +17,4 @@ const NotificationBannerHeadingComponent: FC = ( ); -NotificationBannerHeadingComponent.displayName = 'NotificationBanner.Heading'; - -export const NotificationBannerHeading = Object.assign(NotificationBannerHeadingComponent); +NotificationBannerHeading.displayName = 'NotificationBanner.Heading'; diff --git a/src/components/content-presentation/notification-banner/components/NotificationBannerLink.tsx b/src/components/content-presentation/notification-banner/components/NotificationBannerLink.tsx index 4b0873a1..bc145aac 100644 --- a/src/components/content-presentation/notification-banner/components/NotificationBannerLink.tsx +++ b/src/components/content-presentation/notification-banner/components/NotificationBannerLink.tsx @@ -4,7 +4,7 @@ import classNames from 'classnames'; export type NotificationBannerLinkProps = AsElementLink; -const NotificationBannerLinkComponent = forwardRef( +export const NotificationBannerLink = forwardRef( ({ children, className, asElement: Element = 'a', ...rest }, forwardedRef) => ( = ({ + children, + headingLevel = 'h2', + id = 'nhsuk-notification-banner-title', + success, + ...rest +}) => ( +
    + + {children || (success ? 'Success' : 'Important')} + +
    +); + +NotificationBannerTitle.displayName = 'NotificationBanner.Title'; diff --git a/src/components/content-presentation/notification-banner/components/index.ts b/src/components/content-presentation/notification-banner/components/index.ts index a2edb989..e9dad42b 100644 --- a/src/components/content-presentation/notification-banner/components/index.ts +++ b/src/components/content-presentation/notification-banner/components/index.ts @@ -1,2 +1,3 @@ export * from './NotificationBannerHeading.js'; export * from './NotificationBannerLink.js'; +export * from './NotificationBannerTitle.js'; diff --git a/stories/Content Presentation/NotificationBanner.stories.tsx b/stories/Content Presentation/NotificationBanner.stories.tsx index 91ce6d79..19165454 100644 --- a/stories/Content Presentation/NotificationBanner.stories.tsx +++ b/stories/Content Presentation/NotificationBanner.stories.tsx @@ -1,13 +1,15 @@ import { type Meta, type StoryObj } from '@storybook/react-vite'; import { NotificationBanner } from '#components'; +import { NotificationBannerLink } from '#components/content-presentation/notification-banner/components'; /** * This component can be found in the `nhsuk-frontend` repository here. * * ## Implementation Notes * - * The `NotificationBanner` component has two subcomponents: + * The `NotificationBanner` component has three subcomponents: * + * - `NotificationBanner.Title` * - `NotificationBanner.Heading` * - `NotificationBanner.Link` * @@ -51,7 +53,7 @@ export const StandardPanel: Story = { export const SuccessPanel: Story = { args: {}, render: (args) => ( - + Patient record updated

    Contact{' '} @@ -85,3 +87,66 @@ export const StandardPanelWithCustomTitle: Story = { ), }; + +export const StandardPanelWithCustomTitleElement: Story = { + args: {}, + render: (args) => ( + + + Very Important Message + + Upcoming Maintenance +

    The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025.

    +
    + ), +}; + +export const StandardPanelWithList: Story = { + args: {}, + render: (args) => ( + + 4 files uploaded +
      +
    • + government-strategy.pdf +
    • +
    • + government-strategy-v2.pdf +
    • +
    • + government-strategy-v3-FINAL.pdf +
    • +
    • + government-strategy-v4-FINAL-v2.pdf +
    • +
    +
    + ), +}; + +export const StandardPanelWithLotsOfContent: Story = { + args: {}, + render: (args) => ( + + + Check if you need to apply the reverse charge to this application + +

    + You will have to apply the reverse charge{' '} + if the applicant supplies any of these services: +

    +
      +
    • + constructing, altering, repairing, extending, demolishing or dismantling buildings or + structures (whether permanent or not), including offshore installation services +
    • +
    • + constructing, altering, repairing, extending, demolishing of any works forming, or planned + to form, part of the land, including (in particular) walls, roadworks, power lines, + electronic communications equipment, aircraft runways, railways, inland waterways, docks + and harbours +
    • +
    +
    + ), +}; From 5c38de7ea895191991c2c2ff9b123f9afe12b57a Mon Sep 17 00:00:00 2001 From: Rob Kerry Date: Fri, 10 Oct 2025 12:05:01 +0100 Subject: [PATCH 15/24] Address review comments (sonar) --- docs/upgrade-to-6.0.md | 1 + .../__tests__/NotificationBanner.test.tsx | 12 ++++++------ .../NotificationBanner.test.tsx.snap | 15 +++++++++------ 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/docs/upgrade-to-6.0.md b/docs/upgrade-to-6.0.md index 381e5107..2155f519 100644 --- a/docs/upgrade-to-6.0.md +++ b/docs/upgrade-to-6.0.md @@ -453,6 +453,7 @@ To align with NHS.UK frontend, the skip link component focuses the main content ```html
    +
    ``` For accessibility reasons, you must make the following changes: diff --git a/src/components/content-presentation/notification-banner/__tests__/NotificationBanner.test.tsx b/src/components/content-presentation/notification-banner/__tests__/NotificationBanner.test.tsx index bd1d8289..115d6e4b 100644 --- a/src/components/content-presentation/notification-banner/__tests__/NotificationBanner.test.tsx +++ b/src/components/content-presentation/notification-banner/__tests__/NotificationBanner.test.tsx @@ -268,16 +268,16 @@ describe('NotificationBanner', () => { 4 files uploaded
    • - government-strategy.pdf + file.pdf
    • - government-strategy-v2.pdf + file-v2.pdf
    • - government-strategy-v3-FINAL.pdf + file-v3-FINAL.pdf
    • - government-strategy-v4-FINAL-v2.pdf + file-v4-FINAL-v2.pdf
    , @@ -359,7 +359,7 @@ describe('NotificationBanner', () => { const [notificationBannerEl] = modules; expect(notificationBannerEl?.getAttribute('role')).toBe('region'); - expect(notificationBannerEl?.getAttribute('data-disable-auto-focus')).toBe(null); + expect(notificationBannerEl?.dataset?.disableAutoFocus).toBe(undefined); }); it('has alert role', async () => { @@ -385,6 +385,6 @@ describe('NotificationBanner', () => { const [notificationBannerEl] = modules; - expect(notificationBannerEl?.getAttribute('data-disable-auto-focus')).toBe('true'); + expect(notificationBannerEl?.dataset?.disableAutoFocus).toBe('true'); }); }); diff --git a/src/components/content-presentation/notification-banner/__tests__/__snapshots__/NotificationBanner.test.tsx.snap b/src/components/content-presentation/notification-banner/__tests__/__snapshots__/NotificationBanner.test.tsx.snap index 28577703..8b1982fc 100644 --- a/src/components/content-presentation/notification-banner/__tests__/__snapshots__/NotificationBanner.test.tsx.snap +++ b/src/components/content-presentation/notification-banner/__tests__/__snapshots__/NotificationBanner.test.tsx.snap @@ -456,28 +456,28 @@ exports[`NotificationBanner matches snapshot with list (via server) 1`] = ` - government-strategy.pdf + file.pdf
  • - government-strategy-v2.pdf + file-v2.pdf
  • - government-strategy-v3-FINAL.pdf + file-v3-FINAL.pdf
  • - government-strategy-v4-FINAL-v2.pdf + file-v4-FINAL-v2.pdf
  • @@ -638,7 +638,9 @@ exports[`NotificationBanner matches snapshot with lots of content (via server) 1 Check if you need to apply the reverse charge to this application

    - You will have to apply the + You will have to apply the + + @@ -687,7 +689,8 @@ exports[`NotificationBanner matches snapshot with lots of content 1`] = ` Check if you need to apply the reverse charge to this application

    - You will have to apply the + You will have to apply the + From 480887773ba803e24f6bcd66682458055f12cc6b Mon Sep 17 00:00:00 2001 From: Colin Rotherham Date: Mon, 13 Oct 2025 15:02:04 +0100 Subject: [PATCH 16/24] Move notification banner header bar to main component --- .../notification-banner/NotificationBanner.tsx | 12 +++++++++--- .../components/NotificationBannerTitle.tsx | 18 ++++++++---------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/components/content-presentation/notification-banner/NotificationBanner.tsx b/src/components/content-presentation/notification-banner/NotificationBanner.tsx index c02df791..a2384d05 100644 --- a/src/components/content-presentation/notification-banner/NotificationBanner.tsx +++ b/src/components/content-presentation/notification-banner/NotificationBanner.tsx @@ -40,7 +40,7 @@ const NotificationBannerComponent = forwardRef childIsOfComponentType(child, NotificationBannerTitle), - ) || {title}; + ); const nonTitleItems = items.filter( (child) => !childIsOfComponentType(child, NotificationBannerTitle), ); @@ -57,13 +57,19 @@ const NotificationBannerComponent = forwardRef - {titleElement} +

    {headerElement} {bodyItems} diff --git a/src/components/content-presentation/notification-banner/components/NotificationBannerTitle.tsx b/src/components/content-presentation/notification-banner/components/NotificationBannerTitle.tsx index c596e46d..7cf033d5 100644 --- a/src/components/content-presentation/notification-banner/components/NotificationBannerTitle.tsx +++ b/src/components/content-presentation/notification-banner/components/NotificationBannerTitle.tsx @@ -12,16 +12,14 @@ export const NotificationBannerTitle: FC = ({ success, ...rest }) => ( -
    - - {children || (success ? 'Success' : 'Important')} - -
    + + {children || (success ? 'Success' : 'Important')} + ); NotificationBannerTitle.displayName = 'NotificationBanner.Title'; From dcbd029517877deea548813400e3d81892aa8c2b Mon Sep 17 00:00:00 2001 From: Colin Rotherham Date: Mon, 13 Oct 2025 15:10:04 +0100 Subject: [PATCH 17/24] Export notification banner child components --- src/__tests__/index.test.ts | 3 +++ .../notification-banner/NotificationBanner.tsx | 4 +++- .../content-presentation/notification-banner/index.ts | 1 + 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index d6e3badd..5eab14f0 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -92,6 +92,9 @@ describe('Index', () => { 'NavAZDisabledItem', 'NavAZLinkItem', 'NotificationBanner', + 'NotificationBannerHeading', + 'NotificationBannerLink', + 'NotificationBannerTitle', 'Pagination', 'PaginationLink', 'Panel', diff --git a/src/components/content-presentation/notification-banner/NotificationBanner.tsx b/src/components/content-presentation/notification-banner/NotificationBanner.tsx index a2384d05..1936eb2e 100644 --- a/src/components/content-presentation/notification-banner/NotificationBanner.tsx +++ b/src/components/content-presentation/notification-banner/NotificationBanner.tsx @@ -1,10 +1,12 @@ +'use client'; + import { Children, - type ComponentPropsWithoutRef, createRef, forwardRef, useEffect, useState, + type ComponentPropsWithoutRef, } from 'react'; import classNames from 'classnames'; import { diff --git a/src/components/content-presentation/notification-banner/index.ts b/src/components/content-presentation/notification-banner/index.ts index 9da940f0..4dfa21ab 100644 --- a/src/components/content-presentation/notification-banner/index.ts +++ b/src/components/content-presentation/notification-banner/index.ts @@ -1 +1,2 @@ +export * from './components/index.js'; export * from './NotificationBanner.js'; From 8f123833147d05dee4960bf084b39451d81c2332 Mon Sep 17 00:00:00 2001 From: Colin Rotherham Date: Mon, 13 Oct 2025 15:03:29 +0100 Subject: [PATCH 18/24] Allow NHS.UK frontend errors to be caught --- .../notification-banner/NotificationBanner.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/components/content-presentation/notification-banner/NotificationBanner.tsx b/src/components/content-presentation/notification-banner/NotificationBanner.tsx index 1936eb2e..d1149826 100644 --- a/src/components/content-presentation/notification-banner/NotificationBanner.tsx +++ b/src/components/content-presentation/notification-banner/NotificationBanner.tsx @@ -25,6 +25,7 @@ export interface NotificationBannerProps extends ComponentPropsWithoutRef<'div'> const NotificationBannerComponent = forwardRef( ({ children, className, title, success, role, disableAutoFocus, ...rest }, forwardedRef) => { const [moduleRef] = useState(() => forwardedRef || createRef()); + const [instanceError, setInstanceError] = useState(); const [instance, setInstance] = useState(); useEffect(() => { @@ -32,11 +33,9 @@ const NotificationBannerComponent = forwardRef { - setInstance(new NotificationBanner($root)); - }); + import('nhsuk-frontend') + .then(({ NotificationBanner }) => setInstance(new NotificationBanner(moduleRef.current))) + .catch(setInstanceError); }, [moduleRef, instance]); const items = Children.toArray(children); @@ -52,6 +51,11 @@ const NotificationBannerComponent = forwardRef !childIsOfComponentType(child, NotificationBannerHeading), ); + + if (instanceError) { + throw instanceError; + } + return (
    Date: Mon, 13 Oct 2025 15:05:21 +0100 Subject: [PATCH 19/24] Add fallback `className` to component guards --- .../NotificationBanner.tsx | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/components/content-presentation/notification-banner/NotificationBanner.tsx b/src/components/content-presentation/notification-banner/NotificationBanner.tsx index d1149826..0247da7f 100644 --- a/src/components/content-presentation/notification-banner/NotificationBanner.tsx +++ b/src/components/content-presentation/notification-banner/NotificationBanner.tsx @@ -39,19 +39,21 @@ const NotificationBannerComponent = forwardRef - childIsOfComponentType(child, NotificationBannerTitle), - ); - const nonTitleItems = items.filter( - (child) => !childIsOfComponentType(child, NotificationBannerTitle), - ); - const headerElement = nonTitleItems.find((child) => - childIsOfComponentType(child, NotificationBannerHeading), + childIsOfComponentType(child, NotificationBannerTitle, { + className: 'nhsuk-notification-banner__title', + }), ); - const bodyItems = nonTitleItems.filter( - (child) => !childIsOfComponentType(child, NotificationBannerHeading), + + const headerElement = items.find((child) => + childIsOfComponentType(child, NotificationBannerHeading, { + className: 'nhsuk-notification-banner__heading', + }), ); + const bodyItems = items.filter((child) => child !== titleElement && child !== headerElement); + if (instanceError) { throw instanceError; } From 1c43ecdcf9f95320dffaa792e91dc54b6d901d2e Mon Sep 17 00:00:00 2001 From: Colin Rotherham Date: Mon, 13 Oct 2025 15:12:48 +0100 Subject: [PATCH 20/24] Reduce component filters --- .../notification-banner/NotificationBanner.tsx | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/components/content-presentation/notification-banner/NotificationBanner.tsx b/src/components/content-presentation/notification-banner/NotificationBanner.tsx index 0247da7f..2e673aab 100644 --- a/src/components/content-presentation/notification-banner/NotificationBanner.tsx +++ b/src/components/content-presentation/notification-banner/NotificationBanner.tsx @@ -46,13 +46,7 @@ const NotificationBannerComponent = forwardRef - childIsOfComponentType(child, NotificationBannerHeading, { - className: 'nhsuk-notification-banner__heading', - }), - ); - - const bodyItems = items.filter((child) => child !== titleElement && child !== headerElement); + const contentItems = items.filter((child) => child !== titleElement); if (instanceError) { throw instanceError; @@ -79,8 +73,7 @@ const NotificationBannerComponent = forwardRef
    - {headerElement} - {bodyItems} + {contentItems}
    ); From 6ebc62801e5e53e6046b86b3201ffaea1e2a4162 Mon Sep 17 00:00:00 2001 From: Rob Kerry Date: Mon, 13 Oct 2025 15:43:02 +0100 Subject: [PATCH 21/24] Address further review comments --- docs/upgrade-to-6.0.md | 2 +- .../NotificationBanner.tsx | 12 +++-- .../__tests__/NotificationBanner.test.tsx | 20 ++++---- .../NotificationBanner.test.tsx.snap | 6 ++- .../components/NotificationBannerHeading.tsx | 2 + .../NotificationBanner.stories.tsx | 49 ++++++++++--------- 6 files changed, 51 insertions(+), 40 deletions(-) diff --git a/docs/upgrade-to-6.0.md b/docs/upgrade-to-6.0.md index 2155f519..c4d16572 100644 --- a/docs/upgrade-to-6.0.md +++ b/docs/upgrade-to-6.0.md @@ -37,7 +37,7 @@ The [notification banner](https://service-manual.nhs.uk/design-system/components ```jsx - Upcoming Maintenance + Upcoming maintenance

    The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025.

    ``` diff --git a/src/components/content-presentation/notification-banner/NotificationBanner.tsx b/src/components/content-presentation/notification-banner/NotificationBanner.tsx index 2e673aab..508b538b 100644 --- a/src/components/content-presentation/notification-banner/NotificationBanner.tsx +++ b/src/components/content-presentation/notification-banner/NotificationBanner.tsx @@ -20,10 +20,14 @@ import { childIsOfComponentType } from '#util/types/TypeGuards.js'; export interface NotificationBannerProps extends ComponentPropsWithoutRef<'div'> { success?: boolean; disableAutoFocus?: boolean; + titleId?: string; } const NotificationBannerComponent = forwardRef( - ({ children, className, title, success, role, disableAutoFocus, ...rest }, forwardedRef) => { + ( + { children, className, title, titleId, success, role, disableAutoFocus, ...rest }, + forwardedRef, + ) => { const [moduleRef] = useState(() => forwardedRef || createRef()); const [instanceError, setInstanceError] = useState(); const [instance, setInstance] = useState(); @@ -59,7 +63,7 @@ const NotificationBannerComponent = forwardRef{titleElement} ) : ( - {title} + + {title} + )}
    diff --git a/src/components/content-presentation/notification-banner/__tests__/NotificationBanner.test.tsx b/src/components/content-presentation/notification-banner/__tests__/NotificationBanner.test.tsx index 115d6e4b..0be95909 100644 --- a/src/components/content-presentation/notification-banner/__tests__/NotificationBanner.test.tsx +++ b/src/components/content-presentation/notification-banner/__tests__/NotificationBanner.test.tsx @@ -30,7 +30,7 @@ describe('NotificationBanner', () => { it('matches snapshot custom title', async () => { const { container } = await renderClient( - + The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025. @@ -62,7 +62,7 @@ describe('NotificationBanner', () => { You have 7 days left to send your application.{' '} - View application. + View application. , { className: 'nhsuk-notification-banner' }, @@ -77,7 +77,7 @@ describe('NotificationBanner', () => { Patient record updated

    Contact{' '} - example@department.nhs.uk if + example@department.nhs.uk if you think there's a problem.

    , @@ -146,8 +146,8 @@ describe('NotificationBanner', () => {

    You will have to apply the{' '} - reverse charge if the applicant - supplies any of these services: + reverse charge if the + applicant supplies any of these services:

    • @@ -194,7 +194,7 @@ describe('NotificationBanner', () => { it('matches snapshot custom title (via server)', async () => { const { container } = await renderServer( - + The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025. @@ -226,7 +226,7 @@ describe('NotificationBanner', () => { You have 7 days left to send your application.{' '} - View application. + View application. , { className: 'nhsuk-notification-banner' }, @@ -241,7 +241,7 @@ describe('NotificationBanner', () => { Patient record updated

      Contact{' '} - example@department.nhs.uk if + example@department.nhs.uk if you think there's a problem.

      , @@ -310,8 +310,8 @@ describe('NotificationBanner', () => {

      You will have to apply the{' '} - reverse charge if the applicant - supplies any of these services: + reverse charge if the + applicant supplies any of these services:

      • diff --git a/src/components/content-presentation/notification-banner/__tests__/__snapshots__/NotificationBanner.test.tsx.snap b/src/components/content-presentation/notification-banner/__tests__/__snapshots__/NotificationBanner.test.tsx.snap index 8b1982fc..2040b917 100644 --- a/src/components/content-presentation/notification-banner/__tests__/__snapshots__/NotificationBanner.test.tsx.snap +++ b/src/components/content-presentation/notification-banner/__tests__/__snapshots__/NotificationBanner.test.tsx.snap @@ -78,7 +78,7 @@ exports[`NotificationBanner matches snapshot custom title (via server) 1`] = ` class="nhsuk-notification-banner__title" id="nhsuk-notification-banner-title" > - Upcoming Maintenance + Upcoming maintenance
    - Upcoming Maintenance + Upcoming maintenance
    ); }, From 7eb6bf4403a0499530a901142f14c6477e86a261 Mon Sep 17 00:00:00 2001 From: Rob Kerry Date: Mon, 13 Oct 2025 16:46:11 +0100 Subject: [PATCH 23/24] Address further review comments --- .../notification-banner/NotificationBanner.tsx | 5 +++-- .../__tests__/NotificationBanner.test.tsx | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/components/content-presentation/notification-banner/NotificationBanner.tsx b/src/components/content-presentation/notification-banner/NotificationBanner.tsx index a70c3dbe..0da757f6 100644 --- a/src/components/content-presentation/notification-banner/NotificationBanner.tsx +++ b/src/components/content-presentation/notification-banner/NotificationBanner.tsx @@ -47,6 +47,7 @@ const NotificationBannerComponent = forwardRef child !== titleElement); @@ -61,7 +62,7 @@ const NotificationBannerComponent = forwardRef{titleElement} ) : ( - + {title} )} diff --git a/src/components/content-presentation/notification-banner/__tests__/NotificationBanner.test.tsx b/src/components/content-presentation/notification-banner/__tests__/NotificationBanner.test.tsx index 0be95909..1f984ab8 100644 --- a/src/components/content-presentation/notification-banner/__tests__/NotificationBanner.test.tsx +++ b/src/components/content-presentation/notification-banner/__tests__/NotificationBanner.test.tsx @@ -387,4 +387,20 @@ describe('NotificationBanner', () => { expect(notificationBannerEl?.dataset?.disableAutoFocus).toBe('true'); }); + + it('prioritises id of title element over provided title id', async () => { + const { modules } = await renderClient( + + Important information + + The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025. + + , + { className: 'nhsuk-notification-banner' }, + ); + + const [notificationBannerEl] = modules; + + expect(notificationBannerEl?.getAttribute('aria-labelledby')).toEqual('correct-id'); + }); }); From abc9b8f877bb5768003740e9cf8f951583c7189f Mon Sep 17 00:00:00 2001 From: Colin Rotherham Date: Mon, 13 Oct 2025 16:54:42 +0100 Subject: [PATCH 24/24] Formatting --- .../notification-banner/NotificationBanner.tsx | 6 ++++-- .../__tests__/NotificationBanner.test.tsx | 10 +++++----- .../NotificationBanner.stories.tsx | 2 +- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/components/content-presentation/notification-banner/NotificationBanner.tsx b/src/components/content-presentation/notification-banner/NotificationBanner.tsx index 0da757f6..33eb003f 100644 --- a/src/components/content-presentation/notification-banner/NotificationBanner.tsx +++ b/src/components/content-presentation/notification-banner/NotificationBanner.tsx @@ -26,6 +26,7 @@ export interface NotificationBannerProps extends ComponentPropsWithoutRef<'div'> const NotificationBannerComponent = forwardRef( (props, forwardedRef) => { const { children, className, title, titleId, success, role, disableAutoFocus, ...rest } = props; + const [moduleRef] = useState(() => forwardedRef || createRef()); const [instanceError, setInstanceError] = useState(); const [instance, setInstance] = useState(); @@ -47,6 +48,7 @@ const NotificationBannerComponent = forwardRef child !== titleElement); @@ -73,12 +75,12 @@ const NotificationBannerComponent = forwardRef{titleElement} ) : ( - + {title} )} -
    {contentItems}
    +
    {contentItems}
    ); }, diff --git a/src/components/content-presentation/notification-banner/__tests__/NotificationBanner.test.tsx b/src/components/content-presentation/notification-banner/__tests__/NotificationBanner.test.tsx index 1f984ab8..537c5d08 100644 --- a/src/components/content-presentation/notification-banner/__tests__/NotificationBanner.test.tsx +++ b/src/components/content-presentation/notification-banner/__tests__/NotificationBanner.test.tsx @@ -30,7 +30,7 @@ describe('NotificationBanner', () => { it('matches snapshot custom title', async () => { const { container } = await renderClient( - + The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025. @@ -194,7 +194,7 @@ describe('NotificationBanner', () => { it('matches snapshot custom title (via server)', async () => { const { container } = await renderServer( - + The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025. @@ -208,7 +208,7 @@ describe('NotificationBanner', () => { it('matches snapshot custom title html (via server)', async () => { const { container } = await renderServer( - + Very important information @@ -390,8 +390,8 @@ describe('NotificationBanner', () => { it('prioritises id of title element over provided title id', async () => { const { modules } = await renderClient( - - Important information + + Important information The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025. diff --git a/stories/Content Presentation/NotificationBanner.stories.tsx b/stories/Content Presentation/NotificationBanner.stories.tsx index 8e341b7f..d247ba9d 100644 --- a/stories/Content Presentation/NotificationBanner.stories.tsx +++ b/stories/Content Presentation/NotificationBanner.stories.tsx @@ -93,7 +93,7 @@ export const StandardPanelWithCustomTitleElement: Story = { render: () => ( - Maintenance + Maintenance Upcoming maintenance

    The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025.