Correctness:
- Escape is now strictly topmost-only for EVERY mode. Hover popovers register in
the shared layerStack too (and floating-ui's own Escape is off for all modes), so
an open tooltip/hover-card no longer either swallows the Escape from a Modal/player
underneath (its document-level stopPropagation vs their window listeners) or
double-closes both. A player's own overlay hover-card now takes Escape before the
player, keeping it alive.
- An invisible (referenceHidden, scrolled-out) surface is no longer a dismissable
layer, so it can't silently eat an Escape aimed at what's actually on screen, nor
make a player defer.
- PopoverSurface splits into a wrapper that returns null before any hook and an inner
that holds them, so a mounted-but-closed popover (≈10 per table row) subscribes no
fullscreen listeners — was ~1800 document listeners on a 100-row table.
- The fullscreen portal no longer remounts on a key: one persistent container is
re-parented body↔fullscreen-element with appendChild (atomic move), so the open
panel's focus/caret/scroll/animation survive a transition and an element SWAP is
handled by the element-identity dependency.
- AddToPlaylist drops `disabled={busy}` on its items (it blurred the focused item to
<body> and permanently killed roving focus); re-entrancy is already guarded in
toggle(), and aria-busy conveys the pending state.
- New `hostsInput` option turns off BOTH typeahead and focus-item-on-hover for the two
menus with an inline text field, so a pointer passing over a list item no longer
steals the field's caret and mis-routes the next Enter. Replaces their `typeahead:false`.
- ChannelTags' single-pass fit loop gains a whole-set fast path, so it stops collapsing
chips into "+N" when they would all have fitted.
- `initialFocus={-1}` is applied only when a `selectedIndex` is set (the sync-focus race
it works around); a plain nav menu now lets the focus manager focus normally, so an
item-less menu (first-run tag editor) still has a keyboard entry point.
a11y:
- `ariaLabel` now wins on menu-role panels — the surface strips useRole's competing
`aria-labelledby` when an explicit label is given (verified: the filter/View menus
are named by their column/label, not the trigger).
- PopoverItem routes caller props THROUGH getItemProps and applies its own
tabIndex/disabled/data-active/className last, so a caller handler or tabIndex can no
longer be silently dropped or clobber the roving model.
Regressions / consistency:
- Tooltip shows/hides on contact again (explicit 0/0 hover delay) instead of inheriting
the platform's 120/140 ms intent delay.
- ChannelTags' editor list regains its useScrollFade overflow mask.
- ChannelTagsControl takes a `bounded` prop; the channel detail page passes false so
tags show inline in the wide header instead of collapsing to "+N".
- ChannelPage's tag-catalog query is gated on subscribed && !demo (matches the only
consumer's render condition); the manager's tag toggle now also invalidates
qk.channel(id) so the detail page's cached tags stay in step.
- DetailCustomizeMenu + PrefToggle revert off the platform to the pre-R8 inline
`absolute` menu: in the player's overlay it renders inside a transform:scale(1.25)
subtree, and portalling out to <body> painted it at 1×. Same reason the player's own
menus stay hand-rolled; this also restores PrefToggle to plain buttons (the rows are
independent toggles, not a radio group). The stale PlayerModal fullscreen comment is
corrected too.
Gate green (tsc/eslint/76 vitest). Self-UAT in-browser: hover-over-item no longer
steals the tag input's caret; text filter keeps caret while narrowing; menu aria-label
is the label not the trigger; selectedIndex lands focus on the checked item; roving +
Escape-close+refocus intact; add-to-playlist items never disabled. NOT verified here
(needs a device / playing media): the fullscreen portal reparent and the player-overlay
paths — owed device UAT, as they already were.
167 lines
6.6 KiB
TypeScript
167 lines
6.6 KiB
TypeScript
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { Check, ListPlus, Plus, Youtube } from "lucide-react";
|
|
import { api, type Playlist } from "../lib/api";
|
|
import { useScrollFade } from "../lib/useScrollFade";
|
|
import { playlistName } from "../lib/playlistName";
|
|
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
|
|
|
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
|
|
// "new playlist" creation. Same shape as the channel tag editor (R8 S1's pilot): radio-style
|
|
// toggles plus a free-text creator, so it follows that file's two conventions — typeahead OFF and
|
|
// the input swallowing non-Escape keys, since character and arrow keys belong to the text field.
|
|
export default function AddToPlaylist({
|
|
videoId,
|
|
className,
|
|
}: {
|
|
videoId: string;
|
|
className?: string;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const fade = useScrollFade();
|
|
const [open, setOpen] = useState(false);
|
|
const [newName, setNewName] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const pop = useAnchoredPopover({
|
|
open,
|
|
onOpenChange: setOpen,
|
|
offset: 6,
|
|
// The menu holds a "new playlist" text field — keep typed chars and pointer-hover out of the
|
|
// roving list so they don't steal the caret.
|
|
hostsInput: true,
|
|
});
|
|
|
|
const membership = useQuery({
|
|
queryKey: qk.playlistMembership(videoId),
|
|
queryFn: () => api.playlists(videoId),
|
|
enabled: open,
|
|
});
|
|
|
|
function refresh() {
|
|
qc.invalidateQueries({ queryKey: qk.playlistMembership(videoId) });
|
|
qc.invalidateQueries({ queryKey: qk.playlists() });
|
|
// Also invalidate every open/cached playlist detail (["playlist", id]) so the
|
|
// Playlists page reflects the add/remove when navigated to, not only after F5.
|
|
qc.invalidateQueries({ queryKey: qk.playlist() });
|
|
}
|
|
|
|
async function toggle(pl: Playlist) {
|
|
if (busy) return;
|
|
setBusy(true);
|
|
try {
|
|
if (pl.has_video) await api.removeFromPlaylist(pl.id, videoId);
|
|
else await api.addToPlaylist(pl.id, videoId);
|
|
refresh();
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function createAndAdd() {
|
|
const name = newName.trim();
|
|
if (!name || busy) return;
|
|
setBusy(true);
|
|
try {
|
|
const pl = await api.createPlaylist(name);
|
|
await api.addToPlaylist(pl.id, videoId);
|
|
setNewName("");
|
|
refresh();
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
// All of the user's playlists are add targets, including YouTube-linked ones — adding
|
|
// marks them dirty, and a later "Sync to YouTube" pushes the change back.
|
|
const lists = membership.data ?? [];
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
ref={pop.refs.setReference}
|
|
{...pop.getReferenceProps({
|
|
// The trigger sits on a feed card that opens the player on click, so the toggle must not
|
|
// reach it.
|
|
onClick: (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
},
|
|
})}
|
|
title={t("playlists.addToPlaylist")}
|
|
aria-label={t("playlists.addToPlaylist")}
|
|
className={className ?? "p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"}
|
|
>
|
|
<ListPlus className="w-4 h-4" />
|
|
</button>
|
|
|
|
<PopoverSurface popover={pop} className="glass-menu w-60 rounded-xl p-2 shadow-2xl">
|
|
{/* A React portal still propagates events up the REACT tree, so a click inside this panel
|
|
would otherwise reach the feed card that hosts the trigger and start playback. */}
|
|
<div onClick={(e) => e.stopPropagation()}>
|
|
<div className="text-xs text-muted px-1.5 pb-1.5">{t("playlists.addToPlaylist")}</div>
|
|
<div
|
|
ref={fade.ref}
|
|
style={fade.style}
|
|
aria-busy={busy}
|
|
className="max-h-56 overflow-y-auto no-scrollbar"
|
|
>
|
|
{lists.length === 0 && !membership.isLoading && (
|
|
<div className="text-xs text-muted px-1.5 py-2">{t("playlists.noneYet")}</div>
|
|
)}
|
|
{lists.map((pl) => (
|
|
<PopoverItem
|
|
key={pl.id}
|
|
// A video can sit in several playlists at once — checkboxes, not one choice.
|
|
role="menuitemcheckbox"
|
|
checked={pl.has_video}
|
|
onSelect={() => toggle(pl)}
|
|
// NOT `disabled={busy}`: the native disabled attribute would blur the focused item to
|
|
// <body> mid-request and permanently kill roving focus (useListNavigation listens on
|
|
// the floating element only). `toggle()` already no-ops while busy, so re-entrancy is
|
|
// guarded without touching focus; `aria-busy` on the list conveys the pending state.
|
|
label={playlistName(pl, t)}
|
|
className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg outline-none transition hover:bg-card/60 data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)]"
|
|
>
|
|
<span
|
|
className={`grid place-items-center w-4 h-4 rounded border ${
|
|
pl.has_video ? "bg-accent border-accent text-accent-fg" : "border-border"
|
|
}`}
|
|
>
|
|
{pl.has_video && <Check className="w-3 h-3" />}
|
|
</span>
|
|
<span className="truncate flex-1 text-left">{playlistName(pl, t)}</span>
|
|
{(pl.source === "youtube" || pl.yt_playlist_id) && (
|
|
<Youtube className="w-3 h-3 shrink-0 text-muted" />
|
|
)}
|
|
</PopoverItem>
|
|
))}
|
|
</div>
|
|
<div className="flex items-center gap-1.5 border-t border-border mt-1.5 pt-1.5">
|
|
<Plus className="w-3.5 h-3.5 text-muted shrink-0" />
|
|
<input
|
|
value={newName}
|
|
onChange={(e) => setNewName(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
// Escape bubbles to the popover's dismiss; every other key is text editing here and
|
|
// must not reach the surrounding menu's list navigation.
|
|
if (e.key === "Escape") return;
|
|
e.stopPropagation();
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
void createAndAdd();
|
|
}
|
|
}}
|
|
placeholder={t("playlists.newPlaylist")}
|
|
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</PopoverSurface>
|
|
</>
|
|
);
|
|
}
|