init
This commit is contained in:
@@ -0,0 +1,653 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { isDeepStrictEqual } from 'util'
|
||||
import OptionMap from './option-map.js'
|
||||
import type { OptionWithDescription } from './select.js'
|
||||
|
||||
type State<T> = {
|
||||
/**
|
||||
* Map where key is option's value and value is option's index.
|
||||
*/
|
||||
optionMap: OptionMap<T>
|
||||
|
||||
/**
|
||||
* Number of visible options.
|
||||
*/
|
||||
visibleOptionCount: number
|
||||
|
||||
/**
|
||||
* Value of the currently focused option.
|
||||
*/
|
||||
focusedValue: T | undefined
|
||||
|
||||
/**
|
||||
* Index of the first visible option.
|
||||
*/
|
||||
visibleFromIndex: number
|
||||
|
||||
/**
|
||||
* Index of the last visible option.
|
||||
*/
|
||||
visibleToIndex: number
|
||||
}
|
||||
|
||||
type Action<T> =
|
||||
| FocusNextOptionAction
|
||||
| FocusPreviousOptionAction
|
||||
| FocusNextPageAction
|
||||
| FocusPreviousPageAction
|
||||
| SetFocusAction<T>
|
||||
| ResetAction<T>
|
||||
|
||||
type SetFocusAction<T> = {
|
||||
type: 'set-focus'
|
||||
value: T
|
||||
}
|
||||
|
||||
type FocusNextOptionAction = {
|
||||
type: 'focus-next-option'
|
||||
}
|
||||
|
||||
type FocusPreviousOptionAction = {
|
||||
type: 'focus-previous-option'
|
||||
}
|
||||
|
||||
type FocusNextPageAction = {
|
||||
type: 'focus-next-page'
|
||||
}
|
||||
|
||||
type FocusPreviousPageAction = {
|
||||
type: 'focus-previous-page'
|
||||
}
|
||||
|
||||
type ResetAction<T> = {
|
||||
type: 'reset'
|
||||
state: State<T>
|
||||
}
|
||||
|
||||
const reducer = <T>(state: State<T>, action: Action<T>): State<T> => {
|
||||
switch (action.type) {
|
||||
case 'focus-next-option': {
|
||||
if (state.focusedValue === undefined) {
|
||||
return state
|
||||
}
|
||||
|
||||
const item = state.optionMap.get(state.focusedValue)
|
||||
|
||||
if (!item) {
|
||||
return state
|
||||
}
|
||||
|
||||
// Wrap to first item if at the end
|
||||
const next = item.next || state.optionMap.first
|
||||
|
||||
if (!next) {
|
||||
return state
|
||||
}
|
||||
|
||||
// When wrapping to first, reset viewport to start
|
||||
if (!item.next && next === state.optionMap.first) {
|
||||
return {
|
||||
...state,
|
||||
focusedValue: next.value,
|
||||
visibleFromIndex: 0,
|
||||
visibleToIndex: state.visibleOptionCount,
|
||||
}
|
||||
}
|
||||
|
||||
const needsToScroll = next.index >= state.visibleToIndex
|
||||
|
||||
if (!needsToScroll) {
|
||||
return {
|
||||
...state,
|
||||
focusedValue: next.value,
|
||||
}
|
||||
}
|
||||
|
||||
const nextVisibleToIndex = Math.min(
|
||||
state.optionMap.size,
|
||||
state.visibleToIndex + 1,
|
||||
)
|
||||
|
||||
const nextVisibleFromIndex = nextVisibleToIndex - state.visibleOptionCount
|
||||
|
||||
return {
|
||||
...state,
|
||||
focusedValue: next.value,
|
||||
visibleFromIndex: nextVisibleFromIndex,
|
||||
visibleToIndex: nextVisibleToIndex,
|
||||
}
|
||||
}
|
||||
|
||||
case 'focus-previous-option': {
|
||||
if (state.focusedValue === undefined) {
|
||||
return state
|
||||
}
|
||||
|
||||
const item = state.optionMap.get(state.focusedValue)
|
||||
|
||||
if (!item) {
|
||||
return state
|
||||
}
|
||||
|
||||
// Wrap to last item if at the beginning
|
||||
const previous = item.previous || state.optionMap.last
|
||||
|
||||
if (!previous) {
|
||||
return state
|
||||
}
|
||||
|
||||
// When wrapping to last, reset viewport to end
|
||||
if (!item.previous && previous === state.optionMap.last) {
|
||||
const nextVisibleToIndex = state.optionMap.size
|
||||
const nextVisibleFromIndex = Math.max(
|
||||
0,
|
||||
nextVisibleToIndex - state.visibleOptionCount,
|
||||
)
|
||||
return {
|
||||
...state,
|
||||
focusedValue: previous.value,
|
||||
visibleFromIndex: nextVisibleFromIndex,
|
||||
visibleToIndex: nextVisibleToIndex,
|
||||
}
|
||||
}
|
||||
|
||||
const needsToScroll = previous.index <= state.visibleFromIndex
|
||||
|
||||
if (!needsToScroll) {
|
||||
return {
|
||||
...state,
|
||||
focusedValue: previous.value,
|
||||
}
|
||||
}
|
||||
|
||||
const nextVisibleFromIndex = Math.max(0, state.visibleFromIndex - 1)
|
||||
|
||||
const nextVisibleToIndex = nextVisibleFromIndex + state.visibleOptionCount
|
||||
|
||||
return {
|
||||
...state,
|
||||
focusedValue: previous.value,
|
||||
visibleFromIndex: nextVisibleFromIndex,
|
||||
visibleToIndex: nextVisibleToIndex,
|
||||
}
|
||||
}
|
||||
|
||||
case 'focus-next-page': {
|
||||
if (state.focusedValue === undefined) {
|
||||
return state
|
||||
}
|
||||
|
||||
const item = state.optionMap.get(state.focusedValue)
|
||||
|
||||
if (!item) {
|
||||
return state
|
||||
}
|
||||
|
||||
// Move by a full page (visibleOptionCount items)
|
||||
const targetIndex = Math.min(
|
||||
state.optionMap.size - 1,
|
||||
item.index + state.visibleOptionCount,
|
||||
)
|
||||
|
||||
// Find the item at the target index
|
||||
let targetItem = state.optionMap.first
|
||||
while (targetItem && targetItem.index < targetIndex) {
|
||||
if (targetItem.next) {
|
||||
targetItem = targetItem.next
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetItem) {
|
||||
return state
|
||||
}
|
||||
|
||||
// Update the visible range to include the new focused item
|
||||
const nextVisibleToIndex = Math.min(
|
||||
state.optionMap.size,
|
||||
targetItem.index + 1,
|
||||
)
|
||||
const nextVisibleFromIndex = Math.max(
|
||||
0,
|
||||
nextVisibleToIndex - state.visibleOptionCount,
|
||||
)
|
||||
|
||||
return {
|
||||
...state,
|
||||
focusedValue: targetItem.value,
|
||||
visibleFromIndex: nextVisibleFromIndex,
|
||||
visibleToIndex: nextVisibleToIndex,
|
||||
}
|
||||
}
|
||||
|
||||
case 'focus-previous-page': {
|
||||
if (state.focusedValue === undefined) {
|
||||
return state
|
||||
}
|
||||
|
||||
const item = state.optionMap.get(state.focusedValue)
|
||||
|
||||
if (!item) {
|
||||
return state
|
||||
}
|
||||
|
||||
// Move by a full page (visibleOptionCount items)
|
||||
const targetIndex = Math.max(0, item.index - state.visibleOptionCount)
|
||||
|
||||
// Find the item at the target index
|
||||
let targetItem = state.optionMap.first
|
||||
while (targetItem && targetItem.index < targetIndex) {
|
||||
if (targetItem.next) {
|
||||
targetItem = targetItem.next
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetItem) {
|
||||
return state
|
||||
}
|
||||
|
||||
// Update the visible range to include the new focused item
|
||||
const nextVisibleFromIndex = Math.max(0, targetItem.index)
|
||||
const nextVisibleToIndex = Math.min(
|
||||
state.optionMap.size,
|
||||
nextVisibleFromIndex + state.visibleOptionCount,
|
||||
)
|
||||
|
||||
return {
|
||||
...state,
|
||||
focusedValue: targetItem.value,
|
||||
visibleFromIndex: nextVisibleFromIndex,
|
||||
visibleToIndex: nextVisibleToIndex,
|
||||
}
|
||||
}
|
||||
|
||||
case 'reset': {
|
||||
return action.state
|
||||
}
|
||||
|
||||
case 'set-focus': {
|
||||
// Early return if already focused on this value
|
||||
if (state.focusedValue === action.value) {
|
||||
return state
|
||||
}
|
||||
|
||||
const item = state.optionMap.get(action.value)
|
||||
if (!item) {
|
||||
return state
|
||||
}
|
||||
|
||||
// Check if the item is already in view
|
||||
if (
|
||||
item.index >= state.visibleFromIndex &&
|
||||
item.index < state.visibleToIndex
|
||||
) {
|
||||
// Already visible, just update focus
|
||||
return {
|
||||
...state,
|
||||
focusedValue: action.value,
|
||||
}
|
||||
}
|
||||
|
||||
// Need to scroll to make the item visible
|
||||
// Scroll as little as possible - put item at edge of viewport
|
||||
let nextVisibleFromIndex: number
|
||||
let nextVisibleToIndex: number
|
||||
|
||||
if (item.index < state.visibleFromIndex) {
|
||||
// Item is above viewport - scroll up to put it at the top
|
||||
nextVisibleFromIndex = item.index
|
||||
nextVisibleToIndex = Math.min(
|
||||
state.optionMap.size,
|
||||
nextVisibleFromIndex + state.visibleOptionCount,
|
||||
)
|
||||
} else {
|
||||
// Item is below viewport - scroll down to put it at the bottom
|
||||
nextVisibleToIndex = Math.min(state.optionMap.size, item.index + 1)
|
||||
nextVisibleFromIndex = Math.max(
|
||||
0,
|
||||
nextVisibleToIndex - state.visibleOptionCount,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
focusedValue: action.value,
|
||||
visibleFromIndex: nextVisibleFromIndex,
|
||||
visibleToIndex: nextVisibleToIndex,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type UseSelectNavigationProps<T> = {
|
||||
/**
|
||||
* Number of items to display.
|
||||
*
|
||||
* @default 5
|
||||
*/
|
||||
visibleOptionCount?: number
|
||||
|
||||
/**
|
||||
* Options.
|
||||
*/
|
||||
options: OptionWithDescription<T>[]
|
||||
|
||||
/**
|
||||
* Initially focused option's value.
|
||||
*/
|
||||
initialFocusValue?: T
|
||||
|
||||
/**
|
||||
* Callback for focusing an option.
|
||||
*/
|
||||
onFocus?: (value: T) => void
|
||||
|
||||
/**
|
||||
* Value to focus
|
||||
*/
|
||||
focusValue?: T
|
||||
}
|
||||
|
||||
export type SelectNavigation<T> = {
|
||||
/**
|
||||
* Value of the currently focused option.
|
||||
*/
|
||||
focusedValue: T | undefined
|
||||
|
||||
/**
|
||||
* 1-based index of the focused option in the full list.
|
||||
* Returns 0 if no option is focused.
|
||||
*/
|
||||
focusedIndex: number
|
||||
|
||||
/**
|
||||
* Index of the first visible option.
|
||||
*/
|
||||
visibleFromIndex: number
|
||||
|
||||
/**
|
||||
* Index of the last visible option.
|
||||
*/
|
||||
visibleToIndex: number
|
||||
|
||||
/**
|
||||
* All options.
|
||||
*/
|
||||
options: OptionWithDescription<T>[]
|
||||
|
||||
/**
|
||||
* Visible options.
|
||||
*/
|
||||
visibleOptions: Array<OptionWithDescription<T> & { index: number }>
|
||||
|
||||
/**
|
||||
* Whether the focused option is an input type.
|
||||
*/
|
||||
isInInput: boolean
|
||||
|
||||
/**
|
||||
* Focus next option and scroll the list down, if needed.
|
||||
*/
|
||||
focusNextOption: () => void
|
||||
|
||||
/**
|
||||
* Focus previous option and scroll the list up, if needed.
|
||||
*/
|
||||
focusPreviousOption: () => void
|
||||
|
||||
/**
|
||||
* Focus next page and scroll the list down by a page.
|
||||
*/
|
||||
focusNextPage: () => void
|
||||
|
||||
/**
|
||||
* Focus previous page and scroll the list up by a page.
|
||||
*/
|
||||
focusPreviousPage: () => void
|
||||
|
||||
/**
|
||||
* Focus a specific option by value.
|
||||
*/
|
||||
focusOption: (value: T | undefined) => void
|
||||
}
|
||||
|
||||
const createDefaultState = <T>({
|
||||
visibleOptionCount: customVisibleOptionCount,
|
||||
options,
|
||||
initialFocusValue,
|
||||
currentViewport,
|
||||
}: Pick<UseSelectNavigationProps<T>, 'visibleOptionCount' | 'options'> & {
|
||||
initialFocusValue?: T
|
||||
currentViewport?: { visibleFromIndex: number; visibleToIndex: number }
|
||||
}): State<T> => {
|
||||
const visibleOptionCount =
|
||||
typeof customVisibleOptionCount === 'number'
|
||||
? Math.min(customVisibleOptionCount, options.length)
|
||||
: options.length
|
||||
|
||||
const optionMap = new OptionMap<T>(options)
|
||||
const focusedItem =
|
||||
initialFocusValue !== undefined && optionMap.get(initialFocusValue)
|
||||
const focusedValue = focusedItem ? initialFocusValue : optionMap.first?.value
|
||||
|
||||
let visibleFromIndex = 0
|
||||
let visibleToIndex = visibleOptionCount
|
||||
|
||||
// When there's a valid focused item, adjust viewport to show it
|
||||
if (focusedItem) {
|
||||
const focusedIndex = focusedItem.index
|
||||
|
||||
if (currentViewport) {
|
||||
// If focused item is already in the current viewport range, try to preserve it
|
||||
if (
|
||||
focusedIndex >= currentViewport.visibleFromIndex &&
|
||||
focusedIndex < currentViewport.visibleToIndex
|
||||
) {
|
||||
// Keep the same viewport if it's valid
|
||||
visibleFromIndex = currentViewport.visibleFromIndex
|
||||
visibleToIndex = Math.min(
|
||||
optionMap.size,
|
||||
currentViewport.visibleToIndex,
|
||||
)
|
||||
} else {
|
||||
// Need to adjust viewport to show focused item
|
||||
// Use minimal scrolling - put item at edge of viewport
|
||||
if (focusedIndex < currentViewport.visibleFromIndex) {
|
||||
// Item is above current viewport - scroll up to put it at the top
|
||||
visibleFromIndex = focusedIndex
|
||||
visibleToIndex = Math.min(
|
||||
optionMap.size,
|
||||
visibleFromIndex + visibleOptionCount,
|
||||
)
|
||||
} else {
|
||||
// Item is below current viewport - scroll down to put it at the bottom
|
||||
visibleToIndex = Math.min(optionMap.size, focusedIndex + 1)
|
||||
visibleFromIndex = Math.max(0, visibleToIndex - visibleOptionCount)
|
||||
}
|
||||
}
|
||||
} else if (focusedIndex >= visibleOptionCount) {
|
||||
// No current viewport but focused item is outside default viewport
|
||||
// Scroll to show the focused item at the bottom of the viewport
|
||||
visibleToIndex = Math.min(optionMap.size, focusedIndex + 1)
|
||||
visibleFromIndex = Math.max(0, visibleToIndex - visibleOptionCount)
|
||||
}
|
||||
|
||||
// Ensure viewport bounds are valid
|
||||
visibleFromIndex = Math.max(
|
||||
0,
|
||||
Math.min(visibleFromIndex, optionMap.size - 1),
|
||||
)
|
||||
visibleToIndex = Math.min(
|
||||
optionMap.size,
|
||||
Math.max(visibleOptionCount, visibleToIndex),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
optionMap,
|
||||
visibleOptionCount,
|
||||
focusedValue,
|
||||
visibleFromIndex,
|
||||
visibleToIndex,
|
||||
}
|
||||
}
|
||||
|
||||
export function useSelectNavigation<T>({
|
||||
visibleOptionCount = 5,
|
||||
options,
|
||||
initialFocusValue,
|
||||
onFocus,
|
||||
focusValue,
|
||||
}: UseSelectNavigationProps<T>): SelectNavigation<T> {
|
||||
const [state, dispatch] = useReducer(
|
||||
reducer<T>,
|
||||
{
|
||||
visibleOptionCount,
|
||||
options,
|
||||
initialFocusValue: focusValue || initialFocusValue,
|
||||
} as Parameters<typeof createDefaultState<T>>[0],
|
||||
createDefaultState<T>,
|
||||
)
|
||||
|
||||
// Store onFocus in a ref to avoid re-running useEffect when callback changes
|
||||
const onFocusRef = useRef(onFocus)
|
||||
onFocusRef.current = onFocus
|
||||
|
||||
const [lastOptions, setLastOptions] = useState(options)
|
||||
|
||||
if (options !== lastOptions && !isDeepStrictEqual(options, lastOptions)) {
|
||||
dispatch({
|
||||
type: 'reset',
|
||||
state: createDefaultState({
|
||||
visibleOptionCount,
|
||||
options,
|
||||
initialFocusValue:
|
||||
focusValue ?? state.focusedValue ?? initialFocusValue,
|
||||
currentViewport: {
|
||||
visibleFromIndex: state.visibleFromIndex,
|
||||
visibleToIndex: state.visibleToIndex,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
setLastOptions(options)
|
||||
}
|
||||
|
||||
const focusNextOption = useCallback(() => {
|
||||
dispatch({
|
||||
type: 'focus-next-option',
|
||||
})
|
||||
}, [])
|
||||
|
||||
const focusPreviousOption = useCallback(() => {
|
||||
dispatch({
|
||||
type: 'focus-previous-option',
|
||||
})
|
||||
}, [])
|
||||
|
||||
const focusNextPage = useCallback(() => {
|
||||
dispatch({
|
||||
type: 'focus-next-page',
|
||||
})
|
||||
}, [])
|
||||
|
||||
const focusPreviousPage = useCallback(() => {
|
||||
dispatch({
|
||||
type: 'focus-previous-page',
|
||||
})
|
||||
}, [])
|
||||
|
||||
const focusOption = useCallback((value: T | undefined) => {
|
||||
if (value !== undefined) {
|
||||
dispatch({
|
||||
type: 'set-focus',
|
||||
value,
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const visibleOptions = useMemo(() => {
|
||||
return options
|
||||
.map((option, index) => ({
|
||||
...option,
|
||||
index,
|
||||
}))
|
||||
.slice(state.visibleFromIndex, state.visibleToIndex)
|
||||
}, [options, state.visibleFromIndex, state.visibleToIndex])
|
||||
|
||||
// Validate that focusedValue exists in current options.
|
||||
// This handles the case where options change during render but the reset
|
||||
// action hasn't been processed yet - without this, the cursor would disappear
|
||||
// because focusedValue points to an option that no longer exists.
|
||||
const validatedFocusedValue = useMemo(() => {
|
||||
if (state.focusedValue === undefined) {
|
||||
return undefined
|
||||
}
|
||||
const exists = options.some(opt => opt.value === state.focusedValue)
|
||||
if (exists) {
|
||||
return state.focusedValue
|
||||
}
|
||||
// Fall back to first option if focused value doesn't exist
|
||||
return options[0]?.value
|
||||
}, [state.focusedValue, options])
|
||||
|
||||
const isInInput = useMemo(() => {
|
||||
const focusedOption = options.find(
|
||||
opt => opt.value === validatedFocusedValue,
|
||||
)
|
||||
return focusedOption?.type === 'input'
|
||||
}, [validatedFocusedValue, options])
|
||||
|
||||
// Call onFocus with the validated value (what's actually displayed),
|
||||
// not the internal state value which may be stale if options changed.
|
||||
// Use ref to avoid re-running when callback reference changes.
|
||||
useEffect(() => {
|
||||
if (validatedFocusedValue !== undefined) {
|
||||
onFocusRef.current?.(validatedFocusedValue)
|
||||
}
|
||||
}, [validatedFocusedValue])
|
||||
|
||||
// Allow parent to programmatically set focus via focusValue prop
|
||||
useEffect(() => {
|
||||
if (focusValue !== undefined) {
|
||||
dispatch({
|
||||
type: 'set-focus',
|
||||
value: focusValue,
|
||||
})
|
||||
}
|
||||
}, [focusValue])
|
||||
|
||||
// Compute 1-based focused index for scroll position display
|
||||
const focusedIndex = useMemo(() => {
|
||||
if (validatedFocusedValue === undefined) {
|
||||
return 0
|
||||
}
|
||||
const index = options.findIndex(opt => opt.value === validatedFocusedValue)
|
||||
return index >= 0 ? index + 1 : 0
|
||||
}, [validatedFocusedValue, options])
|
||||
|
||||
return {
|
||||
focusedValue: validatedFocusedValue,
|
||||
focusedIndex,
|
||||
visibleFromIndex: state.visibleFromIndex,
|
||||
visibleToIndex: state.visibleToIndex,
|
||||
visibleOptions,
|
||||
isInInput: isInInput ?? false,
|
||||
focusNextOption,
|
||||
focusPreviousOption,
|
||||
focusNextPage,
|
||||
focusPreviousPage,
|
||||
focusOption,
|
||||
options,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user