-
-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: initial useSelect implementation
- Loading branch information
Showing
13 changed files
with
553 additions
and
125 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
export * from './useSelect'; | ||
export * from './useOption'; | ||
export type * from './useListBox'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; | ||
} |
Oops, something went wrong.