diff --git a/apps/roam/src/components/settings/Settings.tsx b/apps/roam/src/components/settings/Settings.tsx index 5ec8a023d..7225beea4 100644 --- a/apps/roam/src/components/settings/Settings.tsx +++ b/apps/roam/src/components/settings/Settings.tsx @@ -40,6 +40,9 @@ import { tabIdOf, } from "./utils/settingsNavigation"; import { SettingsNavProvider } from "./navigation/SettingsNavContext"; +import SettingsSearchField from "./navigation/SettingsSearchField"; +import { useSettingAnchorScroll } from "./navigation/useSettingAnchorScroll"; +import type { SearchableEntry } from "./utils/settingsCatalog"; import GrammarNodesRoute from "./GrammarNodesRoute"; const SectionHeader = ({ children }: { children: React.ReactNode }) => ( @@ -95,6 +98,17 @@ export const SettingsDialog = ({ (tabId: string) => dispatch({ type: "select-tab", tabId }), [], ); + // Cleared once settled, so a repeat jump to the same row still scrolls. + const [pendingAnchorId, setPendingAnchorId] = useState(null); + const handleSearchSelect = useCallback((entry: SearchableEntry) => { + dispatch({ type: "navigate", path: entry.path }); + setPendingAnchorId(entry.kind === "setting" ? entry.anchorId : null); + }, []); + const clearPendingAnchor = useCallback(() => setPendingAnchorId(null), []); + useSettingAnchorScroll({ + anchorId: pendingAnchorId, + onSettled: clearPendingAnchor, + }); // eslint-disable-next-line react-hooks/exhaustive-deps const settings = useMemo(() => bulkReadSettings(), [activeTabId]); const [leftSidebarEnabled, setLeftSidebarEnabled] = useState( @@ -185,6 +199,7 @@ export const SettingsDialog = ({ vertical={true} renderActiveTabPanelOnly={true} > + Preferences void; pop: () => void; goToDepth: (depth: number) => void; + /** Jumps straight to a full route; `push` only moves one segment at a time. */ + navigate: (path: SettingsPath) => void; }; const SettingsNavContext = createContext(null); @@ -34,6 +36,7 @@ export const SettingsNavProvider = ({ push: (segment) => dispatch({ type: "push", segment }), pop: () => dispatch({ type: "pop" }), goToDepth: (depth) => dispatch({ type: "truncate", depth }), + navigate: (target) => dispatch({ type: "navigate", path: target }), }), [path, dispatch], ); diff --git a/apps/roam/src/components/settings/navigation/SettingsSearchField.tsx b/apps/roam/src/components/settings/navigation/SettingsSearchField.tsx new file mode 100644 index 000000000..cd133fe4c --- /dev/null +++ b/apps/roam/src/components/settings/navigation/SettingsSearchField.tsx @@ -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 => ( + + {entry.label} + {/* Undimmed: any opacity drops white-on-#137CBD below AA. */} + + {entry.breadcrumb} + + + } + // 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(null); + const scrollContainerRef = useRef(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( + '[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) => { + 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 ( + + + No settings match “{query.trim()}” + + ) : ( +
+ + {results.map((entry, index) => ( + + ))} + +
+ ) + } + > +
+ { + inputRef.current = input; + }} + leftIcon="search" + placeholder="Search settings" + value={query} + onChange={(event: React.ChangeEvent) => { + setQuery(event.target.value); + setActiveIndex(0); + setIsOpen(true); + }} + onFocus={() => setIsOpen(true)} + onBlur={() => setIsOpen(false)} + onKeyDown={handleKeyDown} + /> +
+
+ ); +}; + +export default SettingsSearchField; diff --git a/apps/roam/src/components/settings/navigation/useSettingAnchorScroll.ts b/apps/roam/src/components/settings/navigation/useSettingAnchorScroll.ts new file mode 100644 index 000000000..5c5eacd27 --- /dev/null +++ b/apps/roam/src/components/settings/navigation/useSettingAnchorScroll.ts @@ -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(); + +/** 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]); +}; diff --git a/apps/roam/src/components/settings/utils/settingAnchor.ts b/apps/roam/src/components/settings/utils/settingAnchor.ts index 88757806b..be9aef18e 100644 --- a/apps/roam/src/components/settings/utils/settingAnchor.ts +++ b/apps/roam/src/components/settings/utils/settingAnchor.ts @@ -6,3 +6,9 @@ export const settingAnchor = ( ): Record => ({ [SETTING_ANCHOR_ATTRIBUTE]: settingKeys.join("/"), }); + +/** Escaped so a key with a quote or backslash cannot break the selector. */ +export const settingAnchorSelector = (anchorId: string): string => + `[${SETTING_ANCHOR_ATTRIBUTE}="${anchorId.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`; + +export const SETTING_ANCHOR_FLASH_CLASS = "dg-setting-row--flash"; diff --git a/apps/roam/src/components/settings/utils/settingsNavigation.ts b/apps/roam/src/components/settings/utils/settingsNavigation.ts index bcef6a444..34a6b0021 100644 --- a/apps/roam/src/components/settings/utils/settingsNavigation.ts +++ b/apps/roam/src/components/settings/utils/settingsNavigation.ts @@ -10,6 +10,7 @@ export type SettingsPath = readonly string[]; export type SettingsNavAction = | { type: "select-tab"; tabId: string } + | { type: "navigate"; path: SettingsPath } | { type: "push"; segment: string } | { type: "pop" } | { type: "truncate"; depth: number }; @@ -30,6 +31,9 @@ export const depthOf = (path: SettingsPath): number => export const segmentsOf = (path: SettingsPath): readonly string[] => path.slice(1); +export const isSamePath = (a: SettingsPath, b: SettingsPath): boolean => + a.length === b.length && a.every((segment, index) => segment === b[index]); + export const settingsNavReducer = ( state: SettingsPath, action: SettingsNavAction, @@ -39,6 +43,11 @@ export const settingsNavReducer = ( return action.tabId === tabIdOf(state) && state.length === 1 ? state : rootPath(action.tabId); + // Search jumps several segments at once; an empty path is ignored. + case "navigate": + return action.path.length === 0 || isSamePath(action.path, state) + ? state + : [...action.path]; case "push": return [...state, action.segment]; case "pop": diff --git a/apps/roam/src/components/settings/utils/settingsSearch.ts b/apps/roam/src/components/settings/utils/settingsSearch.ts new file mode 100644 index 000000000..6b007805c --- /dev/null +++ b/apps/roam/src/components/settings/utils/settingsSearch.ts @@ -0,0 +1,94 @@ +import type { SearchableEntry } from "./settingsCatalog"; + +export const SETTINGS_SEARCH_RESULT_LIMIT = 8; + +/** Lower is better, so an exact label is never buried under a description that + * happens to mention the same word. */ +const MatchTier = { + exactLabel: 0, + labelPrefix: 1, + labelSubstring: 2, + breadcrumb: 3, + keyword: 4, + description: 5, +} as const; + +type Tier = (typeof MatchTier)[keyof typeof MatchTier]; + +const normalize = (value: string): string => + value.toLowerCase().replace(/\s+/g, " ").trim(); + +const haystackOf = (entry: SearchableEntry): string => + normalize( + [ + entry.label, + entry.breadcrumb, + ...entry.keywords, + entry.kind === "setting" ? (entry.description ?? "") : "", + ].join(" "), + ); + +const tierFor = (entry: SearchableEntry, query: string): Tier | null => { + const label = normalize(entry.label); + if (label === query) return MatchTier.exactLabel; + if (label.startsWith(query)) return MatchTier.labelPrefix; + if (label.includes(query)) return MatchTier.labelSubstring; + if (normalize(entry.breadcrumb).includes(query)) return MatchTier.breadcrumb; + if (entry.keywords.some((keyword) => normalize(keyword).includes(query))) + return MatchTier.keyword; + if ( + entry.kind === "setting" && + entry.description && + normalize(entry.description).includes(query) + ) + return MatchTier.description; + return null; +}; + +/** Multi-word queries match when every word appears somewhere, but the tier still comes + * from the whole query, so single-word precision is not diluted. */ +const matches = ( + entry: SearchableEntry, + query: string, +): { tier: Tier } | null => { + const whole = tierFor(entry, query); + if (whole !== null) return { tier: whole }; + + const words = query.split(" ").filter(Boolean); + if (words.length < 2) return null; + const haystack = haystackOf(entry); + return words.every((word) => haystack.includes(word)) + ? { tier: MatchTier.description } + : null; +}; + +/** Settings before pages at the same tier; then alphabetical, for a stable list. */ +const compare = ( + a: { entry: SearchableEntry; tier: Tier }, + b: { entry: SearchableEntry; tier: Tier }, +): number => { + if (a.tier !== b.tier) return a.tier - b.tier; + if (a.entry.kind !== b.entry.kind) return a.entry.kind === "setting" ? -1 : 1; + return a.entry.label.localeCompare(b.entry.label); +}; + +export const rankSettings = ({ + entries, + query, + limit = SETTINGS_SEARCH_RESULT_LIMIT, +}: { + entries: readonly SearchableEntry[]; + query: string; + limit?: number; +}): SearchableEntry[] => { + const normalized = normalize(query); + if (normalized === "") return []; + return entries + .flatMap((entry) => { + const match = matches(entry, normalized); + return match ? [{ entry, tier: match.tier }] : []; + }) + .sort(compare) + .slice(0, limit) + .map(({ entry }) => entry); +}; diff --git a/apps/roam/src/styles/settingsStyles.css b/apps/roam/src/styles/settingsStyles.css index a0ec6ac5e..e7a7a2390 100644 --- a/apps/roam/src/styles/settingsStyles.css +++ b/apps/roam/src/styles/settingsStyles.css @@ -38,7 +38,8 @@ } :root { - /* Brand secondary, mirroring packages/tailwind-config. */ + /* Brand colours, mirroring packages/tailwind-config. */ + --dg-primary: #ff8c4b; --dg-secondary: #5f57c0; /** Neutrals mirrored too: Roam ships Tailwind's utilities but not this repo's theme extensions. */ --dg-neutral-dark: #1f1f1f; @@ -117,3 +118,46 @@ text-align: center; font-family: monospace; } + +/** Inset to the tab titles' left edge. */ +.dg-settings-search { + margin: 4px 10px 8px; +} + +/** Portalled by Blueprint so the rail's overflow cannot clip it; painted here because + * Roam themes `.bp3-popover` and the library default is transparent. */ +.dg-settings-search__results .bp3-popover-content { + width: 340px; + background-color: #fff; + border-radius: 3px; + box-shadow: + 0 0 0 1px rgba(16, 22, 26, 0.1), + 0 2px 4px rgba(16, 22, 26, 0.2), + 0 8px 24px rgba(16, 22, 26, 0.2); +} + +.dg-settings-search__results .bp3-menu { + background-color: transparent; +} + +.dg-settings-search__scroll { + max-height: 20rem; + overflow-y: auto; +} + +/** Marks the row a search result jumped to. */ +.dg-setting-row--flash { + animation: dg-setting-row-flash 1.6s ease-out; +} + +@keyframes dg-setting-row-flash { + 0%, + 40% { + background-color: var(--dg-neutral-light); + box-shadow: inset 3px 0 0 0 var(--dg-primary); + } + 100% { + background-color: transparent; + box-shadow: inset 3px 0 0 0 transparent; + } +}