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:
@@ -61,8 +61,15 @@ export interface ContextMenuController<T> {
|
||||
target: T | null;
|
||||
open: boolean;
|
||||
close: () => void;
|
||||
/** The element the open menu came from — the row, or its "…" button. A follow-up an item opens
|
||||
* (a panel anchored beside the object) anchors here. `null` while closed. */
|
||||
anchor: Element | null;
|
||||
/** Spread on the element that should answer a right-click — the whole row, not a handle. */
|
||||
triggerProps: (payload: T) => Record<string, unknown>;
|
||||
/** Spread on the row's visible "…" button (E28 C1: nothing is reachable only by a gesture). A
|
||||
* click opens the SAME menu under the button; a second click on it while its menu is open closes
|
||||
* it, like any menu button. Pair it with `aria-expanded` from the caller, which knows its row. */
|
||||
buttonProps: (payload: T) => Record<string, unknown>;
|
||||
/** Consumed by `<ContextMenu>`; not part of the call site's vocabulary. */
|
||||
popover: ReturnType<typeof useAnchoredPopover>;
|
||||
}
|
||||
@@ -71,13 +78,27 @@ export function useContextMenu<T>(): ContextMenuController<T> {
|
||||
// Register the scope HERE, so the cheat sheet lists Shift+F10 exactly on the surfaces that answer it.
|
||||
useKeyScope("contextMenu");
|
||||
const [target, setTarget] = useState<T | null>(null);
|
||||
const [anchor, setAnchor] = useState<Element | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
// What is open right now, for the "…" button's toggle. Read in a handler only, so a ref: state
|
||||
// would change `buttonProps` on every open and re-render every memoized row that holds it.
|
||||
const openFor = useRef<{ payload: T } | null>(null);
|
||||
// The press that dismissed the menu, and whose menu it was: floating-ui's outside-press runs in
|
||||
// the CAPTURE phase, so a press on the open row's own "…" closes the menu before the button's
|
||||
// handler sees it (see `buttonProps`).
|
||||
const dismissedBy = useRef<{ event: Event; payload: T } | null>(null);
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: (next) => {
|
||||
onOpenChange: (next, event) => {
|
||||
setOpen(next);
|
||||
// Drop the target on close so a stale row can't drive a re-open's first paint.
|
||||
if (!next) setTarget(null);
|
||||
if (!next) {
|
||||
dismissedBy.current =
|
||||
event && openFor.current ? { event, payload: openFor.current.payload } : null;
|
||||
setTarget(null);
|
||||
setAnchor(null);
|
||||
openFor.current = null;
|
||||
}
|
||||
},
|
||||
// A native context menu drops down-and-right from the pointer; flip()/shift() take it the other
|
||||
// way near an edge.
|
||||
@@ -94,6 +115,8 @@ export function useContextMenu<T>(): ContextMenuController<T> {
|
||||
(x: number, y: number, payload: T, contextElement?: Element) => {
|
||||
setPositionReference({ getBoundingClientRect: () => pointRect(x, y), contextElement });
|
||||
setTarget(payload);
|
||||
setAnchor(contextElement ?? null);
|
||||
openFor.current = { payload };
|
||||
setOpen(true);
|
||||
},
|
||||
[setPositionReference],
|
||||
@@ -181,7 +204,48 @@ export function useContextMenu<T>(): ContextMenuController<T> {
|
||||
[openAt, cancelPress],
|
||||
);
|
||||
|
||||
return { target, open, close: () => setOpen(false), triggerProps, popover: pop };
|
||||
const close = useCallback(() => {
|
||||
setOpen(false);
|
||||
setTarget(null);
|
||||
setAnchor(null);
|
||||
openFor.current = null;
|
||||
}, []);
|
||||
|
||||
// Set on the "…" button's pointerdown when ITS menu was the open one. The press lands outside the
|
||||
// panel, so floating-ui's dismiss may already have closed it; either way the click that follows
|
||||
// closes rather than opens, or the button could never close its own menu.
|
||||
const pressedWhileOpen = useRef(false);
|
||||
const buttonProps = useCallback(
|
||||
(payload: T) => ({
|
||||
"aria-haspopup": "menu",
|
||||
onPointerDown: (e: ReactPointerEvent<HTMLElement>) => {
|
||||
const dismissed = dismissedBy.current;
|
||||
pressedWhileOpen.current =
|
||||
(openFor.current !== null && openFor.current.payload === payload) ||
|
||||
(dismissed !== null &&
|
||||
dismissed.event === e.nativeEvent &&
|
||||
dismissed.payload === payload);
|
||||
},
|
||||
onClick: (e: React.MouseEvent<HTMLElement>) => {
|
||||
// The button sits on a card that plays on click, and inside a row that may be a link.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Only a pointer click (detail > 0) reads the press: a keyboard click has no pointerdown, so
|
||||
// a flag left by a press that never became a click must not turn Enter into a close.
|
||||
const toggleOff = e.detail > 0 && pressedWhileOpen.current;
|
||||
pressedWhileOpen.current = false;
|
||||
if (toggleOff) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
openAt(r.left, r.bottom, payload, e.currentTarget);
|
||||
},
|
||||
}),
|
||||
[openAt, close],
|
||||
);
|
||||
|
||||
return { target, anchor, open, close, triggerProps, buttonProps, popover: pop };
|
||||
}
|
||||
|
||||
/** One trigger per item, stable while the items and the menu are — for a list whose card component
|
||||
|
||||
Reference in New Issue
Block a user