From 6179a3c60883feebdf317b5c7ec215ba29e8467c Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 27 Aug 2024 03:48:38 +0300 Subject: [PATCH] feat: basic distinction between option and value with value extractor --- packages/core/src/useSelect/useListBox.ts | 22 +++------ packages/core/src/useSelect/useOption.ts | 29 ++++++----- packages/core/src/useSelect/useSelect.ts | 48 +++++++++++-------- packages/playground/src/App.vue | 29 +++++------ .../playground/src/components/InputSelect.vue | 4 +- 5 files changed, 67 insertions(+), 65 deletions(-) diff --git a/packages/core/src/useSelect/useListBox.ts b/packages/core/src/useSelect/useListBox.ts index b0f411d9..a9e2f2a2 100644 --- a/packages/core/src/useSelect/useListBox.ts +++ b/packages/core/src/useSelect/useListBox.ts @@ -27,7 +27,6 @@ export interface OptionRegistration { getValue(): TValue; focus(): void; toggleSelected(): void; - unfocus(): void; } export interface OptionRegistrationWithId extends OptionRegistration { @@ -40,9 +39,9 @@ export interface ListManagerCtx { export const ListManagerKey: InjectionKey = Symbol('ListManagerKey'); -export function useListBox(_props: Reactivify>) { +export function useListBox(_props: Reactivify>) { const props = normalizeProps(_props); - const options = shallowRef[]>([]); + const options = shallowRef[]>([]); const isOpen = shallowRef(false); const isShiftPressed = useKeyPressed(['ShiftLeft', 'ShiftRight'], () => !isOpen.value); const isMetaPressed = useKeyPressed( @@ -51,7 +50,7 @@ export function useListBox(_props: Reactivify>) { ); const listManager: ListManagerCtx = { - useOptionRegistration(init: OptionRegistration) { + useOptionRegistration(init: OptionRegistration) { const id = useUniqId(FieldTypePrefixes.Option); options.value.push({ ...init, id }); @@ -94,7 +93,6 @@ export function useListBox(_props: Reactivify>) { props.onToggleBefore?.(); if (isMetaPressed.value) { - unfocusCurrent(); options.value.at(0)?.focus(); } } @@ -105,7 +103,6 @@ export function useListBox(_props: Reactivify>) { props.onToggleAfter?.(); if (isMetaPressed.value) { - unfocusCurrent(); options.value.at(-1)?.focus(); } } @@ -123,15 +120,12 @@ export function useListBox(_props: Reactivify>) { } } - function unfocusCurrent() { - const currentlyFocusedIdx = options.value.findIndex(o => o.isFocused()); - options.value[currentlyFocusedIdx]?.unfocus(); - - return currentlyFocusedIdx; + function findFocused() { + return options.value.findIndex(o => o.isFocused()); } function focusNext() { - const currentlyFocusedIdx = unfocusCurrent(); + const currentlyFocusedIdx = findFocused(); // Focus first one if none is focused if (currentlyFocusedIdx === -1) { focusAndToggleIfShiftPressed(0); @@ -143,7 +137,7 @@ export function useListBox(_props: Reactivify>) { } function focusPrev() { - const currentlyFocusedIdx = unfocusCurrent(); + const currentlyFocusedIdx = findFocused(); // Focus first one if none is focused if (currentlyFocusedIdx === -1) { focusAndToggleIfShiftPressed(0); @@ -163,8 +157,6 @@ export function useListBox(_props: Reactivify>) { }); watch(isOpen, async value => { - const currentlyFocused = options.value.findIndex(o => o.isFocused()); - options.value[currentlyFocused]?.unfocus(); if (!value) { return; } diff --git a/packages/core/src/useSelect/useOption.ts b/packages/core/src/useSelect/useOption.ts index 7467b920..b0586b93 100644 --- a/packages/core/src/useSelect/useOption.ts +++ b/packages/core/src/useSelect/useOption.ts @@ -18,60 +18,60 @@ interface OptionDomProps { } export interface OptionProps { - value: TValue; + option: TValue; disabled?: boolean; } -export function useOption(_props: Reactivify>, elementRef?: Ref>) { +export function useOption(_props: Reactivify>, elementRef?: Ref>) { const props = normalizeProps(_props); const optionRef = elementRef || ref(); + const isFocused = shallowRef(false); const selectionCtx = inject(SelectionContextKey, null); + const listManager = inject(ListManagerKey, null); + const isSelected = computed(() => selectionCtx?.isValueSelected(getValue()) ?? false); if (!selectionCtx) { warn( 'An option component must exist within a Selection Context. Did you forget to call `useSelect` in a parent component?', ); } - 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); - - function toggleSelected() { - selectionCtx?.toggleOption(toValue(props.value)); + function getValue() { + return selectionCtx?.evaluateOption(toValue(props.option)); } - const isSelected = computed(() => selectionCtx?.isValueSelected(toValue(props.value)) ?? false); const id = listManager?.useOptionRegistration({ toggleSelected, isDisabled: () => !!toValue(props.disabled), isSelected: () => isSelected.value, isFocused: () => isFocused.value, - getValue: () => toValue(props.value), + getValue, focus: () => { isFocused.value = true; nextTick(() => { optionRef.value?.focus(); }); }, - unfocus() { - isFocused.value = false; - }, }) ?? useUniqId(FieldTypePrefixes.Option); + function toggleSelected() { + selectionCtx?.toggleValue(getValue()); + } + const handlers = { onClick() { if (toValue(props.disabled)) { return; } - selectionCtx?.toggleOption(toValue(props.value)); + selectionCtx?.toggleValue(getValue()); }, onKeydown(e: KeyboardEvent) { if (e.code === 'Space' || e.code === 'Enter') { @@ -80,6 +80,9 @@ export function useOption(_props: Reactivify>, eleme toggleSelected(); } }, + onBlur() { + isFocused.value = false; + }, }; const optionProps = computed(() => { diff --git a/packages/core/src/useSelect/useSelect.ts b/packages/core/src/useSelect/useSelect.ts index 3a9d9ae3..63f7e342 100644 --- a/packages/core/src/useSelect/useSelect.ts +++ b/packages/core/src/useSelect/useSelect.ts @@ -16,19 +16,21 @@ import { useLabel } from '../a11y/useLabel'; import { FieldTypePrefixes } from '../constants'; import { useErrorDisplay } from '../useFormField/useErrorDisplay'; -export interface SelectProps { +export interface SelectProps { label: string; name?: string; description?: string; - modelValue?: Arrayable; + modelValue?: Arrayable; disabled?: boolean; options: TOption[]; multiple?: boolean; orientation?: Orientation; - schema?: TypedSchema>; + schema?: TypedSchema>; + + getValue?(option: TOption): TValue; } export interface SelectTriggerDomProps extends AriaLabelableProps { @@ -37,22 +39,26 @@ export interface SelectTriggerDomProps extends AriaLabelableProps { 'aria-expanded': boolean; } -export interface SelectionContext { +export interface SelectionContext { isValueSelected(value: TValue): boolean; isMultiple(): boolean; - toggleOption(value: TValue, force?: boolean): void; + toggleValue(value: TValue, force?: boolean): void; + evaluateOption(option: TOption): TValue; } export const SelectionContextKey: InjectionKey> = Symbol('SelectionContextKey'); const MENU_OPEN_KEYS = ['Enter', 'Space', 'ArrowDown', 'ArrowUp']; -export function useSelect(_props: Reactivify, 'schema'>) { +export function useSelect( + _props: Reactivify, 'schema' | 'getValue'>, +) { const inputId = useUniqId(FieldTypePrefixes.Select); - const props = normalizeProps(_props, ['schema']); - const field = useFormField>({ + const props = normalizeProps(_props, ['schema', 'getValue']); + const evaluate = props.getValue || ((opt: TOption) => opt as unknown as TValue); + const field = useFormField>({ path: props.name, - initialValue: toValue(props.modelValue) as Arrayable, + initialValue: toValue(props.modelValue) as Arrayable, disabled: props.disabled, schema: props.schema, @@ -63,8 +69,8 @@ export function useSelect(_props: Reactivify, 'sch for: inputId, }); - let lastRecentlySelectedOption: TOption | undefined; - const { listBoxProps, isOpen, options, isShiftPressed } = useListBox({ + let lastRecentlySelectedOption: TValue | undefined; + const { listBoxProps, isOpen, options, isShiftPressed } = useListBox({ ...props, onToggleAll: toggleAll, onToggleBefore: toggleBefore, @@ -87,14 +93,15 @@ export function useSelect(_props: Reactivify, 'sch return options.value.findIndex(opt => opt.isSelected()); } - const selectionCtx: SelectionContext = { + const selectionCtx: SelectionContext = { isMultiple: () => toValue(props.multiple) ?? false, - isValueSelected(value: TOption): boolean { - const selectedOptions = normalizeArrayable(fieldValue.value ?? []); + evaluateOption: evaluate, + isValueSelected(value): boolean { + const values = normalizeArrayable(fieldValue.value ?? []); - return selectedOptions.some(opt => isEqual(opt, value)); + return values.some(item => isEqual(item, value)); }, - toggleOption(optionValue: TOption, force?: boolean) { + toggleValue(optionValue, force) { const isMultiple = toValue(props.multiple); if (!isMultiple) { lastRecentlySelectedOption = optionValue; @@ -106,7 +113,7 @@ export function useSelect(_props: Reactivify, 'sch if (!isShiftPressed.value) { lastRecentlySelectedOption = optionValue; - const nextValue = toggleValueSelection(fieldValue.value ?? [], optionValue, force); + const nextValue = toggleValueSelection(fieldValue.value ?? [], optionValue, force); setValue(nextValue); updateValidity(); return; @@ -170,11 +177,10 @@ export function useSelect(_props: Reactivify, 'sch 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 nextIdx = Math.max(0, Math.min(options.value.length - 1, getSelectedIdx() + relativeIdx)); + const option = options.value[nextIdx]; + selectionCtx.toggleValue(option.getValue()); } const handlers = { diff --git a/packages/playground/src/App.vue b/packages/playground/src/App.vue index 1df8d705..1dc2bd71 100644 --- a/packages/playground/src/App.vue +++ b/packages/playground/src/App.vue @@ -1,20 +1,11 @@