-
{currentFolderName}
+
+
+
{showingSearchResults && (
Search results
)}
@@ -1120,6 +1142,7 @@ export const createDocumentsSurface = ({
}) => {
const {
currentFolderName,
+ breadcrumbs,
searchResults,
viewMode,
onViewModeChange,
@@ -1165,7 +1188,7 @@ export const createDocumentsSurface = ({
return {
key: 'documents',
variant: 'documents',
- header: { title, subtitle, leading, actions },
+ header: { title, subtitle, leading, actions, breadcrumbs },
content:
,
detail,
};
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 1c5d8a2..b0fe3f9 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -1,7 +1,7 @@
:root {
color-scheme: light dark;
- --neutral-hue: 260deg;
+ --neutral-hue: 180deg;
--foreground-hue-offset: 10deg;
--accent-hue-offset: -20deg;
--dark-hue-offset: 30deg;
@@ -640,6 +640,7 @@ button.danger:hover:not([disabled]) {
display: flex;
flex-direction: column;
min-height: 0;
+ min-width: 0;
margin: 0;
border-radius: 0;
background: var(--surface);
@@ -683,20 +684,6 @@ button.danger:hover:not([disabled]) {
font-size: 1rem;
font-weight: 600;
color: var(--fg);
- display: inline-flex;
- flex-wrap: wrap;
- align-items: baseline;
- gap: 0.5rem;
- flex: 1 1 auto;
- min-width: 0;
-}
-
-.main-content__subtitle {
- font-size: 0.9rem;
- color: var(--muted);
- font-weight: 400;
- line-height: 1.2;
- display: inline-flex;
}
@@ -724,6 +711,32 @@ button.danger:hover:not([disabled]) {
color: var(--muted);
}
+.main-content__title {
+ margin: 0;
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--fg);
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ min-width: 0;
+ flex: 1 1 auto;
+ overflow: hidden;
+}
+
+.main-content__breadcrumbs {
+ flex: 1 1 auto;
+ overflow: hidden;
+}
+
+.main-content__subtitle {
+ font-size: 0.9rem;
+ color: var(--muted);
+ font-weight: 400;
+ line-height: 1.2;
+ flex-shrink: 0;
+}
+
.panels-main {
flex: 1;
display: flex;
@@ -1156,12 +1169,95 @@ button.danger:hover:not([disabled]) {
font-weight: 600;
}
+.documents-panel__title {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ margin: 0;
+ font-size: 0.9rem;
+ font-weight: 600;
+ min-width: 0;
+ flex-wrap: nowrap;
+ overflow: hidden;
+}
+
.panel-section__subtitle {
margin-top: 0.25rem;
font-size: 0.78rem;
color: var(--muted);
}
+.breadcrumb-trail {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ flex-wrap: nowrap;
+ min-width: 0;
+ flex: 1 1 auto;
+ max-width: 100%;
+ overflow: hidden;
+}
+
+.breadcrumb-trail--measure {
+ position: absolute;
+ visibility: hidden;
+ pointer-events: none;
+ left: -9999px;
+ top: -9999px;
+ max-width: none;
+ overflow: visible;
+}
+
+.breadcrumb-trail__link {
+ display: inline-flex;
+ align-items: center;
+ max-width: 100%;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ border: none;
+ background: none;
+ padding: 0;
+ font: inherit;
+ color: inherit;
+ cursor: default;
+}
+
+.breadcrumb-trail__link:not(.is-current) {
+ cursor: pointer;
+ color: var(--accent);
+}
+
+.breadcrumb-trail__link:not(.is-current):hover,
+.breadcrumb-trail__link:not(.is-current):focus-visible {
+ text-decoration: underline;
+}
+
+.breadcrumb-trail__separator {
+ color: var(--muted);
+ margin: 0 0.2rem;
+}
+
+.breadcrumb-trail__ellipsis {
+ position: relative;
+ display: inline-flex;
+}
+
+.breadcrumb-trail__ellipsis-button {
+ cursor: pointer;
+}
+
+.documents-panel__breadcrumbs {
+ flex: 1 1 auto;
+ min-width: 0;
+ max-width: 100%;
+ overflow: hidden;
+}
+
+.documents-panel__breadcrumbs {
+ max-width: 22rem;
+}
+
.panel-section__body {
flex: 1;
display: flex;
@@ -1765,6 +1861,23 @@ button.danger:hover:not([disabled]) {
gap: 0.5rem;
}
+.documents-panel__title {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ margin: 0;
+ font-size: 0.9rem;
+ font-weight: 600;
+ min-width: 0;
+ white-space: nowrap;
+ overflow: hidden;
+}
+
+.documents-panel__breadcrumbs {
+ flex: 1 1 auto;
+ overflow: hidden;
+}
+
.documents-panel .panel-section__body {
display: flex;
flex-direction: column;
@@ -2104,10 +2217,6 @@ button.danger:hover:not([disabled]) {
color: inherit;
}
-.doc-name__primary {
- font-weight: 600;
-}
-
.doc-correspondent-link {
background: none;
background-color: transparent;
@@ -2292,6 +2401,7 @@ button.danger:hover:not([disabled]) {
align-items: center;
gap: 0.75rem;
padding: 0.5rem;
+ min-width: 0;
}
.panel-header__leading,
@@ -2306,11 +2416,12 @@ button.danger:hover:not([disabled]) {
font-size: 1rem;
font-weight: 600;
line-height: 1.2;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
flex: 1 1 auto;
min-width: 0;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ overflow: hidden;
}
.panel-header > .panel-header__title:first-child {
@@ -2325,6 +2436,7 @@ button.danger:hover:not([disabled]) {
padding: 0.5rem 1rem;
}
+
.detail-panel .panel-body {
flex: 1;
display: flex;
@@ -2978,7 +3090,6 @@ form.inline {
.tags-panel,
.correspondents-panel {
min-height: auto;
- padding: 1.25rem;
}
}
.login-screen {
diff --git a/frontend/src/ui/BreadcrumbTrail.jsx b/frontend/src/ui/BreadcrumbTrail.jsx
new file mode 100644
index 0000000..040c596
--- /dev/null
+++ b/frontend/src/ui/BreadcrumbTrail.jsx
@@ -0,0 +1,431 @@
+import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
+import PropTypes from 'prop-types';
+
+const normalizeEntries = (entries) =>
+ (Array.isArray(entries) ? entries : [])
+ .map((entry, index) => {
+ if (!entry) {
+ return null;
+ }
+ const id = entry.id ?? entry.value ?? index;
+ const label = entry.label ?? entry.name ?? entry.title ?? '';
+ const onClick = typeof entry.onClick === 'function' ? entry.onClick : null;
+ return label ? { id, label, onClick, raw: entry } : null;
+ })
+ .filter(Boolean);
+
+const ELLIPSIS = { id: '__breadcrumbs_ellipsis__', label: '…', onClick: null, raw: null };
+
+const BreadcrumbTrail = ({ entries = [], className = '', separator = '/' }) => {
+ const normalized = useMemo(() => normalizeEntries(entries), [entries]);
+ const containerRef = useRef(null);
+ const measurementRef = useRef(null);
+ const ellipsisRef = useRef(null);
+ const menuRef = useRef(null);
+ const [availableWidth, setAvailableWidth] = useState(null);
+ const [startIndex, setStartIndex] = useState(0);
+ const [showHiddenMenu, setShowHiddenMenu] = useState(false);
+ const [menuPosition, setMenuPosition] = useState(null);
+
+ useEffect(() => {
+ setStartIndex(0);
+ setShowHiddenMenu(false);
+ setMenuPosition(null);
+ }, [normalized]);
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container || typeof ResizeObserver === 'undefined') {
+ return undefined;
+ }
+
+ const target = container.parentElement || container;
+
+ const updateWidth = () => {
+ const nextWidth = target.getBoundingClientRect().width;
+ if (!nextWidth) {
+ return;
+ }
+ setAvailableWidth((prev) => (prev && Math.abs(prev - nextWidth) < 0.5 ? prev : nextWidth));
+ };
+
+ updateWidth();
+
+ const observer = new ResizeObserver(updateWidth);
+ observer.observe(target);
+ return () => observer.disconnect();
+ }, []);
+
+ useLayoutEffect(() => {
+ if (!normalized.length) {
+ return;
+ }
+
+ const container = containerRef.current;
+ const measurement = measurementRef.current;
+ if (!container || !measurement) {
+ return;
+ }
+
+ const entryNodes = Array.from(measurement.querySelectorAll('[data-item-type="entry"]'));
+ if (!entryNodes.length) {
+ return;
+ }
+
+ const separatorNodes = Array.from(measurement.querySelectorAll('[data-item-type="separator"]'));
+ const ellipsisNode = measurement.querySelector('[data-item-type="ellipsis"]');
+
+ const originalEntryDisplay = entryNodes.map((node) => node.style.display);
+ const originalSeparatorDisplay = separatorNodes.map((node) => node.style.display);
+ const originalEllipsisDisplay = ellipsisNode ? ellipsisNode.style.display : null;
+
+ const widths = [];
+
+ for (let start = 0; start < entryNodes.length; start += 1) {
+ entryNodes.forEach((node, index) => {
+ // Hide entries that fall before the visible window.
+ // eslint-disable-next-line no-param-reassign
+ node.style.display = index < start ? 'none' : '';
+ });
+
+ separatorNodes.forEach((node) => {
+ const targetIndex = Number(node.getAttribute('data-target-index'));
+ // eslint-disable-next-line no-param-reassign
+ node.style.display = targetIndex < Math.max(start, 1) ? 'none' : '';
+ });
+
+ if (ellipsisNode) {
+ // eslint-disable-next-line no-param-reassign
+ ellipsisNode.style.display = start > 0 ? '' : 'none';
+ }
+
+ widths[start] = measurement.getBoundingClientRect().width;
+ }
+
+ entryNodes.forEach((node, index) => {
+ // eslint-disable-next-line no-param-reassign
+ node.style.display = originalEntryDisplay[index] ?? '';
+ });
+
+ separatorNodes.forEach((node, index) => {
+ // eslint-disable-next-line no-param-reassign
+ node.style.display = originalSeparatorDisplay[index] ?? '';
+ });
+
+ if (ellipsisNode) {
+ // eslint-disable-next-line no-param-reassign
+ ellipsisNode.style.display = originalEllipsisDisplay ?? 'none';
+ }
+
+ const available = availableWidth ?? container.clientWidth;
+ if (!available || !widths.length) {
+ return;
+ }
+
+ const TOLERANCE = 1;
+ // Reserve a tiny buffer so the live trail doesn't oscillate when the
+ // container width barely fits; shrink the measured allowance a bit.
+ const adjustedAvailable = available * 0.98;
+ let nextStart = widths.length - 1;
+ for (let start = 0; start < widths.length; start += 1) {
+ if (widths[start] <= adjustedAvailable + TOLERANCE) {
+ nextStart = start;
+ break;
+ }
+ }
+
+ if (nextStart !== startIndex) {
+ setStartIndex(nextStart);
+ }
+ }, [normalized, separator, availableWidth, startIndex]);
+
+ useEffect(() => {
+ if (!showHiddenMenu) {
+ return undefined;
+ }
+
+ if (typeof document === 'undefined') {
+ setShowHiddenMenu(false);
+ setMenuPosition(null);
+ return undefined;
+ }
+
+ const handleGlobalInteraction = (event) => {
+ const ellipsisEl = ellipsisRef.current;
+ const menuEl = menuRef.current;
+ if ((ellipsisEl && ellipsisEl.contains(event.target))
+ || (menuEl && menuEl.contains(event.target))) {
+ return;
+ }
+ setShowHiddenMenu(false);
+ setMenuPosition(null);
+ };
+
+ const handleKey = (event) => {
+ if (event.key === 'Escape') {
+ setShowHiddenMenu(false);
+ setMenuPosition(null);
+ }
+ };
+
+ document.addEventListener('mousedown', handleGlobalInteraction);
+ document.addEventListener('touchstart', handleGlobalInteraction, { passive: true });
+ document.addEventListener('focusin', handleGlobalInteraction);
+ document.addEventListener('keydown', handleKey);
+
+ return () => {
+ document.removeEventListener('mousedown', handleGlobalInteraction);
+ document.removeEventListener('touchstart', handleGlobalInteraction);
+ document.removeEventListener('focusin', handleGlobalInteraction);
+ document.removeEventListener('keydown', handleKey);
+ };
+ }, [showHiddenMenu]);
+
+ useEffect(() => {
+ if (!showHiddenMenu) {
+ return undefined;
+ }
+
+ if (typeof window === 'undefined') {
+ setShowHiddenMenu(false);
+ setMenuPosition(null);
+ return undefined;
+ }
+
+ const handleWindowChange = () => {
+ if (!ellipsisRef.current) {
+ setShowHiddenMenu(false);
+ setMenuPosition(null);
+ return;
+ }
+
+ const rect = ellipsisRef.current.getBoundingClientRect();
+ const menuWidth = 192;
+ const viewportWidth = window.innerWidth;
+ const left = Math.min(Math.max(rect.left, 8), Math.max(viewportWidth - menuWidth - 8, 8));
+ setMenuPosition({
+ top: rect.bottom + 6,
+ left,
+ minWidth: menuWidth,
+ });
+ };
+
+ handleWindowChange();
+ window.addEventListener('resize', handleWindowChange);
+ window.addEventListener('scroll', handleWindowChange, true);
+
+ return () => {
+ window.removeEventListener('resize', handleWindowChange);
+ window.removeEventListener('scroll', handleWindowChange, true);
+ };
+ }, [showHiddenMenu]);
+
+ if (!normalized.length) {
+ return null;
+ }
+
+ const sliceStart = Math.min(startIndex, Math.max(0, normalized.length - 1));
+ const slice = normalized.slice(sliceStart);
+ const visibleEntries = sliceStart > 0 ? [ELLIPSIS, ...slice] : slice;
+ const hiddenEntries = sliceStart > 0 ? normalized.slice(0, sliceStart) : [];
+ const hasHiddenEntries = hiddenEntries.length > 0;
+
+ const wrapperClassName = className
+ ? `breadcrumb-trail ${className}`.trim()
+ : 'breadcrumb-trail';
+
+ useEffect(() => {
+ if (!hasHiddenEntries && showHiddenMenu) {
+ setShowHiddenMenu(false);
+ setMenuPosition(null);
+ }
+ }, [hasHiddenEntries, showHiddenMenu]);
+
+ return (
+ <>
+
+ {visibleEntries.map((entry, index) => {
+ const isEllipsis = entry.id === ELLIPSIS.id;
+ const isLast = index === visibleEntries.length - 1;
+ const commonProps = {
+ className: `breadcrumb-trail__link${isLast && !isEllipsis ? ' is-current' : ''}`,
+ title: entry.label,
+ 'aria-current': isLast && !isEllipsis ? 'page' : undefined,
+ };
+
+ if (isEllipsis) {
+ return (
+
+ {index > 0 ? (
+
+ {separator}
+
+ ) : null}
+
+
+
+
+ );
+ }
+
+ const content = !entry.onClick || isLast
+ ? (
+
+ {entry.label}
+
+ )
+ : (
+
+ );
+
+ return (
+
+ {index > 0 ? (
+
+ {separator}
+
+ ) : null}
+ {content}
+
+ );
+ })}
+
+
+
+ {ELLIPSIS.label}
+
+ {normalized.map((entry, index) => (
+
+ {index > 0 ? (
+
+ {separator}
+
+ ) : null}
+
+ {entry.label}
+
+
+ ))}
+
+ {showHiddenMenu && hasHiddenEntries && menuPosition && typeof document !== 'undefined'
+ ? createPortal(
+
+
+ {hiddenEntries.map((hiddenEntry) => (
+
+ ))}
+
+
,
+ document.body,
+ )
+ : null}
+ >
+ );
+};
+
+const breadcrumbEntryShape = PropTypes.shape({
+ id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ name: PropTypes.string,
+ label: PropTypes.string,
+ title: PropTypes.string,
+ onClick: PropTypes.func,
+});
+
+BreadcrumbTrail.propTypes = {
+ entries: PropTypes.arrayOf(breadcrumbEntryShape),
+ className: PropTypes.string,
+ separator: PropTypes.string,
+};
+
+export default BreadcrumbTrail;