fix(documents-ui): a11y, mobile, realtime lift, type-safety, UI polish
- A2: lift useRealtimeInvalidation to DocumentsHub (covers all 3 render modes) - B1-B4: aria-label, aria-pressed, aria-expanded, Lock SVG aria-hidden - C1-C7: Sheet wrap for mobile sidebar, border axis fix, 44x44 tap targets - Mobile Important: useMobileChrome title, FolderActionsMenu icon size, breadcrumb tap targets, signer email truncate - Type-safety: ENTITY_TYPES guard, AggregatedSection discriminated union - UI/UX: em-dash to colon in SigningDetailsDialog, Loading state normalize, StatusPill on HubRootView, view signing details as Button Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { ChevronDown, ChevronRight, FileText, Plus } from 'lucide-react';
|
||||
|
||||
@@ -14,6 +14,7 @@ import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { useDocumentFolders, type FolderNode } from '@/hooks/use-document-folders';
|
||||
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||
import { FolderActionsMenu } from './folder-actions-menu';
|
||||
import { FolderBreadcrumb } from './folder-breadcrumb';
|
||||
import { FolderTreeSidebar } from './folder-tree-sidebar';
|
||||
@@ -61,6 +62,15 @@ const SIGNER_STATUS_LABELS: Record<string, string> = {
|
||||
cancelled: 'Cancelled',
|
||||
};
|
||||
|
||||
// Runtime guard so we don't cast `entityType` from the FolderNode shape; if a
|
||||
// future system folder shape leaks an unexpected entity type the UI falls
|
||||
// back to FlatFolderListing instead of crashing on a bad route.
|
||||
const ENTITY_TYPES = new Set(['client', 'company', 'yacht'] as const);
|
||||
type EntityType = 'client' | 'company' | 'yacht';
|
||||
function isEntityType(v: unknown): v is EntityType {
|
||||
return typeof v === 'string' && ENTITY_TYPES.has(v as EntityType);
|
||||
}
|
||||
|
||||
interface DocumentsHubProps {
|
||||
portSlug: string;
|
||||
}
|
||||
@@ -82,14 +92,45 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) {
|
||||
|
||||
const { data: tree = [] } = useDocumentFolders();
|
||||
|
||||
// Realtime invalidation covers ALL three render modes (HubRootView,
|
||||
// EntityFolderView, FlatFolderListing) so navigating between modes
|
||||
// doesn't tear down the subscription. The hook-level eventKeysSig
|
||||
// dedup means the inline literal is safe across re-renders.
|
||||
useRealtimeInvalidation({
|
||||
'document:created': [['documents']],
|
||||
'document:updated': [['documents']],
|
||||
'document:deleted': [['documents']],
|
||||
'document:sent': [['documents']],
|
||||
'document:completed': [['documents'], ['files']],
|
||||
'document:expired': [['documents']],
|
||||
'document:cancelled': [['documents']],
|
||||
'document:rejected': [['documents']],
|
||||
'document:signer:signed': [['documents']],
|
||||
'file:created': [['files']],
|
||||
'file:updated': [['files']],
|
||||
'file:deleted': [['files']],
|
||||
'folder:created': [['document-folders']],
|
||||
'folder:updated': [['document-folders']],
|
||||
'folder:deleted': [['document-folders']],
|
||||
'folder:moved': [['document-folders']],
|
||||
});
|
||||
|
||||
const { setChrome } = useMobileChrome();
|
||||
useEffect(() => {
|
||||
setChrome({ title: 'Documents' });
|
||||
return () => setChrome({ title: null });
|
||||
}, [setChrome]);
|
||||
|
||||
const selectedFolder =
|
||||
typeof selectedFolderId === 'string' ? findInTree(tree, selectedFolderId) : null;
|
||||
|
||||
const folderEntityType = selectedFolder?.entityType;
|
||||
const isEntityFolder =
|
||||
selectedFolder?.systemManaged === true &&
|
||||
selectedFolder.entityType != null &&
|
||||
selectedFolder.entityType !== 'root' &&
|
||||
selectedFolder.entityId != null;
|
||||
folderEntityType != null &&
|
||||
folderEntityType !== 'root' &&
|
||||
selectedFolder.entityId != null &&
|
||||
isEntityType(folderEntityType);
|
||||
|
||||
const handleFolderSelect = (id: string | null | undefined) => {
|
||||
setSelectedFolderId(id);
|
||||
@@ -128,10 +169,10 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) {
|
||||
|
||||
{selectedFolderId === undefined ? (
|
||||
<HubRootView portSlug={portSlug} />
|
||||
) : isEntityFolder ? (
|
||||
) : isEntityFolder && isEntityType(folderEntityType) ? (
|
||||
<EntityFolderView
|
||||
portSlug={portSlug}
|
||||
entityType={selectedFolder!.entityType as 'client' | 'company' | 'yacht'}
|
||||
entityType={folderEntityType}
|
||||
entityId={selectedFolder!.entityId!}
|
||||
/>
|
||||
) : (
|
||||
@@ -176,17 +217,8 @@ function FlatFolderListing({ portSlug, folderId }: FlatFolderListingProps) {
|
||||
filterDefinitions: [],
|
||||
});
|
||||
|
||||
useRealtimeInvalidation({
|
||||
'document:created': [['documents']],
|
||||
'document:updated': [['documents']],
|
||||
'document:deleted': [['documents']],
|
||||
'document:sent': [['documents']],
|
||||
'document:completed': [['documents']],
|
||||
'document:expired': [['documents']],
|
||||
'document:cancelled': [['documents']],
|
||||
'document:rejected': [['documents']],
|
||||
'document:signer:signed': [['documents']],
|
||||
});
|
||||
// Realtime invalidation is lifted to DocumentsHub so it survives mode
|
||||
// switches (root / entity-folder / flat-folder). Don't re-subscribe here.
|
||||
|
||||
const renderRow = (doc: HubDoc) => {
|
||||
const expanded = expandedDocId === doc.id;
|
||||
@@ -209,9 +241,10 @@ function FlatFolderListing({ portSlug, folderId }: FlatFolderListingProps) {
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3 text-sm sm:grid sm:grid-cols-[auto_1fr_auto_auto_auto_auto] sm:gap-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={expanded ? 'Collapse signers' : 'Expand signers'}
|
||||
aria-label={`${expanded ? 'Collapse' : 'Expand'} signers for ${doc.title}`}
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpandedDocId(expanded ? null : doc.id)}
|
||||
className="text-muted-foreground transition-transform"
|
||||
className="flex min-h-[44px] min-w-[44px] items-center justify-center text-muted-foreground transition-transform"
|
||||
>
|
||||
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
@@ -263,6 +296,7 @@ function FlatFolderListing({ portSlug, folderId }: FlatFolderListingProps) {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
placeholder="Search by title..."
|
||||
aria-label="Search documents by title"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-xs h-9"
|
||||
@@ -274,8 +308,9 @@ function FlatFolderListing({ portSlug, folderId }: FlatFolderListingProps) {
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={typeFilter === undefined}
|
||||
className={cn(
|
||||
'rounded-full border px-2.5 py-0.5 text-xs',
|
||||
'inline-flex min-h-[44px] items-center rounded-full border px-3 py-2 text-xs',
|
||||
typeFilter === undefined ? 'bg-foreground text-background' : 'hover:bg-accent',
|
||||
)}
|
||||
onClick={() => setTypeFilter(undefined)}
|
||||
@@ -286,8 +321,9 @@ function FlatFolderListing({ portSlug, folderId }: FlatFolderListingProps) {
|
||||
<button
|
||||
type="button"
|
||||
key={t}
|
||||
aria-pressed={typeFilter === t}
|
||||
className={cn(
|
||||
'rounded-full border px-2.5 py-0.5 text-xs',
|
||||
'inline-flex min-h-[44px] items-center rounded-full border px-3 py-2 text-xs',
|
||||
typeFilter === t ? 'bg-foreground text-background' : 'hover:bg-accent',
|
||||
)}
|
||||
onClick={() => setTypeFilter(t)}
|
||||
|
||||
Reference in New Issue
Block a user