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

@@ -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>
);
}