Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use SSR-compatible slot implementation in CheckboxGroup/RadioGroup #3146

Merged
merged 6 commits into from
Apr 10, 2023
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
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