From da4bde24e102b9821867324cf67d30e394a445a2 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Mon, 15 Jun 2026 18:41:24 -0700 Subject: [PATCH] Moodboard: keyboard navigation, entry motion, focus & CTA polish P1 + select P2 from the UX change plan: - Keyboard-navigable grid cards: roving tabindex, geometry-aware arrow movement (nearestInDirection), Enter opens / Space selects / Delete removes, programmatic focus on arrow nav, ARIA labels. - Staggered fade-up entry motion, virtualization-safe via a revealed Set so scrolling never re-triggers it; respects reduced-motion. - Complete focus-visible coverage across grid chrome, inspector, selection bar, presentation, and empty state. - Button-in-button trailing-icon CTAs on primary actions. - Drop unsupported tooltipDelay prop that slipped through on a stale dist; add gridLayout spatial-nav tests. --- .../moodboard/src/lib/MoodboardWorkspace.tsx | 4 +- .../src/lib/canvas/MoodboardToolbar.tsx | 1 - .../moodboard/src/lib/grid/BoardCard.tsx | 72 ++++++++++++++++++- .../src/lib/grid/BoardEmptyState.tsx | 6 +- .../moodboard/src/lib/grid/BoardGrid.tsx | 66 ++++++++++++++++- .../src/lib/grid/BoardGridToolbar.tsx | 2 + .../moodboard/src/lib/grid/ItemInspector.tsx | 28 ++++---- .../src/lib/grid/PresentationView.tsx | 4 +- .../moodboard/src/lib/grid/SelectionBar.tsx | 6 +- .../moodboard/src/lib/grid/SmartSearchBar.tsx | 5 +- .../moodboard/src/lib/grid/gridLayout.spec.ts | 59 +++++++++++++++ .../moodboard/src/lib/grid/gridLayout.ts | 50 +++++++++++++ 12 files changed, 273 insertions(+), 30 deletions(-) create mode 100644 frontend/libs/components/moodboard/src/lib/grid/gridLayout.spec.ts diff --git a/frontend/libs/components/moodboard/src/lib/MoodboardWorkspace.tsx b/frontend/libs/components/moodboard/src/lib/MoodboardWorkspace.tsx index c9fe551c5f..cf49443b99 100644 --- a/frontend/libs/components/moodboard/src/lib/MoodboardWorkspace.tsx +++ b/frontend/libs/components/moodboard/src/lib/MoodboardWorkspace.tsx @@ -82,7 +82,7 @@ const ViewSwitch = ({ onClick={() => onChange(opt.mode)} className={[ "flex items-center gap-2 rounded-full px-3.5 py-1.5 text-sm font-medium", - "transition-colors duration-150", + "transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary", active ? "bg-base-fg/15 text-base-fg" : "text-base-fg/55 hover:text-base-fg", @@ -99,7 +99,7 @@ const ViewSwitch = ({ onClick={onPresent} title="Present" aria-label="Present" - className="flex items-center gap-2 rounded-full px-3.5 py-1.5 text-sm font-medium text-base-fg/55 transition-colors duration-150 hover:text-base-fg" + className="flex items-center gap-2 rounded-full px-3.5 py-1.5 text-sm font-medium text-base-fg/55 transition-colors duration-150 hover:text-base-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-primary" > Present diff --git a/frontend/libs/components/moodboard/src/lib/canvas/MoodboardToolbar.tsx b/frontend/libs/components/moodboard/src/lib/canvas/MoodboardToolbar.tsx index 1d58d6cf7b..5478e311f9 100644 --- a/frontend/libs/components/moodboard/src/lib/canvas/MoodboardToolbar.tsx +++ b/frontend/libs/components/moodboard/src/lib/canvas/MoodboardToolbar.tsx @@ -140,7 +140,6 @@ export const MoodboardToolbar = ({ onUploadClick, onGalleryClick }: Props) => { options={MODES} onOptionChange={(value) => setTool(value as Tool)} selectedOption={tool} - tooltipDelay={100} /> diff --git a/frontend/libs/components/moodboard/src/lib/grid/BoardCard.tsx b/frontend/libs/components/moodboard/src/lib/grid/BoardCard.tsx index 932af22500..aa8ce067ed 100644 --- a/frontend/libs/components/moodboard/src/lib/grid/BoardCard.tsx +++ b/frontend/libs/components/moodboard/src/lib/grid/BoardCard.tsx @@ -1,4 +1,4 @@ -import { memo } from "react"; +import { memo, useEffect, useRef, useState } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faUpRightAndDownLeftFromCenter, @@ -15,27 +15,91 @@ interface Props { item: BoardItem; pos: PositionedItem; selected: boolean; + /** Play a one-time fade-up the first time this card is revealed. */ + animateIn?: boolean; + revealDelayMs?: number; + /** Roving tabindex: only the active card is keyboard-reachable via Tab. */ + tabStop?: boolean; + /** Programmatically pull DOM focus here (set after arrow-key navigation). */ + focused?: boolean; + onFocusCard?: (id: string) => void; + onKeyNav?: (id: string, e: React.KeyboardEvent) => void; onSelect: (id: string, additive: boolean) => void; onOpen: (id: string) => void; onDelete: (id: string) => void; onUseReference: (id: string) => void; } +const KIND_NOUN: Record = { + image: "image", + video: "video", + text: "note", + link: "link", + color: "color swatch", +}; + +const prefersReducedMotion = (): boolean => + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + // One masonry tile. Absolutely positioned by the grid; the card owns only its // visual treatment (double-bezel shell), per-kind content, and hover affordances. const BoardCardInner = ({ item, pos, selected, + animateIn, + revealDelayMs = 0, + tabStop = false, + focused = false, + onFocusCard, + onKeyNav, onSelect, onOpen, onDelete, onUseReference, }: Props) => { + const rootRef = useRef(null); + const [entering, setEntering] = useState( + Boolean(animateIn) && !prefersReducedMotion(), + ); + useEffect(() => { + if (!entering) return undefined; + const r = requestAnimationFrame(() => setEntering(false)); + return () => cancelAnimationFrame(r); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Pull DOM focus here when the grid hands us focus via arrow navigation. + useEffect(() => { + if (focused && rootRef.current && document.activeElement !== rootRef.current) { + rootRef.current.focus(); + } + }, [focused]); + + const caption = "caption" in item ? item.caption : undefined; + const label = `${KIND_NOUN[item.kind]}${caption ? `: ${caption}` : ""}${ + item.rating > 0 ? `, rated ${item.rating}` : "" + }${selected ? ", selected" : ""}`; + return (
{ // Left-click selects; modifier-click extends. Action buttons stop // propagation so they don't double as a select. @@ -43,6 +107,8 @@ const BoardCardInner = ({ onSelect(item.id, e.shiftKey || e.metaKey || e.ctrlKey); }} onDoubleClick={() => onOpen(item.id)} + onFocus={() => onFocusCard?.(item.id)} + onKeyDown={(e) => onKeyNav?.(item.id, e)} > {/* Outer shell — double-bezel. Lifts slightly on hover; ring turns to the brand blue when selected. */} diff --git a/frontend/libs/components/moodboard/src/lib/grid/BoardEmptyState.tsx b/frontend/libs/components/moodboard/src/lib/grid/BoardEmptyState.tsx index 7826fc54c3..aab70a32cf 100644 --- a/frontend/libs/components/moodboard/src/lib/grid/BoardEmptyState.tsx +++ b/frontend/libs/components/moodboard/src/lib/grid/BoardEmptyState.tsx @@ -30,10 +30,10 @@ export const BoardEmptyState = ({ onUpload, onLibrary }: Props) => {