fix: improve settings search highlight
This commit is contained in:
@@ -442,37 +442,54 @@ export default function SettingsPage() {
|
|||||||
}, [isDesktop, mobileShowContent]);
|
}, [isDesktop, mobileShowContent]);
|
||||||
|
|
||||||
// After clicking a search sub-result, scroll the matching setting into view
|
// After clicking a search sub-result, scroll the matching setting into view
|
||||||
// and add a temporary highlight class. Two RAFs to wait for the tab content
|
// and add a temporary highlight class. Some tabs fetch data and render
|
||||||
// to mount and lay out before querying the DOM.
|
// their SettingItems only after a loading state, so retry until the element
|
||||||
|
// shows up (or we give up after ~2s).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingHighlight) return;
|
if (!pendingHighlight) return;
|
||||||
if (pendingHighlight.tab !== activeTab) return;
|
if (pendingHighlight.tab !== activeTab) return;
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
let cleanupTimer: ReturnType<typeof setTimeout> | undefined;
|
let cleanupTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
let highlightedEl: HTMLElement | null = null;
|
let highlightedEl: HTMLElement | null = null;
|
||||||
|
|
||||||
const r1 = window.requestAnimationFrame(() => {
|
const escaped = pendingHighlight.label.replace(/"/g, '\\"');
|
||||||
window.requestAnimationFrame(() => {
|
const selector = `[data-search-label="${escaped}"]`;
|
||||||
if (cancelled) return;
|
const deadline = Date.now() + 2000;
|
||||||
const escaped = pendingHighlight.label.replace(/"/g, '\\"');
|
|
||||||
const el = document.querySelector<HTMLElement>(`[data-search-label="${escaped}"]`);
|
|
||||||
if (el) {
|
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
el.classList.add('settings-search-highlight');
|
|
||||||
highlightedEl = el;
|
|
||||||
cleanupTimer = setTimeout(() => {
|
|
||||||
el.classList.remove('settings-search-highlight');
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
setPendingHighlight(null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const tryHighlight = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const el = document.querySelector<HTMLElement>(selector);
|
||||||
|
if (!el) {
|
||||||
|
if (Date.now() < deadline) {
|
||||||
|
retryTimer = setTimeout(tryHighlight, 80);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
// Remove + reflow + add restarts the CSS animation if the class was
|
||||||
|
// already present (re-clicking the same sub-result).
|
||||||
|
el.classList.remove('settings-search-highlight');
|
||||||
|
void el.offsetWidth;
|
||||||
|
el.classList.add('settings-search-highlight');
|
||||||
|
highlightedEl = el;
|
||||||
|
cleanupTimer = setTimeout(() => {
|
||||||
|
el.classList.remove('settings-search-highlight');
|
||||||
|
highlightedEl = null;
|
||||||
|
}, 1800);
|
||||||
|
};
|
||||||
|
|
||||||
|
// First attempt next frame so the freshly-mounted tab content is in DOM.
|
||||||
|
const raf = window.requestAnimationFrame(tryHighlight);
|
||||||
|
|
||||||
|
// Do NOT reset pendingHighlight here — that would retrigger this effect
|
||||||
|
// and the cleanup below would strip the class right after we added it.
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.cancelAnimationFrame(r1);
|
window.cancelAnimationFrame(raf);
|
||||||
|
if (retryTimer) clearTimeout(retryTimer);
|
||||||
if (cleanupTimer) clearTimeout(cleanupTimer);
|
if (cleanupTimer) clearTimeout(cleanupTimer);
|
||||||
if (highlightedEl) highlightedEl.classList.remove('settings-search-highlight');
|
if (highlightedEl) highlightedEl.classList.remove('settings-search-highlight');
|
||||||
};
|
};
|
||||||
|
|||||||
+26
-4
@@ -766,9 +766,31 @@ body {
|
|||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Brief flash applied to a setting row when the user clicks a sub-result in
|
/* Smooth halo applied to a setting row when the user clicks a sub-result in
|
||||||
the settings search. The page removes the class after ~2s. */
|
the settings search. The element bg + a 10px box-shadow fill the row plus
|
||||||
|
a 10px ring of the same tint, then a 2px outline sits exactly at the outer
|
||||||
|
edge. Uses the primary theme color so it adapts to light/dark. */
|
||||||
|
@keyframes settings-search-pulse {
|
||||||
|
0% {
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 0%, transparent);
|
||||||
|
box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-primary) 0%, transparent);
|
||||||
|
outline-color: color-mix(in srgb, var(--color-primary) 0%, transparent);
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||||
|
box-shadow: 0 0 0 10px color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||||
|
outline-color: color-mix(in srgb, var(--color-primary) 60%, transparent);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 0%, transparent);
|
||||||
|
box-shadow: 0 0 0 10px color-mix(in srgb, var(--color-primary) 0%, transparent);
|
||||||
|
outline-color: color-mix(in srgb, var(--color-primary) 0%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.settings-search-highlight {
|
.settings-search-highlight {
|
||||||
background-color: color-mix(in srgb, var(--color-primary) 18%, transparent);
|
outline: 2px solid transparent;
|
||||||
box-shadow: 0 0 0 2px var(--color-primary);
|
outline-offset: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
animation: settings-search-pulse 1.6s ease-in-out forwards;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ interface SettingsSectionProps {
|
|||||||
|
|
||||||
export function SettingsSection({ title, description, children, experimental, experimentalDescription }: SettingsSectionProps) {
|
export function SettingsSection({ title, description, children, experimental, experimentalDescription }: SettingsSectionProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div data-search-label={title} className="space-y-4">
|
||||||
{experimental && (
|
{experimental && (
|
||||||
<div className="flex gap-3 rounded-lg border border-amber-300 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-950/30">
|
<div className="flex gap-3 rounded-lg border border-amber-300 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-950/30">
|
||||||
<FlaskConical className="w-5 h-5 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
|
<FlaskConical className="w-5 h-5 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
|
||||||
@@ -46,7 +46,7 @@ export function SettingItem({ label, description, children, locked }: SettingIte
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-search-label={label}
|
data-search-label={label}
|
||||||
className={cn("flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 py-3 border-b border-border last:border-0 transition-colors duration-300 rounded-md -mx-2 px-2", locked && "opacity-60")}
|
className={cn("flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 py-3 border-b border-border last:border-0", locked && "opacity-60")}
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0 sm:pr-4">
|
<div className="flex-1 min-w-0 sm:pr-4">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
|||||||
Reference in New Issue
Block a user