diff --git a/.storybook/main.ts b/.storybook/main.ts index 193423c10..cc55d51ae 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,7 +1,7 @@ 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'], @@ -19,13 +19,7 @@ const config: StorybookConfig = { return mergeConfig(config, { build: { rollupOptions: { - onwarn(warning, handler) { - if (isLogIgnored(warning)) { - return; - } - - handler(warning); - }, + plugins: [preserveDirectives()], }, }, css: { diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c08600f7..98f0c8589 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,20 +1,26 @@ # NHS.UK React components +## 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. + +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 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). diff --git a/docs/upgrade-to-6.0.md b/docs/upgrade-to-6.0.md index 8c09dc05b..c4d165720 100644 --- a/docs/upgrade-to-6.0.md +++ b/docs/upgrade-to-6.0.md @@ -31,15 +31,45 @@ 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: + +```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 ` ``` @@ -239,22 +269,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 @@ -417,8 +451,9 @@ 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 -
+
+
``` For accessibility reasons, you must make the following changes: diff --git a/package.json b/package.json index e5c94eefb..42bcb7628 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" @@ -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 3de4a7c56..7a98d9b5f 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,15 +59,12 @@ export default defineConfig( babel({ babelHelpers: 'bundled', exclude: 'node_modules/**', + extensions: [...extensions, '.ts', '.tsx'], }), ], // Handle warnings as errors onwarn(warning) { - if (isLogIgnored(warning)) { - return; - } - throw new Error(warning.message, { cause: warning }); }, }), @@ -74,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' */ diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index f20617293..5eab14f0b 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,48 @@ describe('Index', () => { 'LedeText', 'Legend', 'NavAZ', + 'NavAZDisabledItem', + 'NavAZLinkItem', + 'NotificationBanner', + 'NotificationBannerHeading', + 'NotificationBannerLink', + 'NotificationBannerTitle', '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 +138,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 12c169d64..5fd74d94c 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 7c2a12db4..d8b6502a5 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 000000000..6056faa75 --- /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 000000000..bdf5e35b2 --- /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 000000000..e9bea06fb --- /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 bd8c91926..f4bca1e7b 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 1020ac53c..69a218431 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/index.ts b/src/components/content-presentation/index.ts index 55a38c078..e410fcc99 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 000000000..33eb003ff --- /dev/null +++ b/src/components/content-presentation/notification-banner/NotificationBanner.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { + Children, + createRef, + forwardRef, + useEffect, + useState, + type ComponentPropsWithoutRef, +} from 'react'; +import classNames from 'classnames'; +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; + titleId?: string; +} + +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(); + + useEffect(() => { + if (!('current' in moduleRef) || !moduleRef.current || instance) { + return; + } + + import('nhsuk-frontend') + .then(({ NotificationBanner }) => setInstance(new NotificationBanner(moduleRef.current))) + .catch(setInstanceError); + }, [moduleRef, instance]); + + const items = Children.toArray(children); + + const titleElement = items.find((child) => + childIsOfComponentType(child, NotificationBannerTitle, { + className: 'nhsuk-notification-banner__title', + }), + ); + + const titleElementId = titleElement?.props.id || titleId || 'nhsuk-notification-banner-title'; + + const contentItems = items.filter((child) => child !== titleElement); + + if (instanceError) { + throw instanceError; + } + + return ( +

    +
    + {titleElement ? ( + <>{titleElement} + ) : ( + + {title} + + )} +
    +
    {contentItems}
    +
    + ); + }, +); + +NotificationBannerComponent.displayName = 'NotificationBanner'; + +export const NotificationBanner = Object.assign(NotificationBannerComponent, { + Title: NotificationBannerTitle, + 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 000000000..537c5d082 --- /dev/null +++ b/src/components/content-presentation/notification-banner/__tests__/NotificationBanner.test.tsx @@ -0,0 +1,406 @@ +import { renderClient, renderServer } from '#util/components'; +import { NotificationBanner } from '#components/content-presentation/notification-banner'; +import { createRef } from 'react'; +import { NotificationBannerLink } from '#components/content-presentation/notification-banner/components'; + +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 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( + + + 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 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( + + + 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 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( + + + 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(); + }); + + 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 +
      +
    • + file.pdf +
    • +
    • + file-v2.pdf +
    • +
    • + file-v3-FINAL.pdf +
    • +
    • + file-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?.dataset?.disableAutoFocus).toBe(undefined); + }); + + 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?.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'); + }); +}); 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 000000000..2040b917b --- /dev/null +++ b/src/components/content-presentation/notification-banner/__tests__/__snapshots__/NotificationBanner.test.tsx.snap @@ -0,0 +1,769 @@ +// 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 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`] = ` +
    +
    +
    +

    + 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`] = ` +
    +
    +
    +

    + Important +

    +
    +
    +

    + You have 7 days left to send your application. + + + + View application + + . +

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

    + Important +

    +
    +
    +

    + You have 7 days left to send your application. + + + View application + + . +

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

    + Important +

    +
    + +
    +
    +`; + +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 new file mode 100644 index 000000000..a8a30a3b6 --- /dev/null +++ b/src/components/content-presentation/notification-banner/components/NotificationBannerHeading.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { HeadingLevel, type HeadingLevelProps } from '#components/utils/HeadingLevel.js'; +import type { FC } from 'react'; + +export type NotificationBannerHeadingProps = HeadingLevelProps; + +export const NotificationBannerHeading: FC = ({ + children, + headingLevel = 'h3', + ...rest +}) => ( + + {children} + +); + +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 new file mode 100644 index 000000000..bc145aaca --- /dev/null +++ b/src/components/content-presentation/notification-banner/components/NotificationBannerLink.tsx @@ -0,0 +1,19 @@ +import type { AsElementLink } from '#util/types/index.js'; +import { forwardRef } from 'react'; +import classNames from 'classnames'; + +export type NotificationBannerLinkProps = AsElementLink; + +export const NotificationBannerLink = forwardRef( + ({ children, className, asElement: Element = 'a', ...rest }, forwardedRef) => ( + + {children} + + ), +); + +NotificationBannerLink.displayName = 'NotificationBanner.Link'; diff --git a/src/components/content-presentation/notification-banner/components/NotificationBannerTitle.tsx b/src/components/content-presentation/notification-banner/components/NotificationBannerTitle.tsx new file mode 100644 index 000000000..7cf033d5d --- /dev/null +++ b/src/components/content-presentation/notification-banner/components/NotificationBannerTitle.tsx @@ -0,0 +1,25 @@ +import { HeadingLevel, type HeadingLevelProps } from '#components/utils/HeadingLevel.js'; +import type { FC } from 'react'; + +export interface NotificationBannerTitleProps extends HeadingLevelProps { + success?: boolean; +} + +export const NotificationBannerTitle: FC = ({ + 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 new file mode 100644 index 000000000..e9dad42b2 --- /dev/null +++ b/src/components/content-presentation/notification-banner/components/index.ts @@ -0,0 +1,3 @@ +export * from './NotificationBannerHeading.js'; +export * from './NotificationBannerLink.js'; +export * from './NotificationBannerTitle.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 000000000..4dfa21abd --- /dev/null +++ b/src/components/content-presentation/notification-banner/index.ts @@ -0,0 +1,2 @@ +export * from './components/index.js'; +export * from './NotificationBanner.js'; diff --git a/src/components/content-presentation/panel/Panel.tsx b/src/components/content-presentation/panel/Panel.tsx index ce2d1f352..0bc49de55 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'>; @@ -17,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 (
    @@ -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 000000000..a24263eb1 --- /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 000000000..89bd345c2 --- /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 81c82d413..ab24f7a8b 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 97e2e6787..c2cd6e701 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/components/TableBody.tsx b/src/components/content-presentation/table/components/TableBody.tsx index 606465c9c..ae16155da 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/content-presentation/table/index.ts b/src/components/content-presentation/table/index.ts index f90a825e5..ebef4ffdd 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 9fbcaf56a..131274e1d 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}
    @@ -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, 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 552b6937a..597a1cdad 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 80b238bf6..c3db83ea4 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/button/Button.tsx b/src/components/form-elements/button/Button.tsx index 56d07bbec..39a20ae67 100644 --- a/src/components/form-elements/button/Button.tsx +++ b/src/components/form-elements/button/Button.tsx @@ -46,6 +46,7 @@ const ButtonComponent = forwardRef((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 c83c98061..1e17e60c9 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/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 7cf06e33c..9021f5f71 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 2f41fba07..6bf4750e1 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 aa81dc25a..49db9b539 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 03f936271..e523eb67c 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 176436d67..2d71e2c01 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 18eb91bb6..eab8a2367 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 8d099bb3d..78fbf7b8a 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 0ba53b5d5..1af81d44d 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; @@ -67,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(() => { @@ -74,16 +28,22 @@ 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, Title)); - const bodyItems = items.filter((child) => !childIsOfComponentType(child, Title)); + + const title = items.find((child) => + childIsOfComponentType(child, ErrorSummaryTitle, { className: 'nhsuk-error-summary__title' }), + ); + + const bodyItems = items.filter((child) => child !== title); + + if (instanceError) { + throw instanceError; + } 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 000000000..a2ad651f5 --- /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 000000000..3108f0ec2 --- /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 000000000..ee2c905c0 --- /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 000000000..d917f17a1 --- /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 49eaf1ac3..f8907a0fb 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/form/Form.tsx b/src/components/form-elements/form/Form.tsx index b438f77db..4ca40d32d 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/legend/Legend.tsx b/src/components/form-elements/legend/Legend.tsx index 76ba3abdc..448fc384f 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/Radios.tsx b/src/components/form-elements/radios/Radios.tsx index 9b30a1765..b69470ab7 100644 --- a/src/components/form-elements/radios/Radios.tsx +++ b/src/components/form-elements/radios/Radios.tsx @@ -20,6 +20,7 @@ const RadiosComponent = forwardRef((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/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 a0de48d7a..0b5143062 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 06895bdfe..ec9ed5bb6 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 9d28e8f32..25c91ed16 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'; @@ -22,13 +24,13 @@ const SelectComponent = forwardRef( ), ); -const Option = forwardRef>( +export const SelectOption = forwardRef>( (props, forwardedRef) =>
    ((props, forwardedRe > - {childLogo} + + + {childSearch} {childAccount} @@ -144,10 +113,9 @@ const HeaderComponent = forwardRef((props, forwardedRe HeaderComponent.displayName = 'Header'; export const Header = Object.assign(HeaderComponent, { - Account, - AccountItem, - Logo, - Search, - Navigation, - NavigationItem, + Account: HeaderAccount, + AccountItem: HeaderAccountItem, + Search: HeaderSearch, + Navigation: HeaderNavigation, + NavigationItem: HeaderNavigationItem, }); diff --git a/src/components/navigation/header/HeaderContext.ts b/src/components/navigation/header/HeaderContext.ts index 72a3b01d8..3c686f370 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 e1891ca96..52d97f9a8 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 1db8ce3ac..210560d4b 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 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 452f174a6..1308d6f23 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 73% rename from src/components/navigation/header/components/Logo.tsx rename to src/components/navigation/header/components/HeaderLogo.tsx index b9b508f80..db755ff94 100644 --- a/src/components/navigation/header/components/Logo.tsx +++ b/src/components/navigation/header/components/HeaderLogo.tsx @@ -1,23 +1,20 @@ -'use client'; - -import { useContext, useEffect, type FC } from 'react'; -import { HeaderContext, type IHeaderContext } from '../HeaderContext.js'; - -export type LogoProps = NonNullable; - -export const Logo: 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 ( @@ -61,4 +58,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 ab2db3d47..e7900c240 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 54512fe79..23ec21ed9 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 57% rename from src/components/navigation/header/components/ServiceName.tsx rename to src/components/navigation/header/components/HeaderServiceName.tsx index 826772f26..d6b3c4bb8 100644 --- a/src/components/navigation/header/components/ServiceName.tsx +++ b/src/components/navigation/header/components/HeaderServiceName.tsx @@ -1,12 +1,16 @@ -'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 ServiceNameInnerProps = NonNullable; -export type ServiceNameProps = Pick, 'children'>; +export interface HeaderServiceNameProps extends PropsWithChildren, HeaderLogoProps { + service?: { + href?: string; + text?: string; + }; +} -const ServiceNameInner: FC = (service) => +const HeaderServiceNameInner: FC = (service) => service.href ? ( {service.text} @@ -15,13 +19,12 @@ const ServiceNameInner: FC = (service) => {service.text} ); -export const ServiceName: 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. @@ -48,15 +51,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 9a4ba7693..2a280742b 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 d5c1f758d..354a52c1e 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 0a6fde86b..a202b432a 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/navigation/skip-link/SkipLink.tsx b/src/components/navigation/skip-link/SkipLink.tsx index 2d5ef657a..164753778 100644 --- a/src/components/navigation/skip-link/SkipLink.tsx +++ b/src/components/navigation/skip-link/SkipLink.tsx @@ -10,6 +10,7 @@ export const SkipLink = forwardRef((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 ( ( }, ); -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 8c131e294..fc9e79174 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, }); diff --git a/src/util/hooks/UseDevWarning.tsx b/src/util/hooks/UseDevWarning.tsx index 67892e3cb..c13657f50 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'; diff --git a/src/util/types/TypeGuards.ts b/src/util/types/TypeGuards.ts index acbe45b82..58080be5d 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; }; /** diff --git a/stories/Content Presentation/NotificationBanner.stories.tsx b/stories/Content Presentation/NotificationBanner.stories.tsx new file mode 100644 index 000000000..d247ba9df --- /dev/null +++ b/stories/Content Presentation/NotificationBanner.stories.tsx @@ -0,0 +1,153 @@ +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 three subcomponents: + * + * - `NotificationBanner.Title` + * - `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: () => ( + + + The service will be unavailable from 8pm to 9pm on Thursday 1 January 2025. + + + ), +}; + +export const SuccessPanel: Story = { + args: {}, + render: () => ( + + Patient record updated +

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

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

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

    +
    + ), +}; + +export const StandardPanelWithCustomTitleElement: Story = { + args: {}, + render: () => ( + + + Maintenance + + Upcoming maintenance +

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

    +
    + ), +}; + +export const StandardPanelWithList: Story = { + args: {}, + render: () => ( + + 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: () => ( + + + 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 +
    • +
    +
    + ), +}; diff --git a/stories/Navigation/Header.stories.tsx b/stories/Navigation/Header.stories.tsx index b934b43a4..e5e58e695 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 (
    - diff --git a/yarn.lock b/yarn.lock index 4e2079e00..621f5740b 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