// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file hooks.ts * @input Uses React hooks, MultiSelectorOptionData type * @output Exports useMultiCombobox hook * @position Internal hook; used by MultiSelector.tsx * * SYNC: When modified, update: * - /packages/core/src/MultiSelector/index.ts */ import { useCallback, useRef, useState } from 'react'; import { useHighlightedOptionScroll } from "../hooks/useHighlightedOptionScroll.js"; import type { MultiSelectorOptionData } from "./types.js"; interface UseMultiComboboxOptions { selectableItems: MultiSelectorOptionData[]; isDisabled?: boolean; isOpen: boolean; hasSearch?: boolean; onOpen: () => unknown; onClose: () => void; onToggle: (itemValue: string) => void; /** * Clear all selected values. When provided, pressing Delete or Backspace on * the closed trigger clears the selection — a keyboard equivalent of the * clear button (comboboxes-2). No-op when the popup is open or search is on. */ onClear?: () => void; /** * Whether at least one value is selected (i.e. there is something to clear). * The Delete/Backspace clear path is skipped when false. */ hasValue?: boolean; listboxId: string; } interface UseMultiComboboxResult { highlightedIndex: number; setHighlightedIndex: (index: number) => void; getItemId: (index: number) => string; onTriggerClick: () => void; onKeyDown: (e: React.KeyboardEvent) => void; onItemMouseEnter: (item: MultiSelectorOptionData, index: number) => void; } /** * Handles keyboard navigation and toggle logic for multi-select combobox. * Works like useCombobox (index-based) but toggling does NOT close the dropdown. * * The caller must ensure selectableItems is in the same order as the rendered DOM. */ export function useMultiCombobox({ selectableItems, isDisabled = false, isOpen, hasSearch = false, onOpen, onClose, onToggle, onClear, hasValue = false, listboxId }: UseMultiComboboxOptions): UseMultiComboboxResult { const [highlightedIndex, setHighlightedIndex] = useState(-1); const [typeahead, setTypeahead] = useState(''); const typeaheadTimeoutRef = useRef | undefined>(undefined); const getItemId = useCallback((index: number) => `${listboxId}-item-${index}`, [listboxId]); const getEnabledIndices = useCallback(() => { return selectableItems.map((item, i) => !item.disabled ? i : -1).filter(i => i >= 0); }, [selectableItems]); const closeAndReset = useCallback(() => { setHighlightedIndex(-1); onClose(); }, [onClose]); const onTriggerClick = useCallback(() => { if (isDisabled) { return; } if (isOpen) { closeAndReset(); } else { const didOpen = onOpen() !== false; if (didOpen && !hasSearch) { setHighlightedIndex(0); } } }, [isDisabled, isOpen, onOpen, closeAndReset, hasSearch]); // The scroll effect lives here, the highlight owner, so the hover/keyboard // split is shared instead of re-implemented in MultiSelector.tsx (#6077). const highlightOnHover = useHighlightedOptionScroll({ isOpen, highlightedIndex, setHighlightedIndex, getOptionId: getItemId }); const onItemMouseEnter = useCallback((item: MultiSelectorOptionData, index: number) => { if (!item.disabled) { highlightOnHover(index); } }, [highlightOnHover]); const onKeyDown = useCallback((e: React.KeyboardEvent) => { if (isDisabled) { return; } const enabledIndices = getEnabledIndices(); switch (e.key) { case 'ArrowDown': e.preventDefault(); if (!isOpen) { if (onOpen() !== false) { setHighlightedIndex(0); } } else { const currentEnabledPos = enabledIndices.indexOf(highlightedIndex); const nextPos = Math.min(currentEnabledPos + 1, enabledIndices.length - 1); setHighlightedIndex(enabledIndices[nextPos] ?? highlightedIndex); } break; case 'ArrowUp': e.preventDefault(); if (!isOpen) { if (onOpen() !== false) { setHighlightedIndex(selectableItems.length - 1); } } else { const currentEnabledPos = enabledIndices.indexOf(highlightedIndex); const prevPos = Math.max(currentEnabledPos - 1, 0); setHighlightedIndex(enabledIndices[prevPos] ?? highlightedIndex); } break; case 'Enter': case ' ': // Don't intercept Space when search input is focused if (e.key === ' ' && hasSearch) { break; } e.preventDefault(); if (isOpen && highlightedIndex >= 0) { const item = selectableItems[highlightedIndex]; if (item && !item.disabled) { onToggle(item.value); } } else if (!isOpen) { const didOpen = onOpen() !== false; if (didOpen && !hasSearch) { setHighlightedIndex(0); } } break; case 'Tab': if (isOpen) { closeAndReset(); } break; case 'Escape': if (isOpen) { e.preventDefault(); closeAndReset(); } break; case 'Home': e.preventDefault(); if (isOpen && enabledIndices.length > 0) { setHighlightedIndex(enabledIndices[0]); } break; case 'End': e.preventDefault(); if (isOpen && enabledIndices.length > 0) { setHighlightedIndex(enabledIndices[enabledIndices.length - 1]); } break; // PageUp/PageDown mirror Home/End. In search mode Home/End stay on // the input for caret movement (APG editable combobox), so these are // the sanctioned substitute for jumping to the first/last option. case 'PageUp': e.preventDefault(); if (isOpen && enabledIndices.length > 0) { setHighlightedIndex(enabledIndices[0]); } break; case 'PageDown': e.preventDefault(); if (isOpen && enabledIndices.length > 0) { setHighlightedIndex(enabledIndices[enabledIndices.length - 1]); } break; case 'Delete': case 'Backspace': // Keyboard equivalent of the clear button (comboboxes-2): clear all // selected values from the closed trigger so clearing is not // mouse-only. Skipped in search mode (keys edit the query) and while // the popup is open (arrow navigation owns interaction). if (!hasSearch && !isOpen && onClear != null && hasValue) { e.preventDefault(); onClear(); } break; default: // Typeahead only when search is not present if (!hasSearch && e.key.length === 1 && !e.ctrlKey && !e.metaKey) { const newTypeahead = typeahead + e.key.toLowerCase(); const matchIndex = selectableItems.findIndex(item => !item.disabled && item.label?.toLowerCase().startsWith(newTypeahead)); const didOpen = isOpen || matchIndex < 0 || onOpen() !== false; if (didOpen) { setTypeahead(newTypeahead); if (typeaheadTimeoutRef.current) { clearTimeout(typeaheadTimeoutRef.current); } typeaheadTimeoutRef.current = setTimeout(() => { setTypeahead(''); }, 500); if (matchIndex >= 0) { setHighlightedIndex(matchIndex); } } } break; } }, [isDisabled, isOpen, onOpen, closeAndReset, selectableItems, highlightedIndex, onToggle, getEnabledIndices, typeahead, hasSearch, onClear, hasValue]); return { highlightedIndex, setHighlightedIndex, getItemId, onTriggerClick, onKeyDown, onItemMouseEnter }; }