Files
headlessui/packages/@headlessui-react/src/components/listbox/listbox.tsx
T
Robin Malfait fdd4dd1b01 Remove focus() from Listbox Option (#1218)
* cleanup auto-scrolling

We keep the actual container focused, so we don't require the invidiual
option to be focused as well. We do want to scroll it into view but
that's part of another piece of code.

Also cleaned up some manual `document.querySelector` calls now that we
keep track of a `ref`.

* update changelog
2022-03-09 17:34:56 +01:00

809 lines
24 KiB
TypeScript

import React, {
Fragment,
createContext,
createRef,
useCallback,
useContext,
useMemo,
useReducer,
useRef,
// Types
Dispatch,
ElementType,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
MutableRefObject,
Ref,
useEffect,
} from 'react'
import { useDisposables } from '../../hooks/use-disposables'
import { useId } from '../../hooks/use-id'
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
import { useComputed } from '../../hooks/use-computed'
import { useSyncRefs } from '../../hooks/use-sync-refs'
import { Props } from '../../types'
import { Features, forwardRefWithAs, PropsForFeatures, render, compact } from '../../utils/render'
import { match } from '../../utils/match'
import { disposables } from '../../utils/disposables'
import { Keys } from '../keyboard'
import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index'
import { isDisabledReactIssue7711 } from '../../utils/bugs'
import { isFocusableElement, FocusableMode, sortByDomNode } from '../../utils/focus-management'
import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { useOutsideClick } from '../../hooks/use-outside-click'
import { VisuallyHidden } from '../../internal/visually-hidden'
import { objectToFormEntries } from '../../utils/form'
enum ListboxStates {
Open,
Closed,
}
enum ActivationTrigger {
Pointer,
Other,
}
type ListboxOptionDataRef = MutableRefObject<{
textValue?: string
disabled: boolean
value: unknown
domRef: MutableRefObject<HTMLElement | null>
}>
interface StateDefinition {
listboxState: ListboxStates
orientation: 'horizontal' | 'vertical'
propsRef: MutableRefObject<{ value: unknown; onChange(value: unknown): void }>
labelRef: MutableRefObject<HTMLLabelElement | null>
buttonRef: MutableRefObject<HTMLButtonElement | null>
optionsRef: MutableRefObject<HTMLUListElement | null>
disabled: boolean
options: { id: string; dataRef: ListboxOptionDataRef }[]
searchQuery: string
activeOptionIndex: number | null
activationTrigger: ActivationTrigger
}
enum ActionTypes {
OpenListbox,
CloseListbox,
SetDisabled,
SetOrientation,
GoToOption,
Search,
ClearSearch,
RegisterOption,
UnregisterOption,
}
function adjustOrderedState(
state: StateDefinition,
adjustment: (options: StateDefinition['options']) => StateDefinition['options'] = (i) => i
) {
let currentActiveOption =
state.activeOptionIndex !== null ? state.options[state.activeOptionIndex] : null
let sortedOptions = sortByDomNode(
adjustment(state.options.slice()),
(option) => option.dataRef.current.domRef.current
)
// If we inserted an option before the current active option then the active option index
// would be wrong. To fix this, we will re-lookup the correct index.
let adjustedActiveOptionIndex = currentActiveOption
? sortedOptions.indexOf(currentActiveOption)
: null
// Reset to `null` in case the currentActiveOption was removed.
if (adjustedActiveOptionIndex === -1) {
adjustedActiveOptionIndex = null
}
return {
options: sortedOptions,
activeOptionIndex: adjustedActiveOptionIndex,
}
}
type Actions =
| { type: ActionTypes.CloseListbox }
| { type: ActionTypes.OpenListbox }
| { type: ActionTypes.SetDisabled; disabled: boolean }
| { type: ActionTypes.SetOrientation; orientation: StateDefinition['orientation'] }
| { type: ActionTypes.GoToOption; focus: Focus.Specific; id: string; trigger?: ActivationTrigger }
| {
type: ActionTypes.GoToOption
focus: Exclude<Focus, Focus.Specific>
trigger?: ActivationTrigger
}
| { type: ActionTypes.Search; value: string }
| { type: ActionTypes.ClearSearch }
| { type: ActionTypes.RegisterOption; id: string; dataRef: ListboxOptionDataRef }
| { type: ActionTypes.UnregisterOption; id: string }
let reducers: {
[P in ActionTypes]: (
state: StateDefinition,
action: Extract<Actions, { type: P }>
) => StateDefinition
} = {
[ActionTypes.CloseListbox](state) {
if (state.disabled) return state
if (state.listboxState === ListboxStates.Closed) return state
return { ...state, activeOptionIndex: null, listboxState: ListboxStates.Closed }
},
[ActionTypes.OpenListbox](state) {
if (state.disabled) return state
if (state.listboxState === ListboxStates.Open) return state
return { ...state, listboxState: ListboxStates.Open }
},
[ActionTypes.SetDisabled](state, action) {
if (state.disabled === action.disabled) return state
return { ...state, disabled: action.disabled }
},
[ActionTypes.SetOrientation](state, action) {
if (state.orientation === action.orientation) return state
return { ...state, orientation: action.orientation }
},
[ActionTypes.GoToOption](state, action) {
if (state.disabled) return state
if (state.listboxState === ListboxStates.Closed) return state
let adjustedState = adjustOrderedState(state)
let activeOptionIndex = calculateActiveIndex(action, {
resolveItems: () => adjustedState.options,
resolveActiveIndex: () => adjustedState.activeOptionIndex,
resolveId: (option) => option.id,
resolveDisabled: (option) => option.dataRef.current.disabled,
})
return {
...state,
...adjustedState,
searchQuery: '',
activeOptionIndex,
activationTrigger: action.trigger ?? ActivationTrigger.Other,
}
},
[ActionTypes.Search]: (state, action) => {
if (state.disabled) return state
if (state.listboxState === ListboxStates.Closed) return state
let wasAlreadySearching = state.searchQuery !== ''
let offset = wasAlreadySearching ? 0 : 1
let searchQuery = state.searchQuery + action.value.toLowerCase()
let reOrderedOptions =
state.activeOptionIndex !== null
? state.options
.slice(state.activeOptionIndex + offset)
.concat(state.options.slice(0, state.activeOptionIndex + offset))
: state.options
let matchingOption = reOrderedOptions.find(
(option) =>
!option.dataRef.current.disabled &&
option.dataRef.current.textValue?.startsWith(searchQuery)
)
let matchIdx = matchingOption ? state.options.indexOf(matchingOption) : -1
if (matchIdx === -1 || matchIdx === state.activeOptionIndex) return { ...state, searchQuery }
return {
...state,
searchQuery,
activeOptionIndex: matchIdx,
activationTrigger: ActivationTrigger.Other,
}
},
[ActionTypes.ClearSearch](state) {
if (state.disabled) return state
if (state.listboxState === ListboxStates.Closed) return state
if (state.searchQuery === '') return state
return { ...state, searchQuery: '' }
},
[ActionTypes.RegisterOption]: (state, action) => {
let adjustedState = adjustOrderedState(state, (options) => [
...options,
{ id: action.id, dataRef: action.dataRef },
])
return { ...state, ...adjustedState }
},
[ActionTypes.UnregisterOption]: (state, action) => {
let adjustedState = adjustOrderedState(state, (options) => {
let idx = options.findIndex((a) => a.id === action.id)
if (idx !== -1) options.splice(idx, 1)
return options
})
return {
...state,
...adjustedState,
activationTrigger: ActivationTrigger.Other,
}
},
}
let ListboxContext = createContext<[StateDefinition, Dispatch<Actions>] | null>(null)
ListboxContext.displayName = 'ListboxContext'
function useListboxContext(component: string) {
let context = useContext(ListboxContext)
if (context === null) {
let err = new Error(`<${component} /> is missing a parent <Listbox /> component.`)
if (Error.captureStackTrace) Error.captureStackTrace(err, useListboxContext)
throw err
}
return context
}
function stateReducer(state: StateDefinition, action: Actions) {
return match(action.type, reducers, state, action)
}
// ---
let DEFAULT_LISTBOX_TAG = Fragment
interface ListboxRenderPropArg {
open: boolean
disabled: boolean
}
let ListboxRoot = forwardRefWithAs(function Listbox<
TTag extends ElementType = typeof DEFAULT_LISTBOX_TAG,
TType = string
>(
props: Props<
TTag,
ListboxRenderPropArg,
'value' | 'onChange' | 'disabled' | 'horizontal' | 'name'
> & {
value: TType
onChange(value: TType): void
disabled?: boolean
horizontal?: boolean
name?: string
},
ref: Ref<TTag>
) {
let { value, name, onChange, disabled = false, horizontal = false, ...passThroughProps } = props
const orientation = horizontal ? 'horizontal' : 'vertical'
let listboxRef = useSyncRefs(ref)
let reducerBag = useReducer(stateReducer, {
listboxState: ListboxStates.Closed,
propsRef: { current: { value, onChange } },
labelRef: createRef(),
buttonRef: createRef(),
optionsRef: createRef(),
disabled,
orientation,
options: [],
searchQuery: '',
activeOptionIndex: null,
activationTrigger: ActivationTrigger.Other,
} as StateDefinition)
let [{ listboxState, propsRef, optionsRef, buttonRef }, dispatch] = reducerBag
useIsoMorphicEffect(() => {
propsRef.current.value = value
}, [value, propsRef])
useIsoMorphicEffect(() => {
propsRef.current.onChange = onChange
}, [onChange, propsRef])
useIsoMorphicEffect(() => dispatch({ type: ActionTypes.SetDisabled, disabled }), [disabled])
useIsoMorphicEffect(
() => dispatch({ type: ActionTypes.SetOrientation, orientation }),
[orientation]
)
// Handle outside click
useOutsideClick([buttonRef, optionsRef], (event, target) => {
if (listboxState !== ListboxStates.Open) return
dispatch({ type: ActionTypes.CloseListbox })
if (!isFocusableElement(target, FocusableMode.Loose)) {
event.preventDefault()
buttonRef.current?.focus()
}
})
let slot = useMemo<ListboxRenderPropArg>(
() => ({ open: listboxState === ListboxStates.Open, disabled }),
[listboxState, disabled]
)
let renderConfiguration = {
props: { ref: listboxRef, ...passThroughProps },
slot,
defaultTag: DEFAULT_LISTBOX_TAG,
name: 'Listbox',
}
return (
<ListboxContext.Provider value={reducerBag}>
<OpenClosedProvider
value={match(listboxState, {
[ListboxStates.Open]: State.Open,
[ListboxStates.Closed]: State.Closed,
})}
>
{name != null && value != null ? (
<>
{objectToFormEntries({ [name]: value }).map(([name, value]) => (
<VisuallyHidden
{...compact({
key: name,
as: 'input',
type: 'hidden',
hidden: true,
readOnly: true,
name,
value,
})}
/>
))}
{render(renderConfiguration)}
</>
) : (
render(renderConfiguration)
)}
</OpenClosedProvider>
</ListboxContext.Provider>
)
})
// ---
let DEFAULT_BUTTON_TAG = 'button' as const
interface ButtonRenderPropArg {
open: boolean
disabled: boolean
}
type ButtonPropsWeControl =
| 'id'
| 'type'
| 'aria-haspopup'
| 'aria-controls'
| 'aria-expanded'
| 'aria-labelledby'
| 'disabled'
| 'onKeyDown'
| 'onClick'
let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
props: Props<TTag, ButtonRenderPropArg, ButtonPropsWeControl>,
ref: Ref<HTMLButtonElement>
) {
let [state, dispatch] = useListboxContext('Listbox.Button')
let buttonRef = useSyncRefs(state.buttonRef, ref)
let id = `headlessui-listbox-button-${useId()}`
let d = useDisposables()
let handleKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLButtonElement>) => {
switch (event.key) {
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-13
case Keys.Space:
case Keys.Enter:
case Keys.ArrowDown:
event.preventDefault()
dispatch({ type: ActionTypes.OpenListbox })
d.nextFrame(() => {
if (!state.propsRef.current.value)
dispatch({ type: ActionTypes.GoToOption, focus: Focus.First })
})
break
case Keys.ArrowUp:
event.preventDefault()
dispatch({ type: ActionTypes.OpenListbox })
d.nextFrame(() => {
if (!state.propsRef.current.value)
dispatch({ type: ActionTypes.GoToOption, focus: Focus.Last })
})
break
}
},
[dispatch, state, d]
)
let handleKeyUp = useCallback((event: ReactKeyboardEvent<HTMLButtonElement>) => {
switch (event.key) {
case Keys.Space:
// Required for firefox, event.preventDefault() in handleKeyDown for
// the Space key doesn't cancel the handleKeyUp, which in turn
// triggers a *click*.
event.preventDefault()
break
}
}, [])
let handleClick = useCallback(
(event: ReactMouseEvent) => {
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
if (state.listboxState === ListboxStates.Open) {
dispatch({ type: ActionTypes.CloseListbox })
d.nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
} else {
event.preventDefault()
dispatch({ type: ActionTypes.OpenListbox })
}
},
[dispatch, d, state]
)
let labelledby = useComputed(() => {
if (!state.labelRef.current) return undefined
return [state.labelRef.current.id, id].join(' ')
}, [state.labelRef.current, id])
let slot = useMemo<ButtonRenderPropArg>(
() => ({ open: state.listboxState === ListboxStates.Open, disabled: state.disabled }),
[state]
)
let passthroughProps = props
let propsWeControl = {
ref: buttonRef,
id,
type: useResolveButtonType(props, state.buttonRef),
'aria-haspopup': true,
'aria-controls': state.optionsRef.current?.id,
'aria-expanded': state.disabled ? undefined : state.listboxState === ListboxStates.Open,
'aria-labelledby': labelledby,
disabled: state.disabled,
onKeyDown: handleKeyDown,
onKeyUp: handleKeyUp,
onClick: handleClick,
}
return render({
props: { ...passthroughProps, ...propsWeControl },
slot,
defaultTag: DEFAULT_BUTTON_TAG,
name: 'Listbox.Button',
})
})
// ---
let DEFAULT_LABEL_TAG = 'label' as const
interface LabelRenderPropArg {
open: boolean
disabled: boolean
}
type LabelPropsWeControl = 'id' | 'ref' | 'onClick'
let Label = forwardRefWithAs(function Label<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
props: Props<TTag, LabelRenderPropArg, LabelPropsWeControl>,
ref: Ref<HTMLElement>
) {
let [state] = useListboxContext('Listbox.Label')
let id = `headlessui-listbox-label-${useId()}`
let labelRef = useSyncRefs(state.labelRef, ref)
let handleClick = useCallback(
() => state.buttonRef.current?.focus({ preventScroll: true }),
[state.buttonRef]
)
let slot = useMemo<LabelRenderPropArg>(
() => ({ open: state.listboxState === ListboxStates.Open, disabled: state.disabled }),
[state]
)
let propsWeControl = { ref: labelRef, id, onClick: handleClick }
return render({
props: { ...props, ...propsWeControl },
slot,
defaultTag: DEFAULT_LABEL_TAG,
name: 'Listbox.Label',
})
})
// ---
let DEFAULT_OPTIONS_TAG = 'ul' as const
interface OptionsRenderPropArg {
open: boolean
}
type OptionsPropsWeControl =
| 'aria-activedescendant'
| 'aria-labelledby'
| 'aria-orientation'
| 'id'
| 'onKeyDown'
| 'role'
| 'tabIndex'
let OptionsRenderFeatures = Features.RenderStrategy | Features.Static
let Options = forwardRefWithAs(function Options<
TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG
>(
props: Props<TTag, OptionsRenderPropArg, OptionsPropsWeControl> &
PropsForFeatures<typeof OptionsRenderFeatures>,
ref: Ref<HTMLElement>
) {
let [state, dispatch] = useListboxContext('Listbox.Options')
let optionsRef = useSyncRefs(state.optionsRef, ref)
let id = `headlessui-listbox-options-${useId()}`
let d = useDisposables()
let searchDisposables = useDisposables()
let usesOpenClosedState = useOpenClosed()
let visible = (() => {
if (usesOpenClosedState !== null) {
return usesOpenClosedState === State.Open
}
return state.listboxState === ListboxStates.Open
})()
useEffect(() => {
let container = state.optionsRef.current
if (!container) return
if (state.listboxState !== ListboxStates.Open) return
if (container === document.activeElement) return
container.focus({ preventScroll: true })
}, [state.listboxState, state.optionsRef])
let handleKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLUListElement>) => {
searchDisposables.dispose()
switch (event.key) {
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12
// @ts-expect-error Fallthrough is expected here
case Keys.Space:
if (state.searchQuery !== '') {
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.Search, value: event.key })
}
// When in type ahead mode, fallthrough
case Keys.Enter:
event.preventDefault()
event.stopPropagation()
dispatch({ type: ActionTypes.CloseListbox })
if (state.activeOptionIndex !== null) {
let { dataRef } = state.options[state.activeOptionIndex]
state.propsRef.current.onChange(dataRef.current.value)
}
disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
break
case match(state.orientation, { vertical: Keys.ArrowDown, horizontal: Keys.ArrowRight }):
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Next })
case match(state.orientation, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }):
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Previous })
case Keys.Home:
case Keys.PageUp:
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.GoToOption, focus: Focus.First })
case Keys.End:
case Keys.PageDown:
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Last })
case Keys.Escape:
event.preventDefault()
event.stopPropagation()
dispatch({ type: ActionTypes.CloseListbox })
return d.nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
case Keys.Tab:
event.preventDefault()
event.stopPropagation()
break
default:
if (event.key.length === 1) {
dispatch({ type: ActionTypes.Search, value: event.key })
searchDisposables.setTimeout(() => dispatch({ type: ActionTypes.ClearSearch }), 350)
}
break
}
},
[d, dispatch, searchDisposables, state]
)
let labelledby = useComputed(
() => state.labelRef.current?.id ?? state.buttonRef.current?.id,
[state.labelRef.current, state.buttonRef.current]
)
let slot = useMemo<OptionsRenderPropArg>(
() => ({ open: state.listboxState === ListboxStates.Open }),
[state]
)
let propsWeControl = {
'aria-activedescendant':
state.activeOptionIndex === null ? undefined : state.options[state.activeOptionIndex]?.id,
'aria-labelledby': labelledby,
'aria-orientation': state.orientation,
id,
onKeyDown: handleKeyDown,
role: 'listbox',
tabIndex: 0,
ref: optionsRef,
}
let passthroughProps = props
return render({
props: { ...passthroughProps, ...propsWeControl },
slot,
defaultTag: DEFAULT_OPTIONS_TAG,
features: OptionsRenderFeatures,
visible,
name: 'Listbox.Options',
})
})
// ---
let DEFAULT_OPTION_TAG = 'li' as const
interface OptionRenderPropArg {
active: boolean
selected: boolean
disabled: boolean
}
type ListboxOptionPropsWeControl =
| 'id'
| 'role'
| 'tabIndex'
| 'aria-disabled'
| 'aria-selected'
| 'onPointerLeave'
| 'onMouseLeave'
| 'onPointerMove'
| 'onMouseMove'
| 'onFocus'
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 Listbox itself.
// But today is not that day..
TType = Parameters<typeof ListboxRoot>[0]['value']
>(
props: Props<TTag, OptionRenderPropArg, ListboxOptionPropsWeControl | 'value'> & {
disabled?: boolean
value: TType
},
ref: Ref<HTMLElement>
) {
let { disabled = false, value, ...passthroughProps } = props
let [state, dispatch] = useListboxContext('Listbox.Option')
let id = `headlessui-listbox-option-${useId()}`
let active =
state.activeOptionIndex !== null ? state.options[state.activeOptionIndex].id === id : false
let selected = state.propsRef.current.value === value
let internalOptionRef = useRef<HTMLElement | null>(null)
let optionRef = useSyncRefs(ref, internalOptionRef)
useIsoMorphicEffect(() => {
if (state.listboxState !== ListboxStates.Open) return
if (!active) return
if (state.activationTrigger === ActivationTrigger.Pointer) return
let d = disposables()
d.requestAnimationFrame(() => {
internalOptionRef.current?.scrollIntoView?.({ block: 'nearest' })
})
return d.dispose
}, [internalOptionRef, active, state.listboxState, state.activationTrigger, /* 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 bag = useRef<ListboxOptionDataRef['current']>({ disabled, value, domRef: internalOptionRef })
useIsoMorphicEffect(() => {
bag.current.disabled = disabled
}, [bag, disabled])
useIsoMorphicEffect(() => {
bag.current.value = value
}, [bag, value])
useIsoMorphicEffect(() => {
bag.current.textValue = internalOptionRef.current?.textContent?.toLowerCase()
}, [bag, internalOptionRef])
let select = useCallback(() => state.propsRef.current.onChange(value), [state.propsRef, value])
useIsoMorphicEffect(() => {
dispatch({ type: ActionTypes.RegisterOption, id, dataRef: bag })
return () => dispatch({ type: ActionTypes.UnregisterOption, id })
}, [bag, id])
useIsoMorphicEffect(() => {
if (state.listboxState !== ListboxStates.Open) return
if (!selected) return
dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id })
}, [state.listboxState])
let handleClick = useCallback(
(event: { preventDefault: Function }) => {
if (disabled) return event.preventDefault()
select()
dispatch({ type: ActionTypes.CloseListbox })
disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
},
[dispatch, state.buttonRef, disabled, select]
)
let handleFocus = useCallback(() => {
if (disabled) return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Nothing })
dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id })
}, [disabled, id, dispatch])
let handleMove = useCallback(() => {
if (disabled) return
if (active) return
dispatch({
type: ActionTypes.GoToOption,
focus: Focus.Specific,
id,
trigger: ActivationTrigger.Pointer,
})
}, [disabled, active, id, dispatch])
let handleLeave = useCallback(() => {
if (disabled) return
if (!active) return
dispatch({ type: ActionTypes.GoToOption, focus: Focus.Nothing })
}, [disabled, active, dispatch])
let slot = useMemo<OptionRenderPropArg>(
() => ({ active, selected, disabled }),
[active, selected, disabled]
)
let propsWeControl = {
id,
ref: optionRef,
role: 'option',
tabIndex: disabled === true ? undefined : -1,
'aria-disabled': disabled === true ? true : undefined,
'aria-selected': selected === true ? true : undefined,
disabled: undefined, // Never forward the `disabled` prop
onClick: handleClick,
onFocus: handleFocus,
onPointerMove: handleMove,
onMouseMove: handleMove,
onPointerLeave: handleLeave,
onMouseLeave: handleLeave,
}
return render({
props: { ...passthroughProps, ...propsWeControl },
slot,
defaultTag: DEFAULT_OPTION_TAG,
name: 'Listbox.Option',
})
})
// ---
export let Listbox = Object.assign(ListboxRoot, { Button, Label, Options, Option })