-
Notifications
You must be signed in to change notification settings - Fork 7
ENG-2184 Add search to the settings panel #1378
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
102c061
60e624f
70b30e0
23e9551
1b9eb2e
07cd82a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,175 @@ | ||
| import React, { useEffect, useMemo, useRef, useState } from "react"; | ||
| import { | ||
| Icon, | ||
| InputGroup, | ||
| Menu, | ||
| MenuItem, | ||
| Popover, | ||
| Position, | ||
| } from "@blueprintjs/core"; | ||
| import { | ||
| buildSettingsCatalog, | ||
| type SearchableEntry, | ||
| } from "../utils/settingsCatalog"; | ||
| import { rankSettings } from "../utils/settingsSearch"; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ~ |
||
|
|
||
| const SettingsSearchResult = ({ | ||
| entry, | ||
| isActive, | ||
| onSelect, | ||
| }: { | ||
| entry: SearchableEntry; | ||
| isActive: boolean; | ||
| onSelect: (entry: SearchableEntry) => void; | ||
| }): JSX.Element => ( | ||
| <MenuItem | ||
| // `data-active` is what the scroll effect looks for. | ||
| data-active={isActive} | ||
| active={isActive} | ||
| icon={entry.kind === "page" ? "document" : "cog"} | ||
| shouldDismissPopover={false} | ||
| text={ | ||
| <div className="flex flex-col"> | ||
| <span>{entry.label}</span> | ||
| {/* Undimmed: any opacity drops white-on-#137CBD below AA. */} | ||
| <span | ||
| className={`text-xs ${isActive ? "text-inherit" : "text-gray-500"}`} | ||
| > | ||
| {entry.breadcrumb} | ||
| </span> | ||
| </div> | ||
| } | ||
| // Select on mousedown, before the input's blur closes the list. | ||
| onMouseDown={(event: React.MouseEvent) => { | ||
| event.preventDefault(); | ||
| onSelect(entry); | ||
| }} | ||
| /> | ||
| ); | ||
|
|
||
| /** Results are a portalled Popover because the tab list this field sits in is styled | ||
| * `overflow-y: auto; overflow-x: hidden`, which clips an in-flow dropdown on both axes. */ | ||
| const SettingsSearchField = ({ | ||
| onSelect, | ||
| }: { | ||
| onSelect: (entry: SearchableEntry) => void; | ||
| }): JSX.Element => { | ||
| const [query, setQuery] = useState(""); | ||
| const [activeIndex, setActiveIndex] = useState(0); | ||
| const [isOpen, setIsOpen] = useState(false); | ||
| const inputRef = useRef<HTMLInputElement | null>(null); | ||
| const scrollContainerRef = useRef<HTMLDivElement | null>(null); | ||
|
|
||
| const results = useMemo( | ||
| () => rankSettings({ entries: buildSettingsCatalog(), query }), | ||
| [query], | ||
|
Comment on lines
+63
to
+65
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a graph has no node type named Export, typing Useful? React with 👍 / 👎. |
||
| ); | ||
| const isShowingResults = isOpen && query.trim() !== ""; | ||
|
|
||
| // Keeps the keyboard-selected row visible. | ||
| useEffect(() => { | ||
| const container = scrollContainerRef.current; | ||
| if (!container) return; | ||
| const activeItem = container.querySelector<HTMLElement>( | ||
| '[data-active="true"]', | ||
| ); | ||
| if (!activeItem) return; | ||
| const containerRect = container.getBoundingClientRect(); | ||
| const itemRect = activeItem.getBoundingClientRect(); | ||
| if ( | ||
| itemRect.bottom > containerRect.bottom || | ||
| itemRect.top < containerRect.top | ||
| ) { | ||
| activeItem.scrollIntoView({ block: "nearest", behavior: "auto" }); | ||
| } | ||
| }, [activeIndex, results]); | ||
|
|
||
| const select = (entry: SearchableEntry) => { | ||
| onSelect(entry); | ||
| setQuery(""); | ||
| setIsOpen(false); | ||
| inputRef.current?.blur(); | ||
| }; | ||
|
|
||
| const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { | ||
| if (event.key === "Escape") { | ||
| // Escape clears the query instead of closing the dialog. | ||
| if (query !== "") event.stopPropagation(); | ||
| setQuery(""); | ||
| setIsOpen(false); | ||
| return; | ||
| } | ||
| if (!isShowingResults || results.length === 0) return; | ||
| if (event.key === "ArrowDown") { | ||
| event.preventDefault(); | ||
| setActiveIndex((index) => (index + 1) % results.length); | ||
| } else if (event.key === "ArrowUp") { | ||
| event.preventDefault(); | ||
| setActiveIndex((index) => (index - 1 + results.length) % results.length); | ||
| } else if (event.key === "Enter") { | ||
| event.preventDefault(); | ||
| const entry = results[activeIndex]; | ||
| if (entry) select(entry); | ||
| } | ||
| }; | ||
|
|
||
| return ( | ||
| <Popover | ||
| isOpen={isShowingResults} | ||
| // `flip`/`preventOverflow` off: the rail is at the window edge, so Blueprint's | ||
| // overflow handling pushes the list off the dialog instead of across it. | ||
| position={Position.BOTTOM_LEFT} | ||
| modifiers={{ | ||
| flip: { enabled: false }, | ||
| preventOverflow: { enabled: false }, | ||
| }} | ||
| minimal={true} | ||
| autoFocus={false} | ||
| enforceFocus={false} | ||
| fill={true} | ||
| popoverClassName="dg-settings-search__results" | ||
| content={ | ||
| results.length === 0 ? ( | ||
| <div className="flex items-center gap-2 p-3 text-sm text-gray-500"> | ||
| <Icon icon="search" iconSize={12} /> | ||
| <span>No settings match “{query.trim()}”</span> | ||
| </div> | ||
| ) : ( | ||
| <div className="dg-settings-search__scroll" ref={scrollContainerRef}> | ||
| <Menu> | ||
| {results.map((entry, index) => ( | ||
| <SettingsSearchResult | ||
| key={entry.id} | ||
| entry={entry} | ||
| isActive={index === activeIndex} | ||
| onSelect={select} | ||
| /> | ||
| ))} | ||
| </Menu> | ||
| </div> | ||
| ) | ||
| } | ||
| > | ||
| <div className="dg-settings-search"> | ||
| <InputGroup | ||
| inputRef={(input) => { | ||
| inputRef.current = input; | ||
| }} | ||
| leftIcon="search" | ||
| placeholder="Search settings" | ||
| value={query} | ||
| onChange={(event: React.ChangeEvent<HTMLInputElement>) => { | ||
| setQuery(event.target.value); | ||
| setActiveIndex(0); | ||
| setIsOpen(true); | ||
| }} | ||
| onFocus={() => setIsOpen(true)} | ||
| onBlur={() => setIsOpen(false)} | ||
| onKeyDown={handleKeyDown} | ||
| /> | ||
| </div> | ||
| </Popover> | ||
| ); | ||
| }; | ||
|
|
||
| export default SettingsSearchField; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| import { useEffect } from "react"; | ||
| import { | ||
| SETTING_ANCHOR_FLASH_CLASS, | ||
| settingAnchorSelector, | ||
| } from "../utils/settingAnchor"; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ~ |
||
|
|
||
| /** Roughly 500ms at 60fps. */ | ||
| const MAX_LOOKUP_FRAMES = 30; | ||
| const FLASH_DURATION_MS = 1600; | ||
|
|
||
| const flashTimeouts = new WeakMap<Element, number>(); | ||
|
|
||
| /** Owns the flash independently of the effect: settling clears anchorId and re-runs the | ||
| * effect, whose cleanup would otherwise strip the class before it is seen. */ | ||
| const flashRow = (target: Element): void => { | ||
| const pending = flashTimeouts.get(target); | ||
| if (pending !== undefined) window.clearTimeout(pending); | ||
|
|
||
| // Remove and reflow so hitting the same row twice restarts the animation. | ||
| target.classList.remove(SETTING_ANCHOR_FLASH_CLASS); | ||
| target.getBoundingClientRect(); | ||
| target.classList.add(SETTING_ANCHOR_FLASH_CLASS); | ||
|
|
||
| flashTimeouts.set( | ||
| target, | ||
| window.setTimeout(() => { | ||
| target.classList.remove(SETTING_ANCHOR_FLASH_CLASS); | ||
| flashTimeouts.delete(target); | ||
| }, FLASH_DURATION_MS), | ||
| ); | ||
| }; | ||
|
|
||
| /** The row is not in the DOM when the jump is dispatched — only the active panel renders, | ||
| * and a `Collapse` mounts later still — so a single lookup misses and this retries. */ | ||
| export const useSettingAnchorScroll = ({ | ||
| anchorId, | ||
| onSettled, | ||
| }: { | ||
| anchorId: string | null; | ||
| onSettled: () => void; | ||
| }): void => { | ||
| useEffect(() => { | ||
| if (!anchorId) return; | ||
| let frame = 0; | ||
| let rafId = 0; | ||
|
|
||
| const look = () => { | ||
| const target = document.querySelector(settingAnchorSelector(anchorId)); | ||
| if (target) { | ||
| target.scrollIntoView({ block: "center", behavior: "smooth" }); | ||
| flashRow(target); | ||
| onSettled(); | ||
| return; | ||
| } | ||
| if (frame++ >= MAX_LOOKUP_FRAMES) { | ||
| onSettled(); | ||
| return; | ||
| } | ||
| rafId = requestAnimationFrame(look); | ||
| }; | ||
|
|
||
| rafId = requestAnimationFrame(look); | ||
| return () => cancelAnimationFrame(rafId); | ||
| }, [anchorId, onSettled]); | ||
|
trangdoan982 marked this conversation as resolved.
|
||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
~