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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
module.exports = {
|
||||
reactStrictMode: true,
|
||||
reactStrictMode: false,
|
||||
devIndicators: {
|
||||
autoPrerender: false,
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user