refactor(ui): useDismiss hook for outside-click/Escape close

lib/useDismiss.ts replaces the identical mousedown-outside + Escape effect that
DataTable (filter popover), Channels (tag picker) and AddToPlaylist each hand-rolled.
Positioning stays with each caller (it genuinely varies); AddToPlaylist keeps its
own resize/scroll reposition effect.
This commit is contained in:
npeter83 2026-06-29 00:11:44 +02:00
parent f6b9ac2dd1
commit e68e0d3f7a
4 changed files with 40 additions and 43 deletions

View file

@ -0,0 +1,32 @@
import { useEffect, type RefObject } from "react";
/** While `active`, close (call `onClose`) on Escape or a mousedown outside ALL of the given
* element(s). The reusable half of the popover/dropdown pattern that several components each
* hand-rolled; positioning stays with the caller (it varies per popover). Pass the panel ref
* (and optionally a trigger ref so clicking the trigger doesn't immediately re-close). */
export function useDismiss(
active: boolean,
onClose: () => void,
refs: RefObject<HTMLElement | null> | RefObject<HTMLElement | null>[],
): void {
useEffect(() => {
if (!active) return;
const list = Array.isArray(refs) ? refs : [refs];
const onDown = (e: MouseEvent) => {
const target = e.target as Node;
if (!list.some((r) => r.current?.contains(target))) onClose();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
// Matches the original effects: (re)subscribe only when open/closed toggles. Refs are
// stable and onClose is read fresh from this run's closure.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active]);
}