import { computed, ref, watch } from "vue"; import { useEventStore } from "@/stores/eventStore"; import { useActiveElement } from "../useActiveElement"; import type { ComponentInstanceExtends, ComponentInstanceRef, SelectedItemsProps } from "./types"; /** A composable that allows for the selection of items in a list using keyboard and mouse * events, as well as navigating that list using keyboard events. */ export function useSelectedItems({ scopeKey, getItemKey, filterText, totalItemsInQuery, allItems, filterClass, selectable, querySelectionBreak = () => {}, onDelete, expectedKeyDownClass = undefined, disallowedKeyDownClasses = [], attributeForRangeSelection = "id", getAttributeForRangeSelection = (item: T) => getItemKey(item), }: SelectedItemsProps) { const eventStore = useEventStore(); const selectedItems = ref>(new Map()); const showSelection = ref(false); const allSelected = ref(false); const initSelectedItem = ref(); const initDirection = ref(null); const firstInRange = ref(); const lastInRange = ref(); const lastItemId = ref(null); const currItemFocused = useActiveElement(); const selectionSize = computed(() => (isQuerySelection.value ? totalItemsInQuery.value : selectedItems.value.size)); const isQuerySelection = computed(() => allSelected.value && totalItemsInQuery.value !== selectedItems.value.size); const currentFilters = computed(() => filterClass.getFiltersForText(filterText.value)); const initSelectedKey = computed(() => (initSelectedItem.value ? getItemKey(initSelectedItem.value as T) : null)); // TODO: Weird Unwrap ref type const lastInRangeIndex = computed(() => lastInRange.value ? allItems.value.indexOf(lastInRange.value as T) : null, ); const firstInRangeIndex = computed(() => firstInRange.value ? allItems.value.indexOf(firstInRange.value as T) : null, ); const rangeSelectActive = computed(() => lastInRange.value && initDirection.value); const lastItemFocused = computed(() => { return lastItemId.value ? allItems.value.find((item) => lastItemId.value === getItemKey(item)) : null; }); function setShowSelection(val: boolean) { showSelection.value = val; } function selectAllInCurrentQuery(force = true) { // if we are not forcing selectAll, and all items are already selected; deselect them if (!force && allSelected.value) { setShowSelection(false); return; } selectItems(allItems.value); allSelected.value = true; } function isSelected(item: T) { if (isQuerySelection.value) { return filterClass.testFilters(currentFilters.value, item as Record); } const key = getItemKey(item as T); return selectedItems.value.has(key); } /** Adds/Removes an item from the selected items * * @param item - the item to be selected/deselected * @param selected - whether to select or deselect the item * @param checkInRange - whether to check if the item lies outside the range */ function setSelected(item: T, selected: boolean, checkInRange = true) { const key = getItemKey(item as T); const newSelected = new Map(selectedItems.value); selected ? newSelected.set(key, item) : newSelected.delete(key); selectedItems.value = newSelected; breakQuerySelection(); // item selected from panel, range select is active, check if item lies outside the range if (checkInRange && rangeSelectActive.value) { const currentItemIndex = allItems.value.indexOf(item as T); /** If either of the following are `true`, change the range anchor */ // if range select was downwards and the current item is below the lastInRange or above the firstInRange if ( initDirection.value === "ArrowDown" && ((lastInRangeIndex.value !== null && currentItemIndex > lastInRangeIndex.value) || (firstInRangeIndex.value !== null && currentItemIndex < firstInRangeIndex.value)) ) { firstInRange.value = item; lastInRange.value = item; } // if range select was upwards and the current item is above the lastInRange or below the firstInRange else if ( initDirection.value === "ArrowUp" && ((lastInRangeIndex.value !== null && currentItemIndex < lastInRangeIndex.value) || (firstInRangeIndex.value !== null && currentItemIndex > firstInRangeIndex.value)) ) { firstInRange.value = item; lastInRange.value = item; } } } /** Selecting items using `Shift+ArrowUp/ArrowDown` keys */ function shiftArrowKeySelect(item: T, nextItem: T | null, eventKey: string) { const currentItemKey = getItemKey(item as T); if (!initSelectedKey.value) { initSelectedItem.value = item; initDirection.value = eventKey; setSelected(item, true, false); } // got back to the initial selected item else if (initSelectedKey.value === currentItemKey) { initDirection.value = eventKey; } // same direction else if (initDirection.value === eventKey) { setSelected(item, true, false); } // different direction else { setSelected(item, false, false); } if (nextItem) { setSelected(nextItem, true, false); } } /** Range selecting items using `Shift+Click` */ function rangeSelect(item: T, prevItem: T | null) { if (prevItem && item) { // either a range select is not active or we have modified a range select (changing the anchor) const noRangeSelectOrModified = !initSelectedKey.value; if (noRangeSelectOrModified) { // there is a range select active if (rangeSelectActive.value) { const currentItemIndex = allItems.value.indexOf(item as T); // the current item is outside the range in the same direction; // the new range will follow the last item in prev range if ( lastInRangeIndex.value !== null && ((initDirection.value === "ArrowDown" && currentItemIndex > lastInRangeIndex.value) || (initDirection.value === "ArrowUp" && currentItemIndex < lastInRangeIndex.value)) ) { initSelectedItem.value = lastInRange.value; } // the current item is outside the range in the opposite direction; // the new range will follow the first item in prev range else if ( firstInRangeIndex.value !== null && ((initDirection.value === "ArrowDown" && currentItemIndex < firstInRangeIndex.value) || (initDirection.value === "ArrowUp" && currentItemIndex > firstInRangeIndex.value)) ) { initSelectedItem.value = firstInRange.value; } else { initSelectedItem.value = prevItem; firstInRange.value = prevItem; } } // there is no range select active else { // we are staring a new shift+click rangeSelect from `prevItem` initSelectedItem.value = prevItem; firstInRange.value = prevItem; } } const initItemIndex = allItems.value.indexOf(initSelectedItem.value as T); const currentItemIndex = allItems.value.indexOf(item as T); const lastDirection = initDirection.value; let selections: T[] = []; // from allItems, get the items between the init item and the current item if (initItemIndex < currentItemIndex) { initDirection.value = "ArrowDown"; selections = allItems.value.slice(initItemIndex + 1, currentItemIndex + 1); } else if (initItemIndex > currentItemIndex) { initDirection.value = "ArrowUp"; selections = allItems.value.slice(currentItemIndex, initItemIndex); } let deselections; // there is an existing range select; deselect items in certain conditions if (rangeSelectActive.value && lastInRangeIndex.value !== null) { // if there is an active, uninterrupted range-select and the direction changed; // deselect the items between the lastInRange and initSelectedItem if (!noRangeSelectOrModified && lastDirection && lastDirection !== initDirection.value) { if (lastInRangeIndex.value >= initItemIndex) { deselections = allItems.value.slice(initItemIndex + 1, lastInRangeIndex.value + 1); } else { deselections = allItems.value.slice(lastInRangeIndex.value, initItemIndex); } } // if the range has become smaller, deselect items between the lastInRange and the current item else if (lastInRangeIndex.value < currentItemIndex && initDirection.value === "ArrowUp") { deselections = allItems.value.slice(lastInRangeIndex.value, currentItemIndex); } else if (lastInRangeIndex.value > currentItemIndex && initDirection.value === "ArrowDown") { deselections = allItems.value.slice(currentItemIndex + 1, lastInRangeIndex.value + 1); } } selectItems(selections); if (deselections) { deselections.forEach((deselected) => setSelected(deselected, false, false)); } } else { setSelected(item, true); } lastInRange.value = item; } function isRangeSelectAnchor(item: T) { return Boolean(initSelectedItem.value && getItemKey(item) === getItemKey(initSelectedItem.value)); } /** Resets the initial item in a range select (or shiftArrowKeySelect) */ function initKeySelection() { initSelectedItem.value = undefined; } function selectItems(itemsToSelect: T[]) { const newItems = [...selectedItems.value.values(), ...itemsToSelect]; const newEntries: Array<[string, T]> = newItems.map((item) => { const key = getItemKey(item as T); return [key, item]; }); selectedItems.value = new Map(newEntries); breakQuerySelection(); } function breakQuerySelection() { if (allSelected.value) { querySelectionBreak(); } allSelected.value = false; } function resetSelection() { selectedItems.value = new Map(); allSelected.value = false; initKeySelection(); lastInRange.value = undefined; firstInRange.value = undefined; initDirection.value = null; lastItemId.value = null; } // Define refs for component instances const itemRefs = computed>(() => { return allItems.value.reduce((acc, item) => { acc[getItemKey(item)] = ref(null); return acc; }, {} as ComponentInstanceRef); }); function findFirstFocusable(itemRef: InstanceType | null | undefined): HTMLElement | null { if (!itemRef) { return null; } // If itemRef is a component instance with $el, check if it's focusable if ("$el" in itemRef) { if (itemRef.$el && typeof itemRef.$el.focus === "function") { return itemRef.$el; } } // If itemRef is an array, recursively check its elements else if (Array.isArray(itemRef)) { for (const element of itemRef as unknown[]) { const focusable = findFirstFocusable(element as InstanceType); if (focusable) { return focusable; } } } // If itemRef is a DOM element, check if it's focusable else if (typeof itemRef.focus === "function") { return itemRef; } // If no focusable element is found, return null return null; } function arrowNavigate(item: T, eventKey: string) { let nextItem = null; if (eventKey === "ArrowDown") { nextItem = allItems.value[allItems.value.indexOf(item) + 1]; } else if (eventKey === "ArrowUp") { nextItem = allItems.value[allItems.value.indexOf(item) - 1]; } if (nextItem) { const itemRef = itemRefs.value[getItemKey(nextItem)]?.value; const focusableElement = findFirstFocusable(itemRef); if (focusableElement) { focusableElement.focus(); } } return nextItem ?? null; } function onKeyDown(item: T, event: KeyboardEvent) { if (!validateClassList((event.target as HTMLElement)?.classList)) { return; } if ((event.key === "ArrowUp" || event.key === "ArrowDown") && !event.shiftKey) { event.preventDefault(); arrowNavigate(item, event.key); } if (selectable.value) { if (event.key === "Tab") { initKeySelection(); } else { event.preventDefault(); if ((event.key === "ArrowUp" || event.key === "ArrowDown") && event.shiftKey) { shiftArrowKeySelect(item, arrowNavigate(item, event.key), event.key); } else if (event.key === "ArrowUp" || event.key === "ArrowDown") { initKeySelection(); } else if ( item && (event.key === "Delete" || event.key === "Backspace") && !isSelected(item) && typeof item === "object" && "deleted" in item && !item.deleted ) { onDelete(item, event.shiftKey); setSelected(item, false); initKeySelection(); arrowNavigate(item, "ArrowDown"); } else if (event.key === "Escape") { (event.target as HTMLElement)?.blur(); } else if (event.key === "a" && eventStore.isCtrlKey(event)) { selectAllInCurrentQuery(false); } } } } /** Click handler for selectable items * @returns `true` if after the click, more click handling is needed */ function onClick(item: T, e: Event): boolean { const event = e as KeyboardEvent; if (selectable.value) { if (eventStore.isCtrlKey(event)) { initKeySelection(); setSelected(item, !isSelected(item)); return false; } else if (event.shiftKey) { rangeSelect(item, lastItemFocused.value ?? null); return false; } else { initKeySelection(); return true; } } return true; } /** If the event target does not match the expected class, or contains a disallowed class, ignore the event. */ function validateClassList(classList: DOMTokenList) { if ( (expectedKeyDownClass && !classList.contains(expectedKeyDownClass)) || (disallowedKeyDownClasses && disallowedKeyDownClasses.some((className) => classList.contains(className))) ) { return false; } return true; } function resetFocusToOffset(offset: number) { const itemAtOffset = allItems.value[offset]; if (itemAtOffset) { const itemElement = itemRefs.value[getItemKey(itemAtOffset)]?.value?.$el as HTMLElement; itemElement?.focus(); } } watch( () => currItemFocused.value, (newItem, oldItem) => { // if user clicked elsewhere, set lastItemId to the last focused item const lastExpectedParent = oldItem?.closest(`.${expectedKeyDownClass}`); if (newItem) { let lastElement: Element | null = null; if (oldItem && validateClassList(oldItem.classList)) { lastElement = oldItem; } else if (lastExpectedParent && validateClassList(lastExpectedParent.classList)) { lastElement = lastExpectedParent; } const lastItem = lastElement ? allItems.value.find( (item) => lastElement.getAttribute(attributeForRangeSelection) === getAttributeForRangeSelection(item), ) : null; lastItemId.value = lastItem ? getItemKey(lastItem) : null; } }, ); watch(scopeKey, (newKey, oldKey) => { if (newKey !== oldKey) { setShowSelection(false); } }); watch(selectionSize, (newSize) => { if (newSize > 0) { setShowSelection(true); } }); watch(showSelection, (newVal) => { if (!newVal) { resetSelection(); } }); watch(totalItemsInQuery, (newVal, oldVal) => { if (allSelected.value && newVal !== oldVal) { breakQuerySelection(); } }); return { selectedItems, showSelection, selectionSize, initSelectedItem, isQuerySelection, itemRefs, arrowNavigate, setShowSelection, selectAllInCurrentQuery, selectItems, rangeSelect, onClick, onKeyDown, isRangeSelectAnchor, isSelected, setSelected, resetSelection, shiftArrowKeySelect, initKeySelection, resetFocusToOffset, }; }