Skip to content

Commit 26670d2

Browse files
authored
Forward the ref to all components (#1116)
* forward ref to all components * fix playground pages This isn't a perfect fix of course. But the TypeScript changes required to do it properly are a bit bigger and require more work. Having this ready is a good step forward. * update changelog
1 parent 336faab commit 26670d2

File tree

19 files changed

+250
-194
lines changed

19 files changed

+250
-194
lines changed

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased - @headlessui/react]
99

10-
- Nothing yet!
10+
### Fixed
11+
12+
- Forward the `ref` to all components ([#1116](https://github.com/tailwindlabs/headlessui/pull/1116))
1113

1214
## [Unreleased - @headlessui/vue]
1315

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

Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -687,11 +687,13 @@ interface LabelRenderPropArg {
687687
}
688688
type LabelPropsWeControl = 'id' | 'ref' | 'onClick'
689689

690-
function Label<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
691-
props: Props<TTag, LabelRenderPropArg, LabelPropsWeControl>
690+
let Label = forwardRefWithAs(function Label<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
691+
props: Props<TTag, LabelRenderPropArg, LabelPropsWeControl>,
692+
ref: Ref<HTMLLabelElement>
692693
) {
693694
let [state] = useComboboxContext('Combobox.Label')
694695
let id = `headlessui-combobox-label-${useId()}`
696+
let labelRef = useSyncRefs(state.labelRef, ref)
695697

696698
let handleClick = useCallback(
697699
() => state.inputRef.current?.focus({ preventScroll: true }),
@@ -702,14 +704,14 @@ function Label<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
702704
() => ({ open: state.comboboxState === ComboboxStates.Open, disabled: state.disabled }),
703705
[state]
704706
)
705-
let propsWeControl = { ref: state.labelRef, id, onClick: handleClick }
707+
let propsWeControl = { ref: labelRef, id, onClick: handleClick }
706708
return render({
707709
props: { ...props, ...propsWeControl },
708710
slot,
709711
defaultTag: DEFAULT_LABEL_TAG,
710712
name: 'Combobox.Label',
711713
})
712-
}
714+
})
713715

714716
// ---
715717

@@ -821,7 +823,7 @@ type ComboboxOptionPropsWeControl =
821823
| 'onPointerMove'
822824
| 'onMouseMove'
823825

824-
function Option<
826+
let Option = forwardRefWithAs(function Option<
825827
TTag extends ElementType = typeof DEFAULT_OPTION_TAG,
826828
// TODO: One day we will be able to infer this type from the generic in Combobox itself.
827829
// But today is not that day..
@@ -830,7 +832,8 @@ function Option<
830832
props: Props<TTag, OptionRenderPropArg, ComboboxOptionPropsWeControl | 'value'> & {
831833
disabled?: boolean
832834
value: TType
833-
}
835+
},
836+
ref: Ref<HTMLLIElement>
834837
) {
835838
let { disabled = false, value, ...passthroughProps } = props
836839
let [state, dispatch] = useComboboxContext('Combobox.Option')
@@ -840,6 +843,7 @@ function Option<
840843
state.activeOptionIndex !== null ? state.options[state.activeOptionIndex].id === id : false
841844
let selected = state.comboboxPropsRef.current.value === value
842845
let bag = useRef<ComboboxOptionDataRef['current']>({ disabled, value })
846+
let optionRef = useSyncRefs(ref)
843847

844848
useIsoMorphicEffect(() => {
845849
bag.current.disabled = disabled
@@ -883,12 +887,7 @@ function Option<
883887
document.getElementById(id)?.scrollIntoView?.({ block: 'nearest' })
884888
})
885889
return d.dispose
886-
}, [
887-
id,
888-
active,
889-
state.comboboxState,
890-
/* We also want to trigger this when the position of the active item changes so that we can re-trigger the scrollIntoView */ state.activeOptionIndex,
891-
])
890+
}, [id, active, state.comboboxState, /* We also want to trigger this when the position of the active item changes so that we can re-trigger the scrollIntoView */ state.activeOptionIndex])
892891

893892
let handleClick = useCallback(
894893
(event: { preventDefault: Function }) => {
@@ -925,6 +924,7 @@ function Option<
925924

926925
let propsWeControl = {
927926
id,
927+
ref: optionRef,
928928
role: 'option',
929929
tabIndex: disabled === true ? undefined : -1,
930930
'aria-disabled': disabled === true ? true : undefined,
@@ -944,14 +944,8 @@ function Option<
944944
defaultTag: DEFAULT_OPTION_TAG,
945945
name: 'Combobox.Option',
946946
})
947-
}
947+
})
948948

949949
// ---
950950

951-
export let Combobox = Object.assign(ComboboxRoot, {
952-
Input,
953-
Button,
954-
Label,
955-
Options,
956-
Option,
957-
})
951+
export let Combobox = Object.assign(ComboboxRoot, { Input, Button, Label, Options, Option })

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

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ import React, {
88
// Types
99
ElementType,
1010
ReactNode,
11+
Ref,
1112
} from 'react'
1213

1314
import { Props } from '../../types'
1415
import { useId } from '../../hooks/use-id'
15-
import { render } from '../../utils/render'
16+
import { forwardRefWithAs, render } from '../../utils/render'
1617
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
18+
import { useSyncRefs } from '../../hooks/use-sync-refs'
1719

1820
// ---
1921

@@ -86,24 +88,23 @@ export function useDescriptions(): [
8688
// ---
8789

8890
let DEFAULT_DESCRIPTION_TAG = 'p' as const
89-
interface DescriptionRenderPropArg {}
90-
type DescriptionPropsWeControl = 'id'
9191

92-
export function Description<TTag extends ElementType = typeof DEFAULT_DESCRIPTION_TAG>(
93-
props: Props<TTag, DescriptionRenderPropArg, DescriptionPropsWeControl>
94-
) {
92+
export let Description = forwardRefWithAs(function Description<
93+
TTag extends ElementType = typeof DEFAULT_DESCRIPTION_TAG
94+
>(props: Props<TTag, {}, 'id'>, ref: Ref<HTMLParagraphElement>) {
9595
let context = useDescriptionContext()
9696
let id = `headlessui-description-${useId()}`
97+
let descriptionRef = useSyncRefs(ref)
9798

9899
useIsoMorphicEffect(() => context.register(id), [id, context.register])
99100

100101
let passThroughProps = props
101-
let propsWeControl = { ...context.props, id }
102+
let propsWeControl = { ref: descriptionRef, ...context.props, id }
102103

103104
return render({
104105
props: { ...passThroughProps, ...propsWeControl },
105106
slot: context.slot || {},
106107
defaultTag: DEFAULT_DESCRIPTION_TAG,
107108
name: context.name || 'Description',
108109
})
109-
}
110+
})

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ DialogContext.displayName = 'DialogContext'
7777
function useDialogContext(component: string) {
7878
let context = useContext(DialogContext)
7979
if (context === null) {
80-
let err = new Error(`<${component} /> is missing a parent <${Dialog.displayName} /> component.`)
80+
let err = new Error(`<${component} /> is missing a parent <Dialog /> component.`)
8181
if (Error.captureStackTrace) Error.captureStackTrace(err, useDialogContext)
8282
throw err
8383
}
@@ -394,12 +394,14 @@ interface TitleRenderPropArg {
394394
}
395395
type TitlePropsWeControl = 'id'
396396

397-
function Title<TTag extends ElementType = typeof DEFAULT_TITLE_TAG>(
398-
props: Props<TTag, TitleRenderPropArg, TitlePropsWeControl>
397+
let Title = forwardRefWithAs(function Title<TTag extends ElementType = typeof DEFAULT_TITLE_TAG>(
398+
props: Props<TTag, TitleRenderPropArg, TitlePropsWeControl>,
399+
ref: Ref<HTMLHeadingElement>
399400
) {
400401
let [{ dialogState, setTitleId }] = useDialogContext('Dialog.Title')
401402

402403
let id = `headlessui-dialog-title-${useId()}`
404+
let titleRef = useSyncRefs(ref)
403405

404406
useEffect(() => {
405407
setTitleId(id)
@@ -414,12 +416,12 @@ function Title<TTag extends ElementType = typeof DEFAULT_TITLE_TAG>(
414416
let passthroughProps = props
415417

416418
return render({
417-
props: { ...passthroughProps, ...propsWeControl },
419+
props: { ref: titleRef, ...passthroughProps, ...propsWeControl },
418420
slot,
419421
defaultTag: DEFAULT_TITLE_TAG,
420422
name: 'Dialog.Title',
421423
})
422-
}
424+
})
423425

424426
// ---
425427

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

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ DisclosureContext.displayName = 'DisclosureContext'
103103
function useDisclosureContext(component: string) {
104104
let context = useContext(DisclosureContext)
105105
if (context === null) {
106-
let err = new Error(`<${component} /> is missing a parent <${Disclosure.name} /> component.`)
106+
let err = new Error(`<${component} /> is missing a parent <Disclosure /> component.`)
107107
if (Error.captureStackTrace) Error.captureStackTrace(err, useDisclosureContext)
108108
throw err
109109
}
@@ -118,7 +118,7 @@ DisclosureAPIContext.displayName = 'DisclosureAPIContext'
118118
function useDisclosureAPIContext(component: string) {
119119
let context = useContext(DisclosureAPIContext)
120120
if (context === null) {
121-
let err = new Error(`<${component} /> is missing a parent <${Disclosure.name} /> component.`)
121+
let err = new Error(`<${component} /> is missing a parent <Disclosure /> component.`)
122122
if (Error.captureStackTrace) Error.captureStackTrace(err, useDisclosureAPIContext)
123123
throw err
124124
}
@@ -144,14 +144,18 @@ interface DisclosureRenderPropArg {
144144
close(focusableElement?: HTMLElement | MutableRefObject<HTMLElement | null>): void
145145
}
146146

147-
export function Disclosure<TTag extends ElementType = typeof DEFAULT_DISCLOSURE_TAG>(
147+
let DisclosureRoot = forwardRefWithAs(function Disclosure<
148+
TTag extends ElementType = typeof DEFAULT_DISCLOSURE_TAG
149+
>(
148150
props: Props<TTag, DisclosureRenderPropArg> & {
149151
defaultOpen?: boolean
150-
}
152+
},
153+
ref: Ref<TTag>
151154
) {
152155
let { defaultOpen = false, ...passthroughProps } = props
153156
let buttonId = `headlessui-disclosure-button-${useId()}`
154157
let panelId = `headlessui-disclosure-panel-${useId()}`
158+
let disclosureRef = useSyncRefs(ref)
155159

156160
let reducerBag = useReducer(stateReducer, {
157161
disclosureState: defaultOpen ? DisclosureStates.Open : DisclosureStates.Closed,
@@ -198,7 +202,7 @@ export function Disclosure<TTag extends ElementType = typeof DEFAULT_DISCLOSURE_
198202
})}
199203
>
200204
{render({
201-
props: passthroughProps,
205+
props: { ref: disclosureRef, ...passthroughProps },
202206
slot,
203207
defaultTag: DEFAULT_DISCLOSURE_TAG,
204208
name: 'Disclosure',
@@ -207,7 +211,7 @@ export function Disclosure<TTag extends ElementType = typeof DEFAULT_DISCLOSURE_
207211
</DisclosureAPIContext.Provider>
208212
</DisclosureContext.Provider>
209213
)
210-
}
214+
})
211215

212216
// ---
213217

@@ -387,5 +391,4 @@ let Panel = forwardRefWithAs(function Panel<TTag extends ElementType = typeof DE
387391

388392
// ---
389393

390-
Disclosure.Button = Button
391-
Disclosure.Panel = Panel
394+
export let Disclosure = Object.assign(DisclosureRoot, { Button, Panel })

packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,31 +4,37 @@ import {
44
// Types
55
ElementType,
66
MutableRefObject,
7+
Ref,
78
} from 'react'
89

910
import { Props } from '../../types'
10-
import { render } from '../../utils/render'
11+
import { forwardRefWithAs, render } from '../../utils/render'
1112
import { useFocusTrap, Features as FocusTrapFeatures } from '../../hooks/use-focus-trap'
1213
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
14+
import { useSyncRefs } from '../../hooks/use-sync-refs'
1315

1416
let DEFAULT_FOCUS_TRAP_TAG = 'div' as const
1517

16-
export function FocusTrap<TTag extends ElementType = typeof DEFAULT_FOCUS_TRAP_TAG>(
17-
props: Props<TTag> & { initialFocus?: MutableRefObject<HTMLElement | null> }
18+
export let FocusTrap = forwardRefWithAs(function FocusTrap<
19+
TTag extends ElementType = typeof DEFAULT_FOCUS_TRAP_TAG
20+
>(
21+
props: Props<TTag> & { initialFocus?: MutableRefObject<HTMLElement | null> },
22+
ref: Ref<HTMLElement>
1823
) {
1924
let container = useRef<HTMLElement | null>(null)
25+
let focusTrapRef = useSyncRefs(container, ref)
2026
let { initialFocus, ...passthroughProps } = props
2127

2228
let ready = useServerHandoffComplete()
2329
useFocusTrap(container, ready ? FocusTrapFeatures.All : FocusTrapFeatures.None, { initialFocus })
2430

2531
let propsWeControl = {
26-
ref: container,
32+
ref: focusTrapRef,
2733
}
2834

2935
return render({
3036
props: { ...passthroughProps, ...propsWeControl },
3137
defaultTag: DEFAULT_FOCUS_TRAP_TAG,
3238
name: 'FocusTrap',
3339
})
34-
}
40+
})

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

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ import React, {
88
// Types
99
ElementType,
1010
ReactNode,
11+
Ref,
1112
} from 'react'
1213

1314
import { Props } from '../../types'
1415
import { useId } from '../../hooks/use-id'
15-
import { render } from '../../utils/render'
16+
import { forwardRefWithAs, render } from '../../utils/render'
1617
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
18+
import { useSyncRefs } from '../../hooks/use-sync-refs'
1719

1820
// ---
1921

@@ -77,21 +79,23 @@ export function useLabels(): [string | undefined, (props: LabelProviderProps) =>
7779
// ---
7880

7981
let DEFAULT_LABEL_TAG = 'label' as const
80-
interface LabelRenderPropArg {}
81-
type LabelPropsWeControl = 'id'
8282

83-
export function Label<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
84-
props: Props<TTag, LabelRenderPropArg, LabelPropsWeControl> & {
83+
export let Label = forwardRefWithAs(function Label<
84+
TTag extends ElementType = typeof DEFAULT_LABEL_TAG
85+
>(
86+
props: Props<TTag, {}, 'id'> & {
8587
passive?: boolean
86-
}
88+
},
89+
ref: Ref<HTMLLabelElement>
8790
) {
8891
let { passive = false, ...passThroughProps } = props
8992
let context = useLabelContext()
9093
let id = `headlessui-label-${useId()}`
94+
let labelRef = useSyncRefs(ref)
9195

9296
useIsoMorphicEffect(() => context.register(id), [id, context.register])
9397

94-
let propsWeControl = { ...context.props, id }
98+
let propsWeControl = { ref: labelRef, ...context.props, id }
9599

96100
let allProps = { ...passThroughProps, ...propsWeControl }
97101
// @ts-expect-error props are dynamic via context, some components will
@@ -104,4 +108,4 @@ export function Label<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
104108
defaultTag: DEFAULT_LABEL_TAG,
105109
name: context.name || 'Label',
106110
})
107-
}
111+
})

0 commit comments

Comments
 (0)