Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/roam/src/components/settings/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
Expand Down Expand Up @@ -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<string | null>(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(
Expand Down Expand Up @@ -185,6 +199,7 @@ export const SettingsDialog = ({
vertical={true}
renderActiveTabPanelOnly={true}
>
<SettingsSearchField onSelect={handleSearchSelect} />
<SectionHeader>Preferences</SectionHeader>
<Tab
id={SETTINGS_TAB_IDS.preferencesGeneral}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export type SettingsNavValue = {
push: (segment: string) => 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<SettingsNavValue | null>(null);
Expand All @@ -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],
);
Expand Down
175 changes: 175 additions & 0 deletions apps/roam/src/components/settings/navigation/SettingsSearchField.tsx
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;
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]);
Comment thread
trangdoan982 marked this conversation as resolved.
};
6 changes: 6 additions & 0 deletions apps/roam/src/components/settings/utils/settingAnchor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,9 @@ export const settingAnchor = (
): Record<string, string> => ({
[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";
9 changes: 9 additions & 0 deletions apps/roam/src/components/settings/utils/settingsNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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,
Expand All @@ -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":
Expand Down
Loading