fix(ui): stable shelf-card menu triggers, a named Button by type, true layout comments
- Home's rail handed each card a fresh `() => menu.triggerProps(c)` per
render, defeating memo(VideoCard) on every tile whenever the menu's own
state changed. `useItemTriggers` (ui/useContextMenu) keeps one trigger per
item, index-aligned; tested for identity across renders.
- The Button accessible-name union had one hole: `<Button icon={…} />` with
no children and no size typechecked into a nameless button. A textful
button now needs children or a name; pinned with @ts-expect-error.
- comfortPin's header said the pin belongs "on every card and row", which
the code never meant (an episode, a season, a person are not pin kinds).
It now says what is pinnable — and the show page's Related strip, whose
shows ARE pinnable, gets the pin menu it was missing (and a rem width).
- gridColumns / feedView / VirtualGrid claimed layouts 1024–2560 are
"byte-identical" under the column cap; the small cards are capped at 2560
with the filter rail collapsed. The comments say so.
This commit is contained in:
@@ -33,7 +33,7 @@ import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Carousel } from "./ui/Carousel";
|
||||
import { ContextMenu } from "./ui/ContextMenu";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
import { useContextMenu, useItemTriggers } from "./ui/useContextMenu";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import VideoCard from "./VideoCard";
|
||||
@@ -158,6 +158,10 @@ function Rail({
|
||||
: pin.item(pinTargetForCard(target), target.title)),
|
||||
];
|
||||
|
||||
// One stable trigger per card (the menu acts on the unified card, not the `Video`/`PlexCard` the
|
||||
// card component holds) — a fresh lambda per render defeated `memo(VideoCard)` on every tile.
|
||||
const menuFor = useItemTriggers(cards, menu.triggerProps);
|
||||
|
||||
// An empty rail says why it is empty rather than vanishing: a shelf that silently disappears when
|
||||
// you have not pinned anything teaches nothing about how to fill it.
|
||||
if (!cards.length) {
|
||||
@@ -190,10 +194,7 @@ function Rail({
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onOpen={(v, startAt) => onPlay({ ...c, video: v }, startAt)}
|
||||
// Bound to the UNIFIED card, not the native payload the card component holds: the
|
||||
// menu acts on the shelf ENTRY (its pin id, its place in the order), which the
|
||||
// `Video` inside it does not carry.
|
||||
menuProps={() => menu.triggerProps(c)}
|
||||
menuProps={menuFor[i]}
|
||||
/>
|
||||
) : c.plex ? (
|
||||
<PlexPosterCard
|
||||
@@ -202,7 +203,7 @@ function Rail({
|
||||
onOpen={() => onPlay(c)}
|
||||
onInfo={() => onInfo(c)}
|
||||
onToggleWatched={onToggleWatched}
|
||||
menuProps={() => menu.triggerProps(c)}
|
||||
menuProps={menuFor[i]}
|
||||
/>
|
||||
) : (
|
||||
<ShelfTile card={c} onPlay={onPlay} menuProps={menu.triggerProps} navIndex={i} />
|
||||
|
||||
@@ -11,11 +11,12 @@ import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||
|
||||
// E24 S3 — "put this on my comfort shelf", from wherever you happen to be looking at the thing.
|
||||
//
|
||||
// The shelf is the Home surface's object, but the GESTURE belongs on every card and row in the app:
|
||||
// you decide something is comfort media while you are watching it, not while you are on a page that
|
||||
// already lists it. So this file is the one place the pin lives, and every surface composes the same
|
||||
// `ActionMenuItem` into the menu it already has (`Playlists`, `PlaylistsRail`) or mounts the
|
||||
// `ComfortPinMenu` wrapper when it has none.
|
||||
// The shelf is the Home surface's object, but the GESTURE belongs wherever a pinnable thing is
|
||||
// shown — a video, a channel, a playlist, a Plex show or movie: you decide something is comfort media
|
||||
// while you are watching it. (Locked decision 3: an episode, a season and a person are not pin kinds,
|
||||
// so their cards and strips carry no pin.) This file is the one place the pin lives; every surface
|
||||
// composes the same `ActionMenuItem` into the menu it already has (`Playlists`, `PlaylistsRail`) or
|
||||
// mounts the `ComfortPinMenu` wrapper when it has none.
|
||||
//
|
||||
// Feature layer, not `components/ui/`: it speaks a domain API slice, which the platform boundary
|
||||
// (eslint `no-restricted-imports`) forbids a `ui/` primitive from doing. The MENU SURFACE that
|
||||
|
||||
@@ -412,9 +412,12 @@ export function CastStrip({
|
||||
export function RelatedStrip({
|
||||
related,
|
||||
onOpen,
|
||||
menuProps,
|
||||
}: {
|
||||
related: PlexCard[];
|
||||
onOpen: (id: string) => void;
|
||||
/** The comfort-pin context menu's trigger (a related show is pinnable like any poster). */
|
||||
menuProps?: (c: PlexCard) => Record<string, unknown>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -428,8 +431,9 @@ export function RelatedStrip({
|
||||
<button
|
||||
key={r.id}
|
||||
{...navItemAttrs(i)}
|
||||
{...menuProps?.(r)}
|
||||
onClick={() => onOpen(r.id)}
|
||||
className="group w-[130px] shrink-0 text-left"
|
||||
className="group w-32.5 shrink-0 text-left"
|
||||
>
|
||||
<div className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200">
|
||||
<img
|
||||
|
||||
@@ -312,6 +312,7 @@ export function PlexShowView({
|
||||
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
|
||||
const [collOpen, setCollOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const relatedPin = useContextMenu<PlexCard>();
|
||||
|
||||
const show = d?.show;
|
||||
const showLib = show?.library ?? undefined;
|
||||
@@ -532,10 +533,15 @@ export function PlexShowView({
|
||||
<CastStrip cast={show.cast} onFilter={onFilter} onOpenPerson={onOpenPerson} />
|
||||
)}
|
||||
{d.related.length > 0 && showRelated && (
|
||||
<RelatedStrip related={d.related} onOpen={onOpenShow} />
|
||||
<RelatedStrip
|
||||
related={d.related}
|
||||
onOpen={onOpenShow}
|
||||
menuProps={relatedPin.triggerProps}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<PlexPinMenu menu={relatedPin} />
|
||||
|
||||
{addTarget && <PlexPlaylistAdd target={addTarget} onClose={() => setAddTarget(null)} />}
|
||||
{collOpen && show && showLib && (
|
||||
|
||||
@@ -161,6 +161,9 @@ describe("Button accessible-name contract (type level)", () => {
|
||||
<Button size="icon" icon={<span />} data-testid="bad-a" />
|
||||
{/* @ts-expect-error iconOnly with no label/aria-label/title has no accessible name */}
|
||||
<Button iconOnly icon={<span />} data-testid="bad-b" />
|
||||
{/* @ts-expect-error an icon with no children and no size renders no text and no name */}
|
||||
<Button icon={<span />} data-testid="bad-c" />
|
||||
<Button icon={<span />} label="an icon button named by label" />
|
||||
<Button size="icon" icon={<span />} label="by label" />
|
||||
<Button size="icon-sm" icon={<span />} aria-label="by aria-label" />
|
||||
<Button iconOnly icon={<span />} title="by title" />
|
||||
|
||||
@@ -119,7 +119,12 @@ interface ButtonOwnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
// use it for the tooltip that IS the name.
|
||||
type NamedSomehow = { label: string } | { "aria-label": string } | { title: string };
|
||||
type TextlessButton = ({ iconOnly: true } | { size: "icon" | "icon-sm" }) & NamedSomehow;
|
||||
type TextfulButton = { iconOnly?: false; size?: Exclude<ButtonSize, "icon" | "icon-sm"> };
|
||||
// …and a button of any other size must actually HAVE text, or be named: `<Button icon={x} />` with
|
||||
// no children and no size typechecked into a nameless button (E10 review).
|
||||
type TextfulButton = { iconOnly?: false; size?: Exclude<ButtonSize, "icon" | "icon-sm"> } & (
|
||||
| { children: ReactNode }
|
||||
| NamedSomehow
|
||||
);
|
||||
export type ButtonProps = ButtonOwnProps & (TextlessButton | TextfulButton);
|
||||
|
||||
/** The shared action button. `type` defaults to "button" so it never accidentally submits a form. */
|
||||
|
||||
@@ -47,8 +47,8 @@ export interface VirtualGridProps<T> {
|
||||
* just above its minimum column, so on an ultrawide it answers "more room" with more, smaller
|
||||
* items forever — 3440px gave the feed 11 columns of 264px. With a cap the extra width goes into
|
||||
* the items instead (8 columns of ~370px), which is what a screen watched from a distance wants.
|
||||
* It only ever binds ABOVE the width where that many columns already fit, so narrower layouts —
|
||||
* including the 1024px floor and 1920/2560 — are untouched. */
|
||||
* It only ever binds ABOVE the width where that many columns already fit, so the 1024px floor and
|
||||
* 1920 are untouched; at 2560 it depends on the caller's cap (see `feedView.maxCols`). */
|
||||
maxCols?: number | null;
|
||||
/** Estimated row height before measurement; real heights are measured per-row, so this only
|
||||
* affects the very first paint and the scrollbar's initial guess. Measure it, don't guess — a
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
// just above its minimum column width no matter how wide the screen is. Measured at 3440px (E10 S4),
|
||||
// the feed laid out 11 columns of 264px — a diluted feed is as bad a failure of the viewport contract
|
||||
// as a broken one, so `maxCols` stops the multiplication and lets the leftover width flow into the
|
||||
// items instead. The cap can only bind ABOVE the width at which that many columns already fit, which
|
||||
// is what keeps every narrower layout — the 1024px floor included — bit-for-bit unchanged.
|
||||
// items instead. The cap can only bind ABOVE the width at which that many columns already fit, so the
|
||||
// 1024px floor and everything near it is unchanged. How far up that reaches is per view: the feed's
|
||||
// small cards (`feedView.cardsSmall`, 11) are already capped at 2560 with the filter rail collapsed —
|
||||
// 12 would fit there — while the regular cards stay uncapped up to 2560.
|
||||
|
||||
/** The grid's gutter in px; the column maths has to net it out on both sides of the division. */
|
||||
export const GRID_GAP = 16;
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
|
||||
import { matchesShortcut, useKeyScope } from "../../lib/keymap";
|
||||
import { useAnchoredPopover } from "./anchoredPopoverHooks";
|
||||
@@ -177,3 +183,14 @@ export function useContextMenu<T>(): ContextMenuController<T> {
|
||||
|
||||
return { target, open, close: () => setOpen(false), triggerProps, popover: pop };
|
||||
}
|
||||
|
||||
/** One trigger per item, stable while the items and the menu are — for a list whose card component
|
||||
* takes `menuProps: () => props` but whose menu acts on a WRAPPER of what the card holds (Home's
|
||||
* unified shelf card around a `Video`). A fresh `() => menu.triggerProps(c)` per render defeats a
|
||||
* `memo` card on every tile each time the menu's own state changes. Index-aligned with `items`. */
|
||||
export function useItemTriggers<T>(
|
||||
items: readonly T[],
|
||||
triggerProps: (payload: T) => Record<string, unknown>,
|
||||
): (() => Record<string, unknown>)[] {
|
||||
return useMemo(() => items.map((it) => () => triggerProps(it)), [items, triggerProps]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
|
||||
import { useItemTriggers } from "./useContextMenu";
|
||||
|
||||
describe("useItemTriggers", () => {
|
||||
it("hands each item the same trigger across renders, so a memo card is not re-rendered", () => {
|
||||
const items = [{ id: 1 }, { id: 2 }];
|
||||
const triggerProps = vi.fn((it: { id: number }) => ({ "data-id": it.id }));
|
||||
const { result, rerender } = renderHook(() => useItemTriggers(items, triggerProps));
|
||||
const first = result.current;
|
||||
rerender();
|
||||
expect(result.current[0]).toBe(first[0]);
|
||||
expect(result.current[1]).toBe(first[1]);
|
||||
expect(result.current[1]!()).toEqual({ "data-id": 2 });
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,9 @@ export interface FeedViewSpec {
|
||||
* 3440px, `cards` laid out 11 columns of 264px. Each cap here is set so the item tops out near
|
||||
* 1.5x its own `minCol` on a 3440px ultrawide, which keeps each view recognisably itself (a capped
|
||||
* `cardsSmall` must not grow into a `cards`). Below the width where that many columns already fit
|
||||
* the cap does nothing at all, so 1024 through 2560 are byte-identical to before it existed. */
|
||||
* the cap does nothing at all. For `cards` that width is past 2560; `cardsSmall` already binds AT
|
||||
* 2560 with the filter rail collapsed (12 would fit, 11 are laid out) — the intended layout, not
|
||||
* an accident, but not "unchanged" either. */
|
||||
maxCols: number | null;
|
||||
/** Show the video thumbnail. False only for the bare row — whose badges (duration, live state,
|
||||
* saved, resume bar) then move into the meta line, since Thumb is what draws them. */
|
||||
|
||||
Reference in New Issue
Block a user