Skip to content

Commit

Permalink
feat: implement continguous select
Browse files Browse the repository at this point in the history
  • Loading branch information
logaretm committed Aug 25, 2024
1 parent 8c0f442 commit d4fdb28
Show file tree
Hide file tree
Showing 7 changed files with 114 additions and 26 deletions.
28 changes: 23 additions & 5 deletions packages/core/src/helpers/useEventListener.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,30 @@
import { MaybeRefOrGetter, onBeforeUnmount, toValue, watch } from 'vue';
import { isRef, MaybeRefOrGetter, onBeforeUnmount, toValue, watch } from 'vue';
import { Arrayable, Maybe } from '../types';
import { normalizeArrayable } from '../utils/common';
import { isCallable, normalizeArrayable } from '../utils/common';

interface ListenerOptions {
disabled?: MaybeRefOrGetter<boolean>;
}

export function useEventListener(
targetRef: MaybeRefOrGetter<Maybe<HTMLElement>>,
targetRef: MaybeRefOrGetter<Maybe<EventTarget>>,
event: Arrayable<string>,
listener: EventListener,
opts?: ListenerOptions,
) {
function cleanup(el: HTMLElement) {
function cleanup(el: EventTarget) {
const events = normalizeArrayable(event);

events.forEach(evt => {
el.removeEventListener(evt, listener);
});
}

function setup(el: HTMLElement) {
function setup(el: EventTarget) {
if (toValue(opts?.disabled)) {
return;
}

const events = normalizeArrayable(event);

events.forEach(evt => {
Expand Down Expand Up @@ -44,4 +53,13 @@ export function useEventListener(

stop();
});

if (isCallable(opts?.disabled) || isRef(opts?.disabled)) {
watch(opts.disabled, value => {
const target = toValue(targetRef);
if (!value && target) {
setup(target);
}
});
}
}
39 changes: 39 additions & 0 deletions packages/core/src/helpers/useKeyPressed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { useEventListener } from './useEventListener';
import { MaybeRefOrGetter, shallowRef } from 'vue';
import { Arrayable } from '../types';

export function useKeyPressed(codes: Arrayable<string>, disabled?: MaybeRefOrGetter<boolean>) {
const isPressed = shallowRef(false);
function onKeydown(e: KeyboardEvent) {
if (codes.includes(e.code)) {
isPressed.value = true;
}
}

function onKeyup(e: KeyboardEvent) {
if (codes.includes(e.code)) {
isPressed.value = false;
}
}

useEventListener(
window,
'keydown',
e => {
onKeydown(e as KeyboardEvent);
},
{ disabled },
);

useEventListener(
window,
'keyup',
e => {
const keyEvt = e as KeyboardEvent;
onKeyup(keyEvt);
},
{ disabled: () => !isPressed.value },
);

return isPressed;
}
1 change: 1 addition & 0 deletions packages/core/src/useSelect/useListBox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,5 +125,6 @@ export function useListBox<TOption>(_props: Reactivify<ListBoxProps<TOption>>) {
return {
listBoxProps,
isOpen,
options,
};
}
4 changes: 2 additions & 2 deletions packages/core/src/useSelect/useOption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { Maybe, Reactivify, RovingTabIndex } from '../types';
import { computed, inject, nextTick, ref, Ref, shallowRef, toValue } from 'vue';
import { SelectionContextKey } from './useSelect';
import { normalizeProps, useUniqId, warn, withRefCapture } from '../utils/common';
import { ListManagerKey } from '@core/useSelect/useListBox';
import { FieldTypePrefixes } from '@core/constants';
import { ListManagerKey } from './useListBox';
import { FieldTypePrefixes } from '../constants';

interface OptionDomProps {
id: string;
Expand Down
42 changes: 27 additions & 15 deletions packages/core/src/useSelect/useSelect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useListBox } from './useListBox';
import { useLabel } from '../a11y/useLabel';
import { FieldTypePrefixes } from '../constants';
import { useErrorDisplay } from '../useFormField/useErrorDisplay';
import { useKeyPressed } from '@core/helpers/useKeyPressed';

export interface SelectProps<TOption> {
label: string;
Expand All @@ -39,10 +40,8 @@ export interface SelectTriggerDomProps extends AriaLabelableProps {

export interface SelectionContext<TValue> {
isValueSelected(value: TValue): boolean;
getOptionIndex(value: TValue): number;
isMultiple(): boolean;
toggleOption(value: TValue, force?: boolean): void;
toggleIdx(idx: number, force?: boolean): void;
}

export const SelectionContextKey: InjectionKey<SelectionContext<unknown>> = Symbol('SelectionContextKey');
Expand All @@ -65,7 +64,8 @@ export function useSelect<TOption>(_props: Reactivify<SelectProps<TOption>, 'sch
for: inputId,
});

const { listBoxProps, isOpen } = useListBox<TOption>(props);
let lastRecentlySelectedOption: TOption | undefined;
const { listBoxProps, isOpen, options } = useListBox<TOption>(props);
const { updateValidity } = useInputValidity({ field });
const { fieldValue, setValue, isTouched, errorMessage } = field;
const { displayError } = useErrorDisplay(field);
Expand All @@ -79,36 +79,48 @@ export function useSelect<TOption>(_props: Reactivify<SelectProps<TOption>, 'sch
});

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

const isShiftPressed = useKeyPressed(['ShiftLeft', 'ShiftRight'], () => !isOpen.value);

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

return selectedOptions.some(opt => isEqual(opt, value));
},
getOptionIndex(value: TOption) {
const opts = toValue(props.options) || [];

return opts.findIndex(opt => isEqual(opt, value));
},
toggleIdx(idx: number, force?: boolean) {
const opts = toValue(props.options) || [];

this.toggleOption(opts[idx], force);
},
toggleOption(optionValue: TOption, force?: boolean) {
const isMultiple = toValue(props.multiple);
if (!isMultiple) {
lastRecentlySelectedOption = optionValue;
setValue(optionValue);
updateValidity();
isOpen.value = false;
return;
}

const nextValue = toggleValueSelection<TOption>(fieldValue.value ?? [], optionValue, force);
if (!isShiftPressed.value) {
lastRecentlySelectedOption = optionValue;
const nextValue = toggleValueSelection<TOption>(fieldValue.value ?? [], optionValue, force);
setValue(nextValue);
updateValidity();
return;
}

// Handles contiguous selection when shift key is pressed, aka select all options between the two ranges.
let lastRecentIdx = options.value.findIndex(opt => isEqual(opt.getValue(), lastRecentlySelectedOption));
const targetIdx = options.value.findIndex(opt => isEqual(opt.getValue(), optionValue));
if (targetIdx === -1) {
return;
}

lastRecentIdx = lastRecentIdx === -1 ? 0 : lastRecentIdx;
const startIdx = Math.min(lastRecentIdx, targetIdx);
const endIdx = Math.min(Math.max(lastRecentIdx, targetIdx + 1), options.value.length - 1);
const range = options.value.slice(startIdx, endIdx);
const nextValue = range.map(opt => opt.getValue());
setValue(nextValue);
updateValidity();
},
Expand Down
11 changes: 8 additions & 3 deletions packages/core/src/utils/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,10 +331,15 @@ export function toggleValueSelection<TValue>(current: Arrayable<TValue>, value:
const idx = nextValue.findIndex(v => isEqual(v, value));
const shouldAdd = force ?? idx === -1;

if (shouldAdd) {
nextValue.push(value);
} else {
if (!shouldAdd) {
nextValue.splice(idx, 1);

return nextValue;
}

// If it doesn't exist add it
if (idx === -1) {
nextValue.push(value);
}

return nextValue;
Expand Down
15 changes: 14 additions & 1 deletion packages/playground/src/App.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
<template>
<div class="flex gap-4 relative p-8">
<form class="w-full">
<InputSelect name="select" label="Select Input" multiple :options="['Hello', 'World', 'Foo', 'Bar']" />
<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']"
/>

<!-- <div class="flex flex-col gap-4">-->
<!-- <InputText-->
Expand Down

0 comments on commit d4fdb28

Please sign in to comment.