Use internal label and descriptions (#313)

* improve internal Label component

We will now add a name to improve error messages, we also introduced a
`clickable` prop on the label.

Not 100% happy with the implementation of these internal Label &
Description components, but they are internal so we can always change it
to something that makes more sense!

* improve internal Description component

We will now add a name to improve error messages.

* provide the name prop to Description & Label providers

* implement the useLabels and useDescriptions in the Switch components

* update documentation
This commit is contained in:
Robin Malfait
2021-04-08 17:39:02 +02:00
committed by GitHub
parent cdfeeacf43
commit a02c818f94
12 changed files with 276 additions and 293 deletions
@@ -8,7 +8,6 @@ import React, {
// Types
ElementType,
ReactNode,
ContextType,
} from 'react'
import { Props } from '../../types'
@@ -18,23 +17,35 @@ import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
// ---
let DescriptionContext = createContext<{
register(value: string): () => void
slot: Record<string, any>
}>({
register() {
return () => {}
},
slot: {},
})
interface SharedData {
slot?: {}
name?: string
props?: {}
}
let DescriptionContext = createContext<
({ register(value: string): () => void } & SharedData) | null
>(null)
function useDescriptionContext() {
return useContext(DescriptionContext)
let context = useContext(DescriptionContext)
if (context === null) {
let err = new Error(
'You used a <Description /> component, but it is not inside a relevant parent.'
)
if (Error.captureStackTrace) Error.captureStackTrace(err, useDescriptionContext)
throw err
}
return context
}
interface DescriptionProviderProps extends SharedData {
children: ReactNode
}
export function useDescriptions(): [
string | undefined,
(props: { children: ReactNode; slot?: Record<string, any> }) => JSX.Element
(props: DescriptionProviderProps) => JSX.Element
] {
let [descriptionIds, setDescriptionIds] = useState<string[]>([])
@@ -44,10 +55,7 @@ export function useDescriptions(): [
// The provider component
useMemo(() => {
return function DescriptionProvider(props: {
children: ReactNode
slot?: Record<string, any>
}) {
return function DescriptionProvider(props: DescriptionProviderProps) {
let register = useCallback((value: string) => {
setDescriptionIds(existing => [...existing, value])
@@ -60,9 +68,9 @@ export function useDescriptions(): [
})
}, [])
let contextBag = useMemo<ContextType<typeof DescriptionContext>>(
() => ({ register, slot: props.slot ?? {} }),
[register, props.slot]
let contextBag = useMemo(
() => ({ register, slot: props.slot, name: props.name, props: props.props }),
[register, props.slot, props.name, props.props]
)
return (
@@ -84,18 +92,18 @@ type DescriptionPropsWeControl = 'id'
export function Description<TTag extends ElementType = typeof DEFAULT_DESCRIPTION_TAG>(
props: Props<TTag, DescriptionRenderPropArg, DescriptionPropsWeControl>
) {
let { register, slot } = useDescriptionContext()
let context = useDescriptionContext()
let id = `headlessui-description-${useId()}`
useIsoMorphicEffect(() => register(id), [id, register])
useIsoMorphicEffect(() => context.register(id), [id, context.register])
let passThroughProps = props
let propsWeControl = { id }
let propsWeControl = { ...context.props, id }
return render({
props: { ...passThroughProps, ...propsWeControl },
slot,
slot: context.slot || {},
defaultTag: DEFAULT_DESCRIPTION_TAG,
name: 'Description',
name: context.name || 'Description',
})
}
@@ -264,7 +264,7 @@ let DialogRoot = forwardRefWithAs(function Dialog<
<DialogContext.Provider value={contextBag}>
<Portal.Group target={internalDialogRef}>
<ForcePortalRoot force={false}>
<DescriptionProvider slot={slot}>
<DescriptionProvider slot={slot} name="Dialog.Description">
{render({
props: { ...passthroughProps, ...propsWeControl },
slot,
@@ -17,17 +17,31 @@ import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
// ---
let LabelContext = createContext<{ register(value: string): () => void }>({
register() {
return () => {}
},
})
function useLabelContext() {
return useContext(LabelContext)
interface SharedData {
slot?: {}
name?: string
props?: {}
}
export function useLabels(): [string | undefined, (props: { children: ReactNode }) => JSX.Element] {
let LabelContext = createContext<({ register(value: string): () => void } & SharedData) | null>(
null
)
function useLabelContext() {
let context = useContext(LabelContext)
if (context === null) {
let err = new Error('You used a <Label /> component, but it is not inside a relevant parent.')
if (Error.captureStackTrace) Error.captureStackTrace(err, useLabelContext)
throw err
}
return context
}
interface LabelProviderProps extends SharedData {
children: ReactNode
}
export function useLabels(): [string | undefined, (props: LabelProviderProps) => JSX.Element] {
let [labelIds, setLabelIds] = useState<string[]>([])
return [
@@ -36,7 +50,7 @@ export function useLabels(): [string | undefined, (props: { children: ReactNode
// The provider component
useMemo(() => {
return function LabelProvider(props: { children: ReactNode }) {
return function LabelProvider(props: LabelProviderProps) {
let register = useCallback((value: string) => {
setLabelIds(existing => [...existing, value])
@@ -49,7 +63,10 @@ export function useLabels(): [string | undefined, (props: { children: ReactNode
})
}, [])
let contextBag = useMemo(() => ({ register }), [register])
let contextBag = useMemo(
() => ({ register, slot: props.slot, name: props.name, props: props.props }),
[register, props.slot, props.name, props.props]
)
return <LabelContext.Provider value={contextBag}>{props.children}</LabelContext.Provider>
}
@@ -64,19 +81,27 @@ interface LabelRenderPropArg {}
type LabelPropsWeControl = 'id'
export function Label<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
props: Props<TTag, LabelRenderPropArg, LabelPropsWeControl>
props: Props<TTag, LabelRenderPropArg, LabelPropsWeControl> & {
clickable?: boolean
}
) {
let { register } = useLabelContext()
let { clickable = false, ...passThroughProps } = props
let context = useLabelContext()
let id = `headlessui-label-${useId()}`
useIsoMorphicEffect(() => register(id), [id, register])
useIsoMorphicEffect(() => context.register(id), [id, context.register])
let passThroughProps = props
let propsWeControl = { id }
let propsWeControl = { ...context.props, id }
let allProps = { ...passThroughProps, ...propsWeControl }
// @ts-expect-error props are dynamic via context, some components will
// provide an onClick then we can delete it.
if (!clickable) delete allProps['onClick']
return render({
props: { ...passThroughProps, ...propsWeControl },
props: allProps,
slot: context.slot || {},
defaultTag: DEFAULT_LABEL_TAG,
name: 'Label',
name: context.name || 'Label',
})
}
@@ -220,8 +220,8 @@ export function RadioGroup<
}
return (
<DescriptionProvider>
<LabelProvider>
<DescriptionProvider name="RadioGroup.Description">
<LabelProvider name="RadioGroup.Label">
<RadioGroupContext.Provider value={reducerBag}>
{render({
props: { ...passThroughProps, ...propsWeControl },
@@ -320,8 +320,8 @@ function Option<
)
return (
<DescriptionProvider>
<LabelProvider>
<DescriptionProvider name="RadioGroup.Description">
<LabelProvider name="RadioGroup.Label">
{render({
props: { ...passThroughProps, ...propsWeControl },
slot,
@@ -121,9 +121,10 @@ function NotificationsToggle() {
##### Props
| Prop | Type | Default | Description |
| :--- | :------------------ | :------ | :------------------------------------------------------------ |
| `as` | String \| Component | `label` | The element or component the `Switch.Label` should render as. |
| Prop | Type | Default | Description |
| :---------- | :------------------ | :------ | :---------------------------------------------------------------- |
| `as` | String \| Component | `label` | The element or component the `Switch.Label` should render as. |
| `clickable` | Boolean | `false` | Wether or not to toggle the `Switch` when you click on the label. |
#### Switch.Description
@@ -1,4 +1,4 @@
import React, { createElement, useState } from 'react'
import React, { useState } from 'react'
import { render } from '@testing-library/react'
import { Switch } from './switch'
@@ -10,23 +10,10 @@ import {
getSwitchLabel,
} from '../../test-utils/accessibility-assertions'
import { press, click, Keys } from '../../test-utils/interactions'
import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
jest.mock('../../hooks/use-id')
describe('Safe guards', () => {
it.each([
['Switch.Label', Switch.Label],
['Switch.Description', Switch.Description],
])(
'should error when we are using a <%s /> without a parent <Switch.Group />',
suppressConsoleLogs((name, Component) => {
expect(() => render(createElement(Component))).toThrowError(
`<${name} /> is missing a parent <Switch.Group /> component.`
)
})
)
it('should be possible to render a Switch without crashing', () => {
render(<Switch checked={false} onChange={console.log} />)
})
@@ -119,7 +106,7 @@ describe('Render composition', () => {
assertSwitch({ state: SwitchState.Off, label: 'Label B' })
})
it('should be possible to render a Switch.Group, Switch and Switch.Description (before the Switch)', () => {
it('should be possible to render a Switch.Group, Switch and Switch.Description (before the Switch)', async () => {
render(
<Switch.Group>
<Switch.Description>This is an important feature</Switch.Description>
@@ -276,7 +263,7 @@ describe('Mouse interactions', () => {
assertSwitch({ state: SwitchState.Off })
})
it('should be possible to toggle the Switch with a click on the Label', async () => {
it('should be possible to toggle the Switch with a click on the Label (clickable passed)', async () => {
let handleChange = jest.fn()
function Example() {
let [state, setState] = useState(false)
@@ -289,7 +276,7 @@ describe('Mouse interactions', () => {
handleChange(value)
}}
/>
<Switch.Label>The label</Switch.Label>
<Switch.Label clickable>The label</Switch.Label>
</Switch.Group>
)
}
@@ -317,4 +304,34 @@ describe('Mouse interactions', () => {
// Ensure state is off
assertSwitch({ state: SwitchState.Off })
})
it('should not be possible to toggle the Switch with a click on the Label', async () => {
let handleChange = jest.fn()
function Example() {
let [state, setState] = useState(false)
return (
<Switch.Group>
<Switch
checked={state}
onChange={value => {
setState(value)
handleChange(value)
}}
/>
<Switch.Label>The label</Switch.Label>
</Switch.Group>
)
}
render(<Example />)
// Ensure checkbox is off
assertSwitch({ state: SwitchState.Off })
// Toggle
await click(getSwitchLabel())
// Ensure state is still off
assertSwitch({ state: SwitchState.Off })
})
})
@@ -17,66 +17,50 @@ import { render } from '../../utils/render'
import { useId } from '../../hooks/use-id'
import { Keys } from '../keyboard'
import { isDisabledReactIssue7711 } from '../../utils/bugs'
import { Label, useLabels } from '../label/label'
import { Description, useDescriptions } from '../description/description'
interface StateDefinition {
switch: HTMLButtonElement | null
label: HTMLLabelElement | null
description: HTMLParagraphElement | null
setSwitch(element: HTMLButtonElement): void
setLabel(element: HTMLLabelElement): void
setDescription(element: HTMLParagraphElement): void
labelledby: string | undefined
describedby: string | undefined
}
let GroupContext = createContext<StateDefinition | null>(null)
GroupContext.displayName = 'GroupContext'
function useGroupContext(component: string) {
let context = useContext(GroupContext)
if (context === null) {
let err = new Error(`<${component} /> is missing a parent <Switch.Group /> component.`)
if (Error.captureStackTrace) Error.captureStackTrace(err, useGroupContext)
throw err
}
return context
}
// ---
let DEFAULT_GROUP_TAG = Fragment
function Group<TTag extends ElementType = typeof DEFAULT_GROUP_TAG>(props: Props<TTag>) {
let [switchElement, setSwitchElement] = useState<HTMLButtonElement | null>(null)
let [labelElement, setLabelElement] = useState<HTMLLabelElement | null>(null)
let [descriptionElement, setDescriptionElement] = useState<HTMLParagraphElement | null>(null)
let [labelledby, LabelProvider] = useLabels()
let [describedby, DescriptionProvider] = useDescriptions()
let context = useMemo<StateDefinition>(
() => ({
switch: switchElement,
setSwitch: setSwitchElement,
label: labelElement,
setLabel: setLabelElement,
description: descriptionElement,
setDescription: setDescriptionElement,
}),
[
switchElement,
setSwitchElement,
labelElement,
setLabelElement,
descriptionElement,
setDescriptionElement,
]
() => ({ switch: switchElement, setSwitch: setSwitchElement, labelledby, describedby }),
[switchElement, setSwitchElement, labelledby, describedby]
)
return (
<GroupContext.Provider value={context}>
{render({
props,
defaultTag: DEFAULT_GROUP_TAG,
name: 'Switch.Group',
})}
</GroupContext.Provider>
<DescriptionProvider name="Switch.Description">
<LabelProvider
name="Switch.Label"
props={{
onClick() {
if (!switchElement) return
switchElement.click()
switchElement.focus({ preventScroll: true })
},
}}
>
<GroupContext.Provider value={context}>
{render({ props, defaultTag: DEFAULT_GROUP_TAG, name: 'Switch.Group' })}
</GroupContext.Provider>
</LabelProvider>
</DescriptionProvider>
)
}
@@ -137,8 +121,8 @@ export function Switch<TTag extends ElementType = typeof DEFAULT_SWITCH_TAG>(
role: 'switch',
tabIndex: 0,
'aria-checked': checked,
'aria-labelledby': groupContext?.label?.id,
'aria-describedby': groupContext?.description?.id,
'aria-labelledby': groupContext?.labelledby,
'aria-describedby': groupContext?.describedby,
onClick: handleClick,
onKeyUp: handleKeyUp,
onKeyPress: handleKeyPress,
@@ -158,52 +142,6 @@ export function Switch<TTag extends ElementType = typeof DEFAULT_SWITCH_TAG>(
// ---
let DEFAULT_LABEL_TAG = 'label' as const
interface LabelRenderPropArg {}
type LabelPropsWeControl = 'id' | 'ref' | 'onClick'
function Label<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
props: Props<TTag, LabelRenderPropArg, LabelPropsWeControl>
) {
let state = useGroupContext([Switch.name, Label.name].join('.'))
let id = `headlessui-switch-label-${useId()}`
let handleClick = useCallback(() => {
if (!state.switch) return
state.switch.click()
state.switch.focus({ preventScroll: true })
}, [state.switch])
let propsWeControl = { ref: state.setLabel, id, onClick: handleClick }
return render({
props: { ...props, ...propsWeControl },
defaultTag: DEFAULT_LABEL_TAG,
name: 'Switch.Label',
})
}
// ---
let DEFAULT_DESCRIPTIONL_TAG = 'p' as const
interface DescriptionRenderPropArg {}
type DescriptionPropsWeControl = 'id' | 'ref'
function Description<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
props: Props<TTag, DescriptionRenderPropArg, DescriptionPropsWeControl>
) {
let state = useGroupContext([Switch.name, Description.name].join('.'))
let id = `headlessui-switch-description-${useId()}`
let propsWeControl = { ref: state.setDescription, id }
return render({
props: { ...props, ...propsWeControl },
defaultTag: DEFAULT_DESCRIPTIONL_TAG,
name: 'Switch.Description',
})
}
// ---
Switch.Group = Group
Switch.Label = Label
Switch.Description = Description