Skip to content

Commit

Permalink
feat: basic distinction between option and value with value extractor
Browse files Browse the repository at this point in the history
  • Loading branch information
logaretm committed Aug 27, 2024
1 parent 6f73cbf commit 6179a3c
Show file tree
Hide file tree
Showing 5 changed files with 67 additions and 65 deletions.
22 changes: 7 additions & 15 deletions packages/core/src/useSelect/useListBox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ export interface OptionRegistration<TValue> {
getValue(): TValue;
focus(): void;
toggleSelected(): void;
unfocus(): void;
}

export interface OptionRegistrationWithId<TValue> extends OptionRegistration<TValue> {
Expand All @@ -40,9 +39,9 @@ export interface ListManagerCtx<TOption = unknown> {

export const ListManagerKey: InjectionKey<ListManagerCtx> = Symbol('ListManagerKey');

export function useListBox<TOption>(_props: Reactivify<ListBoxProps<TOption>>) {
export function useListBox<TOption, TValue = TOption>(_props: Reactivify<ListBoxProps<TOption>>) {
const props = normalizeProps(_props);
const options = shallowRef<OptionRegistrationWithId<TOption>[]>([]);
const options = shallowRef<OptionRegistrationWithId<TValue>[]>([]);
const isOpen = shallowRef(false);
const isShiftPressed = useKeyPressed(['ShiftLeft', 'ShiftRight'], () => !isOpen.value);
const isMetaPressed = useKeyPressed(
Expand All @@ -51,7 +50,7 @@ export function useListBox<TOption>(_props: Reactivify<ListBoxProps<TOption>>) {
);

const listManager: ListManagerCtx = {
useOptionRegistration(init: OptionRegistration<TOption>) {
useOptionRegistration(init: OptionRegistration<TValue>) {
const id = useUniqId(FieldTypePrefixes.Option);
options.value.push({ ...init, id });

Expand Down Expand Up @@ -94,7 +93,6 @@ export function useListBox<TOption>(_props: Reactivify<ListBoxProps<TOption>>) {
props.onToggleBefore?.();

if (isMetaPressed.value) {
unfocusCurrent();
options.value.at(0)?.focus();
}
}
Expand All @@ -105,7 +103,6 @@ export function useListBox<TOption>(_props: Reactivify<ListBoxProps<TOption>>) {
props.onToggleAfter?.();

if (isMetaPressed.value) {
unfocusCurrent();
options.value.at(-1)?.focus();
}
}
Expand All @@ -123,15 +120,12 @@ export function useListBox<TOption>(_props: Reactivify<ListBoxProps<TOption>>) {
}
}

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);
Expand All @@ -143,7 +137,7 @@ export function useListBox<TOption>(_props: Reactivify<ListBoxProps<TOption>>) {
}

function focusPrev() {
const currentlyFocusedIdx = unfocusCurrent();
const currentlyFocusedIdx = findFocused();
// Focus first one if none is focused
if (currentlyFocusedIdx === -1) {
focusAndToggleIfShiftPressed(0);
Expand All @@ -163,8 +157,6 @@ export function useListBox<TOption>(_props: Reactivify<ListBoxProps<TOption>>) {
});

watch(isOpen, async value => {
const currentlyFocused = options.value.findIndex(o => o.isFocused());
options.value[currentlyFocused]?.unfocus();
if (!value) {
return;
}
Expand Down
29 changes: 16 additions & 13 deletions packages/core/src/useSelect/useOption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,60 +18,60 @@ interface OptionDomProps {
}

export interface OptionProps<TValue> {
value: TValue;
option: TValue;

disabled?: boolean;
}

export function useOption<TValue>(_props: Reactivify<OptionProps<TValue>>, elementRef?: Ref<Maybe<HTMLElement>>) {
export function useOption<TOption>(_props: Reactivify<OptionProps<TOption>>, elementRef?: Ref<Maybe<HTMLElement>>) {
const props = normalizeProps(_props);
const optionRef = elementRef || ref<HTMLElement>();
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') {
Expand All @@ -80,6 +80,9 @@ export function useOption<TValue>(_props: Reactivify<OptionProps<TValue>>, eleme
toggleSelected();
}
},
onBlur() {
isFocused.value = false;
},
};

const optionProps = computed<OptionDomProps>(() => {
Expand Down
48 changes: 27 additions & 21 deletions packages/core/src/useSelect/useSelect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,21 @@ import { useLabel } from '../a11y/useLabel';
import { FieldTypePrefixes } from '../constants';
import { useErrorDisplay } from '../useFormField/useErrorDisplay';

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

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

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

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

getValue?(option: TOption): TValue;
}

export interface SelectTriggerDomProps extends AriaLabelableProps {
Expand All @@ -37,22 +39,26 @@ export interface SelectTriggerDomProps extends AriaLabelableProps {
'aria-expanded': boolean;
}

export interface SelectionContext<TValue> {
export interface SelectionContext<TOption, TValue = TOption> {
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<SelectionContext<unknown>> = Symbol('SelectionContextKey');

const MENU_OPEN_KEYS = ['Enter', 'Space', 'ArrowDown', 'ArrowUp'];

export function useSelect<TOption>(_props: Reactivify<SelectProps<TOption>, 'schema'>) {
export function useSelect<TOption, TValue = TOption>(
_props: Reactivify<SelectProps<TOption, TValue>, 'schema' | 'getValue'>,
) {
const inputId = useUniqId(FieldTypePrefixes.Select);
const props = normalizeProps(_props, ['schema']);
const field = useFormField<Arrayable<TOption>>({
const props = normalizeProps(_props, ['schema', 'getValue']);
const evaluate = props.getValue || ((opt: TOption) => opt as unknown as TValue);
const field = useFormField<Arrayable<TValue>>({
path: props.name,
initialValue: toValue(props.modelValue) as Arrayable<TOption>,
initialValue: toValue(props.modelValue) as Arrayable<TValue>,
disabled: props.disabled,

schema: props.schema,
Expand All @@ -63,8 +69,8 @@ export function useSelect<TOption>(_props: Reactivify<SelectProps<TOption>, 'sch
for: inputId,
});

let lastRecentlySelectedOption: TOption | undefined;
const { listBoxProps, isOpen, options, isShiftPressed } = useListBox<TOption>({
let lastRecentlySelectedOption: TValue | undefined;
const { listBoxProps, isOpen, options, isShiftPressed } = useListBox<TOption, TValue>({
...props,
onToggleAll: toggleAll,
onToggleBefore: toggleBefore,
Expand All @@ -87,14 +93,15 @@ export function useSelect<TOption>(_props: Reactivify<SelectProps<TOption>, 'sch
return options.value.findIndex(opt => opt.isSelected());
}

const selectionCtx: SelectionContext<TOption> = {
const selectionCtx: SelectionContext<TOption, TValue> = {
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;
Expand All @@ -106,7 +113,7 @@ export function useSelect<TOption>(_props: Reactivify<SelectProps<TOption>, 'sch

if (!isShiftPressed.value) {
lastRecentlySelectedOption = optionValue;
const nextValue = toggleValueSelection<TOption>(fieldValue.value ?? [], optionValue, force);
const nextValue = toggleValueSelection<TValue>(fieldValue.value ?? [], optionValue, force);
setValue(nextValue);
updateValidity();
return;
Expand Down Expand Up @@ -170,11 +177,10 @@ export function useSelect<TOption>(_props: Reactivify<SelectProps<TOption>, '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 = {
Expand Down
29 changes: 15 additions & 14 deletions packages/playground/src/App.vue
Original file line number Diff line number Diff line change
@@ -1,20 +1,11 @@
<template>
<div class="flex gap-4 relative p-8">
<form class="w-full">
<select multiple>
<option value="volvo">Volvo</option>
<option value="saab">Saab</option>
<option value="mercedes">Mercedes</option>
<option value="audi">Audi</option>
<option value="audi">Audi</option>
</select>

<InputSelect
name="select"
label="Select Input"
multiple
:options="['Hello', 'World', 'Foo', 'Bar', 'Test', 'GG', 'BIG', 'UUUGE']"
/>
<InputSelect name="select" label="Select Input" :options="options" :get-value="option => option.code" multiple>
<template #option="{ option }">
<div>{{ option.name }}</div>
</template>
</InputSelect>

<!-- <div class="flex flex-col gap-4">-->
<!-- <InputText-->
Expand Down Expand Up @@ -160,6 +151,16 @@ const { values } = useForm({
initialValues: getInitials,
});
const options = [
{ name: 'Egypt', code: 'EG' },
{ name: 'United States', code: 'US' },
{ name: 'Canada', code: 'CA' },
{ name: 'Brazil', code: 'BR' },
{ name: 'Germany', code: 'DE' },
{ name: 'France', code: 'FR' },
{ name: 'Japan', code: 'JP' },
];
async function getInitials() {
await sleep(2000);
Expand Down
4 changes: 2 additions & 2 deletions packages/playground/src/components/InputSelect.vue
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ onMounted(() => {

<div ref="popoverEl" v-bind="listBoxProps" popover class="listbox">
<div v-for="opt in options" :key="opt">
<OptionItem :value="opt">
{{ opt }}
<OptionItem :option="opt">
<slot name="option" :option="opt" />
</OptionItem>
</div>
</div>
Expand Down

0 comments on commit 6179a3c

Please sign in to comment.