This commit is contained in:
Nathan Panchout
2026-04-23 17:45:35 +02:00
parent 3731033f5b
commit c3cfca7711
5 changed files with 303 additions and 42 deletions

View File

@@ -125,6 +125,7 @@ $tablet: map.get($themes, "default", "globals", "breakpoints", "tablet");
display: flex;
align-items: center;
gap: var(--c--globals--spacings--2xs);
flex-shrink: 0;
}
&--mobile {

View File

@@ -92,7 +92,9 @@ const BaseBreadcrumbs = ({
>
{defaultRouteData.icon({ size: IconSize.MEDIUM })}
{t(defaultRouteData.label)}
<span className="c__breadcrumbs__button__label">
{t(defaultRouteData.label)}
</span>
</div>
);
};
@@ -188,6 +190,8 @@ const BaseBreadcrumbs = ({
onClick={() => handleGoBack(item)}
/>
),
label: item.title,
onClick: () => handleGoBack(item),
}));
}
const breadcrumbsItems: BreadcrumbItem[] = [];
@@ -195,17 +199,26 @@ const BaseBreadcrumbs = ({
if (defaultRouteData && !showAllFolderItem) {
breadcrumbsItems.push({
content: getDefaultRouteButton(defaultRouteData),
label: t(defaultRouteData.label),
onClick: () => router.push(defaultRouteData.route),
});
}
const fromRouteButton = getFromRouteButton();
if (fromRouteButton && !showAllFolderItem) {
const fromRouteData =
getFromRouteManualDefaultRouteData() ?? getGuessedDefaultRouteData();
breadcrumbsItems.push({
content: fromRouteButton,
label: fromRouteData ? t(fromRouteData.label) : "",
onClick: fromRouteData
? () => router.push(fromRouteData.route)
: undefined,
});
}
if (showAllFolderItem) {
const allFoldersLabel = t("explorer.breadcrumbs.all_folders");
breadcrumbsItems.push({
content: (
<div
@@ -214,9 +227,13 @@ const BaseBreadcrumbs = ({
goToSpaces?.();
}}
>
{t("explorer.breadcrumbs.all_folders")}
<span className="c__breadcrumbs__button__label">
{allFoldersLabel}
</span>
</div>
),
label: allFoldersLabel,
onClick: () => goToSpaces?.(),
});
}
@@ -226,22 +243,25 @@ const BaseBreadcrumbs = ({
const lastItem = item;
breadcrumbsData.forEach((item) => {
const isActive = item.id === lastItem?.id;
breadcrumbsData.forEach((crumb) => {
const isActive = crumb.id === lastItem?.id;
breadcrumbsItems.push({
content: (
<BreadcrumbItemButton
item={item}
onClick={() => handleGoBack(item)}
item={crumb}
onClick={() => handleGoBack(crumb)}
isActive={isActive}
/>
),
label: crumb.title,
onClick: () => handleGoBack(crumb),
});
});
if (showMenuLastItem && lastItem) {
breadcrumbsItems.push({
content: <LastItemBreadcrumb item={lastItem} />,
label: lastItem.title,
});
}
@@ -278,7 +298,7 @@ export const BreadcrumbItemButton = ({
data-testid="breadcrumb-button"
onClick={onClick}
>
{item.title}
<span className="c__breadcrumbs__button__label">{item.title}</span>
{rightIcon}
</button>
);

View File

@@ -171,7 +171,8 @@
"explorer": {
"breadcrumbs": {
"spaces": "Spaces",
"all_folders": "All folders"
"all_folders": "All folders",
"show_hidden_folders": "Show hidden folders"
},
"modal": {
"move": {
@@ -772,7 +773,8 @@
"explorer": {
"breadcrumbs": {
"spaces": "Espaces",
"all_folders": "Tous les dossiers"
"all_folders": "Tous les dossiers",
"show_hidden_folders": "Afficher les dossiers masqués"
},
"modal": {
"move": {
@@ -1382,7 +1384,8 @@
"explorer": {
"breadcrumbs": {
"spaces": "Werkruimten",
"all_folders": "Alle mappen"
"all_folders": "Alle mappen",
"show_hidden_folders": "Toon verborgen mappen"
},
"modal": {
"move": {

View File

@@ -1,9 +1,18 @@
import { Button } from "@gouvfr-lasuite/cunningham-react";
import React, { ReactElement, ReactNode } from "react";
import { DropdownMenu } from "@gouvfr-lasuite/ui-kit";
import React, {
ReactElement,
ReactNode,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
export type BreadcrumbItem = {
content: ReactNode;
label?: string;
onClick?: () => void;
};
export interface BreadcrumbsProps {
@@ -12,47 +21,226 @@ export interface BreadcrumbsProps {
displayBack?: boolean;
}
const applyActive = (item: BreadcrumbItem, isLast: boolean): ReactNode => {
const content = item.content as ReactElement<HTMLDivElement>;
const existingClassName =
(content.props as { className?: string }).className ?? "";
return React.cloneElement(content, {
className: `${existingClassName} ${isLast ? "active" : ""}`.trim(),
});
};
export const Breadcrumbs = ({
items,
onBack,
displayBack = false,
}: BreadcrumbsProps) => {
const { t } = useTranslation();
return (
<div className="c__breadcrumbs" data-testid="explorer-breadcrumbs">
{displayBack && (
<Button
icon={<span className="material-icons">arrow_back</span>}
color="neutral"
variant="tertiary"
className="mr-t"
onClick={onBack}
disabled={items.length <= 1}
>
{t("Précédent")}
</Button>
)}
const containerRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const separatorRefs = useRef<(HTMLSpanElement | null)[]>([]);
const ellipsisRef = useRef<HTMLDivElement | null>(null);
const [hiddenIndices, setHiddenIndices] = useState<Set<number>>(
() => new Set(),
);
const [isEllipsisOpen, setIsEllipsisOpen] = useState(false);
{items.map((item, index) => {
return (
<React.Fragment key={index}>
const lastIndex = items.length - 1;
useLayoutEffect(() => {
const computeLayout = () => {
const container = containerRef.current;
if (!container || items.length <= 2) {
setHiddenIndices((prev) => (prev.size === 0 ? prev : new Set()));
return;
}
const containerWidth = container.getBoundingClientRect().width;
const widths = itemRefs.current.map(
(el) => el?.getBoundingClientRect().width ?? 0,
);
const sepWidths = separatorRefs.current.map(
(el) => el?.getBoundingClientRect().width ?? 0,
);
const ellipsisW =
ellipsisRef.current?.getBoundingClientRect().width ?? 0;
const totalSeparators = sepWidths
.slice(0, lastIndex)
.reduce((sum, w) => sum + w, 0);
const totalWidth =
widths.reduce((sum, w) => sum + w, 0) + totalSeparators;
if (totalWidth <= containerWidth) {
setHiddenIndices((prev) => (prev.size === 0 ? prev : new Set()));
return;
}
// Need to collapse. Always keep root (0) and last. Walk middle right→left.
const avgSep =
sepWidths.length > 0
? sepWidths.reduce((sum, w) => sum + w, 0) / sepWidths.length
: 0;
// Reserve: root + last + ellipsis + one separator on each side of the ellipsis chain.
let budget =
containerWidth - widths[0] - widths[lastIndex] - ellipsisW - avgSep * 2;
const visibleMiddle: number[] = [];
for (let i = lastIndex - 1; i >= 1; i--) {
// Right neighbor is already in the chain (last or previous middle).
// So additional cost is item width + one separator between this and its right neighbor,
// except the first one added — that separator slot was already reserved.
const cost = widths[i] + (visibleMiddle.length === 0 ? 0 : avgSep);
if (cost > budget) break;
budget -= cost;
visibleMiddle.unshift(i);
}
const newHidden = new Set<number>();
for (let i = 1; i < lastIndex; i++) {
if (!visibleMiddle.includes(i)) newHidden.add(i);
}
setHiddenIndices(newHidden);
};
computeLayout();
const container = containerRef.current;
if (!container || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(computeLayout);
observer.observe(container);
return () => observer.disconnect();
}, [items, lastIndex]);
const hiddenItems = items.filter((_, idx) => hiddenIndices.has(idx));
const showEllipsis = hiddenItems.length > 0;
type Cell =
| { kind: "item"; item: BreadcrumbItem; index: number }
| { kind: "ellipsis" };
const visibleCells: Cell[] = [];
items.forEach((item, index) => {
if (hiddenIndices.has(index)) return;
if (
showEllipsis &&
visibleCells.length === 1 && // just pushed root
index > 0
) {
visibleCells.push({ kind: "ellipsis" });
}
visibleCells.push({ kind: "item", item, index });
});
return (
<>
{/* Hidden measurement layer — a sibling so e2e tests querying inside explorer-breadcrumbs
don't pick up the measurement DOM. Positioned off-screen. */}
<div className="c__breadcrumbs__measure" aria-hidden inert>
{items.map((item, index) => (
<React.Fragment key={`m-${index}`}>
{index > 0 && (
<span
ref={(el) => {
separatorRefs.current[index - 1] = el;
}}
className="material-icons c__breadcrumbs__separator"
>
chevron_right
</span>
)}
<div
ref={(el) => {
itemRefs.current[index] = el;
}}
className="c__breadcrumbs__item"
>
{applyActive(item, index === lastIndex)}
</div>
</React.Fragment>
))}
<div ref={ellipsisRef} className="c__breadcrumbs__ellipsis">
</div>
</div>
<div
className="c__breadcrumbs"
data-testid="explorer-breadcrumbs"
ref={containerRef}
>
{displayBack && (
<Button
icon={<span className="material-icons">arrow_back</span>}
color="neutral"
variant="tertiary"
className="mr-t"
onClick={onBack}
disabled={items.length <= 1}
>
{t("Précédent")}
</Button>
)}
{visibleCells.map((cell, i) => (
<React.Fragment key={cell.kind === "item" ? cell.index : "ellipsis"}>
{i > 0 && (
<span className="material-icons c__breadcrumbs__separator">
chevron_right
</span>
)}
{React.cloneElement(item.content as ReactElement<HTMLDivElement>, {
className: `${
(
(item.content as ReactElement<HTMLDivElement>).props as {
className?: string;
}
).className || ""
} ${index === items.length - 1 ? "active" : ""}`,
})}
{cell.kind === "item" ? (
<div className="c__breadcrumbs__item">
{applyActive(cell.item, cell.index === lastIndex)}
</div>
) : (
<EllipsisDropdown
items={hiddenItems}
isOpen={isEllipsisOpen}
setIsOpen={setIsEllipsisOpen}
ariaLabel={t("explorer.breadcrumbs.show_hidden_folders")}
/>
)}
</React.Fragment>
);
})}
</div>
))}
</div>
</>
);
};
type EllipsisDropdownProps = {
items: BreadcrumbItem[];
isOpen: boolean;
setIsOpen: (open: boolean) => void;
ariaLabel: string;
};
const EllipsisDropdown = ({
items,
isOpen,
setIsOpen,
ariaLabel,
}: EllipsisDropdownProps) => {
const options = items.map((item, idx) => ({
label: item.label ?? "",
value: String(idx),
callback: () => {
item.onClick?.();
setIsOpen(false);
},
}));
return (
<DropdownMenu options={options} isOpen={isOpen} onOpenChange={setIsOpen}>
<button
type="button"
className="c__breadcrumbs__ellipsis"
data-testid="breadcrumb-ellipsis"
aria-label={ariaLabel}
onClick={() => setIsOpen(true)}
>
</button>
</DropdownMenu>
);
};

View File

@@ -1,15 +1,55 @@
.c__breadcrumbs {
display: flex;
align-items: center;
overflow: auto;
flex-wrap: nowrap;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
position: relative;
&__separator {
color: var(--c--contextuals--content--semantic--neutral--tertiary);
flex-shrink: 0;
}
> * {
&__item {
display: flex;
align-items: center;
min-width: 0;
flex-shrink: 0;
}
&__measure {
position: fixed;
top: -9999px;
left: -9999px;
display: flex;
align-items: center;
visibility: hidden;
pointer-events: none;
white-space: nowrap;
}
&__ellipsis {
height: 32px;
padding: 4px 8px;
background-color: transparent;
border: none;
border-radius: 4px;
color: var(--c--contextuals--content--semantic--neutral--secondary);
font-size: 16px;
font-family: var(--c--globals--font--families--base);
cursor: pointer;
display: flex;
align-items: center;
flex-shrink: 0;
&:hover {
background-color: var(
--c--contextuals--background--semantic--neutral--tertiary
);
}
}
}
.c__breadcrumbs__button {
@@ -27,6 +67,8 @@
align-items: center;
gap: 8px;
text-decoration: none;
max-width: 320px;
min-width: 0;
&:hover {
background-color: var(
@@ -38,4 +80,11 @@
font-weight: 600;
color: var(--c--contextuals--content--semantic--neutral--primary);
}
&__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
}