skip the Provider component and simplify context (#315)

I was on a walk, and I realised that in Vue you can just call
provide(Symbol, context), which means that a hook like `useLabels` can
just provide context...

This simplifies a lot!
This commit is contained in:
Robin Malfait
2021-04-09 10:03:14 +02:00
committed by GitHub
parent 2aa95f28c6
commit acbc4d7d7e
7 changed files with 248 additions and 224 deletions
@@ -1,10 +1,12 @@
import { defineComponent, h, nextTick } from 'vue'
import { defineComponent, h, nextTick, ref } from 'vue'
import prettier from 'prettier'
import { render } from '../../test-utils/vue-testing-library'
import { Description, useDescriptions } from './description'
import { html } from '../../test-utils/html'
import { click } from '../../test-utils/interactions'
import { getByText } from '../../test-utils/accessibility-assertions'
function format(input: Element | string) {
let contents = (typeof input === 'string' ? input : (input as HTMLElement).outerHTML).trim()
@@ -36,18 +38,14 @@ function renderTemplate(input: string | Partial<Parameters<typeof defineComponen
)
}
it('should be possible to use a DescriptionProvider without using a Description', async () => {
it('should be possible to use useDescriptions without using a Description', async () => {
let { container } = renderTemplate({
render() {
return h('div', [
h(this.DescriptionProvider, () => [
h('div', { 'aria-describedby': this.describedby }, ['No description']),
]),
])
return h('div', [h('div', { 'aria-describedby': this.describedby }, ['No description'])])
},
setup() {
let [describedby, DescriptionProvider] = useDescriptions()
return { describedby, DescriptionProvider }
let describedby = useDescriptions()
return { describedby }
},
})
@@ -60,21 +58,19 @@ it('should be possible to use a DescriptionProvider without using a Description'
)
})
it('should be possible to use a DescriptionProvider and a single Description, and have them linked', async () => {
it('should be possible to use useDescriptions and a single Description, and have them linked', async () => {
let { container } = renderTemplate({
render() {
return h('div', [
h(this.DescriptionProvider, () => [
h('div', { 'aria-describedby': this.describedby }, [
h(Description, () => 'I am a description'),
h('span', 'Contents'),
]),
h('div', { 'aria-describedby': this.describedby }, [
h(Description, () => 'I am a description'),
h('span', 'Contents'),
]),
])
},
setup() {
let [describedby, DescriptionProvider] = useDescriptions()
return { describedby, DescriptionProvider }
let describedby = useDescriptions()
return { describedby }
},
})
@@ -94,22 +90,20 @@ it('should be possible to use a DescriptionProvider and a single Description, an
)
})
it('should be possible to use a DescriptionProvider and multiple Description components, and have them linked', async () => {
it('should be possible to use useDescriptions and multiple Description components, and have them linked', async () => {
let { container } = renderTemplate({
render() {
return h('div', [
h(this.DescriptionProvider, () => [
h('div', { 'aria-describedby': this.describedby }, [
h(Description, () => 'I am a description'),
h('span', 'Contents'),
h(Description, () => 'I am also a description'),
]),
h('div', { 'aria-describedby': this.describedby }, [
h(Description, () => 'I am a description'),
h('span', 'Contents'),
h(Description, () => 'I am also a description'),
]),
])
},
setup() {
let [describedby, DescriptionProvider] = useDescriptions()
return { describedby, DescriptionProvider }
let describedby = useDescriptions()
return { describedby }
},
})
@@ -131,3 +125,47 @@ it('should be possible to use a DescriptionProvider and multiple Description com
`)
)
})
it('should be possible to update a prop from the parent and it should reflect in the Description component', async () => {
let { container } = renderTemplate({
render() {
return h('div', [
h('div', { 'aria-describedby': this.describedby }, [
h(Description, () => 'I am a description'),
h('button', { onClick: () => this.count++ }, '+1'),
]),
])
},
setup() {
let count = ref(0)
let describedby = useDescriptions({ props: { 'data-count': count } })
return { count, describedby }
},
})
await new Promise<void>(nextTick)
expect(format(container.firstElementChild)).toEqual(
format(html`
<div aria-describedby="headlessui-description-1">
<p data-count="0" id="headlessui-description-1">
I am a description
</p>
<button>+1</button>
</div>
`)
)
await click(getByText('+1'))
expect(format(container.firstElementChild)).toEqual(
format(html`
<div aria-describedby="headlessui-description-1">
<p data-count="1" id="headlessui-description-1">
I am a description
</p>
<button>+1</button>
</div>
`)
)
})
@@ -6,11 +6,11 @@ import {
onUnmounted,
provide,
ref,
unref,
// Types
ComputedRef,
InjectionKey,
Ref,
} from 'vue'
import { useId } from '../../hooks/use-id'
@@ -20,9 +20,9 @@ import { render } from '../../utils/render'
let DescriptionContext = Symbol('DescriptionContext') as InjectionKey<{
register(value: string): () => void
slot: Ref<Record<string, any>>
name: Ref<string>
props: Ref<Record<string, any>>
slot: Record<string, any>
name: string
props: Record<string, any>
}>
function useDescriptionContext() {
@@ -33,42 +33,33 @@ function useDescriptionContext() {
return context
}
export function useDescriptions(): [
ComputedRef<string | undefined>,
ReturnType<typeof defineComponent>
] {
export function useDescriptions({
slot = {},
name = 'Description',
props = {},
}: {
slot?: Record<string, unknown>
name?: string
props?: Record<string, unknown>
} = {}): ComputedRef<string | undefined> {
let descriptionIds = ref<string[]>([])
return [
// The actual id's as string or undefined.
computed(() => (descriptionIds.value.length > 0 ? descriptionIds.value.join(' ') : undefined)),
function register(value: string) {
descriptionIds.value.push(value)
// The provider component
defineComponent({
name: 'DescriptionProvider',
props: ['slot', 'name', 'props'],
setup(props, { slots }) {
function register(value: string) {
descriptionIds.value.push(value)
return () => {
let idx = descriptionIds.value.indexOf(value)
if (idx === -1) return
descriptionIds.value.splice(idx, 1)
}
}
return () => {
let idx = descriptionIds.value.indexOf(value)
if (idx === -1) return
descriptionIds.value.splice(idx, 1)
}
}
provide(DescriptionContext, { register, slot, name, props })
provide(DescriptionContext, {
register,
slot: computed(() => props.slot),
name: computed(() => props.name),
props: computed(() => props.props),
})
return () => slots.default!()
},
}),
]
// The actual id's as string or undefined.
return computed(() =>
descriptionIds.value.length > 0 ? descriptionIds.value.join(' ') : undefined
)
}
// ---
@@ -79,23 +70,30 @@ export let Description = defineComponent({
as: { type: [Object, String], default: 'p' },
},
render() {
let { name = 'Description', slot = {}, props = {} } = this.context
let passThroughProps = this.$props
let propsWeControl = { ...this.props, id: this.id }
let propsWeControl = {
...Object.entries(props).reduce(
(acc, [key, value]) => Object.assign(acc, { [key]: unref(value) }),
{}
),
id: this.id,
}
return render({
props: { ...this.props, ...passThroughProps, ...propsWeControl },
slot: this.slot || {},
props: { ...passThroughProps, ...propsWeControl },
slot,
attrs: this.$attrs,
slots: this.$slots,
name: this.name || 'Description',
name,
})
},
setup() {
let { register, slot, name, props } = useDescriptionContext()
let context = useDescriptionContext()
let id = `headlessui-description-${useId()}`
onMounted(() => onUnmounted(register(id)))
onMounted(() => onUnmounted(context.register(id)))
return { id, slot, name, props }
return { id, context }
},
})
@@ -109,17 +109,15 @@ export let Dialog = defineComponent({
h(Portal, {}, () => [
h(PortalGroup, { target: this.dialogRef }, () => [
h(ForcePortalRoot, { force: false }, () => [
h(this.DescriptionProvider, { slot }, () => [
render({
props: { ...passThroughProps, ...propsWeControl },
slot,
attrs: this.$attrs,
slots: this.$slots,
visible: open,
features: Features.RenderStrategy | Features.Static,
name: 'Dialog',
}),
]),
render({
props: { ...passThroughProps, ...propsWeControl },
slot,
attrs: this.$attrs,
slots: this.$slots,
visible: open,
features: Features.RenderStrategy | Features.Static,
name: 'Dialog',
}),
]),
]),
]),
@@ -182,7 +180,10 @@ export let Dialog = defineComponent({
useFocusTrap(containers, enabled, focusTrapOptions)
useInertOthers(internalDialogRef, enabled)
let [describedby, DescriptionProvider] = useDescriptions()
let describedby = useDescriptions({
name: 'DialogDescription',
slot: { open: props.open },
})
let titleId = ref<StateDefinition['titleId']['value']>(null)
@@ -271,7 +272,6 @@ export let Dialog = defineComponent({
dialogState,
titleId,
describedby,
DescriptionProvider,
}
},
})
@@ -1,10 +1,12 @@
import { defineComponent, h, nextTick } from 'vue'
import { defineComponent, h, nextTick, ref } from 'vue'
import prettier from 'prettier'
import { render } from '../../test-utils/vue-testing-library'
import { Label, useLabels } from './label'
import { html } from '../../test-utils/html'
import { click } from '../../test-utils/interactions'
import { getByText } from '../../test-utils/accessibility-assertions'
function format(input: Element | string) {
let contents = (typeof input === 'string' ? input : (input as HTMLElement).outerHTML).trim()
@@ -36,18 +38,14 @@ function renderTemplate(input: string | Partial<Parameters<typeof defineComponen
)
}
it('should be possible to use a LabelProvider without using a Label', async () => {
it('should be possible to use useLabels without using a Label', async () => {
let { container } = renderTemplate({
render() {
return h('div', [
h(this.LabelProvider, () => [
h('div', { 'aria-labelledby': this.labelledby }, ['No label']),
]),
])
return h('div', [h('div', { 'aria-labelledby': this.labelledby }, ['No label'])])
},
setup() {
let [labelledby, LabelProvider] = useLabels()
return { labelledby, LabelProvider }
let labelledby = useLabels()
return { labelledby }
},
})
@@ -60,21 +58,19 @@ it('should be possible to use a LabelProvider without using a Label', async () =
)
})
it('should be possible to use a LabelProvider and a single Label, and have them linked', async () => {
it('should be possible to use useLabels and a single Label, and have them linked', async () => {
let { container } = renderTemplate({
render() {
return h('div', [
h(this.LabelProvider, () => [
h('div', { 'aria-labelledby': this.labelledby }, [
h(Label, () => 'I am a label'),
h('span', 'Contents'),
]),
h('div', { 'aria-labelledby': this.labelledby }, [
h(Label, () => 'I am a label'),
h('span', 'Contents'),
]),
])
},
setup() {
let [labelledby, LabelProvider] = useLabels()
return { labelledby, LabelProvider }
let labelledby = useLabels()
return { labelledby }
},
})
@@ -94,22 +90,20 @@ it('should be possible to use a LabelProvider and a single Label, and have them
)
})
it('should be possible to use a LabelProvider and multiple Label components, and have them linked', async () => {
it('should be possible to use useLabels and multiple Label components, and have them linked', async () => {
let { container } = renderTemplate({
render() {
return h('div', [
h(this.LabelProvider, () => [
h('div', { 'aria-labelledby': this.labelledby }, [
h(Label, () => 'I am a label'),
h('span', 'Contents'),
h(Label, () => 'I am also a label'),
]),
h('div', { 'aria-labelledby': this.labelledby }, [
h(Label, () => 'I am a label'),
h('span', 'Contents'),
h(Label, () => 'I am also a label'),
]),
])
},
setup() {
let [labelledby, LabelProvider] = useLabels()
return { labelledby, LabelProvider }
let labelledby = useLabels()
return { labelledby }
},
})
@@ -131,3 +125,47 @@ it('should be possible to use a LabelProvider and multiple Label components, and
`)
)
})
it('should be possible to update a prop from the parent and it should reflect in the Label component', async () => {
let { container } = renderTemplate({
render() {
return h('div', [
h('div', { 'aria-labelledby': this.labelledby }, [
h(Label, () => 'I am a label'),
h('button', { onClick: () => this.count++ }, '+1'),
]),
])
},
setup() {
let count = ref(0)
let labelledby = useLabels({ props: { 'data-count': count } })
return { count, labelledby }
},
})
await new Promise<void>(nextTick)
expect(format(container.firstElementChild)).toEqual(
format(html`
<div aria-labelledby="headlessui-label-1">
<label data-count="0" id="headlessui-label-1">
I am a label
</label>
<button>+1</button>
</div>
`)
)
await click(getByText('+1'))
expect(format(container.firstElementChild)).toEqual(
format(html`
<div aria-labelledby="headlessui-label-1">
<label data-count="1" id="headlessui-label-1">
I am a label
</label>
<button>+1</button>
</div>
`)
)
})
@@ -6,11 +6,11 @@ import {
onUnmounted,
provide,
ref,
unref,
// Types
ComputedRef,
InjectionKey,
Ref,
} from 'vue'
import { useId } from '../../hooks/use-id'
@@ -20,9 +20,9 @@ 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>>
slot: Record<string, unknown>
name: string
props: Record<string, unknown>
}>
function useLabelContext() {
@@ -35,44 +35,30 @@ function useLabelContext() {
return context
}
export function useLabels(): [ComputedRef<string | undefined>, ReturnType<typeof defineComponent>] {
export function useLabels({
slot = {},
name = 'Label',
props = {},
}: {
slot?: Record<string, unknown>
name?: string
props?: Record<string, unknown>
} = {}): ComputedRef<string | undefined> {
let labelIds = ref<string[]>([])
function register(value: string) {
labelIds.value.push(value)
return [
// The actual id's as string or undefined.
computed(() => (labelIds.value.length > 0 ? labelIds.value.join(' ') : undefined)),
return () => {
let idx = labelIds.value.indexOf(value)
if (idx === -1) return
labelIds.value.splice(idx, 1)
}
}
// The provider component
// @ts-expect-error The DefineComponent of Vue is just too confusing
defineComponent({
name: 'LabelProvider',
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)
provide(LabelContext, { register, slot, name, props })
return () => {
let idx = labelIds.value.indexOf(value)
if (idx === -1) return
labelIds.value.splice(idx, 1)
}
}
provide(LabelContext, {
register,
slot: computed(() => props.slot),
name: computed(() => props.name),
props: computed(() => props.props),
})
return () => slots.default!()
},
}),
]
// The actual id's as string or undefined.
return computed(() => (labelIds.value.length > 0 ? labelIds.value.join(' ') : undefined))
}
// ---
@@ -84,8 +70,15 @@ export let Label = defineComponent({
clickable: { type: [Boolean], default: false },
},
render() {
let { name = 'Label', slot = {}, props = {} } = this.context
let { clickable, ...passThroughProps } = this.$props
let propsWeControl = { ...this.props, id: this.id }
let propsWeControl = {
...Object.entries(props).reduce(
(acc, [key, value]) => Object.assign(acc, { [key]: unref(value) }),
{}
),
id: this.id,
}
let allProps = { ...passThroughProps, ...propsWeControl }
// @ts-expect-error props are dynamic via context, some components will
@@ -94,18 +87,18 @@ export let Label = defineComponent({
return render({
props: allProps,
slot: this.slot || {},
slot,
attrs: this.$attrs,
slots: this.$slots,
name: this.name || 'Label',
name,
})
},
setup() {
let { register, slot, name, props } = useLabelContext()
let context = useLabelContext()
let id = `headlessui-label-${useId()}`
onMounted(() => onUnmounted(register(id)))
onMounted(() => onUnmounted(context.register(id)))
return { id, slot, name, props }
return { id, context }
},
})
@@ -1,7 +1,6 @@
import {
computed,
defineComponent,
h,
inject,
onMounted,
onUnmounted,
@@ -60,7 +59,6 @@ function useRadioGroupContext(component: string) {
export let RadioGroup = defineComponent({
name: 'RadioGroup',
emits: ['update:modelValue'],
inheritAttrs: false, // Manually handling this
props: {
as: { type: [Object, String], default: 'div' },
disabled: { type: [Boolean], default: false },
@@ -70,9 +68,6 @@ export let RadioGroup = defineComponent({
let { modelValue, disabled, ...passThroughProps } = this.$props
let propsWeControl = {
// Manually passthrough the attributes, because Vue can't automatically pass
// it to the underlying div because of all the wrapper components below.
...this.$attrs,
ref: 'el',
id: this.id,
role: 'radiogroup',
@@ -81,23 +76,19 @@ export let RadioGroup = defineComponent({
onKeydown: this.handleKeyDown,
}
return h(this.DescriptionProvider, () => [
h(this.LabelProvider, () => [
render({
props: { ...passThroughProps, ...propsWeControl },
slot: {},
attrs: this.$attrs,
slots: this.$slots,
name: 'RadioGroup',
}),
]),
])
return render({
props: { ...passThroughProps, ...propsWeControl },
slot: {},
attrs: this.$attrs,
slots: this.$slots,
name: 'RadioGroup',
})
},
setup(props, { emit }) {
let radioGroupRef = ref<HTMLElement | null>(null)
let options = ref<StateDefinition['options']['value']>([])
let [labelledby, LabelProvider] = useLabels()
let [describedby, DescriptionProvider] = useDescriptions()
let labelledby = useLabels({ name: 'RadioGroupLabel' })
let describedby = useDescriptions({ name: 'RadioGroupDescription' })
let value = computed(() => props.modelValue)
@@ -214,8 +205,6 @@ export let RadioGroup = defineComponent({
describedby,
el: radioGroupRef,
handleKeyDown,
LabelProvider,
DescriptionProvider,
}
},
})
@@ -229,7 +218,6 @@ enum OptionState {
export let RadioGroupOption = defineComponent({
name: 'RadioGroupOption',
inheritAttrs: false, // Manually handling this
props: {
as: { type: [Object, String], default: 'div' },
value: { type: [Object, String] },
@@ -251,9 +239,6 @@ export let RadioGroupOption = defineComponent({
let slot = { checked: this.checked, active: Boolean(this.state & OptionState.Active) }
let propsWeControl = {
// Manually passthrough the attributes, because Vue can't automatically pass
// it to the underlying div because of all the wrapper components below.
...this.$attrs,
id: this.id,
ref: 'el',
role: 'radio',
@@ -267,23 +252,19 @@ export let RadioGroupOption = defineComponent({
onBlur: this.handleBlur,
}
return h(this.DescriptionProvider, () => [
h(this.LabelProvider, () => [
render({
props: { ...passThroughProps, ...propsWeControl },
slot,
attrs: this.$attrs,
slots: this.$slots,
name: 'RadioGroupOption',
}),
]),
])
return render({
props: { ...passThroughProps, ...propsWeControl },
slot,
attrs: this.$attrs,
slots: this.$slots,
name: 'RadioGroupOption',
})
},
setup(props) {
let api = useRadioGroupContext('RadioGroupOption')
let id = `headlessui-radiogroup-option-${useId()}`
let [labelledby, LabelProvider] = useLabels()
let [describedby, DescriptionProvider] = useDescriptions()
let labelledby = useLabels({ name: 'RadioGroupLabel' })
let describedby = useDescriptions({ name: 'RadioGroupDescription' })
let optionRef = ref<HTMLElement | null>(null)
let propsRef = computed(() => ({ value: props.value }))
@@ -298,8 +279,6 @@ export let RadioGroupOption = defineComponent({
labelledby,
describedby,
state,
LabelProvider,
DescriptionProvider,
checked: computed(() => toRaw(api.value.value) === toRaw(props.value)),
handleClick() {
let value = props.value
@@ -1,6 +1,5 @@
import {
defineComponent,
h,
inject,
provide,
ref,
@@ -30,49 +29,28 @@ let GroupContext = Symbol('GroupContext') as InjectionKey<StateDefinition>
export let SwitchGroup = defineComponent({
name: 'SwitchGroup',
inheritAttrs: false, // Manually handling this
props: {
as: { type: [Object, String], default: 'template' },
},
setup(props, { slots, attrs }) {
let switchRef = ref<StateDefinition['switchRef']['value']>(null)
let [labelledby, LabelProvider] = useLabels()
let [describedby, DescriptionProvider] = useDescriptions()
let labelledby = useLabels({
name: 'SwitchLabel',
props: {
onClick() {
if (!switchRef.value) return
switchRef.value.click()
switchRef.value.focus({ preventScroll: true })
},
},
})
let describedby = useDescriptions({ name: 'SwitchDescription' })
let api = { switchRef, labelledby, describedby }
provide(GroupContext, api)
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: {
// Manually passthrough the attributes, because Vue can't automatically pass
// it to the underlying div because of all the wrapper components below.
...attrs,
...props,
},
slot: {},
slots,
attrs,
name: 'SwitchGroup',
}),
]
),
])
return () => render({ props, slot: {}, slots, attrs, name: 'SwitchGroup' })
},
})