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:
@@ -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
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
// Types
|
||||
ComputedRef,
|
||||
InjectionKey,
|
||||
Ref,
|
||||
} from 'vue'
|
||||
|
||||
import { useId } from '../../hooks/use-id'
|
||||
@@ -19,16 +20,17 @@ import { render } from '../../utils/render'
|
||||
|
||||
let DescriptionContext = Symbol('DescriptionContext') as InjectionKey<{
|
||||
register(value: string): () => void
|
||||
slot: Record<string, any>
|
||||
slot: Ref<Record<string, any>>
|
||||
name: Ref<string>
|
||||
props: Ref<Record<string, any>>
|
||||
}>
|
||||
|
||||
function useDescriptionContext() {
|
||||
return inject(DescriptionContext, {
|
||||
register() {
|
||||
return () => {}
|
||||
},
|
||||
slot: {},
|
||||
})
|
||||
let context = inject(DescriptionContext, null)
|
||||
if (context === null) {
|
||||
throw new Error('Missing parent')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function useDescriptions(): [
|
||||
@@ -44,7 +46,7 @@ export function useDescriptions(): [
|
||||
// The provider component
|
||||
defineComponent({
|
||||
name: 'DescriptionProvider',
|
||||
props: ['slot'],
|
||||
props: ['slot', 'name', 'props'],
|
||||
setup(props, { slots }) {
|
||||
function register(value: string) {
|
||||
descriptionIds.value.push(value)
|
||||
@@ -56,9 +58,12 @@ export function useDescriptions(): [
|
||||
}
|
||||
}
|
||||
|
||||
let slot = computed(() => props.slot)
|
||||
|
||||
provide(DescriptionContext, { register, slot })
|
||||
provide(DescriptionContext, {
|
||||
register,
|
||||
slot: computed(() => props.slot),
|
||||
name: computed(() => props.name),
|
||||
props: computed(() => props.props),
|
||||
})
|
||||
|
||||
return () => slots.default!()
|
||||
},
|
||||
@@ -75,22 +80,22 @@ export let Description = defineComponent({
|
||||
},
|
||||
render() {
|
||||
let passThroughProps = this.$props
|
||||
let propsWeControl = { id: this.id }
|
||||
let propsWeControl = { ...this.props, id: this.id }
|
||||
|
||||
return render({
|
||||
props: { ...passThroughProps, ...propsWeControl },
|
||||
slot: this.slot,
|
||||
props: { ...this.props, ...passThroughProps, ...propsWeControl },
|
||||
slot: this.slot || {},
|
||||
attrs: this.$attrs,
|
||||
slots: this.$slots,
|
||||
name: 'Description',
|
||||
name: this.name || 'Description',
|
||||
})
|
||||
},
|
||||
setup() {
|
||||
let { register, slot } = useDescriptionContext()
|
||||
let { register, slot, name, props } = useDescriptionContext()
|
||||
let id = `headlessui-description-${useId()}`
|
||||
|
||||
onMounted(() => onUnmounted(register(id)))
|
||||
|
||||
return { id, slot }
|
||||
return { id, slot, name, props }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
// Types
|
||||
ComputedRef,
|
||||
InjectionKey,
|
||||
Ref,
|
||||
} from 'vue'
|
||||
|
||||
import { useId } from '../../hooks/use-id'
|
||||
@@ -19,14 +20,19 @@ import { render } from '../../utils/render'
|
||||
|
||||
let LabelContext = Symbol('LabelContext') as InjectionKey<{
|
||||
register(value: string): () => void
|
||||
slot: Ref<Record<string, unknown>>
|
||||
name: Ref<string>
|
||||
props: Ref<Record<string, unknown>>
|
||||
}>
|
||||
|
||||
function useLabelContext() {
|
||||
return inject(LabelContext, {
|
||||
register() {
|
||||
return () => {}
|
||||
},
|
||||
})
|
||||
let context = inject(LabelContext, null)
|
||||
if (context === null) {
|
||||
let err = new Error('You used a <Label /> component, but it is not inside a parent.')
|
||||
if (Error.captureStackTrace) Error.captureStackTrace(err, useLabelContext)
|
||||
throw err
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function useLabels(): [ComputedRef<string | undefined>, ReturnType<typeof defineComponent>] {
|
||||
@@ -37,9 +43,15 @@ export function useLabels(): [ComputedRef<string | undefined>, ReturnType<typeof
|
||||
computed(() => (labelIds.value.length > 0 ? labelIds.value.join(' ') : undefined)),
|
||||
|
||||
// The provider component
|
||||
// @ts-expect-error The DefineComponent of Vue is just too confusing
|
||||
defineComponent({
|
||||
name: 'LabelProvider',
|
||||
setup(_props, { slots }) {
|
||||
props: {
|
||||
slot: { type: Object, default: undefined },
|
||||
name: { type: String, default: undefined },
|
||||
props: { type: Object, default: undefined },
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
function register(value: string) {
|
||||
labelIds.value.push(value)
|
||||
|
||||
@@ -50,7 +62,12 @@ export function useLabels(): [ComputedRef<string | undefined>, ReturnType<typeof
|
||||
}
|
||||
}
|
||||
|
||||
provide(LabelContext, { register })
|
||||
provide(LabelContext, {
|
||||
register,
|
||||
slot: computed(() => props.slot),
|
||||
name: computed(() => props.name),
|
||||
props: computed(() => props.props),
|
||||
})
|
||||
|
||||
return () => slots.default!()
|
||||
},
|
||||
@@ -64,25 +81,31 @@ export let Label = defineComponent({
|
||||
name: 'Label',
|
||||
props: {
|
||||
as: { type: [Object, String], default: 'label' },
|
||||
clickable: { type: [Boolean], default: false },
|
||||
},
|
||||
render() {
|
||||
let passThroughProps = this.$props
|
||||
let propsWeControl = { id: this.id }
|
||||
let { clickable, ...passThroughProps } = this.$props
|
||||
let propsWeControl = { ...this.props, id: this.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 },
|
||||
slot: {},
|
||||
props: allProps,
|
||||
slot: this.slot || {},
|
||||
attrs: this.$attrs,
|
||||
slots: this.$slots,
|
||||
name: 'Label',
|
||||
name: this.name || 'Label',
|
||||
})
|
||||
},
|
||||
setup() {
|
||||
let { register } = useLabelContext()
|
||||
let { register, slot, name, props } = useLabelContext()
|
||||
let id = `headlessui-label-${useId()}`
|
||||
|
||||
onMounted(() => onUnmounted(register(id)))
|
||||
|
||||
return { id }
|
||||
return { id, slot, name, props }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -147,9 +147,10 @@ export default {
|
||||
|
||||
##### Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| :--- | :------------------ | :------ | :----------------------------------------------------------- |
|
||||
| `as` | String \| Component | `label` | The element or component the `SwitchLabel` should render as. |
|
||||
| Prop | Type | Default | Description |
|
||||
| :---------- | :------------------ | :------ | :---------------------------------------------------------------- |
|
||||
| `as` | String \| Component | `label` | The element or component the `SwitchLabel` should render as. |
|
||||
| `clickable` | Boolean | `false` | Wether or not to toggle the `Switch` when you click on the label. |
|
||||
|
||||
#### SwitchDescription
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
getSwitchLabel,
|
||||
} from '../../test-utils/accessibility-assertions'
|
||||
import { press, click, Keys } from '../../test-utils/interactions'
|
||||
import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
|
||||
import { html } from '../../test-utils/html'
|
||||
|
||||
jest.mock('../../hooks/use-id')
|
||||
@@ -32,18 +31,6 @@ function renderTemplate(input: string | Partial<Parameters<typeof defineComponen
|
||||
}
|
||||
|
||||
describe('Safe guards', () => {
|
||||
it.each([
|
||||
['SwitchLabel', SwitchLabel],
|
||||
['SwitchDescription', SwitchDescription],
|
||||
])(
|
||||
'should error when we are using a <%s /> without a parent <SwitchGroup />',
|
||||
suppressConsoleLogs((name, Component) => {
|
||||
expect(() => render(Component)).toThrowError(
|
||||
`<${name} /> is missing a parent <SwitchGroup /> component.`
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
it('should be possible to render a Switch without crashing', () => {
|
||||
renderTemplate({
|
||||
template: html`
|
||||
@@ -352,13 +339,13 @@ 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()
|
||||
renderTemplate({
|
||||
template: html`
|
||||
<SwitchGroup>
|
||||
<Switch v-model="checked" />
|
||||
<SwitchLabel>The label</SwitchLabel>
|
||||
<SwitchLabel clickable>The label</SwitchLabel>
|
||||
</SwitchGroup>
|
||||
`,
|
||||
setup() {
|
||||
@@ -389,4 +376,30 @@ 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()
|
||||
renderTemplate({
|
||||
template: html`
|
||||
<SwitchGroup>
|
||||
<Switch v-model="checked" />
|
||||
<SwitchLabel>The label</SwitchLabel>
|
||||
</SwitchGroup>
|
||||
`,
|
||||
setup() {
|
||||
let checked = ref(false)
|
||||
watch([checked], () => handleChange(checked.value))
|
||||
return { checked }
|
||||
},
|
||||
})
|
||||
|
||||
// Ensure checkbox is off
|
||||
assertSwitch({ state: SwitchState.Off })
|
||||
|
||||
// Toggle
|
||||
await click(getSwitchLabel())
|
||||
|
||||
// Ensure state is still Off
|
||||
assertSwitch({ state: SwitchState.Off })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,32 +1,31 @@
|
||||
import { computed, defineComponent, inject, InjectionKey, provide, ref, Ref } from 'vue'
|
||||
import {
|
||||
defineComponent,
|
||||
h,
|
||||
inject,
|
||||
provide,
|
||||
ref,
|
||||
|
||||
// Types
|
||||
InjectionKey,
|
||||
Ref,
|
||||
} from 'vue'
|
||||
|
||||
import { render } from '../../utils/render'
|
||||
import { useId } from '../../hooks/use-id'
|
||||
import { Keys } from '../../keyboard'
|
||||
import { resolvePropValue } from '../../utils/resolve-prop-value'
|
||||
import { dom } from '../../utils/dom'
|
||||
import { Label, useLabels } from '../label/label'
|
||||
import { Description, useDescriptions } from '../description/description'
|
||||
|
||||
type StateDefinition = {
|
||||
// State
|
||||
switchRef: Ref<HTMLButtonElement | null>
|
||||
labelRef: Ref<HTMLLabelElement | null>
|
||||
descriptionRef: Ref<HTMLParagraphElement | null>
|
||||
labelledby: Ref<string | undefined>
|
||||
describedby: Ref<string | undefined>
|
||||
}
|
||||
|
||||
let GroupContext = Symbol('GroupContext') as InjectionKey<StateDefinition>
|
||||
|
||||
function useGroupContext(component: string) {
|
||||
let context = inject(GroupContext, null)
|
||||
|
||||
if (context === null) {
|
||||
let err = new Error(`<${component} /> is missing a parent <SwitchGroup /> component.`)
|
||||
if (Error.captureStackTrace) Error.captureStackTrace(err, useGroupContext)
|
||||
throw err
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
export let SwitchGroup = defineComponent({
|
||||
@@ -36,14 +35,30 @@ export let SwitchGroup = defineComponent({
|
||||
},
|
||||
setup(props, { slots, attrs }) {
|
||||
let switchRef = ref<StateDefinition['switchRef']['value']>(null)
|
||||
let labelRef = ref<StateDefinition['labelRef']['value']>(null)
|
||||
let descriptionRef = ref<StateDefinition['descriptionRef']['value']>(null)
|
||||
let [labelledby, LabelProvider] = useLabels()
|
||||
let [describedby, DescriptionProvider] = useDescriptions()
|
||||
|
||||
let api = { switchRef, labelRef, descriptionRef }
|
||||
let api = { switchRef, labelledby, describedby }
|
||||
|
||||
provide(GroupContext, api)
|
||||
|
||||
return () => render({ props, slot: {}, slots, attrs, name: 'SwitchGroup' })
|
||||
return () =>
|
||||
h(DescriptionProvider, { name: 'SwitchDescription' }, () => [
|
||||
h(
|
||||
LabelProvider,
|
||||
{
|
||||
name: 'SwitchLabel',
|
||||
props: {
|
||||
onClick() {
|
||||
if (!switchRef.value) return
|
||||
switchRef.value.click()
|
||||
switchRef.value.focus({ preventScroll: true })
|
||||
},
|
||||
},
|
||||
},
|
||||
() => [render({ props, slot: {}, slots, attrs, name: 'SwitchGroup' })]
|
||||
),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
@@ -62,9 +77,6 @@ export let Switch = defineComponent({
|
||||
let api = inject(GroupContext, null)
|
||||
let { class: defaultClass, className = defaultClass } = this.$props
|
||||
|
||||
let labelledby = computed(() => dom(api?.labelRef)?.id)
|
||||
let describedby = computed(() => dom(api?.descriptionRef)?.id)
|
||||
|
||||
let slot = { checked: this.$props.modelValue }
|
||||
let propsWeControl = {
|
||||
id: this.id,
|
||||
@@ -73,8 +85,8 @@ export let Switch = defineComponent({
|
||||
tabIndex: 0,
|
||||
class: resolvePropValue(className, slot),
|
||||
'aria-checked': this.$props.modelValue,
|
||||
'aria-labelledby': labelledby.value,
|
||||
'aria-describedby': describedby.value,
|
||||
'aria-labelledby': this.labelledby,
|
||||
'aria-describedby': this.describedby,
|
||||
onClick: this.handleClick,
|
||||
onKeyup: this.handleKeyUp,
|
||||
onKeypress: this.handleKeyPress,
|
||||
@@ -103,6 +115,8 @@ export let Switch = defineComponent({
|
||||
return {
|
||||
id,
|
||||
el: api?.switchRef,
|
||||
labelledby: api?.labelledby,
|
||||
describedby: api?.describedby,
|
||||
handleClick(event: MouseEvent) {
|
||||
event.preventDefault()
|
||||
toggle()
|
||||
@@ -121,67 +135,5 @@ export let Switch = defineComponent({
|
||||
|
||||
// ---
|
||||
|
||||
export let SwitchLabel = defineComponent({
|
||||
name: 'SwitchLabel',
|
||||
props: { as: { type: [Object, String], default: 'label' } },
|
||||
render() {
|
||||
let propsWeControl = {
|
||||
id: this.id,
|
||||
ref: 'el',
|
||||
onClick: this.handleClick,
|
||||
}
|
||||
|
||||
return render({
|
||||
props: { ...this.$props, ...propsWeControl },
|
||||
slot: {},
|
||||
attrs: this.$attrs,
|
||||
slots: this.$slots,
|
||||
name: 'SwitchLabel',
|
||||
})
|
||||
},
|
||||
setup() {
|
||||
let api = useGroupContext('SwitchLabel')
|
||||
let id = `headlessui-switch-label-${useId()}`
|
||||
|
||||
return {
|
||||
id,
|
||||
el: api.labelRef,
|
||||
handleClick() {
|
||||
let el = dom(api.switchRef)
|
||||
|
||||
el?.click()
|
||||
el?.focus({ preventScroll: true })
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// ---
|
||||
|
||||
export let SwitchDescription = defineComponent({
|
||||
name: 'SwitchDescription',
|
||||
props: { as: { type: [Object, String], default: 'p' } },
|
||||
render() {
|
||||
let propsWeControl = {
|
||||
id: this.id,
|
||||
ref: 'el',
|
||||
}
|
||||
|
||||
return render({
|
||||
props: { ...this.$props, ...propsWeControl },
|
||||
slot: {},
|
||||
attrs: this.$attrs,
|
||||
slots: this.$slots,
|
||||
name: 'SwitchDescription',
|
||||
})
|
||||
},
|
||||
setup() {
|
||||
let api = useGroupContext('SwitchDescription')
|
||||
let id = `headlessui-switch-description-${useId()}`
|
||||
|
||||
return {
|
||||
id,
|
||||
el: api.descriptionRef,
|
||||
}
|
||||
},
|
||||
})
|
||||
export let SwitchLabel = Label
|
||||
export let SwitchDescription = Description
|
||||
|
||||
Reference in New Issue
Block a user