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:
parent
f6b9ac2dd1
commit
e68e0d3f7a
4 changed files with 40 additions and 43 deletions
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement | null>(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
|
||||
|
|
|
|||
|
|
@ -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<T>({
|
|||
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) => {
|
||||
|
|
|
|||
32
frontend/src/lib/useDismiss.ts
Normal file
32
frontend/src/lib/useDismiss.ts
Normal 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]);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue