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

+102 -16
View File
@@ -1,6 +1,9 @@
/* eslint-disable react-refresh/only-export-components */
import type React from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useRef,
@@ -9,10 +12,16 @@ import {
type RefObject,
} from "react";
import { useTranslation } from "react-i18next";
import { GroupHeading, PANEL } from "@/components/panel-layout";
import { Search } from "lucide-react";
import { GroupHeading, PANEL, PanelSearch } from "@/components/panel-layout";
import { TabStrip } from "@/components/tab-strip";
import { EmptyState } from "@/components/empty-state";
import { HIDDEN_ATTR, useSettingsPageFilter } from "@/settings/page-filter";
import { cn } from "@/lib/utils";
/** True while the editor is searched, so every lazy section mounts. */
const SearchingContext = createContext(false);
export interface EditorNavItem {
id: string;
label: string;
@@ -27,6 +36,25 @@ export interface EditorNavBand {
items: EditorNavItem[];
}
/**
* The element that really scrolls: the editor's own area, or the page around
* it when the editor is embedded somewhere that scrolls itself.
*/
function scroller(el: HTMLElement): HTMLElement {
let node: HTMLElement | null = el;
while (node) {
const overflow = getComputedStyle(node).overflowY;
if (
(overflow === "auto" || overflow === "scroll") &&
node.scrollHeight > node.clientHeight + 1
) {
return node;
}
node = node.parentElement;
}
return el;
}
/** Which section is in view, and a way to jump to one. */
export function useScrollSpy(
containerRef: RefObject<HTMLElement | null>,
@@ -42,7 +70,8 @@ export function useScrollSpy(
if (!container) return;
const onScroll = () => {
if (Date.now() < lockUntil.current) return;
const top = container.getBoundingClientRect().top;
const area = scroller(container);
const top = area.getBoundingClientRect().top;
let current = ids[0] ?? "";
for (const id of ids) {
const el = container.querySelector<HTMLElement>(
@@ -52,17 +81,21 @@ export function useScrollSpy(
if (el.getBoundingClientRect().top - top <= 24) current = id;
}
if (
container.scrollHeight > container.clientHeight &&
container.scrollTop + container.clientHeight >=
container.scrollHeight - 8
area.scrollHeight > area.clientHeight &&
area.scrollTop + area.clientHeight >= area.scrollHeight - 8
) {
current = ids[ids.length - 1] ?? current;
}
setActive(current);
};
onScroll();
container.addEventListener("scroll", onScroll, { passive: true });
return () => container.removeEventListener("scroll", onScroll);
// Captured on the document so an outer scroller is heard too.
document.addEventListener("scroll", onScroll, {
passive: true,
capture: true,
});
return () =>
document.removeEventListener("scroll", onScroll, { capture: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [containerRef, idsKey]);
@@ -76,11 +109,12 @@ export function useScrollSpy(
if (!container || !el) return;
lockUntil.current = Date.now() + 600;
setActive(id);
container.scrollTo?.({
const area = scroller(container);
area.scrollTo?.({
top:
el.getBoundingClientRect().top -
container.getBoundingClientRect().top +
container.scrollTop -
area.getBoundingClientRect().top +
area.scrollTop -
8,
behavior: "smooth",
});
@@ -114,9 +148,13 @@ export function EditorSection({
*/
export function LazySection({ children }: { children: ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const searching = useContext(SearchingContext);
const [shown, setShown] = useState(
() => typeof IntersectionObserver === "undefined",
);
useEffect(() => {
if (searching) setShown(true);
}, [searching]);
useLayoutEffect(() => {
if (shown) return;
const el = ref.current;
@@ -167,9 +205,39 @@ export function EditorPane({
}) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const ids = bands.flatMap((band) => band.items.map((item) => item.id));
const formRef = useRef<HTMLDivElement>(null);
const [query, setQuery] = useState("");
const [hiddenIds, setHiddenIds] = useState<Set<string>>(new Set());
const searching = query.trim().length > 0;
const onFiltered = useCallback((root: HTMLElement) => {
const next = new Set<string>();
for (const el of root.querySelectorAll<HTMLElement>(
`[data-section][${HIDDEN_ATTR}]`,
)) {
if (el.dataset.section) next.add(el.dataset.section);
}
setHiddenIds((prev) =>
prev.size === next.size && [...next].every((id) => prev.has(id))
? prev
: next,
);
}, []);
const anyShown = useSettingsPageFilter(
formRef,
query,
"editor",
"[data-section]",
onFiltered,
);
const visibleBands = bands
.map((band) => ({
...band,
items: band.items.filter((item) => !hiddenIds.has(item.id)),
}))
.filter((band) => band.items.length > 0);
const ids = visibleBands.flatMap((band) => band.items.map((item) => item.id));
const { active, scrollTo } = useScrollSpy(scrollRef, ids);
const showNav = ids.length > 1;
const showNav = bands.flatMap((band) => band.items).length > 1;
if (revealRef) revealRef.current = scrollTo;
return (
@@ -179,7 +247,7 @@ export function EditorPane({
aria-label={t("manage.sections")}
className="hidden w-52 shrink-0 flex-col gap-3 overflow-y-auto border-r border-border py-2.5 thin-scrollbar lg:flex"
>
{bands.map((band) => (
{visibleBands.map((band) => (
<div key={band.id} className="flex flex-col">
<GroupHeading title={band.label} className="px-2.5 pb-1.5" />
{band.items.map((item) => {
@@ -220,10 +288,20 @@ export function EditorPane({
)}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<div className={`shrink-0 border-b border-border ${PANEL.band}`}>
<div className="mx-auto w-full max-w-3xl">
<PanelSearch
value={query}
onChange={setQuery}
placeholder={t("manage.searchSettings")}
fill
/>
</div>
</div>
{showNav && (
<div className="shrink-0 border-b border-border px-1 lg:hidden">
<TabStrip
tabs={bands.flatMap((band) =>
tabs={visibleBands.flatMap((band) =>
band.items.map((item) => ({ id: item.id, label: item.label })),
)}
activeTab={active}
@@ -235,9 +313,17 @@ export function EditorPane({
ref={scrollRef}
className={`min-h-0 flex-1 overflow-y-auto thin-scrollbar ${PANEL.body}`}
>
<div className={`mx-auto flex max-w-3xl flex-col ${PANEL.gap} pb-6`}>
{!anyShown && (
<EmptyState icon={Search} title={t("manage.noMatchingSettings")} />
)}
<div
ref={formRef}
className={`mx-auto flex max-w-3xl flex-col ${PANEL.gap} pb-6`}
>
{banner}
{children}
<SearchingContext.Provider value={searching}>
{children}
</SearchingContext.Provider>
</div>
</div>
{(footer || errorCount > 0) && (
+57 -12
View File
@@ -14,7 +14,7 @@ import {
} from "lucide-react";
import { Button } from "@/components/button";
import { EmptyState } from "@/components/empty-state";
import { PanelSearch, Segmented } from "@/components/panel-layout";
import { PanelSearch } from "@/components/panel-layout";
import { cn } from "@/lib/utils";
import { rem } from "@/lib/rem";
import type { Credential, Host } from "@/types/ui-types";
@@ -143,24 +143,69 @@ export function ManageList({
<div
className={cn(
editing ? "hidden md:flex" : "flex",
"w-full shrink-0 flex-col border-r border-border bg-sidebar md:w-72",
"w-full shrink-0 flex-col border-r border-border bg-background md:w-72",
)}
>
<div className="flex flex-col gap-2 border-b border-border px-2.5 py-2">
<Segmented
value={mode}
onChange={onMode}
options={[
{ value: "hosts", label: t("nav.hosts"), count: hosts.length },
<nav
aria-label={t("manage.sections")}
className="flex shrink-0 flex-col border-b border-border py-1"
>
{(
[
{
value: "hosts",
label: t("nav.hosts"),
icon: Server,
count: hosts.length,
},
{
value: "credentials",
label: t("nav.credentials"),
icon: KeyRound,
count: credentials.length,
},
{ value: "defaults", label: t("manage.defaults") },
]}
className="w-full [&>button]:flex-1"
/>
{
value: "defaults",
label: t("manage.defaults"),
icon: SlidersHorizontal,
},
] as {
value: ManageMode;
label: string;
icon: typeof Server;
count?: number;
}[]
).map((item) => {
const active = item.value === mode;
const Icon = item.icon;
return (
<button
key={item.value}
type="button"
aria-current={active ? "page" : undefined}
onClick={() => onMode(item.value)}
className={cn(
"flex w-full items-center gap-2 border-l-2 py-1.5 pl-2 pr-2.5 text-left transition-colors",
rowFocus,
active
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
: "border-transparent text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon className="size-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate text-xs font-medium">
{item.label}
</span>
{item.count !== undefined && (
<span className="shrink-0 text-[10px] tabular-nums opacity-60">
{item.count}
</span>
)}
</button>
);
})}
</nav>
<div className="flex flex-col gap-2 border-b border-border px-2.5 py-2">
<div className="flex items-center gap-2">
<PanelSearch
value={query}