Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# NHS.UK React components

## Unreleased

This is a bug fix release.

Note: Form components with missing `id` (or `idPrefix`) now fall back to the `name` prop before generating IDs. This helps prevent [hydration mismatch](https://react.dev/link/hydration-mismatch) errors with server-side rendered (SSR) HTML.

### :wrench: **Fixes**

- [#377: Fix radios component with `defaultChecked` inputs and conditional content](https://github.com/NHSDigital/nhsuk-react-components/pull/377)

## 6.0.0 - 10 March 2026

This version provides support for NHS.UK frontend v10.3.0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export const CharacterCount = forwardRef<HTMLTextAreaElement, CharacterCountProp
}}
{...rest}
>
{({ className, id, error, 'aria-describedby': ariaDescribedBy, ...rest }) => (
{({ className, id, error, 'aria-describedby': ariaDescribedBy, ...restRenderProps }) => (
<>
<textarea
className={classNames(
Expand All @@ -76,7 +76,7 @@ export const CharacterCount = forwardRef<HTMLTextAreaElement, CharacterCountProp
id={id}
aria-describedby={ariaDescribedBy ? `${id}-info ${ariaDescribedBy}` : `${id}-info`}
ref={forwardedRef}
{...rest}
{...restRenderProps}
/>
<div className="nhsuk-hint nhsuk-character-count__message" id={`${id}-info`}>
{maxWords
Expand Down
12 changes: 10 additions & 2 deletions src/components/form-elements/checkboxes/Checkboxes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import classNames from 'classnames';
import { type Checkboxes as CheckboxesModule } from 'nhsuk-frontend';
import {
type ChangeEvent,
type ComponentPropsWithoutRef,
forwardRef,
useEffect,
Expand All @@ -27,7 +28,7 @@ export type CheckboxesProps = CheckboxesElementProps &
Omit<FormElementProps, 'label' | 'labelProps'>;

const CheckboxesComponent = forwardRef<HTMLDivElement, CheckboxesProps>((props, forwardedRef) => {
const { children, idPrefix, ...rest } = props;
const { children, idPrefix = props.name, onChange, ...rest } = props;

const moduleRef = useRef<HTMLDivElement>(null);
const importRef = useRef<Promise<CheckboxesModule | void>>(null);
Expand Down Expand Up @@ -79,6 +80,12 @@ const CheckboxesComponent = forwardRef<HTMLDivElement, CheckboxesProps>((props,
_boxIds = {};
};

const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
if (onChange) {
onChange(e);
}
};

if (instanceError) {
throw instanceError;
}
Expand All @@ -92,6 +99,7 @@ const CheckboxesComponent = forwardRef<HTMLDivElement, CheckboxesProps>((props,
getBoxId: (reference) => getBoxId(id, reference),
leaseReference,
unleaseReference,
handleChange,
};
return (
<div
Expand All @@ -101,7 +109,7 @@ const CheckboxesComponent = forwardRef<HTMLDivElement, CheckboxesProps>((props,
className,
)}
data-module="nhsuk-checkboxes"
id={id}
id={id === rest.id ? id : undefined}
ref={moduleRef}
{...restRenderProps}
>
Expand Down
4 changes: 3 additions & 1 deletion src/components/form-elements/checkboxes/CheckboxesContext.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
'use client';

import { createContext } from 'react';
import { type ChangeEvent, createContext } from 'react';

export interface ICheckboxesContext {
name: string;
getBoxId: (reference: string) => string | undefined;
leaseReference: () => string;
unleaseReference: (reference: string) => void;
handleChange: (event: ChangeEvent<HTMLInputElement>) => void;
}

export const CheckboxesContext = createContext<ICheckboxesContext>({
name: '',
getBoxId: () => undefined,
leaseReference: () => '',
unleaseReference: () => {},
handleChange: () => {},
});
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,42 @@ describe('Checkboxes', () => {
expect(fieldRef.current).toHaveClass('nhsuk-checkboxes__input');
});

it('does not render the conditional content if not checked', async () => {
const { container } = await renderClient(
<Checkboxes id="example" name="example">
<Checkboxes.Item value="email" conditional={<p className="conditional-test">Test</p>}>
Email
</Checkboxes.Item>
<Checkboxes.Item value="phone">Phone</Checkboxes.Item>
<Checkboxes.Item value="text">Text message</Checkboxes.Item>
</Checkboxes>,
{ moduleName: 'nhsuk-checkboxes' },
);

const conditionalElement = container.querySelector('.conditional-test');
expect(conditionalElement?.parentElement).toHaveClass('nhsuk-checkboxes__conditional--hidden');
});

it('renders the conditional content if checked', async () => {
const { container } = await renderClient(
<Checkboxes id="example" name="example">
<Checkboxes.Item
value="email"
conditional={<p className="conditional-test">Test</p>}
checked
>
Email
</Checkboxes.Item>
<Checkboxes.Item value="phone">Phone</Checkboxes.Item>
<Checkboxes.Item value="text">Text message</Checkboxes.Item>
</Checkboxes>,
{ moduleName: 'nhsuk-checkboxes' },
);

const conditionalElement = container.querySelector('.conditional-test');
expect(conditionalElement).toHaveTextContent('Test');
});

it('sets attribute `data-checkbox-exclusive` when items are exclusive', async () => {
const { container } = await renderClient(
<Checkboxes id="example" name="example">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import classNames from 'classnames';
import {
type ChangeEvent,
type ComponentPropsWithRef,
type ComponentPropsWithoutRef,
type ReactNode,
Expand Down Expand Up @@ -31,27 +32,39 @@ export type CheckboxesItemProps = CheckboxesItemElementProps &
export const CheckboxesItem = forwardRef<HTMLInputElement, CheckboxesItemProps>(
(props, forwardedRef) => {
const {
id,
labelProps,
className,
children,
id,
hint,
hintProps,
labelProps,
conditional,
defaultChecked,
checked,
forceShowConditional,
conditionalProps,
forceShowConditional,
checked,
defaultChecked,
exclusive,
exclusiveGroup,
onChange,
...rest
} = props;

const { getBoxId, name, leaseReference, unleaseReference } =
useContext<ICheckboxesContext>(CheckboxesContext);
const {
name,
getBoxId,
leaseReference,
unleaseReference,
handleChange: ctxHandleChange,
} = useContext<ICheckboxesContext>(CheckboxesContext);

const [checkboxReference] = useState<string>(leaseReference());
const inputID = id || getBoxId(checkboxReference);
const shouldShowConditional = !!(checked || defaultChecked);

const isChecked =
// 1. Checkbox is checked via props
!!(checked || defaultChecked) ||
// 2. Checkbox should be checked (to show conditional)
forceShowConditional;

const { className: labelClassName, ...restLabelProps } = labelProps || {};
const { className: hintClassName, ...restHintProps } = hintProps || {};
Expand All @@ -61,20 +74,29 @@ export const CheckboxesItem = forwardRef<HTMLInputElement, CheckboxesItemProps>(

const inputProps: ComponentPropsWithDataAttributes<'input'> = rest;

const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
if (onChange) {
onChange(e);
}

ctxHandleChange(e);
};

return (
<>
<div className="nhsuk-checkboxes__item">
<input
className="nhsuk-checkboxes__input"
className={classNames('nhsuk-checkboxes__input', className)}
id={inputID}
name={name}
type="checkbox"
checked={checked}
defaultChecked={defaultChecked}
defaultChecked={defaultChecked ?? (checked === undefined ? isChecked : undefined)}
data-checkbox-exclusive={exclusive}
data-checkbox-exclusive-group={exclusiveGroup}
data-aria-controls={conditional ? `${inputID}--conditional` : undefined}
aria-describedby={hint ? `${inputID}--hint` : undefined}
onChange={handleChange}
ref={forwardedRef}
{...inputProps}
/>
Expand All @@ -98,11 +120,7 @@ export const CheckboxesItem = forwardRef<HTMLInputElement, CheckboxesItemProps>(
<div
className={classNames(
'nhsuk-checkboxes__conditional',
{
'nhsuk-checkboxes__conditional--hidden': !(
shouldShowConditional || forceShowConditional
),
},
{ 'nhsuk-checkboxes__conditional--hidden': !isChecked },
conditionalClassName,
)}
id={`${inputID}--conditional`}
Expand Down
2 changes: 1 addition & 1 deletion src/components/form-elements/date-input/DateInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ const DateInputComponent = forwardRef<HTMLDivElement, DateInputProps>(
return (
<div
className={classNames('nhsuk-date-input', className)}
id={id}
id={id === rest.id ? id : undefined}
ref={forwardedRef}
{...restRenderProps}
>
Expand Down
4 changes: 2 additions & 2 deletions src/components/form-elements/file-upload/FileUpload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,12 @@ export const FileUpload = forwardRef<HTMLInputElement, FileUploadProps>(
'ref': moduleRef,
}}
>
{({ width, className, error, autoComplete, ...rest }) => (
{({ width, className, error, autoComplete, ...restRenderProps }) => (
<input
className={classNames('nhsuk-file-upload__input', className)}
ref={forwardedRef}
type="file"
{...rest}
{...restRenderProps}
/>
)}
</FormGroup>
Expand Down
4 changes: 2 additions & 2 deletions src/components/form-elements/password-input/PasswordInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
'ref': moduleRef,
}}
>
{({ width, className, error, autoComplete, ...rest }) => (
{({ width, className, error, autoComplete, ...restRenderProps }) => (
<input
className={classNames(
'nhsuk-input',
Expand All @@ -98,7 +98,7 @@ export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
spellCheck="false"
autoCapitalize="none"
autoComplete={autoComplete ?? 'current-password'}
{...rest}
{...restRenderProps}
/>
)}
{({ id }) => (
Expand Down
23 changes: 12 additions & 11 deletions src/components/form-elements/radios/Radios.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import classNames from 'classnames';
import { type Radios as RadiosModule } from 'nhsuk-frontend';
import {
type ChangeEvent,
type ComponentPropsWithoutRef,
forwardRef,
useEffect,
Expand All @@ -27,13 +28,12 @@ export interface RadiosElementProps extends ComponentPropsWithoutRef<'div'> {
export type RadiosProps = RadiosElementProps & Omit<FormElementProps, 'label' | 'labelProps'>;

const RadiosComponent = forwardRef<HTMLDivElement, RadiosProps>((props, forwardedRef) => {
const { children, idPrefix, ...rest } = props;
const { children, idPrefix = props.name, onChange, ...rest } = props;

const moduleRef = useRef<HTMLDivElement>(null);
const importRef = useRef<Promise<RadiosModule | void>>(null);
const [instanceError, setInstanceError] = useState<Error>();
const [instance, setInstance] = useState<RadiosModule>();
const [selectedRadio, setSelectedRadio] = useState<string>();

const _radioReferences: string[] = [];
let _radioCount = 0;
Expand Down Expand Up @@ -76,30 +76,31 @@ const RadiosComponent = forwardRef<HTMLDivElement, RadiosProps>((props, forwarde
_radioReferences.splice(_radioReferences.indexOf(reference), 1);
};

const setSelected = (radioReference: string): void => {
setSelectedRadio(radioReference);
};

const resetRadioIds = (): void => {
_radioCount = 0;
_radioIds = {};
};

const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
if (onChange) {
onChange(e);
}
};

if (instanceError) {
throw instanceError;
}

return (
<FormGroup<RadiosProps, 'div'> inputType="radios" {...rest}>
{({ className, inline, small, name, id, error, ...restRenderProps }) => {
{({ className, inline, small, name, id, idPrefix, error, ...restRenderProps }) => {
resetRadioIds();
const contextValue: IRadiosContext = {
name,
getRadioId: (reference) => getRadioId(id, reference),
selectedRadio: selectedRadio,
setSelected: setSelected,
leaseReference: leaseReference,
unleaseReference: unleaseReference,
name,
handleChange,
};

return (
Expand All @@ -113,7 +114,7 @@ const RadiosComponent = forwardRef<HTMLDivElement, RadiosProps>((props, forwarde
className,
)}
data-module="nhsuk-radios"
id={id}
id={id === rest.id ? id : undefined}
ref={moduleRef}
{...restRenderProps}
>
Expand Down
8 changes: 3 additions & 5 deletions src/components/form-elements/radios/RadiosContext.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,19 @@
'use client';

import { createContext } from 'react';
import { type ChangeEvent, createContext } from 'react';

export interface IRadiosContext {
name: string;
selectedRadio?: string;
getRadioId: (reference: string) => string;
setSelected: (radioRef: string) => void;
leaseReference: () => string;
unleaseReference: (reference: string) => void;
handleChange: (event: ChangeEvent<HTMLInputElement>) => void;
}

export const RadiosContext = createContext<IRadiosContext>({
name: '',
selectedRadio: '',
getRadioId: () => '',
setSelected: () => {},
leaseReference: () => '',
unleaseReference: () => {},
handleChange: () => {},
});
Loading