diff --git a/.storybook/main.ts b/.storybook/main.ts index 193423c1..cc55d51a 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 9c08600f..98f0c858 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 8c09dc05..c4d16572 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 e5c94eef..42bcb762 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nhsuk-react-components", - "version": "6.0.0-beta.1", + "version": "6.0.0-beta.2", "license": "MIT", "author": { "name": "NHS England" @@ -113,7 +113,7 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "rollup": "^4.52.4", - "rollup-plugin-preserve-directives": "^0.4.0", + "rollup-preserve-directives": "^1.1.3", "sass-embedded": "^1.93.2", "storybook": "^9.1.10", "tslib": "^2.8.1", diff --git a/rollup.config.js b/rollup.config.js index 3de4a7c5..7a98d9b5 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 f2061729..5eab14f0 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 12c169d6..5fd74d94 100644 --- a/src/components/content-presentation/details/Details.tsx +++ b/src/components/content-presentation/details/Details.tsx @@ -15,7 +15,7 @@ const DetailsComponent = forwardRef( ), ); -const DetailsSummary = forwardRef>( +export const DetailsSummary = forwardRef>( ({ children, className, ...rest }, forwardedRef) => ( >( ), ); -const DetailsText: FC> = ({ className, ...rest }) => ( +export const DetailsText: FC> = ({ className, ...rest }) => (
); -const ExpanderGroup: FC> = ({ className, ...rest }) => ( -
-); +export const DetailsExpanderGroup: FC> = ({ + className, + ...rest +}) =>
; DetailsComponent.displayName = 'Details'; DetailsSummary.displayName = 'Details.Summary'; DetailsText.displayName = 'Details.Text'; -ExpanderGroup.displayName = 'Details.ExpanderGroup'; +DetailsExpanderGroup.displayName = 'Details.ExpanderGroup'; export const Details = Object.assign(DetailsComponent, { Summary: DetailsSummary, Text: DetailsText, - ExpanderGroup, + ExpanderGroup: DetailsExpanderGroup, }); diff --git a/src/components/content-presentation/do-and-dont-list/DoAndDontList.tsx b/src/components/content-presentation/do-and-dont-list/DoAndDontList.tsx index 7c2a12db..d8b6502a 100644 --- a/src/components/content-presentation/do-and-dont-list/DoAndDontList.tsx +++ b/src/components/content-presentation/do-and-dont-list/DoAndDontList.tsx @@ -1,28 +1,18 @@ 'use client'; import classNames from 'classnames'; -import { - createContext, - forwardRef, - useContext, - type ComponentPropsWithoutRef, - type FC, - type ReactNode, -} from 'react'; -import { CrossIcon, TickIcon } from '../icons/index.js'; +import { forwardRef, type ComponentPropsWithoutRef } from 'react'; import { HeadingLevel, type HeadingLevelProps } from '#components/utils/HeadingLevel.js'; - -type ListType = 'do' | 'dont'; +import { DoAndDontListContext, type DoAndDontListType } from './DoAndDontListContext.js'; +import { DoAndDontListItem } from './components/index.js'; export interface DoAndDontListProps extends ComponentPropsWithoutRef<'div'>, Pick { - listType: ListType; + listType: DoAndDontListType; heading?: string; } -const DoAndDontListContext = createContext('do'); - const DoAndDontListComponent = forwardRef( ({ className, listType, children, heading, headingLevel, ...rest }, forwardedRef) => (
@@ -42,36 +32,8 @@ const DoAndDontListComponent = forwardRef( ), ); -export interface DoAndDontItemProps extends ComponentPropsWithoutRef<'li'> { - listItemType?: ListType; - prefixText?: ReactNode; -} - -const DoAndDontItem: FC = ({ prefixText, listItemType, children, ...rest }) => { - const listItem = useContext(DoAndDontListContext); - const defaultPrefix = (listItemType || listItem) === 'do' ? null : 'do not '; - const actualPrefix = prefixText === undefined ? defaultPrefix : prefixText; - return ( -
  • - {(listItemType || listItem) === 'do' ? ( - <> - - {actualPrefix} - - ) : ( - <> - - {actualPrefix} - - )} - {children} -
  • - ); -}; - DoAndDontListComponent.displayName = 'DoAndDontList'; -DoAndDontItem.displayName = 'DoAndDontList.Item'; export const DoAndDontList = Object.assign(DoAndDontListComponent, { - Item: DoAndDontItem, + Item: DoAndDontListItem, }); diff --git a/src/components/content-presentation/do-and-dont-list/DoAndDontListContext.ts b/src/components/content-presentation/do-and-dont-list/DoAndDontListContext.ts new file mode 100644 index 00000000..6056faa7 --- /dev/null +++ b/src/components/content-presentation/do-and-dont-list/DoAndDontListContext.ts @@ -0,0 +1,7 @@ +'use client'; + +import { createContext } from 'react'; + +export type DoAndDontListType = 'do' | 'dont'; + +export const DoAndDontListContext = createContext('do'); diff --git a/src/components/content-presentation/do-and-dont-list/components/DoAndDontListItem.tsx b/src/components/content-presentation/do-and-dont-list/components/DoAndDontListItem.tsx new file mode 100644 index 00000000..bdf5e35b --- /dev/null +++ b/src/components/content-presentation/do-and-dont-list/components/DoAndDontListItem.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { useContext, type ComponentPropsWithoutRef, type FC, type ReactNode } from 'react'; +import { CrossIcon, TickIcon } from '../../icons/index.js'; +import { DoAndDontListContext, type DoAndDontListType } from '../DoAndDontListContext.js'; + +export interface DoAndDontListItemProps extends ComponentPropsWithoutRef<'li'> { + listItemType?: DoAndDontListType; + prefixText?: ReactNode; +} + +export const DoAndDontListItem: FC = ({ + prefixText, + listItemType, + children, + ...rest +}) => { + const listItem = useContext(DoAndDontListContext); + const defaultPrefix = (listItemType || listItem) === 'do' ? null : 'do not '; + const actualPrefix = prefixText === undefined ? defaultPrefix : prefixText; + return ( +
  • + {(listItemType || listItem) === 'do' ? ( + <> + + {actualPrefix} + + ) : ( + <> + + {actualPrefix} + + )} + {children} +
  • + ); +}; + +DoAndDontListItem.displayName = 'DoAndDontList.Item'; diff --git a/src/components/content-presentation/do-and-dont-list/components/index.ts b/src/components/content-presentation/do-and-dont-list/components/index.ts new file mode 100644 index 00000000..e9bea06f --- /dev/null +++ b/src/components/content-presentation/do-and-dont-list/components/index.ts @@ -0,0 +1 @@ +export * from './DoAndDontListItem.js'; diff --git a/src/components/content-presentation/do-and-dont-list/index.ts b/src/components/content-presentation/do-and-dont-list/index.ts index bd8c9192..f4bca1e7 100644 --- a/src/components/content-presentation/do-and-dont-list/index.ts +++ b/src/components/content-presentation/do-and-dont-list/index.ts @@ -1 +1,3 @@ +export * from './components/index.js'; +export * from './DoAndDontListContext.js'; export * from './DoAndDontList.js'; diff --git a/src/components/content-presentation/hero/Hero.tsx b/src/components/content-presentation/hero/Hero.tsx index 1020ac53..69a21843 100644 --- a/src/components/content-presentation/hero/Hero.tsx +++ b/src/components/content-presentation/hero/Hero.tsx @@ -7,7 +7,7 @@ export interface HeroContentProps extends ComponentPropsWithoutRef<'div'> { hasImage: boolean; } -const HeroContent: FC = ({ children, hasImage }) => { +export const HeroContent: FC = ({ children, hasImage }) => { if (!children) { return null; } @@ -29,7 +29,7 @@ const HeroContent: FC = ({ children, hasImage }) => { ); }; -const HeroHeading: FC = ({ className, headingLevel = 'h1', ...rest }) => ( +export const HeroHeading: FC = ({ className, headingLevel = 'h1', ...rest }) => ( = ({ className, headingLevel = 'h1', .. /> ); -const HeroText: FC> = ({ className, ...rest }) => ( +export const HeroText: FC> = ({ className, ...rest }) => (

    ); diff --git a/src/components/content-presentation/index.ts b/src/components/content-presentation/index.ts index 55a38c07..e410fcc9 100644 --- a/src/components/content-presentation/index.ts +++ b/src/components/content-presentation/index.ts @@ -4,6 +4,7 @@ export * from './hero/index.js'; export * from './icons/index.js'; export * from './images/index.js'; export * from './inset-text/index.js'; +export * from './notification-banner/index.js'; export * from './panel/index.js'; export * from './summary-list/index.js'; export * from './table/index.js'; diff --git a/src/components/content-presentation/notification-banner/NotificationBanner.tsx b/src/components/content-presentation/notification-banner/NotificationBanner.tsx new file mode 100644 index 00000000..33eb003f --- /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 00000000..537c5d08 --- /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 00000000..2040b917 --- /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 00000000..a8a30a3b --- /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 00000000..bc145aac --- /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 00000000..7cf033d5 --- /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 00000000..e9dad42b --- /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 00000000..4dfa21ab --- /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 ce2d1f35..0bc49de5 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 00000000..a24263eb --- /dev/null +++ b/src/components/content-presentation/panel/components/PanelTitle.tsx @@ -0,0 +1,12 @@ +import { type FC } from 'react'; +import { HeadingLevel, type HeadingLevelProps } from '#components/utils/HeadingLevel.js'; + +export type PanelTitleProps = HeadingLevelProps; + +export const PanelTitle: FC = ({ children, headingLevel = 'h1', ...rest }) => ( + + {children} + +); + +PanelTitle.displayName = 'Panel.Title'; diff --git a/src/components/content-presentation/panel/components/index.ts b/src/components/content-presentation/panel/components/index.ts new file mode 100644 index 00000000..89bd345c --- /dev/null +++ b/src/components/content-presentation/panel/components/index.ts @@ -0,0 +1 @@ +export * from './PanelTitle.js'; diff --git a/src/components/content-presentation/panel/index.ts b/src/components/content-presentation/panel/index.ts index 81c82d41..ab24f7a8 100644 --- a/src/components/content-presentation/panel/index.ts +++ b/src/components/content-presentation/panel/index.ts @@ -1 +1,2 @@ +export * from './components/index.js'; export * from './Panel.js'; diff --git a/src/components/content-presentation/summary-list/SummaryList.tsx b/src/components/content-presentation/summary-list/SummaryList.tsx index 97e2e678..c2cd6e70 100644 --- a/src/components/content-presentation/summary-list/SummaryList.tsx +++ b/src/components/content-presentation/summary-list/SummaryList.tsx @@ -1,19 +1,19 @@ import classNames from 'classnames'; import { forwardRef, type ComponentPropsWithoutRef, type FC } from 'react'; -const SummaryListRow: FC> = ({ className, ...rest }) => ( +export const SummaryListRow: FC> = ({ className, ...rest }) => (
    ); -const SummaryListKey: FC> = ({ className, ...rest }) => ( +export const SummaryListKey: FC> = ({ className, ...rest }) => (
    ); -const SummaryListValue: FC> = ({ className, ...rest }) => ( +export const SummaryListValue: FC> = ({ className, ...rest }) => (
    ); -const SummaryListActions: FC> = ({ className, ...rest }) => ( +export const SummaryListActions: FC> = ({ className, ...rest }) => (
    ); diff --git a/src/components/content-presentation/table/components/TableBody.tsx b/src/components/content-presentation/table/components/TableBody.tsx index 606465c9..ae16155d 100644 --- a/src/components/content-presentation/table/components/TableBody.tsx +++ b/src/components/content-presentation/table/components/TableBody.tsx @@ -1,3 +1,5 @@ +'use client'; + import classNames from 'classnames'; import { type ComponentPropsWithoutRef, type FC } from 'react'; import { TableSection, TableSectionContext } from '../TableSectionContext.js'; diff --git a/src/components/content-presentation/table/index.ts b/src/components/content-presentation/table/index.ts index f90a825e..ebef4ffd 100644 --- a/src/components/content-presentation/table/index.ts +++ b/src/components/content-presentation/table/index.ts @@ -1,3 +1,4 @@ +export * from './components/index.js'; export * from './TableContext.js'; export * from './TableSectionContext.js'; export * from './Table.js'; diff --git a/src/components/content-presentation/tabs/Tabs.tsx b/src/components/content-presentation/tabs/Tabs.tsx index 9fbcaf56..131274e1 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 552b6937..597a1cda 100644 --- a/src/components/content-presentation/tabs/__tests__/Tabs.test.tsx +++ b/src/components/content-presentation/tabs/__tests__/Tabs.test.tsx @@ -1,6 +1,6 @@ import { render } from '@testing-library/react'; import { createRef } from 'react'; -import { Tabs, type TabTitleProps } from '..'; +import { Tabs, type TabsTitleProps } from '..'; import { renderClient, renderServer } from '#util/components'; describe('Tabs', () => { @@ -120,7 +120,7 @@ describe('Tabs', () => { }); describe('Tabs.Title', () => { - it.each([ + it.each([ undefined, { headingLevel: 'h1' }, { headingLevel: 'h2' }, diff --git a/src/components/content-presentation/warning-callout/WarningCallout.tsx b/src/components/content-presentation/warning-callout/WarningCallout.tsx index 80b238bf..c3db83ea 100644 --- a/src/components/content-presentation/warning-callout/WarningCallout.tsx +++ b/src/components/content-presentation/warning-callout/WarningCallout.tsx @@ -2,7 +2,7 @@ import classNames from 'classnames'; import { forwardRef, type ComponentPropsWithoutRef, type FC } from 'react'; import { HeadingLevel, type HeadingLevelProps } from '#components/utils/HeadingLevel.js'; -const WarningCalloutHeading: FC = ({ children, className, ...rest }) => ( +export const WarningCalloutHeading: FC = ({ children, className, ...rest }) => ( {children?.toString().toLowerCase().includes('important') ? ( <> diff --git a/src/components/form-elements/button/Button.tsx b/src/components/form-elements/button/Button.tsx index 56d07bbe..39a20ae6 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 c83c9806..1e17e60c 100644 --- a/src/components/form-elements/checkboxes/Checkboxes.tsx +++ b/src/components/form-elements/checkboxes/Checkboxes.tsx @@ -19,6 +19,7 @@ const CheckboxesComponent = forwardRef((props, const { children, idPrefix, ...rest } = props; const [moduleRef] = useState(() => forwardedRef || createRef()); + const [instanceError, setInstanceError] = useState(); const [instance, setInstance] = useState(); const _boxReferences: string[] = []; @@ -30,11 +31,9 @@ const CheckboxesComponent = forwardRef((props, return; } - const { current: $root } = moduleRef; - - import('nhsuk-frontend').then(({ Checkboxes }) => { - setInstance(new Checkboxes($root)); - }); + import('nhsuk-frontend') + .then(({ Checkboxes }) => setInstance(new Checkboxes(moduleRef.current))) + .catch(setInstanceError); }, [moduleRef, instance]); const getBoxId = (id: string, reference: string): string => { @@ -66,6 +65,10 @@ const CheckboxesComponent = forwardRef((props, _boxIds = {}; }; + if (instanceError) { + throw instanceError; + } + return ( inputType="checkboxes" {...rest}> {/* eslint-disable-next-line @typescript-eslint/no-unused-vars */} diff --git a/src/components/form-elements/checkboxes/components/Divider.tsx b/src/components/form-elements/checkboxes/components/CheckboxesDivider.tsx similarity index 58% rename from src/components/form-elements/checkboxes/components/Divider.tsx rename to src/components/form-elements/checkboxes/components/CheckboxesDivider.tsx index 7cf06e33..9021f5f7 100644 --- a/src/components/form-elements/checkboxes/components/Divider.tsx +++ b/src/components/form-elements/checkboxes/components/CheckboxesDivider.tsx @@ -1,10 +1,10 @@ import { type FC } from 'react'; -type DividerProps = { +export type CheckboxesDividerProps = { dividerText?: string; }; -export const CheckboxesDivider: FC = ({ dividerText = 'or' }) => ( +export const CheckboxesDivider: FC = ({ dividerText = 'or' }) => (
    {dividerText}
    ); diff --git a/src/components/form-elements/checkboxes/components/Item.tsx b/src/components/form-elements/checkboxes/components/CheckboxesItem.tsx similarity index 100% rename from src/components/form-elements/checkboxes/components/Item.tsx rename to src/components/form-elements/checkboxes/components/CheckboxesItem.tsx diff --git a/src/components/form-elements/checkboxes/components/index.ts b/src/components/form-elements/checkboxes/components/index.ts index 2f41fba0..6bf4750e 100644 --- a/src/components/form-elements/checkboxes/components/index.ts +++ b/src/components/form-elements/checkboxes/components/index.ts @@ -1,2 +1,2 @@ -export * from './Item.js'; -export * from './Divider.js'; +export * from './CheckboxesDivider.js'; +export * from './CheckboxesItem.js'; diff --git a/src/components/form-elements/checkboxes/index.ts b/src/components/form-elements/checkboxes/index.ts index aa81dc25..49db9b53 100644 --- a/src/components/form-elements/checkboxes/index.ts +++ b/src/components/form-elements/checkboxes/index.ts @@ -1,2 +1,3 @@ +export * from './components/index.js'; export * from './CheckboxesContext.js'; export * from './Checkboxes.js'; diff --git a/src/components/form-elements/date-input/DateInput.tsx b/src/components/form-elements/date-input/DateInput.tsx index 03f93627..e523eb67 100644 --- a/src/components/form-elements/date-input/DateInput.tsx +++ b/src/components/form-elements/date-input/DateInput.tsx @@ -9,7 +9,7 @@ import { type ComponentPropsWithoutRef, type EventHandler, } from 'react'; -import { DayInput, MonthInput, YearInput } from './components/index.js'; +import { DateInputDay, DateInputMonth, DateInputYear } from './components/index.js'; import { DateInputContext, type IDateInputContext } from './DateInputContext.js'; import { FormGroup } from '#components/utils/index.js'; import { type FormElementProps } from '#util/types/FormTypes.js'; @@ -26,12 +26,12 @@ export interface DateInputChangeEvent currentTarget: DateInputElement; } -interface DateInputElement extends Omit { +export interface DateInputElement extends Omit { value?: Partial; onChange?: EventHandler; } -interface DateInputProps +export interface DateInputProps extends Omit, 'defaultValue' | 'onChange'>, Omit { value?: Partial; @@ -39,7 +39,7 @@ interface DateInputProps onChange?: EventHandler; } -type InputType = 'day' | 'month' | 'year'; +export type DateInputType = 'day' | 'month' | 'year'; const DateInputComponent = forwardRef( ({ children, onChange, value, defaultValue, formGroupProps, ...rest }, forwardedRef) => { @@ -51,7 +51,7 @@ const DateInputComponent = forwardRef( year: value?.year ?? '', }); - const handleChange = (inputType: InputType, event: ChangeEvent): void => { + const handleChange = (inputType: DateInputType, event: ChangeEvent): void => { event.stopPropagation(); const newEventValue: DateInputValue = { @@ -95,9 +95,9 @@ const DateInputComponent = forwardRef( {children || ( <> - - - + + + )} @@ -112,7 +112,7 @@ const DateInputComponent = forwardRef( DateInputComponent.displayName = 'DateInput'; export const DateInput = Object.assign(DateInputComponent, { - Day: DayInput, - Month: MonthInput, - Year: YearInput, + Day: DateInputDay, + Month: DateInputMonth, + Year: DateInputYear, }); diff --git a/src/components/form-elements/date-input/components/IndividualDateInputs.tsx b/src/components/form-elements/date-input/components/DateInputField.tsx similarity index 79% rename from src/components/form-elements/date-input/components/IndividualDateInputs.tsx rename to src/components/form-elements/date-input/components/DateInputField.tsx index 176436d6..2d71e2c0 100644 --- a/src/components/form-elements/date-input/components/IndividualDateInputs.tsx +++ b/src/components/form-elements/date-input/components/DateInputField.tsx @@ -95,19 +95,22 @@ const IndividualDateInput = forwardRef>( - (props, forwardedRef) => , -); +export const DateInputDay = forwardRef< + HTMLInputElement, + Omit +>((props, forwardedRef) => ); -export const MonthInput = forwardRef>( - (props, forwardedRef) => , -); +export const DateInputMonth = forwardRef< + HTMLInputElement, + Omit +>((props, forwardedRef) => ); -export const YearInput = forwardRef>( - (props, forwardedRef) => , -); +export const DateInputYear = forwardRef< + HTMLInputElement, + Omit +>((props, forwardedRef) => ); IndividualDateInput.displayName = 'DateInput.Field'; -DayInput.displayName = 'DateInput.Day'; -MonthInput.displayName = 'DateInput.Month'; -YearInput.displayName = 'DateInput.Year'; +DateInputDay.displayName = 'DateInput.Day'; +DateInputMonth.displayName = 'DateInput.Month'; +DateInputYear.displayName = 'DateInput.Year'; diff --git a/src/components/form-elements/date-input/components/index.ts b/src/components/form-elements/date-input/components/index.ts index 18eb91bb..eab8a236 100644 --- a/src/components/form-elements/date-input/components/index.ts +++ b/src/components/form-elements/date-input/components/index.ts @@ -1 +1 @@ -export * from './IndividualDateInputs.js'; +export * from './DateInputField.js'; diff --git a/src/components/form-elements/date-input/index.ts b/src/components/form-elements/date-input/index.ts index 8d099bb3..78fbf7b8 100644 --- a/src/components/form-elements/date-input/index.ts +++ b/src/components/form-elements/date-input/index.ts @@ -1,2 +1,3 @@ +export * from './components/index.js'; export * from './DateInputContext.js'; export * from './DateInput.js'; diff --git a/src/components/form-elements/error-summary/ErrorSummary.tsx b/src/components/form-elements/error-summary/ErrorSummary.tsx index 0ba53b5d..1af81d44 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 00000000..a2ad651f --- /dev/null +++ b/src/components/form-elements/error-summary/components/ErrorSummaryList.tsx @@ -0,0 +1,18 @@ +import classNames from 'classnames'; +import { type ComponentPropsWithoutRef, type FC } from 'react'; + +export type ErrorSummaryListProps = ComponentPropsWithoutRef<'ul'>; + +export const ErrorSummaryList: FC = ({ children, className, ...rest }) => { + if (!children) { + return null; + } + + return ( +
      + {children} +
    + ); +}; + +ErrorSummaryList.displayName = 'ErrorSummary.List'; diff --git a/src/components/form-elements/error-summary/components/ErrorSummaryListItem.tsx b/src/components/form-elements/error-summary/components/ErrorSummaryListItem.tsx new file mode 100644 index 00000000..3108f0ec --- /dev/null +++ b/src/components/form-elements/error-summary/components/ErrorSummaryListItem.tsx @@ -0,0 +1,29 @@ +import { forwardRef } from 'react'; + +import { type AsElementLink } from '#util/types/LinkTypes.js'; + +type ErrorSummaryListItemProps = AsElementLink; + +export const ErrorSummaryListItem = forwardRef( + (props, forwardedRef) => { + const { children, asElement: Element = 'a', ...rest } = props; + + if (!children) { + return null; + } + + return ( +
  • + {(props.asElement ?? props.href) ? ( + + {children} + + ) : ( + <>{children} + )} +
  • + ); + }, +); + +ErrorSummaryListItem.displayName = 'ErrorSummary.ListItem'; diff --git a/src/components/form-elements/error-summary/components/ErrorSummaryTitle.tsx b/src/components/form-elements/error-summary/components/ErrorSummaryTitle.tsx new file mode 100644 index 00000000..ee2c905c --- /dev/null +++ b/src/components/form-elements/error-summary/components/ErrorSummaryTitle.tsx @@ -0,0 +1,12 @@ +import classNames from 'classnames'; +import { type ComponentPropsWithoutRef, type FC } from 'react'; + +export type ErrorSummaryTitleProps = ComponentPropsWithoutRef<'h2'>; + +export const ErrorSummaryTitle: FC = ({ children, className, ...rest }) => ( +

    + {children} +

    +); + +ErrorSummaryTitle.displayName = 'ErrorSummary.Title'; diff --git a/src/components/form-elements/error-summary/components/index.ts b/src/components/form-elements/error-summary/components/index.ts new file mode 100644 index 00000000..d917f17a --- /dev/null +++ b/src/components/form-elements/error-summary/components/index.ts @@ -0,0 +1,3 @@ +export * from './ErrorSummaryList.js'; +export * from './ErrorSummaryListItem.js'; +export * from './ErrorSummaryTitle.js'; diff --git a/src/components/form-elements/error-summary/index.ts b/src/components/form-elements/error-summary/index.ts index 49eaf1ac..f8907a0f 100644 --- a/src/components/form-elements/error-summary/index.ts +++ b/src/components/form-elements/error-summary/index.ts @@ -1 +1,2 @@ +export * from './components/index.js'; export * from './ErrorSummary.js'; diff --git a/src/components/form-elements/form/Form.tsx b/src/components/form-elements/form/Form.tsx index b438f77d..4ca40d32 100644 --- a/src/components/form-elements/form/Form.tsx +++ b/src/components/form-elements/form/Form.tsx @@ -1,3 +1,5 @@ +'use client'; + import { type ComponentPropsWithoutRef, type FC } from 'react'; import { FormContext } from './FormContext.js'; diff --git a/src/components/form-elements/legend/Legend.tsx b/src/components/form-elements/legend/Legend.tsx index 76ba3abd..448fc384 100644 --- a/src/components/form-elements/legend/Legend.tsx +++ b/src/components/form-elements/legend/Legend.tsx @@ -42,4 +42,4 @@ export const Legend: FC = ({ ); }; -Legend.displayName = 'Fieldset.Legend'; +Legend.displayName = 'Legend'; diff --git a/src/components/form-elements/radios/Radios.tsx b/src/components/form-elements/radios/Radios.tsx index 9b30a176..b69470ab 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 a0de48d7..0b514306 100644 --- a/src/components/form-elements/radios/components/index.ts +++ b/src/components/form-elements/radios/components/index.ts @@ -1,2 +1,2 @@ -export * from './Divider.js'; -export * from './Item.js'; +export * from './RadiosDivider.js'; +export * from './RadiosItem.js'; diff --git a/src/components/form-elements/radios/index.ts b/src/components/form-elements/radios/index.ts index 06895bdf..ec9ed5bb 100644 --- a/src/components/form-elements/radios/index.ts +++ b/src/components/form-elements/radios/index.ts @@ -1,2 +1,3 @@ +export * from './components/index.js'; export * from './RadiosContext.js'; export * from './Radios.js'; diff --git a/src/components/form-elements/select/Select.tsx b/src/components/form-elements/select/Select.tsx index 9d28e8f3..25c91ed1 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 72a3b01d..3c686f37 100644 --- a/src/components/navigation/header/HeaderContext.ts +++ b/src/components/navigation/header/HeaderContext.ts @@ -3,35 +3,11 @@ import { createContext, type Dispatch, type SetStateAction } from 'react'; export interface IHeaderContext { - logoProps?: { - 'href'?: string; - 'src'?: string; - 'alt'?: string; - 'aria-label'?: string; - }; - serviceProps?: { - href?: string; - text?: string; - }; - organisationProps?: { - name?: string; - split?: string; - descriptor?: string; - }; menuOpen: boolean; setMenuOpen: Dispatch>; - setLogoProps: Dispatch>; - setServiceProps: Dispatch>; - setOrganisationProps: Dispatch>; } export const HeaderContext = createContext({ - logoProps: undefined, - serviceProps: undefined, - organisationProps: undefined, menuOpen: false, setMenuOpen: () => {}, - setLogoProps: () => {}, - setServiceProps: () => {}, - setOrganisationProps: () => {}, }); diff --git a/src/components/navigation/header/__tests__/Header.test.tsx b/src/components/navigation/header/__tests__/Header.test.tsx index e1891ca9..52d97f9a 100644 --- a/src/components/navigation/header/__tests__/Header.test.tsx +++ b/src/components/navigation/header/__tests__/Header.test.tsx @@ -5,8 +5,7 @@ import { renderClient, renderServer } from '#util/components'; describe('Header', () => { it('matches snapshot', async () => { const { container } = await renderClient( -
    - +
    Health A-Z @@ -27,8 +26,7 @@ describe('Header', () => { it('matches snapshot (via server)', async () => { const { container, element } = await renderServer( -
    - +
    Health A-Z @@ -58,12 +56,9 @@ describe('Header', () => { it('forwards refs', async () => { const ref = createRef(); - const { modules } = await renderClient( -
    - -
    , - { moduleName: 'nhsuk-header' }, - ); + const { modules } = await renderClient(
    , { + moduleName: 'nhsuk-header', + }); const [headerEl] = modules; @@ -106,12 +101,9 @@ describe('Header', () => { describe('Header.Logo', () => { it('renders logo only', async () => { - const { container } = await renderClient( -
    - -
    , - { moduleName: 'nhsuk-header' }, - ); + const { container } = await renderClient(
    , { + moduleName: 'nhsuk-header', + }); const linkEl = container.querySelector('.nhsuk-header__service a'); const logoEl = container.querySelector('.nhsuk-header__logo'); @@ -121,12 +113,9 @@ describe('Header', () => { }); it('renders logo only (with link)', async () => { - const { container } = await renderClient( -
    - -
    , - { moduleName: 'nhsuk-header' }, - ); + const { container } = await renderClient(
    , { + moduleName: 'nhsuk-header', + }); const linkEl = container.querySelector('.nhsuk-header__service a'); const logoEl = container.querySelector('.nhsuk-header__logo'); @@ -138,9 +127,7 @@ describe('Header', () => { it('renders logo and organisation name', async () => { const { container } = await renderClient( -
    - -
    , +
    , { moduleName: 'nhsuk-header' }, ); @@ -157,9 +144,7 @@ describe('Header', () => { it('renders logo (with link) and organisation name', async () => { const { container } = await renderClient( -
    - -
    , +
    , { moduleName: 'nhsuk-header' }, ); @@ -177,9 +162,7 @@ describe('Header', () => { it('renders logo (custom src) and organisation name', async () => { const { container } = await renderClient( -
    - -
    , +
    , { moduleName: 'nhsuk-header' }, ); @@ -196,9 +179,10 @@ describe('Header', () => { it('renders logo (with link, custom src) and organisation name', async () => { const { container } = await renderClient( -
    - -
    , +
    , { moduleName: 'nhsuk-header' }, ); @@ -217,14 +201,13 @@ describe('Header', () => { it('renders logo (with link) and organisation name (split, with descriptor)', async () => { const { container } = await renderClient(
    - -
    , + />, { moduleName: 'nhsuk-header' }, ); @@ -245,7 +228,6 @@ describe('Header', () => { it('matches snapshot', async () => { const { container } = await renderClient(
    - florence.nightingale@nhs.net @@ -267,7 +249,6 @@ describe('Header', () => { const { container } = await renderClient(
    - florence.nightingale@nhs.net @@ -305,7 +286,6 @@ describe('Header', () => { const { container } = await renderClient(
    - florence.nightingale@nhs.net diff --git a/src/components/navigation/header/__tests__/__snapshots__/Header.test.tsx.snap b/src/components/navigation/header/__tests__/__snapshots__/Header.test.tsx.snap index 1db8ce3a..210560d4 100644 --- a/src/components/navigation/header/__tests__/__snapshots__/Header.test.tsx.snap +++ b/src/components/navigation/header/__tests__/__snapshots__/Header.test.tsx.snap @@ -276,24 +276,30 @@ exports[`Header matches snapshot (via server): server 1`] = `
    - + +
    ; -export const Account: FC = ({ +export const HeaderAccount: FC = ({ className, children, 'aria-label': ariaLabel = 'Account', @@ -16,4 +16,4 @@ export const Account: FC = ({ ); -Account.displayName = 'Header.Account'; +HeaderAccount.displayName = 'Header.Account'; diff --git a/src/components/navigation/header/components/AccountItem.tsx b/src/components/navigation/header/components/HeaderAccountItem.tsx similarity index 64% rename from src/components/navigation/header/components/AccountItem.tsx rename to src/components/navigation/header/components/HeaderAccountItem.tsx index 452f174a..1308d6f2 100644 --- a/src/components/navigation/header/components/AccountItem.tsx +++ b/src/components/navigation/header/components/HeaderAccountItem.tsx @@ -3,19 +3,19 @@ import { forwardRef, type ComponentPropsWithRef, type ForwardedRef, type ReactNo import { UserIcon } from '#components/content-presentation/index.js'; import { type AsElementLink } from '#util/types/LinkTypes.js'; -export interface AccountItemButtonProps extends AsElementLink { +export interface HeaderAccountItemButtonProps extends AsElementLink { as?: 'button'; formProps?: ComponentPropsWithRef<'form'>; icon?: boolean; } -export interface AccountItemLinkProps extends AsElementLink { +export interface HeaderAccountItemLinkProps extends AsElementLink { href: string; as?: 'a'; icon?: boolean; } -const AccountItemButton = forwardRef( +const HeaderAccountItemButton = forwardRef( (props, forwardedRef) => { const { className, as, asElement: Element = as ?? 'button', ...rest } = props; const { formProps, ...buttonRest } = rest; @@ -32,7 +32,7 @@ const AccountItemButton = forwardRef( }, ); -const AccountItemLink = forwardRef( +const HeaderAccountItemLink = forwardRef( ({ className, as, asElement: Element = as ?? 'a', ...rest }, forwardedRef) => ( ( ), ); -export const AccountItem = forwardRef< +export const HeaderAccountItem = forwardRef< HTMLAnchorElement | HTMLButtonElement, - AccountItemButtonProps | AccountItemLinkProps + HeaderAccountItemButtonProps | HeaderAccountItemLinkProps >(({ children, className, icon, ...rest }, forwardedRef) => { let element: ReactNode; if (rest.as === 'a' || 'href' in rest) { element = ( - } {...rest}> + } {...rest}> {icon ? : null} {children} - + ); } else if (rest.as === 'button' || 'formProps' in rest) { element = ( - } {...rest}> + } {...rest}> {icon ? : null} {children} - + ); } else { element = ( @@ -74,6 +74,6 @@ export const AccountItem = forwardRef< return
  • {element}
  • ; }); -AccountItem.displayName = 'Header.AccountItem'; -AccountItemLink.displayName = 'Header.AccountItemLink'; -AccountItemButton.displayName = 'Header.AccountItemButton'; +HeaderAccountItem.displayName = 'Header.AccountItem'; +HeaderAccountItemLink.displayName = 'Header.AccountItemLink'; +HeaderAccountItemButton.displayName = 'Header.AccountItemButton'; diff --git a/src/components/navigation/header/components/Logo.tsx b/src/components/navigation/header/components/HeaderLogo.tsx similarity index 73% rename from src/components/navigation/header/components/Logo.tsx rename to src/components/navigation/header/components/HeaderLogo.tsx index b9b508f8..db755ff9 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 ab2db3d4..e7900c24 100644 --- a/src/components/navigation/header/components/MenuToggle.tsx +++ b/src/components/navigation/header/components/HeaderMenuToggle.tsx @@ -1,8 +1,8 @@ import { type ComponentPropsWithoutRef, type FC } from 'react'; -export type MenuToggleProps = ComponentPropsWithoutRef<'button'>; +export type HeaderMenuToggleProps = ComponentPropsWithoutRef<'button'>; -export const MenuToggle: FC = (props) => ( +export const HeaderMenuToggle: FC = (props) => ( ); }; -NavigationItem.displayName = 'Header.NavigationItem'; +HeaderNavigationItem.displayName = 'Header.NavigationItem'; diff --git a/src/components/navigation/header/components/Search.tsx b/src/components/navigation/header/components/HeaderSearch.tsx similarity index 86% rename from src/components/navigation/header/components/Search.tsx rename to src/components/navigation/header/components/HeaderSearch.tsx index 54512fe7..23ec21ed 100644 --- a/src/components/navigation/header/components/Search.tsx +++ b/src/components/navigation/header/components/HeaderSearch.tsx @@ -1,14 +1,14 @@ import { type ComponentPropsWithoutRef, type FC } from 'react'; import { SearchIcon } from '#components/content-presentation/index.js'; -export interface SearchProps extends ComponentPropsWithoutRef<'form'> { +export interface HeaderSearchProps extends ComponentPropsWithoutRef<'form'> { name?: string; placeholder?: string; visuallyHiddenLabel?: string; visuallyHiddenButton?: string; } -export const Search: FC = ({ +export const HeaderSearch: FC = ({ action = 'https://www.nhs.uk/search/', method = 'get', id = 'search', @@ -40,4 +40,4 @@ export const Search: FC = ({ ); }; -Search.displayName = 'Header.Search'; +HeaderSearch.displayName = 'Header.Search'; diff --git a/src/components/navigation/header/components/ServiceName.tsx b/src/components/navigation/header/components/HeaderServiceName.tsx similarity index 57% rename from src/components/navigation/header/components/ServiceName.tsx rename to src/components/navigation/header/components/HeaderServiceName.tsx index 826772f2..d6b3c4bb 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 9a4ba769..2a280742 100644 --- a/src/components/navigation/header/components/index.ts +++ b/src/components/navigation/header/components/index.ts @@ -1,8 +1,8 @@ -export * from './Account.js'; -export * from './AccountItem.js'; -export * from './Logo.js'; -export * from './MenuToggle.js'; -export * from './Navigation.js'; -export * from './NavigationItem.js'; -export * from './Search.js'; -export * from './ServiceName.js'; +export * from './HeaderAccount.js'; +export * from './HeaderAccountItem.js'; +export * from './HeaderLogo.js'; +export * from './HeaderMenuToggle.js'; +export * from './HeaderNavigation.js'; +export * from './HeaderNavigationItem.js'; +export * from './HeaderSearch.js'; +export * from './HeaderServiceName.js'; diff --git a/src/components/navigation/header/index.ts b/src/components/navigation/header/index.ts index d5c1f758..354a52c1 100644 --- a/src/components/navigation/header/index.ts +++ b/src/components/navigation/header/index.ts @@ -1,2 +1,3 @@ +export * from './components/index.js'; export * from './HeaderContext.js'; export * from './Header.js'; diff --git a/src/components/navigation/pagination/Pagination.tsx b/src/components/navigation/pagination/Pagination.tsx index 0a6fde86..a202b432 100644 --- a/src/components/navigation/pagination/Pagination.tsx +++ b/src/components/navigation/pagination/Pagination.tsx @@ -8,7 +8,7 @@ export interface PaginationLinkProps extends AsElementLink { next?: boolean; } -const PaginationLink = forwardRef( +export const PaginationLink = forwardRef( ({ className, children, asElement: Element = 'a', previous, next, ...rest }, forwardedRef) => (
  • ( ), ); -type PaginationProps = ComponentPropsWithoutRef<'nav'>; +export type PaginationProps = ComponentPropsWithoutRef<'nav'>; const PaginationComponent = forwardRef( ({ className, children, 'aria-label': ariaLabel = 'Pagination', ...rest }, forwardedRef) => ( diff --git a/src/components/navigation/skip-link/SkipLink.tsx b/src/components/navigation/skip-link/SkipLink.tsx index 2d5ef657..16475377 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 8c131e29..fc9e7917 100644 --- a/src/patterns/nav-a-z/NavAZ.tsx +++ b/src/patterns/nav-a-z/NavAZ.tsx @@ -16,12 +16,12 @@ const processLetters = ( return null; } if (disabledLetters && disabledLetters.includes(letter)) { - return {letter}; + return {letter}; } return ( - + {letter} - + ); }; if (children) { @@ -70,7 +70,7 @@ const NavAZComponent = forwardRef((props, forwardedRef) ); }); -const LinkItem: FC> = ({ +export const NavAZLinkItem: FC> = ({ className, asElement: Element = 'a', ...rest @@ -88,7 +88,7 @@ const LinkItem: FC> = ({
  • ); -const DisabledItem: FC> = ({ className, ...rest }) => ( +export const NavAZDisabledItem: FC> = ({ className, ...rest }) => (
  • > = ({ className, ...rest ); NavAZComponent.displayName = 'NavAZ'; -LinkItem.displayName = 'NavAZ.LinkItem'; -DisabledItem.displayName = 'NavAZ.DisabledItem'; +NavAZLinkItem.displayName = 'NavAZ.LinkItem'; +NavAZDisabledItem.displayName = 'NavAZ.DisabledItem'; export const NavAZ = Object.assign(NavAZComponent, { - LinkItem, - DisabledItem, + LinkItem: NavAZLinkItem, + DisabledItem: NavAZDisabledItem, }); diff --git a/src/util/hooks/UseDevWarning.tsx b/src/util/hooks/UseDevWarning.tsx index 67892e3c..c13657f5 100644 --- a/src/util/hooks/UseDevWarning.tsx +++ b/src/util/hooks/UseDevWarning.tsx @@ -1,3 +1,5 @@ +'use client'; + import { useEffect } from 'react'; import { isDev } from '#util/tools/index.js'; diff --git a/src/util/types/TypeGuards.ts b/src/util/types/TypeGuards.ts index acbe45b8..58080be5 100644 --- a/src/util/types/TypeGuards.ts +++ b/src/util/types/TypeGuards.ts @@ -7,14 +7,40 @@ import { } from 'react'; import { type CardType, type CareCardType } from './NHSUKTypes.js'; +type WithProps = T & { + props: HTMLAttributes; +}; + /** - * Assert that a child item is of the given component type. + * Assert that a child item is a valid component with props. + */ +const isValidComponent = ( + child: T, +): child is WithProps> => + isValidElement(child) && !!child.props && typeof child.props === 'object'; + +/** + * Assert that a child item is of the given component type, optionally + * checking via props for lazy or deferred server components. */ export const childIsOfComponentType = >( child: ReactNode, component: FC

    , + fallback?: Required>, ): child is ReactElement => { - return isValidElement(child) && child.type === component; + if (!isValidComponent(child)) { + return false; + } + + // Check type for client only components + if (child.type === component) { + return true; + } + + // Check props for lazy or deferred server components + return child.props.className && fallback?.className + ? child.props.className.split(' ').includes(fallback.className) + : false; }; /** diff --git a/stories/Content Presentation/NotificationBanner.stories.tsx b/stories/Content Presentation/NotificationBanner.stories.tsx new file mode 100644 index 00000000..d247ba9d --- /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 b934b43a..e5e58e69 100644 --- a/stories/Navigation/Header.stories.tsx +++ b/stories/Navigation/Header.stories.tsx @@ -17,7 +17,6 @@ export const HeaderDefault: Story = { href: '/', }} > - ( -
    - -
    - ), + render: (args) =>
    , }; export const HeaderWithServiceName: Story = { @@ -48,16 +43,13 @@ export const HeaderWithServiceName: Story = { text: 'Manage patients', href: '/', }} - > - -
    + /> ), }; export const HeaderWithSearch: Story = { render: (args) => ( -
    - +
    ), @@ -65,8 +57,7 @@ export const HeaderWithSearch: Story = { export const HeaderWithNavigation: Story = { render: (args) => ( -
    - +
    NHS service standard @@ -88,7 +79,6 @@ export const HeaderWithAccount: Story = { href: '/', }} > - florence.nightingale@nhs.net @@ -109,7 +99,6 @@ export const HeaderWithAccountComplex: Story = { href: '/', }} > - Florence Nightingale (Regional Manager) @@ -131,13 +120,13 @@ export const HeaderWithAccountComplex: Story = { export const HeaderOrganisationalBlueWithNavigation: Story = { render: (args) => (
    - Your hospital visit @@ -156,13 +145,13 @@ export const HeaderOrganisationalWhiteWithNavigation: Story = { render: (args) => (
    - Your hospital visit @@ -181,13 +170,13 @@ export const HeaderOrganisationalWhiteWithNavigationWhite: Story = { render: (args) => (
    - Your hospital visit @@ -214,13 +203,13 @@ export const HeaderWithCustomNavItemComponent: Story = { return (
    - diff --git a/yarn.lock b/yarn.lock index 4e2079e0..621f5740 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8510,7 +8510,7 @@ __metadata: react: "npm:^19.2.0" react-dom: "npm:^19.2.0" rollup: "npm:^4.52.4" - rollup-plugin-preserve-directives: "npm:^0.4.0" + rollup-preserve-directives: "npm:^1.1.3" sass-embedded: "npm:^1.93.2" storybook: "npm:^9.1.10" tslib: "npm:^2.8.1" @@ -9519,15 +9519,14 @@ __metadata: languageName: node linkType: hard -"rollup-plugin-preserve-directives@npm:^0.4.0": - version: 0.4.0 - resolution: "rollup-plugin-preserve-directives@npm:0.4.0" +"rollup-preserve-directives@npm:^1.1.3": + version: 1.1.3 + resolution: "rollup-preserve-directives@npm:1.1.3" dependencies: - "@rollup/pluginutils": "npm:^5.1.0" magic-string: "npm:^0.30.5" peerDependencies: - rollup: 2.x || 3.x || 4.x - checksum: 10c0/83e27b6cefe5d2185a39b79fa860644e60b53ed0c32942e1829023e10aa75a2fdd9c0d57e9a46eec4b60804d3fdb1ae51e847d5762f372b0f22fafcaca8c6a0e + rollup: ^2.0.0 || ^3.0.0 || ^4.0.0 + checksum: 10c0/a3d1ecb0672f5dd15c8350d6c741345dfbc267e33d9f1c805a22af264d58fc7abb407bd57b8fceee7d522bd57948898c2f55361572e6735085e69d842055b680 languageName: node linkType: hard