Skip to content

Commit

Permalink
Forward the ref to all components (#1116)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
RobinMalfait authored Feb 24, 2022
1 parent 336faab commit 26670d2
Show file tree
Hide file tree
Showing 19 changed files with 250 additions and 194 deletions.
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased - @headlessui/react]

- Nothing yet!
### Fixed

- Forward the `ref` to all components ([#1116](https://github.com/tailwindlabs/headlessui/pull/1116))

## [Unreleased - @headlessui/vue]

Expand Down
34 changes: 14 additions & 20 deletions packages/@headlessui-react/src/components/combobox/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -687,11 +687,13 @@ interface LabelRenderPropArg {
}
type LabelPropsWeControl = 'id' | 'ref' | 'onClick'

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

let handleClick = useCallback(
() => state.inputRef.current?.focus({ preventScroll: true }),
Expand All @@ -702,14 +704,14 @@ function Label<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
() => ({ open: state.comboboxState === ComboboxStates.Open, disabled: state.disabled }),
[state]
)
let propsWeControl = { ref: state.labelRef, id, onClick: handleClick }
let propsWeControl = { ref: labelRef, id, onClick: handleClick }
return render({
props: { ...props, ...propsWeControl },
slot,
defaultTag: DEFAULT_LABEL_TAG,
name: 'Combobox.Label',
})
}
})

// ---

Expand Down Expand Up @@ -821,7 +823,7 @@ type ComboboxOptionPropsWeControl =
| 'onPointerMove'
| 'onMouseMove'

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

useIsoMorphicEffect(() => {
bag.current.disabled = disabled
Expand Down Expand Up @@ -883,12 +887,7 @@ function Option<
document.getElementById(id)?.scrollIntoView?.({ block: 'nearest' })
})
return d.dispose
}, [
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,
])
}, [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])

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

let propsWeControl = {
id,
ref: optionRef,
role: 'option',
tabIndex: disabled === true ? undefined : -1,
'aria-disabled': disabled === true ? true : undefined,
Expand All @@ -944,14 +944,8 @@ function Option<
defaultTag: DEFAULT_OPTION_TAG,
name: 'Combobox.Option',
})
}
})

// ---

export let Combobox = Object.assign(ComboboxRoot, {
Input,
Button,
Label,
Options,
Option,
})
export let Combobox = Object.assign(ComboboxRoot, { Input, Button, Label, Options, Option })
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ import React, {
// Types
ElementType,
ReactNode,
Ref,
} from 'react'

import { Props } from '../../types'
import { useId } from '../../hooks/use-id'
import { render } from '../../utils/render'
import { forwardRefWithAs, render } from '../../utils/render'
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
import { useSyncRefs } from '../../hooks/use-sync-refs'

// ---

Expand Down Expand Up @@ -86,24 +88,23 @@ export function useDescriptions(): [
// ---

let DEFAULT_DESCRIPTION_TAG = 'p' as const
interface DescriptionRenderPropArg {}
type DescriptionPropsWeControl = 'id'

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

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

let passThroughProps = props
let propsWeControl = { ...context.props, id }
let propsWeControl = { ref: descriptionRef, ...context.props, id }

return render({
props: { ...passThroughProps, ...propsWeControl },
slot: context.slot || {},
defaultTag: DEFAULT_DESCRIPTION_TAG,
name: context.name || 'Description',
})
}
})
12 changes: 7 additions & 5 deletions packages/@headlessui-react/src/components/dialog/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ DialogContext.displayName = 'DialogContext'
function useDialogContext(component: string) {
let context = useContext(DialogContext)
if (context === null) {
let err = new Error(`<${component} /> is missing a parent <${Dialog.displayName} /> component.`)
let err = new Error(`<${component} /> is missing a parent <Dialog /> component.`)
if (Error.captureStackTrace) Error.captureStackTrace(err, useDialogContext)
throw err
}
Expand Down Expand Up @@ -394,12 +394,14 @@ interface TitleRenderPropArg {
}
type TitlePropsWeControl = 'id'

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

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

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

return render({
props: { ...passthroughProps, ...propsWeControl },
props: { ref: titleRef, ...passthroughProps, ...propsWeControl },
slot,
defaultTag: DEFAULT_TITLE_TAG,
name: 'Dialog.Title',
})
}
})

// ---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ DisclosureContext.displayName = 'DisclosureContext'
function useDisclosureContext(component: string) {
let context = useContext(DisclosureContext)
if (context === null) {
let err = new Error(`<${component} /> is missing a parent <${Disclosure.name} /> component.`)
let err = new Error(`<${component} /> is missing a parent <Disclosure /> component.`)
if (Error.captureStackTrace) Error.captureStackTrace(err, useDisclosureContext)
throw err
}
Expand All @@ -118,7 +118,7 @@ DisclosureAPIContext.displayName = 'DisclosureAPIContext'
function useDisclosureAPIContext(component: string) {
let context = useContext(DisclosureAPIContext)
if (context === null) {
let err = new Error(`<${component} /> is missing a parent <${Disclosure.name} /> component.`)
let err = new Error(`<${component} /> is missing a parent <Disclosure /> component.`)
if (Error.captureStackTrace) Error.captureStackTrace(err, useDisclosureAPIContext)
throw err
}
Expand All @@ -144,14 +144,18 @@ interface DisclosureRenderPropArg {
close(focusableElement?: HTMLElement | MutableRefObject<HTMLElement | null>): void
}

export function Disclosure<TTag extends ElementType = typeof DEFAULT_DISCLOSURE_TAG>(
let DisclosureRoot = forwardRefWithAs(function Disclosure<
TTag extends ElementType = typeof DEFAULT_DISCLOSURE_TAG
>(
props: Props<TTag, DisclosureRenderPropArg> & {
defaultOpen?: boolean
}
},
ref: Ref<TTag>
) {
let { defaultOpen = false, ...passthroughProps } = props
let buttonId = `headlessui-disclosure-button-${useId()}`
let panelId = `headlessui-disclosure-panel-${useId()}`
let disclosureRef = useSyncRefs(ref)

let reducerBag = useReducer(stateReducer, {
disclosureState: defaultOpen ? DisclosureStates.Open : DisclosureStates.Closed,
Expand Down Expand Up @@ -198,7 +202,7 @@ export function Disclosure<TTag extends ElementType = typeof DEFAULT_DISCLOSURE_
})}
>
{render({
props: passthroughProps,
props: { ref: disclosureRef, ...passthroughProps },
slot,
defaultTag: DEFAULT_DISCLOSURE_TAG,
name: 'Disclosure',
Expand All @@ -207,7 +211,7 @@ export function Disclosure<TTag extends ElementType = typeof DEFAULT_DISCLOSURE_
</DisclosureAPIContext.Provider>
</DisclosureContext.Provider>
)
}
})

// ---

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

// ---

Disclosure.Button = Button
Disclosure.Panel = Panel
export let Disclosure = Object.assign(DisclosureRoot, { Button, Panel })
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,37 @@ import {
// Types
ElementType,
MutableRefObject,
Ref,
} from 'react'

import { Props } from '../../types'
import { render } from '../../utils/render'
import { forwardRefWithAs, render } from '../../utils/render'
import { useFocusTrap, Features as FocusTrapFeatures } from '../../hooks/use-focus-trap'
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
import { useSyncRefs } from '../../hooks/use-sync-refs'

let DEFAULT_FOCUS_TRAP_TAG = 'div' as const

export function FocusTrap<TTag extends ElementType = typeof DEFAULT_FOCUS_TRAP_TAG>(
props: Props<TTag> & { initialFocus?: MutableRefObject<HTMLElement | null> }
export let FocusTrap = forwardRefWithAs(function FocusTrap<
TTag extends ElementType = typeof DEFAULT_FOCUS_TRAP_TAG
>(
props: Props<TTag> & { initialFocus?: MutableRefObject<HTMLElement | null> },
ref: Ref<HTMLElement>
) {
let container = useRef<HTMLElement | null>(null)
let focusTrapRef = useSyncRefs(container, ref)
let { initialFocus, ...passthroughProps } = props

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

let propsWeControl = {
ref: container,
ref: focusTrapRef,
}

return render({
props: { ...passthroughProps, ...propsWeControl },
defaultTag: DEFAULT_FOCUS_TRAP_TAG,
name: 'FocusTrap',
})
}
})
20 changes: 12 additions & 8 deletions packages/@headlessui-react/src/components/label/label.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ import React, {
// Types
ElementType,
ReactNode,
Ref,
} from 'react'

import { Props } from '../../types'
import { useId } from '../../hooks/use-id'
import { render } from '../../utils/render'
import { forwardRefWithAs, render } from '../../utils/render'
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
import { useSyncRefs } from '../../hooks/use-sync-refs'

// ---

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

let DEFAULT_LABEL_TAG = 'label' as const
interface LabelRenderPropArg {}
type LabelPropsWeControl = 'id'

export function Label<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
props: Props<TTag, LabelRenderPropArg, LabelPropsWeControl> & {
export let Label = forwardRefWithAs(function Label<
TTag extends ElementType = typeof DEFAULT_LABEL_TAG
>(
props: Props<TTag, {}, 'id'> & {
passive?: boolean
}
},
ref: Ref<HTMLLabelElement>
) {
let { passive = false, ...passThroughProps } = props
let context = useLabelContext()
let id = `headlessui-label-${useId()}`
let labelRef = useSyncRefs(ref)

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

let propsWeControl = { ...context.props, id }
let propsWeControl = { ref: labelRef, ...context.props, id }

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

0 comments on commit 26670d2

Please sign in to comment.