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:
2026-09-26 01:23:44 +02:00
parent 9d7526ee57
commit 75ae810866
26 changed files with 1189 additions and 557 deletions
+9 -1
View File
@@ -58,6 +58,10 @@ interface ActionItem extends MenuItemBase {
onSelect: () => void;
/** Destructive action: painted in the danger colour, like the outline-danger buttons. */
danger?: boolean;
/** Run INSIDE the click, then close — for a clipboard write, which Safari allows only within the
* user's gesture (a task later, as `closeThenAct` runs every other row, it is refused). Only for
* an action that opens nothing and moves no focus: that is what the deferral exists for. */
inGesture?: boolean;
items?: never;
}
@@ -100,7 +104,11 @@ export function MenuItemList({
label={it.label}
title={it.title}
// Any row may open a dialog (a confirm), so every row closes first — see closeThenAct.
onSelect={() => closeThenAct(onClose, it.onSelect)}
onSelect={() => {
if (!it.inGesture) return closeThenAct(onClose, it.onSelect);
it.onSelect();
onClose();
}}
className={`${ROW}${it.danger ? " text-danger" : ""}`}
>
{it.icon && (
+37
View File
@@ -0,0 +1,37 @@
import { MoreHorizontal } from "lucide-react";
import { IconButton } from "./IconButton";
import { type IconButtonSize, type IconButtonVariant } from "./buttonStyles";
// The visible door to an object's ONE action list (docs/DESIGN.md "Action placement", C1): the "…"
// that opens the same menu as a right-click, a long-press and Shift+F10, so no action is reachable
// only by a gesture. It carries no menu of its own — `menuProps` is the list's
// `useContextMenu().buttonProps(row)`, and `expanded` says whether THIS row's menu is the open one.
export function MoreButton({
label,
expanded,
menuProps,
variant = "ghost",
size = "sm",
className,
}: {
/** The menu's name ("Video actions"): the button's accessible name and tooltip. */
label: string;
expanded: boolean;
menuProps: Record<string, unknown>;
variant?: IconButtonVariant;
size?: IconButtonSize;
className?: string;
}) {
return (
<IconButton
variant={variant}
size={size}
label={label}
aria-expanded={expanded}
className={className}
{...menuProps}
>
<MoreHorizontal />
</IconButton>
);
}
+4 -4
View File
@@ -36,10 +36,10 @@ domain-free.
## What lives here
| area | components |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10), `Modal`, `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `IconButton`, `Pressable`, `Chip` + `chipStyles`, `DragHandle` (one per button family, docs/DESIGN.md; a raw `<button>` outside this folder is a lint error), `Input` + `fieldStyles`, `NativeControls`, `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the |
| area | components |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens), `Modal`, `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `IconButton`, `Pressable`, `Chip` + `chipStyles`, `DragHandle` (one per button family, docs/DESIGN.md; a raw `<button>` outside this folder is a lint error), `Input` + `fieldStyles`, `NativeControls`, `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the |
B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a
link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
+67 -3
View File
@@ -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