From a488d0bb9db812001587677b5b89e5aff1b89ef8 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sat, 11 Jan 2025 02:35:41 +0200 Subject: [PATCH] feat: implement focus strategy --- packages/core/src/useComboBox/useComboBox.ts | 62 ++++++++++++------- packages/core/src/useListBox/useListBox.ts | 25 +++++++- packages/core/src/useOption/useOption.ts | 24 +++++-- packages/playground/src/App.vue | 18 ++++-- .../playground/src/components/ComboBox.vue | 61 ++++++++++++++++++ .../playground/src/components/InputSelect.vue | 19 +++++- .../playground/src/components/OptionItem.vue | 7 ++- .../minimal/src/components/SelectField.vue | 11 +++- 8 files changed, 185 insertions(+), 42 deletions(-) create mode 100644 packages/playground/src/components/ComboBox.vue diff --git a/packages/core/src/useComboBox/useComboBox.ts b/packages/core/src/useComboBox/useComboBox.ts index 0386b933..cc1d24f7 100644 --- a/packages/core/src/useComboBox/useComboBox.ts +++ b/packages/core/src/useComboBox/useComboBox.ts @@ -110,20 +110,22 @@ export function useComboBox(_props: Reactivify({ - labeledBy: () => labelledByProps.value['aria-labelledby'], - disabled: isDisabled, - label: props.label, - multiple: false, - orientation: props.orientation, - isValueSelected: value => { - return fieldValue.value === value; - }, - handleToggleValue: value => { - setValue(value); - isPopupOpen.value = false; - }, - }); + const { listBoxId, listBoxProps, isPopupOpen, listBoxEl, selectedOption, focusNext, focusPrev, findFocusedOption } = + useListBox({ + labeledBy: () => labelledByProps.value['aria-labelledby'], + focusStrategy: 'VIRTUAL_WITH_SELECTED', + disabled: isDisabled, + label: props.label, + multiple: false, + orientation: props.orientation, + isValueSelected: value => { + return fieldValue.value === value; + }, + handleToggleValue: value => { + setValue(value); + isPopupOpen.value = false; + }, + }); const handlers: InputEvents & { onKeydown(evt: KeyboardEvent): void } = { onInput(evt) { @@ -140,26 +142,38 @@ export function useComboBox(_props: Reactivify { label: string; isValueSelected(value: TValue): boolean; handleToggleValue(value: TValue): void; + focusStrategy?: FocusStrategy; labeledBy?: string; multiple?: boolean; orientation?: Orientation; @@ -36,6 +39,7 @@ export interface OptionRegistration { isDisabled(): boolean; getValue(): TValue; focus(): void; + unfocus(): void; toggleSelected(): void; } @@ -48,6 +52,8 @@ export interface ListManagerCtx { isValueSelected(value: TValue): boolean; isMultiple(): boolean; toggleValue(value: TValue, force?: boolean): void; + getFocusStrategy(): FocusStrategy; + isPopupOpen(): boolean; } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -84,6 +90,8 @@ export function useListBox( }, isValueSelected: props.isValueSelected, toggleValue: props.handleToggleValue, + getFocusStrategy: () => toValue(props.focusStrategy) ?? 'DOM_FOCUS', + isPopupOpen: () => isOpen.value, }; provide(ListManagerKey, listManager); @@ -147,18 +155,26 @@ export function useListBox( }; function focusAndToggleIfShiftPressed(idx: number) { + if (listManager.getFocusStrategy() !== 'DOM_FOCUS') { + findFocusedOption()?.unfocus(); + } + options.value[idx]?.focus(); if (isShiftPressed.value) { options.value[idx]?.toggleSelected(); } } - function findFocused() { + function findFocusedIdx() { return options.value.findIndex(o => o.isFocused()); } + function findFocusedOption() { + return options.value.find(o => o.isFocused()); + } + function focusNext() { - const currentlyFocusedIdx = findFocused(); + const currentlyFocusedIdx = findFocusedIdx(); for (let i = currentlyFocusedIdx + 1; i < options.value.length; i++) { if (!options.value[i].isDisabled()) { focusAndToggleIfShiftPressed(i); @@ -168,7 +184,7 @@ export function useListBox( } function focusPrev() { - const currentlyFocusedIdx = findFocused(); + const currentlyFocusedIdx = findFocusedIdx(); if (currentlyFocusedIdx === -1) { focusNext(); return; @@ -242,5 +258,8 @@ export function useListBox( listBoxEl, selectedOption, selectedOptions, + focusNext, + focusPrev, + findFocusedOption, }; } diff --git a/packages/core/src/useOption/useOption.ts b/packages/core/src/useOption/useOption.ts index 58b90e86..c82c6bdb 100644 --- a/packages/core/src/useOption/useOption.ts +++ b/packages/core/src/useOption/useOption.ts @@ -1,7 +1,7 @@ import { Maybe, Reactivify, RovingTabIndex } from '../types'; import { computed, inject, nextTick, ref, Ref, shallowRef, toValue } from 'vue'; import { hasKeyCode, normalizeProps, useUniqId, warn, withRefCapture } from '../utils/common'; -import { ListManagerKey } from '../useListBox/useListBox'; +import { ListManagerKey } from '../useListBox'; import { FieldTypePrefixes } from '../constants'; import { createDisabledContext } from '../helpers/createDisabledContext'; @@ -63,10 +63,19 @@ export function useOption(_props: Reactivify>, ele isFocused: () => isFocused.value, getLabel: () => toValue(props.label) ?? '', getValue, + unfocus: () => { + // Doesn't actually unfocus the option, just sets the focus state to false. + isFocused.value = false; + }, focus: () => { isFocused.value = true; nextTick(() => { - optionEl.value?.focus(); + if (listManager?.getFocusStrategy() === 'DOM_FOCUS') { + optionEl.value?.focus(); + return; + } + + optionEl.value?.scrollIntoView(); }); }, }); @@ -101,14 +110,19 @@ export function useOption(_props: Reactivify>, ele const optionProps = computed(() => { const isMultiple = listManager?.isMultiple() ?? false; + const focusStrategy = listManager?.getFocusStrategy(); + const isVirtuallyFocused = + focusStrategy === 'VIRTUAL_WITH_SELECTED' && isFocused.value && listManager?.isPopupOpen(); return withRefCapture( { id: optionId, role: 'option', - tabindex: isFocused.value ? '0' : '-1', - 'aria-selected': isMultiple ? undefined : isSelected.value, - 'aria-checked': isMultiple ? isSelected.value : undefined, + tabindex: isFocused.value && focusStrategy === 'DOM_FOCUS' ? '0' : '-1', + 'aria-selected': + isVirtuallyFocused || (isMultiple ? undefined : isSelected.value && focusStrategy === 'DOM_FOCUS'), + 'aria-checked': + isMultiple || focusStrategy === 'VIRTUAL_WITH_SELECTED' ? isSelected.value || undefined : undefined, 'aria-disabled': isDisabled.value || undefined, ...handlers, }, diff --git a/packages/playground/src/App.vue b/packages/playground/src/App.vue index 01246625..4b891320 100644 --- a/packages/playground/src/App.vue +++ b/packages/playground/src/App.vue @@ -1,7 +1,7 @@ + + + + diff --git a/packages/playground/src/components/InputSelect.vue b/packages/playground/src/components/InputSelect.vue index 2857ff00..d19b1a33 100644 --- a/packages/playground/src/components/InputSelect.vue +++ b/packages/playground/src/components/InputSelect.vue @@ -2,6 +2,7 @@ import { useSelect, SelectProps } from '@formwerk/core'; import OptionItem from './OptionItem.vue'; import OptionGroup from './OptionGroup.vue'; +import { watchEffect } from 'vue'; export interface TheProps extends SelectProps { groups?: { items: TOption[]; label: string }[]; @@ -11,8 +12,22 @@ export interface TheProps extends SelectProps const props = defineProps>(); -const { triggerProps, labelProps, errorMessageProps, isTouched, displayError, fieldValue, popupProps } = - useSelect(props); +const { + triggerProps, + labelProps, + errorMessageProps, + isTouched, + displayError, + fieldValue, + popupProps, + selectedOptions, + selectedOption, +} = useSelect(props); + +watchEffect(() => { + console.log(selectedOptions.value); + console.log(selectedOption.value); +});