Skip to content

Commit a02c818

Browse files
authored
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
1 parent cdfeeac commit a02c818

File tree

12 files changed

+274
-291
lines changed

12 files changed

+274
-291
lines changed

packages/@headlessui-react/src/components/description/description.tsx

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import React, {
88
// Types
99
ElementType,
1010
ReactNode,
11-
ContextType,
1211
} from 'react'
1312

1413
import { Props } from '../../types'
@@ -18,23 +17,35 @@ import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
1817

1918
// ---
2019

21-
let DescriptionContext = createContext<{
22-
register(value: string): () => void
23-
slot: Record<string, any>
24-
}>({
25-
register() {
26-
return () => {}
27-
},
28-
slot: {},
29-
})
20+
interface SharedData {
21+
slot?: {}
22+
name?: string
23+
props?: {}
24+
}
25+
26+
let DescriptionContext = createContext<
27+
({ register(value: string): () => void } & SharedData) | null
28+
>(null)
3029

3130
function useDescriptionContext() {
32-
return useContext(DescriptionContext)
31+
let context = useContext(DescriptionContext)
32+
if (context === null) {
33+
let err = new Error(
34+
'You used a <Description /> component, but it is not inside a relevant parent.'
35+
)
36+
if (Error.captureStackTrace) Error.captureStackTrace(err, useDescriptionContext)
37+
throw err
38+
}
39+
return context
40+
}
41+
42+
interface DescriptionProviderProps extends SharedData {
43+
children: ReactNode
3344
}
3445

3546
export function useDescriptions(): [
3647
string | undefined,
37-
(props: { children: ReactNode; slot?: Record<string, any> }) => JSX.Element
48+
(props: DescriptionProviderProps) => JSX.Element
3849
] {
3950
let [descriptionIds, setDescriptionIds] = useState<string[]>([])
4051

@@ -44,10 +55,7 @@ export function useDescriptions(): [
4455

4556
// The provider component
4657
useMemo(() => {
47-
return function DescriptionProvider(props: {
48-
children: ReactNode
49-
slot?: Record<string, any>
50-
}) {
58+
return function DescriptionProvider(props: DescriptionProviderProps) {
5159
let register = useCallback((value: string) => {
5260
setDescriptionIds(existing => [...existing, value])
5361

@@ -60,9 +68,9 @@ export function useDescriptions(): [
6068
})
6169
}, [])
6270

63-
let contextBag = useMemo<ContextType<typeof DescriptionContext>>(
64-
() => ({ register, slot: props.slot ?? {} }),
65-
[register, props.slot]
71+
let contextBag = useMemo(
72+
() => ({ register, slot: props.slot, name: props.name, props: props.props }),
73+
[register, props.slot, props.name, props.props]
6674
)
6775

6876
return (
@@ -84,18 +92,18 @@ type DescriptionPropsWeControl = 'id'
8492
export function Description<TTag extends ElementType = typeof DEFAULT_DESCRIPTION_TAG>(
8593
props: Props<TTag, DescriptionRenderPropArg, DescriptionPropsWeControl>
8694
) {
87-
let { register, slot } = useDescriptionContext()
95+
let context = useDescriptionContext()
8896
let id = `headlessui-description-${useId()}`
8997

90-
useIsoMorphicEffect(() => register(id), [id, register])
98+
useIsoMorphicEffect(() => context.register(id), [id, context.register])
9199

92100
let passThroughProps = props
93-
let propsWeControl = { id }
101+
let propsWeControl = { ...context.props, id }
94102

95103
return render({
96104
props: { ...passThroughProps, ...propsWeControl },
97-
slot,
105+
slot: context.slot || {},
98106
defaultTag: DEFAULT_DESCRIPTION_TAG,
99-
name: 'Description',
107+
name: context.name || 'Description',
100108
})
101109
}

packages/@headlessui-react/src/components/dialog/dialog.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ let DialogRoot = forwardRefWithAs(function Dialog<
264264
<DialogContext.Provider value={contextBag}>
265265
<Portal.Group target={internalDialogRef}>
266266
<ForcePortalRoot force={false}>
267-
<DescriptionProvider slot={slot}>
267+
<DescriptionProvider slot={slot} name="Dialog.Description">
268268
{render({
269269
props: { ...passthroughProps, ...propsWeControl },
270270
slot,

packages/@headlessui-react/src/components/label/label.tsx

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,31 @@ import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
1717

1818
// ---
1919

20-
let LabelContext = createContext<{ register(value: string): () => void }>({
21-
register() {
22-
return () => {}
23-
},
24-
})
20+
interface SharedData {
21+
slot?: {}
22+
name?: string
23+
props?: {}
24+
}
25+
26+
let LabelContext = createContext<({ register(value: string): () => void } & SharedData) | null>(
27+
null
28+
)
2529

2630
function useLabelContext() {
27-
return useContext(LabelContext)
31+
let context = useContext(LabelContext)
32+
if (context === null) {
33+
let err = new Error('You used a <Label /> component, but it is not inside a relevant parent.')
34+
if (Error.captureStackTrace) Error.captureStackTrace(err, useLabelContext)
35+
throw err
36+
}
37+
return context
2838
}
2939

30-
export function useLabels(): [string | undefined, (props: { children: ReactNode }) => JSX.Element] {
40+
interface LabelProviderProps extends SharedData {
41+
children: ReactNode
42+
}
43+
44+
export function useLabels(): [string | undefined, (props: LabelProviderProps) => JSX.Element] {
3145
let [labelIds, setLabelIds] = useState<string[]>([])
3246

3347
return [
@@ -36,7 +50,7 @@ export function useLabels(): [string | undefined, (props: { children: ReactNode
3650

3751
// The provider component
3852
useMemo(() => {
39-
return function LabelProvider(props: { children: ReactNode }) {
53+
return function LabelProvider(props: LabelProviderProps) {
4054
let register = useCallback((value: string) => {
4155
setLabelIds(existing => [...existing, value])
4256

@@ -49,7 +63,10 @@ export function useLabels(): [string | undefined, (props: { children: ReactNode
4963
})
5064
}, [])
5165

52-
let contextBag = useMemo(() => ({ register }), [register])
66+
let contextBag = useMemo(
67+
() => ({ register, slot: props.slot, name: props.name, props: props.props }),
68+
[register, props.slot, props.name, props.props]
69+
)
5370

5471
return <LabelContext.Provider value={contextBag}>{props.children}</LabelContext.Provider>
5572
}
@@ -64,19 +81,27 @@ interface LabelRenderPropArg {}
6481
type LabelPropsWeControl = 'id'
6582

6683
export function Label<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
67-
props: Props<TTag, LabelRenderPropArg, LabelPropsWeControl>
84+
props: Props<TTag, LabelRenderPropArg, LabelPropsWeControl> & {
85+
clickable?: boolean
86+
}
6887
) {
69-
let { register } = useLabelContext()
88+
let { clickable = false, ...passThroughProps } = props
89+
let context = useLabelContext()
7090
let id = `headlessui-label-${useId()}`
7191

72-
useIsoMorphicEffect(() => register(id), [id, register])
92+
useIsoMorphicEffect(() => context.register(id), [id, context.register])
93+
94+
let propsWeControl = { ...context.props, id }
7395

74-
let passThroughProps = props
75-
let propsWeControl = { id }
96+
let allProps = { ...passThroughProps, ...propsWeControl }
97+
// @ts-expect-error props are dynamic via context, some components will
98+
// provide an onClick then we can delete it.
99+
if (!clickable) delete allProps['onClick']
76100

77101
return render({
78-
props: { ...passThroughProps, ...propsWeControl },
102+
props: allProps,
103+
slot: context.slot || {},
79104
defaultTag: DEFAULT_LABEL_TAG,
80-
name: 'Label',
105+
name: context.name || 'Label',
81106
})
82107
}

packages/@headlessui-react/src/components/radio-group/radio-group.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -220,8 +220,8 @@ export function RadioGroup<
220220
}
221221

222222
return (
223-
<DescriptionProvider>
224-
<LabelProvider>
223+
<DescriptionProvider name="RadioGroup.Description">
224+
<LabelProvider name="RadioGroup.Label">
225225
<RadioGroupContext.Provider value={reducerBag}>
226226
{render({
227227
props: { ...passThroughProps, ...propsWeControl },
@@ -320,8 +320,8 @@ function Option<
320320
)
321321

322322
return (
323-
<DescriptionProvider>
324-
<LabelProvider>
323+
<DescriptionProvider name="RadioGroup.Description">
324+
<LabelProvider name="RadioGroup.Label">
325325
{render({
326326
props: { ...passThroughProps, ...propsWeControl },
327327
slot,

packages/@headlessui-react/src/components/switch/README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,9 +121,10 @@ function NotificationsToggle() {
121121

122122
##### Props
123123

124-
| Prop | Type | Default | Description |
125-
| :--- | :------------------ | :------ | :------------------------------------------------------------ |
126-
| `as` | String \| Component | `label` | The element or component the `Switch.Label` should render as. |
124+
| Prop | Type | Default | Description |
125+
| :---------- | :------------------ | :------ | :---------------------------------------------------------------- |
126+
| `as` | String \| Component | `label` | The element or component the `Switch.Label` should render as. |
127+
| `clickable` | Boolean | `false` | Wether or not to toggle the `Switch` when you click on the label. |
127128

128129
#### Switch.Description
129130

packages/@headlessui-react/src/components/switch/switch.test.tsx

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { createElement, useState } from 'react'
1+
import React, { useState } from 'react'
22
import { render } from '@testing-library/react'
33

44
import { Switch } from './switch'
@@ -10,23 +10,10 @@ import {
1010
getSwitchLabel,
1111
} from '../../test-utils/accessibility-assertions'
1212
import { press, click, Keys } from '../../test-utils/interactions'
13-
import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
1413

1514
jest.mock('../../hooks/use-id')
1615

1716
describe('Safe guards', () => {
18-
it.each([
19-
['Switch.Label', Switch.Label],
20-
['Switch.Description', Switch.Description],
21-
])(
22-
'should error when we are using a <%s /> without a parent <Switch.Group />',
23-
suppressConsoleLogs((name, Component) => {
24-
expect(() => render(createElement(Component))).toThrowError(
25-
`<${name} /> is missing a parent <Switch.Group /> component.`
26-
)
27-
})
28-
)
29-
3017
it('should be possible to render a Switch without crashing', () => {
3118
render(<Switch checked={false} onChange={console.log} />)
3219
})
@@ -119,7 +106,7 @@ describe('Render composition', () => {
119106
assertSwitch({ state: SwitchState.Off, label: 'Label B' })
120107
})
121108

122-
it('should be possible to render a Switch.Group, Switch and Switch.Description (before the Switch)', () => {
109+
it('should be possible to render a Switch.Group, Switch and Switch.Description (before the Switch)', async () => {
123110
render(
124111
<Switch.Group>
125112
<Switch.Description>This is an important feature</Switch.Description>
@@ -276,7 +263,7 @@ describe('Mouse interactions', () => {
276263
assertSwitch({ state: SwitchState.Off })
277264
})
278265

279-
it('should be possible to toggle the Switch with a click on the Label', async () => {
266+
it('should be possible to toggle the Switch with a click on the Label (clickable passed)', async () => {
280267
let handleChange = jest.fn()
281268
function Example() {
282269
let [state, setState] = useState(false)
@@ -289,7 +276,7 @@ describe('Mouse interactions', () => {
289276
handleChange(value)
290277
}}
291278
/>
292-
<Switch.Label>The label</Switch.Label>
279+
<Switch.Label clickable>The label</Switch.Label>
293280
</Switch.Group>
294281
)
295282
}
@@ -317,4 +304,34 @@ describe('Mouse interactions', () => {
317304
// Ensure state is off
318305
assertSwitch({ state: SwitchState.Off })
319306
})
307+
308+
it('should not be possible to toggle the Switch with a click on the Label', async () => {
309+
let handleChange = jest.fn()
310+
function Example() {
311+
let [state, setState] = useState(false)
312+
return (
313+
<Switch.Group>
314+
<Switch
315+
checked={state}
316+
onChange={value => {
317+
setState(value)
318+
handleChange(value)
319+
}}
320+
/>
321+
<Switch.Label>The label</Switch.Label>
322+
</Switch.Group>
323+
)
324+
}
325+
326+
render(<Example />)
327+
328+
// Ensure checkbox is off
329+
assertSwitch({ state: SwitchState.Off })
330+
331+
// Toggle
332+
await click(getSwitchLabel())
333+
334+
// Ensure state is still off
335+
assertSwitch({ state: SwitchState.Off })
336+
})
320337
})

0 commit comments

Comments
 (0)