Skip to content

Commit

Permalink
Use SSR-compatible slot implementation in CheckboxGroup/RadioGroup (#…
Browse files Browse the repository at this point in the history
…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
  • Loading branch information
colebemis committed Apr 10, 2023
1 parent d64b5c1 commit a19b721
Show file tree
Hide file tree
Showing 10 changed files with 126 additions and 131 deletions.
7 changes: 7 additions & 0 deletions .changeset/young-queens-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@primer/react": patch
---

`CheckboxGroup` and `RadioGroup` are now SSR-compatible.

Warning: In this new implementation, `CheckboxGroup.Caption`, `CheckboxGroup.Label,` and `CheckboxGroup.Validation` must be direct children of `CheckboxGroup`. The same applies to `RadioGroup`.
4 changes: 2 additions & 2 deletions src/FormControl/FormControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import ValidationAnimationContainer from '../_ValidationAnimationContainer'
import {get} from '../constants'
import FormControlLeadingVisual from './_FormControlLeadingVisual'
import {SxProp} from '../sx'
import CheckboxOrRadioGroupContext from '../_CheckboxOrRadioGroup/_CheckboxOrRadioGroupContext'
import {CheckboxOrRadioGroupContext} from '../_CheckboxOrRadioGroup'
import InlineAutocomplete from '../drafts/InlineAutocomplete'

export type FormControlProps = {
Expand Down Expand Up @@ -58,7 +58,7 @@ const FormControl = React.forwardRef<HTMLDivElement, FormControlProps>(
InlineAutocomplete,
]
const choiceGroupContext = useContext(CheckboxOrRadioGroupContext)
const disabled = choiceGroupContext?.disabled || disabledProp
const disabled = choiceGroupContext.disabled || disabledProp
const id = useSSRSafeId(idProp)
const validationChild = React.Children.toArray(children).find(child =>
React.isValidElement(child) && child.type === FormControlValidation ? child : null,
Expand Down
142 changes: 71 additions & 71 deletions src/_CheckboxOrRadioGroup/CheckboxOrRadioGroup.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import React from 'react'
import styled from 'styled-components'
import Box from '../Box'
import {useSSRSafeId} from '../utils/ssr'
import ValidationAnimationContainer from '../_ValidationAnimationContainer'
import {get} from '../constants'
import {useSSRSafeId} from '../utils/ssr'
import CheckboxOrRadioGroupCaption from './_CheckboxOrRadioGroupCaption'
import CheckboxOrRadioGroupLabel from './_CheckboxOrRadioGroupLabel'
import CheckboxOrRadioGroupValidation from './_CheckboxOrRadioGroupValidation'
import {Slots} from './slots'
import styled from 'styled-components'
import {get} from '../constants'
import CheckboxOrRadioGroupContext from './_CheckboxOrRadioGroupContext'
import VisuallyHidden from '../_VisuallyHidden'
import {useSlots} from '../hooks/useSlots'
import {SxProp} from '../sx'

export type CheckboxOrRadioGroupProps = {
Expand Down Expand Up @@ -37,6 +36,8 @@ export type CheckboxOrRadioGroupContext = {
captionId?: string
} & CheckboxOrRadioGroupProps

export const CheckboxOrRadioGroupContext = React.createContext<CheckboxOrRadioGroupContext>({})

const Body = styled.div`
display: flex;
flex-direction: column;
Expand All @@ -57,6 +58,11 @@ const CheckboxOrRadioGroup: React.FC<React.PropsWithChildren<CheckboxOrRadioGrou
required = false,
sx,
}) => {
const [slots, rest] = useSlots(children, {
caption: CheckboxOrRadioGroupCaption,
label: CheckboxOrRadioGroupLabel,
validation: CheckboxOrRadioGroupValidation,
})
const labelChild = React.Children.toArray(children).find(
child => React.isValidElement(child) && child.type === CheckboxOrRadioGroupLabel,
)
Expand All @@ -67,8 +73,8 @@ const CheckboxOrRadioGroup: React.FC<React.PropsWithChildren<CheckboxOrRadioGrou
React.isValidElement(child) && child.type === CheckboxOrRadioGroupCaption ? child : null,
)
const id = useSSRSafeId(idProp)
const validationMessageId = validationChild && `${id}-validationMessage`
const captionId = captionChild && `${id}-caption`
const validationMessageId = validationChild ? `${id}-validationMessage` : undefined
const captionId = captionChild ? `${id}-caption` : undefined

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

const isLegendVisible = React.isValidElement(labelChild) && !labelChild.props.visuallyHidden

return (
<Slots
context={{
<CheckboxOrRadioGroupContext.Provider
value={{
disabled,
required,
captionId,
validationMessageId,
}}
>
{slots => {
const isLegendVisible = React.isValidElement(labelChild) && !labelChild.props.visuallyHidden

return (
<CheckboxOrRadioGroupContext.Provider value={{disabled}}>
<div>
<Box
border="none"
margin={0}
mb={validationChild ? 2 : undefined}
padding={0}
{...(labelChild && {
as: 'fieldset',
disabled,
})}
sx={sx}
>
{labelChild ? (
/*
Placing the caption text and validation text in the <legend> provides a better user
experience for more screenreaders.
<div>
<Box
border="none"
margin={0}
mb={validationChild ? 2 : undefined}
padding={0}
{...(labelChild && {
as: 'fieldset',
disabled,
})}
sx={sx}
>
{labelChild ? (
/*
Placing the caption text and validation text in the <legend> provides a better user
experience for more screenreaders.
Reference: https://blog.tenon.io/accessible-validation-of-checkbox-and-radiobutton-groups/
*/
<Box as="legend" mb={isLegendVisible ? 2 : undefined} padding={0}>
{slots.Label}
{slots.Caption}
{React.isValidElement(slots.Validation) && slots.Validation.props.children && (
<VisuallyHidden>{slots.Validation.props.children}</VisuallyHidden>
)}
</Box>
) : (
/*
If CheckboxOrRadioGroup.Label wasn't passed as a child, we don't render a <legend>
but we still want to render a caption
*/
slots.Caption
)}

<Body
{...(!labelChild && {
['aria-labelledby']: ariaLabelledby,
['aria-describedby']: [validationMessageId, captionId].filter(Boolean).join(' '),
as: 'div',
role: 'group',
})}
>
{React.Children.toArray(children).filter(child => React.isValidElement(child))}
</Body>
</Box>
{validationChild && (
<ValidationAnimationContainer
// If we have CheckboxOrRadioGroup.Label as a child, we render a screenreader-accessible validation message in the <legend>
aria-hidden={Boolean(labelChild)}
show
>
{slots.Validation}
</ValidationAnimationContainer>
Reference: https://blog.tenon.io/accessible-validation-of-checkbox-and-radiobutton-groups/
*/
<Box as="legend" mb={isLegendVisible ? 2 : undefined} padding={0}>
{slots.label}
{slots.caption}
{React.isValidElement(slots.validation) && slots.validation.props.children && (
<VisuallyHidden>{slots.validation.props.children}</VisuallyHidden>
)}
</div>
</CheckboxOrRadioGroupContext.Provider>
)
}}
</Slots>
</Box>
) : (
/*
If CheckboxOrRadioGroup.Label wasn't passed as a child, we don't render a <legend>
but we still want to render a caption
*/
slots.caption
)}

<Body
{...(!labelChild && {
['aria-labelledby']: ariaLabelledby,
['aria-describedby']: [validationMessageId, captionId].filter(Boolean).join(' '),
as: 'div',
role: 'group',
})}
>
{React.Children.toArray(rest).filter(child => React.isValidElement(child))}
</Body>
</Box>
{validationChild && (
<ValidationAnimationContainer
// If we have CheckboxOrRadioGroup.Label as a child, we render a screenreader-accessible validation message in the <legend>
aria-hidden={Boolean(labelChild)}
show
>
{slots.validation}
</ValidationAnimationContainer>
)}
</div>
</CheckboxOrRadioGroupContext.Provider>
)
}

Expand Down
18 changes: 8 additions & 10 deletions src/_CheckboxOrRadioGroup/_CheckboxOrRadioGroupCaption.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,14 @@ import React from 'react'
import Text from '../Text'
import {SxProp} from '../sx'
import {CheckboxOrRadioGroupContext} from './CheckboxOrRadioGroup'
import {Slot} from './slots'

const CheckboxOrRadioGroupCaption: React.FC<React.PropsWithChildren<SxProp>> = ({children, sx}) => (
<Slot name="Caption">
{({disabled, captionId}: CheckboxOrRadioGroupContext) => (
<Text color={disabled ? 'fg.muted' : 'fg.subtle'} fontSize={1} id={captionId} sx={sx}>
{children}
</Text>
)}
</Slot>
)
const CheckboxOrRadioGroupCaption: React.FC<React.PropsWithChildren<SxProp>> = ({children, sx}) => {
const {disabled, captionId} = React.useContext(CheckboxOrRadioGroupContext)
return (
<Text color={disabled ? 'fg.muted' : 'fg.subtle'} fontSize={1} id={captionId} sx={sx}>
{children}
</Text>
)
}

export default CheckboxOrRadioGroupCaption
7 changes: 0 additions & 7 deletions src/_CheckboxOrRadioGroup/_CheckboxOrRadioGroupContext.tsx

This file was deleted.

52 changes: 25 additions & 27 deletions src/_CheckboxOrRadioGroup/_CheckboxOrRadioGroupLabel.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import React from 'react'
import Box from '../Box'
import {SxProp} from '../sx'
import VisuallyHidden from '../_VisuallyHidden'
import {SxProp} from '../sx'
import {CheckboxOrRadioGroupContext} from './CheckboxOrRadioGroup'
import {Slot} from './slots'

export type CheckboxOrRadioGroupLabelProps = {
/**
Expand All @@ -16,30 +15,29 @@ const CheckboxOrRadioGroupLabel: React.FC<React.PropsWithChildren<CheckboxOrRadi
children,
visuallyHidden = false,
sx,
}) => (
<Slot name="Label">
{({required, disabled}: CheckboxOrRadioGroupContext) => (
<VisuallyHidden
isVisible={!visuallyHidden}
title={required ? 'required field' : undefined}
sx={{
display: 'block',
color: disabled ? 'fg.muted' : undefined,
fontSize: 2,
...sx,
}}
>
{required ? (
<Box display="flex" as="span">
<Box mr={1}>{children}</Box>
<span>*</span>
</Box>
) : (
children
)}
</VisuallyHidden>
)}
</Slot>
)
}) => {
const {required, disabled} = React.useContext(CheckboxOrRadioGroupContext)
return (
<VisuallyHidden
isVisible={!visuallyHidden}
title={required ? 'required field' : undefined}
sx={{
display: 'block',
color: disabled ? 'fg.muted' : undefined,
fontSize: 2,
...sx,
}}
>
{required ? (
<Box display="flex" as="span">
<Box mr={1}>{children}</Box>
<span>*</span>
</Box>
) : (
children
)}
</VisuallyHidden>
)
}

export default CheckboxOrRadioGroupLabel
18 changes: 8 additions & 10 deletions src/_CheckboxOrRadioGroup/_CheckboxOrRadioGroupValidation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import InputValidation from '../_InputValidation'
import {SxProp} from '../sx'
import {FormValidationStatus} from '../utils/types/FormValidationStatus'
import {CheckboxOrRadioGroupContext} from './CheckboxOrRadioGroup'
import {Slot} from './slots'

export type CheckboxOrRadioGroupValidationProps = {
/** Changes the visual style to match the validation status */
Expand All @@ -14,14 +13,13 @@ const CheckboxOrRadioGroupValidation: React.FC<React.PropsWithChildren<CheckboxO
children,
variant,
sx,
}) => (
<Slot name="Validation">
{({validationMessageId = ''}: CheckboxOrRadioGroupContext) => (
<InputValidation validationStatus={variant} id={validationMessageId} sx={sx}>
{children}
</InputValidation>
)}
</Slot>
)
}) => {
const {validationMessageId = ''} = React.useContext(CheckboxOrRadioGroupContext)
return (
<InputValidation validationStatus={variant} id={validationMessageId} sx={sx}>
{children}
</InputValidation>
)
}

export default CheckboxOrRadioGroupValidation
2 changes: 1 addition & 1 deletion src/_CheckboxOrRadioGroup/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export {default} from './CheckboxOrRadioGroup'
export {default, CheckboxOrRadioGroupContext} from './CheckboxOrRadioGroup'
export type {CheckboxOrRadioGroupProps} from './CheckboxOrRadioGroup'
3 changes: 2 additions & 1 deletion src/__tests__/CheckboxOrRadioGroup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import '@testing-library/jest-dom/extend-expect'
import {render, within} from '@testing-library/react'
import {Checkbox, FormControl, Radio, SSRProvider, TextInput} from '..'
import {behavesAsComponent, checkExports} from '../utils/testing'
import CheckboxOrRadioGroup from '../_CheckboxOrRadioGroup'
import CheckboxOrRadioGroup, {CheckboxOrRadioGroupContext} from '../_CheckboxOrRadioGroup'

const INPUT_GROUP_LABEL = 'Choices'

Expand Down Expand Up @@ -41,6 +41,7 @@ describe('CheckboxOrRadioGroup', () => {
})
checkExports('_CheckboxOrRadioGroup', {
default: CheckboxOrRadioGroup,
CheckboxOrRadioGroupContext,
})
it('renders a group of inputs with a caption in the <legend>', () => {
render(
Expand Down
4 changes: 2 additions & 2 deletions src/hooks/useSlots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {warning} from '../utils/warning'
export type SlotConfig = Record<string, React.ComponentType<any>>

type SlotElements<Type extends SlotConfig> = {
[Property in keyof Type]: React.ReactElement
[Property in keyof Type]: React.ReactElement<React.ComponentPropsWithoutRef<Type[Property]>, Type[Property]>
}

/**
Expand Down Expand Up @@ -52,7 +52,7 @@ export function useSlots<T extends SlotConfig>(
}

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

return [slots, rest]
Expand Down

0 comments on commit a19b721

Please sign in to comment.