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:
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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` |
|
||||
|
||||
@@ -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