breadcrumbs
This commit is contained in:
@@ -2046,7 +2046,13 @@ export const createDesktopSurface = ({ workspaceProps, renderSidebarToggle }) =>
|
||||
return {
|
||||
key: 'workspace',
|
||||
variant: 'workspace',
|
||||
header: { title, subtitle, leading, actions },
|
||||
header: {
|
||||
title,
|
||||
subtitle,
|
||||
leading,
|
||||
actions,
|
||||
breadcrumbs: workspaceProps?.breadcrumbs || null,
|
||||
},
|
||||
content: <DesktopWorkspace {...workspaceProps} />,
|
||||
supportsDetail: false,
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ import { api, useAppDispatch, useAppState } from './appState';
|
||||
const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early
|
||||
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
||||
|
||||
const DEFAULT_FOLDER_NAME = 'All Documents';
|
||||
const DEFAULT_FOLDER_NAME = 'Documents';
|
||||
|
||||
const ROW_KEY_SEPARATOR = ':';
|
||||
const DOCUMENT_ROW_PREFIX = 'document';
|
||||
@@ -394,7 +394,7 @@ const AppLayout = () => {
|
||||
|
||||
const handleNeutralHueChange = useCallback((value) => {
|
||||
if (value === '') {
|
||||
setNeutralHue(260);
|
||||
setNeutralHue(180);
|
||||
return;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useAppShell } from '../appShellContext';
|
||||
import DocumentsLayout from './DocumentsLayout';
|
||||
import { useWorkspaceSurface } from './useWorkspaceSurface';
|
||||
import PanelHeader from '../ui/PanelHeader';
|
||||
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
|
||||
|
||||
const DocumentsRoute = () => {
|
||||
const {
|
||||
@@ -59,6 +60,14 @@ const DocumentsRoute = () => {
|
||||
navigate(target);
|
||||
}, [navigate, parentBreadcrumb]);
|
||||
|
||||
const handleHeaderBreadcrumbClick = useCallback((crumb) => {
|
||||
if (!crumb || !crumb.id) {
|
||||
return;
|
||||
}
|
||||
const target = crumb.id === 'root' ? '/documents' : `/documents/folder/${crumb.id}`;
|
||||
navigate(target);
|
||||
}, [navigate]);
|
||||
|
||||
const { surface } = useWorkspaceSurface({
|
||||
sidebarCollapsed,
|
||||
onExpandSidebar: expandSidebar,
|
||||
@@ -104,16 +113,31 @@ const DocumentsRoute = () => {
|
||||
|
||||
const header = surface.header || null;
|
||||
|
||||
const headerTitle = header
|
||||
? header.subtitle
|
||||
? (
|
||||
<h2 className="main-content__title">
|
||||
{header.title}
|
||||
<span className="main-content__subtitle">{header.subtitle}</span>
|
||||
</h2>
|
||||
)
|
||||
: header.title
|
||||
: null;
|
||||
let headerTitle = null;
|
||||
if (header) {
|
||||
const breadcrumbEntries = Array.isArray(header.breadcrumbs) ? header.breadcrumbs.filter(Boolean) : [];
|
||||
const lastIndex = breadcrumbEntries.length - 1;
|
||||
const trailEntries = breadcrumbEntries.length
|
||||
? breadcrumbEntries.map((crumb, index) => ({
|
||||
id: crumb.id ?? index,
|
||||
label: crumb.name ?? crumb.label ?? crumb.title ?? '',
|
||||
onClick: index < lastIndex ? () => handleHeaderBreadcrumbClick(crumb) : null,
|
||||
}))
|
||||
: [{ id: 'current-location', label: header.title }];
|
||||
|
||||
headerTitle = (
|
||||
<h2 className="main-content__title">
|
||||
<BreadcrumbTrail
|
||||
entries={trailEntries}
|
||||
className="main-content__breadcrumbs"
|
||||
separator="/"
|
||||
/>
|
||||
{header.subtitle ? (
|
||||
<span className="main-content__subtitle">{header.subtitle}</span>
|
||||
) : null}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DocumentsLayout
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
MinusVerticalIcon,
|
||||
ArrowUpIcon,
|
||||
} from '../ui/icons';
|
||||
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
|
||||
|
||||
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
||||
const DEFAULT_GRID_ICON_SIZE = 144;
|
||||
@@ -197,6 +198,7 @@ const DocumentThumbnailImage = ({
|
||||
|
||||
const DocumentsTable = ({
|
||||
currentFolderName,
|
||||
breadcrumbs,
|
||||
onRefresh,
|
||||
subfolders,
|
||||
documents,
|
||||
@@ -477,6 +479,20 @@ const DocumentsTable = ({
|
||||
const showGridSearchEmptyState =
|
||||
isGridView && showingSearchResults && rows.length === 0 && !isSearchLoading;
|
||||
const showSearchHint = showingSearchResults && rows.length > 0;
|
||||
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 (
|
||||
<section
|
||||
@@ -485,7 +501,13 @@ const DocumentsTable = ({
|
||||
{showHeader ? (
|
||||
<div className="panel-section__header">
|
||||
<div className="panel-section__titles">
|
||||
<h2>{currentFolderName}</h2>
|
||||
<h2 className="documents-panel__title">
|
||||
<BreadcrumbTrail
|
||||
entries={trailEntries}
|
||||
className="documents-panel__breadcrumbs"
|
||||
separator="/"
|
||||
/>
|
||||
</h2>
|
||||
{showingSearchResults && (
|
||||
<div className="panel-section__subtitle">Search results</div>
|
||||
)}
|
||||
@@ -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: <DocumentsTable {...tableProps} showHeader={false} />,
|
||||
detail,
|
||||
};
|
||||
|
||||
+134
-23
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<span ref={containerRef} className={wrapperClassName}>
|
||||
{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 (
|
||||
<React.Fragment key="breadcrumb-ellipsis">
|
||||
{index > 0 ? (
|
||||
<span className="breadcrumb-trail__separator" aria-hidden="true">
|
||||
{separator}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="breadcrumb-trail__ellipsis" ref={ellipsisRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="breadcrumb-trail__link breadcrumb-trail__ellipsis-button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={showHiddenMenu}
|
||||
onClick={() => {
|
||||
if (!hasHiddenEntries) {
|
||||
setShowHiddenMenu(false);
|
||||
setMenuPosition(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (showHiddenMenu) {
|
||||
setShowHiddenMenu(false);
|
||||
setMenuPosition(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ellipsisRef.current) {
|
||||
setShowHiddenMenu(false);
|
||||
setMenuPosition(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = ellipsisRef.current.getBoundingClientRect();
|
||||
const menuWidth = 192;
|
||||
const viewportWidth = typeof window !== 'undefined'
|
||||
? Math.max(window.innerWidth, menuWidth)
|
||||
: menuWidth;
|
||||
const left = Math.min(
|
||||
Math.max(rect.left, 8),
|
||||
Math.max(viewportWidth - menuWidth - 8, 8),
|
||||
);
|
||||
|
||||
setMenuPosition({
|
||||
top: rect.bottom + 6,
|
||||
left,
|
||||
minWidth: menuWidth,
|
||||
});
|
||||
setShowHiddenMenu(true);
|
||||
}}
|
||||
title="Show parent folders"
|
||||
>
|
||||
{entry.label}
|
||||
</button>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
const content = !entry.onClick || isLast
|
||||
? (
|
||||
<span key={`${entry.id}-label`} {...commonProps}>
|
||||
{entry.label}
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<button
|
||||
key={`${entry.id}-button`}
|
||||
type="button"
|
||||
{...commonProps}
|
||||
onClick={() => entry.onClick?.(entry.raw ?? entry)}
|
||||
>
|
||||
{entry.label}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment key={entry.id || index}>
|
||||
{index > 0 ? (
|
||||
<span className="breadcrumb-trail__separator" aria-hidden="true">
|
||||
{separator}
|
||||
</span>
|
||||
) : null}
|
||||
{content}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
ref={measurementRef}
|
||||
className="breadcrumb-trail breadcrumb-trail--measure"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
className="breadcrumb-trail__link breadcrumb-trail__ellipsis-button"
|
||||
data-item-type="ellipsis"
|
||||
style={{ display: 'none' }}
|
||||
>
|
||||
{ELLIPSIS.label}
|
||||
</span>
|
||||
{normalized.map((entry, index) => (
|
||||
<React.Fragment key={`measure-${entry.id || index}`}>
|
||||
{index > 0 ? (
|
||||
<span
|
||||
className="breadcrumb-trail__separator"
|
||||
data-item-type="separator"
|
||||
data-target-index={index}
|
||||
>
|
||||
{separator}
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className="breadcrumb-trail__link"
|
||||
data-item-type="entry"
|
||||
data-entry-index={index}
|
||||
>
|
||||
{entry.label}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
{showHiddenMenu && hasHiddenEntries && menuPosition && typeof document !== 'undefined'
|
||||
? createPortal(
|
||||
<div
|
||||
className="menu menu--floating"
|
||||
role="menu"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: `${menuPosition.top}px`,
|
||||
left: `${menuPosition.left}px`,
|
||||
minWidth: `${menuPosition.minWidth}px`,
|
||||
}}
|
||||
ref={menuRef}
|
||||
>
|
||||
<div className="menu__list">
|
||||
{hiddenEntries.map((hiddenEntry) => (
|
||||
<button
|
||||
key={hiddenEntry.id}
|
||||
type="button"
|
||||
className="menu__item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setShowHiddenMenu(false);
|
||||
setMenuPosition(null);
|
||||
hiddenEntry.onClick?.(hiddenEntry.raw ?? hiddenEntry);
|
||||
}}
|
||||
disabled={!hiddenEntry.onClick}
|
||||
>
|
||||
<span className="menu__label">{hiddenEntry.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
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;
|
||||
Reference in New Issue
Block a user