feat: shared list kit, settings search, rail order and ui consistency fixes

This commit is contained in:
LukeGus committed 2026-10-06 13:02:05 -05:00
1 parent 3ed5775ed5
commit 58c3434e58
68 files changed
+2078 -512

No files matched your search

+2 -1
View File
@@ -233,8 +233,9 @@ export function FeatureSettingsForm({
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
className="h-7 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
disabled={!dirty || saving}
onClick={() => void save()}
>
+3 -2
View File
@@ -1,3 +1,4 @@
import { Select2 } from "@/components/select2";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -453,7 +454,7 @@ export function KeybindingsSettings() {
<label className="text-xs font-semibold">
{t("newUi.sidebar.keybindings.actionLabel")}
</label>
<select
<Select2
value={draft.type}
onChange={(e) => setDraft({ type: e.target.value })}
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
@@ -466,7 +467,7 @@ export function KeybindingsSettings() {
{t(action.labelKey)}
</option>
))}
</select>
</Select2>
</div>
{Editor && <Editor action={draft} onChange={setDraft} />}
+49 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, Settings as SettingsIcon } from "lucide-react";
import { ChevronDown, Search, Settings as SettingsIcon } from "lucide-react";
import {
GroupHeading,
PANEL,
@@ -23,6 +23,7 @@ import {
} from "./FeatureSettingsSections";
import { KeybindingsSettings } from "./KeybindingsSettings";
import { SettingsEmbedContext } from "./settings-embed";
import { useSettingsPageFilter } from "./page-filter";
import {
CORE_SETTINGS_PAGES,
SETTINGS_BAND_ORDER,
@@ -35,6 +36,8 @@ import {
export interface SettingsTabProps {
/** The page to open, from the tab's data. */
section?: string;
/** An element id on that page to scroll to, as "id@nonce". */
reveal?: string;
username: string;
isAdmin: boolean;
onLogout?: () => void;
@@ -76,6 +79,7 @@ function revealText(container: HTMLElement, needle: string): boolean {
/** Everything that configures the app, one page at a time. */
export function SettingsTab({
section,
reveal,
username,
isAdmin,
onLogout,
@@ -88,7 +92,9 @@ export function SettingsTab({
const adminFeatures = useFeatureSettings("admin");
const [query, setQuery] = useState("");
const [navOpen, setNavOpen] = useState(false);
const [pageQuery, setPageQuery] = useState("");
const contentRef = useRef<HTMLDivElement>(null);
const pageRef = useRef<HTMLDivElement>(null);
const pages = useMemo(() => {
const plugins = new Map<string, SettingsPage>();
@@ -130,15 +136,42 @@ export function SettingsTab({
if (section) setCurrent(section);
}, [section]);
// The page renders lazily, so the target may take a moment to appear.
useEffect(() => {
const target = reveal?.split("@")[0];
if (!target) return;
let tries = 0;
let timer = 0;
const attempt = () => {
const el = contentRef.current?.querySelector<HTMLElement>(
`#${CSS.escape(target)}`,
);
if (el) {
el.scrollIntoView?.({ block: "start", behavior: "smooth" });
el.classList.add("settings-search-hit");
timer = window.setTimeout(
() => el.classList.remove("settings-search-hit"),
1600,
);
return;
}
if (++tries < 15) timer = window.setTimeout(attempt, 120);
};
timer = window.setTimeout(attempt, 60);
return () => window.clearTimeout(timer);
}, [reveal]);
const page =
pages.find((p) => p.id === current) ??
pages.find((p) => p.id === "account")!;
const pageHasMatches = useSettingsPageFilter(pageRef, pageQuery, page.id);
const hits = searchSettingsPages(pages, query, (key) => t(key));
const pageLabel = (p: SettingsPage) => p.label ?? t(p.labelKey);
const go = (id: string) => {
setCurrent(id);
setNavOpen(false);
setPageQuery("");
contentRef.current?.scrollTo?.({ top: 0 });
const needle = query.trim();
if (!needle) return;
@@ -261,7 +294,7 @@ export function SettingsTab({
<nav
aria-label={t("settings.pages")}
className={cn(
"w-full shrink-0 overflow-y-auto border-r border-border bg-sidebar thin-scrollbar md:static md:block md:w-60",
"w-full shrink-0 overflow-y-auto border-r border-border bg-background thin-scrollbar md:static md:block md:w-60",
navOpen ? "absolute inset-0 z-20 block" : "hidden",
)}
>
@@ -269,11 +302,25 @@ export function SettingsTab({
</nav>
<SurfaceScope kind="tab" className="min-w-0">
<div className={`shrink-0 border-b border-border ${PANEL.band}`}>
<div className="mx-auto w-full max-w-3xl">
<PanelSearch
value={pageQuery}
onChange={setPageQuery}
placeholder={t("settings.searchPage")}
fill
/>
</div>
</div>
<div
ref={contentRef}
className={`min-h-0 flex-1 overflow-y-auto thin-scrollbar ${PANEL.body}`}
>
{!pageHasMatches && (
<EmptyState icon={Search} title={t("settings.noPageMatches")} />
)}
<div
ref={pageRef}
className={`mx-auto flex w-full max-w-3xl flex-col ${PANEL.gap} pb-6`}
>
<SettingsEmbedContext.Provider value>
+139
View File
@@ -0,0 +1,139 @@
import { useEffect, useState, type RefObject } from "react";
export const HIDDEN_ATTR = "data-search-hidden";
const HIGHLIGHT = "settings-search";
const BLOCK = ".bg-card, [data-setting-row]";
function matches(el: Element, needle: string) {
return (el.textContent ?? "").toLowerCase().includes(needle);
}
/** Visible blocks with no other block above them inside root. */
function topBlocks(root: HTMLElement, blocks: Element[], selector: string) {
return blocks.filter((el) => {
let parent = el.parentElement;
while (parent && parent !== root) {
if (parent.matches(selector) || parent.classList.contains("hidden"))
return false;
parent = parent.parentElement;
}
return true;
});
}
/**
* Hides the cards and rows on a settings page that do not contain the query.
* A card whose own text matches keeps every row; otherwise only its matching
* rows stay. Returns how many cards are still showing.
*/
export function filterSettingsPage(
root: HTMLElement,
query: string,
blockSelector: string = BLOCK,
): number {
for (const el of root.querySelectorAll(`[${HIDDEN_ATTR}]`)) {
el.removeAttribute(HIDDEN_ATTR);
}
const needle = query.trim().toLowerCase();
const blocks = [...root.querySelectorAll(blockSelector)];
const cards = topBlocks(root, blocks, blockSelector);
if (!needle) return cards.length;
let shown = 0;
for (const card of cards) {
if (!matches(card, needle)) {
card.setAttribute(HIDDEN_ATTR, "");
continue;
}
shown++;
const rows = [...card.querySelectorAll("[data-setting-row]")].filter(
(row) => row !== card,
);
if (!rows.some((row) => matches(row, needle))) continue;
for (const row of rows) {
if (!matches(row, needle)) row.setAttribute(HIDDEN_ATTR, "");
}
for (const inner of card.querySelectorAll(".bg-card")) {
if (inner !== card && !matches(inner, needle)) {
inner.setAttribute(HIDDEN_ATTR, "");
}
}
}
return shown;
}
type HighlightRegistry = Map<string, unknown>;
function highlights(): HighlightRegistry | undefined {
const css = (globalThis as { CSS?: { highlights?: HighlightRegistry } }).CSS;
return css?.highlights;
}
/** Marks every match with the CSS highlight API where the browser has it. */
function highlightMatches(root: HTMLElement, query: string) {
const registry = highlights();
const Highlight = (
globalThis as { Highlight?: new (...r: Range[]) => unknown }
).Highlight;
if (!registry || !Highlight) return;
registry.delete(HIGHLIGHT);
const needle = query.trim().toLowerCase();
if (!needle) return;
const ranges: Range[] = [];
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const text = node.textContent?.toLowerCase() ?? "";
let at = text.indexOf(needle);
while (at !== -1) {
const range = document.createRange();
range.setStart(node, at);
range.setEnd(node, at + needle.length);
ranges.push(range);
at = text.indexOf(needle, at + needle.length);
}
}
registry.set(HIGHLIGHT, new Highlight(...ranges));
}
/**
* Keeps a settings page filtered while its content loads and changes.
* Returns false when the query hides everything.
*/
export function useSettingsPageFilter(
rootRef: RefObject<HTMLElement | null>,
query: string,
pageKey: string,
blockSelector: string = BLOCK,
onFiltered?: (root: HTMLElement) => void,
): boolean {
const [anyShown, setAnyShown] = useState(true);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
let frame = 0;
const run = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
setAnyShown(
filterSettingsPage(root, query, blockSelector) > 0 || !query.trim(),
);
highlightMatches(root, query);
onFiltered?.(root);
});
};
run();
if (!query.trim()) return () => cancelAnimationFrame(frame);
const observer = new MutationObserver(run);
observer.observe(root, {
childList: true,
subtree: true,
characterData: true,
});
return () => {
observer.disconnect();
cancelAnimationFrame(frame);
highlights()?.delete(HIGHLIGHT);
};
}, [rootRef, query, pageKey, blockSelector, onFiltered]);
return anyShown;
}
+4 -2
View File
@@ -348,8 +348,9 @@ export function LinkServerDialog({
{t("common.cancel")}
</Button>
<Button
variant="outline"
type="submit"
className="rounded-none"
className="rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
disabled={checking || !target.serverUrl.trim()}
>
{checking && <Loader2 className="size-4 animate-spin" />}
@@ -420,8 +421,9 @@ export function LinkServerDialog({
{t("common.cancel")}
</Button>
<Button
variant="outline"
type="button"
className="rounded-none"
className="rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
disabled={!token}
onClick={() => token && void finish(token, mode)}
>
+6 -3
View File
@@ -198,7 +198,8 @@ export function SyncPanel() {
<li>{t("sync.unlinkedPointAccount")}</li>
</ul>
<Button
className="self-start rounded-none"
variant="outline"
className="self-start rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
onClick={() => setLinkOpen(true)}
>
<Cloud className="size-4" />
@@ -302,8 +303,9 @@ export function SyncPanel() {
<div className="flex flex-wrap gap-2">
{state === "signed_out" ? (
<Button
variant="outline"
size="sm"
className="h-7 text-[10px] rounded-none"
className="h-7 text-[10px] rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
onClick={() => setReloginOpen(true)}
>
{t("sync.signInAgain")}
@@ -535,8 +537,9 @@ export function SyncPanel() {
</div>
{proxyDraft && (
<Button
variant="outline"
size="sm"
className="self-start h-7 text-[10px] rounded-none"
className="self-start h-7 text-[10px] rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
disabled={busy === "proxy"}
onClick={() =>
run("proxy", async () => {