diff --git a/packages/core/src/helpers/useEventListener.ts b/packages/core/src/helpers/useEventListener.ts index 08800f03..22b57df4 100644 --- a/packages/core/src/helpers/useEventListener.ts +++ b/packages/core/src/helpers/useEventListener.ts @@ -1,13 +1,18 @@ -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; +} export function useEventListener( - targetRef: MaybeRefOrGetter>, + targetRef: MaybeRefOrGetter>, event: Arrayable, listener: EventListener, + opts?: ListenerOptions, ) { - function cleanup(el: HTMLElement) { + function cleanup(el: EventTarget) { const events = normalizeArrayable(event); events.forEach(evt => { @@ -15,7 +20,11 @@ export function useEventListener( }); } - function setup(el: HTMLElement) { + function setup(el: EventTarget) { + if (toValue(opts?.disabled)) { + return; + } + const events = normalizeArrayable(event); events.forEach(evt => { @@ -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); + } + }); + } } diff --git a/packages/core/src/helpers/useKeyPressed.ts b/packages/core/src/helpers/useKeyPressed.ts new file mode 100644 index 00000000..3e167371 --- /dev/null +++ b/packages/core/src/helpers/useKeyPressed.ts @@ -0,0 +1,39 @@ +import { useEventListener } from './useEventListener'; +import { MaybeRefOrGetter, shallowRef } from 'vue'; +import { Arrayable } from '../types'; + +export function useKeyPressed(codes: Arrayable, disabled?: MaybeRefOrGetter) { + 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; +} diff --git a/packages/core/src/useSelect/useListBox.ts b/packages/core/src/useSelect/useListBox.ts index da5103db..4614ad43 100644 --- a/packages/core/src/useSelect/useListBox.ts +++ b/packages/core/src/useSelect/useListBox.ts @@ -125,5 +125,6 @@ export function useListBox(_props: Reactivify>) { return { listBoxProps, isOpen, + options, }; } diff --git a/packages/core/src/useSelect/useOption.ts b/packages/core/src/useSelect/useOption.ts index eba5f4a8..cfebf813 100644 --- a/packages/core/src/useSelect/useOption.ts +++ b/packages/core/src/useSelect/useOption.ts @@ -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; diff --git a/packages/core/src/useSelect/useSelect.ts b/packages/core/src/useSelect/useSelect.ts index bc1bfdf5..411cbeba 100644 --- a/packages/core/src/useSelect/useSelect.ts +++ b/packages/core/src/useSelect/useSelect.ts @@ -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 { label: string; @@ -39,10 +40,8 @@ export interface SelectTriggerDomProps extends AriaLabelableProps { export interface SelectionContext { 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> = Symbol('SelectionContextKey'); @@ -65,7 +64,8 @@ export function useSelect(_props: Reactivify, 'sch for: inputId, }); - const { listBoxProps, isOpen } = useListBox(props); + let lastRecentlySelectedOption: TOption | undefined; + const { listBoxProps, isOpen, options } = useListBox(props); const { updateValidity } = useInputValidity({ field }); const { fieldValue, setValue, isTouched, errorMessage } = field; const { displayError } = useErrorDisplay(field); @@ -79,9 +79,11 @@ export function useSelect(_props: Reactivify, '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 = { isMultiple: () => toValue(props.multiple) ?? false, isValueSelected(value: TOption): boolean { @@ -89,26 +91,36 @@ export function useSelect(_props: Reactivify, 'sch 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(fieldValue.value ?? [], optionValue, force); + if (!isShiftPressed.value) { + lastRecentlySelectedOption = optionValue; + const nextValue = toggleValueSelection(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(); }, diff --git a/packages/core/src/utils/common.ts b/packages/core/src/utils/common.ts index 2e04bae1..91f1d9f8 100644 --- a/packages/core/src/utils/common.ts +++ b/packages/core/src/utils/common.ts @@ -331,10 +331,15 @@ export function toggleValueSelection(current: Arrayable, 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; diff --git a/packages/playground/src/App.vue b/packages/playground/src/App.vue index db296a58..1df8d705 100644 --- a/packages/playground/src/App.vue +++ b/packages/playground/src/App.vue @@ -1,7 +1,20 @@