From e68e0d3f7ad71e640bd7422872405f204438f01d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 29 Jun 2026 00:11:44 +0200 Subject: [PATCH] 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. --- frontend/src/components/AddToPlaylist.tsx | 17 +++--------- frontend/src/components/Channels.tsx | 17 +++--------- frontend/src/components/DataTable.tsx | 17 ++---------- frontend/src/lib/useDismiss.ts | 32 +++++++++++++++++++++++ 4 files changed, 40 insertions(+), 43 deletions(-) create mode 100644 frontend/src/lib/useDismiss.ts diff --git a/frontend/src/components/AddToPlaylist.tsx b/frontend/src/components/AddToPlaylist.tsx index 5260163..a3b174e 100644 --- a/frontend/src/components/AddToPlaylist.tsx +++ b/frontend/src/components/AddToPlaylist.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, ListPlus, Plus, Youtube } from "lucide-react"; import { api, type Playlist } from "../lib/api"; +import { useDismiss } from "../lib/useDismiss"; // A small popover (portaled to body, so the feed grid can't clip it) for toggling a // video's membership in the user's playlists, with inline "new playlist" creation. @@ -59,25 +60,13 @@ export default function AddToPlaylist({ setOpen((o) => !o); } + useDismiss(open, () => setOpen(false), [panelRef, triggerRef]); + // Keep the panel anchored to the trigger as the page resizes/scrolls while open. useEffect(() => { if (!open) return; - function onDoc(e: MouseEvent) { - if ( - !panelRef.current?.contains(e.target as Node) && - !triggerRef.current?.contains(e.target as Node) - ) - setOpen(false); - } - function onKey(e: KeyboardEvent) { - if (e.key === "Escape") setOpen(false); - } - document.addEventListener("mousedown", onDoc); - document.addEventListener("keydown", onKey); window.addEventListener("resize", place); window.addEventListener("scroll", place, true); return () => { - document.removeEventListener("mousedown", onDoc); - document.removeEventListener("keydown", onKey); window.removeEventListener("resize", place); window.removeEventListener("scroll", place, true); }; diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index ddcdfa4..385448d 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -14,6 +14,7 @@ import { UserMinus, } from "lucide-react"; import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api"; +import { useDismiss } from "../lib/useDismiss"; import { formatEta, formatViews, relativeTime } from "../lib/format"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; @@ -616,19 +617,7 @@ function TagsCell({ const { t } = useTranslation(); const [open, setOpen] = useState(false); const ref = useRef(null); - useEffect(() => { - if (!open) return; - const onDown = (e: MouseEvent) => { - if (!ref.current?.contains(e.target as Node)) setOpen(false); - }; - const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false); - document.addEventListener("mousedown", onDown); - document.addEventListener("keydown", onKey); - return () => { - document.removeEventListener("mousedown", onDown); - document.removeEventListener("keydown", onKey); - }; - }, [open]); + useDismiss(open, () => setOpen(false), ref); if (userTags.length === 0) return null; // Show only the tags actually attached to this channel; the “+” opens a per-channel // picker to attach/detach (kept the convenient "kirakás" without listing every tag diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/DataTable.tsx index cad982d..8b9404c 100644 --- a/frontend/src/components/DataTable.tsx +++ b/frontend/src/components/DataTable.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ListFilter, X } from "lucide-react"; +import { useDismiss } from "../lib/useDismiss"; // A reusable, client-side data table: per-column sort + filter (in the header) + pagination, // with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps @@ -121,21 +122,7 @@ export default function DataTable({ setPage(0); }, [resetFiltersToken]); - useEffect(() => { - if (!openFilter) return; - function onDown(e: MouseEvent) { - if (!popRef.current?.contains(e.target as Node)) setOpenFilter(null); - } - function onKey(e: KeyboardEvent) { - if (e.key === "Escape") setOpenFilter(null); - } - document.addEventListener("mousedown", onDown); - document.addEventListener("keydown", onKey); - return () => { - document.removeEventListener("mousedown", onDown); - document.removeEventListener("keydown", onKey); - }; - }, [openFilter]); + useDismiss(!!openFilter, () => setOpenFilter(null), popRef); const filtered = rows.filter((row) => columns.every((col) => { diff --git a/frontend/src/lib/useDismiss.ts b/frontend/src/lib/useDismiss.ts new file mode 100644 index 0000000..58c7fbe --- /dev/null +++ b/frontend/src/lib/useDismiss.ts @@ -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 | RefObject[], +): 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]); +}