Skip to content

Commit

Permalink
feat: implement focus strategy
Browse files Browse the repository at this point in the history
  • Loading branch information
logaretm committed Jan 11, 2025
1 parent d870118 commit a488d0b
Show file tree
Hide file tree
Showing 8 changed files with 185 additions and 42 deletions.
62 changes: 38 additions & 24 deletions packages/core/src/useComboBox/useComboBox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,20 +110,22 @@ export function useComboBox(_props: Reactivify<ComboBoxProps, 'schema' | 'filter
errorMessage,
});

const { listBoxId, listBoxProps, isPopupOpen, listBoxEl, selectedOption } = useListBox<string>({
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<string>({
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) {
Expand All @@ -140,26 +142,38 @@ export function useComboBox(_props: Reactivify<ComboBoxProps, 'schema' | 'filter
return;
}

// Close the popup when either Enter or Escape is pressed if it is open.
if (['Enter', 'Escape'].includes(evt.code) && isPopupOpen.value) {
// Clear the input value when Escape is pressed if the popup is not open.
if (evt.code === 'Escape') {
evt.preventDefault();
isPopupOpen.value = false;
if (!isPopupOpen.value) {
setValue('');
} else {
isPopupOpen.value = false;
}

return;
}

// Open the popup when vertical arrow keys are pressed and the popup is not open.
if (['ArrowDown', 'ArrowUp'].includes(evt.code) && !isPopupOpen.value) {
evt.preventDefault();
isPopupOpen.value = true;
if (evt.code === 'Enter') {
if (isPopupOpen.value) {
findFocusedOption()?.toggleSelected();
}

return;
}

// Clear the input value when Escape is pressed if the popup is not open.
if (evt.code === 'Escape' && !isPopupOpen.value) {
// Open the popup when vertical arrow keys are pressed and the popup is not open.
if (['ArrowDown', 'ArrowUp'].includes(evt.code)) {
evt.preventDefault();
setValue('');

if (!isPopupOpen.value) {
isPopupOpen.value = true;
return;
}

// eslint-disable-next-line @typescript-eslint/no-unused-expressions
evt.code === 'ArrowDown' ? focusNext() : focusPrev();

return;
}

Expand Down
25 changes: 22 additions & 3 deletions packages/core/src/useListBox/useListBox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@ import { usePopoverController } from '../helpers/usePopoverController';
import { FieldTypePrefixes } from '../constants';
import { useBasicOptionFinder } from './basicOptionFinder';

export type FocusStrategy = 'DOM_FOCUS' | 'VIRTUAL_WITH_SELECTED';

export interface ListBoxProps<TOption, TValue = TOption> {
label: string;
isValueSelected(value: TValue): boolean;
handleToggleValue(value: TValue): void;

focusStrategy?: FocusStrategy;
labeledBy?: string;
multiple?: boolean;
orientation?: Orientation;
Expand All @@ -36,6 +39,7 @@ export interface OptionRegistration<TValue> {
isDisabled(): boolean;
getValue(): TValue;
focus(): void;
unfocus(): void;
toggleSelected(): void;
}

Expand All @@ -48,6 +52,8 @@ export interface ListManagerCtx<TValue = unknown> {
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
Expand Down Expand Up @@ -84,6 +90,8 @@ export function useListBox<TOption, TValue = TOption>(
},
isValueSelected: props.isValueSelected,
toggleValue: props.handleToggleValue,
getFocusStrategy: () => toValue(props.focusStrategy) ?? 'DOM_FOCUS',
isPopupOpen: () => isOpen.value,
};

provide(ListManagerKey, listManager);
Expand Down Expand Up @@ -147,18 +155,26 @@ export function useListBox<TOption, TValue = TOption>(
};

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);
Expand All @@ -168,7 +184,7 @@ export function useListBox<TOption, TValue = TOption>(
}

function focusPrev() {
const currentlyFocusedIdx = findFocused();
const currentlyFocusedIdx = findFocusedIdx();
if (currentlyFocusedIdx === -1) {
focusNext();
return;
Expand Down Expand Up @@ -242,5 +258,8 @@ export function useListBox<TOption, TValue = TOption>(
listBoxEl,
selectedOption,
selectedOptions,
focusNext,
focusPrev,
findFocusedOption,
};
}
24 changes: 19 additions & 5 deletions packages/core/src/useOption/useOption.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -63,10 +63,19 @@ export function useOption<TOption>(_props: Reactivify<OptionProps<TOption>>, 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();
});
},
});
Expand Down Expand Up @@ -101,14 +110,19 @@ export function useOption<TOption>(_props: Reactivify<OptionProps<TOption>>, ele

const optionProps = computed<OptionDomProps>(() => {
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,
},
Expand Down
18 changes: 13 additions & 5 deletions packages/playground/src/App.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useForm } from '@formwerk/core';
import { ref } from 'vue';
import CustomField from './components/CustomField.vue';
import ComboBox from './components/ComboBox.vue';
import Select from './components/InputSelect.vue';
const { handleSubmit, values } = useForm({
scrollToInvalidFieldOnSubmit: {
Expand All @@ -18,9 +18,17 @@ const onSubmit = handleSubmit(data => {

<template>
<div class="flex flex-col">
<CustomField name="custom" label="Custom Field" />

<pre>{{ values }}</pre>
<Select
name="select"
label="Select"
multiple
:options="[
{ label: 'Option 1', value: 'Option 1' },
{ label: 'Option 2', value: 'Option 2' },
{ label: 'Option 3', value: 'Option 3' },
]"
/>
<ComboBox name="combo" label="Combo" :options="['Option 1', 'Option 2', 'Option 3']" />

<button class="bg-blue-500 text-white p-2 rounded-md" @click="onSubmit">Submit</button>
</div>
Expand Down
61 changes: 61 additions & 0 deletions packages/playground/src/components/ComboBox.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { useComboBox, ComboBoxProps } from '@formwerk/core';
import OptionGroup from './OptionGroup.vue';
import Option from './OptionItem.vue';
interface Props extends ComboBoxProps {
options?: string[];
}
const props = defineProps<Props>();
const { inputProps, listBoxProps, labelProps, buttonProps, errorMessageProps, errorMessage, descriptionProps } =
useComboBox(props);
</script>

<template>
<div class="select-field">
<p v-bind="labelProps">{{ label }}</p>

<div class="flex items-center gap-2">
<input v-bind="inputProps" type="text" />

<button v-bind="buttonProps">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<path
d="M184.49,167.51a12,12,0,0,1,0,17l-48,48a12,12,0,0,1-17,0l-48-48a12,12,0,0,1,17-17L128,207l39.51-39.52A12,12,0,0,1,184.49,167.51Zm-96-79L128,49l39.51,39.52a12,12,0,0,0,17-17l-48-48a12,12,0,0,0-17,0l-48,48a12,12,0,0,0,17,17Z"
></path>
</svg>
</button>
</div>

<div v-bind="listBoxProps" popover>
<Option v-for="option in options" :key="option" :label="option" :value="option" />
</div>

<p v-if="errorMessage" v-bind="errorMessageProps" class="error-message">{{ errorMessage }}</p>
<p v-else-if="description" v-bind="descriptionProps">{{ description }}</p>
</div>
</template>

<style scoped>
button {
svg {
width: 16px;
height: 16px;
margin-left: auto;
}
}
[popover] {
position-anchor: --combobox;
position-area: bottom;
inset: 0;
margin: 0;
background: black;
}
input {
anchor-name: --combobox;
}
</style>
19 changes: 17 additions & 2 deletions packages/playground/src/components/InputSelect.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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<TOption, TValue> extends SelectProps<TOption, TValue> {
groups?: { items: TOption[]; label: string }[];
Expand All @@ -11,8 +12,22 @@ export interface TheProps<TOption, TValue> extends SelectProps<TOption, TValue>
const props = defineProps<TheProps<TOption, TValue>>();
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);
});
</script>

<template>
Expand Down
7 changes: 5 additions & 2 deletions packages/playground/src/components/OptionItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,12 @@ const { optionProps } = useOption(props);
@apply border-emerald-500 outline-none;
}
&[aria-selected='true'],
&[aria-checked='true'] {
&[aria-selected='true'] {
@apply bg-emerald-500 text-white;
}
&[aria-checked='true'] {
@apply bg-purple-500 text-white;
}
}
</style>
11 changes: 10 additions & 1 deletion packages/starter-kits/minimal/src/components/SelectField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,16 @@ interface Props extends SelectProps<TValue> {
const props = defineProps<Props>();
const { triggerProps, popupProps, labelProps, errorMessage, errorMessageProps, descriptionProps } = useSelect(props);
const {
triggerProps,
popupProps,
labelProps,
errorMessage,
errorMessageProps,
descriptionProps,
selectedOptions,
selectedOption,
} = useSelect(props);
</script>

<template>
Expand Down

0 comments on commit a488d0b

Please sign in to comment.