Skip to content

Commit

Permalink
feat: implement option finder
Browse files Browse the repository at this point in the history
  • Loading branch information
logaretm committed Aug 28, 2024
1 parent 5c9dfe1 commit 6cea74e
Show file tree
Hide file tree
Showing 3 changed files with 83 additions and 1 deletion.
74 changes: 74 additions & 0 deletions packages/core/src/useSelect/useListBox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { useKeyPressed } from '../helpers/useKeyPressed';
import { isMac } from '../utils/platform';
import { usePopoverController } from '../helpers/usePopoverController';

const SEARCH_CLEAR_TIMEOUT = 500;

export interface ListBoxProps {
multiple?: boolean;
orientation?: Orientation;
Expand All @@ -22,6 +24,7 @@ export interface ListBoxDomProps {

export interface OptionRegistration<TValue> {
id: string;
getLabel(): string;
isFocused(): boolean;
isSelected(): boolean;
isDisabled(): boolean;
Expand Down Expand Up @@ -49,6 +52,7 @@ export function useListBox<TOption, TValue = TOption>(
const options = shallowRef<OptionRegistrationWithId<TValue>[]>([]);
// Initialize popover controller, NO-OP if the element is not a popover-enabled element.
const { isOpen } = usePopoverController(listBoxRef);
const finder = useOptionFinder(options);
const isShiftPressed = useKeyPressed(['ShiftLeft', 'ShiftRight'], () => !isOpen.value);
const isMetaPressed = useKeyPressed(
isMac() ? ['MetaLeft', 'MetaRight'] : ['ControlLeft', 'ControlRight'],
Expand Down Expand Up @@ -98,6 +102,7 @@ export function useListBox<TOption, TValue = TOption>(
}

options.value.at(0)?.focus();
return;
}

if (e.code === 'End') {
Expand All @@ -108,11 +113,15 @@ export function useListBox<TOption, TValue = TOption>(
}

options.value.at(-1)?.focus();
return;
}

if (e.code === 'Tab') {
isOpen.value = false;
return;
}

finder.handleKeydown(e);
},
};

Expand Down Expand Up @@ -185,3 +194,68 @@ export function useListBox<TOption, TValue = TOption>(
isShiftPressed,
};
}

function useOptionFinder(options: Ref<OptionRegistrationWithId<unknown>[]>) {
let keysSoFar: string = '';
let clearKeysTimeout: number | null = null;

function findOption(key: string) {
const lowerKey = key.toLowerCase();
let startIdx = 0;
if (!keysSoFar) {
const focusedIdx = options.value.findIndex(o => o.isFocused());
startIdx = focusedIdx === -1 ? 0 : focusedIdx;
}

// Append the key to the keysSoFar
keysSoFar += lowerKey;
// Clear the keys after a timeout so that the next key press starts a new search
scheduleClearKeys();

// +1 to skip the currently focused one
let match = findWithinRange(startIdx + 1, options.value.length);
if (!match) {
// Flip the search range and try again if not found in the first pass
match = findWithinRange(0, startIdx);
}

return match;
}

function findWithinRange(startIdx: number, endIdx: number) {
// Better than slice because we don't have to worry about inclusive/exclusive.
for (let i = startIdx; i < endIdx; i++) {
const option = options.value[i];
if (option.getLabel().toLowerCase().startsWith(keysSoFar)) {
return option;
}
}

return null;
}

function handleKeydown(e: KeyboardEvent) {
if (e.key.length === 1) {
findOption(e.key)?.focus();
}
}

function scheduleClearKeys() {
if (clearKeysTimeout) {
clearTimeout(clearKeysTimeout);
}

clearKeysTimeout = window.setTimeout(clearKeys, SEARCH_CLEAR_TIMEOUT);
}

function clearKeys() {
keysSoFar = '';
clearKeysTimeout = null;
}

return {
keysSoFar,
handleKeydown,
clearKeys,
};
}
2 changes: 2 additions & 0 deletions packages/core/src/useSelect/useOption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface OptionDomProps {
}

export interface OptionProps<TValue> {
label: string;
option: TValue;

disabled?: boolean;
Expand Down Expand Up @@ -54,6 +55,7 @@ export function useOption<TOption>(_props: Reactivify<OptionProps<TOption>>, ele
isDisabled: () => !!toValue(props.disabled),
isSelected: () => isSelected.value,
isFocused: () => isFocused.value,
getLabel: () => toValue(props.label) ?? '',
getValue,
focus: () => {
isFocused.value = true;
Expand Down
8 changes: 7 additions & 1 deletion packages/playground/src/components/InputSelect.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const { triggerProps, labelProps, errorMessageProps, isTouched, displayError, fi
v-for="(option, idx) in group.items"
:key="(getValue?.(option) as any) ?? idx"
:option="option"
:label="option.label"
>
<slot name="option" :option="option">
{{ option.label }}
Expand All @@ -43,7 +44,12 @@ const { triggerProps, labelProps, errorMessageProps, isTouched, displayError, fi
</template>

<template v-else-if="options">
<OptionItem v-for="(option, idx) in options" :key="(getValue?.(option) as any) ?? idx" :option="option">
<OptionItem
v-for="(option, idx) in options"
:key="(getValue?.(option) as any) ?? idx"
:option="option"
:label="option.label"
>
<slot name="option" :option="option" />
</OptionItem>
</template>
Expand Down

0 comments on commit 6cea74e

Please sign in to comment.