From 770c559f0668aa00dd911fe533db0dc6d6b6ce0d Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Sat, 23 May 2026 14:37:21 -0400 Subject: [PATCH 1/2] ENG-1731: add keyboard chip filtering input Replace the advanced search input with a chip-based type filter input that supports ghost tab-completion and keyboard chip navigation while staying in sync with the dropdown filter state. Co-authored-by: Cursor --- .../AdvancedSearchDialog.tsx | 91 ++++-- .../NodeTypeChipsSearchInput.tsx | 309 ++++++++++++++++++ 2 files changed, 366 insertions(+), 34 deletions(-) create mode 100644 apps/roam/src/components/AdvancedNodeSearchDialog/NodeTypeChipsSearchInput.tsx diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx index a5d6bbdd6..9bce5635b 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx @@ -1,14 +1,8 @@ -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { Button, Dialog, - InputGroup, + Icon, NonIdealState, Spinner, SpinnerSize, @@ -34,6 +28,7 @@ import { getNodeTagStyles } from "~/utils/getDiscourseNodeColors"; import { DEBOUNCE_MS, DEFAULT_SORT_CONFIG, + MAX_RESULTS, type SearchResult, type SortConfig, buildSearchIndex, @@ -46,6 +41,7 @@ import { import { DiscourseNodeTypeFilter } from "~/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter"; import { RenderRoamBlock, RenderRoamPage } from "~/utils/roamReactComponents"; import { AdvancedSearchFooter } from "./AdvancedSearchFooter"; +import { NodeTypeChipsSearchInput } from "./NodeTypeChipsSearchInput"; type Props = Record; @@ -160,6 +156,7 @@ const AdvancedNodeSearchDialog = ({ const [sort, setSort] = useState(DEFAULT_SORT_CONFIG); const [discourseNodes, setDiscourseNodes] = useState([]); const [selectedNodeTypeIds, setSelectedNodeTypeIds] = useState([]); + const [isTypeFilterPopoverOpen, setIsTypeFilterPopoverOpen] = useState(false); const miniSearchRef = useRef | null>(null); @@ -205,25 +202,31 @@ const AdvancedNodeSearchDialog = ({ }, [isOpen]); useEffect(() => { - if ( - !isOpen || - isIndexLoading || - indexError || - !debouncedSearchTerm || - !miniSearchRef.current - ) { + const hasTypeFilters = selectedNodeTypeIds.length > 0; + + if (!isOpen || isIndexLoading || indexError || !miniSearchRef.current) { setResults([]); return; } - const scoredHits = searchIndexedNodes({ - miniSearch: miniSearchRef.current, - allResults: allResultsRef.current, - searchTerm: debouncedSearchTerm, - typeFilter: selectedNodeTypeIds.length ? selectedNodeTypeIds : undefined, - }); + if (!debouncedSearchTerm && !hasTypeFilters) { + setResults([]); + return; + } - setResults(sortSearchResults({ hits: scoredHits, sort })); + const scoredHits = debouncedSearchTerm + ? searchIndexedNodes({ + miniSearch: miniSearchRef.current, + allResults: allResultsRef.current, + searchTerm: debouncedSearchTerm, + typeFilter: hasTypeFilters ? selectedNodeTypeIds : undefined, + }) + : allResultsRef.current + .filter((result) => selectedNodeTypeIds.includes(result.type)) + .map((result) => ({ result, score: 1 })); + + const sortedResults = sortSearchResults({ hits: scoredHits, sort }); + setResults(sortedResults.slice(0, MAX_RESULTS)); }, [ debouncedSearchTerm, indexError, @@ -314,7 +317,7 @@ const AdvancedNodeSearchDialog = ({ ? "error" : isIndexLoading ? "indexing" - : !debouncedSearchTerm + : !debouncedSearchTerm && selectedNodeTypeIds.length === 0 ? "initial" : !results.length ? "empty" @@ -351,6 +354,7 @@ const AdvancedNodeSearchDialog = ({ const onKeyDown = useCallback( (event: React.KeyboardEvent) => { + if (event.defaultPrevented) return; if (event.key === "ArrowDown" && results.length) { event.preventDefault(); setActiveIndex((index) => Math.min(index + 1, results.length - 1)); @@ -377,6 +381,7 @@ const AdvancedNodeSearchDialog = ({ event.preventDefault(); void onInsert(); } else if (event.key === "Escape") { + if (isTypeFilterPopoverOpen) return; event.preventDefault(); onClose(); } @@ -384,6 +389,7 @@ const AdvancedNodeSearchDialog = ({ [ activeResult, contentState, + isTypeFilterPopoverOpen, insertTarget, onClose, onInsert, @@ -417,18 +423,35 @@ const AdvancedNodeSearchDialog = ({ className="flex min-h-0 flex-1 flex-col overflow-hidden" >
- ) => - setSearchTerm(event.target.value) - } - placeholder="Search discourse nodes..." - value={searchTerm} - /> +
+ + + setActiveIndex((index) => + Math.min(index + 1, results.length - 1), + ) + } + onArrowUp={() => + setActiveIndex((index) => Math.max(index - 1, 0)) + } + onCmdEnter={() => void onInsert()} + onEnter={() => void onOpen()} + onEscape={() => { + if (isTypeFilterPopoverOpen) return; + onClose(); + }} + onSearchTermChange={setSearchTerm} + onSelectedTypeIdsChange={setSelectedNodeTypeIds} + onShiftEnter={() => void onOpenInSidebar()} + searchTerm={searchTerm} + selectedTypeIds={selectedNodeTypeIds} + /> +
@@ -476,7 +499,7 @@ const AdvancedNodeSearchDialog = ({ )} {contentState === "empty" && ( - No matches. Try another keyword. + No matches. Try another keyword or filter. )} {contentState === "error" && ( diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/NodeTypeChipsSearchInput.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/NodeTypeChipsSearchInput.tsx new file mode 100644 index 000000000..a4d16800c --- /dev/null +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/NodeTypeChipsSearchInput.tsx @@ -0,0 +1,309 @@ +import { Button, Classes, Icon } from "@blueprintjs/core"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { getNodeTagStyles } from "~/utils/getDiscourseNodeColors"; +import { type DiscourseNode } from "~/utils/getDiscourseNodes"; + +type NodeTypeChipsSearchInputProps = { + nodeTypes: DiscourseNode[]; + searchTerm: string; + selectedTypeIds: string[]; + inputRef: React.RefObject; + onSearchTermChange: (value: string) => void; + onSelectedTypeIdsChange: (ids: string[]) => void; + onArrowDown: () => void; + onArrowUp: () => void; + onEnter: () => void; + onShiftEnter: () => void; + onCmdEnter: () => void; + onEscape: () => void; +}; + +const isPlainCharacterKey = (event: React.KeyboardEvent): boolean => + event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey; + +const getUniquePrefixMatch = ({ + nodeTypes, + query, + selectedTypeIds, +}: { + nodeTypes: DiscourseNode[]; + query: string; + selectedTypeIds: string[]; +}): DiscourseNode | null => { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return null; + + const selectedTypeIdSet = new Set(selectedTypeIds); + const matches = nodeTypes.filter( + (node) => + !selectedTypeIdSet.has(node.type) && + node.text.toLowerCase().startsWith(normalizedQuery), + ); + + return matches.length === 1 ? matches[0] : null; +}; + +export const NodeTypeChipsSearchInput = ({ + nodeTypes, + searchTerm, + selectedTypeIds, + inputRef, + onSearchTermChange, + onSelectedTypeIdsChange, + onArrowDown, + onArrowUp, + onEnter, + onShiftEnter, + onCmdEnter, + onEscape, +}: NodeTypeChipsSearchInputProps): React.ReactElement => { + const [focusedChipIndex, setFocusedChipIndex] = useState(-1); + const chipRefs = useRef<(HTMLSpanElement | null)[]>([]); + + const nodeTypeById = useMemo( + () => + Object.fromEntries( + nodeTypes.map((nodeType) => [nodeType.type, nodeType]), + ), + [nodeTypes], + ); + + const selectedNodeTypes = useMemo( + () => + selectedTypeIds + .map((typeId) => nodeTypeById[typeId]) + .filter((nodeType): nodeType is DiscourseNode => !!nodeType), + [nodeTypeById, selectedTypeIds], + ); + + const uniquePrefixMatch = useMemo( + () => + getUniquePrefixMatch({ + nodeTypes, + query: searchTerm, + selectedTypeIds, + }), + [nodeTypes, searchTerm, selectedTypeIds], + ); + + const completionSuffix = useMemo(() => { + if (!uniquePrefixMatch) return ""; + const normalizedQuery = searchTerm.trim(); + const nodeText = uniquePrefixMatch.text; + if (nodeText.toLowerCase() === normalizedQuery.toLowerCase()) return ""; + return nodeText.slice(normalizedQuery.length); + }, [searchTerm, uniquePrefixMatch]); + + useEffect(() => { + if (focusedChipIndex < 0) return; + chipRefs.current[focusedChipIndex]?.focus(); + }, [focusedChipIndex]); + + const focusInput = (): void => { + setFocusedChipIndex(-1); + requestAnimationFrame(() => { + inputRef.current?.focus(); + }); + }; + + const commitNodeType = (nodeType: DiscourseNode): void => { + if (selectedTypeIds.includes(nodeType.type)) return; + onSelectedTypeIdsChange([...selectedTypeIds, nodeType.type]); + onSearchTermChange(""); + }; + + const removeChipAtIndex = (chipIndex: number): void => { + const nextIds = selectedTypeIds.filter((_, index) => index !== chipIndex); + onSelectedTypeIdsChange(nextIds); + }; + + const handleChipKeyDown = ( + event: React.KeyboardEvent, + chipIndex: number, + ): void => { + if (event.key === "ArrowLeft") { + event.preventDefault(); + setFocusedChipIndex(Math.max(0, chipIndex - 1)); + return; + } + if (event.key === "ArrowRight") { + event.preventDefault(); + if (chipIndex >= selectedTypeIds.length - 1) { + focusInput(); + return; + } + setFocusedChipIndex(chipIndex + 1); + return; + } + if (event.key === "Backspace" || event.key === "Delete") { + event.preventDefault(); + const nextIds = selectedTypeIds.filter((_, index) => index !== chipIndex); + onSelectedTypeIdsChange(nextIds); + if (nextIds.length === 0) { + focusInput(); + return; + } + if (event.key === "Backspace") { + setFocusedChipIndex(Math.max(0, chipIndex - 1)); + return; + } + if (chipIndex >= nextIds.length) { + focusInput(); + return; + } + setFocusedChipIndex(chipIndex); + return; + } + if (event.key === "ArrowDown") { + event.preventDefault(); + onArrowDown(); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + onArrowUp(); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + onEscape(); + return; + } + if (isPlainCharacterKey(event)) { + event.preventDefault(); + onSearchTermChange(event.key); + focusInput(); + } + }; + + const handleInputKeyDown = ( + event: React.KeyboardEvent, + ): void => { + if (event.key === "Tab") { + if (uniquePrefixMatch) { + event.preventDefault(); + commitNodeType(uniquePrefixMatch); + } + return; + } + + if (event.key === "Backspace") { + const input = inputRef.current; + if ( + input && + input.selectionStart === 0 && + input.selectionEnd === 0 && + searchTerm.length === 0 && + selectedTypeIds.length > 0 + ) { + event.preventDefault(); + setFocusedChipIndex(selectedTypeIds.length - 1); + return; + } + } + + if (event.key === "ArrowLeft") { + const input = inputRef.current; + if ( + input && + input.selectionStart === 0 && + input.selectionEnd === 0 && + selectedTypeIds.length > 0 + ) { + event.preventDefault(); + setFocusedChipIndex(selectedTypeIds.length - 1); + return; + } + } + + if (event.key === "ArrowDown") { + event.preventDefault(); + onArrowDown(); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + onArrowUp(); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + if (event.metaKey || event.ctrlKey) onCmdEnter(); + else if (event.shiftKey) onShiftEnter(); + else onEnter(); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + onEscape(); + } + }; + + return ( +
+ {selectedNodeTypes.map((nodeType, index) => { + const isFocused = focusedChipIndex === index; + return ( + { + chipRefs.current[index] = element; + }} + role="button" + tabIndex={-1} + onClick={() => setFocusedChipIndex(index)} + onKeyDown={(event) => handleChipKeyDown(event, index)} + style={{ + boxShadow: isFocused + ? "0 0 0 2px rgba(95, 87, 192, 0.2)" + : undefined, + borderRadius: 3, + }} + > + + {nodeType.text} +
+ ); +}; From 620c204db859f4bea5e084c68d1aac4cafec9f32 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Sat, 23 May 2026 23:22:05 -0400 Subject: [PATCH 2/2] final touches --- .../AdvancedSearchDialog.tsx | 30 ++++++------ .../NodeTypeChipsSearchInput.tsx | 46 +++++++++++-------- 2 files changed, 44 insertions(+), 32 deletions(-) diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx index 9bce5635b..ac2da0d2f 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx @@ -422,7 +422,7 @@ const AdvancedNodeSearchDialog = ({ onMouseUp={(event) => event.stopPropagation()} className="flex min-h-0 flex-1 flex-col overflow-hidden" > -
+
- - +
+ +
+
+ +