Implement new virtual API for the Combobox component (#2779)

* add `(Vue)` or `(React)` to playground header

* show amount of items in virtualized example

* improve calculating the active index

* disable strict mode

* update virtualized playground examples with preferred API

* optimize `calculateActiveIndex`

* implement new `virtual` API

* update changelog
This commit is contained in:
Robin Malfait
2023-10-02 11:55:20 +02:00
committed by GitHub
parent d4a94cb564
commit 99cdf91631
14 changed files with 2147 additions and 3875 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Add `immediate` prop to `<Combobox />` for immediately opening the Combobox when the `input` receives focus ([#2686](https://github.com/tailwindlabs/headlessui/pull/2686))
- Add `virtual` prop to `Combobox` component ([#2740](https://github.com/tailwindlabs/headlessui/pull/2740))
- Add `virtual` prop to `Combobox` component ([#2779](https://github.com/tailwindlabs/headlessui/pull/2779))
## [1.7.17] - 2023-08-17
File diff suppressed because it is too large Load Diff
@@ -9,7 +9,7 @@ import React, {
useMemo,
useReducer,
useRef,
type CSSProperties,
useState,
type ElementType,
type FocusEvent as ReactFocusEvent,
type KeyboardEvent as ReactKeyboardEvent,
@@ -74,13 +74,14 @@ type ComboboxOptionDataRef<T> = MutableRefObject<{
value: T
domRef: MutableRefObject<HTMLElement | null>
order: number | null
onVirtualRangeUpdate: (virtualizer: Virtualizer<any, any>) => void
}>
interface StateDefinition<T> {
dataRef: MutableRefObject<_Data | null>
labelId: string | null
virtual: { options: T[]; disabled: (value: unknown) => boolean } | null
comboboxState: ComboboxState
options: { id: string; dataRef: ComboboxOptionDataRef<T> }[]
@@ -100,6 +101,8 @@ enum ActionTypes {
RegisterLabel,
SetActivationTrigger,
UpdateVirtualOptions,
}
function adjustOrderedState<T>(
@@ -137,16 +140,25 @@ function adjustOrderedState<T>(
type Actions<T> =
| { type: ActionTypes.CloseCombobox }
| { type: ActionTypes.OpenCombobox }
| { type: ActionTypes.GoToOption; focus: Focus.Specific; id: string; trigger?: ActivationTrigger }
| {
type: ActionTypes.GoToOption
focus: Focus.Specific
idx: number
trigger?: ActivationTrigger
}
| {
type: ActionTypes.GoToOption
focus: Exclude<Focus, Focus.Specific>
trigger?: ActivationTrigger
}
| { type: ActionTypes.RegisterOption; id: string; dataRef: ComboboxOptionDataRef<T> }
| {
type: ActionTypes.RegisterOption
payload: { id: string; dataRef: ComboboxOptionDataRef<T> }
}
| { type: ActionTypes.RegisterLabel; id: string | null }
| { type: ActionTypes.UnregisterOption; id: string }
| { type: ActionTypes.SetActivationTrigger; trigger: ActivationTrigger }
| { type: ActionTypes.UpdateVirtualOptions; options: T[] }
let reducers: {
[P in ActionTypes]: <T>(
@@ -157,6 +169,7 @@ let reducers: {
[ActionTypes.CloseCombobox](state) {
if (state.dataRef.current?.disabled) return state
if (state.comboboxState === ComboboxState.Closed) return state
return { ...state, activeOptionIndex: null, comboboxState: ComboboxState.Closed }
},
[ActionTypes.OpenCombobox](state) {
@@ -164,18 +177,18 @@ let reducers: {
if (state.comboboxState === ComboboxState.Open) return state
// Check if we have a selected value that we can make active
let activeOptionIndex = state.activeOptionIndex
if (state.dataRef.current) {
let { isSelected } = state.dataRef.current
let optionIdx = state.options.findIndex((option) => isSelected(option.dataRef.current.value))
if (optionIdx !== -1) {
activeOptionIndex = optionIdx
if (state.dataRef.current?.value) {
let idx = state.dataRef.current.calculateIndex(state.dataRef.current.value)
if (idx !== -1) {
return {
...state,
activeOptionIndex: idx,
comboboxState: ComboboxState.Open,
}
}
}
return { ...state, comboboxState: ComboboxState.Open, activeOptionIndex }
return { ...state, comboboxState: ComboboxState.Open }
},
[ActionTypes.GoToOption](state, action) {
if (state.dataRef.current?.disabled) return state
@@ -187,6 +200,38 @@ let reducers: {
return state
}
if (state.virtual) {
let activeOptionIndex =
action.focus === Focus.Specific
? action.idx
: calculateActiveIndex(action, {
resolveItems: () => state.virtual!.options,
resolveActiveIndex: () =>
state.activeOptionIndex ??
state.virtual!.options.findIndex((option) => !state.virtual!.disabled(option)) ??
null,
resolveDisabled: state.virtual!.disabled,
resolveId() {
throw new Error('Function not implemented.')
},
})
let activationTrigger = action.trigger ?? ActivationTrigger.Other
if (
state.activeOptionIndex === activeOptionIndex &&
state.activationTrigger === activationTrigger
) {
return state
}
return {
...state,
activeOptionIndex,
activationTrigger,
}
}
let adjustedState = adjustOrderedState(state)
// It's possible that the activeOptionIndex is set to `null` internally, but
@@ -202,12 +247,15 @@ let reducers: {
}
}
let activeOptionIndex = calculateActiveIndex(action, {
resolveItems: () => adjustedState.options,
resolveActiveIndex: () => adjustedState.activeOptionIndex,
resolveId: (item) => item.id,
resolveDisabled: (item) => item.dataRef.current.disabled,
})
let activeOptionIndex =
action.focus === Focus.Specific
? action.idx
: calculateActiveIndex(action, {
resolveItems: () => adjustedState.options,
resolveActiveIndex: () => adjustedState.activeOptionIndex,
resolveId: (item) => item.id,
resolveDisabled: (item) => item.dataRef.current.disabled,
})
let activationTrigger = action.trigger ?? ActivationTrigger.Other
if (
@@ -225,7 +273,14 @@ let reducers: {
}
},
[ActionTypes.RegisterOption]: (state, action) => {
let option = { id: action.id, dataRef: action.dataRef }
if (state.dataRef.current?.virtual) {
return {
...state,
options: [...state.options, action.payload],
}
}
let option = action.payload
let adjustedState = adjustOrderedState(state, (options) => {
options.push(option)
@@ -234,7 +289,7 @@ let reducers: {
// Check if we need to make the newly registered option active.
if (state.activeOptionIndex === null) {
if (state.dataRef.current?.isSelected(action.dataRef.current.value)) {
if (state.dataRef.current?.isSelected(action.payload.dataRef.current.value)) {
adjustedState.activeOptionIndex = adjustedState.options.indexOf(option)
}
}
@@ -252,8 +307,15 @@ let reducers: {
return nextState
},
[ActionTypes.UnregisterOption]: (state, action) => {
if (state.dataRef.current?.virtual) {
return {
...state,
options: state.options.filter((option) => option.id !== action.id),
}
}
let adjustedState = adjustOrderedState(state, (options) => {
let idx = options.findIndex((a) => a.id === action.id)
let idx = options.findIndex((option) => option.id === action.id)
if (idx !== -1) options.splice(idx, 1)
return options
})
@@ -265,17 +327,46 @@ let reducers: {
}
},
[ActionTypes.RegisterLabel]: (state, action) => {
if (state.labelId === action.id) {
return state
}
return {
...state,
labelId: action.id,
}
},
[ActionTypes.SetActivationTrigger]: (state, action) => {
if (state.activationTrigger === action.trigger) {
return state
}
return {
...state,
activationTrigger: action.trigger,
}
},
[ActionTypes.UpdateVirtualOptions]: (state, action) => {
if (state.virtual?.options === action.options) {
return state
}
let adjustedActiveOptionIndex = state.activeOptionIndex
if (state.activeOptionIndex !== null) {
let idx = action.options.indexOf(state.virtual!.options[state.activeOptionIndex])
if (idx !== -1) {
adjustedActiveOptionIndex = idx
} else {
adjustedActiveOptionIndex = null
}
}
return {
...state,
activeOptionIndex: adjustedActiveOptionIndex,
virtual: Object.assign({}, state.virtual, { options: action.options }),
}
},
}
let ComboboxActionsContext = createContext<{
@@ -283,9 +374,8 @@ let ComboboxActionsContext = createContext<{
closeCombobox(): void
registerOption(id: string, dataRef: ComboboxOptionDataRef<unknown>): () => void
registerLabel(id: string): () => void
goToOption(focus: Focus.Specific, id: string, trigger?: ActivationTrigger): void
goToOption(focus: Focus, id?: string, trigger?: ActivationTrigger): void
selectOption(id: string): void
goToOption(focus: Focus.Specific, idx: number, trigger?: ActivationTrigger): void
goToOption(focus: Focus, idx?: number, trigger?: ActivationTrigger): void
selectActiveOption(): void
setActivationTrigger(trigger: ActivationTrigger): void
onChange(value: unknown): void
@@ -305,16 +395,11 @@ type _Actions = ReturnType<typeof useActions>
let VirtualContext = createContext<Virtualizer<any, any> | null>(null)
function VirtualProvider(props: React.PropsWithChildren<{}>) {
function VirtualProvider(props: {
children: (data: { option: unknown; open: boolean }) => React.ReactElement
}) {
let data = useData('VirtualProvider')
let firstAvailableOption = data.options.find((option) => option.dataRef.current.domRef.current)
let measuredHeight = useMemo(() => {
let height =
firstAvailableOption?.dataRef.current.domRef.current?.getBoundingClientRect().height
return height ?? 40
}, [firstAvailableOption])
let [paddingStart, paddingEnd] = useMemo(() => {
let el = data.optionsRef.current
if (!el) return [0, 0]
@@ -330,27 +415,21 @@ function VirtualProvider(props: React.PropsWithChildren<{}>) {
let virtualizer = useVirtualizer({
scrollPaddingStart: paddingStart,
scrollPaddingEnd: paddingEnd,
count: data.options.length,
count: data.virtual!.options.length,
estimateSize() {
return measuredHeight
return 40
},
getScrollElement() {
return (data.optionsRef.current ?? null) as HTMLElement | null
},
overscan: 12,
onChange(event) {
let list = event.getVirtualItems()
if (list.length === 0) return
let min = list[0].index
let max = list[list.length - 1].index + 1
for (let option of data.options.slice(min, max)) {
option.dataRef.current.onVirtualRangeUpdate(event)
}
},
})
let [baseKey, setBaseKey] = useState(0)
useIsoMorphicEffect(() => {
setBaseKey((v) => v + 1)
}, [data.virtual?.options])
return (
<VirtualContext.Provider value={virtualizer}>
<div
@@ -359,8 +438,57 @@ function VirtualProvider(props: React.PropsWithChildren<{}>) {
width: '100%',
height: `${virtualizer.getTotalSize()}px`,
}}
ref={(el) => {
if (!el) {
return
}
// Scroll to the active index
{
// Ignore this when we are in a test environment
if (typeof process !== 'undefined' && process.env.JEST_WORKER_ID !== undefined) {
return
}
// Do not scroll when the mouse/pointer is being used
if (data.activationTrigger === ActivationTrigger.Pointer) {
return
}
if (
data.activeOptionIndex !== null &&
data.virtual!.options.length > data.activeOptionIndex
) {
virtualizer.scrollToIndex(data.activeOptionIndex)
}
}
}}
>
{props.children}
{virtualizer.getVirtualItems().map((item) => {
return (
<Fragment key={item.key}>
{React.cloneElement(
props.children?.({
option: data.virtual!.options[item.index],
open: data.comboboxState === ComboboxState.Open,
}),
{
key: `${baseKey}-${item.key}`,
'data-index': item.index,
'aria-setsize': data.virtual!.options.length,
'aria-posinset': item.index + 1,
style: {
position: 'absolute',
top: 0,
left: 0,
transform: `translateY(${item.start}px)`,
overflowAnchor: 'none',
},
}
)}
</Fragment>
)
})}
</div>
</VirtualContext.Provider>
)
@@ -375,11 +503,14 @@ let ComboboxDataContext = createContext<
activeOptionIndex: number | null
nullable: boolean
immediate: boolean
virtual: { options: unknown[]; disabled: (value: unknown) => boolean } | null
calculateIndex(value: unknown): number
compare(a: unknown, z: unknown): boolean
isSelected(value: unknown): boolean
__demoMode: boolean
isActive(value: unknown): boolean
virtual: boolean
__demoMode: boolean
optionsPropsRef: MutableRefObject<{
static: boolean
@@ -475,7 +606,10 @@ export type ComboboxProps<
form?: string
name?: string
immediate?: boolean
virtual?: boolean
virtual?: {
options: TValue[]
disabled?: (value: TValue) => boolean
} | null
}
function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_TAG>(
@@ -505,13 +639,13 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
onChange: controlledOnChange,
form: formName,
name,
by = (a: TValue, z: TValue) => a === z,
by = null,
disabled = false,
__demoMode = false,
nullable = false,
multiple = false,
immediate = false,
virtual = false,
virtual = null,
...theirProps
} = props
let [value = multiple ? [] : undefined, theirOnChange] = useControllable<any>(
@@ -524,6 +658,9 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
dataRef: createRef(),
comboboxState: __demoMode ? ComboboxState.Open : ComboboxState.Closed,
options: [],
virtual: virtual
? { options: virtual.options, disabled: virtual.disabled ?? (() => false) }
: null,
activeOptionIndex: null,
activationTrigger: ActivationTrigger.Other,
labelId: null,
@@ -546,18 +683,36 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
let property = by as unknown as keyof TActualValue
return a?.[property] === z?.[property]
}
: by
: by ?? ((a: TValue, z: TValue) => a === z)
)
let calculateIndex = useEvent((value: TValue) => {
if (virtual) {
if (by === null) {
return virtual.options.indexOf(value)
} else {
return virtual.options.findIndex((other) => compare(other, value))
}
} else {
// @ts-expect-error
return state.options.findIndex((other) => compare(other.dataRef.current.value, value))
}
})
let isSelected: (value: TValue) => boolean = useCallback(
(compareValue) =>
(other) =>
match(data.mode, {
[ValueMode.Multi]: () =>
(value as EnsureArray<TValue>).some((option) => compare(option, compareValue)),
[ValueMode.Single]: () => compare(value as TValue, compareValue),
(value as EnsureArray<TValue>).some((option) => compare(option, other)),
[ValueMode.Single]: () => compare(value as TValue, other),
}),
[value]
)
let isActive = useEvent((other: TValue) => {
return state.activeOptionIndex === calculateIndex(other)
})
let data = useMemo<_Data>(
() => ({
...state,
@@ -571,16 +726,26 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
defaultValue,
disabled,
mode: multiple ? ValueMode.Multi : ValueMode.Single,
virtual,
virtual: state.virtual,
get activeOptionIndex() {
if (
defaultToFirstOption.current &&
state.activeOptionIndex === null &&
state.options.length > 0
(virtual ? virtual.options.length > 0 : state.options.length > 0)
) {
let localActiveOptionIndex = state.options.findIndex(
(option) => !option.dataRef.current.disabled
)
if (virtual) {
let localActiveOptionIndex = virtual.options.findIndex(
(option) => !(virtual?.disabled?.(option) ?? false)
)
if (localActiveOptionIndex !== -1) {
return localActiveOptionIndex
}
}
let localActiveOptionIndex = state.options.findIndex((option) => {
return !option.dataRef.current.disabled
})
if (localActiveOptionIndex !== -1) {
return localActiveOptionIndex
@@ -589,24 +754,20 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
return state.activeOptionIndex
},
calculateIndex,
compare,
isSelected,
isActive,
nullable,
__demoMode,
}),
[value, defaultValue, disabled, multiple, nullable, __demoMode, state, virtual]
)
let lastActiveOption = useRef(
data.activeOptionIndex !== null ? data.options[data.activeOptionIndex] : null
)
useEffect(() => {
let currentActiveOption =
data.activeOptionIndex !== null ? data.options[data.activeOptionIndex] : null
if (lastActiveOption.current !== currentActiveOption) {
lastActiveOption.current = currentActiveOption
}
})
useIsoMorphicEffect(() => {
if (!virtual) return
dispatch({ type: ActionTypes.UpdateVirtualOptions, options: virtual.options })
}, [virtual, virtual?.options])
useIsoMorphicEffect(() => {
state.dataRef.current = data
@@ -627,28 +788,27 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
activeOption:
data.activeOptionIndex === null
? null
: (data.options[data.activeOptionIndex].dataRef.current.value as TValue),
: data.virtual
? data.virtual.options[data.activeOptionIndex ?? 0]
: (data.options[data.activeOptionIndex]?.dataRef.current.value as TValue) ?? null,
value,
}),
[data, disabled, value]
)
let selectOption = useEvent((id: string) => {
let option = data.options.find((item) => item.id === id)
if (!option) return
onChange(option.dataRef.current.value)
})
let selectActiveOption = useEvent(() => {
if (data.activeOptionIndex !== null) {
let { dataRef, id } = data.options[data.activeOptionIndex]
onChange(dataRef.current.value)
if (data.activeOptionIndex === null) return
// It could happen that the `activeOptionIndex` stored in state is actually null,
// but we are getting the fallback active option back instead.
actions.goToOption(Focus.Specific, id)
if (data.virtual) {
onChange(data.virtual.options[data.activeOptionIndex])
} else {
let { dataRef } = data.options[data.activeOptionIndex]
onChange(dataRef.current.value)
}
// It could happen that the `activeOptionIndex` stored in state is actually null, but we are
// getting the fallback active option back instead.
actions.goToOption(Focus.Specific, data.activeOptionIndex)
})
let openCombobox = useEvent(() => {
@@ -661,18 +821,18 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
defaultToFirstOption.current = false
})
let goToOption = useEvent((focus, id, trigger) => {
let goToOption = useEvent((focus, idx, trigger) => {
defaultToFirstOption.current = false
if (focus === Focus.Specific) {
return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id: id!, trigger })
return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, idx: idx!, trigger })
}
return dispatch({ type: ActionTypes.GoToOption, focus, trigger })
})
let registerOption = useEvent((id, dataRef) => {
dispatch({ type: ActionTypes.RegisterOption, id, dataRef })
dispatch({ type: ActionTypes.RegisterOption, payload: { id, dataRef } })
return () => {
// When we are unregistering the currently active option, then we also have to make sure to
// reset the `defaultToFirstOption` flag, so that visually something is selected and the next
@@ -683,7 +843,7 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
// the very first option seems like a fine default. We _could_ be smarter about this by going
// to the previous / next item in list if we know the direction of the keyboard navigation,
// but that might be too complex/confusing from an end users perspective.
if (lastActiveOption.current?.id === id) {
if (data.isActive(dataRef.current.value)) {
defaultToFirstOption.current = true
}
@@ -730,7 +890,6 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
openCombobox,
setActivationTrigger,
selectActiveOption,
selectOption,
}),
[]
)
@@ -1008,12 +1167,8 @@ function InputFn<
event.preventDefault()
event.stopPropagation()
return match(data.comboboxState, {
[ComboboxState.Open]: () => {
actions.goToOption(Focus.Next)
},
[ComboboxState.Closed]: () => {
actions.openCombobox()
},
[ComboboxState.Open]: () => actions.goToOption(Focus.Next),
[ComboboxState.Closed]: () => actions.openCombobox(),
})
case Keys.ArrowUp:
@@ -1021,9 +1176,7 @@ function InputFn<
event.preventDefault()
event.stopPropagation()
return match(data.comboboxState, {
[ComboboxState.Open]: () => {
actions.goToOption(Focus.Previous)
},
[ComboboxState.Open]: () => actions.goToOption(Focus.Previous),
[ComboboxState.Closed]: () => {
actions.openCombobox()
d.nextFrame(() => {
@@ -1199,7 +1352,18 @@ function InputFn<
'aria-controls': data.optionsRef.current?.id,
'aria-expanded': data.comboboxState === ComboboxState.Open,
'aria-activedescendant':
data.activeOptionIndex === null ? undefined : data.options[data.activeOptionIndex]?.id,
data.activeOptionIndex === null
? undefined
: data.virtual
? data.options.find(
(option) =>
!data.virtual?.disabled(option.dataRef.current.value) &&
data.compare(
option.dataRef.current.value,
data.virtual!.options[data.activeOptionIndex!]
)
)?.id
: data.options[data.activeOptionIndex]?.id,
'aria-labelledby': labelledby,
'aria-autocomplete': 'list',
defaultValue:
@@ -1392,6 +1556,7 @@ function LabelFn<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
let DEFAULT_OPTIONS_TAG = 'ul' as const
interface OptionsRenderPropArg {
open: boolean
option: unknown
}
type OptionsPropsWeControl = 'aria-labelledby' | 'aria-multiselectable' | 'role' | 'tabIndex'
@@ -1451,7 +1616,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
)
let slot = useMemo<OptionsRenderPropArg>(
() => ({ open: data.comboboxState === ComboboxState.Open }),
() => ({ open: data.comboboxState === ComboboxState.Open, option: undefined }),
[data]
)
let ourProps = {
@@ -1465,6 +1630,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
// Map the children in a scrollable container when virtualization is enabled
if (data.virtual && data.comboboxState === ComboboxState.Open) {
Object.assign(theirProps, {
// @ts-expect-error The `children` prop now is a callback function that receives `{ option }`.
children: <VirtualProvider>{theirProps.children}</VirtualProvider>,
})
}
@@ -1519,25 +1685,22 @@ function OptionFn<
let data = useData('Combobox.Option')
let actions = useActions('Combobox.Option')
let active =
data.activeOptionIndex !== null ? data.options[data.activeOptionIndex].id === id : false
let active = data.virtual
? data.activeOptionIndex === data.calculateIndex(value)
: data.activeOptionIndex === null
? false
: data.options[data.activeOptionIndex]?.id === id
if (order === null && data.virtual) {
throw new Error(
`The \`order\` prop on <Combobox.Option /> is required when using <Combobox virtual />.`
)
}
let [, rerender] = useReducer((v) => !v, true)
let selected = data.isSelected(value)
let internalOptionRef = useRef<HTMLLIElement | null>(null)
let bag = useLatestValue<ComboboxOptionDataRef<TType>['current']>({
disabled,
value,
domRef: internalOptionRef,
order,
onVirtualRangeUpdate: rerender,
})
let virtualizer = useContext(VirtualContext)
let optionRef = useSyncRefs(
ref,
@@ -1545,7 +1708,7 @@ function OptionFn<
virtualizer ? virtualizer.measureElement : null
)
let select = useEvent(() => actions.selectOption(id))
let select = useEvent(() => actions.onChange(value))
useIsoMorphicEffect(() => actions.registerOption(id, bag), [bag, id])
let enableScrollIntoView = useRef(data.virtual || data.__demoMode ? false : true)
@@ -1578,7 +1741,7 @@ function OptionFn<
])
let handleClick = useEvent((event: { preventDefault: Function }) => {
if (disabled) return event.preventDefault()
if (disabled || data.virtual?.disabled(value)) return event.preventDefault()
select()
// We want to make sure that we don't accidentally trigger the virtual keyboard.
@@ -1603,8 +1766,11 @@ function OptionFn<
})
let handleFocus = useEvent(() => {
if (disabled) return actions.goToOption(Focus.Nothing)
actions.goToOption(Focus.Specific, id)
if (disabled || data.virtual?.disabled(value)) {
return actions.goToOption(Focus.Nothing)
}
let idx = data.calculateIndex(value)
actions.goToOption(Focus.Specific, idx)
})
let pointer = useTrackedPointer()
@@ -1613,14 +1779,15 @@ function OptionFn<
let handleMove = useEvent((evt) => {
if (!pointer.wasMoved(evt)) return
if (disabled) return
if (disabled || data.virtual?.disabled(value)) return
if (active) return
actions.goToOption(Focus.Specific, id, ActivationTrigger.Pointer)
let idx = data.calculateIndex(value)
actions.goToOption(Focus.Specific, idx, ActivationTrigger.Pointer)
})
let handleLeave = useEvent((evt) => {
if (!pointer.wasMoved(evt)) return
if (disabled) return
if (disabled || data.virtual?.disabled(value)) return
if (!active) return
if (data.optionsPropsRef.current.hold) return
actions.goToOption(Focus.Nothing)
@@ -1631,43 +1798,6 @@ function OptionFn<
[active, selected, disabled]
)
let virtualIdx = useMemo(() => {
if (!data.virtual) return -1
return data.options.findIndex((o) => o.id === id) ?? 0
}, [virtualizer, data.options, id])
let virtualItem =
virtualIdx === -1
? undefined
: (virtualizer?.getVirtualItems() ?? []).find((item) => item.index === virtualIdx)
let d = useDisposables()
let shouldScroll =
virtualizer && data.activationTrigger !== ActivationTrigger.Pointer && data.virtual && active
useEffect(() => {
if (!shouldScroll) return
// Try scrolling to the item
virtualizer!.scrollToIndex(virtualIdx)
// Ensure we scrolled to the correct location
;(function ensureScrolledCorrectly() {
if (virtualizer?.isScrolling) {
d.requestAnimationFrame(ensureScrolledCorrectly)
return
}
virtualizer!.scrollToIndex(virtualIdx)
})()
return d.dispose
}, [active, virtualizer, virtualIdx, shouldScroll])
if (data.virtual && !virtualItem) {
return null
}
let ourProps = {
id,
ref: optionRef,
@@ -1678,9 +1808,6 @@ function OptionFn<
// multi-select,but Voice-Over disagrees. So we use aria-checked instead for
// both single and multi-select.
'aria-selected': selected,
'data-index': virtualizer && virtualIdx !== -1 ? virtualIdx : undefined,
'aria-setsize': virtualizer ? data.options.length : undefined,
'aria-posinset': virtualizer && virtualIdx !== -1 ? virtualIdx + 1 : undefined,
disabled: undefined, // Never forward the `disabled` prop
onClick: handleClick,
onFocus: handleFocus,
@@ -1692,21 +1819,6 @@ function OptionFn<
onMouseLeave: handleLeave,
}
if (virtualItem) {
let localOurProps = ourProps as typeof ourProps & { style: CSSProperties }
localOurProps.style = {
...localOurProps.style,
position: 'absolute',
top: 0,
left: 0,
transform: `translateY(${virtualItem.start}px)`,
}
// Technically unnecessary
ourProps = localOurProps
}
return render({
ourProps,
theirProps,
@@ -27,8 +27,8 @@ export function calculateActiveIndex<TItem>(
resolvers: {
resolveItems(): TItem[]
resolveActiveIndex(): number | null
resolveId(item: TItem): string
resolveDisabled(item: TItem): boolean
resolveId(item: TItem, index: number, items: TItem[]): string
resolveDisabled(item: TItem, index: number, items: TItem[]): boolean
}
) {
let items = resolvers.resolveItems()
@@ -37,48 +37,56 @@ export function calculateActiveIndex<TItem>(
let currentActiveIndex = resolvers.resolveActiveIndex()
let activeIndex = currentActiveIndex ?? -1
let nextActiveIndex = (() => {
switch (action.focus) {
case Focus.First:
return items.findIndex((item) => !resolvers.resolveDisabled(item))
case Focus.Previous: {
let idx = items
.slice()
.reverse()
.findIndex((item, idx, all) => {
if (activeIndex !== -1 && all.length - idx - 1 >= activeIndex) return false
return !resolvers.resolveDisabled(item)
})
if (idx === -1) return idx
return items.length - 1 - idx
switch (action.focus) {
case Focus.First: {
for (let i = 0; i < items.length; ++i) {
if (!resolvers.resolveDisabled(items[i], i, items)) {
return i
}
}
case Focus.Next:
return items.findIndex((item, idx) => {
if (idx <= activeIndex) return false
return !resolvers.resolveDisabled(item)
})
case Focus.Last: {
let idx = items
.slice()
.reverse()
.findIndex((item) => !resolvers.resolveDisabled(item))
if (idx === -1) return idx
return items.length - 1 - idx
}
case Focus.Specific:
return items.findIndex((item) => resolvers.resolveId(item) === action.id)
case Focus.Nothing:
return null
default:
assertNever(action)
return currentActiveIndex
}
})()
return nextActiveIndex === -1 ? currentActiveIndex : nextActiveIndex
case Focus.Previous: {
for (let i = activeIndex - 1; i >= 0; --i) {
if (!resolvers.resolveDisabled(items[i], i, items)) {
return i
}
}
return currentActiveIndex
}
case Focus.Next: {
for (let i = activeIndex + 1; i < items.length; ++i) {
if (!resolvers.resolveDisabled(items[i], i, items)) {
return i
}
}
return currentActiveIndex
}
case Focus.Last: {
for (let i = items.length - 1; i >= 0; --i) {
if (!resolvers.resolveDisabled(items[i], i, items)) {
return i
}
}
return currentActiveIndex
}
case Focus.Specific: {
for (let i = 0; i < items.length; ++i) {
if (resolvers.resolveId(items[i], i, items) === action.id) {
return i
}
}
return currentActiveIndex
}
case Focus.Nothing:
return null
default:
assertNever(action)
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Add `immediate` prop to `<Combobox />` for immediately opening the Combobox when the `input` receives focus ([#2686](https://github.com/tailwindlabs/headlessui/pull/2686))
- Add `virtual` prop to `Combobox` component ([#2740](https://github.com/tailwindlabs/headlessui/pull/2740))
- Add `virtual` prop to `Combobox` component ([#2779](https://github.com/tailwindlabs/headlessui/pull/2779))
## [1.7.16] - 2023-08-17
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,7 @@
import type { Virtualizer } from '@tanstack/virtual-core'
import { useVirtualizer } from '@tanstack/vue-virtual'
import {
cloneVNode,
computed,
defineComponent,
Fragment,
@@ -12,18 +13,14 @@ import {
provide,
reactive,
ref,
shallowRef,
toRaw,
watch,
watchEffect,
watchPostEffect,
type ComputedRef,
type CSSProperties,
type InjectionKey,
type PropType,
type Ref,
type UnwrapNestedRefs,
type UnwrapRef,
} from 'vue'
import { useControllable } from '../../hooks/use-controllable'
import { useId } from '../../hooks/use-id'
@@ -70,7 +67,6 @@ type ComboboxOptionData = {
value: unknown
domRef: Ref<HTMLElement | null>
order: Ref<number | null>
onVirtualRangeUpdate: (virtualizer: Virtualizer<any, any>) => void
}
type StateDefinition = {
// State
@@ -81,7 +77,14 @@ type StateDefinition = {
mode: ComputedRef<ValueMode>
nullable: ComputedRef<boolean>
immediate: ComputedRef<boolean>
virtual: ComputedRef<boolean>
virtual: ComputedRef<{
options: unknown[]
disabled: (value: unknown) => boolean
} | null>
calculateIndex(value: unknown): number
isSelected(value: unknown): boolean
isActive(value: unknown): boolean
compare: (a: unknown, z: unknown) => boolean
@@ -94,7 +97,6 @@ type StateDefinition = {
disabled: Ref<boolean>
options: Ref<{ id: string; dataRef: ComputedRef<ComboboxOptionData> }[]>
indexes: Ref<Record<string, number>>
activeOptionIndex: Ref<number | null>
activationTrigger: Ref<ActivationTrigger>
@@ -102,12 +104,12 @@ type StateDefinition = {
closeCombobox(): void
openCombobox(): void
setActivationTrigger(trigger: ActivationTrigger): void
goToOption(focus: Focus, id?: string, trigger?: ActivationTrigger): void
goToOption(focus: Focus, idx?: number, trigger?: ActivationTrigger): void
change(value: unknown): void
selectOption(id: string): void
selectActiveOption(): void
registerOption(id: string, dataRef: ComputedRef<ComboboxOptionData>): void
unregisterOption(id: string): void
unregisterOption(id: string, active: boolean): void
select(value: unknown): void
}
@@ -134,14 +136,6 @@ let VirtualProvider = defineComponent({
setup(_, { slots }) {
let api = useComboboxContext('VirtualProvider')
let measuredHeight = computed(() => {
let firstAvailableOption = api.options.value.find(
(option) => dom(option.dataRef.value.domRef) !== null
)
let height = dom(firstAvailableOption?.dataRef.value.domRef)?.getBoundingClientRect().height
return height ?? 40
})
let padding = computed(() => {
let el = dom(api.optionsRef)
if (!el) return { start: 0, end: 0 }
@@ -159,45 +153,86 @@ let VirtualProvider = defineComponent({
return {
scrollPaddingStart: padding.value.start,
scrollPaddingEnd: padding.value.end,
count: api.options.value.length,
count: api.virtual.value!.options.length,
estimateSize() {
return measuredHeight.value
return 40
},
getScrollElement() {
return dom(api.optionsRef)
},
overscan: 12,
onChange(event) {
let list = event.getVirtualItems()
if (list.length === 0) return
let min = list[0].index
let max = list[list.length - 1].index + 1
for (let option of api.options.value.slice(min, max)) {
let dataRef = option.dataRef as unknown as UnwrapRef<typeof option.dataRef>
dataRef.onVirtualRangeUpdate(event)
}
},
}
})
)
let options = computed(() => api.virtual.value?.options)
let baseKey = ref(0)
watch([options], () => {
baseKey.value += 1
})
provide(VirtualContext, api.virtual.value ? virtualizer : null)
return () => [
h(
'div',
{
style: {
position: 'relative',
width: '100%',
height: `${virtualizer.value.getTotalSize()}px`,
return () => {
return [
h(
'div',
{
style: {
position: 'relative',
width: '100%',
height: `${virtualizer.value.getTotalSize()}px`,
},
ref: (el) => {
if (!el) {
return
}
// Scroll to the active index
{
// Ignore this when we are in a test environment
if (typeof process !== 'undefined' && process.env.JEST_WORKER_ID !== undefined) {
return
}
// Do not scroll when the mouse/pointer is being used
if (api.activationTrigger.value === ActivationTrigger.Pointer) {
return
}
if (
api.activeOptionIndex.value !== null &&
api.virtual.value!.options.length > api.activeOptionIndex.value
) {
virtualizer.value.scrollToIndex(api.activeOptionIndex.value)
}
}
},
},
},
slots.default?.()
),
]
virtualizer.value.getVirtualItems().map((item) => {
return cloneVNode(
slots.default!({
option: api.virtual.value!.options[item.index],
open: api.comboboxState.value === ComboboxStates.Open,
})![0],
{
key: `${baseKey.value}-${item.index}`,
'data-index': item.index,
'aria-setsize': api.virtual.value!.options.length,
'aria-posinset': item.index + 1,
style: {
position: 'absolute',
top: 0,
left: 0,
transform: `translateY(${item.start}px)`,
overflowAnchor: 'none',
},
}
)
})
),
]
}
},
})
@@ -209,7 +244,7 @@ export let Combobox = defineComponent({
props: {
as: { type: [Object, String], default: 'template' },
disabled: { type: [Boolean], default: false },
by: { type: [String, Function], default: () => defaultComparator },
by: { type: [String, Function], nullable: true, default: null },
modelValue: {
type: [Object, String, Number, Boolean] as PropType<
object | string | number | boolean | null
@@ -227,7 +262,13 @@ export let Combobox = defineComponent({
nullable: { type: Boolean, default: false },
multiple: { type: [Boolean], default: false },
immediate: { type: [Boolean], default: false },
virtual: { type: [Boolean], default: false },
virtual: {
type: Object as PropType<null | {
options: unknown[]
disabled?: (value: unknown) => boolean
}>,
default: null,
},
},
inheritAttrs: false,
setup(props, { slots, attrs, emit }) {
@@ -243,19 +284,12 @@ export let Combobox = defineComponent({
hold: false,
}) as StateDefinition['optionsPropsRef']
let options = ref<StateDefinition['options']['value']>([])
let indexes = shallowRef<Record<string, number>>({})
let activeOptionIndex = ref<StateDefinition['activeOptionIndex']['value']>(null)
let activationTrigger = ref<StateDefinition['activationTrigger']['value']>(
ActivationTrigger.Other
)
let defaultToFirstOption = ref(false)
// This is not a "computed" ref because we eventually
// want to calculate this only when the length or order can actually change
function recalculateIndexes() {
indexes.value = Object.fromEntries(options.value.map((v, idx) => [v.id, idx]))
}
function adjustOrderedState(
adjustment: (
options: UnwrapNestedRefs<StateDefinition['options']['value']>
@@ -310,7 +344,44 @@ export let Combobox = defineComponent({
let goToOptionRaf: ReturnType<typeof requestAnimationFrame> | null = null
let orderOptionsRaf: ReturnType<typeof requestAnimationFrame> | null = null
let api = {
function onChange(value: unknown) {
return match(mode.value, {
[ValueMode.Single]() {
return theirOnChange?.(value)
},
[ValueMode.Multi]: () => {
let copy = toRaw(api.value.value as unknown[]).slice()
let raw = toRaw(value)
let idx = copy.findIndex((value) => api.compare(raw, toRaw(value)))
if (idx === -1) {
copy.push(raw)
} else {
copy.splice(idx, 1)
}
return theirOnChange?.(copy)
},
})
}
let virtualOptions = computed(() => props.virtual?.options)
watch([virtualOptions], ([newOptions], [oldOptions]) => {
if (!api.virtual.value) return
if (!newOptions) return
if (!oldOptions) return
if (activeOptionIndex.value !== null) {
let idx = newOptions.indexOf(oldOptions[activeOptionIndex.value])
if (idx !== -1) {
activeOptionIndex.value = idx
} else {
activeOptionIndex.value = null
}
}
})
let api: StateDefinition = {
comboboxState,
value,
mode,
@@ -319,19 +390,42 @@ export let Combobox = defineComponent({
let property = props.by as unknown as any
return a?.[property] === z?.[property]
}
if (props.by === null) {
return defaultComparator(a, z)
}
return props.by(a, z)
},
calculateIndex(value: any) {
if (api.virtual.value) {
if (props.by === null) {
return api.virtual.value!.options.indexOf(value)
} else {
return api.virtual.value!.options.findIndex((other) => api.compare(other, value))
}
} else {
return options.value.findIndex((other) => api.compare(other.dataRef.value, value))
}
},
defaultValue: computed(() => props.defaultValue),
nullable,
immediate: computed(() => props.immediate),
virtual: computed(() => props.virtual),
virtual: computed(() => {
return props.virtual
? {
options: props.virtual.options,
disabled: props.virtual.disabled ?? (() => false),
}
: null
}),
inputRef,
labelRef,
buttonRef,
optionsRef,
disabled: computed(() => props.disabled),
// @ts-expect-error dateRef types are incorrect due to unwrapped or wrapped refs
options,
indexes,
change(value: unknown) {
theirOnChange(value as typeof props.modelValue)
},
@@ -339,8 +433,18 @@ export let Combobox = defineComponent({
if (
defaultToFirstOption.value &&
activeOptionIndex.value === null &&
options.value.length > 0
(api.virtual.value ? api.virtual.value.options.length > 0 : options.value.length > 0)
) {
if (api.virtual.value) {
let localActiveOptionIndex = api.virtual.value.options.findIndex(
(option) => !api.virtual.value?.disabled(option)
)
if (localActiveOptionIndex !== -1) {
return localActiveOptionIndex
}
}
let localActiveOptionIndex = options.value.findIndex((option) => !option.dataRef.disabled)
if (localActiveOptionIndex !== -1) {
return localActiveOptionIndex
@@ -365,22 +469,12 @@ export let Combobox = defineComponent({
if (props.disabled) return
if (comboboxState.value === ComboboxStates.Open) return
// Check if we have a selected value that we can make active.
let optionIdx = options.value.findIndex((option) => {
let optionValue = toRaw(option.dataRef.value)
let selected = match(mode.value, {
[ValueMode.Single]: () => api.compare(toRaw(api.value.value), toRaw(optionValue)),
[ValueMode.Multi]: () =>
(toRaw(api.value.value) as unknown[]).some((value) =>
api.compare(toRaw(value), toRaw(optionValue))
),
})
return selected
})
if (optionIdx !== -1) {
activeOptionIndex.value = optionIdx
// Check if we have a selected value that we can make active
if (api.value.value) {
let idx = api.calculateIndex(api.value.value)
if (idx !== -1) {
activeOptionIndex.value = idx
}
}
comboboxState.value = ComboboxStates.Open
@@ -388,7 +482,7 @@ export let Combobox = defineComponent({
setActivationTrigger(trigger: ActivationTrigger) {
activationTrigger.value = trigger
},
goToOption(focus: Focus, id?: string, trigger?: ActivationTrigger) {
goToOption(focus: Focus, idx?: number, trigger?: ActivationTrigger) {
defaultToFirstOption.value = false
if (goToOptionRaf !== null) {
@@ -405,6 +499,33 @@ export let Combobox = defineComponent({
return
}
if (api.virtual.value) {
activeOptionIndex.value =
focus === Focus.Specific
? idx!
: calculateActiveIndex(
{ focus: focus as Exclude<Focus, Focus.Specific> },
{
resolveItems: () => api.virtual.value!.options,
resolveActiveIndex: () => {
return (
api.activeOptionIndex.value ??
api.virtual.value!.options.findIndex(
(option) => !api.virtual.value?.disabled(option)
) ??
null
)
},
resolveDisabled: (item) => api.virtual.value!.disabled(item),
resolveId() {
throw new Error('Function not implemented.')
},
}
)
activationTrigger.value = trigger ?? ActivationTrigger.Other
return
}
let adjustedState = adjustOrderedState()
// It's possible that the activeOptionIndex is set to `null` internally, but
@@ -420,22 +541,22 @@ export let Combobox = defineComponent({
}
}
let nextActiveOptionIndex = calculateActiveIndex(
let nextActiveOptionIndex =
focus === Focus.Specific
? { focus: Focus.Specific, id: id! }
: { focus: focus as Exclude<Focus, Focus.Specific> },
{
resolveItems: () => adjustedState.options,
resolveActiveIndex: () => adjustedState.activeOptionIndex,
resolveId: (option) => option.id,
resolveDisabled: (option) => option.dataRef.disabled,
}
)
? idx!
: calculateActiveIndex(
{ focus: focus as Exclude<Focus, Focus.Specific> },
{
resolveItems: () => adjustedState.options,
resolveActiveIndex: () => adjustedState.activeOptionIndex,
resolveId: (option) => option.id,
resolveDisabled: (option) => option.dataRef.disabled,
}
)
activeOptionIndex.value = nextActiveOptionIndex
activationTrigger.value = trigger ?? ActivationTrigger.Other
options.value = adjustedState.options
recalculateIndexes()
})
},
selectOption(id: string) {
@@ -443,77 +564,44 @@ export let Combobox = defineComponent({
if (!option) return
let { dataRef } = option
theirOnChange(
match(mode.value, {
[ValueMode.Single]: () => dataRef.value,
[ValueMode.Multi]: () => {
let copy = toRaw(api.value.value as unknown[]).slice()
let raw = toRaw(dataRef.value)
let idx = copy.findIndex((value) => api.compare(raw, toRaw(value)))
if (idx === -1) {
copy.push(raw)
} else {
copy.splice(idx, 1)
}
return copy
},
})
)
onChange(dataRef.value)
},
selectActiveOption() {
if (api.activeOptionIndex.value === null) return
let { dataRef, id } = options.value[api.activeOptionIndex.value]
theirOnChange(
match(mode.value, {
[ValueMode.Single]: () => dataRef.value,
[ValueMode.Multi]: () => {
let copy = toRaw(api.value.value as unknown[]).slice()
let raw = toRaw(dataRef.value)
let idx = copy.findIndex((value) => api.compare(raw, toRaw(value)))
if (idx === -1) {
copy.push(raw)
} else {
copy.splice(idx, 1)
}
return copy
},
})
)
if (api.virtual.value) {
onChange(api.virtual.value.options[api.activeOptionIndex.value])
} else {
let { dataRef } = options.value[api.activeOptionIndex.value]
onChange(dataRef.value)
}
// It could happen that the `activeOptionIndex` stored in state is actually null,
// but we are getting the fallback active option back instead.
api.goToOption(Focus.Specific, id)
api.goToOption(Focus.Specific, api.activeOptionIndex.value)
},
registerOption(id: string, dataRef: ComboboxOptionData) {
if (orderOptionsRaf) cancelAnimationFrame(orderOptionsRaf)
registerOption(id: string, dataRef: ComputedRef<ComboboxOptionData>) {
let option = reactive({ id, dataRef }) as unknown as {
id: typeof id
dataRef: typeof dataRef
dataRef: typeof dataRef['value']
}
if (api.virtual.value) {
options.value.push(option)
return
}
if (orderOptionsRaf) cancelAnimationFrame(orderOptionsRaf)
let adjustedState = adjustOrderedState((options) => {
options.push(option)
return options
})
// Check if we have a selected value that we can make active.
// Check if we need to make the newly registered option active.
if (activeOptionIndex.value === null) {
let optionValue = (dataRef.value as any).value
let selected = match(mode.value, {
[ValueMode.Single]: () => api.compare(toRaw(api.value.value), toRaw(optionValue)),
[ValueMode.Multi]: () =>
(toRaw(api.value.value) as unknown[]).some((value) =>
api.compare(toRaw(value), toRaw(optionValue))
),
})
if (selected) {
if (api.isSelected(dataRef.value.value)) {
adjustedState.activeOptionIndex = adjustedState.options.indexOf(option)
}
}
@@ -521,7 +609,6 @@ export let Combobox = defineComponent({
options.value = adjustedState.options
activeOptionIndex.value = adjustedState.activeOptionIndex
activationTrigger.value = ActivationTrigger.Other
recalculateIndexes()
// If some of the DOM elements aren't ready yet, then we can retry in the next tick.
if (adjustedState.options.some((option) => !dom(option.dataRef.domRef))) {
@@ -530,11 +617,14 @@ export let Combobox = defineComponent({
options.value = adjustedState.options
activeOptionIndex.value = adjustedState.activeOptionIndex
recalculateIndexes()
})
}
},
unregisterOption(id: string) {
unregisterOption(id: string, active: boolean) {
if (goToOptionRaf !== null) {
cancelAnimationFrame(goToOptionRaf)
}
// When we are unregistering the currently active option, then we also have to make sure to
// reset the `defaultToFirstOption` flag, so that visually something is selected and the
// next time you press a key on your keyboard it will go to the proper next or previous
@@ -544,15 +634,17 @@ export let Combobox = defineComponent({
// to the very first option seems like a fine default. We _could_ be smarter about this by
// going to the previous / next item in list if we know the direction of the keyboard
// navigation, but that might be too complex/confusing from an end users perspective.
if (
api.activeOptionIndex.value !== null &&
api.options.value[api.activeOptionIndex.value]?.id === id
) {
if (active) {
defaultToFirstOption.value = true
}
if (api.virtual.value) {
options.value = options.value.filter((option) => option.id !== id)
return
}
let adjustedState = adjustOrderedState((options) => {
let idx = options.findIndex((a) => a.id === id)
let idx = options.findIndex((option) => option.id === id)
if (idx !== -1) options.splice(idx, 1)
return options
})
@@ -560,7 +652,18 @@ export let Combobox = defineComponent({
options.value = adjustedState.options
activeOptionIndex.value = adjustedState.activeOptionIndex
activationTrigger.value = ActivationTrigger.Other
recalculateIndexes()
},
isSelected(other) {
return match(mode.value, {
[ValueMode.Single]: () => api.compare(toRaw(api.value.value), toRaw(other)),
[ValueMode.Multi]: () =>
(toRaw(api.value.value) as unknown[]).some((option) =>
api.compare(toRaw(option), toRaw(other))
),
})
},
isActive(other) {
return activeOptionIndex.value === api.calculateIndex(other)
},
}
@@ -571,8 +674,8 @@ export let Combobox = defineComponent({
computed(() => comboboxState.value === ComboboxStates.Open)
)
// @ts-expect-error Types of property 'dataRef' are incompatible.
provide(ComboboxContext, api)
useOpenClosedProvider(
computed(() =>
match(comboboxState.value, {
@@ -582,12 +685,6 @@ export let Combobox = defineComponent({
)
)
let activeOption = computed(() =>
api.activeOptionIndex.value === null
? null
: (options.value[api.activeOptionIndex.value].dataRef.value as any)
)
let form = computed(() => dom(inputRef)?.closest('form'))
onMounted(() => {
watch(
@@ -616,7 +713,12 @@ export let Combobox = defineComponent({
open: comboboxState.value === ComboboxStates.Open,
disabled,
activeIndex: api.activeOptionIndex.value,
activeOption: activeOption.value,
activeOption:
api.activeOptionIndex.value === null
? null
: api.virtual.value
? api.virtual.value.options[api.activeOptionIndex.value ?? 0]
: api.options.value[api.activeOptionIndex.value]?.dataRef.value.value ?? null,
value: value.value,
}
@@ -1182,6 +1284,16 @@ export let ComboboxInput = defineComponent({
'aria-activedescendant':
api.activeOptionIndex.value === null
? undefined
: api.virtual.value
? api.options.value.find((option) => {
return (
!api.virtual.value!.disabled(option.dataRef.value) &&
api.compare(
option.dataRef.value,
api.virtual.value!.options[api.activeOptionIndex.value!]
)
)
})?.id
: api.options.value[api.activeOptionIndex.value]?.id,
'aria-labelledby': dom(api.labelRef)?.id ?? dom(api.buttonRef)?.id,
'aria-autocomplete': 'list',
@@ -1309,29 +1421,15 @@ export let ComboboxOption = defineComponent({
expose({ el: internalOptionRef, $el: internalOptionRef })
watchEffect(() => {
if (props.order === null && api.virtual.value) {
throw new Error(
`The \`order\` prop on <ComboboxOption /> is required when using <Combobox virtual />.`
)
}
})
let active = computed(() => {
return api.activeOptionIndex.value !== null
? api.options.value[api.activeOptionIndex.value].id === id
: false
return api.virtual.value
? api.activeOptionIndex.value === api.calculateIndex(props.value)
: api.activeOptionIndex.value === null
? false
: api.options.value[api.activeOptionIndex.value]?.id === id
})
let selected = computed(() =>
match(api.mode.value, {
[ValueMode.Single]: () => api.compare(toRaw(api.value.value), toRaw(props.value)),
[ValueMode.Multi]: () =>
(toRaw(api.value.value) as unknown[]).some((value) =>
api.compare(toRaw(value), toRaw(props.value))
),
})
)
let selected = computed(() => api.isSelected(props.value))
let virtualizer = inject(VirtualContext, null)
let dataRef = computed<ComboboxOptionData>(() => ({
@@ -1339,11 +1437,10 @@ export let ComboboxOption = defineComponent({
value: props.value,
domRef: internalOptionRef,
order: computed(() => props.order),
onVirtualRangeUpdate: () => {},
}))
onMounted(() => api.registerOption(id, dataRef))
onUnmounted(() => api.unregisterOption(id))
onUnmounted(() => api.unregisterOption(id, active.value))
watchEffect(() => {
let el = dom(internalOptionRef)
@@ -1361,7 +1458,7 @@ export let ComboboxOption = defineComponent({
})
function handleClick(event: MouseEvent) {
if (props.disabled) return event.preventDefault()
if (props.disabled || api.virtual.value?.disabled(props.value)) return event.preventDefault()
api.selectOption(id)
// We want to make sure that we don't accidentally trigger the virtual keyboard.
@@ -1386,8 +1483,11 @@ export let ComboboxOption = defineComponent({
}
function handleFocus() {
if (props.disabled) return api.goToOption(Focus.Nothing)
api.goToOption(Focus.Specific, id)
if (props.disabled || api.virtual.value?.disabled(props.value)) {
return api.goToOption(Focus.Nothing)
}
let idx = api.calculateIndex(props.value)
api.goToOption(Focus.Specific, idx)
}
let pointer = useTrackedPointer()
@@ -1398,66 +1498,21 @@ export let ComboboxOption = defineComponent({
function handleMove(evt: PointerEvent) {
if (!pointer.wasMoved(evt)) return
if (props.disabled) return
if (props.disabled || api.virtual.value?.disabled(props.value)) return
if (active.value) return
api.goToOption(Focus.Specific, id, ActivationTrigger.Pointer)
let idx = api.calculateIndex(props.value)
api.goToOption(Focus.Specific, idx, ActivationTrigger.Pointer)
}
function handleLeave(evt: PointerEvent) {
if (!pointer.wasMoved(evt)) return
if (props.disabled) return
if (props.disabled || api.virtual.value?.disabled(props.value)) return
if (!active.value) return
if (api.optionsPropsRef.value.hold) return
api.goToOption(Focus.Nothing)
}
let virtualIdx = computed(() => {
if (!api.virtual.value) return -1
return api.indexes.value[id] ?? 0
})
let virtualItem = computed(() => {
return virtualIdx.value === -1
? undefined
: virtualizer?.value.getVirtualItems().find((item) => item.index === virtualIdx.value)
})
let d = disposables()
onUnmounted(() => d.dispose())
let shouldScroll = computed(() => {
return (
virtualizer?.value &&
api.activationTrigger.value !== ActivationTrigger.Pointer &&
api.virtual.value &&
active.value
)
})
watchPostEffect((onCleanup) => {
if (!shouldScroll.value) return
// Try scrolling to the item
virtualizer!.value.scrollToIndex(virtualIdx.value)
// Ensure we scrolled to the correct location
;(function ensureScrolledCorrectly() {
if (virtualizer?.value.isScrolling) {
d.requestAnimationFrame(ensureScrolledCorrectly)
return
}
virtualizer!.value.scrollToIndex(virtualIdx.value)
})()
onCleanup(d.dispose)
})
return () => {
if (api.virtual.value && !virtualItem.value) {
return null
}
let { disabled } = props
let slot = { active: active.value, selected: selected.value, disabled }
let ourProps = {
@@ -1470,9 +1525,6 @@ export let ComboboxOption = defineComponent({
// multi-select,but Voice-Over disagrees. So we use aria-selected instead for
// both single and multi-select.
'aria-selected': selected.value,
'data-index': virtualizer && virtualIdx.value !== -1 ? virtualIdx.value : undefined,
'aria-setsize': virtualizer ? api.options.value.length : undefined,
'aria-posinset': virtualizer && virtualIdx.value !== -1 ? virtualIdx.value + 1 : undefined,
disabled: undefined, // Never forward the `disabled` prop
onClick: handleClick,
onFocus: handleFocus,
@@ -1484,22 +1536,7 @@ export let ComboboxOption = defineComponent({
onMouseleave: handleLeave,
}
if (virtualItem.value) {
let localOurProps = ourProps as typeof ourProps & { style: CSSProperties }
localOurProps.style = {
...localOurProps.style,
position: 'absolute',
top: 0,
left: 0,
transform: `translateY(${virtualItem.value!.start}px)`,
}
// Technically unnecessary
ourProps = localOurProps
}
let theirProps = omit(props, ['order'])
let theirProps = omit(props, ['order', 'value'])
return render({
ourProps,
@@ -27,8 +27,8 @@ export function calculateActiveIndex<TItem>(
resolvers: {
resolveItems(): TItem[]
resolveActiveIndex(): number | null
resolveId(item: TItem): string
resolveDisabled(item: TItem): boolean
resolveId(item: TItem, index: number, items: TItem[]): string
resolveDisabled(item: TItem, index: number, items: TItem[]): boolean
}
) {
let items = resolvers.resolveItems()
@@ -37,48 +37,56 @@ export function calculateActiveIndex<TItem>(
let currentActiveIndex = resolvers.resolveActiveIndex()
let activeIndex = currentActiveIndex ?? -1
let nextActiveIndex = (() => {
switch (action.focus) {
case Focus.First:
return items.findIndex((item) => !resolvers.resolveDisabled(item))
case Focus.Previous: {
let idx = items
.slice()
.reverse()
.findIndex((item, idx, all) => {
if (activeIndex !== -1 && all.length - idx - 1 >= activeIndex) return false
return !resolvers.resolveDisabled(item)
})
if (idx === -1) return idx
return items.length - 1 - idx
switch (action.focus) {
case Focus.First: {
for (let i = 0; i < items.length; ++i) {
if (!resolvers.resolveDisabled(items[i], i, items)) {
return i
}
}
case Focus.Next:
return items.findIndex((item, idx) => {
if (idx <= activeIndex) return false
return !resolvers.resolveDisabled(item)
})
case Focus.Last: {
let idx = items
.slice()
.reverse()
.findIndex((item) => !resolvers.resolveDisabled(item))
if (idx === -1) return idx
return items.length - 1 - idx
}
case Focus.Specific:
return items.findIndex((item) => resolvers.resolveId(item) === action.id)
case Focus.Nothing:
return null
default:
assertNever(action)
return currentActiveIndex
}
})()
return nextActiveIndex === -1 ? currentActiveIndex : nextActiveIndex
case Focus.Previous: {
for (let i = activeIndex - 1; i >= 0; --i) {
if (!resolvers.resolveDisabled(items[i], i, items)) {
return i
}
}
return currentActiveIndex
}
case Focus.Next: {
for (let i = activeIndex + 1; i < items.length; ++i) {
if (!resolvers.resolveDisabled(items[i], i, items)) {
return i
}
}
return currentActiveIndex
}
case Focus.Last: {
for (let i = items.length - 1; i >= 0; --i) {
if (!resolvers.resolveDisabled(items[i], i, items)) {
return i
}
}
return currentActiveIndex
}
case Focus.Specific: {
for (let i = 0; i < items.length; ++i) {
if (resolvers.resolveId(items[i], i, items) === action.id) {
return i
}
}
return currentActiveIndex
}
case Focus.Nothing:
return null
default:
assertNever(action)
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
module.exports = {
reactStrictMode: true,
reactStrictMode: false,
devIndicators: {
autoPrerender: false,
},
+1
View File
@@ -150,6 +150,7 @@ function MyApp({ Component, pageProps }) {
<NextLink href="/">
<Logo className="h-6" />
</NextLink>
<span className="font-bold text-white">(React)</span>
</header>
<KeyCaster />
@@ -1,27 +1,61 @@
import { Combobox } from '@headlessui/react'
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { Button } from '../../components/button'
import { timezones as allTimezones } from '../../data'
import { timezones as _allTimezones } from '../../data'
import { classNames } from '../../utils/class-names'
export default function Home() {
let [count, setCount] = useState(1_000)
let list = useMemo(() => {
console.time('Generating list')
let result = []
while (result.length < count) {
let batch = Math.floor(result.length / _allTimezones.length) + 1
result.push(`${_allTimezones[result.length % _allTimezones.length]} #${batch}`)
}
console.timeEnd('Generating list')
return result
}, [count])
return (
<div className="flex">
<Example virtual={true} initial="Europe/Brussels" />
<Example virtual={false} initial="Europe/Brussels" />
<div className="flex flex-col p-12">
<label className="mx-auto flex w-24 items-center gap-2">
<span>Items:</span>
<select
defaultValue={count}
className="mx-auto"
onChange={(e) => {
setCount(Number(e.target.value))
}}
>
<option value={100}>100</option>
<option value={1_000}>1000</option>
<option value={10_000}>10k</option>
<option value={100_000}>100k</option>
</select>
</label>
<div className="flex">
<Example data={list} virtual={true} initial="Europe/Brussels #1" />
<Example data={list} virtual={false} initial="Europe/Brussels #1" />
</div>
</div>
)
}
function Example({ virtual = true, initial }: { virtual?: boolean; initial: string }) {
let nf = new Intl.NumberFormat('en-US')
function Example({ virtual = true, data, initial }: { virtual?: boolean; data; initial: string }) {
let [query, setQuery] = useState('')
let [activeTimezone, setActiveTimezone] = useState(initial)
let timezones =
query === ''
? allTimezones
: allTimezones.filter((timezone) => timezone.toLowerCase().includes(query.toLowerCase()))
? data
: data.filter((timezone) => timezone.toLowerCase().includes(query.toLowerCase()))
return (
<div className="flex h-full w-screen justify-center bg-gray-50 p-12">
@@ -29,7 +63,7 @@ function Example({ virtual = true, initial }: { virtual?: boolean; initial: stri
<div className="py-8 font-mono text-xs">Selected timezone: {activeTimezone}</div>
<div className="space-y-1">
<Combobox
virtual={virtual}
virtual={virtual ? { options: timezones } : undefined}
value={activeTimezone}
nullable
onChange={(value) => {
@@ -39,7 +73,10 @@ function Example({ virtual = true, initial }: { virtual?: boolean; initial: stri
as="div"
>
<Combobox.Label className="block text-sm font-medium leading-5 text-gray-700">
Timezone {virtual ? `(virtual)` : ''}
Timezone{' '}
{virtual
? `(virtual — ${nf.format(timezones.length)} items)`
: `(${nf.format(timezones.length)} items)`}
</Combobox.Label>
<div className="relative">
@@ -68,52 +105,106 @@ function Example({ virtual = true, initial }: { virtual?: boolean; initial: stri
</span>
<div className="absolute mt-1 w-full rounded-md bg-white shadow-lg">
<Combobox.Options className="shadow-xs max-h-60 overflow-auto rounded-md py-1 text-base leading-6 focus:outline-none sm:text-sm sm:leading-5">
{timezones.map((timezone, idx) => {
return (
<Combobox.Option
key={timezone}
order={virtual ? idx : undefined}
value={timezone}
className={({ active }) => {
return classNames(
'relative w-full cursor-default select-none py-2 pl-3 pr-9 focus:outline-none',
active ? 'bg-indigo-600 text-white' : 'text-gray-900'
)
}}
>
{({ active, selected }) => (
<>
<span
className={classNames(
'block truncate',
selected ? 'font-semibold' : 'font-normal'
)}
>
{timezone}
</span>
{selected && (
{virtual ? (
<Combobox.Options className="shadow-xs max-h-60 overflow-auto rounded-md py-1 text-base leading-6 focus:outline-none sm:text-sm sm:leading-5">
{
// @ts-expect-error TODO
({ option, idx }) => {
return (
<Combobox.Option
value={option}
className={({ active }) => {
return classNames(
'relative w-full cursor-default select-none py-2 pl-3 pr-9 focus:outline-none',
active ? 'bg-indigo-600 text-white' : 'text-gray-900'
)
}}
>
{({ active, selected }) => (
<>
<span
className={classNames(
'block truncate',
selected ? 'font-semibold' : 'font-normal'
)}
>
{option}
</span>
{selected && (
<span
className={classNames(
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-indigo-600'
)}
>
<svg
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</span>
)}
</>
)}
</Combobox.Option>
)
}
}
</Combobox.Options>
) : (
<Combobox.Options className="shadow-xs max-h-60 overflow-auto rounded-md py-1 text-base leading-6 focus:outline-none sm:text-sm sm:leading-5">
{timezones.map((timezone, idx) => {
return (
<Combobox.Option
key={timezone}
order={virtual ? idx : undefined}
value={timezone}
className={({ active }) => {
return classNames(
'relative w-full cursor-default select-none py-2 pl-3 pr-9 focus:outline-none',
active ? 'bg-indigo-600 text-white' : 'text-gray-900'
)
}}
>
{({ active, selected }) => (
<>
<span
className={classNames(
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-indigo-600'
'block truncate',
selected ? 'font-semibold' : 'font-normal'
)}
>
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
{timezone}
</span>
)}
</>
)}
</Combobox.Option>
)
})}
</Combobox.Options>
{selected && (
<span
className={classNames(
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-indigo-600'
)}
>
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</span>
)}
</>
)}
</Combobox.Option>
)
})}
</Combobox.Options>
)}
</div>
</div>
</Combobox>
+1
View File
@@ -59,6 +59,7 @@
</defs>
</svg>
</router-link>
<span class="font-bold text-white">(Vue)</span>
</header>
<main class="flex-1 overflow-auto bg-gray-50">
<slot></slot>
@@ -5,7 +5,12 @@
<div class="space-y-1">
<Combobox nullable v-model="activeTimezone" as="div" :virtual="virtual">
<ComboboxLabel class="block text-sm font-medium leading-5 text-gray-700">
Timezone {{ virtual ? '(virtual)' : '' }}
Timezone
{{
virtual
? `(virtual — ${nf.format(timezones.length)} items)`
: `(${nf.format(timezones.length)} items)`
}}
</ComboboxLabel>
<div class="relative">
@@ -37,6 +42,7 @@
<div class="absolute mt-1 w-full rounded-md bg-white shadow-lg">
<ComboboxOptions
v-if="!virtual"
class="shadow-xs max-h-60 overflow-auto rounded-md py-1 text-base leading-6 focus:outline-none sm:text-sm sm:leading-5"
>
<ComboboxOption
@@ -74,6 +80,39 @@
</li>
</ComboboxOption>
</ComboboxOptions>
<ComboboxOptions
v-if="virtual"
class="shadow-xs max-h-60 overflow-auto rounded-md py-1 text-base leading-6 focus:outline-none sm:text-sm sm:leading-5"
v-slot="{ option: timezone }"
>
<ComboboxOption :value="timezone" v-slot="{ active, selected }" as="template">
<li
:class="[
'relative w-full cursor-default select-none py-2 pl-3 pr-9 focus:outline-none',
active ? 'bg-indigo-600 text-white' : 'text-gray-900',
]"
>
<span :class="['block truncate', selected ? 'font-semibold' : 'font-normal']">
{{ timezone }}
</span>
<span
v-if="selected"
:class="[
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-indigo-600',
]"
>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</span>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</div>
</Combobox>
@@ -83,7 +122,7 @@
</template>
<script setup>
import { timezones as allTimezones } from '../../data'
let nf = new Intl.NumberFormat('en-US')
import { ref, computed } from 'vue'
import {
Combobox,
@@ -94,18 +133,17 @@ import {
ComboboxOptions,
} from '@headlessui/vue'
defineProps({
virtual: {
type: Boolean,
default: false,
},
})
let props = defineProps(['data', 'initial', 'virtual'])
let query = ref('')
let activeTimezone = ref('Europe/Brussels')
let activeTimezone = ref(props.initial)
let timezones = computed(() => {
return query.value === ''
? allTimezones
: allTimezones.filter((timezone) => timezone.toLowerCase().includes(query.value.toLowerCase()))
? props.data
: props.data.filter((timezone) => timezone.toLowerCase().includes(query.value.toLowerCase()))
})
let virtual = computed(() => {
return props.virtual ? { options: timezones.value } : null
})
</script>
@@ -1,10 +1,37 @@
<template>
<div class="flex">
<Example :virtual="true" />
<Example :virtual="false" />
<div className="flex flex-col p-12">
<label class="mx-auto flex w-24 items-center gap-2">
<span>Items:</span>
<select v-model="count" class="mx-auto">
<option :value="100">100</option>
<option :value="1_000">1000</option>
<option :value="10_000">10k</option>
<option :value="100_000">100k</option>
</select>
</label>
<div class="flex">
<Example :data="list" initial="Europe/Brussels #1" :virtual="true" />
<Example :data="list" initial="Europe/Brussels #1" :virtual="false" />
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { timezones as _allTimezones } from '../../data'
import Example from './_virtual-example.vue'
let count = ref(1_000)
let list = computed(() => {
console.time('Generating list')
let result = []
while (result.length < Number(count.value)) {
let batch = Math.floor(result.length / _allTimezones.length) + 1
result.push(`${_allTimezones[result.length % _allTimezones.length]} #${batch}`)
}
console.timeEnd('Generating list')
return result
})
</script>