Skip to content

Commit 0e0f3d4

Browse files
Fix radios component with defaultChecked inputs and conditional content (#377)
* Fallback to `name` when `id` (or `idPrefix`) is not provided Form components with missing `id` (or `idPrefix`) now fall back to the `name` prop before generating IDs. This helps prevent hydration mismatch errors with server-side rendering (SSR) HTML https://react.dev/link/hydration-mismatch * Fix story typos * Fix unique IDs required for character count * Simplify checkbox ID and name stories * Populate conditional content text input values * Add radios change handler story * Fix checkboxes `forceShowConditional` option by checking input * Fix radios `forceShowConditional` option by checking input * Remove non-working radio selection state from v5.x * Align differences between checkboxes and radios * Add default change handlers to checkboxes and radios * Add radios stories to show controlled `checked` change via state * Add checkboxes stories to show controlled `checked` change via state * Update tests for conditional content * Add changelog entry
1 parent 384e38a commit 0e0f3d4

19 files changed

Lines changed: 704 additions & 437 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# NHS.UK React components
22

3+
## Unreleased
4+
5+
This is a bug fix release.
6+
7+
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.
8+
9+
### :wrench: **Fixes**
10+
11+
- [#377: Fix radios component with `defaultChecked` inputs and conditional content](https://github.com/NHSDigital/nhsuk-react-components/pull/377)
12+
313
## 6.0.0 - 10 March 2026
414

515
This version provides support for NHS.UK frontend v10.3.0

src/components/form-elements/character-count/CharacterCount.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export const CharacterCount = forwardRef<HTMLTextAreaElement, CharacterCountProp
6464
}}
6565
{...rest}
6666
>
67-
{({ className, id, error, 'aria-describedby': ariaDescribedBy, ...rest }) => (
67+
{({ className, id, error, 'aria-describedby': ariaDescribedBy, ...restRenderProps }) => (
6868
<>
6969
<textarea
7070
className={classNames(
@@ -76,7 +76,7 @@ export const CharacterCount = forwardRef<HTMLTextAreaElement, CharacterCountProp
7676
id={id}
7777
aria-describedby={ariaDescribedBy ? `${id}-info ${ariaDescribedBy}` : `${id}-info`}
7878
ref={forwardedRef}
79-
{...rest}
79+
{...restRenderProps}
8080
/>
8181
<div className="nhsuk-hint nhsuk-character-count__message" id={`${id}-info`}>
8282
{maxWords

src/components/form-elements/checkboxes/Checkboxes.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import classNames from 'classnames';
44
import { type Checkboxes as CheckboxesModule } from 'nhsuk-frontend';
55
import {
6+
type ChangeEvent,
67
type ComponentPropsWithoutRef,
78
forwardRef,
89
useEffect,
@@ -27,7 +28,7 @@ export type CheckboxesProps = CheckboxesElementProps &
2728
Omit<FormElementProps, 'label' | 'labelProps'>;
2829

2930
const CheckboxesComponent = forwardRef<HTMLDivElement, CheckboxesProps>((props, forwardedRef) => {
30-
const { children, idPrefix, ...rest } = props;
31+
const { children, idPrefix = props.name, onChange, ...rest } = props;
3132

3233
const moduleRef = useRef<HTMLDivElement>(null);
3334
const importRef = useRef<Promise<CheckboxesModule | void>>(null);
@@ -79,6 +80,12 @@ const CheckboxesComponent = forwardRef<HTMLDivElement, CheckboxesProps>((props,
7980
_boxIds = {};
8081
};
8182

83+
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
84+
if (onChange) {
85+
onChange(e);
86+
}
87+
};
88+
8289
if (instanceError) {
8390
throw instanceError;
8491
}
@@ -92,6 +99,7 @@ const CheckboxesComponent = forwardRef<HTMLDivElement, CheckboxesProps>((props,
9299
getBoxId: (reference) => getBoxId(id, reference),
93100
leaseReference,
94101
unleaseReference,
102+
handleChange,
95103
};
96104
return (
97105
<div
@@ -101,7 +109,7 @@ const CheckboxesComponent = forwardRef<HTMLDivElement, CheckboxesProps>((props,
101109
className,
102110
)}
103111
data-module="nhsuk-checkboxes"
104-
id={id}
112+
id={id === rest.id ? id : undefined}
105113
ref={moduleRef}
106114
{...restRenderProps}
107115
>
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
'use client';
22

3-
import { createContext } from 'react';
3+
import { type ChangeEvent, createContext } from 'react';
44

55
export interface ICheckboxesContext {
66
name: string;
77
getBoxId: (reference: string) => string | undefined;
88
leaseReference: () => string;
99
unleaseReference: (reference: string) => void;
10+
handleChange: (event: ChangeEvent<HTMLInputElement>) => void;
1011
}
1112

1213
export const CheckboxesContext = createContext<ICheckboxesContext>({
1314
name: '',
1415
getBoxId: () => undefined,
1516
leaseReference: () => '',
1617
unleaseReference: () => {},
18+
handleChange: () => {},
1719
});

src/components/form-elements/checkboxes/__tests__/Checkboxes.test.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,42 @@ describe('Checkboxes', () => {
194194
expect(fieldRef.current).toHaveClass('nhsuk-checkboxes__input');
195195
});
196196

197+
it('does not render the conditional content if not checked', async () => {
198+
const { container } = await renderClient(
199+
<Checkboxes id="example" name="example">
200+
<Checkboxes.Item value="email" conditional={<p className="conditional-test">Test</p>}>
201+
Email
202+
</Checkboxes.Item>
203+
<Checkboxes.Item value="phone">Phone</Checkboxes.Item>
204+
<Checkboxes.Item value="text">Text message</Checkboxes.Item>
205+
</Checkboxes>,
206+
{ moduleName: 'nhsuk-checkboxes' },
207+
);
208+
209+
const conditionalElement = container.querySelector('.conditional-test');
210+
expect(conditionalElement?.parentElement).toHaveClass('nhsuk-checkboxes__conditional--hidden');
211+
});
212+
213+
it('renders the conditional content if checked', async () => {
214+
const { container } = await renderClient(
215+
<Checkboxes id="example" name="example">
216+
<Checkboxes.Item
217+
value="email"
218+
conditional={<p className="conditional-test">Test</p>}
219+
checked
220+
>
221+
Email
222+
</Checkboxes.Item>
223+
<Checkboxes.Item value="phone">Phone</Checkboxes.Item>
224+
<Checkboxes.Item value="text">Text message</Checkboxes.Item>
225+
</Checkboxes>,
226+
{ moduleName: 'nhsuk-checkboxes' },
227+
);
228+
229+
const conditionalElement = container.querySelector('.conditional-test');
230+
expect(conditionalElement).toHaveTextContent('Test');
231+
});
232+
197233
it('sets attribute `data-checkbox-exclusive` when items are exclusive', async () => {
198234
const { container } = await renderClient(
199235
<Checkboxes id="example" name="example">

src/components/form-elements/checkboxes/components/CheckboxesItem.tsx

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import classNames from 'classnames';
44
import {
5+
type ChangeEvent,
56
type ComponentPropsWithRef,
67
type ComponentPropsWithoutRef,
78
type ReactNode,
@@ -31,27 +32,39 @@ export type CheckboxesItemProps = CheckboxesItemElementProps &
3132
export const CheckboxesItem = forwardRef<HTMLInputElement, CheckboxesItemProps>(
3233
(props, forwardedRef) => {
3334
const {
34-
id,
35-
labelProps,
35+
className,
3636
children,
37+
id,
3738
hint,
3839
hintProps,
40+
labelProps,
3941
conditional,
40-
defaultChecked,
41-
checked,
42-
forceShowConditional,
4342
conditionalProps,
43+
forceShowConditional,
44+
checked,
45+
defaultChecked,
4446
exclusive,
4547
exclusiveGroup,
48+
onChange,
4649
...rest
4750
} = props;
4851

49-
const { getBoxId, name, leaseReference, unleaseReference } =
50-
useContext<ICheckboxesContext>(CheckboxesContext);
52+
const {
53+
name,
54+
getBoxId,
55+
leaseReference,
56+
unleaseReference,
57+
handleChange: ctxHandleChange,
58+
} = useContext<ICheckboxesContext>(CheckboxesContext);
5159

5260
const [checkboxReference] = useState<string>(leaseReference());
5361
const inputID = id || getBoxId(checkboxReference);
54-
const shouldShowConditional = !!(checked || defaultChecked);
62+
63+
const isChecked =
64+
// 1. Checkbox is checked via props
65+
!!(checked || defaultChecked) ||
66+
// 2. Checkbox should be checked (to show conditional)
67+
forceShowConditional;
5568

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

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

77+
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
78+
if (onChange) {
79+
onChange(e);
80+
}
81+
82+
ctxHandleChange(e);
83+
};
84+
6485
return (
6586
<>
6687
<div className="nhsuk-checkboxes__item">
6788
<input
68-
className="nhsuk-checkboxes__input"
89+
className={classNames('nhsuk-checkboxes__input', className)}
6990
id={inputID}
7091
name={name}
7192
type="checkbox"
7293
checked={checked}
73-
defaultChecked={defaultChecked}
94+
defaultChecked={defaultChecked ?? (checked === undefined ? isChecked : undefined)}
7495
data-checkbox-exclusive={exclusive}
7596
data-checkbox-exclusive-group={exclusiveGroup}
7697
data-aria-controls={conditional ? `${inputID}--conditional` : undefined}
7798
aria-describedby={hint ? `${inputID}--hint` : undefined}
99+
onChange={handleChange}
78100
ref={forwardedRef}
79101
{...inputProps}
80102
/>
@@ -98,11 +120,7 @@ export const CheckboxesItem = forwardRef<HTMLInputElement, CheckboxesItemProps>(
98120
<div
99121
className={classNames(
100122
'nhsuk-checkboxes__conditional',
101-
{
102-
'nhsuk-checkboxes__conditional--hidden': !(
103-
shouldShowConditional || forceShowConditional
104-
),
105-
},
123+
{ 'nhsuk-checkboxes__conditional--hidden': !isChecked },
106124
conditionalClassName,
107125
)}
108126
id={`${inputID}--conditional`}

src/components/form-elements/date-input/DateInput.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ const DateInputComponent = forwardRef<HTMLDivElement, DateInputProps>(
9898
return (
9999
<div
100100
className={classNames('nhsuk-date-input', className)}
101-
id={id}
101+
id={id === rest.id ? id : undefined}
102102
ref={forwardedRef}
103103
{...restRenderProps}
104104
>

src/components/form-elements/file-upload/FileUpload.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,12 @@ export const FileUpload = forwardRef<HTMLInputElement, FileUploadProps>(
6060
'ref': moduleRef,
6161
}}
6262
>
63-
{({ width, className, error, autoComplete, ...rest }) => (
63+
{({ width, className, error, autoComplete, ...restRenderProps }) => (
6464
<input
6565
className={classNames('nhsuk-file-upload__input', className)}
6666
ref={forwardedRef}
6767
type="file"
68-
{...rest}
68+
{...restRenderProps}
6969
/>
7070
)}
7171
</FormGroup>

src/components/form-elements/password-input/PasswordInput.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
8484
'ref': moduleRef,
8585
}}
8686
>
87-
{({ width, className, error, autoComplete, ...rest }) => (
87+
{({ width, className, error, autoComplete, ...restRenderProps }) => (
8888
<input
8989
className={classNames(
9090
'nhsuk-input',
@@ -98,7 +98,7 @@ export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
9898
spellCheck="false"
9999
autoCapitalize="none"
100100
autoComplete={autoComplete ?? 'current-password'}
101-
{...rest}
101+
{...restRenderProps}
102102
/>
103103
)}
104104
{({ id }) => (

src/components/form-elements/radios/Radios.tsx

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import classNames from 'classnames';
44
import { type Radios as RadiosModule } from 'nhsuk-frontend';
55
import {
6+
type ChangeEvent,
67
type ComponentPropsWithoutRef,
78
forwardRef,
89
useEffect,
@@ -27,13 +28,12 @@ export interface RadiosElementProps extends ComponentPropsWithoutRef<'div'> {
2728
export type RadiosProps = RadiosElementProps & Omit<FormElementProps, 'label' | 'labelProps'>;
2829

2930
const RadiosComponent = forwardRef<HTMLDivElement, RadiosProps>((props, forwardedRef) => {
30-
const { children, idPrefix, ...rest } = props;
31+
const { children, idPrefix = props.name, onChange, ...rest } = props;
3132

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

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

79-
const setSelected = (radioReference: string): void => {
80-
setSelectedRadio(radioReference);
81-
};
82-
8379
const resetRadioIds = (): void => {
8480
_radioCount = 0;
8581
_radioIds = {};
8682
};
8783

84+
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
85+
if (onChange) {
86+
onChange(e);
87+
}
88+
};
89+
8890
if (instanceError) {
8991
throw instanceError;
9092
}
9193

9294
return (
9395
<FormGroup<RadiosProps, 'div'> inputType="radios" {...rest}>
94-
{({ className, inline, small, name, id, error, ...restRenderProps }) => {
96+
{({ className, inline, small, name, id, idPrefix, error, ...restRenderProps }) => {
9597
resetRadioIds();
9698
const contextValue: IRadiosContext = {
99+
name,
97100
getRadioId: (reference) => getRadioId(id, reference),
98-
selectedRadio: selectedRadio,
99-
setSelected: setSelected,
100101
leaseReference: leaseReference,
101102
unleaseReference: unleaseReference,
102-
name,
103+
handleChange,
103104
};
104105

105106
return (
@@ -113,7 +114,7 @@ const RadiosComponent = forwardRef<HTMLDivElement, RadiosProps>((props, forwarde
113114
className,
114115
)}
115116
data-module="nhsuk-radios"
116-
id={id}
117+
id={id === rest.id ? id : undefined}
117118
ref={moduleRef}
118119
{...restRenderProps}
119120
>

0 commit comments

Comments
 (0)