Skip to content

Commit

Permalink
feat: rework options registeration for focus management
Browse files Browse the repository at this point in the history
  • Loading branch information
logaretm committed Aug 25, 2024
1 parent 984d5c8 commit 8c0f442
Show file tree
Hide file tree
Showing 8 changed files with 228 additions and 96 deletions.
1 change: 1 addition & 0 deletions packages/core/src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const FieldTypePrefixes = {
SearchField: 'sf',
FormGroup: 'fg',
Select: 'se',
Option: 'opt',
} as const;

export const NOOP = () => {};
Expand Down
133 changes: 97 additions & 36 deletions packages/core/src/useSelect/useListBox.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Orientation, Reactivify } from '../types';
import { computed, ref, Ref, shallowRef, toValue } from 'vue';
import { getNextCycleArrIdx, isEqual, normalizeProps, withRefCapture } from '../utils/common';
import { computed, InjectionKey, nextTick, onBeforeUnmount, provide, shallowRef, toValue, watch } from 'vue';
import { normalizeProps, removeFirst, useUniqId } from '../utils/common';
import { FieldTypePrefixes } from '../constants';

export interface ListBoxProps<TOption> {
options: TOption[];
Expand All @@ -13,56 +14,116 @@ export interface ListBoxDomProps {
'aria-multiselectable'?: boolean;
}

export function useListBox<TOption>(_props: Reactivify<ListBoxProps<TOption>>, elementRef?: Ref<HTMLElement>) {
const listBoxRef = elementRef ?? ref();
export interface OptionRegistration<TValue> {
isFocused(): boolean;
isSelected(): boolean;
isDisabled(): boolean;
getValue(): TValue;
focus(): void;
unfocus(): void;
}

export interface OptionRegistrationWithId<TValue> extends OptionRegistration<TValue> {
id: string;
}

export interface ListManagerCtx<TOption = unknown> {
useOptionRegistration(init: OptionRegistration<TOption>): string;
}

export const ListManagerKey: InjectionKey<ListManagerCtx> = Symbol('ListManagerKey');

export function useListBox<TOption>(_props: Reactivify<ListBoxProps<TOption>>) {
const props = normalizeProps(_props);
const getOptions = () => toValue(props.options) ?? [];
const highlightedOption = shallowRef<TOption>();
const options = shallowRef<OptionRegistrationWithId<TOption>[]>([]);
const isOpen = shallowRef(false);

const listManager: ListManagerCtx = {
useOptionRegistration(init: OptionRegistration<TOption>) {
const id = useUniqId(FieldTypePrefixes.Option);
options.value.push({ ...init, id });

onBeforeUnmount(() => {
removeFirst(options.value, reg => reg.id === id);
});

return id;
},
};

provide(ListManagerKey, listManager);

function highlightNext() {
const options = getOptions();
if (!highlightedOption.value) {
highlightedOption.value = options[0];
const handlers = {
onKeydown(e: KeyboardEvent) {
if (e.code === 'ArrowDown') {
e.preventDefault();
e.stopPropagation();
focusNext();
return;
}

if (e.code === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
focusPrev();
return;
}

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

function focusNext() {
const currentlyFocusedIdx = options.value.findIndex(o => o.isFocused());
// Focus first one if none is focused
if (currentlyFocusedIdx === -1) {
options.value[0]?.focus();
return;
}

const currentIdx = options.findIndex(opt => isHighlighted(opt));
const nextIdx = getNextCycleArrIdx(currentIdx + 1, options);
highlightedOption.value = options[nextIdx];
const nextIdx = Math.min(currentlyFocusedIdx + 1, options.value.length - 1);
options.value[currentlyFocusedIdx]?.unfocus();
options.value[nextIdx]?.focus();
}

function highlightPrev() {
const options = getOptions();
if (!highlightedOption.value) {
highlightedOption.value = options[0];
function focusPrev() {
const currentlyFocusedIdx = options.value.findIndex(o => o.isFocused());
// Focus first one if none is focused
if (currentlyFocusedIdx === -1) {
options.value[0]?.focus();
return;
}

const currentIdx = options.findIndex(opt => isHighlighted(opt));
const nextIdx = getNextCycleArrIdx(currentIdx - 1, options);
highlightedOption.value = options[nextIdx];
}

function isHighlighted(opt: TOption) {
return isEqual(opt, highlightedOption.value);
const nextIdx = Math.max(currentlyFocusedIdx - 1, 0);
options.value[currentlyFocusedIdx]?.unfocus();
options.value[nextIdx]?.focus();
}

const listBoxProps = computed<ListBoxDomProps>(() => {
return withRefCapture(
{
role: 'listbox',
'aria-multiselectable': toValue(props.multiple) ?? undefined,
},
listBoxRef,
elementRef,
);
return {
role: 'listbox',
'aria-multiselectable': toValue(props.multiple) ?? undefined,
...handlers,
};
});

watch(isOpen, async value => {
const currentlyFocused = options.value.findIndex(o => o.isFocused());
options.value[currentlyFocused]?.unfocus();
if (!value) {
return;
}

await nextTick();
const currentlySelected = options.value.findIndex(o => o.isSelected());
const toBeSelected = currentlySelected === -1 ? 0 : currentlySelected;
options.value[toBeSelected]?.focus();
});

return {
listBoxProps,
highlightedOption,
isHighlighted,
highlightNext,
highlightPrev,
isOpen,
};
}
71 changes: 57 additions & 14 deletions packages/core/src/useSelect/useOption.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { Reactivify } from '../types';
import { computed, inject, toValue } from 'vue';
import { Maybe, Reactivify, RovingTabIndex } from '../types';
import { computed, inject, nextTick, ref, Ref, shallowRef, toValue } from 'vue';
import { SelectionContextKey } from './useSelect';
import { normalizeProps, warn } from '../utils/common';
import { normalizeProps, useUniqId, warn, withRefCapture } from '../utils/common';
import { ListManagerKey } from '@core/useSelect/useListBox';
import { FieldTypePrefixes } from '@core/constants';

interface OptionDomProps {
id: string;
role: 'option';

tabindex: RovingTabIndex;

// Used when the listbox allows single selection
'aria-selected'?: boolean;
// Used when the listbox allows multiple selections
Expand All @@ -14,20 +19,46 @@ interface OptionDomProps {

export interface OptionProps<TValue> {
value: TValue;

disabled?: boolean;
}

export function useOption<TValue>(_props: Reactivify<OptionProps<TValue>>) {
export function useOption<TValue>(_props: Reactivify<OptionProps<TValue>>, elementRef?: Ref<Maybe<HTMLElement>>) {
const props = normalizeProps(_props);
const optionRef = elementRef || ref<HTMLElement>();
const selectionCtx = inject(SelectionContextKey, null);
if (!selectionCtx) {
warn(
'An option component must exist within a Selection Context. Did you forget to call `useSelect` in a parent component?',
);
}

const isSelected = computed(() => selectionCtx?.isSelected(toValue(props.value)) ?? false);
const isHighlighted = computed(() => selectionCtx?.isHighlighted(toValue(props.value)) ?? false);
const listManager = inject(ListManagerKey, null);
if (!listManager) {
warn(
'An option component must exist within a ListBox Context. Did you forget to call `useSelect` or `useListBox` in a parent component?',
);
}

const isFocused = shallowRef(false);

const isSelected = computed(() => selectionCtx?.isValueSelected(toValue(props.value)) ?? false);
const id =
listManager?.useOptionRegistration({
isDisabled: () => !!toValue(props.disabled),
isSelected: () => isSelected.value,
isFocused: () => isFocused.value,
getValue: () => toValue(props.value),
focus: () => {
isFocused.value = true;
nextTick(() => {
optionRef.value?.focus();
});
},
unfocus() {
isFocused.value = false;
},
}) ?? useUniqId(FieldTypePrefixes.Option);

const handlers = {
onClick() {
Expand All @@ -37,23 +68,35 @@ export function useOption<TValue>(_props: Reactivify<OptionProps<TValue>>) {

selectionCtx?.toggleOption(toValue(props.value));
},
onKeydown(e: KeyboardEvent) {
if (e.code === 'Space' || e.code === 'Enter') {
e.preventDefault();
e.stopPropagation();
selectionCtx?.toggleOption(toValue(props.value));
}
},
};

const optionProps = computed<OptionDomProps>(() => {
const isMultiple = selectionCtx?.isMultiple() ?? false;

return {
role: 'option',
'aria-selected': isMultiple ? undefined : isSelected.value,
'aria-checked': isMultiple ? isSelected.value : undefined,
'aria-disabled': toValue(props.disabled),
...handlers,
};
return withRefCapture(
{
id,
role: 'option',
tabindex: isFocused.value ? '0' : '-1',
'aria-selected': isMultiple ? undefined : isSelected.value,
'aria-checked': isMultiple ? isSelected.value : undefined,
'aria-disabled': toValue(props.disabled),
...handlers,
},
optionRef,
elementRef,
);
});

return {
optionProps,
isSelected,
isHighlighted,
};
}
46 changes: 17 additions & 29 deletions packages/core/src/useSelect/useSelect.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { computed, InjectionKey, provide, shallowRef, toValue } from 'vue';
import { computed, InjectionKey, provide, toValue } from 'vue';
import { useFormField } from '../useFormField';
import { AriaLabelableProps, Arrayable, Orientation, Reactivify, TypedSchema } from '../types';
import {
Expand Down Expand Up @@ -38,10 +38,11 @@ export interface SelectTriggerDomProps extends AriaLabelableProps {
}

export interface SelectionContext<TValue> {
isSelected(value: TValue): boolean;
isValueSelected(value: TValue): boolean;
getOptionIndex(value: TValue): number;
isMultiple(): boolean;
isHighlighted(opt: TValue): boolean;
toggleOption(value: TValue): void;
toggleOption(value: TValue, force?: boolean): void;
toggleIdx(idx: number, force?: boolean): void;
}

export const SelectionContextKey: InjectionKey<SelectionContext<unknown>> = Symbol('SelectionContextKey');
Expand All @@ -50,7 +51,6 @@ const MENU_OPEN_KEYS = ['Enter', 'Space', 'ArrowDown', 'ArrowUp'];

export function useSelect<TOption>(_props: Reactivify<SelectProps<TOption>, 'schema'>) {
const inputId = useUniqId(FieldTypePrefixes.Select);
const isOpen = shallowRef(false);
const props = normalizeProps(_props, ['schema']);
const field = useFormField<Arrayable<TOption>>({
path: props.name,
Expand All @@ -65,7 +65,7 @@ export function useSelect<TOption>(_props: Reactivify<SelectProps<TOption>, 'sch
for: inputId,
});

const { listBoxProps, isHighlighted, highlightPrev, highlightNext, highlightedOption } = useListBox<TOption>(props);
const { listBoxProps, isOpen } = useListBox<TOption>(props);
const { updateValidity } = useInputValidity({ field });
const { fieldValue, setValue, isTouched, errorMessage } = field;
const { displayError } = useErrorDisplay(field);
Expand All @@ -84,12 +84,21 @@ export function useSelect<TOption>(_props: Reactivify<SelectProps<TOption>, 'sch

const selectionCtx: SelectionContext<TOption> = {
isMultiple: () => toValue(props.multiple) ?? false,
isSelected(value: TOption): boolean {
isValueSelected(value: TOption): boolean {
const selectedOptions = normalizeArrayable(fieldValue.value ?? []);

return selectedOptions.some(opt => isEqual(opt, value));
},
isHighlighted,
getOptionIndex(value: TOption) {
const opts = toValue(props.options) || [];

return opts.findIndex(opt => isEqual(opt, value));
},
toggleIdx(idx: number, force?: boolean) {
const opts = toValue(props.options) || [];

this.toggleOption(opts[idx], force);
},
toggleOption(optionValue: TOption, force?: boolean) {
const isMultiple = toValue(props.multiple);
if (!isMultiple) {
Expand Down Expand Up @@ -139,27 +148,6 @@ export function useSelect<TOption>(_props: Reactivify<SelectProps<TOption>, 'sch
return;
}
}

if (e.code === 'ArrowDown') {
e.preventDefault();
highlightNext();
return;
}

if (e.code === 'ArrowUp') {
e.preventDefault();
highlightPrev();
return;
}

if (e.code === 'Space' || e.code === 'Enter') {
if (highlightedOption.value) {
e.preventDefault();
selectionCtx.toggleOption(highlightedOption.value);
}

return;
}
},
};

Expand Down
Loading

0 comments on commit 8c0f442

Please sign in to comment.