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.
This commit is contained in:
Tanish Manku committed 2026-06-15 18:41:24 -07:00
1 parent b68fe3c563
commit da4bde24e1
12 files changed
+273 -30

No files matched your search

@@ -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"
>
<FontAwesomeIcon icon={faPlay} className="h-3 w-3" />
Present
@@ -140,7 +140,6 @@ export const MoodboardToolbar = ({ onUploadClick, onGalleryClick }: Props) => {
options={MODES}
onOptionChange={(value) => setTool(value as Tool)}
selectedOption={tool}
tooltipDelay={100}
/>
<FloatingToolbarDivider />
@@ -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<BoardItem["kind"], string> = {
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<HTMLDivElement | null>(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 (
<div
className="group absolute"
style={{ left: pos.x, top: pos.y, width: pos.width, height: pos.height }}
ref={rootRef}
role="button"
tabIndex={tabStop ? 0 : -1}
aria-label={label}
aria-pressed={selected}
className="group absolute rounded-[18px] transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-ui-panel motion-reduce:transition-none"
style={{
left: pos.x,
top: pos.y,
width: pos.width,
height: pos.height,
transitionDelay: `${revealDelayMs}ms`,
opacity: entering ? 0 : 1,
transform: entering ? "translateY(8px)" : undefined,
}}
onPointerDown={(e) => {
// 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. */}
@@ -30,10 +30,10 @@ export const BoardEmptyState = ({ onUpload, onLibrary }: Props) => {
<button
type="button"
onClick={onUpload}
className="group flex items-center gap-2.5 rounded-full bg-primary py-2.5 pl-5 pr-2.5 text-sm font-medium text-white transition-all duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:bg-primary-600 active:scale-[0.98]"
className="group flex items-center gap-2.5 rounded-full bg-primary py-2.5 pl-5 pr-2.5 text-sm font-medium text-white transition-all duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:bg-primary/90 active:scale-[0.98] focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
>
Upload
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-white/15 transition-transform duration-200 group-hover:translate-x-0.5">
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-white/15 transition-transform duration-200 group-hover:translate-x-0.5 group-hover:scale-105">
<FontAwesomeIcon
icon={faArrowUpFromBracket}
className="h-3.5 w-3.5"
@@ -43,7 +43,7 @@ export const BoardEmptyState = ({ onUpload, onLibrary }: Props) => {
<button
type="button"
onClick={onLibrary}
className="flex items-center gap-2 rounded-full border border-ui-divider px-5 py-2.5 text-sm font-medium text-base-fg/80 transition-colors duration-200 hover:bg-base-fg/5 hover:text-base-fg"
className="flex items-center gap-2 rounded-full border border-ui-divider px-5 py-2.5 text-sm font-medium text-base-fg/80 transition-colors duration-200 hover:bg-base-fg/5 hover:text-base-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<FontAwesomeIcon icon={faImages} className="h-3.5 w-3.5" />
From library
@@ -3,10 +3,19 @@ import { BoardItem, GridDensity } from "../boards/boardTypes";
import { BoardCard } from "./BoardCard";
import {
DENSITY_CONFIG,
NavDirection,
computeMasonryLayout,
nearestInDirection,
sliceVisible,
} from "./gridLayout";
const ARROW_DIRS: Record<string, NavDirection> = {
ArrowUp: "up",
ArrowDown: "down",
ArrowLeft: "left",
ArrowRight: "right",
};
interface Props {
items: BoardItem[];
density: GridDensity;
@@ -45,10 +54,12 @@ export const BoardGrid = ({
onDelete,
}: Props) => {
const scrollRef = useRef<HTMLDivElement | null>(null);
const revealedRef = useRef<Set<string>>(new Set());
const [width, setWidth] = useState(0);
const [scrollTop, setScrollTop] = useState(0);
const [viewportH, setViewportH] = useState(0);
const [marquee, setMarquee] = useState<Marquee | null>(null);
const [focusedId, setFocusedId] = useState<string | null>(null);
const marqueeRef = useRef<{ active: boolean; moved: boolean; additive: boolean }>(
{ active: false, moved: false, additive: false },
);
@@ -87,6 +98,48 @@ export const BoardGrid = ({
[layout, scrollTop, viewportH],
);
// ----- keyboard navigation (roving tabindex) -----
// Exactly one card is a tab stop: the focused one, falling back to the first.
// Arrows move focus geometrically; Enter opens, Space selects, Delete removes.
const activeId = focusedId ?? items[0]?.id ?? null;
const scrollCardIntoView = (id: string) => {
const el = scrollRef.current;
const p = layout.byId[id];
if (!el || !p) return;
// Positions are content-relative; the scroll container adds OUTER_PADDING.
const top = p.y + OUTER_PADDING;
const bottom = top + p.height;
if (top < el.scrollTop) {
el.scrollTop = Math.max(0, top - OUTER_PADDING);
} else if (bottom > el.scrollTop + el.clientHeight) {
el.scrollTop = bottom - el.clientHeight + OUTER_PADDING;
}
};
const handleCardKeyDown = (id: string, e: React.KeyboardEvent) => {
const dir = ARROW_DIRS[e.key];
if (dir) {
const next = nearestInDirection(layout.byId, id, dir);
if (next) {
e.preventDefault();
setFocusedId(next);
scrollCardIntoView(next);
}
return;
}
if (e.key === "Enter") {
e.preventDefault();
onOpen(id);
} else if (e.key === " ") {
e.preventDefault();
onSelect(id, e.shiftKey || e.metaKey || e.ctrlKey);
} else if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault();
onDelete(id);
}
};
// ----- marquee selection on empty canvas -----
const pointToContent = (clientX: number, clientY: number) => {
const el = scrollRef.current;
@@ -160,15 +213,26 @@ export const BoardGrid = ({
onPointerDown={handlePointerDown}
>
<div className="relative" style={{ height: layout.totalHeight }}>
{visible.map((pos) => {
{visible.map((pos, i) => {
const item = itemsById[pos.id];
if (!item) return null;
// Animate each card in only the first time it's revealed this session,
// so scrolling (which mounts/unmounts virtualized cards) never
// re-triggers the entry motion.
const firstReveal = !revealedRef.current.has(pos.id);
if (firstReveal) revealedRef.current.add(pos.id);
return (
<BoardCard
key={pos.id}
item={item}
pos={pos}
selected={selectedIds.has(pos.id)}
animateIn={firstReveal}
revealDelayMs={firstReveal ? Math.min(i, 8) * 25 : 0}
tabStop={pos.id === activeId}
focused={pos.id === focusedId}
onFocusCard={setFocusedId}
onKeyNav={handleCardKeyDown}
onSelect={onSelect}
onOpen={onOpen}
onUseReference={onUseReference}
@@ -111,6 +111,7 @@ export const BoardGridToolbar = ({
onClick={() => onDensityChange(d)}
className={[
"flex h-7 w-7 items-center justify-center rounded-full 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/50 hover:text-base-fg",
@@ -136,6 +137,7 @@ export const BoardGridToolbar = ({
onClick={onCycleRatingFilter}
className={[
"flex h-8 items-center gap-1 rounded-full px-2.5 transition-colors duration-150",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
ratingFilter > 0
? "bg-yellow-400/15 text-yellow-500"
: "text-base-fg/55 hover:bg-base-fg/10 hover:text-base-fg",
@@ -126,7 +126,7 @@ export const ItemInspector = ({
type="button"
aria-label="Close"
onClick={onClose}
className="flex h-8 w-8 items-center justify-center rounded-full text-base-fg/60 transition-colors hover:bg-base-fg/10 hover:text-base-fg"
className="flex h-8 w-8 items-center justify-center rounded-full text-base-fg/60 transition-colors hover:bg-base-fg/10 hover:text-base-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<FontAwesomeIcon icon={faXmark} className="h-4 w-4" />
</button>
@@ -141,7 +141,7 @@ export const ItemInspector = ({
type="button"
aria-label={`Rate ${n}`}
onClick={() => onSetRating(item.rating === n ? 0 : n)}
className="p-0.5"
className="rounded p-0.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<FontAwesomeIcon
icon={faStar}
@@ -164,7 +164,7 @@ export const ItemInspector = ({
key={tag}
type="button"
onClick={() => onRemoveTag(tag)}
className="group/tag flex items-center gap-1 rounded-full bg-base-fg/10 px-2.5 py-1 text-[11px] font-medium text-base-fg/80 transition-colors hover:bg-danger/15 hover:text-danger"
className="group/tag flex items-center gap-1 rounded-full bg-base-fg/10 px-2.5 py-1 text-[11px] font-medium text-base-fg/80 transition-colors hover:bg-danger/15 hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-danger"
>
{tag}
<FontAwesomeIcon icon={faXmark} className="h-2.5 w-2.5" />
@@ -178,7 +178,7 @@ export const ItemInspector = ({
}}
onBlur={commitTag}
placeholder="Add tag"
className="w-20 rounded-full bg-base-fg/5 px-2.5 py-1 text-[11px] text-base-fg placeholder:text-base-fg/40 focus:outline-none"
className="w-20 rounded-full bg-base-fg/5 px-2.5 py-1 text-[11px] text-base-fg placeholder:text-base-fg/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
/>
</div>
</Section>
@@ -196,7 +196,7 @@ export const ItemInspector = ({
void navigator.clipboard?.writeText(hex);
toast.success(`Copied ${hex}`);
}}
className="h-7 w-7 rounded-md ring-1 ring-inset ring-black/10"
className="h-7 w-7 rounded-md ring-1 ring-inset ring-black/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
style={{ background: hex }}
/>
))}
@@ -204,7 +204,7 @@ export const ItemInspector = ({
<button
type="button"
onClick={() => onAddPaletteToBoard(palette)}
className="mt-2.5 flex items-center gap-1.5 text-xs font-medium text-base-fg/60 transition-colors hover:text-base-fg"
className="mt-2.5 flex items-center gap-1.5 rounded text-xs font-medium text-base-fg/60 transition-colors hover:text-base-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<FontAwesomeIcon icon={faPlus} className="h-3 w-3" />
Add swatches to board
@@ -220,19 +220,21 @@ export const ItemInspector = ({
<button
type="button"
onClick={onUseReference}
className="group flex items-center justify-center gap-2.5 rounded-full bg-primary py-2.5 text-sm font-medium text-white transition-all duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:bg-primary-600 active:scale-[0.98]"
className="group flex items-center justify-center gap-2.5 rounded-full bg-primary py-2 pl-5 pr-2 text-sm font-medium text-white transition-all duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:bg-primary/90 active:scale-[0.98] focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
>
<FontAwesomeIcon
icon={faWandMagicSparkles}
className="h-3.5 w-3.5"
/>
Use as reference
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-white/15 transition-transform duration-200 group-hover:translate-x-0.5 group-hover:scale-105">
<FontAwesomeIcon
icon={faWandMagicSparkles}
className="h-3.5 w-3.5"
/>
</span>
</button>
)}
<button
type="button"
onClick={onDelete}
className="flex items-center justify-center gap-2 rounded-full border border-ui-divider py-2.5 text-sm font-medium text-base-fg/70 transition-colors hover:bg-danger/10 hover:text-danger"
className="flex items-center justify-center gap-2 rounded-full border border-ui-divider py-2.5 text-sm font-medium text-base-fg/70 transition-colors hover:bg-danger/10 hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-danger"
>
<FontAwesomeIcon icon={faTrashCan} className="h-3.5 w-3.5" />
Delete
@@ -342,7 +344,7 @@ const NavArrow = ({
onClick={onClick}
className={[
"absolute top-1/2 z-20 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full",
"border border-white/15 bg-black/40 text-white/85 backdrop-blur-md transition-colors hover:bg-black/60 hover:text-white",
"border border-white/15 bg-black/40 text-white/85 backdrop-blur-md transition-colors hover:bg-black/60 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white/70",
side === "left" ? "left-4" : "right-4",
].join(" ")}
>
@@ -38,7 +38,7 @@ export const PresentationView = ({ items, onClose }: Props) => {
type="button"
aria-label="Exit presentation"
onClick={onClose}
className="absolute right-5 top-5 z-20 flex h-10 w-10 items-center justify-center rounded-full border border-white/15 bg-white/5 text-white/80 backdrop-blur-md transition-colors hover:bg-white/15 hover:text-white"
className="absolute right-5 top-5 z-20 flex h-10 w-10 items-center justify-center rounded-full border border-white/15 bg-white/5 text-white/80 backdrop-blur-md transition-colors hover:bg-white/15 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
>
<FontAwesomeIcon icon={faXmark} className="h-4 w-4" />
</button>
@@ -151,7 +151,7 @@ const Arrow = ({
onClick={onClick}
className={[
"absolute top-1/2 z-20 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full",
"border border-white/15 bg-white/5 text-white/80 backdrop-blur-md transition-colors hover:bg-white/15 hover:text-white",
"border border-white/15 bg-white/5 text-white/80 backdrop-blur-md transition-colors hover:bg-white/15 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white/70",
side === "left" ? "left-5" : "right-5",
].join(" ")}
>
@@ -37,7 +37,7 @@ export const SelectionBar = ({
<button
type="button"
onClick={onUseReference}
className="flex items-center gap-2 rounded-full px-3 py-1.5 text-sm text-base-fg/80 transition-colors duration-150 hover:bg-primary/15 hover:text-primary"
className="flex items-center gap-2 rounded-full px-3 py-1.5 text-sm text-base-fg/80 transition-colors duration-150 hover:bg-primary/15 hover:text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<FontAwesomeIcon icon={faWandMagicSparkles} className="h-3.5 w-3.5" />
Use as reference
@@ -45,7 +45,7 @@ export const SelectionBar = ({
<button
type="button"
onClick={onDelete}
className="flex items-center gap-2 rounded-full px-3 py-1.5 text-sm text-base-fg/80 transition-colors duration-150 hover:bg-danger/15 hover:text-danger"
className="flex items-center gap-2 rounded-full px-3 py-1.5 text-sm text-base-fg/80 transition-colors duration-150 hover:bg-danger/15 hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-danger"
>
<FontAwesomeIcon icon={faTrashCan} className="h-3.5 w-3.5" />
Delete
@@ -54,7 +54,7 @@ export const SelectionBar = ({
type="button"
aria-label="Clear selection"
onClick={onClear}
className="flex h-8 w-8 items-center justify-center rounded-full text-base-fg/60 transition-colors duration-150 hover:bg-base-fg/10 hover:text-base-fg"
className="flex h-8 w-8 items-center justify-center rounded-full text-base-fg/60 transition-colors duration-150 hover:bg-base-fg/10 hover:text-base-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<FontAwesomeIcon icon={faXmark} className="h-4 w-4" />
</button>
@@ -23,7 +23,7 @@ export const SmartSearchBar = ({
}: Props) => {
return (
<div className="flex min-w-0 items-center gap-2">
<div className="glass flex h-9 min-w-[200px] items-center gap-2 rounded-full border border-ui-divider px-3">
<div className="glass flex h-9 min-w-[200px] items-center gap-2 rounded-full border border-ui-divider px-3 focus-within:ring-2 focus-within:ring-primary/50">
<FontAwesomeIcon
icon={faMagnifyingGlass}
className="h-3.5 w-3.5 text-base-fg/45"
@@ -39,7 +39,7 @@ export const SmartSearchBar = ({
type="button"
aria-label="Clear search"
onClick={() => onQueryChange("")}
className="text-base-fg/45 transition-colors hover:text-base-fg"
className="rounded-full text-base-fg/45 transition-colors hover:text-base-fg focus:outline-none focus-visible:text-base-fg focus-visible:ring-2 focus-visible:ring-primary"
>
<FontAwesomeIcon icon={faXmark} className="h-3.5 w-3.5" />
</button>
@@ -57,6 +57,7 @@ export const SmartSearchBar = ({
onClick={() => onToggleTag(tag)}
className={[
"shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors duration-150",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
active
? "bg-primary text-white"
: "bg-base-fg/8 text-base-fg/70 hover:bg-base-fg/15",
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import {
PositionedItem,
computeMasonryLayout,
nearestInDirection,
} from "./gridLayout";
// A 3x3 grid of 100x100 tiles on a 20px pitch — centers at 50/170/290.
const GRID: Record<string, PositionedItem> = {};
["a", "b", "c", "d", "e", "f", "g", "h", "i"].forEach((id, idx) => {
const col = idx % 3;
const row = Math.floor(idx / 3);
GRID[id] = { id, x: col * 120, y: row * 120, width: 100, height: 100 };
});
// Layout: a b c
// d e f
// g h i
describe("nearestInDirection", () => {
it("moves to the adjacent tile on each axis from the center", () => {
expect(nearestInDirection(GRID, "e", "left")).toBe("d");
expect(nearestInDirection(GRID, "e", "right")).toBe("f");
expect(nearestInDirection(GRID, "e", "up")).toBe("b");
expect(nearestInDirection(GRID, "e", "down")).toBe("h");
});
it("returns null when there is nothing in that direction", () => {
expect(nearestInDirection(GRID, "a", "left")).toBeNull();
expect(nearestInDirection(GRID, "a", "up")).toBeNull();
expect(nearestInDirection(GRID, "i", "right")).toBeNull();
expect(nearestInDirection(GRID, "i", "down")).toBeNull();
});
it("stays in the same row/column rather than jumping diagonally", () => {
// From the top-left, moving down should land on the tile directly below
// (same column), never the closer-by-raw-distance diagonal neighbor.
expect(nearestInDirection(GRID, "a", "down")).toBe("d");
expect(nearestInDirection(GRID, "a", "right")).toBe("b");
});
it("returns null for an unknown origin id", () => {
expect(nearestInDirection(GRID, "zzz", "down")).toBeNull();
});
});
describe("computeMasonryLayout", () => {
it("indexes every item in byId and keeps positions in sync", () => {
const layout = computeMasonryLayout(
[
{ id: "x", aspect: 1 },
{ id: "y", aspect: 1.5 },
],
600,
{ targetColumnWidth: 280, gap: 20, minColumns: 1, maxColumns: 8 },
);
expect(Object.keys(layout.byId)).toHaveLength(2);
expect(layout.byId.x).toBe(layout.positions[0]);
});
});
@@ -88,6 +88,56 @@ export const computeMasonryLayout = (
};
};
export type NavDirection = "up" | "down" | "left" | "right";
// Spatial navigation: the nearest card to `fromId` in a direction, biased toward
// staying in the same row/column (cross-axis distance weighted more). Used for
// arrow-key focus movement across the masonry.
export const nearestInDirection = (
byId: Record<string, PositionedItem>,
fromId: string,
dir: NavDirection,
): string | null => {
const from = byId[fromId];
if (!from) return null;
const fcx = from.x + from.width / 2;
const fcy = from.y + from.height / 2;
let best: string | null = null;
let bestScore = Infinity;
for (const p of Object.values(byId)) {
if (p.id === fromId) continue;
const cx = p.x + p.width / 2;
const cy = p.y + p.height / 2;
const dx = cx - fcx;
const dy = cy - fcy;
let primary: number;
let cross: number;
if (dir === "right") {
if (dx <= 1) continue;
primary = dx;
cross = Math.abs(dy);
} else if (dir === "left") {
if (dx >= -1) continue;
primary = -dx;
cross = Math.abs(dy);
} else if (dir === "down") {
if (dy <= 1) continue;
primary = dy;
cross = Math.abs(dx);
} else {
if (dy >= -1) continue;
primary = -dy;
cross = Math.abs(dx);
}
const score = primary + cross * 2;
if (score < bestScore) {
bestScore = score;
best = p.id;
}
}
return best;
};
// Returns only positions intersecting the scroll viewport (± buffer). Linear
// scan is fine to ~thousands of items; positions are already y-ordered enough
// for the predicate to be cheap.