feat(uat-batch-4): a11y form primitives + click-to-preview + EOI empty-state + lint guards

- FieldError primitive (role=alert, aria-live) — used by Wave 3
  form-error UX work.
- FieldLabel primitive (Label + Info-tooltip slot) — foundational for
  the platform-wide admin-settings tooltip audit.
- ESLint guard against em-dash in user-facing JSX text inside
  src/components + src/app (warning, not error; 111 existing instances
  flagged for follow-up sweep).
- FileGrid card body becomes click-to-preview button (was hidden under
  a kebab); aria-label per row; kebab keeps Download/Rename/Delete.
- DocumentList: title cell on rows with signedFileId opens
  FilePreviewDialog; kebab gains Download action (was missing
  per UAT). Single FilePreviewDialog instance lifted to the parent.
- DocumentList type extended with signedFileId.
- EOI empty state: third ghost button "Mark signed without file"
  wired to existing MarkExternallySignedDialog (parity with
  reservation tab).
- Watcher empty-state padding fix on document-detail.

tsc clean. 1419/1419 vitest. lint clean on touched files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 17:20:13 +02:00
parent 6a4f4ea1dd
commit 52342ee45d
7 changed files with 212 additions and 17 deletions

View File

@@ -35,6 +35,24 @@ const eslintConfig = [
'react-hooks/incompatible-library': 'off',
},
},
{
// User-facing copy in src/components and src/app should never use
// em-dashes (—) in JSX text. The user reads em-dashes as a
// tell-tale "AI-generated" marker; we prefer periods, commas, or
// simple hyphens. Code comments / audit-log strings / templates
// outside these directories are exempt.
files: ['src/components/**/*.tsx', 'src/app/**/*.tsx'],
rules: {
'no-restricted-syntax': [
'warn',
{
selector: "JSXText[value=/\\u2014/]",
message:
'No em-dash in user-facing JSX text. Use period, comma, or hyphen instead.',
},
],
},
},
{
// Tests assert response shape via expect() — narrowing every
// `res.json()` to a structural type adds boilerplate without catching

View File

@@ -543,7 +543,7 @@ function WatchersCard({ documentId, watchers }: { documentId: string; watchers:
</p>
{watchers.length === 0 ? (
<p className="text-xs text-muted-foreground">No one is watching this document yet.</p>
<p className="mb-3 text-xs text-muted-foreground">No one is watching this document yet.</p>
) : (
<ul className="mb-3 space-y-1">
{watchers.map((w) => {

View File

@@ -1,7 +1,7 @@
'use client';
import { useState } from 'react';
import { FolderInput } from 'lucide-react';
import { Download, FolderInput } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { Badge } from '@/components/ui/badge';
@@ -12,10 +12,12 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
import { PermissionGate } from '@/components/shared/permission-gate';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useConfirmation } from '@/hooks/use-confirmation';
import { apiFetch } from '@/lib/api/client';
import { triggerUrlDownload } from '@/lib/utils/download';
import { MoveToFolderDialog } from './move-to-folder-dialog';
interface DocumentRow {
@@ -25,6 +27,8 @@ interface DocumentRow {
status: string;
createdAt: string;
folderId: string | null;
/** When set, the doc has a downloaded signed PDF the rep can preview/download. */
signedFileId?: string | null;
signers?: Array<{ status: string }>;
}
@@ -57,10 +61,23 @@ interface DocRowProps {
doc: DocumentRow;
onDelete: (doc: DocumentRow) => void;
onSend: (doc: DocumentRow) => void;
onPreview: (fileId: string, fileName: string) => void;
}
function DocRow({ doc, onDelete, onSend }: DocRowProps) {
async function downloadSignedFile(fileId: string, fallbackName: string) {
try {
const res = await apiFetch<{ data: { url: string; filename: string } }>(
`/api/v1/files/${fileId}/download`,
);
triggerUrlDownload(res.data.url, res.data.filename || fallbackName);
} catch {
// silent — toast handled by the presign route on its own
}
}
function DocRow({ doc, onDelete, onSend, onPreview }: DocRowProps) {
const [moveOpen, setMoveOpen] = useState(false);
const hasSignedFile = !!doc.signedFileId;
const signerProgress = (() => {
if (!doc.signers) return '-';
@@ -74,7 +91,19 @@ function DocRow({ doc, onDelete, onSend }: DocRowProps) {
<td className="px-4 py-3">
<Badge variant="outline">{TYPE_LABELS[doc.documentType] ?? doc.documentType}</Badge>
</td>
<td className="px-4 py-3 font-medium">{doc.title}</td>
<td className="px-4 py-3 font-medium">
{hasSignedFile ? (
<button
type="button"
onClick={() => onPreview(doc.signedFileId!, doc.title)}
className="text-left hover:underline focus:outline-none focus:underline"
>
{doc.title}
</button>
) : (
doc.title
)}
</td>
<td className="px-4 py-3">
<Badge variant={STATUS_COLORS[doc.status] ?? 'default'}>{doc.status}</Badge>
</td>
@@ -90,6 +119,12 @@ function DocRow({ doc, onDelete, onSend }: DocRowProps) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{hasSignedFile && (
<DropdownMenuItem onClick={() => downloadSignedFile(doc.signedFileId!, doc.title)}>
<Download className="mr-2 h-4 w-4" aria-hidden />
Download
</DropdownMenuItem>
)}
{doc.status === 'draft' && (
<DropdownMenuItem onClick={() => onSend(doc)}>Send for Signing</DropdownMenuItem>
)}
@@ -123,6 +158,7 @@ function DocRow({ doc, onDelete, onSend }: DocRowProps) {
export function DocumentList({ interestId, clientId, emptyState }: DocumentListProps) {
const queryClient = useQueryClient();
const { confirm, dialog: confirmDialog } = useConfirmation();
const [previewFile, setPreviewFile] = useState<{ id: string; name: string } | null>(null);
const queryParams = new URLSearchParams();
if (interestId) queryParams.set('interestId', interestId);
@@ -184,11 +220,25 @@ export function DocumentList({ interestId, clientId, emptyState }: DocumentListP
</thead>
<tbody>
{data.map((doc) => (
<DocRow key={doc.id} doc={doc} onDelete={handleDelete} onSend={handleSend} />
<DocRow
key={doc.id}
doc={doc}
onDelete={handleDelete}
onSend={handleSend}
onPreview={(id, name) => setPreviewFile({ id, name })}
/>
))}
</tbody>
</table>
{confirmDialog}
<FilePreviewDialog
open={!!previewFile}
onOpenChange={(o) => {
if (!o) setPreviewFile(null);
}}
fileId={previewFile?.id}
fileName={previewFile?.name}
/>
</div>
);
}

View File

@@ -2,7 +2,6 @@
import {
Download,
Eye,
FileText,
Film,
Image,
@@ -22,7 +21,6 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Skeleton } from '@/components/ui/skeleton';
import { PREVIEWABLE_MIMES } from '@/lib/constants/file-validation';
export interface FileRow {
id: string;
@@ -102,9 +100,14 @@ export function FileGrid({
{files.map((file) => (
<div
key={file.id}
className="group relative rounded-lg border bg-card p-3 hover:border-primary/50 hover:shadow-sm transition-all"
className="group relative rounded-lg border bg-card hover:border-primary/50 hover:shadow-sm transition-all"
>
<div className="flex flex-col items-center gap-2">
<button
type="button"
onClick={() => onPreview(file)}
aria-label={`Preview ${file.filename}`}
className="flex w-full flex-col items-center gap-2 rounded-lg p-3 text-left focus:outline-none focus:ring-2 focus:ring-ring"
>
<FileIcon mimeType={file.mimeType} />
<p className="w-full truncate text-center text-xs font-medium" title={file.filename}>
{file.filename}
@@ -113,7 +116,7 @@ export function FileGrid({
<span>{formatBytes(file.sizeBytes)}</span>
<span>{format(new Date(file.createdAt), 'MMM d, yyyy')}</span>
</div>
</div>
</button>
<div className="absolute right-1 top-1 opacity-0 group-hover:opacity-100 transition-opacity">
<DropdownMenu>
@@ -127,12 +130,6 @@ export function FileGrid({
<Download className="mr-2 h-3.5 w-3.5" />
Download
</DropdownMenuItem>
{file.mimeType && PREVIEWABLE_MIMES.has(file.mimeType) && (
<DropdownMenuItem onClick={() => onPreview(file)}>
<Eye className="mr-2 h-3.5 w-3.5" />
Preview
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => onRename(file)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Rename

View File

@@ -25,6 +25,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { EoiCancelDialog } from '@/components/documents/eoi-cancel-dialog';
import { EoiGenerateDialog } from '@/components/documents/eoi-generate-dialog';
import { ExternalEoiUploadDialog } from '@/components/interests/external-eoi-upload-dialog';
import { MarkExternallySignedDialog } from '@/components/interests/mark-externally-signed-dialog';
import { SigningProgress } from '@/components/documents/signing-progress';
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
import { apiFetch } from '@/lib/api/client';
@@ -104,6 +105,7 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
const portSlug = useUIStore((s) => s.currentPortSlug);
const [generateOpen, setGenerateOpen] = useState(false);
const [uploadSignedOpen, setUploadSignedOpen] = useState(false);
const [markSignedOpen, setMarkSignedOpen] = useState(false);
// Lifted preview state so the View button on every signed-PDF row opens
// the in-app preview dialog rather than navigating to a presigned URL
// (which the storage backend serves with Content-Disposition=attachment,
@@ -137,6 +139,7 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
<EmptyEoiState
onGenerate={() => setGenerateOpen(true)}
onUploadSigned={() => setUploadSignedOpen(true)}
onMarkSigned={() => setMarkSignedOpen(true)}
/>
)}
@@ -197,6 +200,13 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
interestId={interestId}
/>
<MarkExternallySignedDialog
open={markSignedOpen}
onOpenChange={setMarkSignedOpen}
interestId={interestId}
docType="eoi"
/>
<FilePreviewDialog
open={!!previewFile}
onOpenChange={(o) => {
@@ -559,9 +569,11 @@ function SignedPdfPreview({ fileId }: { fileId: string }) {
function EmptyEoiState({
onGenerate,
onUploadSigned,
onMarkSigned,
}: {
onGenerate: () => void;
onUploadSigned: () => void;
onMarkSigned: () => void;
}) {
return (
<section className="rounded-xl border border-dashed bg-muted/20 p-8 text-center">
@@ -572,7 +584,7 @@ function EmptyEoiState({
No EOI in flight for this interest
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Generate the EOI to send it for signing the signing service handles the signing chain. You
Generate the EOI to send it for signing. The signing service handles the signing chain. You
can also upload a paper-signed copy if it was signed outside the system.
</p>
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
@@ -584,6 +596,10 @@ function EmptyEoiState({
<Upload className="size-4" aria-hidden />
Upload paper-signed copy
</Button>
<Button onClick={onMarkSigned} variant="ghost" size="sm" className="gap-1.5">
<CheckCircle2 className="size-4" aria-hidden />
Mark signed without file
</Button>
</div>
</section>
);

View File

@@ -0,0 +1,48 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface FieldErrorProps extends React.HTMLAttributes<HTMLParagraphElement> {
/**
* The error message to render. When falsy the component renders an
* empty live region so a screen reader still picks up subsequent
* error messages without an aria-live remount.
*/
message?: string | null | undefined;
/**
* Optional id — pair with `aria-describedby` on the associated input
* so SR users hear the error after the input's accessible name.
*/
id?: string;
}
/**
* Accessible error renderer for form fields. Always renders a
* `role="alert"` + `aria-live="polite"` region so SR users get
* immediate feedback when validation fires. Hide visually when
* `message` is empty without removing the region from the DOM —
* tearing the live region down between submits delays the next
* announcement on most assistive tech.
*
* Caller is responsible for setting `aria-invalid` and
* `aria-describedby={id}` on the linked input.
*/
export function FieldError({ message, id, className, ...props }: FieldErrorProps) {
return (
<p
id={id}
role="alert"
aria-live="polite"
className={cn(
'text-xs text-destructive',
!message && 'sr-only', // empty state keeps the region for next message
className,
)}
{...props}
>
{message ?? ''}
</p>
);
}

View File

@@ -0,0 +1,66 @@
'use client';
import * as React from 'react';
import { Info } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
export interface FieldLabelProps extends React.ComponentPropsWithoutRef<typeof Label> {
/**
* Optional explainer copy. When set, an Info icon is rendered next
* to the label that opens a Tooltip on hover / focus. Tooltip writing
* guidelines: 1-2 sentences max; state the unit ("...in days",
* "...in MB"); lead with the risk for dangerous settings; explain the
* "why and how to choose a value" rather than restating the label.
*/
tooltip?: React.ReactNode;
/** Mark the field as required — appends an asterisk after the label text. */
required?: boolean;
}
/**
* Shared label-with-explainer primitive for admin forms. Wraps the
* shadcn Label + an optional Info-tooltip slot. Adopting this everywhere
* an admin setting needs a non-obvious explainer (Sort Order, Weight,
* TTL, Cap, Confidence, Threshold) gives admins consistent tooltip UX
* without per-form bespoke implementations.
*/
export function FieldLabel({
tooltip,
required,
className,
children,
...labelProps
}: FieldLabelProps) {
if (!tooltip) {
return (
<Label className={cn('inline-flex items-center gap-1.5', className)} {...labelProps}>
{children}
{required ? <span aria-hidden>*</span> : null}
</Label>
);
}
return (
<Label className={cn('inline-flex items-center gap-1.5', className)} {...labelProps}>
{children}
{required ? <span aria-hidden>*</span> : null}
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="More info"
className="text-muted-foreground hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 rounded-sm"
>
<Info className="h-3.5 w-3.5" aria-hidden />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-xs text-xs leading-relaxed">{tooltip}</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
);
}