refactor(sidebar): H2 — extract 5 hooks + 5 sub-components (#664)

* refactor(sidebar): H2.1 — extract helpers + constants

* refactor(sidebar): H2.2 — extract useSidebarNewReleasesUnread hook

* refactor(sidebar): H2.3 — extract useSidebarNavDnd hook

* refactor(sidebar): H2.4 — extract 3 hooks (LibraryDropdown + ScrollVisible + PerfProbe)

* refactor(sidebar): H2.5 — extract SidebarPerfProbeModal component

* refactor(sidebar): H2.5–H2.8 — split JSX into per-block components

Sidebar.tsx 938 → 271 LOC. All resulting files under the 400-LOC guideline:
  components/sidebar/SidebarLibraryPicker.tsx       96
  components/sidebar/SidebarActiveJobs.tsx          58
  components/sidebar/SidebarNavBody.tsx            293
  components/sidebar/SidebarPerfProbeModal.tsx     259
  components/sidebar/SidebarPerfProbePhase2.tsx    176
This commit is contained in:
Frank Stellmacher
2026-05-13 21:42:18 +02:00
committed by GitHub
parent ef5eda263d
commit b8a9fe860e
12 changed files with 1673 additions and 1298 deletions
+65
View File
@@ -0,0 +1,65 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
interface DropdownRect {
top: number;
left: number;
width: number;
}
interface Result {
libraryDropdownOpen: boolean;
setLibraryDropdownOpen: (open: boolean) => void;
dropdownRect: DropdownRect;
libraryTriggerRef: React.RefObject<HTMLButtonElement | null>;
}
export function useSidebarLibraryDropdown(): Result {
const [libraryDropdownOpen, setLibraryDropdownOpen] = useState(false);
const [dropdownRect, setDropdownRect] = useState<DropdownRect>({ top: 0, left: 0, width: 0 });
const libraryTriggerRef = useRef<HTMLButtonElement>(null);
const updateDropdownPosition = useCallback(() => {
const el = libraryTriggerRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
setDropdownRect({
top: r.bottom + 4,
left: r.left,
width: r.width,
});
}, []);
useLayoutEffect(() => {
if (!libraryDropdownOpen) return;
updateDropdownPosition();
const onWin = () => updateDropdownPosition();
window.addEventListener('resize', onWin);
window.addEventListener('scroll', onWin, true);
return () => {
window.removeEventListener('resize', onWin);
window.removeEventListener('scroll', onWin, true);
};
}, [libraryDropdownOpen, updateDropdownPosition]);
useEffect(() => {
if (!libraryDropdownOpen) return;
const onDown = (e: MouseEvent) => {
const t = e.target as Node;
if (libraryTriggerRef.current?.contains(t)) return;
const panel = document.querySelector('.nav-library-dropdown-panel');
if (panel?.contains(t)) return;
setLibraryDropdownOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setLibraryDropdownOpen(false);
};
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [libraryDropdownOpen]);
return { libraryDropdownOpen, setLibraryDropdownOpen, dropdownRect, libraryTriggerRef };
}
+263
View File
@@ -0,0 +1,263 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type { SidebarItemConfig } from '../store/sidebarStore';
import {
applySidebarDropReorder,
getLibraryItemsForReorder,
getSystemItemsForReorder,
isSidebarNavItemUserHideable,
type SidebarNavDropTarget,
} from '../utils/sidebarNavReorder';
import {
SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX,
SIDEBAR_NAV_LONG_PRESS_MS,
isPointerOutsideAsideSidebar,
} from '../utils/sidebarHelpers';
interface NavDndState {
section: 'library' | 'system';
fromIdx: number;
}
interface Args {
isCollapsed: boolean;
sidebarItemsRef: React.MutableRefObject<SidebarItemConfig[]>;
randomNavModeRef: React.MutableRefObject<'hub' | 'separate'>;
setSidebarItems: (items: SidebarItemConfig[]) => void;
}
interface Result {
navDnd: NavDndState | null;
navDropTarget: SidebarNavDropTarget | null;
navDndTrashHint: { x: number; y: number } | null;
suppressNavClickRef: React.MutableRefObject<boolean>;
handleNavRowPointerDown: (e: React.PointerEvent, section: 'library' | 'system', sectionIdx: number) => void;
navDndRowClass: (section: 'library' | 'system', sectionIdx: number) => string;
}
export function useSidebarNavDnd({
isCollapsed, sidebarItemsRef, randomNavModeRef, setSidebarItems,
}: Args): Result {
const [navDnd, setNavDnd] = useState<NavDndState | null>(null);
const [navDropTarget, setNavDropTarget] = useState<SidebarNavDropTarget | null>(null);
const navDropTargetRef = useRef<SidebarNavDropTarget | null>(null);
navDropTargetRef.current = navDropTarget;
/** DOM timers are numeric; avoid NodeJS `Timeout` typing from `setTimeout`. */
const longPressTimersRef = useRef<Map<number, number>>(new Map());
const suppressNavClickRef = useRef(false);
const lastPointerDuringNavDndRef = useRef({ x: 0, y: 0 });
const [navDndTrashHint, setNavDndTrashHint] = useState<{ x: number; y: number } | null>(null);
useEffect(() => () => {
longPressTimersRef.current.forEach(t => window.clearTimeout(t));
longPressTimersRef.current.clear();
}, []);
useEffect(() => {
if (!navDnd) return;
const updateDropFromPoint = (clientX: number, clientY: number) => {
if (isPointerOutsideAsideSidebar(clientX, clientY)) {
navDropTargetRef.current = null;
setNavDropTarget(null);
return;
}
const rows = document.querySelectorAll<HTMLElement>('.sidebar [data-sidebar-nav-dnd-row]');
let target: SidebarNavDropTarget | null = null;
for (const row of rows) {
const section = row.dataset.sidebarSection as 'library' | 'system' | undefined;
if (section !== navDnd.section) continue;
const rect = row.getBoundingClientRect();
const idx = Number(row.dataset.sidebarIdx);
if (Number.isNaN(idx)) continue;
if (clientY < rect.top + rect.height / 2) {
target = { idx, before: true, section };
break;
}
target = { idx, before: false, section };
}
navDropTargetRef.current = target;
setNavDropTarget(target);
};
const endDrag = (apply: boolean) => {
window.removeEventListener('pointermove', onMove, { capture: true });
window.removeEventListener('pointerup', onUp, true);
window.removeEventListener('pointercancel', onUp, true);
window.removeEventListener('keydown', onKey, true);
document.body.style.userSelect = '';
setNavDndTrashHint(null);
const currentDnd = navDnd;
const drop = navDropTargetRef.current;
setNavDnd(null);
setNavDropTarget(null);
navDropTargetRef.current = null;
if (!apply || !currentDnd) return;
const { x, y } = lastPointerDuringNavDndRef.current;
if (isPointerOutsideAsideSidebar(x, y)) {
const sectionItems =
currentDnd.section === 'library'
? getLibraryItemsForReorder(sidebarItemsRef.current, randomNavModeRef.current)
: getSystemItemsForReorder(sidebarItemsRef.current);
const id = sectionItems[currentDnd.fromIdx]?.id;
if (id && isSidebarNavItemUserHideable(id)) {
const nextItems: SidebarItemConfig[] = sidebarItemsRef.current.map(i =>
i.id === id ? { ...i, visible: false } : i,
);
setSidebarItems(nextItems);
suppressNavClickRef.current = true;
}
return;
}
const next = applySidebarDropReorder(
sidebarItemsRef.current,
currentDnd.section,
currentDnd.fromIdx,
drop,
randomNavModeRef.current,
);
if (next) {
setSidebarItems(next);
suppressNavClickRef.current = true;
}
};
const onMove = (e: PointerEvent) => {
e.preventDefault();
lastPointerDuringNavDndRef.current = { x: e.clientX, y: e.clientY };
const outside = isPointerOutsideAsideSidebar(e.clientX, e.clientY);
const sectionItems =
navDnd.section === 'library'
? getLibraryItemsForReorder(sidebarItemsRef.current, randomNavModeRef.current)
: getSystemItemsForReorder(sidebarItemsRef.current);
const draggedId = sectionItems[navDnd.fromIdx]?.id;
const canTrash = Boolean(draggedId && isSidebarNavItemUserHideable(draggedId));
if (outside && canTrash) {
setNavDndTrashHint({ x: e.clientX, y: e.clientY });
} else {
setNavDndTrashHint(null);
}
updateDropFromPoint(e.clientX, e.clientY);
};
const onUp = (e: PointerEvent) => {
lastPointerDuringNavDndRef.current = { x: e.clientX, y: e.clientY };
suppressNavClickRef.current = true;
endDrag(true);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
endDrag(false);
}
};
document.body.style.userSelect = 'none';
window.addEventListener('pointermove', onMove, { capture: true, passive: false });
window.addEventListener('pointerup', onUp, true);
window.addEventListener('pointercancel', onUp, true);
window.addEventListener('keydown', onKey, true);
return () => {
window.removeEventListener('pointermove', onMove, { capture: true });
window.removeEventListener('pointerup', onUp, true);
window.removeEventListener('pointercancel', onUp, true);
window.removeEventListener('keydown', onKey, true);
document.body.style.userSelect = '';
setNavDndTrashHint(null);
};
}, [navDnd, setSidebarItems, sidebarItemsRef, randomNavModeRef]);
const handleNavRowPointerDown = useCallback(
(e: React.PointerEvent, section: 'library' | 'system', sectionIdx: number) => {
if (isCollapsed || navDnd) return;
if (e.pointerType === 'mouse' && e.button !== 0) return;
const pid = e.pointerId;
const sx = e.clientX;
const sy = e.clientY;
let cleaned = false;
const cleanupEarly = () => {
if (cleaned) return;
cleaned = true;
document.removeEventListener('pointermove', onEarlyMove);
document.removeEventListener('pointerup', onEarlyUp, true);
document.removeEventListener('pointercancel', onEarlyUp, true);
};
const onEarlyMove = (ev: PointerEvent) => {
if (ev.pointerId !== pid) return;
if (Math.hypot(ev.clientX - sx, ev.clientY - sy) > SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX) {
const t = longPressTimersRef.current.get(pid);
if (t != null) window.clearTimeout(t);
longPressTimersRef.current.delete(pid);
cleanupEarly();
}
};
const onEarlyUp = (ev: PointerEvent) => {
if (ev.pointerId !== pid) return;
const t = longPressTimersRef.current.get(pid);
if (t != null) window.clearTimeout(t);
longPressTimersRef.current.delete(pid);
cleanupEarly();
};
const timer = window.setTimeout(() => {
longPressTimersRef.current.delete(pid);
cleanupEarly();
window.getSelection()?.removeAllRanges();
lastPointerDuringNavDndRef.current = { x: sx, y: sy };
setNavDnd({ section, fromIdx: sectionIdx });
navDropTargetRef.current = { idx: sectionIdx, before: true, section };
setNavDropTarget({ idx: sectionIdx, before: true, section });
try {
(e.currentTarget as HTMLElement).setPointerCapture(pid);
} catch {
/* ignore */
}
}, SIDEBAR_NAV_LONG_PRESS_MS) as unknown as number;
longPressTimersRef.current.set(pid, timer);
document.addEventListener('pointermove', onEarlyMove);
document.addEventListener('pointerup', onEarlyUp, true);
document.addEventListener('pointercancel', onEarlyUp, true);
},
[isCollapsed, navDnd],
);
const navDndRowClass = useCallback(
(section: 'library' | 'system', sectionIdx: number) => {
const dragging = navDnd?.section === section && navDnd.fromIdx === sectionIdx;
let drop = '';
if (
navDnd &&
navDropTarget?.section === section &&
navDropTarget.idx === sectionIdx &&
!(navDnd.section === section && navDnd.fromIdx === sectionIdx)
) {
drop = navDropTarget.before
? 'sidebar-nav-dnd-row--drop-before'
: 'sidebar-nav-dnd-row--drop-after';
}
return `sidebar-nav-dnd-row${dragging ? ' sidebar-nav-dnd-row--dragging' : ''}${drop ? ` ${drop}` : ''}`.trim();
},
[navDnd, navDropTarget],
);
return {
navDnd,
navDropTarget,
navDndTrashHint,
suppressNavClickRef,
handleNavRowPointerDown,
navDndRowClass,
};
}
+171
View File
@@ -0,0 +1,171 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getAlbumList } from '../api/subsonicLibrary';
import {
NEW_RELEASES_RESET_DELAY_MS,
NEW_RELEASES_SEEN_MAX_IDS,
NEW_RELEASES_UNREAD_POLL_MS,
NEW_RELEASES_UNREAD_SAMPLE_SIZE,
NEW_RELEASES_UNREAD_STORAGE_PREFIX,
mergeSeenNewReleaseIdsCap,
} from '../utils/sidebarHelpers';
interface Args {
serverId: string;
filterId: string;
isLoggedIn: boolean;
pathname: string;
}
export function useSidebarNewReleasesUnread({ serverId, filterId, isLoggedIn, pathname }: Args): number {
const [newReleasesUnreadCount, setNewReleasesUnreadCount] = useState(0);
const newReleasesRefreshSeqRef = useRef(0);
const newReleasesPageEnteredAtRef = useRef<number | null>(null);
const newReleasesResetTimerRef = useRef<number | null>(null);
const newReleasesSeenStorageKey = useMemo(
() => `${NEW_RELEASES_UNREAD_STORAGE_PREFIX}:${serverId || 'no-server'}:${filterId || 'all'}`,
[serverId, filterId],
);
const newReleasesSeenAllScopeStorageKey = useMemo(
() => `${NEW_RELEASES_UNREAD_STORAGE_PREFIX}:${serverId || 'no-server'}:all`,
[serverId],
);
const readSeenNewReleaseIdsByKey = useCallback((key: string): string[] => {
try {
const raw = localStorage.getItem(key);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((id): id is string => typeof id === 'string' && id.length > 0);
} catch {
return [];
}
}, []);
const readSeenNewReleaseIds = useCallback(
() => readSeenNewReleaseIdsByKey(newReleasesSeenStorageKey),
[newReleasesSeenStorageKey, readSeenNewReleaseIdsByKey],
);
const writeSeenNewReleaseIdsByKey = useCallback((key: string, ids: string[]) => {
const normalized = Array.from(new Set(ids.filter(Boolean))).slice(0, NEW_RELEASES_SEEN_MAX_IDS);
localStorage.setItem(key, JSON.stringify(normalized));
}, []);
const writeSeenNewReleaseIds = useCallback(
(ids: string[]) => writeSeenNewReleaseIdsByKey(newReleasesSeenStorageKey, ids),
[newReleasesSeenStorageKey, writeSeenNewReleaseIdsByKey],
);
const refreshNewReleasesUnread = useCallback(async (markAsSeen = false) => {
const seq = ++newReleasesRefreshSeqRef.current;
const isCurrent = () => seq === newReleasesRefreshSeqRef.current;
if (!isLoggedIn || !serverId) {
if (isCurrent()) setNewReleasesUnreadCount(0);
return;
}
try {
const newest = await getAlbumList('newest', NEW_RELEASES_UNREAD_SAMPLE_SIZE, 0);
const newestIds = newest.map(a => a.id).filter(Boolean);
let seenIds = readSeenNewReleaseIds();
// For a concrete library scope, bootstrap from the server-wide "all libraries"
// baseline when available, so switching scope doesn't hide existing unread.
if (seenIds.length === 0 && filterId !== 'all') {
const allScopeSeen = readSeenNewReleaseIdsByKey(newReleasesSeenAllScopeStorageKey);
if (allScopeSeen.length > 0) {
seenIds = allScopeSeen;
writeSeenNewReleaseIdsByKey(newReleasesSeenStorageKey, allScopeSeen);
}
}
if (seenIds.length === 0) {
// First bootstrap for this server/scope: baseline is "already seen".
writeSeenNewReleaseIds(newestIds);
if (isCurrent()) setNewReleasesUnreadCount(0);
return;
}
if (markAsSeen) {
// Prepend the live newest sample so a full `seenIds` list + slice(500)
// cannot silently discard freshly "read" albums (fixes badge coming back).
writeSeenNewReleaseIds(mergeSeenNewReleaseIdsCap(seenIds, newestIds, NEW_RELEASES_SEEN_MAX_IDS));
// Keep server-wide baseline in sync so scope fallback never resurrects
// already-viewed items after opening the New Releases page.
const allScopeSeen = readSeenNewReleaseIdsByKey(newReleasesSeenAllScopeStorageKey);
writeSeenNewReleaseIdsByKey(
newReleasesSeenAllScopeStorageKey,
mergeSeenNewReleaseIdsCap(allScopeSeen, newestIds, NEW_RELEASES_SEEN_MAX_IDS),
);
if (isCurrent()) setNewReleasesUnreadCount(0);
return;
}
const seenSet = new Set(seenIds);
const unread = newestIds.reduce((count, id) => count + (seenSet.has(id) ? 0 : 1), 0);
if (isCurrent()) setNewReleasesUnreadCount(unread);
} catch {
// Keep previous value on transient network/API errors.
}
}, [
filterId,
isLoggedIn,
newReleasesSeenAllScopeStorageKey,
newReleasesSeenStorageKey,
readSeenNewReleaseIds,
readSeenNewReleaseIdsByKey,
serverId,
writeSeenNewReleaseIds,
writeSeenNewReleaseIdsByKey,
]);
useEffect(() => {
const onNewReleasesPage = pathname.startsWith('/new-releases');
if (newReleasesResetTimerRef.current != null) {
window.clearTimeout(newReleasesResetTimerRef.current);
newReleasesResetTimerRef.current = null;
}
if (onNewReleasesPage) {
if (newReleasesPageEnteredAtRef.current == null) {
newReleasesPageEnteredAtRef.current = Date.now();
}
const elapsed = Date.now() - newReleasesPageEnteredAtRef.current;
const shouldMarkAsSeen = elapsed >= NEW_RELEASES_RESET_DELAY_MS;
void refreshNewReleasesUnread(shouldMarkAsSeen);
if (!shouldMarkAsSeen) {
const remaining = NEW_RELEASES_RESET_DELAY_MS - elapsed;
newReleasesResetTimerRef.current = window.setTimeout(() => {
newReleasesResetTimerRef.current = null;
void refreshNewReleasesUnread(true);
}, remaining);
}
} else {
newReleasesPageEnteredAtRef.current = null;
void refreshNewReleasesUnread(false);
}
const timer = window.setInterval(() => {
const activeOnNewReleases = pathname.startsWith('/new-releases');
const enteredAt = newReleasesPageEnteredAtRef.current;
const delayedSeenReached =
activeOnNewReleases &&
enteredAt != null &&
Date.now() - enteredAt >= NEW_RELEASES_RESET_DELAY_MS;
void refreshNewReleasesUnread(delayedSeenReached);
}, NEW_RELEASES_UNREAD_POLL_MS);
return () => {
window.clearInterval(timer);
if (newReleasesResetTimerRef.current != null) {
window.clearTimeout(newReleasesResetTimerRef.current);
newReleasesResetTimerRef.current = null;
}
};
}, [pathname, refreshNewReleasesUnread]);
return newReleasesUnreadCount;
}
+140
View File
@@ -0,0 +1,140 @@
import { useEffect, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { setPerfProbeTelemetryActive } from '../utils/perfTelemetry';
interface PerfCpu {
app: number;
webkit: number;
supported: boolean;
}
interface PerfDiagRates {
progress: number;
waveform: number;
home: number;
}
interface Result {
perfProbeOpen: boolean;
setPerfProbeOpen: (open: boolean) => void;
perfCpu: PerfCpu | null;
perfDiagRates: PerfDiagRates | null;
}
/** Wires up Ctrl+Shift+D to open the perf probe; polls CPU + diag-rate counters
* every 2s while it is open. */
export function useSidebarPerfProbe(): Result {
const [perfProbeOpen, setPerfProbeOpen] = useState(false);
const [perfCpu, setPerfCpu] = useState<PerfCpu | null>(null);
const [perfDiagRates, setPerfDiagRates] = useState<PerfDiagRates | null>(null);
useEffect(() => {
setPerfProbeTelemetryActive(perfProbeOpen);
return () => setPerfProbeTelemetryActive(false);
}, [perfProbeOpen]);
useEffect(() => {
if (!perfProbeOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setPerfProbeOpen(false);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [perfProbeOpen]);
useEffect(() => {
if (!perfProbeOpen) return;
type Snapshot = {
supported: boolean;
total_jiffies: number;
app_jiffies: number;
webkit_jiffies: number;
logical_cpus: number;
};
let cancelled = false;
let prev: Snapshot | null = null;
let prevCounters: { progress: number; waveform: number; home: number } | null = null;
let prevCountersAt = 0;
let timer: number | null = null;
const poll = async () => {
try {
const snap = await invoke<Snapshot>('performance_cpu_snapshot');
if (cancelled) return;
if (!snap.supported) {
setPerfCpu({ app: 0, webkit: 0, supported: false });
return;
}
if (prev) {
const totalDelta = snap.total_jiffies - prev.total_jiffies;
const appDelta = snap.app_jiffies - prev.app_jiffies;
const webkitDelta = snap.webkit_jiffies - prev.webkit_jiffies;
if (totalDelta > 0) {
const cpuScale = Math.max(1, snap.logical_cpus || 1) * 100;
const appPct = Math.max(0, Math.min(1000, (appDelta / totalDelta) * cpuScale));
const webkitPct = Math.max(0, Math.min(1000, (webkitDelta / totalDelta) * cpuScale));
setPerfCpu({
app: Number.isFinite(appPct) ? appPct : 0,
webkit: Number.isFinite(webkitPct) ? webkitPct : 0,
supported: true,
});
}
}
const now = Date.now();
const root = globalThis as unknown as { __psyPerfCounters?: Record<string, number> };
const counters = root.__psyPerfCounters ?? {};
const nextCounters = {
progress: counters.audioProgressEvents ?? 0,
waveform: counters.waveformDraws ?? 0,
home: counters.homeCommits ?? 0,
};
if (prevCounters && prevCountersAt > 0) {
const dt = Math.max(0.25, (now - prevCountersAt) / 1000);
setPerfDiagRates({
progress: (nextCounters.progress - prevCounters.progress) / dt,
waveform: (nextCounters.waveform - prevCounters.waveform) / dt,
home: (nextCounters.home - prevCounters.home) / dt,
});
}
prevCounters = nextCounters;
prevCountersAt = now;
prev = snap;
} catch {
if (!cancelled) setPerfCpu({ app: 0, webkit: 0, supported: false });
} finally {
if (!cancelled) timer = window.setTimeout(poll, 2000);
}
};
void poll();
return () => {
cancelled = true;
if (timer != null) window.clearTimeout(timer);
};
}, [perfProbeOpen]);
useEffect(() => {
if (!perfProbeOpen) {
setPerfCpu(null);
setPerfDiagRates(null);
}
}, [perfProbeOpen]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!(e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey)) return;
if (e.key.toLowerCase() !== 'd') return;
const target = e.target as HTMLElement | null;
if (target && (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable
)) return;
e.preventDefault();
setPerfProbeOpen(true);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
return { perfProbeOpen, setPerfProbeOpen, perfCpu, perfDiagRates };
}
+28
View File
@@ -0,0 +1,28 @@
import { useEffect, useState } from 'react';
/** Adds .is-scrolling-on-scroll cue to the sidebar viewport for 180ms. */
export function useSidebarScrollVisible(sidebarViewportEl: HTMLDivElement | null): boolean {
const [isSidebarScrolling, setIsSidebarScrolling] = useState(false);
useEffect(() => {
if (!sidebarViewportEl) return;
let hideTimer: number | null = null;
const onScroll = () => {
setIsSidebarScrolling(true);
if (hideTimer != null) window.clearTimeout(hideTimer);
hideTimer = window.setTimeout(() => {
setIsSidebarScrolling(false);
hideTimer = null;
}, 180);
};
sidebarViewportEl.addEventListener('scroll', onScroll, { passive: true });
return () => {
sidebarViewportEl.removeEventListener('scroll', onScroll);
if (hideTimer != null) window.clearTimeout(hideTimer);
};
}, [sidebarViewportEl]);
return isSidebarScrolling;
}