feat(design): a video's one action list (C1) — the card's "…", the menu holds the rest (E28 S3e1)
- useContextMenu gains buttonProps (a row's visible "…" opens the same menu under the button; a second press closes it) and anchor (the element the menu came from, for a panel a row opens). - ui/MoreButton; ActionMenu rows can run inGesture (a clipboard write inside the click, Safari). - useVideoActions: one builder for a video's menu (play / restart, channel, watched, save, add to playlist, download, details, copy title / link, hide, reset, pin), composed by the feed and by Home's rails; the playlists and details panels open anchored beside the card (VideoFollowUp). - The card keeps watched / save / add to playlist and the "…": on the picture on hover (the duration steps aside), in the rows' action column; on touch only a 44px "…" beside the title. Download, hide, reset and the hover info card leave the card. - The title is selectable: a click ending a selection neither plays nor follows the link. - DESIGN.md: how C1 is built + the log row.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
@@ -23,24 +23,78 @@ export default function AddToPlaylist({
|
||||
size = "sm",
|
||||
}: {
|
||||
videoId: string;
|
||||
variant?: "ghost" | "outline" | "overlay";
|
||||
variant?: "ghost" | "outline" | "overlay" | "media";
|
||||
size?: "sm" | "md";
|
||||
}) {
|
||||
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 = useMembershipPopover(open, setOpen);
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
ref={pop.refs.setReference}
|
||||
variant={variant}
|
||||
size={size}
|
||||
{...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();
|
||||
},
|
||||
})}
|
||||
label={t("playlists.addToPlaylist")}
|
||||
>
|
||||
<ListPlus />
|
||||
</IconButton>
|
||||
<MembershipPanel videoId={videoId} pop={pop} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const pop = useAnchoredPopover({
|
||||
/** The same panel, opened from an object's menu ("Add to playlist…", E28 C1) rather than its own
|
||||
* button: anchored to the element the menu came from, and gone when it closes. */
|
||||
export function AddToPlaylistAt({
|
||||
videoId,
|
||||
anchor,
|
||||
onClose,
|
||||
}: {
|
||||
videoId: string;
|
||||
anchor: Element;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const pop = useMembershipPopover(true, (next) => {
|
||||
if (!next) onClose();
|
||||
});
|
||||
const { setPositionReference } = pop.refs;
|
||||
useLayoutEffect(() => setPositionReference(anchor), [anchor, setPositionReference]);
|
||||
return <MembershipPanel videoId={videoId} pop={pop} />;
|
||||
}
|
||||
|
||||
function useMembershipPopover(open: boolean, onOpenChange: (open: boolean) => void) {
|
||||
return useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
onOpenChange,
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
function MembershipPanel({
|
||||
videoId,
|
||||
pop,
|
||||
}: {
|
||||
videoId: string;
|
||||
pop: ReturnType<typeof useAnchoredPopover>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const fade = useScrollFade();
|
||||
const open = pop.open;
|
||||
const [newName, setNewName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const membership = useQuery({
|
||||
queryKey: qk.playlistMembership(videoId),
|
||||
@@ -95,23 +149,6 @@ export default function AddToPlaylist({
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
ref={pop.refs.setReference}
|
||||
variant={variant}
|
||||
size={size}
|
||||
{...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();
|
||||
},
|
||||
})}
|
||||
label={t("playlists.addToPlaylist")}
|
||||
>
|
||||
<ListPlus />
|
||||
</IconButton>
|
||||
|
||||
<PopoverSurface popover={pop} className={`${SURFACE.popover} w-60 p-2`}>
|
||||
{/* 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. */}
|
||||
|
||||
Reference in New Issue
Block a user