Skip to content

Commit a19b721

Browse files
authored
Use SSR-compatible slot implementation in CheckboxGroup/RadioGroup (#3146)
* Update slots for checkbox group and radio group * Update useSlot return type * Update exports test * Create .changeset/young-queens-notice.md * Update comment indentation
1 parent d64b5c1 commit a19b721

File tree

10 files changed

+126
-131
lines changed

10 files changed

+126
-131
lines changed

.changeset/young-queens-notice.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@primer/react": patch
3+
---
4+
5+
`CheckboxGroup` and `RadioGroup` are now SSR-compatible.
6+
7+
Warning: In this new implementation, `CheckboxGroup.Caption`, `CheckboxGroup.Label,` and `CheckboxGroup.Validation` must be direct children of `CheckboxGroup`. The same applies to `RadioGroup`.

src/FormControl/FormControl.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import ValidationAnimationContainer from '../_ValidationAnimationContainer'
1616
import {get} from '../constants'
1717
import FormControlLeadingVisual from './_FormControlLeadingVisual'
1818
import {SxProp} from '../sx'
19-
import CheckboxOrRadioGroupContext from '../_CheckboxOrRadioGroup/_CheckboxOrRadioGroupContext'
19+
import {CheckboxOrRadioGroupContext} from '../_CheckboxOrRadioGroup'
2020
import InlineAutocomplete from '../drafts/InlineAutocomplete'
2121

2222
export type FormControlProps = {
@@ -58,7 +58,7 @@ const FormControl = React.forwardRef<HTMLDivElement, FormControlProps>(
5858
InlineAutocomplete,
5959
]
6060
const choiceGroupContext = useContext(CheckboxOrRadioGroupContext)
61-
const disabled = choiceGroupContext?.disabled || disabledProp
61+
const disabled = choiceGroupContext.disabled || disabledProp
6262
const id = useSSRSafeId(idProp)
6363
const validationChild = React.Children.toArray(children).find(child =>
6464
React.isValidElement(child) && child.type === FormControlValidation ? child : null,

src/_CheckboxOrRadioGroup/CheckboxOrRadioGroup.tsx

Lines changed: 71 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
import React from 'react'
2+
import styled from 'styled-components'
23
import Box from '../Box'
3-
import {useSSRSafeId} from '../utils/ssr'
44
import ValidationAnimationContainer from '../_ValidationAnimationContainer'
5+
import {get} from '../constants'
6+
import {useSSRSafeId} from '../utils/ssr'
57
import CheckboxOrRadioGroupCaption from './_CheckboxOrRadioGroupCaption'
68
import CheckboxOrRadioGroupLabel from './_CheckboxOrRadioGroupLabel'
79
import CheckboxOrRadioGroupValidation from './_CheckboxOrRadioGroupValidation'
8-
import {Slots} from './slots'
9-
import styled from 'styled-components'
10-
import {get} from '../constants'
11-
import CheckboxOrRadioGroupContext from './_CheckboxOrRadioGroupContext'
1210
import VisuallyHidden from '../_VisuallyHidden'
11+
import {useSlots} from '../hooks/useSlots'
1312
import {SxProp} from '../sx'
1413

1514
export type CheckboxOrRadioGroupProps = {
@@ -37,6 +36,8 @@ export type CheckboxOrRadioGroupContext = {
3736
captionId?: string
3837
} & CheckboxOrRadioGroupProps
3938

39+
export const CheckboxOrRadioGroupContext = React.createContext<CheckboxOrRadioGroupContext>({})
40+
4041
const Body = styled.div`
4142
display: flex;
4243
flex-direction: column;
@@ -57,6 +58,11 @@ const CheckboxOrRadioGroup: React.FC<React.PropsWithChildren<CheckboxOrRadioGrou
5758
required = false,
5859
sx,
5960
}) => {
61+
const [slots, rest] = useSlots(children, {
62+
caption: CheckboxOrRadioGroupCaption,
63+
label: CheckboxOrRadioGroupLabel,
64+
validation: CheckboxOrRadioGroupValidation,
65+
})
6066
const labelChild = React.Children.toArray(children).find(
6167
child => React.isValidElement(child) && child.type === CheckboxOrRadioGroupLabel,
6268
)
@@ -67,8 +73,8 @@ const CheckboxOrRadioGroup: React.FC<React.PropsWithChildren<CheckboxOrRadioGrou
6773
React.isValidElement(child) && child.type === CheckboxOrRadioGroupCaption ? child : null,
6874
)
6975
const id = useSSRSafeId(idProp)
70-
const validationMessageId = validationChild && `${id}-validationMessage`
71-
const captionId = captionChild && `${id}-caption`
76+
const validationMessageId = validationChild ? `${id}-validationMessage` : undefined
77+
const captionId = captionChild ? `${id}-caption` : undefined
7278

7379
if (!labelChild && !ariaLabelledby) {
7480
// eslint-disable-next-line no-console
@@ -77,79 +83,73 @@ const CheckboxOrRadioGroup: React.FC<React.PropsWithChildren<CheckboxOrRadioGrou
7783
)
7884
}
7985

86+
const isLegendVisible = React.isValidElement(labelChild) && !labelChild.props.visuallyHidden
87+
8088
return (
81-
<Slots
82-
context={{
89+
<CheckboxOrRadioGroupContext.Provider
90+
value={{
8391
disabled,
8492
required,
8593
captionId,
8694
validationMessageId,
8795
}}
8896
>
89-
{slots => {
90-
const isLegendVisible = React.isValidElement(labelChild) && !labelChild.props.visuallyHidden
91-
92-
return (
93-
<CheckboxOrRadioGroupContext.Provider value={{disabled}}>
94-
<div>
95-
<Box
96-
border="none"
97-
margin={0}
98-
mb={validationChild ? 2 : undefined}
99-
padding={0}
100-
{...(labelChild && {
101-
as: 'fieldset',
102-
disabled,
103-
})}
104-
sx={sx}
105-
>
106-
{labelChild ? (
107-
/*
108-
Placing the caption text and validation text in the <legend> provides a better user
109-
experience for more screenreaders.
97+
<div>
98+
<Box
99+
border="none"
100+
margin={0}
101+
mb={validationChild ? 2 : undefined}
102+
padding={0}
103+
{...(labelChild && {
104+
as: 'fieldset',
105+
disabled,
106+
})}
107+
sx={sx}
108+
>
109+
{labelChild ? (
110+
/*
111+
Placing the caption text and validation text in the <legend> provides a better user
112+
experience for more screenreaders.
110113
111-
Reference: https://blog.tenon.io/accessible-validation-of-checkbox-and-radiobutton-groups/
112-
*/
113-
<Box as="legend" mb={isLegendVisible ? 2 : undefined} padding={0}>
114-
{slots.Label}
115-
{slots.Caption}
116-
{React.isValidElement(slots.Validation) && slots.Validation.props.children && (
117-
<VisuallyHidden>{slots.Validation.props.children}</VisuallyHidden>
118-
)}
119-
</Box>
120-
) : (
121-
/*
122-
If CheckboxOrRadioGroup.Label wasn't passed as a child, we don't render a <legend>
123-
but we still want to render a caption
124-
*/
125-
slots.Caption
126-
)}
127-
128-
<Body
129-
{...(!labelChild && {
130-
['aria-labelledby']: ariaLabelledby,
131-
['aria-describedby']: [validationMessageId, captionId].filter(Boolean).join(' '),
132-
as: 'div',
133-
role: 'group',
134-
})}
135-
>
136-
{React.Children.toArray(children).filter(child => React.isValidElement(child))}
137-
</Body>
138-
</Box>
139-
{validationChild && (
140-
<ValidationAnimationContainer
141-
// If we have CheckboxOrRadioGroup.Label as a child, we render a screenreader-accessible validation message in the <legend>
142-
aria-hidden={Boolean(labelChild)}
143-
show
144-
>
145-
{slots.Validation}
146-
</ValidationAnimationContainer>
114+
Reference: https://blog.tenon.io/accessible-validation-of-checkbox-and-radiobutton-groups/
115+
*/
116+
<Box as="legend" mb={isLegendVisible ? 2 : undefined} padding={0}>
117+
{slots.label}
118+
{slots.caption}
119+
{React.isValidElement(slots.validation) && slots.validation.props.children && (
120+
<VisuallyHidden>{slots.validation.props.children}</VisuallyHidden>
147121
)}
148-
</div>
149-
</CheckboxOrRadioGroupContext.Provider>
150-
)
151-
}}
152-
</Slots>
122+
</Box>
123+
) : (
124+
/*
125+
If CheckboxOrRadioGroup.Label wasn't passed as a child, we don't render a <legend>
126+
but we still want to render a caption
127+
*/
128+
slots.caption
129+
)}
130+
131+
<Body
132+
{...(!labelChild && {
133+
['aria-labelledby']: ariaLabelledby,
134+
['aria-describedby']: [validationMessageId, captionId].filter(Boolean).join(' '),
135+
as: 'div',
136+
role: 'group',
137+
})}
138+
>
139+
{React.Children.toArray(rest).filter(child => React.isValidElement(child))}
140+
</Body>
141+
</Box>
142+
{validationChild && (
143+
<ValidationAnimationContainer
144+
// If we have CheckboxOrRadioGroup.Label as a child, we render a screenreader-accessible validation message in the <legend>
145+
aria-hidden={Boolean(labelChild)}
146+
show
147+
>
148+
{slots.validation}
149+
</ValidationAnimationContainer>
150+
)}
151+
</div>
152+
</CheckboxOrRadioGroupContext.Provider>
153153
)
154154
}
155155

src/_CheckboxOrRadioGroup/_CheckboxOrRadioGroupCaption.tsx

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,14 @@ import React from 'react'
22
import Text from '../Text'
33
import {SxProp} from '../sx'
44
import {CheckboxOrRadioGroupContext} from './CheckboxOrRadioGroup'
5-
import {Slot} from './slots'
65

7-
const CheckboxOrRadioGroupCaption: React.FC<React.PropsWithChildren<SxProp>> = ({children, sx}) => (
8-
<Slot name="Caption">
9-
{({disabled, captionId}: CheckboxOrRadioGroupContext) => (
10-
<Text color={disabled ? 'fg.muted' : 'fg.subtle'} fontSize={1} id={captionId} sx={sx}>
11-
{children}
12-
</Text>
13-
)}
14-
</Slot>
15-
)
6+
const CheckboxOrRadioGroupCaption: React.FC<React.PropsWithChildren<SxProp>> = ({children, sx}) => {
7+
const {disabled, captionId} = React.useContext(CheckboxOrRadioGroupContext)
8+
return (
9+
<Text color={disabled ? 'fg.muted' : 'fg.subtle'} fontSize={1} id={captionId} sx={sx}>
10+
{children}
11+
</Text>
12+
)
13+
}
1614

1715
export default CheckboxOrRadioGroupCaption

src/_CheckboxOrRadioGroup/_CheckboxOrRadioGroupContext.tsx

Lines changed: 0 additions & 7 deletions
This file was deleted.
Lines changed: 25 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import React from 'react'
22
import Box from '../Box'
3-
import {SxProp} from '../sx'
43
import VisuallyHidden from '../_VisuallyHidden'
4+
import {SxProp} from '../sx'
55
import {CheckboxOrRadioGroupContext} from './CheckboxOrRadioGroup'
6-
import {Slot} from './slots'
76

87
export type CheckboxOrRadioGroupLabelProps = {
98
/**
@@ -16,30 +15,29 @@ const CheckboxOrRadioGroupLabel: React.FC<React.PropsWithChildren<CheckboxOrRadi
1615
children,
1716
visuallyHidden = false,
1817
sx,
19-
}) => (
20-
<Slot name="Label">
21-
{({required, disabled}: CheckboxOrRadioGroupContext) => (
22-
<VisuallyHidden
23-
isVisible={!visuallyHidden}
24-
title={required ? 'required field' : undefined}
25-
sx={{
26-
display: 'block',
27-
color: disabled ? 'fg.muted' : undefined,
28-
fontSize: 2,
29-
...sx,
30-
}}
31-
>
32-
{required ? (
33-
<Box display="flex" as="span">
34-
<Box mr={1}>{children}</Box>
35-
<span>*</span>
36-
</Box>
37-
) : (
38-
children
39-
)}
40-
</VisuallyHidden>
41-
)}
42-
</Slot>
43-
)
18+
}) => {
19+
const {required, disabled} = React.useContext(CheckboxOrRadioGroupContext)
20+
return (
21+
<VisuallyHidden
22+
isVisible={!visuallyHidden}
23+
title={required ? 'required field' : undefined}
24+
sx={{
25+
display: 'block',
26+
color: disabled ? 'fg.muted' : undefined,
27+
fontSize: 2,
28+
...sx,
29+
}}
30+
>
31+
{required ? (
32+
<Box display="flex" as="span">
33+
<Box mr={1}>{children}</Box>
34+
<span>*</span>
35+
</Box>
36+
) : (
37+
children
38+
)}
39+
</VisuallyHidden>
40+
)
41+
}
4442

4543
export default CheckboxOrRadioGroupLabel

src/_CheckboxOrRadioGroup/_CheckboxOrRadioGroupValidation.tsx

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import InputValidation from '../_InputValidation'
33
import {SxProp} from '../sx'
44
import {FormValidationStatus} from '../utils/types/FormValidationStatus'
55
import {CheckboxOrRadioGroupContext} from './CheckboxOrRadioGroup'
6-
import {Slot} from './slots'
76

87
export type CheckboxOrRadioGroupValidationProps = {
98
/** Changes the visual style to match the validation status */
@@ -14,14 +13,13 @@ const CheckboxOrRadioGroupValidation: React.FC<React.PropsWithChildren<CheckboxO
1413
children,
1514
variant,
1615
sx,
17-
}) => (
18-
<Slot name="Validation">
19-
{({validationMessageId = ''}: CheckboxOrRadioGroupContext) => (
20-
<InputValidation validationStatus={variant} id={validationMessageId} sx={sx}>
21-
{children}
22-
</InputValidation>
23-
)}
24-
</Slot>
25-
)
16+
}) => {
17+
const {validationMessageId = ''} = React.useContext(CheckboxOrRadioGroupContext)
18+
return (
19+
<InputValidation validationStatus={variant} id={validationMessageId} sx={sx}>
20+
{children}
21+
</InputValidation>
22+
)
23+
}
2624

2725
export default CheckboxOrRadioGroupValidation

src/_CheckboxOrRadioGroup/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
export {default} from './CheckboxOrRadioGroup'
1+
export {default, CheckboxOrRadioGroupContext} from './CheckboxOrRadioGroup'
22
export type {CheckboxOrRadioGroupProps} from './CheckboxOrRadioGroup'

src/__tests__/CheckboxOrRadioGroup.test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import '@testing-library/jest-dom/extend-expect'
33
import {render, within} from '@testing-library/react'
44
import {Checkbox, FormControl, Radio, SSRProvider, TextInput} from '..'
55
import {behavesAsComponent, checkExports} from '../utils/testing'
6-
import CheckboxOrRadioGroup from '../_CheckboxOrRadioGroup'
6+
import CheckboxOrRadioGroup, {CheckboxOrRadioGroupContext} from '../_CheckboxOrRadioGroup'
77

88
const INPUT_GROUP_LABEL = 'Choices'
99

@@ -41,6 +41,7 @@ describe('CheckboxOrRadioGroup', () => {
4141
})
4242
checkExports('_CheckboxOrRadioGroup', {
4343
default: CheckboxOrRadioGroup,
44+
CheckboxOrRadioGroupContext,
4445
})
4546
it('renders a group of inputs with a caption in the <legend>', () => {
4647
render(

src/hooks/useSlots.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {warning} from '../utils/warning'
55
export type SlotConfig = Record<string, React.ComponentType<any>>
66

77
type SlotElements<Type extends SlotConfig> = {
8-
[Property in keyof Type]: React.ReactElement
8+
[Property in keyof Type]: React.ReactElement<React.ComponentPropsWithoutRef<Type[Property]>, Type[Property]>
99
}
1010

1111
/**
@@ -52,7 +52,7 @@ export function useSlots<T extends SlotConfig>(
5252
}
5353

5454
// If the child is a slot, add it to the `slots` object
55-
slots[slotKey] = child
55+
slots[slotKey] = child as React.ReactElement<React.ComponentPropsWithoutRef<T[keyof T]>, T[keyof T]>
5656
})
5757

5858
return [slots, rest]

0 commit comments

Comments
 (0)