mirror of
https://github.com/suitenumerique/drive.git
synced 2026-04-25 17:15:19 +02:00
wip
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user