Skip to content

Commit 7b194c3

Browse files
ENG-1731: Add keyboard-only filtering with tag chips (#1072)
* 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 <cursoragent@cursor.com> * final touches * Address PR review: Blueprint Tag chips, alignment, and arrow guard. Use Tag with getNodeTagStyles for filter chips while keeping a custom input for ghost Tab completion. Fix header alignment, Roam-safe styling, remove unnecessary memos, and guard ArrowDown when there are no results. Co-authored-by: Cursor <cursoragent@cursor.com> * address PR comments * fix style * 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 <cursoragent@cursor.com> * change sizing * fix lint --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent a804fa5 commit 7b194c3

4 files changed

Lines changed: 367 additions & 24 deletions

File tree

apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx

Lines changed: 52 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react";
22
import {
33
Button,
44
Dialog,
5-
InputGroup,
5+
Icon,
66
NonIdealState,
77
Spinner,
88
SpinnerSize,
@@ -40,6 +40,7 @@ import {
4040
import { DiscourseNodeTypeFilter } from "~/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter";
4141
import { RenderRoamBlock, RenderRoamPage } from "~/utils/roamReactComponents";
4242
import { AdvancedSearchFooter } from "./AdvancedSearchFooter";
43+
import { NodeTypeChipsSearchInput } from "./NodeTypeChipsSearchInput";
4344
import {
4445
type SearchIndex,
4546
useAdvancedNodeSearchResults,
@@ -161,6 +162,7 @@ const AdvancedNodeSearchDialog = ({
161162
const [sort, setSort] = useState<SortConfig>(DEFAULT_SORT_CONFIG);
162163
const [discourseNodes, setDiscourseNodes] = useState<DiscourseNode[]>([]);
163164
const [selectedNodeTypeIds, setSelectedNodeTypeIds] = useState<string[]>([]);
165+
const [isTypeFilterPopoverOpen, setIsTypeFilterPopoverOpen] = useState(false);
164166
const resultsPanelRef = useRef<HTMLDivElement | null>(null);
165167
const inputRef = useRef<HTMLInputElement | null>(null);
166168
const [insertTarget, setInsertTarget] = useState<InsertTarget | null>(null);
@@ -291,7 +293,7 @@ const AdvancedNodeSearchDialog = ({
291293
? "error"
292294
: isIndexLoading
293295
? "indexing"
294-
: !debouncedSearchTerm
296+
: !debouncedSearchTerm && selectedNodeTypeIds.length === 0
295297
? "initial"
296298
: !results.length
297299
? "empty"
@@ -362,15 +364,21 @@ const AdvancedNodeSearchDialog = ({
362364
onClose();
363365
}, [activeResult, contentState, onClose]);
364366

365-
const onKeyDown = useCallback(
366-
(event: React.KeyboardEvent<HTMLDivElement>) => {
367+
const handleSearchKeyDown = useCallback(
368+
(event: React.KeyboardEvent): void => {
367369
if (event.key === "ArrowDown" && results.length) {
368370
event.preventDefault();
369-
setActiveIndex((index) => Math.min(index + 1, results.length - 1));
370-
} else if (event.key === "ArrowUp" && results.length) {
371+
setActiveIndex((index) =>
372+
Math.min(Math.max(index, 0) + 1, results.length - 1),
373+
);
374+
return;
375+
}
376+
if (event.key === "ArrowUp" && results.length) {
371377
event.preventDefault();
372378
setActiveIndex((index) => Math.max(index - 1, 0));
373-
} else if (
379+
return;
380+
}
381+
if (
374382
event.key === "Enter" &&
375383
event.altKey &&
376384
contentState === "results" &&
@@ -388,7 +396,9 @@ const AdvancedNodeSearchDialog = ({
388396
event.preventDefault();
389397
if (event.shiftKey) void onOpenInSidebar();
390398
else void onOpen();
391-
} else if (
399+
return;
400+
}
401+
if (
392402
event.key === "Enter" &&
393403
(event.metaKey || event.ctrlKey) &&
394404
contentState === "results" &&
@@ -397,14 +407,18 @@ const AdvancedNodeSearchDialog = ({
397407
) {
398408
event.preventDefault();
399409
void onInsert();
400-
} else if (event.key === "Escape") {
410+
return;
411+
}
412+
if (event.key === "Escape") {
413+
if (isTypeFilterPopoverOpen) return;
401414
event.preventDefault();
402415
onClose();
403416
}
404417
},
405418
[
406419
activeResult,
407420
contentState,
421+
isTypeFilterPopoverOpen,
408422
insertTarget,
409423
onClose,
410424
onOpenSearchSidebar,
@@ -415,14 +429,22 @@ const AdvancedNodeSearchDialog = ({
415429
],
416430
);
417431

432+
const onKeyDown = useCallback(
433+
(event: React.KeyboardEvent<HTMLDivElement>) => {
434+
if (event.defaultPrevented) return;
435+
handleSearchKeyDown(event);
436+
},
437+
[handleSearchKeyDown],
438+
);
439+
418440
const showSplitView = contentState === "results";
419441

420442
return (
421443
<Dialog
422444
autoFocus={false}
423445
canEscapeKeyClose
424446
canOutsideClickClose
425-
className="flex max-w-4xl flex-col overflow-hidden bg-white p-0"
447+
className="flex w-full max-w-4xl flex-col overflow-hidden bg-white p-0"
426448
enforceFocus={false}
427449
isOpen={isOpen}
428450
onClose={onClose}
@@ -438,19 +460,27 @@ const AdvancedNodeSearchDialog = ({
438460
onMouseUp={(event) => event.stopPropagation()}
439461
className="flex min-h-0 flex-1 flex-col overflow-hidden"
440462
>
441-
<div className="flex flex-none items-center gap-2 border-b border-gray-200 px-3 py-2">
442-
<InputGroup
443-
fill
444-
inputRef={inputRef}
445-
leftIcon="search"
446-
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
447-
setSearchTerm(event.target.value)
448-
}
449-
placeholder="Search discourse nodes..."
450-
value={searchTerm}
451-
/>
463+
<div className="flex w-full flex-none items-start gap-2 border-b border-gray-200 px-3 py-2">
464+
<div className="flex min-h-9 min-w-0 flex-1 items-center rounded border border-gray-300 bg-white px-2 py-1">
465+
<Icon
466+
className="mr-2 shrink-0 self-center text-gray-500"
467+
icon="search"
468+
size={16}
469+
/>
470+
<NodeTypeChipsSearchInput
471+
inputRef={inputRef}
472+
nodeTypes={discourseNodes}
473+
onSearchKeyDown={handleSearchKeyDown}
474+
onSearchTermChange={setSearchTerm}
475+
onSelectedTypeIdsChange={setSelectedNodeTypeIds}
476+
searchTerm={searchTerm}
477+
selectedTypeIds={selectedNodeTypeIds}
478+
/>
479+
</div>
452480
<DiscourseNodeTypeFilter
481+
layoutAnchorKey={selectedNodeTypeIds.length}
453482
nodeTypes={discourseNodes}
483+
onPopoverOpenChange={setIsTypeFilterPopoverOpen}
454484
onSelectedTypeIdsChange={setSelectedNodeTypeIds}
455485
selectedTypeIds={selectedNodeTypeIds}
456486
/>
@@ -498,7 +528,7 @@ const AdvancedNodeSearchDialog = ({
498528
<Spinner size={SpinnerSize.SMALL} />
499529
)}
500530
{contentState === "empty" && (
501-
<span>No matches. Try another keyword.</span>
531+
<span>No matches. Try another keyword or filter.</span>
502532
)}
503533
{contentState === "error" && (
504534
<span>

apps/roam/src/components/AdvancedNodeSearchDialog/DiscourseNodeTypeFilter.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export type DiscourseNodeTypeFilterProps = {
2828
selectedTypeIds: string[];
2929
onSelectedTypeIdsChange: (ids: string[]) => void;
3030
onPopoverOpenChange?: (isOpen: boolean) => void;
31+
/** Bumps when surrounding layout changes (e.g. chip wrap) so the popover repositions. */
32+
layoutAnchorKey?: number;
3133
};
3234

3335
const NodeTypeFilterRow = ({
@@ -183,6 +185,7 @@ const FilterPopoverPanel = ({
183185
};
184186

185187
export const DiscourseNodeTypeFilter = ({
188+
layoutAnchorKey = 0,
186189
nodeTypes,
187190
onPopoverOpenChange,
188191
onSelectedTypeIdsChange,
@@ -246,6 +249,11 @@ export const DiscourseNodeTypeFilter = ({
246249

247250
const isTriggerActive = isOpen || isFilterActive;
248251

252+
useEffect(() => {
253+
if (!isOpen) return;
254+
window.dispatchEvent(new Event("resize"));
255+
}, [isOpen, layoutAnchorKey]);
256+
249257
const filterButton = (
250258
<span className="relative inline-flex shrink-0 items-center">
251259
<Button
@@ -311,7 +319,7 @@ export const DiscourseNodeTypeFilter = ({
311319
onInteraction={handlePopoverInteraction}
312320
popoverClassName="p-0 overflow-hidden"
313321
popoverRef={popoverRef}
314-
position={Position.BOTTOM_RIGHT}
322+
position={Position.BOTTOM}
315323
target={filterButton}
316324
usePortal
317325
/>

0 commit comments

Comments
 (0)