import React, { forwardRef, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react'; /** * The picture list a stepper opens ("A · Peek", approval "inbox-image-viewer", * 2026-10-15): one row per group (a message, a section of a document) with * its label, each item a thumbnail with its name under it, the open one * ringed. It floats over what is open and moves nothing under it; on a phone * it is a bottom sheet, three across. * * Keys only while it is open: ← → along the items, ↑ ↓ between rows (the * nearest column), Home / End, Enter or Space opens the item (`onPick`), Esc or * Tab closes it (`onClose`). The host puts focus back on the control that * opened it. Focus starts on the open item. * * Images are only ever drawn through ``. */ export interface ImagePeekItem { /** Unique across the peek. */ key: string; name: string; /** An image URL for the thumbnail; absent for a file drawn as a type tile. */ thumb?: string | null; /** The type tile's short label ("MD", "PDF") when there is no thumbnail. */ tile?: string; /** Comments on it, shown as a count badge when more than 0. */ count?: number; /** A paperclip badge (an attached file, as opposed to an image in the text). */ attached?: boolean; /** Extra words for a screen reader ("changed since it was sent"). */ detail?: string; } export interface ImagePeekRow { key: string; /** "11:22 AM", or a section heading. */ label: string; items: ImagePeekItem[]; } export interface ImagePeekProps { rows: readonly ImagePeekRow[]; /** The open item's key: ringed, and where focus starts. */ current: string | null; onPick: (key: string) => void; /** Esc or Tab (`key`: the host puts focus back on its opener), or a press outside / the sheet's backdrop (`outside`). */ onClose: (reason: 'key' | 'outside') => void; /** `popover`: a panel the host positions above its bar. `sheet`: a phone's bottom sheet, three across. */ variant?: 'popover' | 'sheet'; /** The grid's accessible name. */ label: string; /** An id for the panel, for the opener's `aria-controls`. */ id?: string; className?: string; } const PaperclipIcon = () => ( ); /** Where focus may move from `at` (row, column) for a key, or null when the key is not the grid's. */ export function peekMove(rows: readonly { items: readonly unknown[] }[], at: { row: number; col: number }, key: string): { row: number; col: number } | null { const flat: { row: number; col: number }[] = []; rows.forEach((row, r) => row.items.forEach((_, c) => flat.push({ row: r, col: c }))); if (flat.length === 0) return null; const index = flat.findIndex((p) => p.row === at.row && p.col === at.col); switch (key) { case 'ArrowRight': return flat[Math.min(flat.length - 1, index + 1)]!; case 'ArrowLeft': return flat[Math.max(0, index - 1)]!; case 'Home': return flat[0]!; case 'End': return flat[flat.length - 1]!; case 'ArrowDown': case 'ArrowUp': { const step = key === 'ArrowDown' ? 1 : -1; for (let r = at.row + step; r >= 0 && r < rows.length; r += step) { const count = rows[r]!.items.length; if (count > 0) return { row: r, col: Math.min(at.col, count - 1) }; } return at; } default: return null; } } /** A host that intercepts keys before they reach the peek (a modal's capture listener) hands them over here. */ export interface ImagePeekHandle { /** Handle one key as the peek would; true when it was the peek's (the caller prevents its default). */ handleKey: (event: { key: string; isComposing?: boolean }) => boolean; } export const ImagePeek = forwardRef(function ImagePeek({ rows, current, onPick, onClose, variant = 'popover', label, id, className = '' }, ref) { const sheet = variant === 'sheet'; const gridRef = useRef(null); const autoId = useId(); const labelId = `${(id ?? `pn-peek-${autoId}`).replace(/:/g, '')}-label`; const start = useMemo(() => { for (let r = 0; r < rows.length; r++) { const c = rows[r]!.items.findIndex((item) => item.key === current); if (c >= 0) return { row: r, col: c }; } const r = rows.findIndex((row) => row.items.length > 0); return { row: Math.max(0, r), col: 0 }; }, [rows, current]); const [focus, setFocus] = useState(start); const onCloseRef = useRef(onClose); onCloseRef.current = onClose; const cellOf = (at: { row: number; col: number }) => gridRef.current?.querySelector(`[data-peek-cell="${at.row}:${at.col}"]`) ?? null; // Focus starts on the open item, and the open item is in view. useLayoutEffect(() => { const cell = cellOf(start); cell?.focus({ preventScroll: true }); cell?.scrollIntoView?.({ block: 'nearest' }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // A press outside closes it (the opener toggles it itself). useEffect(() => { if (sheet) return; const onDown = (event: PointerEvent) => { const target = event.target instanceof Node ? event.target : null; if (!target || gridRef.current?.contains(target)) return; if (target instanceof Element && target.closest('[data-peek-opener]')) return; onCloseRef.current('outside'); }; document.addEventListener('pointerdown', onDown, true); return () => document.removeEventListener('pointerdown', onDown, true); }, [sheet]); const focusRef = useRef(focus); focusRef.current = focus; const handleKey = (event: { key: string; isComposing?: boolean }): boolean => { if (event.isComposing) return false; if (event.key === 'Escape' || event.key === 'Tab') { onClose('key'); return true; } const at = focusRef.current; if (event.key === 'Enter' || event.key === ' ') { const item = rows[at.row]?.items[at.col]; if (item) onPick(item.key); return true; } const next = peekMove(rows, at, event.key); if (!next) return false; focusRef.current = next; setFocus(next); const cell = cellOf(next); cell?.focus({ preventScroll: true }); cell?.scrollIntoView?.({ block: 'nearest' }); return true; }; const handleKeyRef = useRef(handleKey); handleKeyRef.current = handleKey; useImperativeHandle(ref, () => ({ handleKey: (event) => handleKeyRef.current(event) }), []); const onKeyDown = (event: React.KeyboardEvent) => { if (!handleKey({ key: event.key, isComposing: event.nativeEvent.isComposing })) return; event.preventDefault(); event.stopPropagation(); }; const panel = (
{label} {sheet && ); if (!sheet) return panel; return (
); });