From a9f600cfeda99dfad5f85c24977786a4f6eddae4 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Sat, 23 May 2026 14:37:21 -0400 Subject: [PATCH 1/6] 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 f209f11d2abb4ec8e969523ad89ccd37b154f565 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Sat, 23 May 2026 23:22:05 -0400 Subject: [PATCH 2/6] 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" > -
+
- - +
+ +
+
+ +
{showSplitView ? ( diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter.tsx index 0eef336b9..9c2f91a03 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter.tsx @@ -28,6 +28,8 @@ export type DiscourseNodeTypeFilterProps = { selectedTypeIds: string[]; onSelectedTypeIdsChange: (ids: string[]) => void; onPopoverOpenChange?: (isOpen: boolean) => void; + /** Bumps when surrounding layout changes (e.g. chip wrap) so the popover repositions. */ + layoutAnchorKey?: number; }; const getNodeIndicatorColor = (node: DiscourseNode): string => @@ -186,6 +188,7 @@ const FilterPopoverPanel = ({ }; export const DiscourseNodeTypeFilter = ({ + layoutAnchorKey = 0, nodeTypes, onPopoverOpenChange, onSelectedTypeIdsChange, @@ -249,6 +252,11 @@ export const DiscourseNodeTypeFilter = ({ const isTriggerActive = isOpen || isFilterActive; + useEffect(() => { + if (!isOpen) return; + window.dispatchEvent(new Event("resize")); + }, [isOpen, layoutAnchorKey]); + const filterButton = (
+ + +
{showSplitView ? ( From 40c2af8e77b169ff35445d42eaf2befee6321d4e Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 28 May 2026 15:39:18 -0400 Subject: [PATCH 6/6] Address review feedback: flex-1 classes, Tag focus, consolidated key handler. Use Tailwind flex-1 instead of inline flex styles, rely on Blueprint Tag active state for chip focus, document Tag vs TagInput choice, pass a single onSearchKeyDown prop, and restore fixed-height toolbar for filter controls. Co-authored-by: Cursor --- .../AdvancedSearchDialog.tsx | 92 +++++++++---------- .../NodeTypeChipsSearchInput.tsx | 75 +++++---------- 2 files changed, 66 insertions(+), 101 deletions(-) diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx index 175299ab6..c043604a6 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx @@ -352,18 +352,21 @@ const AdvancedNodeSearchDialog = ({ onClose(); }, [activeResult, contentState, onClose]); - const onKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (event.defaultPrevented) return; + const handleSearchKeyDown = useCallback( + (event: React.KeyboardEvent): void => { if (event.key === "ArrowDown" && results.length) { event.preventDefault(); setActiveIndex((index) => Math.min(Math.max(index, 0) + 1, results.length - 1), ); - } else if (event.key === "ArrowUp" && results.length) { + return; + } + if (event.key === "ArrowUp" && results.length) { event.preventDefault(); setActiveIndex((index) => Math.max(index - 1, 0)); - } else if ( + return; + } + if ( event.key === "Enter" && !event.metaKey && !event.ctrlKey && @@ -373,7 +376,9 @@ const AdvancedNodeSearchDialog = ({ event.preventDefault(); if (event.shiftKey) void onOpenInSidebar(); else void onOpen(); - } else if ( + return; + } + if ( event.key === "Enter" && (event.metaKey || event.ctrlKey) && contentState === "results" && @@ -382,7 +387,9 @@ const AdvancedNodeSearchDialog = ({ ) { event.preventDefault(); void onInsert(); - } else if (event.key === "Escape") { + return; + } + if (event.key === "Escape") { if (isTypeFilterPopoverOpen) return; event.preventDefault(); onClose(); @@ -401,6 +408,14 @@ const AdvancedNodeSearchDialog = ({ ], ); + const onKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.defaultPrevented) return; + handleSearchKeyDown(event); + }, + [handleSearchKeyDown], + ); + const showSplitView = contentState === "results"; return ( @@ -425,10 +440,7 @@ const AdvancedNodeSearchDialog = ({ className="flex min-h-0 flex-1 flex-col overflow-hidden" >
-
+
{ - if (!results.length) return; - setActiveIndex((index) => - Math.min(Math.max(index, 0) + 1, results.length - 1), - ); - }} - onArrowUp={() => { - if (!results.length) return; - setActiveIndex((index) => Math.max(index - 1, 0)); - }} - onCmdEnter={() => void onInsert()} - onEnter={() => void onOpen()} - onEscape={() => { - if (isTypeFilterPopoverOpen) return; - onClose(); - }} + onSearchKeyDown={handleSearchKeyDown} onSearchTermChange={setSearchTerm} onSelectedTypeIdsChange={setSelectedNodeTypeIds} - onShiftEnter={() => void onOpenInSidebar()} searchTerm={searchTerm} selectedTypeIds={selectedNodeTypeIds} />
- - -
{showSplitView ? ( diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/NodeTypeChipsSearchInput.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/NodeTypeChipsSearchInput.tsx index b3b105fc6..8b6e5d9d8 100644 --- a/apps/roam/src/components/AdvancedNodeSearchDialog/NodeTypeChipsSearchInput.tsx +++ b/apps/roam/src/components/AdvancedNodeSearchDialog/NodeTypeChipsSearchInput.tsx @@ -3,6 +3,11 @@ import React, { useEffect, useRef, useState } from "react"; import { getNodeTagColorStyles } from "~/utils/getDiscourseNodeColors"; import { type DiscourseNode } from "~/utils/getDiscourseNodes"; +/** + * Blueprint `Tag` for filter chips; custom `` for inline ghost Tab completion. + * `TagInput` does not support ghost suggest + Tab commit, and its Enter handler + * conflicts with opening search results (see AdvancedSearchDialog key handling). + */ type NodeTypeChipsSearchInputProps = { nodeTypes: DiscourseNode[]; searchTerm: string; @@ -10,12 +15,7 @@ type NodeTypeChipsSearchInputProps = { inputRef: React.RefObject; onSearchTermChange: (value: string) => void; onSelectedTypeIdsChange: (ids: string[]) => void; - onArrowDown: () => void; - onArrowUp: () => void; - onEnter: () => void; - onShiftEnter: () => void; - onCmdEnter: () => void; - onEscape: () => void; + onSearchKeyDown: (event: React.KeyboardEvent) => void; }; const CHIP_LABEL_MAX_WIDTH = "10rem"; @@ -72,12 +72,7 @@ export const NodeTypeChipsSearchInput = ({ inputRef, onSearchTermChange, onSelectedTypeIdsChange, - onArrowDown, - onArrowUp, - onEnter, - onShiftEnter, - onCmdEnter, - onEscape, + onSearchKeyDown, }: NodeTypeChipsSearchInputProps): React.ReactElement => { const [focusedChipIndex, setFocusedChipIndex] = useState(-1); const chipRefs = useRef<(HTMLSpanElement | null)[]>([]); @@ -153,19 +148,13 @@ export const NodeTypeChipsSearchInput = ({ setFocusedChipIndex(chipIndex); return; } - if (event.key === "ArrowDown") { + if ( + event.key === "ArrowDown" || + event.key === "ArrowUp" || + event.key === "Escape" + ) { event.preventDefault(); - onArrowDown(); - return; - } - if (event.key === "ArrowUp") { - event.preventDefault(); - onArrowUp(); - return; - } - if (event.key === "Escape") { - event.preventDefault(); - onEscape(); + onSearchKeyDown(event); return; } if (isPlainCharacterKey(event)) { @@ -215,34 +204,19 @@ export const NodeTypeChipsSearchInput = ({ } } - if (event.key === "ArrowDown") { - event.preventDefault(); - onArrowDown(); - return; - } - if (event.key === "ArrowUp") { - event.preventDefault(); - onArrowUp(); - return; - } - if (event.key === "Enter") { + if ( + event.key === "ArrowDown" || + event.key === "ArrowUp" || + event.key === "Enter" || + event.key === "Escape" + ) { event.preventDefault(); - if (event.metaKey || event.ctrlKey) onCmdEnter(); - else if (event.shiftKey) onShiftEnter(); - else onEnter(); - return; - } - if (event.key === "Escape") { - event.preventDefault(); - onEscape(); + onSearchKeyDown(event); } }; return ( -
+
{selectedNodeTypes.map((nodeType, index) => { const isFocused = focusedChipIndex === index; return ( @@ -255,13 +229,6 @@ export const NodeTypeChipsSearchInput = ({ tabIndex={-1} onClick={() => setFocusedChipIndex(index)} onKeyDown={(event) => handleChipKeyDown(event, index)} - style={{ - borderRadius: 3, - boxShadow: isFocused - ? "0 0 0 2px rgba(95, 87, 192, 0.2)" - : undefined, - display: "inline-flex", - }} >