Skip to content

Commit

Permalink
feat: initial useSelect implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
logaretm committed Aug 24, 2024
1 parent 4f9b891 commit 984d5c8
Show file tree
Hide file tree
Showing 13 changed files with 553 additions and 125 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 @@ -9,6 +9,7 @@ export const FieldTypePrefixes = {
Slider: 'sl',
SearchField: 'sf',
FormGroup: 'fg',
Select: 'se',
} as const;

export const NOOP = () => {};
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ export * from './useRadio';
export * from './useSlider';
export * from './useCheckbox';
export * from './useNumberField';
export * from './useSpinButton';
export * from './useSelect';
export * from './types';
export * from './config';
export * from './useForm';
export * from './useFormGroup';
export * from './validation';
export { normalizePath } from './utils/path';

// Internals should export types only
export type * from './useSpinButton';
2 changes: 2 additions & 0 deletions packages/core/src/types/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ export type NormalizedProps<TProps extends object, Exclude extends keyof TProps
: TProps[TProp] extends MaybeRefOrGetter<infer TValue>
? Getter<TValue>
: Getter<TProps[TProp]>;
} & {
__isFwNormalized__: boolean;
};

export type Arrayable<T> = T | T[];
Expand Down
11 changes: 2 additions & 9 deletions packages/core/src/useCheckbox/useCheckboxGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
normalizeProps,
isEqual,
createAccessibleErrorMessageProps,
toggleValueSelection,
} from '../utils/common';
import { useLocale } from '../i18n/useLocale';
import { FormField, useFormField } from '../useFormField';
Expand Down Expand Up @@ -133,15 +134,7 @@ export function useCheckboxGroup<TCheckbox>(_props: Reactivify<CheckboxGroupProp
}

function toggleValue(value: TCheckbox, force?: boolean) {
const nextValue = [...(fieldValue.value ?? [])];
const idx = nextValue.findIndex(v => isEqual(v, value));
const shouldAdd = force ?? idx === -1;

if (shouldAdd) {
nextValue.push(value);
} else {
nextValue.splice(idx, 1);
}
const nextValue = toggleValueSelection(fieldValue.value ?? [], value, force);

setValue(nextValue);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/useFormGroup/useFormGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { FormKey } from '../useForm';
import { useValidationProvider } from '../validation/useValidationProvider';
import { FormValidationMode } from '../useForm/formContext';
import { prefixPath as _prefixPath } from '../utils/path';
import { getConfig } from '@core/config';
import { getConfig } from '../config';

export interface FormGroupProps<TInput extends FormObject = FormObject, TOutput extends FormObject = TInput> {
name: string;
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/useSelect/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './useSelect';
export * from './useOption';
export type * from './useListBox';
68 changes: 68 additions & 0 deletions packages/core/src/useSelect/useListBox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Orientation, Reactivify } from '../types';
import { computed, ref, Ref, shallowRef, toValue } from 'vue';
import { getNextCycleArrIdx, isEqual, normalizeProps, withRefCapture } from '../utils/common';

export interface ListBoxProps<TOption> {
options: TOption[];
multiple?: boolean;
orientation?: Orientation;
}

export interface ListBoxDomProps {
role: 'listbox';
'aria-multiselectable'?: boolean;
}

export function useListBox<TOption>(_props: Reactivify<ListBoxProps<TOption>>, elementRef?: Ref<HTMLElement>) {
const listBoxRef = elementRef ?? ref();
const props = normalizeProps(_props);
const getOptions = () => toValue(props.options) ?? [];
const highlightedOption = shallowRef<TOption>();

function highlightNext() {
const options = getOptions();
if (!highlightedOption.value) {
highlightedOption.value = options[0];
return;
}

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

function highlightPrev() {
const options = getOptions();
if (!highlightedOption.value) {
highlightedOption.value = options[0];
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 listBoxProps = computed<ListBoxDomProps>(() => {
return withRefCapture(
{
role: 'listbox',
'aria-multiselectable': toValue(props.multiple) ?? undefined,
},
listBoxRef,
elementRef,
);
});

return {
listBoxProps,
highlightedOption,
isHighlighted,
highlightNext,
highlightPrev,
};
}
59 changes: 59 additions & 0 deletions packages/core/src/useSelect/useOption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Reactivify } from '../types';
import { computed, inject, toValue } from 'vue';
import { SelectionContextKey } from './useSelect';
import { normalizeProps, warn } from '../utils/common';

interface OptionDomProps {
role: 'option';

// Used when the listbox allows single selection
'aria-selected'?: boolean;
// Used when the listbox allows multiple selections
'aria-checked'?: boolean;
}

export interface OptionProps<TValue> {
value: TValue;
disabled?: boolean;
}

export function useOption<TValue>(_props: Reactivify<OptionProps<TValue>>) {
const props = normalizeProps(_props);
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 handlers = {
onClick() {
if (toValue(props.disabled)) {
return;
}

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 {
optionProps,
isSelected,
isHighlighted,
};
}
191 changes: 191 additions & 0 deletions packages/core/src/useSelect/useSelect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { computed, InjectionKey, provide, shallowRef, toValue } from 'vue';
import { useFormField } from '../useFormField';
import { AriaLabelableProps, Arrayable, Orientation, Reactivify, TypedSchema } from '../types';
import {
createAccessibleErrorMessageProps,
createDescribedByProps,
isEqual,
normalizeArrayable,
normalizeProps,
toggleValueSelection,
useUniqId,
} from '../utils/common';
import { useInputValidity } from '../validation';
import { useListBox } from './useListBox';
import { useLabel } from '../a11y/useLabel';
import { FieldTypePrefixes } from '../constants';
import { useErrorDisplay } from '../useFormField/useErrorDisplay';

export interface SelectProps<TOption> {
label: string;
name?: string;
description?: string;

modelValue?: Arrayable<TOption>;
disabled?: boolean;

options: TOption[];
multiple?: boolean;
orientation?: Orientation;

schema?: TypedSchema<Arrayable<TOption>>;
}

export interface SelectTriggerDomProps extends AriaLabelableProps {
id: string;
'aria-haspopup': 'listbox';
'aria-expanded': boolean;
}

export interface SelectionContext<TValue> {
isSelected(value: TValue): boolean;
isMultiple(): boolean;
isHighlighted(opt: TValue): boolean;
toggleOption(value: TValue): void;
}

export const SelectionContextKey: InjectionKey<SelectionContext<unknown>> = Symbol('SelectionContextKey');

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,
initialValue: toValue(props.modelValue) as Arrayable<TOption>,
disabled: props.disabled,

schema: props.schema,
});

const { labelProps, labelledByProps } = useLabel({
label: props.label,
for: inputId,
});

const { listBoxProps, isHighlighted, highlightPrev, highlightNext, highlightedOption } = useListBox<TOption>(props);
const { updateValidity } = useInputValidity({ field });
const { fieldValue, setValue, isTouched, errorMessage } = field;
const { displayError } = useErrorDisplay(field);
const { descriptionProps, describedByProps } = createDescribedByProps({
inputId,
description: props.description,
});
const { accessibleErrorProps, errorMessageProps } = createAccessibleErrorMessageProps({
inputId,
errorMessage,
});

function getSelectedIdx() {
return toValue(props.options).findIndex(opt => isEqual(opt, fieldValue.value));
}

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

return selectedOptions.some(opt => isEqual(opt, value));
},
isHighlighted,
toggleOption(optionValue: TOption, force?: boolean) {
const isMultiple = toValue(props.multiple);
if (!isMultiple) {
setValue(optionValue);
updateValidity();
isOpen.value = false;
return;
}

const nextValue = toggleValueSelection<TOption>(fieldValue.value ?? [], optionValue, force);
setValue(nextValue);
updateValidity();
},
};

provide(SelectionContextKey, selectionCtx);

function setSelectedByRelativeIdx(relativeIdx: number) {
const options = toValue(props.options);
// Clamps selection between 0 and the array length
const nextIdx = Math.max(0, Math.min(options.length - 1, getSelectedIdx() + relativeIdx));
const option = options[nextIdx];
selectionCtx.toggleOption(option);
}

const handlers = {
onClick() {
isOpen.value = !isOpen.value;
},
onKeydown(e: KeyboardEvent) {
if (!isOpen.value && MENU_OPEN_KEYS.includes(e.code)) {
e.preventDefault();
isOpen.value = true;
return;
}

if (!selectionCtx.isMultiple() && !isOpen.value) {
if (e.code === 'ArrowLeft') {
e.preventDefault();
setSelectedByRelativeIdx(-1);
return;
}

if (e.code === 'ArrowRight') {
e.preventDefault();
setSelectedByRelativeIdx(1);
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;
}
},
};

const triggerProps = computed<SelectTriggerDomProps>(() => {
return {
...labelledByProps.value,
...describedByProps.value,
...accessibleErrorProps.value,
id: inputId,
tabindex: '0',
'aria-haspopup': 'listbox',
'aria-expanded': isOpen.value,
...handlers,
};
});

return {
isOpen,
triggerProps,
labelProps,
listBoxProps,
fieldValue,
errorMessage,
isTouched,
errorMessageProps,
descriptionProps,
displayError,
};
}
Loading

0 comments on commit 984d5c8

Please sign in to comment.