Skip to content
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
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import React, {
// Types
ElementType,
ReactNode,
ContextType,
} from 'react'

import { Props } from '../../types'
Expand All @@ -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[]>([])

Expand All @@ -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])

Expand All @@ -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 (
Expand All @@ -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',
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
57 changes: 41 additions & 16 deletions packages/@headlessui-react/src/components/label/label.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,31 @@ import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'

// ---

let LabelContext = createContext<{ register(value: string): () => void }>({
register() {
return () => {}
},
})
interface SharedData {
slot?: {}
name?: string
props?: {}
}

let LabelContext = createContext<({ register(value: string): () => void } & SharedData) | null>(
null
)

function useLabelContext() {
return useContext(LabelContext)
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
}

export function useLabels(): [string | undefined, (props: { children: ReactNode }) => JSX.Element] {
interface LabelProviderProps extends SharedData {
children: ReactNode
}

export function useLabels(): [string | undefined, (props: LabelProviderProps) => JSX.Element] {
let [labelIds, setLabelIds] = useState<string[]>([])

return [
Expand All @@ -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])

Expand All @@ -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>
}
Expand All @@ -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 propsWeControl = { ...context.props, id }

let passThroughProps = props
let propsWeControl = { 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',
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -320,8 +320,8 @@ function Option<
)

return (
<DescriptionProvider>
<LabelProvider>
<DescriptionProvider name="RadioGroup.Description">
<LabelProvider name="RadioGroup.Label">
{render({
props: { ...passThroughProps, ...propsWeControl },
slot,
Expand Down
7 changes: 4 additions & 3 deletions packages/@headlessui-react/src/components/switch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
51 changes: 34 additions & 17 deletions packages/@headlessui-react/src/components/switch/switch.test.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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} />)
})
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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)
Expand All @@ -289,7 +276,7 @@ describe('Mouse interactions', () => {
handleChange(value)
}}
/>
<Switch.Label>The label</Switch.Label>
<Switch.Label clickable>The label</Switch.Label>
</Switch.Group>
)
}
Expand Down Expand Up @@ -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 })
})
})
Loading