-
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
Open
trangdoan982
wants to merge
6
commits into
eng-2213-settings-catalog-address-every-setting-by-key-and-location
Choose a base branch
from
eng-2184-add-search-to-the-settings-panel
base: eng-2213-settings-catalog-address-every-setting-by-key-and-location
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
33f5acb
ENG-2184 Add search to the settings panel
trangdoan982 aa77d83
ENG-2184 Let the search-result flash outlive the jump that started it
trangdoan982 223126c
ENG-2184 Cover the search ranking tiers
trangdoan982 a6e4e23
ENG-2184 Inset the search field to the rail's text edge
trangdoan982 050e164
ENG-2184 Escape backslashes in the anchor selector; drop a redundant …
trangdoan982 ab521e3
ENG-2184 Drop unit tests and trim comments to the decisions they record
trangdoan982 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
175 changes: 175 additions & 0 deletions
175
apps/roam/src/components/settings/navigation/SettingsSearchField.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"; | ||
|
|
||
| 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], | ||
| ); | ||
| 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; |
65 changes: 65 additions & 0 deletions
65
apps/roam/src/components/settings/navigation/useSettingAnchorScroll.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"; | ||
|
|
||
| /** 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]); | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.