diff --git a/packages/core/src/constants/index.ts b/packages/core/src/constants/index.ts index f9b7cf45..79501c59 100644 --- a/packages/core/src/constants/index.ts +++ b/packages/core/src/constants/index.ts @@ -10,6 +10,7 @@ export const FieldTypePrefixes = { SearchField: 'sf', FormGroup: 'fg', Select: 'se', + Option: 'opt', } as const; export const NOOP = () => {}; diff --git a/packages/core/src/useSelect/useListBox.ts b/packages/core/src/useSelect/useListBox.ts index 99072217..da5103db 100644 --- a/packages/core/src/useSelect/useListBox.ts +++ b/packages/core/src/useSelect/useListBox.ts @@ -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 { options: TOption[]; @@ -13,56 +14,116 @@ export interface ListBoxDomProps { 'aria-multiselectable'?: boolean; } -export function useListBox(_props: Reactivify>, elementRef?: Ref) { - const listBoxRef = elementRef ?? ref(); +export interface OptionRegistration { + isFocused(): boolean; + isSelected(): boolean; + isDisabled(): boolean; + getValue(): TValue; + focus(): void; + unfocus(): void; +} + +export interface OptionRegistrationWithId extends OptionRegistration { + id: string; +} + +export interface ListManagerCtx { + useOptionRegistration(init: OptionRegistration): string; +} + +export const ListManagerKey: InjectionKey = Symbol('ListManagerKey'); + +export function useListBox(_props: Reactivify>) { const props = normalizeProps(_props); - const getOptions = () => toValue(props.options) ?? []; - const highlightedOption = shallowRef(); + const options = shallowRef[]>([]); + const isOpen = shallowRef(false); + + const listManager: ListManagerCtx = { + useOptionRegistration(init: OptionRegistration) { + 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(() => { - 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, }; } diff --git a/packages/core/src/useSelect/useOption.ts b/packages/core/src/useSelect/useOption.ts index fca917b2..eba5f4a8 100644 --- a/packages/core/src/useSelect/useOption.ts +++ b/packages/core/src/useSelect/useOption.ts @@ -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 @@ -14,11 +19,13 @@ interface OptionDomProps { export interface OptionProps { value: TValue; + disabled?: boolean; } -export function useOption(_props: Reactivify>) { +export function useOption(_props: Reactivify>, elementRef?: Ref>) { const props = normalizeProps(_props); + const optionRef = elementRef || ref(); const selectionCtx = inject(SelectionContextKey, null); if (!selectionCtx) { warn( @@ -26,8 +33,32 @@ export function useOption(_props: Reactivify>) { ); } - 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() { @@ -37,23 +68,35 @@ export function useOption(_props: Reactivify>) { 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(() => { 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, }; } diff --git a/packages/core/src/useSelect/useSelect.ts b/packages/core/src/useSelect/useSelect.ts index 8e4fa21f..bc1bfdf5 100644 --- a/packages/core/src/useSelect/useSelect.ts +++ b/packages/core/src/useSelect/useSelect.ts @@ -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 { @@ -38,10 +38,11 @@ export interface SelectTriggerDomProps extends AriaLabelableProps { } export interface SelectionContext { - 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> = Symbol('SelectionContextKey'); @@ -50,7 +51,6 @@ const MENU_OPEN_KEYS = ['Enter', 'Space', 'ArrowDown', 'ArrowUp']; export function useSelect(_props: Reactivify, 'schema'>) { const inputId = useUniqId(FieldTypePrefixes.Select); - const isOpen = shallowRef(false); const props = normalizeProps(_props, ['schema']); const field = useFormField>({ path: props.name, @@ -65,7 +65,7 @@ export function useSelect(_props: Reactivify, 'sch for: inputId, }); - const { listBoxProps, isHighlighted, highlightPrev, highlightNext, highlightedOption } = useListBox(props); + const { listBoxProps, isOpen } = useListBox(props); const { updateValidity } = useInputValidity({ field }); const { fieldValue, setValue, isTouched, errorMessage } = field; const { displayError } = useErrorDisplay(field); @@ -84,12 +84,21 @@ export function useSelect(_props: Reactivify, 'sch const selectionCtx: SelectionContext = { 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) { @@ -139,27 +148,6 @@ export function useSelect(_props: Reactivify, '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; - } }, }; diff --git a/packages/core/src/utils/common.ts b/packages/core/src/utils/common.ts index 7afd40f7..2e04bae1 100644 --- a/packages/core/src/utils/common.ts +++ b/packages/core/src/utils/common.ts @@ -65,7 +65,7 @@ export function createAccessibleErrorMessageProps({ inputId, errorMessage }: Cre }; } -export function createRefCapture(elRef: Ref) { +export function createRefCapture(elRef: Ref>) { return function captureRef(el: HTMLElement) { elRef.value = el as TEl; }; @@ -138,8 +138,8 @@ export function getNextCycleArrIdx(idx: number, arr: unknown[]): number { */ export function withRefCapture( props: TProps, - inputRef: Ref, - elementRef?: Ref, + inputRef: Ref>, + elementRef?: Ref>, ): TProps { if (!elementRef) { (props as any).ref = createRefCapture(inputRef); @@ -339,3 +339,11 @@ export function toggleValueSelection(current: Arrayable, value: return nextValue; } + +export function removeFirst(items: TItem[], predicate: (item: TItem) => boolean) { + const idx = items.findIndex(predicate); + if (idx >= 0) { + items.splice(idx, 1); + return; + } +} diff --git a/packages/playground/src/App.vue b/packages/playground/src/App.vue index aaee40aa..db296a58 100644 --- a/packages/playground/src/App.vue +++ b/packages/playground/src/App.vue @@ -1,7 +1,7 @@