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 { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Check, ListPlus, Plus, Youtube } from "lucide-react";
|
import { Check, ListPlus, Plus, Youtube } from "lucide-react";
|
||||||
import { api, type Playlist } from "../lib/api";
|
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
|
// 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.
|
// video's membership in the user's playlists, with inline "new playlist" creation.
|
||||||
|
|
@ -59,25 +60,13 @@ export default function AddToPlaylist({
|
||||||
setOpen((o) => !o);
|
setOpen((o) => !o);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useDismiss(open, () => setOpen(false), [panelRef, triggerRef]);
|
||||||
|
// Keep the panel anchored to the trigger as the page resizes/scrolls while open.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
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("resize", place);
|
||||||
window.addEventListener("scroll", place, true);
|
window.addEventListener("scroll", place, true);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("mousedown", onDoc);
|
|
||||||
document.removeEventListener("keydown", onKey);
|
|
||||||
window.removeEventListener("resize", place);
|
window.removeEventListener("resize", place);
|
||||||
window.removeEventListener("scroll", place, true);
|
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 { Trans, useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
UserMinus,
|
UserMinus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api";
|
import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api";
|
||||||
|
import { useDismiss } from "../lib/useDismiss";
|
||||||
import { formatEta, formatViews, relativeTime } from "../lib/format";
|
import { formatEta, formatViews, relativeTime } from "../lib/format";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
|
|
@ -616,19 +617,7 @@ function TagsCell({
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
useEffect(() => {
|
useDismiss(open, () => setOpen(false), ref);
|
||||||
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]);
|
|
||||||
if (userTags.length === 0) return null;
|
if (userTags.length === 0) return null;
|
||||||
// Show only the tags actually attached to this channel; the “+” opens a per-channel
|
// 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
|
// 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 { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ListFilter, X } from "lucide-react";
|
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,
|
// 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
|
// 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);
|
setPage(0);
|
||||||
}, [resetFiltersToken]);
|
}, [resetFiltersToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useDismiss(!!openFilter, () => setOpenFilter(null), popRef);
|
||||||
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]);
|
|
||||||
|
|
||||||
const filtered = rows.filter((row) =>
|
const filtered = rows.filter((row) =>
|
||||||
columns.every((col) => {
|
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