mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-31 15:37:49 +00:00
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:
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.
|
||||
|
||||
Reference in new issue
Block a user