- Select all that apply
+ Select all options that are relevant to you
- Select all that apply
+ Select all options that are relevant to you
- Select all that apply
+ Select all options that are relevant to you
with HTML
@@ -325,7 +315,6 @@ exports[`Checkboxes matches snapshot with HTML in props 1`] = `
- Waste from animal carcasses
+ Email
- Waste from mines or quarries
+ Phone
- Farm or agricultural waste
+ Text message
@@ -423,13 +409,13 @@ exports[`Checkboxes matches snapshot with an exclusive checkbox 1`] = `
- What types of waste do you transport regularly?
+ How do you want to be contacted about this?
- Select all that apply
+ Select all options that are relevant to you
- Waste from animal carcasses
+ Email
- Waste from mines or quarries
+ Phone
- Farm or agricultural waste
+ Text message
None
@@ -525,6 +507,120 @@ exports[`Checkboxes matches snapshot with an exclusive checkbox 1`] = `
`;
+exports[`Checkboxes matches snapshot with an exclusive checkbox and named groups 1`] = `
+
+`;
+
exports[`Checkboxes matches snapshot with error message 1`] = `
diff --git a/src/components/form-elements/checkboxes/components/CheckboxesItem.tsx b/src/components/form-elements/checkboxes/components/CheckboxesItem.tsx
index 84d401abd..bb7721a8e 100644
--- a/src/components/form-elements/checkboxes/components/CheckboxesItem.tsx
+++ b/src/components/form-elements/checkboxes/components/CheckboxesItem.tsx
@@ -21,14 +21,12 @@ export interface CheckboxesItemElementProps extends ComponentPropsWithoutRef<'in
conditional?: ReactNode;
forceShowConditional?: boolean;
conditionalProps?: ComponentPropsWithRef<'div'>;
- exclusive?: boolean;
+ exclusive?: true;
+ exclusiveGroup?: string;
}
export type CheckboxesItemProps = CheckboxesItemElementProps &
- Omit<
- FormElementProps
,
- 'fieldsetProps' | 'label' | 'legend' | 'legendProps'
- >;
+ Omit;
export const CheckboxesItem = forwardRef(
(props, forwardedRef) => {
@@ -43,7 +41,8 @@ export const CheckboxesItem = forwardRef(
checked,
forceShowConditional,
conditionalProps,
- exclusive = false,
+ exclusive,
+ exclusiveGroup,
...rest
} = props;
@@ -62,10 +61,6 @@ export const CheckboxesItem = forwardRef(
const inputProps: ComponentPropsWithDataAttributes<'input'> = rest;
- if (exclusive) {
- inputProps['data-checkbox-exclusive'] = 'true';
- }
-
return (
<>
@@ -74,11 +69,12 @@ export const CheckboxesItem = forwardRef
(
id={inputID}
name={name}
type="checkbox"
- aria-controls={conditional ? `${inputID}--conditional` : undefined}
- aria-describedby={hint ? `${inputID}--hint` : undefined}
- data-checkbox-exclusive-group={name}
checked={checked}
defaultChecked={defaultChecked}
+ data-checkbox-exclusive={exclusive}
+ data-checkbox-exclusive-group={exclusiveGroup}
+ data-aria-controls={conditional ? `${inputID}--conditional` : undefined}
+ aria-describedby={hint ? `${inputID}--hint` : undefined}
ref={forwardedRef}
{...inputProps}
/>
diff --git a/src/components/form-elements/date-input/DateInput.tsx b/src/components/form-elements/date-input/DateInput.tsx
index b34726abf..229ed579a 100644
--- a/src/components/form-elements/date-input/DateInput.tsx
+++ b/src/components/form-elements/date-input/DateInput.tsx
@@ -45,11 +45,7 @@ export interface DateInputElementProps extends Omit<
onChange?: EventHandler;
}
-export type DateInputProps = DateInputElementProps &
- Omit<
- FormElementProps, 'defaultValue' | 'onChange'>, 'div'>,
- 'label' | 'labelProps'
- >;
+export type DateInputProps = DateInputElementProps & Omit;
export type DateInputType = 'day' | 'month' | 'year';
diff --git a/src/components/form-elements/date-input/__tests__/__snapshots__/DateInput.test.tsx.snap b/src/components/form-elements/date-input/__tests__/__snapshots__/DateInput.test.tsx.snap
index 84617216f..7ca7ab7da 100644
--- a/src/components/form-elements/date-input/__tests__/__snapshots__/DateInput.test.tsx.snap
+++ b/src/components/form-elements/date-input/__tests__/__snapshots__/DateInput.test.tsx.snap
@@ -331,9 +331,8 @@ exports[`DateInput matches snapshot with HTML in props 1`] = `
- Error:
+ Error:
-
Error text
with HTML
@@ -543,9 +542,8 @@ exports[`DateInput matches snapshot with custom date fields and error message 1`
- Error:
+ Error:
-
Date of birth must include a day
- Error:
+ Error:
-
Date of birth must include a day
,
- 'error' | 'fieldsetProps' | 'legend' | 'legendProps'
- >;
+ Omit;
const labels: Record<'day' | 'month' | 'year', string> = {
day: 'Day',
diff --git a/src/components/form-elements/error-message/ErrorMessage.tsx b/src/components/form-elements/error-message/ErrorMessage.tsx
index 14f767443..8ce490896 100644
--- a/src/components/form-elements/error-message/ErrorMessage.tsx
+++ b/src/components/form-elements/error-message/ErrorMessage.tsx
@@ -1,8 +1,8 @@
import classNames from 'classnames';
-import { type ComponentPropsWithoutRef, type FC } from 'react';
+import { type ComponentPropsWithoutRef, type FC, type ReactElement } from 'react';
export interface ErrorMessageProps extends ComponentPropsWithoutRef<'span'> {
- visuallyHiddenText?: string;
+ visuallyHiddenText?: string | ReactElement;
}
export const ErrorMessage: FC = ({
@@ -19,7 +19,14 @@ export const ErrorMessage: FC = ({
{visuallyHiddenText ? (
<>
- {`${visuallyHiddenText}:`} {children}
+
+ {typeof visuallyHiddenText === 'string' ? (
+ `${visuallyHiddenText}: `
+ ) : (
+ <>{visuallyHiddenText}: >
+ )}
+
+ {children}
>
) : (
<>{children}>
diff --git a/src/components/form-elements/error-message/__tests__/ErrorMessage.test.tsx b/src/components/form-elements/error-message/__tests__/ErrorMessage.test.tsx
index bae0afe4f..1dba9e147 100644
--- a/src/components/form-elements/error-message/__tests__/ErrorMessage.test.tsx
+++ b/src/components/form-elements/error-message/__tests__/ErrorMessage.test.tsx
@@ -4,26 +4,63 @@ import { ErrorMessage } from '..';
describe('ErrorMessage', () => {
it('matches snapshot', () => {
- const { container } = render(Error );
+ const { container } = render(Enter NHS number );
expect(container).toMatchSnapshot('ErrorMessage');
});
- it('has default visuallyHiddenText', () => {
- const { container } = render(Error );
+ it('has default visually hidden text', () => {
+ const { container } = render(Enter NHS number );
- expect(container.querySelector('.nhsuk-u-visually-hidden')?.textContent).toBe('Error:');
+ const errorMessageEl = container.querySelector('.nhsuk-error-message');
+ const visuallyHiddenEl = container.querySelector('.nhsuk-u-visually-hidden');
+
+ expect(errorMessageEl).toHaveTextContent('Error: Enter NHS number');
+ expect(visuallyHiddenEl).toHaveTextContent('Error:');
});
- it('has custom visuallyHiddenText', () => {
- const { container } = render(Error );
+ it('has custom visually hidden text', () => {
+ const { container } = render(
+ Enter NHS number ,
+ );
+
+ const errorMessageEl = container.querySelector('.nhsuk-error-message');
+ const visuallyHiddenEl = container.querySelector('.nhsuk-u-visually-hidden');
- expect(container.querySelector('.nhsuk-u-visually-hidden')?.textContent).toBe('Custom:');
+ expect(errorMessageEl).toHaveTextContent('Custom: Enter NHS number');
+ expect(visuallyHiddenEl).toHaveTextContent('Custom:');
});
- it('has empty visuallyHiddenText', () => {
- const { container } = render(Error );
+ it('has custom visually hidden HTML', () => {
+ const { container } = render(
+
+ Custom with HTML
+ >
+ }
+ >
+ Enter NHS number
+ ,
+ );
+
+ const errorMessageEl = container.querySelector('.nhsuk-error-message');
+ const visuallyHiddenEl = container.querySelector('.nhsuk-u-visually-hidden');
+
+ expect(errorMessageEl).toHaveTextContent('Custom with HTML: Enter NHS number');
+ expect(visuallyHiddenEl).toHaveTextContent('Custom with HTML:');
+ expect(visuallyHiddenEl).toContainHTML('Custom with HTML :');
+ });
+
+ it('has empty visually hidden text', () => {
+ const { container } = render(
+ Enter NHS number ,
+ );
+
+ const errorMessageEl = container.querySelector('.nhsuk-error-message');
+ const visuallyHiddenEl = container.querySelector('.nhsuk-u-visually-hidden');
- expect(container.querySelector('.nhsuk-u-visually-hidden')).toBeFalsy();
+ expect(errorMessageEl).toHaveTextContent('Enter NHS number');
+ expect(visuallyHiddenEl).not.toBeInTheDocument();
});
});
diff --git a/src/components/form-elements/error-message/__tests__/__snapshots__/ErrorMessage.test.tsx.snap b/src/components/form-elements/error-message/__tests__/__snapshots__/ErrorMessage.test.tsx.snap
index c6a3f5483..18d8c4dd2 100644
--- a/src/components/form-elements/error-message/__tests__/__snapshots__/ErrorMessage.test.tsx.snap
+++ b/src/components/form-elements/error-message/__tests__/__snapshots__/ErrorMessage.test.tsx.snap
@@ -8,10 +8,9 @@ exports[`ErrorMessage matches snapshot: ErrorMessage 1`] = `
- Error:
+ Error:
-
- Error
+ Enter NHS number
`;
diff --git a/src/components/form-elements/file-upload/FileUpload.tsx b/src/components/form-elements/file-upload/FileUpload.tsx
new file mode 100644
index 000000000..ff0b7d73f
--- /dev/null
+++ b/src/components/form-elements/file-upload/FileUpload.tsx
@@ -0,0 +1,76 @@
+'use client';
+
+import classNames from 'classnames';
+import { type FileUpload as FileUploadModule, type FileUploadTranslations } from 'nhsuk-frontend';
+import {
+ forwardRef,
+ useEffect,
+ useImperativeHandle,
+ useRef,
+ useState,
+ type ComponentPropsWithoutRef,
+} from 'react';
+
+import { FormGroup } from '#components/utils/index.js';
+import { type FormElementProps } from '#util/types/FormTypes.js';
+
+export interface FileUploadElementProps extends ComponentPropsWithoutRef<'input'> {
+ chooseFilesButtonClassList?: string[];
+ i18n?: FileUploadTranslations;
+}
+
+export type FileUploadProps = FileUploadElementProps &
+ Omit
;
+
+export const FileUpload = forwardRef(
+ ({ formGroupProps, ...props }, forwardedRef) => {
+ const { chooseFilesButtonClassList, i18n = {}, ...rest } = props;
+
+ const moduleRef = useRef(null);
+ const importRef = useRef>(null);
+ const [instanceError, setInstanceError] = useState();
+ const [instance, setInstance] = useState();
+
+ useImperativeHandle(formGroupProps?.ref, () => moduleRef.current!, [moduleRef]);
+
+ useEffect(() => {
+ if (!moduleRef.current || importRef.current || instance) {
+ return;
+ }
+
+ importRef.current = import('nhsuk-frontend')
+ .then(({ FileUpload }) => setInstance(new FileUpload(moduleRef.current, { i18n })))
+ .catch(setInstanceError);
+ }, [moduleRef, importRef, instance, i18n]);
+
+ if (instanceError) {
+ throw instanceError;
+ }
+
+ return (
+
+ {...rest}
+ formGroupProps={{
+ ...formGroupProps,
+ 'className': classNames('nhsuk-file-upload', formGroupProps?.className),
+ 'data-module': 'nhsuk-file-upload',
+ 'data-choose-files-button-class-list': chooseFilesButtonClassList
+ ? JSON.stringify(chooseFilesButtonClassList)
+ : undefined,
+ 'ref': moduleRef,
+ }}
+ >
+ {({ width, className, error, autoComplete, ...rest }) => (
+
+ )}
+
+ );
+ },
+);
+
+FileUpload.displayName = 'FileUpload';
diff --git a/src/components/form-elements/file-upload/__tests__/FileUpload.test.tsx b/src/components/form-elements/file-upload/__tests__/FileUpload.test.tsx
new file mode 100644
index 000000000..cda5ffd72
--- /dev/null
+++ b/src/components/form-elements/file-upload/__tests__/FileUpload.test.tsx
@@ -0,0 +1,143 @@
+import { createRef } from 'react';
+
+import { FileUpload } from '..';
+
+import { renderClient, renderServer } from '#util/components';
+
+describe('FileUpload', () => {
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('matches snapshot', async () => {
+ const { container } = await renderClient(
+ ,
+ { moduleName: 'nhsuk-file-upload' },
+ );
+
+ expect(container).toMatchSnapshot('FileUpload');
+ });
+
+ it('matches snapshot with HTML in props', async () => {
+ const { container } = await renderClient(
+
+ Example Label text
+ >
+ }
+ labelProps={{
+ isPageHeading: true,
+ size: 'l',
+ }}
+ hint={
+ <>
+ Hint text with HTML
+ >
+ }
+ error={
+ <>
+ Error text with HTML
+ >
+ }
+ id="file-upload"
+ />,
+ { moduleName: 'nhsuk-file-upload' },
+ );
+
+ expect(container).toMatchSnapshot();
+ });
+
+ it('matches snapshot (via server)', async () => {
+ const { container, element } = await renderServer(
+ ,
+ { moduleName: 'nhsuk-file-upload' },
+ );
+
+ expect(container).toMatchSnapshot('server');
+
+ await renderClient(element, {
+ moduleName: 'nhsuk-file-upload',
+ hydrate: true,
+ container,
+ });
+
+ expect(container).toMatchSnapshot('client');
+ });
+
+ it('forwards refs', async () => {
+ const groupRef = createRef();
+ const fieldRef = createRef();
+
+ const { container } = await renderClient(
+ ,
+ { moduleName: 'nhsuk-file-upload' },
+ );
+
+ const groupEl = container.querySelector('div');
+ const inputEl = container.querySelector('input');
+
+ expect(groupRef.current).toBe(groupEl);
+ expect(groupRef.current).toHaveClass('nhsuk-form-group');
+
+ expect(fieldRef.current).toBe(inputEl);
+ expect(fieldRef.current).toHaveClass('nhsuk-file-upload__input');
+ });
+
+ it('should handle DOM events where ref exists', async () => {
+ const ref = createRef();
+ const mock = jest.fn();
+
+ const handleClick = () => {
+ if (!ref.current) return;
+ mock();
+ };
+
+ const { modules } = await renderClient(
+ ,
+ { className: 'nhsuk-file-upload__input' },
+ );
+
+ const [inputEl] = modules;
+ inputEl.click();
+
+ expect(mock).toHaveBeenCalledTimes(1);
+ });
+
+ it('Sets the error class when error message is provided', async () => {
+ const { modules } = await renderClient(
+ <>
+
+
+ >,
+ { moduleName: 'nhsuk-file-upload' },
+ );
+
+ const [inputEl1, inputEl2] = modules;
+
+ expect(inputEl1).not.toHaveClass('nhsuk-form-group--error');
+ expect(inputEl2).toHaveClass('nhsuk-form-group--error');
+ });
+});
diff --git a/src/components/form-elements/file-upload/__tests__/__snapshots__/FileUpload.test.tsx.snap b/src/components/form-elements/file-upload/__tests__/__snapshots__/FileUpload.test.tsx.snap
new file mode 100644
index 000000000..6b2fc1322
--- /dev/null
+++ b/src/components/form-elements/file-upload/__tests__/__snapshots__/FileUpload.test.tsx.snap
@@ -0,0 +1,241 @@
+// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
+
+exports[`FileUpload matches snapshot (via server): client 1`] = `
+
+`;
+
+exports[`FileUpload matches snapshot (via server): server 1`] = `
+
+
+
+ Upload a file 1
+
+
+
+
+`;
+
+exports[`FileUpload matches snapshot with HTML in props 1`] = `
+
+`;
+
+exports[`FileUpload matches snapshot: FileUpload 1`] = `
+
+`;
diff --git a/src/components/form-elements/file-upload/index.ts b/src/components/form-elements/file-upload/index.ts
new file mode 100644
index 000000000..4eb019cab
--- /dev/null
+++ b/src/components/form-elements/file-upload/index.ts
@@ -0,0 +1 @@
+export * from './FileUpload.js';
diff --git a/src/components/form-elements/form/Form.tsx b/src/components/form-elements/form/Form.tsx
index ee475ceda..224eea0e2 100644
--- a/src/components/form-elements/form/Form.tsx
+++ b/src/components/form-elements/form/Form.tsx
@@ -10,7 +10,7 @@ export type FormProps = ComponentPropsWithoutRef<'form'> & {
export const Form: FC = ({ disableErrorFromComponents, ...rest }) => (
-
+
);
diff --git a/src/components/form-elements/index.ts b/src/components/form-elements/index.ts
index 46a3cde8d..197e2b969 100644
--- a/src/components/form-elements/index.ts
+++ b/src/components/form-elements/index.ts
@@ -5,6 +5,7 @@ export * from './date-input/index.js';
export * from './error-message/index.js';
export * from './error-summary/index.js';
export * from './fieldset/index.js';
+export * from './file-upload/index.js';
export * from './form/index.js';
export * from './hint-text/index.js';
export * from './label/index.js';
diff --git a/src/components/form-elements/label/Label.tsx b/src/components/form-elements/label/Label.tsx
index a594f112f..228e6036f 100644
--- a/src/components/form-elements/label/Label.tsx
+++ b/src/components/form-elements/label/Label.tsx
@@ -1,16 +1,14 @@
import classNames from 'classnames';
import { type ComponentPropsWithoutRef, type FC } from 'react';
-import { HeadingLevel, type HeadingLevelProps } from '#components/utils/HeadingLevel.js';
+import { Heading, type HeadingProps } from '#components/typography/Heading.js';
import { type NHSUKSize } from '#util/types/NHSUKTypes.js';
-export interface LabelProps
- extends ComponentPropsWithoutRef<'label'>, Pick {
- isPageHeading?: boolean;
- size?: NHSUKSize;
+export interface LabelComponentProps extends ComponentPropsWithoutRef<'label'> {
+ size?: Exclude;
}
-const LabelComponent: FC> = ({ className, size, ...rest }) => (
+const LabelComponent: FC = ({ className, size, ...rest }) => (
// eslint-disable-next-line jsx-a11y/label-has-associated-control
> = ({ className, size
/>
);
+export interface LabelProps
+ extends ComponentPropsWithoutRef<'label'>, Pick {
+ isPageHeading?: boolean;
+ size?: Exclude;
+}
+
export const Label: FC = (props) => {
const { children, isPageHeading, headingLevel = 'h1', ...rest } = props;
@@ -27,9 +31,9 @@ export const Label: FC = (props) => {
if (isPageHeading || props.headingLevel) {
return (
-
+
{children}
-
+
);
}
diff --git a/src/components/form-elements/label/__tests__/Label.test.tsx b/src/components/form-elements/label/__tests__/Label.test.tsx
index 914dd0b5d..09e3df5ca 100644
--- a/src/components/form-elements/label/__tests__/Label.test.tsx
+++ b/src/components/form-elements/label/__tests__/Label.test.tsx
@@ -2,8 +2,6 @@ import { render } from '@testing-library/react';
import { Label, type LabelProps } from '..';
-import { type NHSUKSize } from '#util/types';
-
describe('Label', () => {
it('can be defaulted', () => {
const { container } = render(Text );
@@ -12,7 +10,7 @@ describe('Label', () => {
expect(container.innerHTML).toBe('Text ');
});
- it.each(['s', 'm', 'l', 'xl'])('renders with custom size %s', (size) => {
+ it.each(['s', 'm', 'l', 'xl'])('renders with custom size %s', (size) => {
const { container } = render(Text );
const labelEl = container.querySelector('.nhsuk-label');
@@ -27,12 +25,12 @@ describe('Label', () => {
const headingEl = container.querySelector('.nhsuk-label-wrapper');
const labelEl = headingEl?.querySelector('.nhsuk-label');
- expect(headingEl?.tagName).toBe('H1');
+ expect(headingEl).toHaveProperty('tagName', 'H1');
expect(labelEl).toHaveTextContent('Text');
expect(labelEl).not.toHaveClass(`nhsuk-label--xl`);
});
- it.each(['s', 'm', 'l', 'xl'])(
+ it.each(['s', 'm', 'l', 'xl'])(
'renders as page heading with custom size %s',
(size) => {
const { container } = render(
@@ -44,7 +42,7 @@ describe('Label', () => {
const headingEl = container.querySelector('.nhsuk-label-wrapper');
const labelEl = headingEl?.querySelector('.nhsuk-label');
- expect(headingEl?.tagName).toBe('H1');
+ expect(headingEl).toHaveProperty('tagName', 'H1');
expect(labelEl).toHaveTextContent('Text');
expect(labelEl).toHaveClass(`nhsuk-label--${size}`);
},
@@ -61,7 +59,7 @@ describe('Label', () => {
const headingEl = container.querySelector('.nhsuk-label-wrapper');
const labelEl = headingEl?.querySelector('.nhsuk-label');
- expect(headingEl?.tagName).toBe(props?.headingLevel?.toUpperCase());
+ expect(headingEl).toHaveProperty('tagName', props.headingLevel?.toUpperCase());
expect(labelEl).toHaveTextContent('Text');
});
diff --git a/src/components/form-elements/legend/Legend.tsx b/src/components/form-elements/legend/Legend.tsx
index 9f12f7ce3..b1dcd4de9 100644
--- a/src/components/form-elements/legend/Legend.tsx
+++ b/src/components/form-elements/legend/Legend.tsx
@@ -1,17 +1,17 @@
import classNames from 'classnames';
import { type ComponentPropsWithoutRef, type FC } from 'react';
-import { HeadingLevel, type HeadingLevelProps } from '#components/utils/HeadingLevel.js';
+import { Heading, type HeadingProps } from '#components/typography/Heading.js';
import { type NHSUKSize } from '#util/types/NHSUKTypes.js';
export interface LegendProps
- extends ComponentPropsWithoutRef<'legend'>, Pick {
+ extends ComponentPropsWithoutRef<'legend'>, Pick {
isPageHeading?: boolean;
- size?: NHSUKSize;
+ size?: Exclude;
}
-export const Legend: FC = (params) => {
- const { className, children, isPageHeading, headingLevel = 'h1', size, ...rest } = params;
+export const Legend: FC = (props) => {
+ const { className, children, isPageHeading, headingLevel = 'h1', size, ...rest } = props;
if (!children) {
return null;
@@ -26,10 +26,10 @@ export const Legend: FC = (params) => {
)}
{...rest}
>
- {isPageHeading || params.headingLevel ? (
-
+ {isPageHeading || props.headingLevel ? (
+
{children}
-
+
) : (
children
)}
diff --git a/src/components/form-elements/legend/__tests__/Legend.test.tsx b/src/components/form-elements/legend/__tests__/Legend.test.tsx
index 31e22b9b2..b66b7279a 100644
--- a/src/components/form-elements/legend/__tests__/Legend.test.tsx
+++ b/src/components/form-elements/legend/__tests__/Legend.test.tsx
@@ -2,8 +2,6 @@ import { render } from '@testing-library/react';
import { Legend, type LegendProps } from '..';
-import { type NHSUKSize } from '#util/types/NHSUKTypes';
-
describe('Legend', () => {
it('matches snapshot', () => {
const { container } = render(Text );
@@ -19,10 +17,10 @@ describe('Legend', () => {
expect(legendEl).toHaveTextContent('Text');
expect(legendEl).not.toHaveClass('nhsuk-fieldset__legend--xl');
- expect(headingEl?.tagName).toBe('H1');
+ expect(headingEl).toHaveProperty('tagName', 'H1');
});
- it.each(['s', 'm', 'l', 'xl'])(
+ it.each(['s', 'm', 'l', 'xl'])(
'renders as page heading with custom size %s',
(size) => {
const { container } = render(
@@ -36,7 +34,7 @@ describe('Legend', () => {
expect(legendEl).toHaveTextContent('Text');
expect(legendEl).toHaveClass(`nhsuk-fieldset__legend--${size}`);
- expect(headingEl?.tagName).toBe('H1');
+ expect(headingEl).toHaveProperty('tagName', 'H1');
},
);
@@ -52,7 +50,7 @@ describe('Legend', () => {
const headingEl = legendEl?.querySelector('.nhsuk-fieldset__heading');
expect(legendEl).toHaveTextContent('Text');
- expect(headingEl?.tagName).toBe(props?.headingLevel?.toUpperCase());
+ expect(headingEl).toHaveProperty('tagName', props.headingLevel?.toUpperCase());
});
it('renders null with no children', () => {
diff --git a/src/components/form-elements/password-input/PasswordInput.tsx b/src/components/form-elements/password-input/PasswordInput.tsx
index 4c4ee1d84..c0acf1a42 100644
--- a/src/components/form-elements/password-input/PasswordInput.tsx
+++ b/src/components/form-elements/password-input/PasswordInput.tsx
@@ -1,7 +1,10 @@
'use client';
import classNames from 'classnames';
-import { type PasswordInput as PasswordInputModule } from 'nhsuk-frontend';
+import {
+ type PasswordInput as PasswordInputModule,
+ type PasswordInputTranslations,
+} from 'nhsuk-frontend';
import {
forwardRef,
useEffect,
@@ -23,13 +26,11 @@ export interface PasswordInputElementProps extends ComponentPropsWithoutRef<'inp
showPasswordText?: string;
showPasswordAriaLabelText?: string;
buttonProps?: ComponentPropsWithRef<'button'>;
+ i18n?: PasswordInputTranslations;
}
export type PasswordInputProps = PasswordInputElementProps &
- Omit<
- FormElementProps,
- 'fieldsetProps' | 'legend' | 'legendProps'
- >;
+ Omit;
const PasswordInputButton: FC> = ({
children,
@@ -49,7 +50,7 @@ const PasswordInputButton: FC> = ({
);
export const PasswordInput = forwardRef(
- ({ buttonProps, formGroupProps, ...props }, forwardedRef) => {
+ ({ buttonProps, formGroupProps, i18n = {}, ...props }, forwardedRef) => {
const { showPasswordText, showPasswordAriaLabelText, ...rest } = props;
const moduleRef = useRef(null);
@@ -65,9 +66,9 @@ export const PasswordInput = forwardRef(
}
importRef.current = import('nhsuk-frontend')
- .then(({ PasswordInput }) => setInstance(new PasswordInput(moduleRef.current)))
+ .then(({ PasswordInput }) => setInstance(new PasswordInput(moduleRef.current, { i18n })))
.catch(setInstanceError);
- }, [moduleRef, importRef, instance]);
+ }, [moduleRef, importRef, instance, i18n]);
if (instanceError) {
throw instanceError;
@@ -80,15 +81,6 @@ export const PasswordInput = forwardRef(
...formGroupProps,
'className': classNames('nhsuk-password-input', formGroupProps?.className),
'data-module': 'nhsuk-password-input',
- 'afterInput': ({ id }) => (
-
- {showPasswordText ?? 'Show'}
-
- ),
'ref': moduleRef,
}}
>
@@ -109,6 +101,15 @@ export const PasswordInput = forwardRef(
{...rest}
/>
)}
+ {({ id }) => (
+
+ {showPasswordText ?? 'Show'}
+
+ )}
);
},
diff --git a/src/components/form-elements/radios/Radios.tsx b/src/components/form-elements/radios/Radios.tsx
index 62398ab6c..6d7ab07db 100644
--- a/src/components/form-elements/radios/Radios.tsx
+++ b/src/components/form-elements/radios/Radios.tsx
@@ -24,8 +24,7 @@ export interface RadiosElementProps extends ComponentPropsWithoutRef<'div'> {
small?: boolean;
}
-export type RadiosProps = RadiosElementProps &
- Omit, 'label' | 'labelProps'>;
+export type RadiosProps = RadiosElementProps & Omit;
const RadiosComponent = forwardRef((props, forwardedRef) => {
const { children, idPrefix, ...rest } = props;
diff --git a/src/components/form-elements/radios/__tests__/__snapshots__/Radios.test.tsx.snap b/src/components/form-elements/radios/__tests__/__snapshots__/Radios.test.tsx.snap
index 6dd8f5fa1..534cc472d 100644
--- a/src/components/form-elements/radios/__tests__/__snapshots__/Radios.test.tsx.snap
+++ b/src/components/form-elements/radios/__tests__/__snapshots__/Radios.test.tsx.snap
@@ -242,9 +242,8 @@ exports[`Radios matches snapshot with HTML in props 1`] = `
- Error:
+ Error:
-
Error text
with HTML
diff --git a/src/components/form-elements/radios/components/RadiosItem.tsx b/src/components/form-elements/radios/components/RadiosItem.tsx
index ceb668886..287006a41 100644
--- a/src/components/form-elements/radios/components/RadiosItem.tsx
+++ b/src/components/form-elements/radios/components/RadiosItem.tsx
@@ -24,10 +24,7 @@ export interface RadiosItemElementProps extends ComponentPropsWithoutRef<'input'
}
export type RadiosItemProps = RadiosItemElementProps &
- Omit<
- FormElementProps,
- 'fieldsetProps' | 'label' | 'legend' | 'legendProps'
- >;
+ Omit;
export const RadiosItem = forwardRef((props, forwardedRef) => {
const {
@@ -74,10 +71,10 @@ export const RadiosItem = forwardRef((props,
id={inputID}
name={name}
type="radio"
- aria-controls={conditional ? `${inputID}--conditional` : undefined}
- aria-describedby={hint ? `${inputID}--hint` : undefined}
checked={checked}
defaultChecked={defaultChecked}
+ data-aria-controls={conditional ? `${inputID}--conditional` : undefined}
+ aria-describedby={hint ? `${inputID}--hint` : undefined}
ref={forwardedRef}
{...rest}
/>
diff --git a/src/components/form-elements/select/Select.tsx b/src/components/form-elements/select/Select.tsx
index eaaff3ad0..ba998025c 100644
--- a/src/components/form-elements/select/Select.tsx
+++ b/src/components/form-elements/select/Select.tsx
@@ -9,7 +9,7 @@ import { type FormElementProps } from '#util/types/FormTypes.js';
export type SelectElementProps = ComponentPropsWithoutRef<'select'>;
export type SelectProps = SelectElementProps &
- Omit, 'fieldsetProps' | 'legend' | 'legendProps'>;
+ Omit;
const SelectComponent = forwardRef(
({ children, ...rest }, forwardedRef) => (
diff --git a/src/components/form-elements/text-input/TextInput.tsx b/src/components/form-elements/text-input/TextInput.tsx
index 9aba917f3..1f3a65eb0 100644
--- a/src/components/form-elements/text-input/TextInput.tsx
+++ b/src/components/form-elements/text-input/TextInput.tsx
@@ -15,19 +15,16 @@ export interface TextInputElementProps extends ComponentPropsWithoutRef<'input'>
}
export type TextInputProps = TextInputElementProps &
- Omit<
- FormElementProps,
- 'fieldsetProps' | 'legend' | 'legendProps'
- >;
+ Omit;
const TextInputPrefix: FC> = ({ prefix }) => (
-
+
{prefix}
);
const TextInputSuffix: FC
> = ({ suffix }) => (
-
+
{suffix}
);
@@ -38,8 +35,24 @@ export const TextInput = forwardRef
(
{...props}
formGroupProps={{
...formGroupProps,
- beforeInput: prefix ? () => : undefined,
- afterInput: suffix ? () => : undefined,
+
+ // Prevent form group 'beforeInput' overriding prefix
+ beforeInput:
+ formGroupProps?.beforeInput || prefix ? (
+ <>
+ {formGroupProps?.beforeInput}
+ {prefix ? : null}
+ >
+ ) : undefined,
+
+ // Prevent form group 'afterInput' overriding suffix
+ afterInput:
+ formGroupProps?.afterInput || suffix ? (
+ <>
+ {formGroupProps?.afterInput}
+ {suffix ? : null}
+ >
+ ) : undefined,
}}
>
{({ width, className, code, error, type = 'text', prefix, suffix, ...rest }) => (
diff --git a/src/components/form-elements/text-input/__tests__/TextInput.test.tsx b/src/components/form-elements/text-input/__tests__/TextInput.test.tsx
index b5ffc1df4..7874d55c6 100644
--- a/src/components/form-elements/text-input/__tests__/TextInput.test.tsx
+++ b/src/components/form-elements/text-input/__tests__/TextInput.test.tsx
@@ -149,8 +149,8 @@ describe('TextInput', () => {
({ prefix, suffix }) => {
const { container } = render( );
- const prefixElement = container.querySelector('.nhsuk-input-wrapper > .nhsuk-input__prefix');
- const suffixElement = container.querySelector('.nhsuk-input-wrapper > .nhsuk-input__suffix');
+ const prefixElement = container.querySelector('.nhsuk-input-wrapper__prefix');
+ const suffixElement = container.querySelector('.nhsuk-input-wrapper__suffix');
if (prefix) {
expect(prefixElement).not.toBeNull();
diff --git a/src/components/form-elements/text-input/__tests__/__snapshots__/TextInput.test.tsx.snap b/src/components/form-elements/text-input/__tests__/__snapshots__/TextInput.test.tsx.snap
index fe32e07ab..b9a3bc6be 100644
--- a/src/components/form-elements/text-input/__tests__/__snapshots__/TextInput.test.tsx.snap
+++ b/src/components/form-elements/text-input/__tests__/__snapshots__/TextInput.test.tsx.snap
@@ -81,9 +81,8 @@ exports[`TextInput matches snapshot with HTML in props 1`] = `
- Error:
+ Error:
-
Error text
with HTML
diff --git a/src/components/form-elements/textarea/Textarea.tsx b/src/components/form-elements/textarea/Textarea.tsx
index de4cc2143..fad9c3cf2 100644
--- a/src/components/form-elements/textarea/Textarea.tsx
+++ b/src/components/form-elements/textarea/Textarea.tsx
@@ -9,10 +9,7 @@ import { type FormElementProps } from '#util/types/FormTypes.js';
export type TextareaElementProps = ComponentPropsWithoutRef<'textarea'>;
export type TextareaProps = TextareaElementProps &
- Omit<
- FormElementProps,
- 'fieldsetProps' | 'legend' | 'legendProps'
- >;
+ Omit;
export const Textarea = forwardRef((props, forwardedRef) => (
inputType="textarea" {...props}>
diff --git a/src/components/navigation/action-link/ActionLink.tsx b/src/components/navigation/action-link/ActionLink.tsx
index 306f64700..a384377b6 100644
--- a/src/components/navigation/action-link/ActionLink.tsx
+++ b/src/components/navigation/action-link/ActionLink.tsx
@@ -4,11 +4,21 @@ import { forwardRef } from 'react';
import { ArrowRightCircleIcon } from '#components/content-presentation/index.js';
import { type AsElementLink } from '#util/types/LinkTypes.js';
-export type ActionLinkProps = AsElementLink;
+export interface ActionLinkProps extends AsElementLink {
+ reverse?: boolean;
+}
export const ActionLink = forwardRef(
- ({ children, className, asElement: Element = 'a', ...rest }, forwardedRef) => (
-
+ ({ children, className, reverse, asElement: Element = 'a', ...rest }, forwardedRef) => (
+
{children}
diff --git a/src/components/navigation/back-link/BackLink.tsx b/src/components/navigation/back-link/BackLink.tsx
index 8c9f77ddb..60b2c5e8f 100644
--- a/src/components/navigation/back-link/BackLink.tsx
+++ b/src/components/navigation/back-link/BackLink.tsx
@@ -1,16 +1,45 @@
import classNames from 'classnames';
-import { forwardRef } from 'react';
+import { forwardRef, type ReactElement } from 'react';
import { type AsElementLink } from '#util/types/LinkTypes.js';
-export type BackLinkProps = AsElementLink;
+export interface BackLinkProps extends AsElementLink {
+ reverse?: boolean;
+ visuallyHiddenText?: string | ReactElement;
+}
-export const BackLink = forwardRef(
- ({ children = 'Back', className, asElement: Element = 'a', ...rest }, forwardedRef) => (
-
- {children}
+export const BackLink = forwardRef((props, forwardedRef) => {
+ const {
+ children = 'Back',
+ className,
+ reverse,
+ visuallyHiddenText,
+ asElement: Element = 'a',
+ ...rest
+ } = props;
+
+ return (
+
+ {visuallyHiddenText ? (
+ <>
+
+ {typeof visuallyHiddenText === 'string' ? (
+ `${visuallyHiddenText} `
+ ) : (
+ <>{visuallyHiddenText} >
+ )}
+
+ {children}
+ >
+ ) : (
+ <>{children}>
+ )}
- ),
-);
+ );
+});
BackLink.displayName = 'BackLink';
diff --git a/src/components/navigation/breadcrumb/Breadcrumb.tsx b/src/components/navigation/breadcrumb/Breadcrumb.tsx
index d05ac575f..e63c97926 100644
--- a/src/components/navigation/breadcrumb/Breadcrumb.tsx
+++ b/src/components/navigation/breadcrumb/Breadcrumb.tsx
@@ -5,10 +5,12 @@ import { BreadcrumbBack, BreadcrumbItem } from './components/index.js';
import { childIsOfComponentType } from '#util/types/index.js';
-export type BreadcrumbProps = ComponentPropsWithoutRef<'nav'>;
+export interface BreadcrumbProps extends ComponentPropsWithoutRef<'nav'> {
+ reverse?: boolean;
+}
const BreadcrumbComponent = forwardRef((props, forwardedRef) => {
- const { children, className, 'aria-label': ariaLabel = 'Breadcrumb', ...rest } = props;
+ const { children, className, 'aria-label': ariaLabel = 'Breadcrumb', reverse, ...rest } = props;
// Split off any "Item" components
const { ItemChildren, OtherChildren } = Children.toArray(children).reduce<{
@@ -31,7 +33,11 @@ const BreadcrumbComponent = forwardRef((props, for
return (
{
expect(child.classList).toContain('nhsuk-breadcrumb__list-item');
});
- expect(container.querySelector('#otherElement')?.textContent).toEqual('Test Element');
- expect(container.querySelector('.nhsuk-back-link')?.textContent).toBe('Back to Breadcrumb 2');
+ expect(container.querySelector('#otherElement')).toHaveTextContent('Test Element');
+ expect(container.querySelector('.nhsuk-back-link')).toHaveTextContent('Back to Breadcrumb 2');
});
it('passes through other children fine', () => {
@@ -63,7 +63,7 @@ describe('Breadcrumb', () => {
,
);
- expect(container.querySelector('#otherElement')?.textContent).toEqual('Test Element');
+ expect(container.querySelector('#otherElement')).toHaveTextContent('Test Element');
});
it.each([undefined, 'Test label'])(
@@ -85,9 +85,11 @@ describe('Breadcrumb', () => {
,
);
- const hiddenSpan = container.querySelector('.nhsuk-back-link > .nhsuk-u-visually-hidden');
+ const visuallyHiddenEl = container.querySelector(
+ '.nhsuk-back-link > .nhsuk-u-visually-hidden',
+ );
- expect(hiddenSpan?.textContent).toBe('Back to ');
+ expect(visuallyHiddenEl).toHaveTextContent('Back to');
});
it('renders as custom element', () => {
diff --git a/src/components/navigation/breadcrumb/components/BreadcrumbBack.tsx b/src/components/navigation/breadcrumb/components/BreadcrumbBack.tsx
index 8fb8c5fc1..2d0023c0a 100644
--- a/src/components/navigation/breadcrumb/components/BreadcrumbBack.tsx
+++ b/src/components/navigation/breadcrumb/components/BreadcrumbBack.tsx
@@ -1,14 +1,10 @@
import { forwardRef } from 'react';
-import { BackLink } from '#components/navigation/back-link/index.js';
-import { type AsElementLink } from '#util/types/index.js';
+import { BackLink, type BackLinkProps } from '#components/navigation/back-link/index.js';
-export type BreadcrumbBackProps = AsElementLink;
-
-export const BreadcrumbBack = forwardRef(
+export const BreadcrumbBack = forwardRef(
({ children, ...rest }, forwardedRef) => (
-
- Back to
+
{children}
),
diff --git a/src/components/navigation/card/Card.tsx b/src/components/navigation/card/Card.tsx
index 4e3551754..4baef2bcf 100644
--- a/src/components/navigation/card/Card.tsx
+++ b/src/components/navigation/card/Card.tsx
@@ -1,55 +1,121 @@
-'use client';
-
import classNames from 'classnames';
-import { forwardRef, type ComponentPropsWithoutRef } from 'react';
+import { Children, forwardRef, type ComponentPropsWithoutRef } from 'react';
-import { CardContext } from './CardContext.js';
import {
- CardContent,
+ CardAction,
CardDescription,
CardGroup,
CardGroupItem,
+ CardHeadingContainer,
CardHeading,
CardImage,
CardLink,
} from './components/index.js';
-import { cardTypeIsCareCard, type CardType } from '#util/types/index.js';
+import { ChevronRightCircleIcon } from '#components/content-presentation/icons/individual/index.js';
+import { childIsOfComponentType, type CareCardType } from '#util/types/index.js';
export interface CardProps extends ComponentPropsWithoutRef<'div'> {
clickable?: boolean;
- cardType?: CardType;
+ feature?: boolean;
+ primary?: boolean;
+ secondary?: boolean;
+ warning?: boolean;
+ cardType?: CareCardType;
}
const CardComponent = forwardRef((props, forwardedRef) => {
- const { className, clickable, children, cardType, ...rest } = props;
-
- let cardClassNames = classNames(
- 'nhsuk-card',
- { 'nhsuk-card--clickable': cardType === 'clickable' || clickable },
- { 'nhsuk-card--feature': cardType === 'feature' },
- { 'nhsuk-card--primary': cardType === 'primary' },
- { 'nhsuk-card--secondary': cardType === 'secondary' },
+ const {
className,
+ children,
+ clickable,
+ feature,
+ primary,
+ secondary,
+ warning,
+ cardType,
+ ...rest
+ } = props;
+
+ const items = Children.toArray(children);
+
+ // Allow single image
+ const imageItem = items.find((child) => childIsOfComponentType(child, CardImage));
+
+ // Allow single heading
+ const headingItem = items.find((child) => childIsOfComponentType(child, CardHeading));
+ const headingText = headingItem?.props.children?.toString();
+
+ // Allow multiple actions
+ const actionItems = items.filter((child) => childIsOfComponentType(child, CardAction));
+
+ // Only content items remain
+ const contentItems = items.filter(
+ (child) =>
+ child !== imageItem &&
+ child !== headingItem &&
+ !actionItems.some((action) => action === child),
);
- if (cardTypeIsCareCard(cardType)) {
- cardClassNames = classNames(
- cardClassNames,
- 'nhsuk-card--care',
- `nhsuk-card--care--${cardType}`,
- );
- }
+ // Determine actions item
+ const actionsItem = (
+ <>
+ {actionItems?.length === 1 ? (
+
+
+
+ ) : null}
+ {actionItems?.length > 1 ? (
+
+ {actionItems.map(({ key, props }) => (
+
+
+
+ ))}
+
+ ) : null}
+ >
+ );
return (
-
-
- {children}
-
+
+ {cardType || actionItems.length ? (
+ <>
+ {imageItem}
+ {headingItem || actionItems.length ? (
+
+ {headingItem}
+ {actionsItem}
+
+ ) : null}
+ {contentItems.length ?
{contentItems}
: null}
+ >
+ ) : (
+ <>
+ {imageItem}
+ {headingItem || contentItems.length ? (
+
+ {headingItem}
+ {contentItems}
+ {primary ? : null}
+
+ ) : null}
+ >
+ )}
);
});
@@ -61,7 +127,7 @@ export const Card = Object.assign(CardComponent, {
Description: CardDescription,
Image: CardImage,
Link: CardLink,
- Content: CardContent,
Group: CardGroup,
GroupItem: CardGroupItem,
+ Action: CardAction,
});
diff --git a/src/components/navigation/card/CardContext.ts b/src/components/navigation/card/CardContext.ts
deleted file mode 100644
index 1af685a29..000000000
--- a/src/components/navigation/card/CardContext.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-'use client';
-
-import { createContext } from 'react';
-
-import { type CardType } from '#util/types/index.js';
-
-export interface ICardContext {
- cardType?: CardType;
-}
-
-export const CardContext = createContext
({});
diff --git a/src/components/navigation/card/__tests__/Card.test.tsx b/src/components/navigation/card/__tests__/Card.test.tsx
index 285e38e2a..d5fd0d46d 100644
--- a/src/components/navigation/card/__tests__/Card.test.tsx
+++ b/src/components/navigation/card/__tests__/Card.test.tsx
@@ -1,22 +1,18 @@
-/* eslint-disable jsx-a11y/anchor-is-valid */
import { render } from '@testing-library/react';
-import { Card } from '..';
+import { Card, type CardProps } from '..';
+import { type HeadingProps } from '#components/typography/Heading.js';
import { renderClient, renderServer } from '#util/components';
-import { type CardType } from '#util/types';
+import { type CareCardType } from '#util/types';
describe('Card', () => {
- it('matches snapshot', async () => {
+ it('matches snapshot with single action', async () => {
const { container } = await renderClient(
-
-
- If you need help now but it's not an emergency
-
- Go to 111.nhs.uk or call 111
-
-
+ Regional Manager
+ Delete
+ Karen Francis
,
{ className: 'nhsuk-card' },
);
@@ -24,16 +20,27 @@ describe('Card', () => {
expect(container).toMatchSnapshot();
});
- it('matches snapshot (via server)', async () => {
+ it('matches snapshot with multiple actions', async () => {
+ const { container } = await renderClient(
+
+ Regional Manager
+ Delete
+ Withdraw
+ Karen Francis
+ ,
+ { className: 'nhsuk-card' },
+ );
+
+ expect(container).toMatchSnapshot();
+ });
+
+ it('matches snapshot with multiple actions (via server)', async () => {
const { container, element } = await renderServer(
-
-
- If you need help now but it's not an emergency
-
- Go to 111.nhs.uk or call 111
-
-
+ Regional Manager
+ Delete
+ Withdraw
+ Karen Francis
,
{ className: 'nhsuk-card' },
);
@@ -52,13 +59,11 @@ describe('Card', () => {
it('can render Card.Link as different elements', () => {
const { container } = render(
-
-
-
- Click me!
-
-
-
+
+
+ Click me!
+
+
,
);
@@ -68,14 +73,12 @@ describe('Card', () => {
it('adds clickable modifier', () => {
const { container } = render(
-
-
- Introduction to care and support
-
-
- A quick guide for people who have care and support needs and their carers
-
-
+
+ Introduction to care and support
+
+
+ A quick guide for people who have care and support needs and their carers
+
,
);
@@ -84,33 +87,50 @@ describe('Card', () => {
expect(cardEl).toHaveClass('nhsuk-card--clickable');
});
- it.each<{ cardType: CardType }>([
- { cardType: 'clickable' },
- { cardType: 'feature' },
- { cardType: 'primary' },
- { cardType: 'secondary' },
- ])('adds $cardType card type modifier', ({ cardType }) => {
+ it.each<{ modifier: keyof CardProps }>([
+ { modifier: 'clickable' },
+ { modifier: 'feature' },
+ { modifier: 'primary' },
+ { modifier: 'secondary' },
+ { modifier: 'warning' },
+ ])('adds $modifier card type modifier', ({ modifier }) => {
const { container } = render(
-
-
- Feature card heading
- Feature card description
-
+
+ Example card heading
+ Example card description
,
);
const cardEl = container.querySelector('.nhsuk-card');
const cardHeadingEl = cardEl?.querySelector('.nhsuk-card__heading');
- expect(cardEl).toHaveClass(`nhsuk-card--${cardType}`);
+ expect(cardEl).toHaveClass(`nhsuk-card--${modifier}`);
expect(cardHeadingEl).toHaveClass('nhsuk-card__heading');
- expect(cardHeadingEl?.tagName).toBe('H2');
+ expect(cardHeadingEl).toHaveProperty('tagName', 'H2');
+ });
+
+ it.each>>([
+ { size: 's' },
+ { size: 'm' },
+ { size: 'l' },
+ { size: 'xl' },
+ ])('renders the heading with custom size $size', (props) => {
+ const { container } = render(
+
+ Example card heading
+ ,
+ );
+
+ const cardEl = container.querySelector('.nhsuk-card');
+ const cardHeadingEl = cardEl?.querySelector('.nhsuk-card__heading');
+
+ expect(cardHeadingEl).toHaveClass(`nhsuk-heading-${props.size}`);
});
describe('Care card variant', () => {
describe.each<{
heading: string;
- cardType: CardType;
+ cardType: CareCardType;
visuallyHidden: string;
}>([
{
@@ -159,7 +179,42 @@ describe('Card', () => {
,
);
- expect(container.querySelector('h2')).toHaveAccessibleName(`${visuallyHidden}: ${heading}`);
+ const headingEl = container.querySelector('h2');
+
+ expect(headingEl).toHaveTextContent(`${visuallyHidden}: ${heading}`);
+ });
+
+ it('renders the heading with custom visually hidden text', () => {
+ const { container } = render(
+
+ {heading}
+ ,
+ );
+
+ const headingEl = container.querySelector('h2');
+
+ expect(headingEl).toHaveTextContent(`Custom: ${heading}`);
+ });
+
+ it('renders the heading with custom visually hidden HTML', () => {
+ const { container } = render(
+
+
+ Custom with HTML
+ >
+ }
+ >
+ {heading}
+
+ ,
+ );
+
+ const headingEl = container.querySelector('h2');
+
+ expect(headingEl).toHaveTextContent(`Custom with HTML: ${heading}`);
+ expect(headingEl).toContainHTML('Custom with HTML ');
});
it('renders the heading with custom heading level', () => {
@@ -169,7 +224,27 @@ describe('Card', () => {
,
);
- expect(container.querySelector('h3')).toHaveAccessibleName(`${visuallyHidden}: ${heading}`);
+ const headingEl = container.querySelector('h3');
+
+ expect(headingEl).toHaveTextContent(`${visuallyHidden}: ${heading}`);
+ });
+
+ it.each>>([
+ { size: 's' },
+ { size: 'm' },
+ { size: 'l' },
+ { size: 'xl' },
+ ])('renders the heading with custom size $size', (props) => {
+ const { container } = render(
+
+ Example card heading
+ ,
+ );
+
+ const cardEl = container.querySelector('.nhsuk-card');
+ const cardHeadingEl = cardEl?.querySelector('.nhsuk-card__heading');
+
+ expect(cardHeadingEl).toHaveClass(`nhsuk-heading-${props.size}`);
});
});
});
@@ -180,18 +255,14 @@ describe('Card', () => {
-
- Test Card 1
- Test Card 1 Description
-
+ Test Card 1
+ Test Card 1 Description
-
- Test Card 2
- Test Card 2 Description
-
+ Test Card 2
+ Test Card 2 Description
,
diff --git a/src/components/navigation/card/__tests__/__snapshots__/Card.test.tsx.snap b/src/components/navigation/card/__tests__/__snapshots__/Card.test.tsx.snap
index bb1716ff7..077eb9b17 100644
--- a/src/components/navigation/card/__tests__/__snapshots__/Card.test.tsx.snap
+++ b/src/components/navigation/card/__tests__/__snapshots__/Card.test.tsx.snap
@@ -146,127 +146,240 @@ exports[`Card Care card variant urgent matches the snapshot 1`] = `
`;
-exports[`Card matches snapshot (via server): client 1`] = `
+exports[`Card matches snapshot with multiple actions (via server): client 1`] = `
`;
-exports[`Card matches snapshot (via server): server 1`] = `
+exports[`Card matches snapshot with multiple actions (via server): server 1`] = `
`;
-exports[`Card matches snapshot 1`] = `
+exports[`Card matches snapshot with multiple actions 1`] = `
-
- If you need help now but it's not an emergency
+ Regional Manager
+
+
+
- Go to
-
- 111.nhs.uk
-
- or
+ Karen Francis
+
+
+
+
+`;
+
+exports[`Card matches snapshot with single action 1`] = `
+
+
+
+
+ Regional Manager
+
+
+
+
diff --git a/src/components/navigation/card/components/CardAction.tsx b/src/components/navigation/card/components/CardAction.tsx
new file mode 100644
index 000000000..696b92852
--- /dev/null
+++ b/src/components/navigation/card/components/CardAction.tsx
@@ -0,0 +1,33 @@
+import { forwardRef, type ReactElement } from 'react';
+
+import { type AsElementLink } from '#util/types/LinkTypes.js';
+
+export interface CardActionProps extends AsElementLink
{
+ heading?: string | ReactElement;
+ visuallyHiddenText?: string | ReactElement;
+}
+
+export const CardAction = forwardRef((props, forwardedRef) => {
+ const {
+ children,
+ className,
+ heading,
+ visuallyHiddenText,
+ asElement: Element = 'a',
+ ...rest
+ } = props;
+
+ return (
+
+ {children}
+ {heading || visuallyHiddenText ? (
+
+ {visuallyHiddenText ? <> {visuallyHiddenText}> : null}
+ {heading ? <> ({heading})> : null}
+
+ ) : null}
+
+ );
+});
+
+CardAction.displayName = 'Card.Action';
diff --git a/src/components/navigation/card/components/CardContent.tsx b/src/components/navigation/card/components/CardContent.tsx
deleted file mode 100644
index 3975f15b9..000000000
--- a/src/components/navigation/card/components/CardContent.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-'use client';
-
-import classNames from 'classnames';
-import { forwardRef, type ComponentPropsWithoutRef } from 'react';
-
-export type CardContentProps = ComponentPropsWithoutRef<'div'>;
-
-export const CardContent = forwardRef(
- ({ className, ...rest }, forwardedRef) => (
-
- ),
-);
-
-CardContent.displayName = 'Card.Content';
diff --git a/src/components/navigation/card/components/CardHeading.tsx b/src/components/navigation/card/components/CardHeading.tsx
index c579b37dd..9a324f0e0 100644
--- a/src/components/navigation/card/components/CardHeading.tsx
+++ b/src/components/navigation/card/components/CardHeading.tsx
@@ -1,64 +1,63 @@
-'use client';
-
import classNames from 'classnames';
-import { forwardRef, useContext } from 'react';
+import { Children, forwardRef, type FC } from 'react';
-import { CardContext } from '../CardContext.js';
+import { Heading, type HeadingProps } from '#components/typography/Heading.js';
+import { childIsOfComponentType, type CareCardType } from '#util/types/index.js';
-import { HeadingLevel, type HeadingLevelProps } from '#components/utils/HeadingLevel.js';
-import { cardTypeIsCareCard, type CareCardType } from '#util/types/index.js';
+const genHiddenText = (cardType?: CareCardType) => {
+ if (!cardType) {
+ return;
+ }
-const genHiddenText = (cardType: CareCardType): string => {
switch (cardType) {
case 'emergency':
- return 'Immediate action required: ';
+ return 'Immediate action required';
case 'urgent':
- return 'Urgent advice: ';
+ return 'Urgent advice';
default:
- return 'Non-urgent advice: ';
+ return 'Non-urgent advice';
}
};
-const CareHeading = forwardRef(
- ({ children, className, careType, headingLevel = 'h2', ...rest }, forwardedRef) => (
-
-
- {/* eslint-disable-next-line jsx-a11y/aria-role */}
-
- {genHiddenText(careType)}
- {children}
-
-
-
-
- ),
-);
+export interface CardHeadingProps extends HeadingProps {
+ cardType?: CareCardType;
+}
-export const CardHeading = forwardRef(
+export const CardHeadingContainer = forwardRef(
(props, forwardedRef) => {
- const { cardType } = useContext(CardContext);
+ const { children, cardType, ...rest } = props;
+ const items = Children.toArray(children);
- if (cardTypeIsCareCard(cardType)) {
- return ;
- }
+ // Allow single heading
+ const headingItem = items.find((child) => childIsOfComponentType(child, CardHeading));
- const { className, headingLevel = 'h2', ...rest } = props;
+ // Only actions remain
+ const actionsItem = items.filter((child) => child !== headingItem);
return (
-
+
+ {headingItem ? : null}
+ {actionsItem}
+ {cardType ? : null}
+
);
},
);
-CareHeading.displayName = 'Card.CareHeading';
+export const CardHeading: FC = ({
+ className,
+ cardType,
+ headingLevel = 'h2',
+ visuallyHiddenText = genHiddenText(cardType),
+ ...rest
+}) => (
+
+);
+
CardHeading.displayName = 'Card.Heading';
+CardHeadingContainer.displayName = 'Card.HeadingContainer';
diff --git a/src/components/navigation/card/components/index.ts b/src/components/navigation/card/components/index.ts
index 9f6745d60..e09a3f386 100644
--- a/src/components/navigation/card/components/index.ts
+++ b/src/components/navigation/card/components/index.ts
@@ -1,4 +1,4 @@
-export * from './CardContent.js';
+export * from './CardAction.js';
export * from './CardDescription.js';
export * from './CardGroup.js';
export * from './CardGroupItem.js';
diff --git a/src/components/navigation/card/index.ts b/src/components/navigation/card/index.ts
index 855ba2cc7..cf0cced1b 100644
--- a/src/components/navigation/card/index.ts
+++ b/src/components/navigation/card/index.ts
@@ -1,3 +1,2 @@
export * from './components/index.js';
-export * from './CardContext.js';
export * from './Card.js';
diff --git a/src/components/navigation/contents-list/ContentsList.tsx b/src/components/navigation/contents-list/ContentsList.tsx
index 06f1c7bb1..43a14cf61 100644
--- a/src/components/navigation/contents-list/ContentsList.tsx
+++ b/src/components/navigation/contents-list/ContentsList.tsx
@@ -1,5 +1,5 @@
import classNames from 'classnames';
-import { forwardRef, type ComponentPropsWithoutRef } from 'react';
+import { forwardRef, type ComponentPropsWithoutRef, type ReactElement } from 'react';
import { type AsElementLink } from '#util/types/LinkTypes.js';
@@ -32,7 +32,7 @@ export const ContentsListItem = forwardRef {
- visuallyHiddenText?: string;
+ visuallyHiddenText?: string | ReactElement;
}
const ContentsListComponent = forwardRef((props, forwardedRef) => {
diff --git a/src/components/navigation/contents-list/__tests__/ContentsList.test.tsx b/src/components/navigation/contents-list/__tests__/ContentsList.test.tsx
index eb9400b6c..151b11c91 100644
--- a/src/components/navigation/contents-list/__tests__/ContentsList.test.tsx
+++ b/src/components/navigation/contents-list/__tests__/ContentsList.test.tsx
@@ -21,16 +21,37 @@ describe('ContentsList', () => {
expect(ref.current).toHaveClass('nhsuk-contents-list');
});
- it('renders default hidden text', () => {
+ it('renders default visually hidden text', () => {
const { container } = render( );
- expect(container.querySelector('.nhsuk-u-visually-hidden')?.textContent).toEqual('Contents');
+ const visuallyHiddenEl = container.querySelector('.nhsuk-u-visually-hidden');
+
+ expect(visuallyHiddenEl).toHaveTextContent('Contents');
});
- it('renders custom hidden text', () => {
+ it('renders custom visually hidden text', () => {
const { container } = render( );
- expect(container.querySelector('.nhsuk-u-visually-hidden')?.textContent).toEqual('Custom');
+ const visuallyHiddenEl = container.querySelector('.nhsuk-u-visually-hidden');
+
+ expect(visuallyHiddenEl).toHaveTextContent('Custom');
+ });
+
+ it('renders custom visually hidden HTML', () => {
+ const { container } = render(
+
+ Custom with HTML
+ >
+ }
+ />,
+ );
+
+ const visuallyHiddenEl = container.querySelector('.nhsuk-u-visually-hidden');
+
+ expect(visuallyHiddenEl).toHaveTextContent('Custom with HTML');
+ expect(visuallyHiddenEl).toContainHTML('Custom with HTML ');
});
describe('ContentsList.Item', () => {
diff --git a/src/components/navigation/footer/Footer.tsx b/src/components/navigation/footer/Footer.tsx
index 423c6ab55..b0334c64b 100644
--- a/src/components/navigation/footer/Footer.tsx
+++ b/src/components/navigation/footer/Footer.tsx
@@ -1,26 +1,63 @@
import classNames from 'classnames';
import { Children, forwardRef, type ComponentPropsWithoutRef } from 'react';
-import { FooterCopyright, FooterList, FooterListItem, FooterMeta } from './components/index.js';
+import {
+ FooterContent,
+ FooterCopyright,
+ FooterHeading,
+ FooterList,
+ FooterListItem,
+ FooterListItemLink,
+ FooterMeta,
+ type FooterContentProps,
+} from './components/index.js';
import { Container } from '#components/layout/index.js';
import { childIsOfComponentType } from '#util/types/index.js';
export interface FooterProps extends ComponentPropsWithoutRef<'div'> {
+ columns?: '1' | '2' | '3' | '4';
containerClassName?: string;
}
const FooterComponent = forwardRef(
- ({ className, containerClassName, children, ...rest }, forwardedRef) => {
+ ({ className, containerClassName, children, columns, ...rest }, forwardedRef) => {
const items = Children.toArray(children);
+
+ // Allow meta content
const meta = items.filter((child) => childIsOfComponentType(child, FooterMeta));
- const columns = items.filter((child) => childIsOfComponentType(child, FooterList));
- const columnsPerRow = 4;
- const columnsTotal = Math.ceil(columns.length / columnsPerRow);
+ // Allow lists and content blocks for column arrangement
+ const listsOrContent = items.filter(
+ (child) =>
+ childIsOfComponentType(child, FooterList) || // Footer lists
+ childIsOfComponentType(child, FooterContent), // Content blocks
+ );
+
+ let width: FooterContentProps['width'];
+
+ switch (columns) {
+ case '1':
+ width = 'full';
+ break;
+
+ case '2':
+ width = 'one-half';
+ break;
+
+ case '3':
+ width = 'one-third';
+ break;
+
+ default:
+ width = 'one-quarter';
+ }
+
+ const columnsPerRow = columns ? Number.parseInt(columns) : 4;
+ const columnsTotal = Math.ceil(listsOrContent.length / columnsPerRow);
const rows = Array.from({ length: columnsTotal }, (column, index) =>
- columns.slice(index * columnsPerRow, index * columnsPerRow + columnsPerRow),
+ listsOrContent.slice(index * columnsPerRow, index * columnsPerRow + columnsPerRow),
);
return (
@@ -33,14 +70,17 @@ const FooterComponent = forwardRef(
{rows.map((row, rowIndex) => (
- {row.map((column, columnIndex) => (
-
- {column}
-
- ))}
+ {row.map((column, columnIndex) => {
+ const { children, ...rest } = column.props;
+ return (
+
+ {childIsOfComponentType(column, FooterContent) ? children : column}
+
+ );
+ })}
))}
- {meta}
+ {meta.length ? meta : }
);
@@ -50,8 +90,11 @@ const FooterComponent = forwardRef(
FooterComponent.displayName = 'Footer';
export const Footer = Object.assign(FooterComponent, {
- Meta: FooterMeta,
+ Content: FooterContent,
+ Copyright: FooterCopyright,
+ Heading: FooterHeading,
List: FooterList,
ListItem: FooterListItem,
- Copyright: FooterCopyright,
+ ListItemLink: FooterListItemLink,
+ Meta: FooterMeta,
});
diff --git a/src/components/navigation/footer/__tests__/Footer.test.tsx b/src/components/navigation/footer/__tests__/Footer.test.tsx
index c0049f08e..c8d27c238 100644
--- a/src/components/navigation/footer/__tests__/Footer.test.tsx
+++ b/src/components/navigation/footer/__tests__/Footer.test.tsx
@@ -1,6 +1,6 @@
import { render } from '@testing-library/react';
-import { Footer } from '..';
+import { Footer, type FooterContentProps } from '..';
jest.spyOn(console, 'warn').mockImplementation();
@@ -11,6 +11,51 @@ describe('Footer', () => {
expect(container).toMatchSnapshot('Footer');
});
+ describe('Footer.Content', () => {
+ it('matches snapshot', () => {
+ const { container } = render( );
+
+ expect(container).toMatchSnapshot('Footer.Content');
+ });
+
+ it('sets default column width', () => {
+ const { container } = render( );
+
+ const columnEl = container.querySelector('div');
+
+ expect(columnEl).toHaveClass(`nhsuk-grid-column-one-quarter`);
+ });
+
+ it.each([
+ { width: 'full' },
+ { width: 'one-half' },
+ { width: 'one-third' },
+ { width: 'one-quarter' },
+ ])('sets custom column width %s', (props) => {
+ const { container } = render( );
+
+ const columnEl = container.querySelector('div');
+
+ expect(columnEl).toHaveClass(`nhsuk-grid-column-${props.width}`);
+ });
+ });
+
+ describe('Footer.Copyright', () => {
+ it('matches snapshot', () => {
+ const { container } = render( );
+
+ expect(container).toMatchSnapshot('Footer.Copyright');
+ });
+ });
+
+ describe('Footer.Heading', () => {
+ it('matches snapshot', () => {
+ const { container } = render( );
+
+ expect(container).toMatchSnapshot('Footer.Heading');
+ });
+ });
+
describe('Footer.List', () => {
afterEach(() => {
jest.clearAllMocks();
@@ -23,6 +68,22 @@ describe('Footer', () => {
});
});
+ describe('Footer.ListItem', () => {
+ it('matches snapshot', () => {
+ const { container } = render( );
+
+ expect(container).toMatchSnapshot('Footer.ListItem');
+ });
+ });
+
+ describe('Footer.ListItemLink', () => {
+ it('matches snapshot', () => {
+ const { container } = render( );
+
+ expect(container).toMatchSnapshot('Footer.ListItemLink');
+ });
+ });
+
describe('Footer.Meta', () => {
it('matches snapshot', () => {
const { container } = render( );
@@ -30,18 +91,57 @@ describe('Footer', () => {
expect(container).toMatchSnapshot('Footer.Meta');
});
- it('has default visually hidden text', () => {
- const { container } = render( );
+ it('matches snapshot with list item', () => {
+ const { container } = render(
+
+ Example link
+ ,
+ );
- expect(container.querySelector('.nhsuk-u-visually-hidden')?.textContent).toBe(
- 'Support links',
+ expect(container).toMatchSnapshot();
+ });
+
+ it('has default visually hidden text', () => {
+ const { container } = render(
+
+ Example link
+ ,
);
+
+ const visuallyHiddenEl = container.querySelector('.nhsuk-u-visually-hidden');
+
+ expect(visuallyHiddenEl).toHaveTextContent('Support links');
});
it('has custom visually hidden text', () => {
- const { container } = render( );
+ const { container } = render(
+
+ Example link
+ ,
+ );
+
+ const visuallyHiddenEl = container.querySelector('.nhsuk-u-visually-hidden');
+
+ expect(visuallyHiddenEl).toHaveTextContent('Custom');
+ });
+
+ it('has custom visually hidden HTML', () => {
+ const { container } = render(
+
+ Custom with HTML
+ >
+ }
+ >
+ Example link
+ ,
+ );
- expect(container.querySelector('.nhsuk-u-visually-hidden')?.textContent).toBe('Custom');
+ const visuallyHiddenEl = container.querySelector('.nhsuk-u-visually-hidden');
+
+ expect(visuallyHiddenEl).toHaveTextContent('Custom with HTML');
+ expect(visuallyHiddenEl).toContainHTML('Custom with HTML ');
});
it('has default copyright text', () => {
@@ -62,20 +162,4 @@ describe('Footer', () => {
);
});
});
-
- describe('Footer.ListItem', () => {
- it('matches snapshot', () => {
- const { container } = render( );
-
- expect(container).toMatchSnapshot('Footer.ListItem');
- });
- });
-
- describe('Footer.Copyright', () => {
- it('matches snapshot', () => {
- const { container } = render( );
-
- expect(container).toMatchSnapshot('Footer.Copyright');
- });
- });
});
diff --git a/src/components/navigation/footer/__tests__/__snapshots__/Footer.test.tsx.snap b/src/components/navigation/footer/__tests__/__snapshots__/Footer.test.tsx.snap
index d196ac396..9a02bbb57 100644
--- a/src/components/navigation/footer/__tests__/__snapshots__/Footer.test.tsx.snap
+++ b/src/components/navigation/footer/__tests__/__snapshots__/Footer.test.tsx.snap
@@ -1,5 +1,13 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
+exports[`Footer Footer.Content matches snapshot: Footer.Content 1`] = `
+
+`;
+
exports[`Footer Footer.Copyright matches snapshot: Footer.Copyright 1`] = `
`;
+exports[`Footer Footer.Heading matches snapshot: Footer.Heading 1`] = `
+
+
+
+`;
+
exports[`Footer Footer.List matches snapshot: Footer.List 1`] = `
`;
-exports[`Footer Footer.Meta matches snapshot: Footer.Meta 1`] = `
+exports[`Footer Footer.ListItemLink matches snapshot: Footer.ListItemLink 1`] = `
+
+
+
+`;
+
+exports[`Footer Footer.Meta matches snapshot with list item 1`] = `
+
+`;
+
+exports[`Footer Footer.Meta matches snapshot: Footer.Meta 1`] = `
+
+
`;
diff --git a/src/components/navigation/footer/components/FooterContent.tsx b/src/components/navigation/footer/components/FooterContent.tsx
new file mode 100644
index 000000000..27c601eaf
--- /dev/null
+++ b/src/components/navigation/footer/components/FooterContent.tsx
@@ -0,0 +1,18 @@
+import { type ComponentPropsWithoutRef, type FC } from 'react';
+
+import { type ColWidth } from '#util/types/NHSUKTypes.js';
+
+export interface FooterContentProps extends Pick<
+ ComponentPropsWithoutRef<'div'>,
+ 'className' | 'children'
+> {
+ width?: Extract;
+}
+
+export const FooterContent: FC = ({ children, className, width, ...rest }) => (
+
+ {children}
+
+);
+
+FooterContent.displayName = 'Footer.Content';
diff --git a/src/components/navigation/footer/components/FooterHeading.tsx b/src/components/navigation/footer/components/FooterHeading.tsx
new file mode 100644
index 000000000..c5a6f2702
--- /dev/null
+++ b/src/components/navigation/footer/components/FooterHeading.tsx
@@ -0,0 +1,11 @@
+import { type FC } from 'react';
+
+import { Heading, type HeadingProps } from '#components/typography/Heading.js';
+
+export type FooterHeadingProps = Omit;
+
+export const FooterHeading: FC = (props) => (
+
+);
+
+FooterHeading.displayName = 'Footer.Heading';
diff --git a/src/components/navigation/footer/components/FooterList.tsx b/src/components/navigation/footer/components/FooterList.tsx
index a51bc8c45..cd59a3cd6 100644
--- a/src/components/navigation/footer/components/FooterList.tsx
+++ b/src/components/navigation/footer/components/FooterList.tsx
@@ -1,9 +1,9 @@
import classNames from 'classnames';
-import { type ComponentPropsWithoutRef, type FC } from 'react';
+import { type FC } from 'react';
-export type FooterListProps = ComponentPropsWithoutRef<'ul'>;
+import { type FooterContentProps } from './FooterContent.js';
-export const FooterList: FC = ({ children, className, ...rest }) => (
+export const FooterList: FC = ({ children, className, width, ...rest }) => (
diff --git a/src/components/navigation/footer/components/FooterListItem.tsx b/src/components/navigation/footer/components/FooterListItem.tsx
index 4ad384deb..f3a4d52b6 100644
--- a/src/components/navigation/footer/components/FooterListItem.tsx
+++ b/src/components/navigation/footer/components/FooterListItem.tsx
@@ -1,18 +1,11 @@
-import classNames from 'classnames';
import { forwardRef } from 'react';
-import { type AsElementLink } from '#util/types/index.js';
+import { FooterListItemLink, type FooterListItemLinkProps } from './FooterListItemLink.js';
-export type FooterListItemProps = AsElementLink;
-
-export const FooterListItem = forwardRef(
- ({ className, asElement: Element = 'a', ...rest }, forwardedRef) => (
+export const FooterListItem = forwardRef(
+ (props, forwardedRef) => (
-
+
),
);
diff --git a/src/components/navigation/footer/components/FooterListItemLink.tsx b/src/components/navigation/footer/components/FooterListItemLink.tsx
new file mode 100644
index 000000000..650f3d886
--- /dev/null
+++ b/src/components/navigation/footer/components/FooterListItemLink.tsx
@@ -0,0 +1,18 @@
+import classNames from 'classnames';
+import { forwardRef } from 'react';
+
+import { type AsElementLink } from '#util/types/index.js';
+
+export type FooterListItemLinkProps = AsElementLink;
+
+export const FooterListItemLink = forwardRef(
+ ({ className, asElement: Element = 'a', ...rest }, forwardedRef) => (
+
+ ),
+);
+
+FooterListItemLink.displayName = 'Footer.ListItemLink';
diff --git a/src/components/navigation/footer/components/FooterMeta.tsx b/src/components/navigation/footer/components/FooterMeta.tsx
index 65334b1d3..dd85976e7 100644
--- a/src/components/navigation/footer/components/FooterMeta.tsx
+++ b/src/components/navigation/footer/components/FooterMeta.tsx
@@ -1,13 +1,14 @@
-import { Children, type FC } from 'react';
+import { Children, type FC, type ReactElement } from 'react';
+import { type FooterContentProps } from './FooterContent.js';
import { FooterCopyright } from './FooterCopyright.js';
-import { FooterList, type FooterListProps } from './FooterList.js';
+import { FooterList } from './FooterList.js';
import { FooterListItem } from './FooterListItem.js';
import { childIsOfComponentType } from '#util/types/index.js';
-export interface FooterMetaProps extends FooterListProps {
- visuallyHiddenText?: string;
+export interface FooterMetaProps extends FooterContentProps {
+ visuallyHiddenText?: string | ReactElement;
}
export const FooterMeta: FC = ({
@@ -17,14 +18,27 @@ export const FooterMeta: FC = ({
}) => {
const items = Children.toArray(children);
- const metaItems = items.filter((child) => childIsOfComponentType(child, FooterListItem));
- const metaCopyright = items.filter((child) => childIsOfComponentType(child, FooterCopyright));
+ // Allow custom copyright
+ const metaCopyright = items.find((child) => childIsOfComponentType(child, FooterCopyright));
+
+ // Allow meta list item
+ const metaListItems = items.filter((child) => childIsOfComponentType(child, FooterListItem));
+
+ // Only meta content items remain
+ const metaContentItems = items.filter(
+ (child) => child !== metaCopyright && !metaListItems.some((listItem) => listItem === child),
+ );
return (
-
{visuallyHiddenText}
- {metaItems}
- {metaCopyright.length ? metaCopyright : }
+ {metaListItems.length ? (
+ <>
+ {visuallyHiddenText}
+ {metaListItems}
+ >
+ ) : null}
+ {metaContentItems}
+ {metaCopyright ? metaCopyright : }
);
};
diff --git a/src/components/navigation/footer/components/index.ts b/src/components/navigation/footer/components/index.ts
index 15a6cdeb3..faf6d2a67 100644
--- a/src/components/navigation/footer/components/index.ts
+++ b/src/components/navigation/footer/components/index.ts
@@ -1,4 +1,7 @@
+export * from './FooterContent.js';
export * from './FooterCopyright.js';
+export * from './FooterHeading.js';
export * from './FooterList.js';
export * from './FooterListItem.js';
+export * from './FooterListItemLink.js';
export * from './FooterMeta.js';
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 210560d4b..2dc2aad7f 100644
--- a/src/components/navigation/header/__tests__/__snapshots__/Header.test.tsx.snap
+++ b/src/components/navigation/header/__tests__/__snapshots__/Header.test.tsx.snap
@@ -70,6 +70,7 @@ exports[`Header Header.Account matches snapshot 1`] = `
action="/log-out"
class="nhsuk-header__account-form"
method="post"
+ novalidate=""
>
@@ -309,40 +322,51 @@ exports[`Header matches snapshot (via server): server 1`] = `
class="nhsuk-header__search-form"
id="search"
method="get"
+ novalidate=""
>
-
- Search the NHS website
-
-
-
@@ -487,40 +511,52 @@ exports[`Header matches snapshot 1`] = `
class="nhsuk-header__search-form"
id="search"
method="get"
+ novalidate=""
>
-
- Search the NHS website
-
-
-
diff --git a/src/components/navigation/header/components/HeaderAccountItem.tsx b/src/components/navigation/header/components/HeaderAccountItem.tsx
index 4306f4573..7570d9c5b 100644
--- a/src/components/navigation/header/components/HeaderAccountItem.tsx
+++ b/src/components/navigation/header/components/HeaderAccountItem.tsx
@@ -22,7 +22,7 @@ const HeaderAccountItemButton = forwardRef
+
`;
+
+exports[`FormGroup matches snapshot with multiple children (via server): client 1`] = `
+
+`;
+
+exports[`FormGroup matches snapshot with multiple children (via server): server 1`] = `
+
+`;
+
+exports[`FormGroup matches snapshot with multiple children 1`] = `
+
+`;
diff --git a/src/components/utils/index.ts b/src/components/utils/index.ts
index 77602c09e..e980adace 100644
--- a/src/components/utils/index.ts
+++ b/src/components/utils/index.ts
@@ -1,5 +1,4 @@
export * from './Clearfix.js';
export * from './FormGroup.js';
export * from './FormGroupContext.js';
-export * from './HeadingLevel.js';
export * from './ReadingWidth.js';
diff --git a/src/util/types/FormTypes.ts b/src/util/types/FormTypes.ts
index 33aa7f985..8267149e8 100644
--- a/src/util/types/FormTypes.ts
+++ b/src/util/types/FormTypes.ts
@@ -12,7 +12,7 @@ import { type LabelProps } from '#components/form-elements/label/index.js';
import { type LegendProps } from '#components/form-elements/legend/index.js';
import { type ComponentPropsWithDataAttributes } from '#util/types/NHSUKTypes.js';
-export interface FormElementProps, T extends ElementType> {
+export interface FormElementProps {
'fieldsetProps'?: FieldsetProps;
'legend'?: string | ReactElement;
'legendProps'?: LegendProps;
@@ -23,8 +23,8 @@ export interface FormElementProps
, T exten
'hint'?: string | ReactElement;
'hintProps'?: HintTextProps;
'formGroupProps'?: ComponentPropsWithDataAttributes<'div'> & {
- beforeInput?: (props: FormElementRenderProps
) => ReactNode | undefined;
- afterInput?: (props: FormElementRenderProps
) => ReactNode | undefined;
+ beforeInput?: ReactNode | undefined;
+ afterInput?: ReactNode | undefined;
};
'inputWrapperProps'?: ComponentPropsWithDataAttributes<'div'>;
'id'?: string;
diff --git a/src/util/types/NHSUKTypes.ts b/src/util/types/NHSUKTypes.ts
index c5ee9113e..1d8cc6e9a 100644
--- a/src/util/types/NHSUKTypes.ts
+++ b/src/util/types/NHSUKTypes.ts
@@ -1,13 +1,11 @@
import { type ComponentProps, type ElementType } from 'react';
-export type NHSUKSize = 's' | 'm' | 'l' | 'xl';
+export type NHSUKSize = 'xxs' | 'xs' | 's' | 'm' | 'l' | 'xl';
export type InputWidth = '2' | '3' | '4' | '5' | '10' | '20' | '30' | 2 | 3 | 4 | 5 | 10 | 20 | 30;
export type CareCardType = 'non-urgent' | 'urgent' | 'emergency';
-export type CardType = 'clickable' | 'feature' | 'primary' | 'secondary' | CareCardType;
-
export type ColWidth =
| 'full'
| 'three-quarters'
diff --git a/src/util/types/TypeGuards.ts b/src/util/types/TypeGuards.ts
index bdc4fe3a1..98c1a4a42 100644
--- a/src/util/types/TypeGuards.ts
+++ b/src/util/types/TypeGuards.ts
@@ -6,8 +6,6 @@ import {
type ReactNode,
} from 'react';
-import { type CardType, type CareCardType } from './NHSUKTypes.js';
-
type WithProps = T & {
props: HTMLAttributes;
};
@@ -43,9 +41,3 @@ export const childIsOfComponentType =
- cardType === 'non-urgent' || cardType === 'urgent' || cardType === 'emergency';
diff --git a/stories/Content Presentation/Hero.stories.tsx b/stories/Content Presentation/Hero.stories.tsx
index 067984f07..bbb38ec7b 100644
--- a/stories/Content Presentation/Hero.stories.tsx
+++ b/stories/Content Presentation/Hero.stories.tsx
@@ -5,6 +5,10 @@ import { Hero } from '#components/content-presentation/hero/index.js';
const meta: Meta = {
title: 'Content Presentation/Hero',
component: Hero,
+ parameters: {
+ layout: 'fullscreen',
+ width: false,
+ },
};
export default meta;
type Story = StoryObj;
diff --git a/stories/Content Presentation/Panel.stories.tsx b/stories/Content Presentation/Panel.stories.tsx
index 667d20428..c699f0c3a 100644
--- a/stories/Content Presentation/Panel.stories.tsx
+++ b/stories/Content Presentation/Panel.stories.tsx
@@ -1,6 +1,7 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { Panel } from '#components/content-presentation/panel/index.js';
+import { Button } from '#components/form-elements/button/index.js';
const meta: Meta = {
title: 'Content Presentation/Panel',
@@ -18,11 +19,68 @@ export const StandardPanel: Story = {
),
};
-export const PanelWithCustomHeadingLevel: Story = {
+export const InterruptionPanel: Story = {
+ name: 'Interruption panel',
+ args: {
+ interruption: true,
+ },
render: (args) => (
- Booking complete
- We have sent you a confirmation email
+ Jodie Brown had a COVID-19 vaccine less than 3 months ago
+ They had a COVID-19 vaccine on 25 September 2025.
+
+ For most people, the minimum recommended gap between COVID-19 vaccine doses is 3 months.
+
+
+
+ Continue anyway
+
+
Cancel
+
+
+ ),
+};
+
+export const InterruptionPanelCancel: Story = {
+ name: 'Interruption panel for confirmation to cancel',
+ args: {
+ interruption: true,
+ },
+ render: (args) => (
+
+ Confirm you want to cancel your hospital appointment
+
+ You will be able to reschedule your appointment for another time, but this may delay your
+ treatment.
+
+ Cancelling your appointment cannot be undone.
+
+
+ ),
+};
+
+export const InterruptionPanelContinue: Story = {
+ name: 'Interruption panel for confirmation to continue',
+ args: {
+ interruption: true,
+ },
+ render: (args) => (
+
+ Is your weight correct?
+
+ You entered your weight as 21.4 kilograms . This is lower than expected.
+
+
),
};
diff --git a/stories/Content Presentation/SummaryList.stories.tsx b/stories/Content Presentation/SummaryList.stories.tsx
index 497d0040c..e593e3de2 100644
--- a/stories/Content Presentation/SummaryList.stories.tsx
+++ b/stories/Content Presentation/SummaryList.stories.tsx
@@ -1,6 +1,7 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { SummaryList } from '#components/content-presentation/summary-list/index.js';
+import { Card } from '#components/navigation/card/index.js';
import { BodyText } from '#components/typography/BodyText.js';
const meta: Meta = {
@@ -17,25 +18,22 @@ export default meta;
type Story = StoryObj;
export const Standard: Story = {
+ name: 'Summary list default',
render: (args) => (
Name
Karen Francis
-
-
- Change
-
-
+
+ Change
+
Date of birth
15 March 1984
-
-
- Change
-
-
+
+ Change
+
Contact information
@@ -46,48 +44,44 @@ export const Standard: Story = {
LS2 5ZN
-
-
- Change
-
-
+
+ Change
+
Contact details
- 07700 900457
- sarah.phillips@example.com
+ 07700 900362
+ karen.francis@example.com
-
-
- Change
-
-
+
+ Change
+
),
};
-export const SummaryListWithoutActionsOnLastRow: Story = {
+export const WithMultipleActions: Story = {
+ name: 'Summary list with multiple actions',
+ parameters: {
+ width: 'full',
+ },
render: (args) => (
Name
Karen Francis
-
-
- Change
-
-
+
+ Change
+
Date of birth
15 March 1984
-
-
- Change
-
-
+
+ Change
+
Contact information
@@ -98,24 +92,43 @@ export const SummaryListWithoutActionsOnLastRow: Story = {
LS2 5ZN
-
-
- Change
-
-
+
+ Change
+
-
+
Contact details
- 07700 900457
- sarah.phillips@example.com
+ 07700 900362
+ karen.francis@example.com
+
+
+ Add
+
+
+ Change
+
+
+
+ Medicines
+
+ Isotretinoin capsules (Roaccutane)
+ Isotretinoin gel (Isotrex)
+ Pepto-Bismol (bismuth subsalicylate)
+
+ Add
+
+
+ Change
+
),
};
export const SummaryListWithoutActions: Story = {
+ name: 'Summary list without actions',
render: (args) => (
@@ -139,24 +152,31 @@ export const SummaryListWithoutActions: Story = {
Contact details
- 07700 900457
- sarah.phillips@example.com
+ 07700 900362
+ karen.francis@example.com
),
};
-export const SummaryListWithoutBorderOnLastRow: Story = {
+export const SummaryListWithoutRowActions: Story = {
+ name: 'Summary list without row actions',
render: (args) => (
Name
Karen Francis
+
+ Change
+
Date of birth
15 March 1984
+
+ Change
+
Contact information
@@ -167,12 +187,15 @@ export const SummaryListWithoutBorderOnLastRow: Story = {
LS2 5ZN
+
+ Change
+
-
+
Contact details
- 07700 900457
- sarah.phillips@example.com
+ 07700 900362
+ karen.francis@example.com
@@ -180,6 +203,7 @@ export const SummaryListWithoutBorderOnLastRow: Story = {
};
export const SummaryListWithoutBorder: Story = {
+ name: 'Summary list without border',
args: {
noBorder: true,
},
@@ -206,10 +230,266 @@ export const SummaryListWithoutBorder: Story = {
Contact details
- 07700 900457
- sarah.phillips@example.com
+ 07700 900362
+ karen.francis@example.com
+
+
+
+ ),
+};
+
+export const SummaryListWithoutRowBorder: Story = {
+ name: 'Summary list without row border',
+ render: (args) => (
+
+
+ Name
+ Karen Francis
+
+
+ Date of birth
+ 15 March 1984
+
+
+ Contact information
+
+ 73 Roman Rd
+
+ Leeds
+
+ LS2 5ZN
+
+
+
+ Contact details
+
+ 07700 900362
+ karen.francis@example.com
),
};
+
+export const SummaryListAsACard: Story = {
+ name: 'Summary list as a card',
+ render: (args) => (
+
+ Regional Manager
+
+
+ Name
+ Karen Francis
+
+
+ Date of birth
+ 15 March 1984
+
+
+ Contact information
+
+ 73 Roman Rd
+
+ Leeds
+
+ LS2 5ZN
+
+
+
+
+ ),
+};
+
+export const SummaryListAsACardWithActions: Story = {
+ name: 'Summary list as a card with actions',
+ render: (args) => (
+
+ Regional Manager
+ Delete
+ Withdraw
+
+
+ Name
+ Karen Francis
+
+
+ Date of birth
+ 15 March 1984
+
+
+ Contact information
+
+ 73 Roman Rd
+
+ Leeds
+
+ LS2 5ZN
+
+
+
+
+ ),
+};
+
+export const SummaryListAsACardClickableWithoutActions: Story = {
+ name: 'Summary list as a card (clickable) without actions',
+ render: (args) => (
+
+
+ Regional Manager
+
+
+
+ Name
+ Karen Francis
+
+
+ Date of birth
+ 15 March 1984
+
+
+ Contact information
+
+ 73 Roman Rd
+
+ Leeds
+
+ LS2 5ZN
+
+
+
+
+ ),
+};
+
+export const SummaryListAsACardFeatureWithActions: Story = {
+ name: 'Summary list as a card (feature) with actions',
+ render: (args) => (
+
+ Regional Manager
+ Delete
+ Withdraw
+
+
+ Name
+ Karen Francis
+
+
+ Date of birth
+ 15 March 1984
+
+
+ Contact information
+
+ 73 Roman Rd
+
+ Leeds
+
+ LS2 5ZN
+
+
+
+
+ ),
+};
+
+export const SummaryListAsACardSecondaryWithActions: Story = {
+ name: 'Summary list as a card (secondary) with actions',
+ render: (args) => (
+
+ Regional Manager
+ Delete
+ Withdraw
+
+
+ Name
+ Karen Francis
+
+
+ Date of birth
+ 15 March 1984
+
+
+ Contact information
+
+ 73 Roman Rd
+
+ Leeds
+
+ LS2 5ZN
+
+
+
+
+ ),
+};
+
+export const SummaryListAsACardWithMultipleActions: Story = {
+ name: 'Summary list as a card with multiple actions',
+ parameters: {
+ width: 'full',
+ },
+ render: (args) => (
+
+ Regional Manager
+ Delete
+ Withdraw
+
+
+ Name
+ Karen Francis
+
+ Change
+
+
+
+ Date of birth
+ 15 March 1984
+
+ Change
+
+
+
+ Contact information
+
+ 73 Roman Rd
+
+ Leeds
+
+ LS2 5ZN
+
+
+ Change
+
+
+
+ Contact details
+
+ 07700 900362
+ karen.francis@example.com
+
+
+ Add
+
+
+ Change
+
+
+
+ Medicines
+
+ Isotretinoin capsules (Roaccutane)
+ Isotretinoin gel (Isotrex)
+ Pepto-Bismol (bismuth subsalicylate)
+
+
+ Add
+
+
+ Change
+
+
+
+
+ ),
+};
diff --git a/stories/Content Presentation/Table.stories.tsx b/stories/Content Presentation/Table.stories.tsx
index ad5586d27..5d0c69a91 100644
--- a/stories/Content Presentation/Table.stories.tsx
+++ b/stories/Content Presentation/Table.stories.tsx
@@ -1,8 +1,7 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { Table } from '#components/content-presentation/table/index.js';
-import { HintText } from '#components/form-elements/hint-text/index.js';
-import { Col, Row } from '#components/layout/index.js';
+import { Card } from '#components/navigation/card/index.js';
const meta: Meta = {
title: 'Content Presentation/Table',
@@ -12,8 +11,11 @@ export default meta;
type Story = StoryObj;
export const StandardTable: Story = {
+ args: {
+ caption: 'Skin symptoms and possible causes',
+ },
render: (args) => (
-
+
Skin Symptoms
@@ -38,10 +40,14 @@ export const StandardTable: Story = {
),
};
-export const TablePanel: Story = {
+export const TableCard: Story = {
+ args: {
+ caption: 'Impetigo can look similar to other skin conditions',
+ },
render: (args) => (
-
-
+
+ Other conditions like impetigo
+
Skin Symptoms
@@ -63,14 +69,17 @@ export const TablePanel: Story = {
-
+
),
};
export const ResponsiveTable: Story = {
- args: { responsive: true },
- render: ({ responsive }) => (
-
+ args: {
+ caption: 'Ibuprofen syrup dosages for children',
+ responsive: true,
+ },
+ render: (args) => (
+
Age
@@ -99,9 +108,86 @@ export const ResponsiveTable: Story = {
),
};
+export const ResponsiveTableWithCustomHTML: Story = {
+ args: {
+ caption: 'Skin symptoms and possible causes',
+ },
+ render: (args) => (
+
+
+
+ Name
+ Type
+ Description
+
+
+
+
+ id
+ string
+ The ID of the table.
+
+
+ rows
+ array
+
+ Required. The rows within the table component.{' '}
+ See macro options for rows .
+
+
+
+ head
+ array
+
+ Can be used to add a row of table header cells (
+ <th>) at the top of the table component.{' '}
+ See macro options for head .
+
+
+
+ caption
+ string
+ Caption text.
+
+
+ captionClasses
+ string
+
+ Classes for caption text size. Classes should correspond to the available typography
+ heading classes.
+
+
+
+ firstCellIsHeader
+ string
+
+ If set to true, the first cell in each row will be a
+ table header (<th>).
+
+
+
+ classes
+ string
+ Classes to add to the table container.
+
+
+ attributes
+ object
+
+ HTML attributes (for example data attributes) to add to the table container.
+
+
+
+
+ ),
+};
+
export const FirstCellAsHeader: Story = {
+ args: {
+ firstCellIsHeader: true,
+ },
render: (args) => (
-
+
Day of the week
@@ -143,33 +229,31 @@ export const FirstCellAsHeader: Story = {
};
export const NumericCells: Story = {
+ args: {
+ caption: 'Number of cases',
+ },
render: (args) => (
-
-
- Right-aligned cells are used for numeric values
-
-
-
- Location
- Number of cases
-
-
-
-
- England
- 4,000
-
-
- Wales
- 2,500
-
-
- Scotland
- 600
-
-
-
-
-
+
+
+
+ Location
+ Number of cases
+
+
+
+
+ England
+ 4,000
+
+
+ Wales
+ 2,500
+
+
+ Scotland
+ 600
+
+
+
),
};
diff --git a/stories/Content Presentation/Tabs.stories.tsx b/stories/Content Presentation/Tabs.stories.tsx
index 6696911be..b9fc34b0c 100644
--- a/stories/Content Presentation/Tabs.stories.tsx
+++ b/stories/Content Presentation/Tabs.stories.tsx
@@ -13,6 +13,9 @@ import { Tabs } from '#components/content-presentation/tabs/index.js';
const meta: Meta = {
title: 'Content Presentation/Tabs',
component: Tabs,
+ parameters: {
+ width: 'full',
+ },
};
export default meta;
@@ -51,7 +54,7 @@ export const Standard: Story = {
export const DifferentAccessibleHeading: Story = {
render: (args) => (
- Tabs title
+ Tabs title
Past day
Past week
diff --git a/stories/Form Elements/CharacterCount.stories.tsx b/stories/Form Elements/CharacterCount.stories.tsx
index d640e6372..75ec204ab 100644
--- a/stories/Form Elements/CharacterCount.stories.tsx
+++ b/stories/Form Elements/CharacterCount.stories.tsx
@@ -51,3 +51,28 @@ export const MessageThreshold: Story = {
threshold: 75,
},
};
+
+export const WithTranslations: Story = {
+ args: {
+ label: 'Allwch chi roi mwy o fanylion?',
+ hint: 'Peidiwch â chynnwys gwybodaeth bersonol, fel eich enw, dyddiad geni na rhif y GIG',
+ maxLength: 200,
+ i18n: {
+ charactersUnderLimit: {
+ one: 'Mae gennych %{count} nod ar ôl',
+ two: 'Mae gennych %{count} nod ar ôl',
+ few: 'Mae gennych %{count} nod ar ôl',
+ many: 'Mae gennych %{count} nod ar ôl',
+ other: 'Mae gennych %{count} nod ar ôl',
+ },
+ charactersAtLimit: 'Mae gennych 0 nod ar ôl',
+ charactersOverLimit: {
+ one: 'Mae gennych %{count} nod yn ormod',
+ two: 'Mae gennych %{count} nod yn ormod',
+ few: 'Mae gennych %{count} nod yn ormod',
+ many: 'Mae gennych %{count} nod yn ormod',
+ other: 'Mae gennych chi %{count} nod yn ormod',
+ },
+ },
+ },
+};
diff --git a/stories/Form Elements/Checkboxes.stories.tsx b/stories/Form Elements/Checkboxes.stories.tsx
index 6386dc6ec..21bed488f 100644
--- a/stories/Form Elements/Checkboxes.stories.tsx
+++ b/stories/Form Elements/Checkboxes.stories.tsx
@@ -2,7 +2,10 @@ import { type Meta, type StoryObj } from '@storybook/react-vite';
import { useEffect, useRef, useState, type SyntheticEvent } from 'react';
import { Checkboxes } from '#components/form-elements/checkboxes/index.js';
+import { Fieldset } from '#components/form-elements/fieldset/Fieldset.js';
import { TextInput } from '#components/form-elements/text-input/index.js';
+import { BodyText } from '#components/typography/BodyText.js';
+import { Heading } from '#components/typography/Heading.js';
/**
* This component can be found in the `nhsuk-frontend` repository here .
@@ -18,9 +21,8 @@ const meta: Meta = {
title: 'Form Elements/Checkboxes',
component: Checkboxes,
args: {
- legend: 'What is your nationality?',
+ legend: 'How do you want to be contacted about this?',
legendProps: { isPageHeading: true, size: 'l' },
- hint: 'If you have more than 1 nationality, select all options that are relevant to you',
name: 'example',
},
};
@@ -34,23 +36,38 @@ type CheckboxState = {
};
export const Standard: Story = {
+ name: 'Checkboxes default',
+ args: {
+ hint: 'Select all options that are relevant to you',
+ },
render: (args) => (
- British
- Irish
- Citizen of another country
+ Email
+ Phone
+ Text message
),
};
export const WithCaption: Story = {
+ name: 'Checkboxes with caption',
args: {
legend: (
<>
- About you What is your nationality?
+ About you
+ How do you want to be contacted about this?
>
),
},
+ render: Standard.render,
+};
+
+export const WithHintText: Story = {
+ name: 'Checkboxes with hint',
+ args: {
+ legend: 'What is your nationality?',
+ hint: 'If you have dual nationality, select all options that are relevant to you',
+ },
render: (args) => (
British
@@ -60,33 +77,78 @@ export const WithCaption: Story = {
),
};
-export const WithHintText: Story = {
+export const WithHintTextOnItems: Story = {
+ name: 'Checkboxes with hints on items',
+ args: {
+ legend: 'What is your nationality?',
+ hint: 'If you have dual nationality, select all options that are relevant to you',
+ },
+ render: (args) => (
+
+
+ British
+
+ Irish
+ Citizen of another country
+
+ ),
+};
+
+export const WithValues: Story = {
+ name: 'Checkboxes with pre-checked values',
args: {
- legend: 'How do you want to sign in?',
- hint: undefined,
+ name: 'exampleConditional1',
+ error: 'Select how you want to be contacted',
},
render: (args) => (
+ }
>
- Sign in with Government Gateway
+ Email
+ }
>
- Sign in with NHS.UK login
+ Phone
+
+
+ }
+ >
+ Text message
),
};
export const Small: Story = {
+ name: 'Checkboxes small',
args: {
...Standard.args,
legendProps: { isPageHeading: true, size: 'm' },
@@ -96,6 +158,7 @@ export const Small: Story = {
};
export const SmallWithHintText: Story = {
+ name: 'Checkboxes small with hint',
args: {
...WithHintText.args,
legendProps: { isPageHeading: true, size: 'm' },
@@ -104,77 +167,309 @@ export const SmallWithHintText: Story = {
render: WithHintText.render,
};
+export const SmallWithHintTextOnItems: Story = {
+ name: 'Checkboxes small with hints on items',
+ args: {
+ ...WithHintTextOnItems.args,
+ legendProps: { isPageHeading: true, size: 'm' },
+ small: true,
+ },
+ render: WithHintTextOnItems.render,
+};
+
export const WithDisabledItem: Story = {
+ name: 'Checkboxes with disabled item',
render: (args) => (
- British
- Irish
-
- Citizen of another country
+ Red
+ Green
+
+ Blue
),
};
+export const WithError: Story = {
+ name: 'Checkboxes with error message',
+ args: {
+ error: 'Select how you want to be contacted',
+ },
+ render: Standard.render,
+};
+
+export const WithHintAndError: Story = {
+ name: 'Checkboxes with hint and error',
+ args: {
+ hint: 'Select all options that are relevant to you',
+ error: 'Select how you want to be contacted',
+ },
+ render: Standard.render,
+};
+
export const WithConditionalContent: Story = {
+ name: 'Checkboxes with conditional content',
+ args: {
+ name: 'exampleConditional2',
+ hint: 'Select all options that are relevant to you',
+ },
+ render: (args) => (
+
+
+ }
+ >
+ Email
+
+
+ }
+ >
+ Phone
+
+
+ }
+ >
+ Text message
+
+
+ ),
+};
+
+export const WithConditionalContentError: Story = {
+ name: 'Checkboxes with conditional content, error message',
args: {
- legend: 'What types of waste do you transport regularly?',
- hint: 'Select all that apply',
+ hint: 'Select all options that are relevant to you',
+ error: 'Select how you like to be contacted',
+ },
+ render: WithConditionalContent.render,
+};
+
+export const WithConditionalContentErrorNested: Story = {
+ name: 'Checkboxes with conditional content, error message (nested)',
+ args: {
+ name: 'exampleConditional3',
+ hint: 'Select all options that are relevant to you',
},
render: (args) => (
- This includes rocks and earth.} value="mines">
- Waste from mines or quarries
+
+ }
+ >
+ Email
+
+
+ }
+ >
+ Phone
+
+
+ }
+ >
+ Text message
),
};
export const WithExclusiveNoneOption: Story = {
+ name: 'Checkboxes with "none of the above" option',
args: {
- legend: 'Do you have any of these symptoms?',
- hint: 'Select all the symptoms you have',
+ legend: 'How do you want to be contacted about this?',
+ hint: 'Select all options that are relevant to you',
},
render: (args) => (
- Sore throat
- Runny nose
- Muscle or joint pain
+ Email
+ Phone
+ Text message
- None
+ None of the above
),
};
-export const WithError: Story = {
+export const WithExclusiveNoneOptionConditional: Story = {
+ name: 'Checkboxes with "none of the above" option, conditional content',
args: {
- legend: 'What types of waste do you transport regularly?',
- hint: 'Select all that apply',
+ name: 'exampleConditional4',
+ legend: 'How do you want to be contacted about this?',
+ hint: 'Select all options that are relevant to you',
},
- render: (args) => {
- // eslint-disable-next-line react-hooks/rules-of-hooks
- const [error, setError] = useState('Please select an option');
- return (
- <>
-
- Waste from animal carcasses
- Waste from mines or quarries
- Farm or agricultural waste
-
- setError(e.currentTarget.value)}
- />
- >
- );
- },
- name: 'With Error (String)',
+ render: (args) => (
+
+
+ }
+ >
+ Email
+
+
+ }
+ >
+ Phone
+
+
+ }
+ >
+ Text message
+
+
+
+ None of the above
+
+
+ ),
+};
+
+export const WithExclusiveNoneOptionNamed: Story = {
+ name: 'Checkboxes with "none of the above" option (named groups)',
+ render: (args) => (
+
+
+ What is your address?
+
+
+
+ Primary colours
+
+
+
+
+ Red
+
+ Orange is much nicer than yellow!}
+ >
+ Yellow
+
+
+ Blue
+
+
+
+ None of the primary colours
+
+
+
+
+ Secondary colours
+
+
+
+
+ Green
+
+
+ Purple
+
+ I like orange too!}
+ >
+ Orange
+
+
+
+ None of the secondary colours
+
+
+
+
+ Other colours
+
+
+
+
+ An imaginary colour
+
+
+
+ None of the above
+
+
+
+ ),
};
export const NoIDSupplied: Story = {
+ name: 'Checkboxes with no ID supplied',
render: function NoIDSuppliedRender() {
const checkbox1Ref = useRef(null);
const checkbox2Ref = useRef(null);
@@ -259,6 +554,7 @@ export const NoIDSupplied: Story = {
};
export const NameSupplied: Story = {
+ name: 'Checkboxes with name supplied',
render: function NameSuppliedRender() {
const checkbox1Ref = useRef(null);
const checkbox2Ref = useRef(null);
@@ -343,6 +639,7 @@ export const NameSupplied: Story = {
};
export const IDPrefixSupplied: Story = {
+ name: 'Checkboxes with ID prefix supplied',
render: function IDPrefixSuppliedRender() {
const checkbox1Ref = useRef(null);
const checkbox2Ref = useRef(null);
@@ -428,6 +725,7 @@ export const IDPrefixSupplied: Story = {
};
export const IDPrefixAndNameSupplied: Story = {
+ name: 'Checkboxes with ID prefix and name supplied',
render: function IDPrefixAndNameSuppliedRender() {
const checkbox1Ref = useRef(null);
const checkbox2Ref = useRef(null);
@@ -513,6 +811,7 @@ export const IDPrefixAndNameSupplied: Story = {
};
export const OnChangeAndOnInputHandlers: Story = {
+ name: 'Checkboxes change and input handlers',
render: function OnChangeAndOnInputHandlersRender() {
const [changeEventLog, setChangeEventLog] = useState>([]);
const [inputEventLog, setInputEventLog] = useState>([]);
diff --git a/stories/Form Elements/FileUpload.stories.tsx b/stories/Form Elements/FileUpload.stories.tsx
new file mode 100644
index 000000000..6c685017a
--- /dev/null
+++ b/stories/Form Elements/FileUpload.stories.tsx
@@ -0,0 +1,94 @@
+import { type Meta, type StoryObj } from '@storybook/react-vite';
+
+import { FileUpload } from '#components/form-elements/file-upload/index.js';
+
+const meta: Meta = {
+ title: 'Form Elements/FileUpload',
+ component: FileUpload,
+ args: {
+ id: 'input-example',
+ name: 'example',
+ label: 'Upload a file',
+ labelProps: { isPageHeading: true, size: 'l' },
+ },
+ argTypes: {
+ ref: { table: { disable: true } },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Standard: Story = {};
+export const WithHintText: Story = {
+ args: {
+ hint: 'Your photo may be in your Pictures, Photos, Downloads or Desktop folder',
+ },
+};
+
+export const WithError: Story = {
+ args: {
+ error: 'The selected file must be a JPG, BMP or TIF',
+ },
+};
+
+export const WithErrorAndHintText: Story = {
+ args: {
+ error: 'The selected file must be a JPG, BMP or TIF',
+ hint: 'Your photo may be in your Pictures, Photos, Downloads or Desktop folder',
+ },
+};
+
+export const WithSmallPrimaryButton: Story = {
+ args: {
+ chooseFilesButtonClassList: ['nhsuk-button--small'],
+ },
+};
+
+export const WithSecondaryButton: Story = {
+ args: {
+ chooseFilesButtonClassList: ['nhsuk-button--secondary'],
+ },
+};
+
+export const WithSmallSecondaryButton: Story = {
+ args: {
+ chooseFilesButtonClassList: ['nhsuk-button--small', 'nhsuk-button--secondary'],
+ },
+};
+
+export const WithMultiple: Story = {
+ args: {
+ label: 'Upload multiple files',
+ multiple: true,
+ i18n: {
+ chooseFilesButton: 'Choose files',
+ dropInstruction: 'or drop files',
+ noFileChosen: 'No files chosen',
+ },
+ },
+};
+
+export const WithTranslations: Story = {
+ args: {
+ label: 'Upload multiple files',
+ multiple: true,
+ i18n: {
+ chooseFilesButton: 'Dewiswch ffeil',
+ dropInstruction: 'neu ollwng ffeil',
+ noFileChosen: "Dim ffeil wedi'i dewis",
+ multipleFilesChosen: {
+ other: "%{count} ffeil wedi'u dewis",
+ one: "%{count} ffeil wedi'i dewis",
+ },
+ enteredDropZone: "Wedi mynd i mewn i'r parth gollwng",
+ leftDropZone: "Parth gollwng i'r chwith",
+ },
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ disabled: true,
+ },
+};
diff --git a/stories/Form Elements/PasswordInput.stories.tsx b/stories/Form Elements/PasswordInput.stories.tsx
index 555fd8171..a652175a6 100644
--- a/stories/Form Elements/PasswordInput.stories.tsx
+++ b/stories/Form Elements/PasswordInput.stories.tsx
@@ -38,3 +38,17 @@ export const WithErrorAndHintText: Story = {
hint: 'It probably has some letters, numbers and maybe even some symbols in it',
},
};
+
+export const WithTranslations: Story = {
+ args: {
+ label: 'Cyfrinair',
+ i18n: {
+ showPassword: 'Datguddia',
+ hidePassword: 'Cuddio',
+ showPasswordAriaLabel: 'Datgelu cyfrinair',
+ hidePasswordAriaLabel: 'Cuddio cyfrinair',
+ passwordShownAnnouncement: 'Mae eich cyfrinair yn weladwy.',
+ passwordHiddenAnnouncement: "Mae eich cyfrinair wedi'i guddio.",
+ },
+ },
+};
diff --git a/stories/Form Elements/Select.stories.tsx b/stories/Form Elements/Select.stories.tsx
index a0eb75f21..ae74c37bf 100644
--- a/stories/Form Elements/Select.stories.tsx
+++ b/stories/Form Elements/Select.stories.tsx
@@ -73,7 +73,7 @@ export const SelectWithDivider: Story = {
export const SelectWithButton: Story = {
args: {
formGroupProps: {
- afterInput: () => (
+ afterInput: (
Search
@@ -86,7 +86,7 @@ export const SelectWithButtonAndError: Story = {
args: {
error: 'Select a location',
formGroupProps: {
- afterInput: () => (
+ afterInput: (
Search
diff --git a/stories/Form Elements/TextInput.stories.tsx b/stories/Form Elements/TextInput.stories.tsx
index 4e66f1d00..2aa614a58 100644
--- a/stories/Form Elements/TextInput.stories.tsx
+++ b/stories/Form Elements/TextInput.stories.tsx
@@ -121,7 +121,7 @@ export const WithButton: Story = {
inputMode: 'numeric',
spellCheck: false,
formGroupProps: {
- afterInput: () => (
+ afterInput: (
Search
@@ -139,7 +139,7 @@ export const WithButtonAndError: Story = {
inputMode: 'numeric',
spellCheck: false,
formGroupProps: {
- afterInput: () => (
+ afterInput: (
Search
diff --git a/stories/Layout/Grid.stories.tsx b/stories/Layout/Grid.stories.tsx
index 6efa6cfc9..66780c2e8 100644
--- a/stories/Layout/Grid.stories.tsx
+++ b/stories/Layout/Grid.stories.tsx
@@ -5,50 +5,56 @@ import { Card } from '#components/navigation/card/index.js';
const meta: Meta = {
title: 'Layout/Grid',
+ parameters: {
+ layout: 'fullscreen',
+ width: false,
+ },
};
export default meta;
export const Grid: StoryObj = {
render: (args) => (
-
-
- full column
-
-
-
-
- one-half column
-
-
- one-half column
-
-
-
-
- one-third column
-
-
- one-third column
-
-
- one-third column
-
-
-
-
- one-quarter column
-
-
- one-quarter column
-
-
- one-quarter column
-
-
- one-quarter column
-
-
+
+
+
+ full column
+
+
+
+
+ one-half column
+
+
+ one-half column
+
+
+
+
+ one-third column
+
+
+ one-third column
+
+
+ one-third column
+
+
+
+
+ one-quarter column
+
+
+ one-quarter column
+
+
+ one-quarter column
+
+
+ one-quarter column
+
+
+
),
};
diff --git a/stories/Navigation/ActionLink.stories.tsx b/stories/Navigation/ActionLink.stories.tsx
index 09037fc6c..27dc91268 100644
--- a/stories/Navigation/ActionLink.stories.tsx
+++ b/stories/Navigation/ActionLink.stories.tsx
@@ -5,16 +5,26 @@ import { ActionLink } from '#components/navigation/action-link/index.js';
const meta: Meta = {
title: 'Navigation/ActionLink',
component: ActionLink,
- args: { children: 'Link', asElement: 'a', href: '#' },
+ render: (args) => Find your nearest A&E ,
};
+
export default meta;
type Story = StoryObj;
-export const StandardLink: Story = {};
+export const Standard: Story = {
+ name: 'Action link default',
+ args: {
+ href: '#',
+ },
+};
-export const OpenLinkInNewTab: Story = {
+export const Reverse: Story = {
+ name: 'Action link reverse',
args: {
- target: '_blank',
- rel: 'noopener noreferrer',
+ href: '#',
+ reverse: true,
+ },
+ globals: {
+ backgrounds: { value: 'dark' },
},
};
diff --git a/stories/Navigation/BackLink.stories.tsx b/stories/Navigation/BackLink.stories.tsx
index d34a25625..283cb87ca 100644
--- a/stories/Navigation/BackLink.stories.tsx
+++ b/stories/Navigation/BackLink.stories.tsx
@@ -5,16 +5,41 @@ import { BackLink } from '#components/navigation/back-link/index.js';
const meta: Meta = {
title: 'Navigation/BackLink',
component: BackLink,
- args: { children: 'Go back', href: '/', asElement: 'a' },
+ render: (args) => ,
};
export default meta;
type Story = StoryObj;
-export const StandardLink: Story = {};
+export const Standard: Story = {
+ name: 'Back link default',
+ args: {
+ href: '#',
+ },
+};
+
+export const WithVisuallyHiddenText: Story = {
+ name: 'Back link with visually hidden text',
+ args: {
+ href: '#',
+ visuallyHiddenText: 'Back to',
+ children: 'Search results',
+ },
+};
-export const BackLinkAsAButton: Story = {
+export const Button: Story = {
+ name: 'Back link as a button',
args: {
asElement: 'button',
- href: undefined,
+ },
+};
+
+export const Reverse: Story = {
+ name: 'Back link reverse',
+ args: {
+ href: '#',
+ reverse: true,
+ },
+ globals: {
+ backgrounds: { value: 'dark' },
},
};
diff --git a/stories/Navigation/Breadcrumb.stories.tsx b/stories/Navigation/Breadcrumb.stories.tsx
index b464b2d4b..3d6028108 100644
--- a/stories/Navigation/Breadcrumb.stories.tsx
+++ b/stories/Navigation/Breadcrumb.stories.tsx
@@ -1,6 +1,5 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
-import { Container } from '#components/layout/index.js';
import { Breadcrumb } from '#components/navigation/breadcrumb/index.js';
/**
@@ -18,23 +17,44 @@ const meta: Meta = {
},
},
render: (args) => (
-
-
- Level One
- Level Two
- Level Three
- Level Three
-
-
+
+ Home
+ NHS services
+ Hospitals
+
),
};
export default meta;
type Story = StoryObj;
-export const Standard: Story = {};
+export const Standard: Story = {
+ name: 'Breadcrumb default',
+};
export const OverrideAriaLabel: Story = {
+ name: 'Breadcrumb with custom ARIA label',
args: {
'aria-label': 'custom-aria-label',
},
};
+
+export const OverrideBackLink: Story = {
+ name: 'Breadcrumb with custom back link text',
+ render: (args) => (
+
+ Home
+ Advanced search
+ Search results
+
+ ),
+};
+
+export const Reverse: Story = {
+ name: 'Breadcrumb reverse',
+ args: {
+ reverse: true,
+ },
+ globals: {
+ backgrounds: { value: 'dark' },
+ },
+};
diff --git a/stories/Navigation/Card.stories.tsx b/stories/Navigation/Card.stories.tsx
index 8313e2375..659b635ca 100644
--- a/stories/Navigation/Card.stories.tsx
+++ b/stories/Navigation/Card.stories.tsx
@@ -1,110 +1,314 @@
-/* eslint-disable jsx-a11y/anchor-is-valid */
import { type Meta, type StoryObj } from '@storybook/react-vite';
-import { ChevronRightCircleIcon } from '#components/content-presentation/icons/individual/index.js';
import { SummaryList } from '#components/content-presentation/summary-list/index.js';
+import { Tag } from '#components/content-presentation/tag/index.js';
+import { Button } from '#components/form-elements/button/index.js';
+import { ActionLink } from '#components/navigation/action-link/index.js';
import { Card } from '#components/navigation/card/index.js';
-import { BodyText } from '#components/typography/BodyText.js';
+import { BodyText, Heading } from '#components/typography/index.js';
const meta: Meta = {
title: 'Navigation/Card',
component: Card,
+ argTypes: {
+ cardType: {
+ control: {
+ type: 'select',
+ labels: {
+ 'undefined': 'Not set',
+ 'non-urgent': 'Non-urgent',
+ 'urgent': 'Urgent',
+ 'emergency': 'Emergency',
+ },
+ },
+ options: [undefined, 'non-urgent', 'urgent', 'emergency'],
+ },
+ },
};
export default meta;
type Story = StoryObj;
type StoryGroup = StoryObj;
export const Standard: Story = {
+ name: 'Basic card default',
render: (args) => (
-
-
- If you need help now but it's not an emergency
-
-
- Go to 111.nhs.uk or call 111
-
-
+ If you need help now but it's not an emergency
+
+ Go to 111.nhs.uk or call 111
+
),
};
-export const BasicWithHeadingLink: Story = {
+export const WithCustomHTML: Story = {
+ name: 'Basic card with custom HTML',
render: (args) => (
-
-
- Introduction to care and support
-
-
- A quick guide for people who have care and support needs and their carers
-
-
+ Help from NHS 111
+
+ If you're worried about a symptom and not sure what help you need, NHS 111 can tell you
+ what to do next.
+
+
+ Go to 111.nhs.uk or call 111 .
+
+ For a life-threatening emergency call 999.
),
};
-export const BasicWithCustomHTML: Story = {
+export const WithHeadingLink: Story = {
+ name: 'Basic card with heading link',
render: (args) => (
-
- Help from NHS 111
-
- If you're worried about a symptom and not sure what help you need, NHS 111 can tell
- you what to do next.
-
-
- Go to 111.nhs.uk or call 111 .
-
- For a life-threatening emergency call 999.
-
+
+ Introduction to care and support
+
+
+ A quick guide for people who have care and support needs and their carers
+
),
};
-export const BasicWithSummaryList: Story = {
+export const WithoutHeading: Story = {
+ name: 'Basic card without heading',
render: (args) => (
-
- Help from NHS 111
-
-
- Name
- Karen Francis
-
-
- Date of birth
- 15 March 1984
-
-
-
+
+ A quick guide for people who have care and support needs and their carers
+
),
};
-export const BasicWithSummaryListAndHeadingLink: Story = {
+export const WithSummaryList: Story = {
+ name: 'Basic card with summary list',
render: (args) => (
-
-
- Help from NHS 111
-
-
-
- Name
- Karen Francis
-
-
- Date of birth
- 15 March 1984
-
-
-
+ Regional Manager
+
+
+ Name
+ Karen Francis
+
+
+ Date of birth
+ 15 March 1984
+
+
+
+ ),
+};
+
+export const WithSummaryListAndActions: Story = {
+ name: 'Basic card with summary list and actions',
+ render: (args) => (
+
+ Regional Manager
+ Delete
+ Withdraw
+
+
+ Name
+ Karen Francis
+
+
+ Date of birth
+ 15 March 1984
+
+
+
+ ),
+};
+
+export const WithSummaryListAndButton: Story = {
+ name: 'Basic card with summary list and button',
+ render: (args) => (
+
+ Regional Manager
+
+
+ Name
+ Karen Francis
+
+
+ Date of birth
+ 15 March 1984
+
+
+
+ Add role
+
+
+ ),
+};
+
+export const WithSummaryListAndHeadingLink: Story = {
+ name: 'Basic card with summary list and heading link',
+ render: (args) => (
+
+
+ Help from NHS 111
+
+
+
+ Name
+ Karen Francis
+
+
+ Date of birth
+ 15 March 1984
+
+
+
+ ),
+};
+
+export const WithSummaryLists: Story = {
+ name: 'Basic card with summary lists',
+ render: (args) => (
+
+ Regional Manager
+
+
+ Name
+ Karen Francis
+
+
+ Date of birth
+ 15 March 1984
+
+
+
+
+ Name
+ Sarah Philips
+
+
+ Date of birth
+ 5 January 1978
+
+
+
+ ),
+};
+
+export const ClickableCard: Story = {
+ name: 'Clickable card',
+ args: {
+ clickable: true,
+ },
+ render: (args) => (
+
+
+ Introduction to care and support
+
+
+ A quick guide for people who have care and support needs and their carers
+
+
+ ),
+};
+
+export const NonUrgentCard: Story = {
+ name: 'Non-urgent card',
+ args: {
+ cardType: 'non-urgent',
+ },
+ render: (args) => (
+
+ Speak to a GP if:
+
+ you're not sure it's chickenpox
+ the skin around the blisters is red, hot or painful (signs of infection)
+
+ your child is dehydrated
+
+ you're concerned about your child or they get worse
+
+
+ Tell the receptionist you think it's chickenpox before going in. They may recommend a
+ special appointment time if other patients are at risk.
+
+
+ ),
+};
+
+export const UrgentCard: Story = {
+ name: 'Urgent card',
+ args: {
+ cardType: 'urgent',
+ },
+ render: (args) => (
+
+ Ask for an urgent GP appointment if:
+
+ you're an adult and have chickenpox
+
+ you're pregnant and haven't had chickenpox before and you've been near
+ someone with it
+
+ you have a weakened immune system and you've been near someone with chickenpox
+ you think your newborn baby has chickenpox
+
+
+ In these situations, your GP can prescribe medicine to prevent complications. You need to
+ take it within 24 hours of the spots coming out.
+
+
+ ),
+};
+
+export const EmergencyCard: Story = {
+ name: 'Emergency card',
+ args: {
+ cardType: 'emergency',
+ },
+ render: (args) => (
+
+ Call 999 if you have sudden chest pain that:
+
+ spreads to your arms, back, neck or jaw
+ makes your chest feel tight or heavy
+ also started with shortness of breath, sweating and feeling or being sick
+
+
+ You could be having a heart attack. Call 999 immediately as you need immediate treatment in
+ hospital.
+
+
+ ),
+};
+
+export const EmergencyCardWithActionLink: Story = {
+ name: 'Emergency card with action link',
+ args: {
+ cardType: 'emergency',
+ },
+ render: (args) => (
+
+ Call 999 or go to A&E now if:
+
+
+ you're coughing up more than just a few spots or streaks of blood – this could be a
+ sign of serious bleeding in your lungs
+
+
+ you have severe difficulty breathing – you're gasping, choking or not able to get
+ words out
+
+
+
+ Find your nearest A&E
+
),
};
export const CardWithImage: Story = {
+ name: 'Basic card with image',
args: {
clickable: true,
},
@@ -114,153 +318,221 @@ export const CardWithImage: Story = {
src="https://assets.nhs.uk/prod/images/A_0218_exercise-main_FKW1X7.width-690.jpg"
alt=""
/>
-
-
- Exercise
-
-
- Programmes, workouts and tips to get you moving and improve your fitness and wellbeing
-
-
+
+ Exercise
+
+
+ Programmes, workouts and tips to get you moving and improve your fitness and wellbeing
+
),
};
-export const FeatureCard: Story = {
+export const CardWithImageAndCustomHTML: Story = {
+ name: 'Basic card with image and custom HTML',
args: {
- cardType: 'feature',
+ clickable: true,
+ },
+ parameters: {
+ width: 'one-half',
},
render: (args) => (
-
- Feature card heading
- Feature card description
-
+
+
+
+ Why we are reinvesting in the NHS prototype kit
+
+
+
+ Published on: 21 July 2025
+
+
+ NHS England Design Matters blog
+
+
+ Frankie Roberto and Mike Gallagher explain why we revived the NHS prototype kit, the
+ benefits of prototyping in code and how digital teams in the NHS can get started using it.
+
),
};
-export const FeatureCardWithList: Story = {
+export const TopTask: Story = {
+ name: 'Top task',
args: {
- cardType: 'feature',
+ clickable: true,
+ },
+ parameters: {
+ width: 'one-third',
},
render: (args) => (
-
- Feature card heading
-
-
+
+ Order a repeat prescription
+
),
};
-export const PrimaryCardWithChevron: Story = {
+export const FeatureCard: Story = {
+ name: 'Feature card',
args: {
- cardType: 'primary',
- clickable: true,
+ feature: true,
},
render: (args) => (
-
-
- Breast screening
+ Feature card heading
+ Feature card description
+
+ ),
+};
+
+export const FeatureCardWithNestedCardAndSummaryList: Story = {
+ name: 'Feature card with nested card and summary list',
+ args: {
+ feature: true,
+ },
+ render: (args) => (
+
+ Flu: Follow-up requested
+
+ Sarah Philips (Mum) would like to speak to a member of the team about other options for
+ their child's vaccination.
+
+
+ Record a new consent response
+
+
+ Consent responses
+
+
+
+ Sarah Philips (Mum)
-
-
+
+
+ Name
+ Sarah Philips
+
+
+ Date
+ 25 August 2025 at 4:04 pm
+
+
+ Response
+
+ Follow up requested
+
+
+
+
),
};
-export const PrimaryCardWithChevronAndDescription: Story = {
+export const FeatureCardWithAZContent: Story = {
+ name: 'Feature card with A to Z content',
args: {
- cardType: 'primary',
+ feature: true,
+ },
+ render: (args) => (
+
+
+ A
+
+
+
+ ),
+};
+
+export const PrimaryCardWithChevron: Story = {
+ name: 'Primary card (with chevron)',
+ args: {
+ primary: true,
clickable: true,
},
render: (args) => (
-
-
- Introduction to care and support
-
-
- A quick guide for people who have care and support needs and their carers
-
-
-
+
+ Breast screening
+
),
};
-export const ClickableCard: Story = {
+export const PrimaryCardWithChevronAndDescription: Story = {
+ name: 'Primary card (with chevron and description)',
args: {
+ primary: true,
clickable: true,
},
render: (args) => (
-
-
- Introduction to care and support
-
-
- A quick guide for people who have care and support needs and their carers
-
-
+
+ Introduction to care and support
+
+
+ A quick guide for people who have care and support needs and their carers
+
),
};
export const SecondaryCard: Story = {
+ name: 'Secondary card',
args: {
- cardType: 'secondary',
+ secondary: true,
clickable: true,
},
render: (args) => (
-
-
- Urgent and emergency care services
-
-
- Services the NHS provides if you need urgent or emergency medical help
-
-
+
+ Urgent and emergency care services
+
+
+ Services the NHS provides if you need urgent or emergency medical help
+
),
};
export const SecondaryNonClickableWithCustomHTML: Story = {
+ name: 'Secondary non-clickable card with custom HTML',
args: {
- cardType: 'secondary',
+ secondary: true,
},
render: (args) => (
-
-
- Why we are reinvesting in the NHS Prototype kit
-
-
- Services the NHS provides if you need urgent or emergency medical help
-
-
- Frankie and Mike explain why we revived the NHS prototype kit, the benefits of prototyping
- in code and how digital teams in the NHS can get started using it.
-
-
+
+ Why we are reinvesting in the NHS Prototype kit
+
+
+ Services the NHS provides if you need urgent or emergency medical help
+
+
+ Frankie and Mike explain why we revived the NHS prototype kit, the benefits of prototyping
+ in code and how digital teams in the NHS can get started using it.
+
),
};
export const CardGroup: StoryGroup = {
+ name: 'Basic card group',
args: { width: 'one-half' },
argTypes: {
width: {
@@ -272,126 +544,44 @@ export const CardGroup: StoryGroup = {
-
-
- Introduction to care and support
-
-
- A quick guide for people who have care and support needs and their carers
-
-
+
+ Introduction to care and support
+
+
+ A quick guide for people who have care and support needs and their carers
+
-
-
- Help from social services and charities
-
-
- Includes helplines, needs assessments, advocacy and reporting abuse
-
-
+
+ Help from social services and charities
+
+
+ Includes helplines, needs assessments, advocacy and reporting abuse
+
-
-
- Money, work and benefits
-
-
- How to pay for care and support, and where you can get help with costs
-
-
+
+ Money, work and benefits
+
+
+ How to pay for care and support, and where you can get help with costs
+
-
-
- Care after a hospital stay
-
-
- Includes hospital discharge and care and support afterwards
-
-
+
+ Care after a hospital stay
+
+
+ Includes hospital discharge and care and support afterwards
+
),
};
-
-export const NonUrgentCareCard: Story = {
- args: {
- cardType: 'non-urgent',
- },
- render: (args) => (
-
- Speak to a GP if:
-
-
- you're not sure it's chickenpox
- the skin around the blisters is red, hot or painful (signs of infection)
-
- your child is dehydrated
-
- you're concerned about your child or they get worse
-
-
- Tell the receptionist you think it's chickenpox before going in. They may recommend a
- special appointment time if other patients are at risk.
-
-
-
- ),
-};
-
-export const UrgentCareCard: Story = {
- args: {
- cardType: 'urgent',
- },
- render: (args) => (
-
- Ask for an urgent GP appointment if:
-
-
- you're an adult and have chickenpox
-
- you're pregnant and haven't had chickenpox before and you've been near
- someone with it
-
-
- you have a weakened immune system and you've been near someone with chickenpox
-
- you think your newborn baby has chickenpox
-
-
- In these situations, your GP can prescribe medicine to prevent complications. You need to
- take it within 24 hours of the spots coming out.
-
-
-
- ),
-};
-
-export const EmergencyCareCard: Story = {
- args: {
- cardType: 'emergency',
- },
- render: (args) => (
-
- Call 999 or go to A&E now if:
-
-
- you or someone you know needs immediate help
- you have seriously harmed yourself - for example, by taking a drug overdose
-
- A mental health emergency should be taken as seriously as a medical emergency.
-
- Find your nearest A&E
-
-
-
- ),
-};
diff --git a/stories/Navigation/Footer.stories.tsx b/stories/Navigation/Footer.stories.tsx
index 1ec88d9d8..1f937f854 100644
--- a/stories/Navigation/Footer.stories.tsx
+++ b/stories/Navigation/Footer.stories.tsx
@@ -1,66 +1,244 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { Footer } from '#components/navigation/footer/index.js';
+import { BodyText } from '#components/typography/BodyText.js';
const meta: Meta = {
title: 'Navigation/Footer',
component: Footer,
+ parameters: {
+ layout: 'fullscreen',
+ width: false,
+ },
};
export default meta;
type Story = StoryObj;
export const Standard: Story = {
+ name: 'Footer default',
render: (args) => (
+
+ Accessibility statement
+ Contact us
+ Cookies
+ Privacy policy
+ Terms and conditions
+
+
+ ),
+};
+
+export const WithCopyrightTextOnly: Story = {
+ name: 'Footer with copyright text only',
+ render: (args) => ,
+};
+
+export const WithCopyrightTextCustom: Story = {
+ name: 'Footer with custom copyright text',
+ render: (args) => (
+
+
+ © East London NHS Foundation Trust
+
+
+ ),
+};
+
+export const WithMetaAndNavigation: Story = {
+ name: 'Footer with meta and navigation',
+ render: (args) => (
+
+
+ Home
+ Health A to Z
+ NHS services
+ Live Well
+ Mental health
+ Care and support
+ Pregnancy
+ COVID-19
+
+
+
+ NHS App
+ Find my NHS number
+ View your GP health records
+ View your test results
+ About the NHS
+ Healthcare abroad
+
+
+
+ Other NHS websites
+ Profile editor login
+
+
About us
+ Give us feedback
Accessibility statement
Our policies
Cookies
-
+
+ All content is available under the{' '}
+
+ Open Government Licence v3.0
+
+ , except where otherwise stated.
+
+
+ © Crown copyright
),
};
-export const WithLinksArrangedInColumns: Story = {
+export const WithMetaLinksOnly: Story = {
+ name: 'Footer with meta (links only)',
+ render: Standard.render,
+};
+
+export const WithMetaLinksAndHTML: Story = {
+ name: 'Footer with meta (links and custom HTML)',
+ render: (args) => (
+
+
+ Accessibility statement
+ Contact us
+ Cookies
+ Privacy policy
+ Terms and conditions
+
+ All content is available under the Open Government Licence v3.0, except where otherwise
+ stated.
+
+ © Custom copyright
+
+
+ ),
+};
+
+export const WithMultipleNavigationGroups: Story = {
+ name: 'Footer with multiple navigation groups',
render: (args) => (
Home
Health A to Z
+ NHS services
Live Well
Mental health
Care and support
- Accessibility statement
Pregnancy
- NHS services
- Coronavirus (COVID-19)
+ COVID-19
NHS App
Find my NHS number
- Your health records
+ View your GP health records
+ View your test results
About the NHS
Healthcare abroad
- Give us feedback
Other NHS websites
Profile editor login
-
+
About us
+ Give us feedback
Accessibility statement
Our policies
Cookies
+
+
+ ),
+};
+
+export const WithMultipleNavigationGroupsCustomHTML: Story = {
+ name: 'Footer with multiple navigation groups and custom HTML',
+ render: (args) => (
+
+
+ About us
+ Give us feedback
+ Accessibility statement
+
+
+
+ Cookies
+ Privacy policy
+ Terms and conditions
+
+
+
+
+ Manchester University NHS Foundation Trust (MFT) was formed on 1st
+ October 2017 following the merger of Central Manchester University Hospitals NHS
+ Foundation Trust (CMFT) and University Hospital of South Manchester NHS Foundation Trust
+ (UHSM).
+
+
+
+
+
+ Cobbett House, Manchester University NHS Foundation Trust, Oxford Road, Manchester, M13
+ 9WL
+
+
-
+
+ © 2025 – Manchester University NHS Foundation Trust
),
};
+
+export const WithMultipleTitledNavigationGroups: Story = {
+ name: 'Footer with multiple titled navigation groups',
+ render: (args) => (
+
+
+ Legal
+ Looking after your data
+ Freedom of information
+ Modern Slavery and human trafficking statement
+
+
+
+ Get in touch
+ Get in touch
+ Contact us
+ Press office
+ Tell us what you think of our website
+ RSS feeds
+
+
+
+ Follow us
+ LinkedIn
+ YouTube
+
+
+ ),
+};
+
+export const WithSingleNavigationGroup: Story = {
+ name: 'Footer with single navigation group',
+ render: (args) => (
+
+
+ Accessibility statement
+ Contact us
+ Cookies
+ Privacy policy
+ Terms and conditions
+
+
+ ),
+};
diff --git a/stories/Navigation/Header.stories.tsx b/stories/Navigation/Header.stories.tsx
index 7a7f0f640..97fe6b719 100644
--- a/stories/Navigation/Header.stories.tsx
+++ b/stories/Navigation/Header.stories.tsx
@@ -6,6 +6,10 @@ import { Header } from '#components/navigation/header/index.js';
const meta: Meta = {
title: 'Navigation/Header',
component: Header,
+ parameters: {
+ layout: 'fullscreen',
+ width: false,
+ },
};
export default meta;
type Story = StoryObj;
diff --git a/stories/Navigation/Pagination.stories.tsx b/stories/Navigation/Pagination.stories.tsx
index 8cd740eb4..b208201b8 100644
--- a/stories/Navigation/Pagination.stories.tsx
+++ b/stories/Navigation/Pagination.stories.tsx
@@ -5,6 +5,9 @@ import { Pagination } from '#components/navigation/pagination/index.js';
const meta: Meta = {
title: 'Navigation/Pagination',
component: Pagination,
+ parameters: {
+ width: 'full',
+ },
};
export default meta;
type Story = StoryObj;
diff --git a/stories/Navigation/SkipLink.stories.tsx b/stories/Navigation/SkipLink.stories.tsx
index 1c7d3d49a..51f92374c 100644
--- a/stories/Navigation/SkipLink.stories.tsx
+++ b/stories/Navigation/SkipLink.stories.tsx
@@ -2,6 +2,7 @@ import { type Meta, type StoryObj } from '@storybook/react-vite';
import { type FC, type ReactNode } from 'react';
import { HintText } from '#components/form-elements/hint-text/index.js';
+import { Row, Col, Container } from '#components/layout/index.js';
import { SkipLink } from '#components/navigation/skip-link/index.js';
const CodeText: FC<{ children: ReactNode }> = ({ children }) => (
@@ -23,7 +24,6 @@ const CodeText: FC<{ children: ReactNode }> = ({ children }) => (
const meta: Meta = {
title: 'Navigation/SkipLink',
component: SkipLink,
-
render: (args) => (
<>
@@ -31,9 +31,19 @@ const meta: Meta = {
tab
to show the SkipLink
+
- Page heading
- This is the main content
+
+
+
+
+
+ Page heading
+ This is the main content
+
+
+
+
>
),
};
diff --git a/stories/Patterns/PageAZ.stories.tsx b/stories/Patterns/PageAZ.stories.tsx
index 7a70602bb..e6b897450 100644
--- a/stories/Patterns/PageAZ.stories.tsx
+++ b/stories/Patterns/PageAZ.stories.tsx
@@ -2,7 +2,7 @@ import { type Meta, type StoryObj } from '@storybook/react-vite';
import { Col, Container, Row } from '#components/layout/index.js';
import { Card } from '#components/navigation/card/index.js';
-import { HeadingLevel } from '#components/utils/index.js';
+import { Heading } from '#components/typography/index.js';
import { NavAZ } from '#patterns/nav-a-z/index.js';
/**
@@ -19,6 +19,10 @@ const meta: Meta = {
disabledLetters: [],
letters: [],
},
+ parameters: {
+ layout: 'fullscreen',
+ width: false,
+ },
};
export default meta;
type Story = StoryObj;
@@ -29,7 +33,7 @@ export const Standard: Story = {
- Health A to Z
+ Health A to Z
A
@@ -60,21 +64,19 @@ export const Standard: Story = {
Z
-
-
- A
-
-
+
+ A
+
@@ -83,13 +85,11 @@ export const Standard: Story = {
-
-
- B
-
- There are currently no conditions listed
-
-
+
+ B
+
+ There are currently no conditions listed
+
@@ -98,18 +98,16 @@ export const Standard: Story = {
-
-
- C
-
-
+
+ C
+
@@ -118,21 +116,19 @@ export const Standard: Story = {
-
-
- D
-
-
+
+ D
+
diff --git a/tsconfig.stories.json b/tsconfig.stories.json
index ae0ba1312..a2d24395d 100644
--- a/tsconfig.stories.json
+++ b/tsconfig.stories.json
@@ -4,5 +4,10 @@
"jsx": "react-jsxdev",
"types": ["node"]
},
- "include": ["./.storybook/*.ts", "./stories/**/*.tsx"]
+ "include": [
+ "./.storybook/*.ts",
+ "./.storybook/*.tsx",
+ "./stories/**/*.tsx",
+ ".storybook/preview.tsx"
+ ]
}
diff --git a/yarn.lock b/yarn.lock
index 3de28f126..9651cc493 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7382,10 +7382,10 @@ __metadata:
languageName: node
linkType: hard
-"nhsuk-frontend@npm:^10.2.2":
- version: 10.2.2
- resolution: "nhsuk-frontend@npm:10.2.2"
- checksum: 10c0/19811214e56188204924a828780771a994ed9ed496c21fa90d49b46a58c1c500903a3b346229ab3df4ba8c67262f17b8fa05ec2c4c25582a4e5e84ec678ba059
+"nhsuk-frontend@npm:^10.3.1":
+ version: 10.3.1
+ resolution: "nhsuk-frontend@npm:10.3.1"
+ checksum: 10c0/66941f6dd2ce4c275c66b54880e6b7e9ae96b3b3479a1797c5bba4a7d58f7b8c029189e51f6628c017b3d173c81996c910545a4373c04766f139a994f13ac82d
languageName: node
linkType: hard
@@ -7430,7 +7430,7 @@ __metadata:
jest: "npm:^30.2.0"
jest-axe: "npm:^10.0.0"
jest-environment-jsdom: "npm:^30.2.0"
- nhsuk-frontend: "npm:^10.2.2"
+ nhsuk-frontend: "npm:^10.3.1"
outdent: "npm:^0.8.0"
prettier: "npm:^3.8.0"
react: "npm:^19.2.3"
@@ -7446,7 +7446,7 @@ __metadata:
vite-tsconfig-paths: "npm:^6.0.4"
peerDependencies:
classnames: ">=2.5.0"
- nhsuk-frontend: ">=10.2.0 <11.0.0"
+ nhsuk-frontend: ">=10.3.0 <11.0.0"
react: ">=18.2.0"
react-dom: ">=18.2.0"
tslib: ">=2.8.0"