- {label}
-
- )}
- triggerAriaLabel={`Sort by ${label}`}
- triggerTitle={`Sort by ${label}`}
- placeholder="Select sort field"
- menuMinWidth={200}
- align="start"
- positionStrategy="absolute"
- />
- );
-};
-
-export const createDocumentsTableHeaderActions = ({
- viewMode,
- onViewModeChange,
- onRefresh,
- onShowDeskHelp = null,
- sortField = 'title',
- onSortFieldChange = null,
- sortDirection = 'asc',
- onSortDirectionToggle = null,
- isFilterActive = false,
- includeDescendants = true,
- onToggleIncludeDescendants = null,
-}) => {
- const isListView = viewMode === 'list';
- const isGridView = viewMode === 'grid';
- const isDeskView = viewMode === 'desk';
-
- const sortDirectionIsDesc = sortDirection === 'desc';
- const sortDirectionTitle = sortDirectionIsDesc
- ? 'Sorting Z → A. Click to switch to ascending.'
- : 'Sorting A → Z. Click to switch to descending.';
- const includeDescendantsToggle = isFilterActive && typeof onToggleIncludeDescendants === 'function'
- ? (
-
- )
- : null;
-
- const sortControls = typeof onSortFieldChange === 'function'
- ? (
-
-
- {typeof onSortDirectionToggle === 'function' ? (
-
- ) : null}
-
- )
- : null;
-
- return (
- <>
- {isDeskView && typeof onShowDeskHelp === 'function' ? (
- <>
-
-
-
-
- >
- ) : null}
- {includeDescendantsToggle ? (
- <>
- {includeDescendantsToggle}
-
-
-
- >
- ) : null}
- {sortControls ? (
- <>
- {sortControls}
-
-
-
- >
- ) : null}
-
-
-
-
-
-
-
-
-
- >
- );
-};
-
-export const createDocumentsSurface = ({
- tableProps,
- parentBreadcrumb,
- onNavigateParent,
- renderSidebarToggle,
- detailProps,
- detailOpen = false,
-}) => {
- const {
- currentFolderName,
- breadcrumbs,
- searchResults,
- isFilterActive,
- viewMode,
- onViewModeChange,
- onRefresh,
- sortField,
- sortDirection,
- onSortFieldChange,
- onSortDirectionToggle,
- selectedDocumentIds,
- selectedFolderIds,
- onDeleteSelection,
- onClearSelection,
- tags,
- correspondents,
- documentLookup,
- tagLookupById,
- onBulkTagAdd,
- onBulkTagRemove,
- onBulkCorrespondentAdd,
- onBulkCorrespondentRemove,
- onBulkReanalyze,
- folderOptions,
- onMoveDocumentsToFolder,
- searchIncludeDescendants,
- onToggleSearchIncludeDescendants,
- onInspectDocument,
- } = tableProps;
-
- const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
- const subtitle = Array.isArray(searchResults)
- ? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}`
- : null;
-
-
- const documentSelectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0;
- const folderSelectionCount = Array.isArray(selectedFolderIds) ? selectedFolderIds.length : 0;
- const selectionCount = documentSelectionCount + folderSelectionCount;
-
- const actions = createDocumentsTableHeaderActions({
- viewMode,
- onViewModeChange,
- onRefresh,
- sortField,
- onSortFieldChange,
- sortDirection,
- onSortDirectionToggle,
- isFilterActive,
- includeDescendants: searchIncludeDescendants,
- onToggleIncludeDescendants: onToggleSearchIncludeDescendants,
- });
-
- const floatingActions = selectionCount > 0
- ? (
-
- )
- : null;
-
-
- const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
- const detail = detailOpen && detailProps ? : null;
-
- const surfaceConfig = createWorkspaceSurfaceConfig({
- key: 'documents',
- variant: 'documents',
- title,
- subtitle,
- sidebarToggle,
- parentBreadcrumb,
- onNavigateParent,
- actions,
- breadcrumbs,
- selectionLabel: null,
- floatingActions,
- content: (
-
- ),
- detail,
- });
-
- return surfaceConfig;
-};
+export { default } from './panel/DocumentsPanel';
+export { default as createDocumentsSurface } from './panel/createDocumentsSurface';
+export { createDocumentsTableHeaderActions } from './panel/DocumentsToolbar';
diff --git a/frontend/src/documents/panel/DocumentsPanel.jsx b/frontend/src/documents/panel/DocumentsPanel.jsx
new file mode 100644
index 0000000..a5d307b
--- /dev/null
+++ b/frontend/src/documents/panel/DocumentsPanel.jsx
@@ -0,0 +1,725 @@
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { ViewListIcon, ViewGridIcon, IconFileStack } from '../../ui/icons';
+import BreadcrumbTrail from '../../ui/BreadcrumbTrail';
+import DocumentsGrid from '../DocumentsGrid';
+import DocumentsList from '../DocumentsList';
+import { isTagTransferEvent } from '../tagTransfer';
+import PreviewZoomOverlay from '../../detail/PreviewZoomOverlay';
+import { useAssetNavigator } from '../../hooks/useAssetNavigator';
+import { isPointerModifierEvent, isPrimaryPointerEvent } from '../useEntryPointer';
+
+const DEFAULT_GRID_ICON_SIZE = 144;
+
+const EntryType = {
+ folder: 'folder',
+ document: 'document',
+};
+
+const DocumentsPanel = ({
+ currentFolderName,
+ breadcrumbs,
+ onRefresh,
+ subfolders,
+ documents,
+ searchResults,
+ isFilterActive = false,
+ onFolderSelect,
+ onFolderDrop,
+ onFolderDragOver,
+ onFolderDragLeave,
+ onFolderDragStart,
+ onFolderDragEnd,
+ draggedFolderId,
+ onFolderRename,
+ selectedFolderIds = [],
+ selectedDocumentIds = [],
+ focusedRowKey,
+ draggingDocumentIds = [],
+ onDocumentDragStart,
+ onDocumentDragEnd,
+ onDocumentRename,
+ onEntryPointer = null,
+ onEntrySelection = null,
+ onInspectDocument = null,
+ tagLookupById,
+ activeCorrespondentIds = [],
+ onFocusedRowChange,
+ ensureAssetUrl = null,
+ getDocumentAsset = () => null,
+ onTagClick,
+ onCorrespondentClick,
+ isSearchLoading = false,
+ onDocumentTagDrop,
+ viewMode = 'list',
+ onViewModeChange,
+ onClearSelection,
+ selectedEntries = [],
+ showHeader = true,
+}) => {
+ const showingSearchResults = searchResults !== null;
+ const rows = showingSearchResults ? searchResults : documents;
+
+ const entries = useMemo(() => {
+ const list = [];
+ if (!showingSearchResults) {
+ subfolders.forEach((folder) => {
+ if (!folder || !folder.id) {
+ return;
+ }
+ list.push({ type: EntryType.folder, id: folder.id, key: `folder:${folder.id}`, folder });
+ });
+ }
+ rows.forEach((doc) => {
+ if (!doc || !doc.id) {
+ return;
+ }
+ list.push({ type: EntryType.document, id: doc.id, key: `document:${doc.id}`, document: doc });
+ });
+ return list;
+ }, [showingSearchResults, subfolders, rows]);
+
+ const selectedSet = useMemo(
+ () => new Set(selectedDocumentIds),
+ [selectedDocumentIds],
+ );
+ const selectedFolderSet = useMemo(
+ () => new Set(selectedFolderIds || []),
+ [selectedFolderIds],
+ );
+ const draggingSet = useMemo(
+ () => new Set(draggingDocumentIds || []),
+ [draggingDocumentIds],
+ );
+ const activeCorrespondentIdSet = useMemo(
+ () => new Set(activeCorrespondentIds || []),
+ [activeCorrespondentIds],
+ );
+ const scrollRef = useRef(null);
+ const suppressDocumentClickRef = useRef(false);
+ const [, forceVisibilityTick] = useState(0);
+ const lastScrollNodeRef = useRef(null);
+ const assignScrollRef = useCallback((node) => {
+ if (lastScrollNodeRef.current === node) {
+ return;
+ }
+ lastScrollNodeRef.current = node;
+ scrollRef.current = node;
+ if (node) {
+ forceVisibilityTick((value) => value + 1);
+ }
+ }, []);
+ const isGridView = viewMode === 'grid';
+ const isDeskView = viewMode === 'desk';
+
+ const [previewDocId, setPreviewDocId] = useState(null);
+
+ const previewDoc = useMemo(() => {
+ if (!previewDocId) {
+ return null;
+ }
+ return rows.find((doc) => doc?.id === previewDocId) || null;
+ }, [previewDocId, rows]);
+
+ useEffect(() => {
+ if (previewDocId && !previewDoc) {
+ setPreviewDocId(null);
+ }
+ }, [previewDocId, previewDoc]);
+
+ const previewNavigator = useAssetNavigator({
+ document: previewDoc,
+ assetType: 'preview',
+ ensureAssetUrl,
+ getAsset: getDocumentAsset,
+ prefetch: 3,
+ });
+
+ const {
+ currentUrl: previewUrl,
+ canGoPrev: previewCanGoPrev,
+ canGoNext: previewCanGoNext,
+ goPrev: previewGoPrev,
+ goNext: previewGoNext,
+ } = previewNavigator;
+
+ const previewDisplay = useMemo(() => {
+ if (!previewDoc || !previewUrl) {
+ return null;
+ }
+ return {
+ url: previewUrl,
+ alt: previewDoc.title,
+ canGoPrev: Boolean(previewCanGoPrev),
+ canGoNext: Boolean(previewCanGoNext),
+ goPrev: previewGoPrev,
+ goNext: previewGoNext,
+ };
+ }, [previewDoc, previewUrl, previewCanGoPrev, previewCanGoNext, previewGoPrev, previewGoNext]);
+
+ const closePreviewOverlay = useCallback(() => {
+ setPreviewDocId(null);
+ }, []);
+
+ const handleDocumentPreviewZoom = useCallback(
+ (doc) => {
+ if (!doc || !doc.id) {
+ return;
+ }
+ const previewAsset = typeof getDocumentAsset === 'function' ? getDocumentAsset(doc, 'preview') : null;
+ if (!previewAsset) {
+ return;
+ }
+ setPreviewDocId(doc.id);
+ },
+ [getDocumentAsset],
+ );
+
+ const handleDocumentActivate = useCallback(
+ (doc, event) => {
+ if (!doc) {
+ return;
+ }
+ if (event) {
+ if (typeof event.preventDefault === 'function') {
+ event.preventDefault();
+ }
+ if (typeof event.stopPropagation === 'function') {
+ event.stopPropagation();
+ }
+ }
+ if (event?.altKey) {
+ handleDocumentPreviewZoom(doc);
+ return;
+ }
+ onInspectDocument?.(doc.id, event);
+ },
+ [handleDocumentPreviewZoom, onInspectDocument],
+ );
+
+ const selectedRowKeySet = useMemo(() => new Set(selectedEntries || []), [selectedEntries]);
+ const navigableRows = useMemo(
+ () => entries.map((entry) => ({ key: entry.key, type: entry.type, id: entry.id })),
+ [entries],
+ );
+ const navigableRowKeys = useMemo(() => navigableRows.map((row) => row.key), [navigableRows]);
+
+ const getEntryByKey = useCallback(
+ (rowKey) => entries.find((entry) => entry.key === rowKey) || null,
+ [entries],
+ );
+
+ const handlePanelFocus = useCallback(() => {
+ let resolvedKey = null;
+
+ if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) {
+ resolvedKey = focusedRowKey;
+ }
+
+ if (!resolvedKey) {
+ for (let index = selectedEntries.length - 1; index >= 0; index -= 1) {
+ const candidate = selectedEntries[index];
+ if (navigableRowKeys.includes(candidate)) {
+ resolvedKey = candidate;
+ break;
+ }
+ }
+ }
+
+ if (!resolvedKey && navigableRows.length) {
+ resolvedKey = navigableRows[0].key;
+ }
+
+ if (!resolvedKey) {
+ return;
+ }
+
+ onFocusedRowChange?.(resolvedKey);
+
+ if (!selectedRowKeySet.has(resolvedKey) && typeof onEntrySelection === 'function') {
+ onEntrySelection(resolvedKey, {
+ shiftKey: false,
+ preventDefault: () => {},
+ });
+ }
+ }, [
+ focusedRowKey,
+ navigableRowKeys,
+ navigableRows,
+ onEntrySelection,
+ onFocusedRowChange,
+ selectedEntries,
+ selectedRowKeySet,
+ ]);
+
+ const handlePanelKeyDown = useCallback(
+ (event) => {
+ const { key, shiftKey } = event;
+ const triggers = ['ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', ' ', 'Space', 'Spacebar'];
+ if (!triggers.includes(key)) {
+ return;
+ }
+
+ if (!navigableRows.length) {
+ return;
+ }
+
+ event.preventDefault();
+
+ let activeKey =
+ focusedRowKey && navigableRowKeys.includes(focusedRowKey)
+ ? focusedRowKey
+ : null;
+
+ if (!activeKey) {
+ if (selectedEntries.length) {
+ for (let index = selectedEntries.length - 1; index >= 0; index -= 1) {
+ const candidate = selectedEntries[index];
+ if (navigableRowKeys.includes(candidate)) {
+ activeKey = candidate;
+ break;
+ }
+ }
+ }
+
+ if (!activeKey) {
+ activeKey = key === 'ArrowUp' ? navigableRowKeys[navigableRowKeys.length - 1] : navigableRowKeys[0];
+ }
+ }
+
+ const currentIndex = navigableRowKeys.indexOf(activeKey);
+ const activeRow = currentIndex === -1 ? null : navigableRows[currentIndex];
+
+ if (key === 'Enter' || key === ' ' || key === 'Space' || key === 'Spacebar') {
+ if (activeRow) {
+ onEntrySelection?.(activeRow.key, event);
+ if (activeRow.type === EntryType.folder) {
+ onFolderSelect?.(activeRow.id);
+ } else {
+ const entry = getEntryByKey(activeRow.key);
+ if (entry?.document) {
+ handleDocumentPreviewZoom(entry.document);
+ }
+ }
+ }
+ return;
+ }
+
+ let nextIndex = currentIndex;
+ if (key === 'ArrowDown') {
+ nextIndex = currentIndex === -1 ? 0 : Math.min(currentIndex + 1, navigableRows.length - 1);
+ } else if (key === 'ArrowUp') {
+ nextIndex = currentIndex === -1 ? navigableRows.length - 1 : Math.max(currentIndex - 1, 0);
+ } else if (key === 'Home') {
+ nextIndex = 0;
+ } else if (key === 'End') {
+ nextIndex = navigableRows.length - 1;
+ }
+
+ if (nextIndex === -1 || nextIndex >= navigableRows.length) {
+ return;
+ }
+
+ const targetRow = navigableRows[nextIndex];
+ if (!targetRow) {
+ return;
+ }
+
+ onFocusedRowChange?.(targetRow.key);
+ onEntrySelection?.(targetRow.key, {
+ shiftKey,
+ preventDefault: () => {},
+ });
+ },
+ [
+ focusedRowKey,
+ getEntryByKey,
+ navigableRowKeys,
+ navigableRows,
+ onEntrySelection,
+ onFocusedRowChange,
+ onFolderSelect,
+ selectedEntries,
+ handleDocumentPreviewZoom,
+ ],
+ );
+
+ const isListView = viewMode === 'list';
+ const gridIconSize = DEFAULT_GRID_ICON_SIZE;
+ const handleSetViewMode = useCallback(
+ (nextMode) => {
+ if (!onViewModeChange) {
+ return;
+ }
+ onViewModeChange(nextMode);
+ if (scrollRef.current) {
+ scrollRef.current.scrollTop = 0;
+ }
+ },
+ [onViewModeChange],
+ );
+ useEffect(() => {
+ if (scrollRef.current) {
+ scrollRef.current.scrollTop = 0;
+ }
+ }, [viewMode]);
+ const isTagDragEvent = useCallback((event) => isTagTransferEvent(event), []);
+ const ensureFocusedRowVisible = useCallback(() => {
+ if (!focusedRowKey) return;
+ const container = scrollRef.current;
+ if (!container) return;
+ let selector = null;
+ if (focusedRowKey.startsWith('document:')) {
+ selector = `#document-row-${focusedRowKey.slice('document:'.length)}`;
+ } else if (focusedRowKey.startsWith('folder:')) {
+ selector = `#folder-row-${focusedRowKey.slice('folder:'.length)}`;
+ }
+ if (!selector) {
+ return;
+ }
+ const row = container.querySelector(selector);
+ if (!row || !container.contains(row)) {
+ return;
+ }
+
+ const header = container.querySelector('thead');
+ const headerHeight = header ? header.getBoundingClientRect().height : 0;
+ const rowTop = row.offsetTop;
+ const rowBottom = rowTop + row.offsetHeight;
+ const visibleTop = container.scrollTop + headerHeight;
+ const visibleBottom = container.scrollTop + container.clientHeight;
+
+ if (rowTop < visibleTop) {
+ container.scrollTop = Math.max(rowTop - headerHeight, 0);
+ return;
+ }
+
+ if (rowBottom > visibleBottom) {
+ const nextScrollTop = rowBottom - container.clientHeight;
+ container.scrollTop = Math.max(nextScrollTop, 0);
+ }
+ }, [focusedRowKey]);
+
+ useEffect(() => {
+ ensureFocusedRowVisible();
+ }, [ensureFocusedRowVisible]);
+
+ const activeDescendantId = useMemo(() => {
+ if (!focusedRowKey) return undefined;
+ if (focusedRowKey.startsWith('document:')) {
+ return `document-row-${focusedRowKey.slice('document:'.length)}`;
+ }
+ if (focusedRowKey.startsWith('folder:')) {
+ return `folder-row-${focusedRowKey.slice('folder:'.length)}`;
+ }
+ return undefined;
+ }, [focusedRowKey]);
+
+ const handleDocumentTagDragOver = useCallback(
+ (event) => {
+ if (!isTagDragEvent(event)) {
+ return;
+ }
+ event.preventDefault();
+ event.dataTransfer.dropEffect = 'copy';
+ event.currentTarget.classList.add('tag-drop-target');
+ },
+ [isTagDragEvent],
+ );
+
+ const handleDocumentTagDragLeave = useCallback(
+ (event) => {
+ if (!isTagDragEvent(event)) {
+ return;
+ }
+ if (event.relatedTarget && event.currentTarget.contains(event.relatedTarget)) {
+ return;
+ }
+ event.currentTarget.classList.remove('tag-drop-target');
+ },
+ [isTagDragEvent],
+ );
+
+ const handleDocumentTagDrop = useCallback(
+ (event, documentId) => {
+ if (!isTagDragEvent(event)) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ event.currentTarget.classList.remove('tag-drop-target');
+ const payload =
+ event.dataTransfer.getData('application/x-papercrate-tag') ||
+ event.dataTransfer.getData('text/papercrate-tag');
+ if (!payload) {
+ return;
+ }
+ try {
+ const parsed = JSON.parse(payload);
+ if (parsed?.id && onDocumentTagDrop) {
+ onDocumentTagDrop(documentId, parsed);
+ }
+ } catch (error) {
+ console.warn('[documents] Failed to parse tag drop payload', error);
+ }
+ },
+ [isTagDragEvent, onDocumentTagDrop],
+ );
+
+ const handleDocumentClick = useCallback(
+ (doc, event) => {
+ if (!doc || suppressDocumentClickRef.current) {
+ return;
+ }
+
+ if (typeof onEntryPointer === 'function') {
+ onEntryPointer(
+ { type: EntryType.document, id: doc.id, key: `document:${doc.id}`, document: doc },
+ event,
+ );
+ }
+ },
+ [onEntryPointer],
+ );
+
+ const handleFolderClick = useCallback(
+ (folder, event) => {
+ if (!folder) {
+ return;
+ }
+
+ if (typeof onEntryPointer === 'function') {
+ onEntryPointer(
+ { type: EntryType.folder, id: folder.id, key: `folder:${folder.id}`, folder },
+ event,
+ );
+ }
+
+ if (
+ !isPointerModifierEvent(event)
+ && isPrimaryPointerEvent(event)
+ && scrollRef.current
+ ) {
+ scrollRef.current.focus({ preventScroll: true });
+ onFocusedRowChange?.(`folder:${folder.id}`);
+ }
+ },
+ [onEntryPointer, onFocusedRowChange],
+ );
+
+ const handleDocumentDragStartLocal = useCallback(
+ (event, doc) => {
+ suppressDocumentClickRef.current = true;
+ onDocumentDragStart?.(event, doc);
+ },
+ [onDocumentDragStart],
+ );
+
+ const handleDocumentDragEndLocal = useCallback(
+ (event) => {
+ onDocumentDragEnd?.(event);
+ requestAnimationFrame(() => {
+ suppressDocumentClickRef.current = false;
+ });
+ },
+ [onDocumentDragEnd],
+ );
+
+ const hasDocumentEntries = useMemo(
+ () => entries.some((entry) => entry.type === EntryType.document),
+ [entries],
+ );
+ const showTableRows = entries.length > 0;
+ const showDefaultEmptyState = !showingSearchResults && !isFilterActive && entries.length === 0;
+ const showListSearchEmptyState = showingSearchResults && !hasDocumentEntries && !isGridView && !isSearchLoading;
+ const showGridSearchEmptyState = isGridView && showingSearchResults && !hasDocumentEntries && !isSearchLoading;
+ const breadcrumbEntries = useMemo(() => (Array.isArray(breadcrumbs) ? breadcrumbs.filter(Boolean) : []), [breadcrumbs]);
+ const trailEntries = useMemo(() => {
+ if (!breadcrumbEntries.length) {
+ return [{ id: 'current-folder', label: currentFolderName }];
+ }
+ const lastIndex = breadcrumbEntries.length - 1;
+ return breadcrumbEntries.map((crumb, index) => ({
+ id: crumb.id ?? index,
+ label: crumb.name ?? crumb.label ?? crumb.title ?? '',
+ onClick: index < lastIndex && onFolderSelect
+ ? () => onFolderSelect(crumb.id)
+ : null,
+ }));
+ }, [breadcrumbEntries, currentFolderName, onFolderSelect]);
+
+ return (
+ <>
+
+ {showHeader ? (
+
+
+
+
+
+ {showingSearchResults && (
+
Search results
+ )}
+
+
+
+
+
+
+
+
+
+
+ ) : null}
+ {showDefaultEmptyState ? (
+
+
+ Drop files anywhere or onto a folder to upload documents.
+
+
+ ) : showGridSearchEmptyState ? (
+
+
+ No documents match the current filters.
+
+
+ ) : showListSearchEmptyState ? (
+
+
No documents match the current filters.
+
+ ) : (
+
+
{
+ if (event.target === scrollRef.current) {
+ handlePanelFocus();
+ }
+ }}
+ onKeyDown={(event) => {
+ if (event.target !== scrollRef.current) {
+ return;
+ }
+ handlePanelKeyDown(event);
+ }}
+ onClick={(event) => {
+ if (event.target === event.currentTarget) {
+ onClearSelection?.();
+ }
+ }}
+ aria-activedescendant={isGridView ? undefined : activeDescendantId}
+ >
+ {isGridView ? (
+
+ ) : !showTableRows ? null : (
+
+ )}
+
+
+ )}
+
+
+ >
+ );
+};
+
+export default DocumentsPanel;
diff --git a/frontend/src/documents/panel/DocumentsToolbar.jsx b/frontend/src/documents/panel/DocumentsToolbar.jsx
new file mode 100644
index 0000000..e593be0
--- /dev/null
+++ b/frontend/src/documents/panel/DocumentsToolbar.jsx
@@ -0,0 +1,158 @@
+import React from 'react';
+import {
+ ViewListIcon,
+ ViewGridIcon,
+ IconFileStack,
+ RefreshIcon,
+ MinusVerticalIcon,
+ InfoIcon,
+ FoldersIcon,
+ FoldersOffIcon,
+ SortAscendingLettersIcon,
+ SortDescendingLettersIcon,
+} from '../../ui/icons';
+import SortFieldQuickMenu from './SortFieldQuickMenu';
+
+export const createDocumentsTableHeaderActions = ({
+ viewMode,
+ onViewModeChange,
+ onRefresh,
+ onShowDeskHelp = null,
+ sortField = 'title',
+ onSortFieldChange = null,
+ sortDirection = 'asc',
+ onSortDirectionToggle = null,
+ isFilterActive = false,
+ includeDescendants = true,
+ onToggleIncludeDescendants = null,
+}) => {
+ const isListView = viewMode === 'list';
+ const isGridView = viewMode === 'grid';
+ const isDeskView = viewMode === 'desk';
+
+ const sortDirectionIsDesc = sortDirection === 'desc';
+ const sortDirectionTitle = sortDirectionIsDesc
+ ? 'Sorting Z → A. Click to switch to ascending.'
+ : 'Sorting A → Z. Click to switch to descending.';
+
+ const includeDescendantsToggle = isFilterActive && typeof onToggleIncludeDescendants === 'function'
+ ? (
+
+ )
+ : null;
+
+ const sortControls = typeof onSortFieldChange === 'function'
+ ? (
+
+
+ {typeof onSortDirectionToggle === 'function' ? (
+
+ ) : null}
+
+ )
+ : null;
+
+ return (
+ <>
+ {isDeskView && typeof onShowDeskHelp === 'function' ? (
+ <>
+
+
+
+
+ >
+ ) : null}
+ {includeDescendantsToggle ? (
+ <>
+ {includeDescendantsToggle}
+
+
+
+ >
+ ) : null}
+ {sortControls ? (
+ <>
+ {sortControls}
+
+
+
+ >
+ ) : null}
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default createDocumentsTableHeaderActions;
diff --git a/frontend/src/documents/panel/SortFieldQuickMenu.jsx b/frontend/src/documents/panel/SortFieldQuickMenu.jsx
new file mode 100644
index 0000000..35d5631
--- /dev/null
+++ b/frontend/src/documents/panel/SortFieldQuickMenu.jsx
@@ -0,0 +1,64 @@
+import React, { useCallback, useMemo } from 'react';
+import QuickAddMenu from '../../ui/QuickAddMenu';
+
+const SORT_OPTIONS = [
+ { value: 'title', label: 'Title' },
+ { value: 'issued_at', label: 'Issued date' },
+ { value: 'created_at', label: 'Added' },
+ { value: 'updated_at', label: 'Updated date' },
+];
+
+const SORT_LABEL_LOOKUP = SORT_OPTIONS.reduce((acc, option) => {
+ const next = acc;
+ next[option.value] = option.label;
+ return next;
+}, {});
+
+const SortFieldQuickMenu = ({ sortField, onChange }) => {
+ const currentOption = useMemo(
+ () => SORT_OPTIONS.find((option) => option.value === sortField) || SORT_OPTIONS[0],
+ [sortField],
+ );
+
+ const options = useMemo(
+ () => SORT_OPTIONS.map((option) => ({ id: option.value, label: option.label })),
+ [],
+ );
+
+ const handleSelect = useCallback(
+ (value, option) => {
+ if (typeof onChange !== 'function') {
+ return;
+ }
+ const nextValue = option?.id || option?.original?.id || value;
+ if (nextValue) {
+ onChange(nextValue);
+ }
+ },
+ [onChange],
+ );
+
+ const label = currentOption?.label || SORT_LABEL_LOOKUP[currentOption?.value] || 'Title';
+
+ return (
+
+ {label}
+
+ )}
+ triggerAriaLabel={`Sort by ${label}`}
+ triggerTitle={`Sort by ${label}`}
+ placeholder="Select sort field"
+ menuMinWidth={200}
+ align="start"
+ positionStrategy="absolute"
+ />
+ );
+};
+
+export default SortFieldQuickMenu;
diff --git a/frontend/src/documents/panel/createDocumentsSurface.js b/frontend/src/documents/panel/createDocumentsSurface.js
new file mode 100644
index 0000000..1124db1
--- /dev/null
+++ b/frontend/src/documents/panel/createDocumentsSurface.js
@@ -0,0 +1,119 @@
+import React from 'react';
+import DetailPanel from '../../detail/DetailPanel';
+import SelectionFloatingActions from '../SelectionFloatingActions';
+import createWorkspaceSurfaceConfig from '../workspaceHeader';
+import DocumentsPanel from './DocumentsPanel';
+import { createDocumentsTableHeaderActions } from './DocumentsToolbar';
+
+const createDocumentsSurface = ({
+ tableProps,
+ parentBreadcrumb,
+ onNavigateParent,
+ renderSidebarToggle,
+ detailProps,
+ detailOpen = false,
+}) => {
+ const {
+ currentFolderName,
+ breadcrumbs,
+ searchResults,
+ isFilterActive,
+ viewMode,
+ onViewModeChange,
+ onRefresh,
+ sortField,
+ sortDirection,
+ onSortFieldChange,
+ onSortDirectionToggle,
+ selectedDocumentIds,
+ selectedFolderIds,
+ onDeleteSelection,
+ onClearSelection,
+ tags,
+ correspondents,
+ documentLookup,
+ tagLookupById,
+ onBulkTagAdd,
+ onBulkTagRemove,
+ onBulkCorrespondentAdd,
+ onBulkCorrespondentRemove,
+ onBulkReanalyze,
+ folderOptions,
+ onMoveDocumentsToFolder,
+ searchIncludeDescendants,
+ onToggleSearchIncludeDescendants,
+ onInspectDocument,
+ } = tableProps;
+
+ const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
+ const subtitle = Array.isArray(searchResults)
+ ? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}`
+ : null;
+
+ const documentSelectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0;
+ const folderSelectionCount = Array.isArray(selectedFolderIds) ? selectedFolderIds.length : 0;
+ const selectionCount = documentSelectionCount + folderSelectionCount;
+
+ const actions = createDocumentsTableHeaderActions({
+ viewMode,
+ onViewModeChange,
+ onRefresh,
+ sortField,
+ onSortFieldChange,
+ sortDirection,
+ onSortDirectionToggle,
+ isFilterActive,
+ includeDescendants: searchIncludeDescendants,
+ onToggleIncludeDescendants: onToggleSearchIncludeDescendants,
+ });
+
+ const floatingActions = selectionCount > 0
+ ? (
+
+ )
+ : null;
+
+ const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
+ const detail = detailOpen && detailProps ? : null;
+
+ return createWorkspaceSurfaceConfig({
+ key: 'documents',
+ variant: 'documents',
+ title,
+ subtitle,
+ sidebarToggle,
+ parentBreadcrumb,
+ onNavigateParent,
+ actions,
+ breadcrumbs,
+ selectionLabel: null,
+ floatingActions,
+ content: (
+
+ ),
+ detail,
+ });
+};
+
+export default createDocumentsSurface;