Skip to content

Commit 46a7ab6

Browse files
authored
Ensure Combobox.Label is properly linked when rendered after Combobox.Button and Combobox.Input components (#1838)
* ensure `Combbox.Label` is properly linked when rendered after other components Even when rendered after the Combobox.Input / Combobox.Button * update changelog
1 parent b296b73 commit 46a7ab6

File tree

4 files changed

+67
-8
lines changed

4 files changed

+67
-8
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Improve iOS scroll locking ([#1830](https://github.com/tailwindlabs/headlessui/pull/1830))
1313
- Add `<fieldset disabled>` check to radio group options in React ([#1835](https://github.com/tailwindlabs/headlessui/pull/1835))
1414
- Ensure `Tab` order stays consistent, and the currently active `Tab` stays active ([#1837](https://github.com/tailwindlabs/headlessui/pull/1837))
15+
- Ensure `Combobox.Label` is properly linked when rendered after `Combobox.Button` and `Combobox.Input` components ([#1838](https://github.com/tailwindlabs/headlessui/pull/1838))
1516

1617
## [1.7.0] - 2022-09-06
1718

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,22 @@ describe('Rendering', () => {
631631
})
632632
)
633633

634+
it(
635+
'should be possible to link Input/Button and Label if Label is rendered last',
636+
suppressConsoleLogs(async () => {
637+
render(
638+
<Combobox value="Test" onChange={console.log}>
639+
<Combobox.Input onChange={NOOP} />
640+
<Combobox.Button />
641+
<Combobox.Label>Label</Combobox.Label>
642+
</Combobox>
643+
)
644+
645+
assertComboboxLabelLinkedWithCombobox()
646+
assertComboboxButtonLinkedWithComboboxLabel()
647+
})
648+
)
649+
634650
it(
635651
'should be possible to render a Combobox.Label using a render prop and an `as` prop',
636652
suppressConsoleLogs(async () => {

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

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ type ComboboxOptionDataRef<T> = MutableRefObject<{
6767

6868
interface StateDefinition<T> {
6969
dataRef: MutableRefObject<_Data>
70+
labelId: string | null
7071

7172
comboboxState: ComboboxState
7273

@@ -83,6 +84,8 @@ enum ActionTypes {
8384

8485
RegisterOption,
8586
UnregisterOption,
87+
88+
RegisterLabel,
8689
}
8790

8891
function adjustOrderedState<T>(
@@ -124,6 +127,7 @@ type Actions<T> =
124127
trigger?: ActivationTrigger
125128
}
126129
| { type: ActionTypes.RegisterOption; id: string; dataRef: ComboboxOptionDataRef<T> }
130+
| { type: ActionTypes.RegisterLabel; id: string | null }
127131
| { type: ActionTypes.UnregisterOption; id: string }
128132

129133
let reducers: {
@@ -227,12 +231,19 @@ let reducers: {
227231
activationTrigger: ActivationTrigger.Other,
228232
}
229233
},
234+
[ActionTypes.RegisterLabel]: (state, action) => {
235+
return {
236+
...state,
237+
labelId: action.id,
238+
}
239+
},
230240
}
231241

232242
let ComboboxActionsContext = createContext<{
233243
openCombobox(): void
234244
closeCombobox(): void
235245
registerOption(id: string, dataRef: ComboboxOptionDataRef<unknown>): () => void
246+
registerLabel(id: string): () => void
236247
goToOption(focus: Focus.Specific, id: string, trigger?: ActivationTrigger): void
237248
goToOption(focus: Focus, id?: string, trigger?: ActivationTrigger): void
238249
selectOption(id: string): void
@@ -402,6 +413,7 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
402413
options: [],
403414
activeOptionIndex: null,
404415
activationTrigger: ActivationTrigger.Other,
416+
labelId: null,
405417
} as StateDefinition<TValue>)
406418

407419
let defaultToFirstOption = useRef(false)
@@ -536,6 +548,11 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
536548
return () => dispatch({ type: ActionTypes.UnregisterOption, id })
537549
})
538550

551+
let registerLabel = useEvent((id) => {
552+
dispatch({ type: ActionTypes.RegisterLabel, id })
553+
return () => dispatch({ type: ActionTypes.RegisterLabel, id: null })
554+
})
555+
539556
let onChange = useEvent((value: unknown) => {
540557
return match(data.mode, {
541558
[ValueMode.Single]() {
@@ -560,6 +577,7 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
560577
() => ({
561578
onChange,
562579
registerOption,
580+
registerLabel,
563581
goToOption,
564582
closeCombobox,
565583
openCombobox,
@@ -775,9 +793,9 @@ let Input = forwardRefWithAs(function Input<
775793
// TODO: Verify this. The spec says that, for the input/combobox, the label is the labelling element when present
776794
// Otherwise it's the ID of the non-label element
777795
let labelledby = useComputed(() => {
778-
if (!data.labelRef.current) return undefined
779-
return [data.labelRef.current.id].join(' ')
780-
}, [data.labelRef.current])
796+
if (!data.labelId) return undefined
797+
return [data.labelId].join(' ')
798+
}, [data.labelId])
781799

782800
let slot = useMemo<InputRenderPropArg>(
783801
() => ({ open: data.comboboxState === ComboboxState.Open, disabled: data.disabled }),
@@ -892,9 +910,9 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
892910
})
893911

894912
let labelledby = useComputed(() => {
895-
if (!data.labelRef.current) return undefined
896-
return [data.labelRef.current.id, id].join(' ')
897-
}, [data.labelRef.current, id])
913+
if (!data.labelId) return undefined
914+
return [data.labelId, id].join(' ')
915+
}, [data.labelId, id])
898916

899917
let slot = useMemo<ButtonRenderPropArg>(
900918
() => ({
@@ -943,8 +961,11 @@ let Label = forwardRefWithAs(function Label<TTag extends ElementType = typeof DE
943961
) {
944962
let data = useData('Combobox.Label')
945963
let id = `headlessui-combobox-label-${useId()}`
964+
let actions = useActions('Combobox.Label')
946965
let labelRef = useSyncRefs(data.labelRef, ref)
947966

967+
useIsoMorphicEffect(() => actions.registerLabel(id), [id])
968+
948969
let handleClick = useEvent(() => data.inputRef.current?.focus({ preventScroll: true }))
949970

950971
let slot = useMemo<LabelRenderPropArg>(
@@ -1027,8 +1048,8 @@ let Options = forwardRefWithAs(function Options<
10271048
})
10281049

10291050
let labelledby = useComputed(
1030-
() => data.labelRef.current?.id ?? data.buttonRef.current?.id,
1031-
[data.labelRef.current, data.buttonRef.current]
1051+
() => data.labelId ?? data.buttonRef.current?.id,
1052+
[data.labelId, data.buttonRef.current]
10321053
)
10331054

10341055
let slot = useMemo<OptionsRenderPropArg>(

packages/@headlessui-vue/src/components/combobox/combobox.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,27 @@ describe('Rendering', () => {
654654
})
655655
)
656656

657+
it(
658+
'should be possible to link Input/Button and Label if Label is rendered last',
659+
suppressConsoleLogs(async () => {
660+
renderTemplate({
661+
template: html`
662+
<Combobox v-model="value">
663+
<ComboboxInput />
664+
<ComboboxButton />
665+
<ComboboxLabel>Label</ComboboxLabel>
666+
</Combobox>
667+
`,
668+
setup: () => ({ value: ref(null) }),
669+
})
670+
671+
await new Promise<void>(nextTick)
672+
673+
assertComboboxLabelLinkedWithCombobox()
674+
assertComboboxButtonLinkedWithComboboxLabel()
675+
})
676+
)
677+
657678
it(
658679
'should be possible to render a ComboboxLabel using a render prop and an `as` prop',
659680
suppressConsoleLogs(async () => {

0 commit comments

Comments
 (0)