Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions docs/Header-Breadcrumb.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Header Middle-Ellipsis Breadcrumb — GrantFox FWC26

Date: 2026-07-28

## Summary

Added a `middleEllipsis` prop to the `Breadcrumb` component and created a new `Header` page component that renders the topbar banner with a breadcrumb that collapses long paths using a middle-ellipsis pattern.

## Changes

### `src/components/Breadcrumb.tsx`

- Added `middleEllipsis?: boolean` prop to `BreadcrumbProps` (default `false`).
- When `middleEllipsis` is `true`, intermediate breadcrumb items (between the first and last) are hidden from the main breadcrumb trail on **all viewports** and exposed via a "…" popover triggered by an ellipsis button in the first crumb.
- Added CSS modifier class `.breadcrumb-nav--middle-ellipsis` that:
- Hides `.breadcrumb-middle` items (`display: none`) on all viewports.
- Shows `.breadcrumb-collapsed` as `display: flex` on all viewports.
- Focus management is preserved: Arrow keys navigate popover items, Escape closes and returns focus to the ellipsis button, the popover auto-focuses its first item on open.

### `src/pages/Header.tsx`

- New component: `Header` with a `breadcrumbItems` prop of type `ReadonlyArray<BreadcrumbItem>`.
- Renders a `<header>` with `role="banner"` and the existing `topbar` styling classes.
- Integrates `Breadcrumb` with `middleEllipsis={true}` inside the topbar actions area.
- Includes the Callora Vault branding (eyebrow + brand text) alongside the breadcrumb.
- Fully accessible: WCAG 2.1 AA compliant with `aria-label`, `aria-current`, `title`, and `aria-label` overrides on truncated items.
- Design-token consistent: uses `var(--accent)`, `var(--text)`, `var(--muted)`, `var(--surface)`, `var(--border)`, `var(--accent-strong)`.

### `src/components/Breadcrumb.test.tsx`

- Added 10 focused tests for the `middleEllipsis` prop covering:
- Default behavior (no hiding without `middleEllipsis`).
- Hiding middle items when `middleEllipsis` is `true`.
- Ellipsis button rendering and ARIA attributes.
- Popover open/close with click and keyboard (Escape).
- Popover content correctness.
- No ellipsis button when there are no middle items.
- First and last crumb visibility with `middleEllipsis`.
- Desktop viewport collapse behavior.
- Keyboard navigation within the popover.

### `src/pages/Header.test.tsx`

- New test file with 9 focused tests covering:
- Banner landmark rendering.
- Brand text presence.
- Breadcrumb with `middleEllipsis` class.
- Middle items hidden.
- Ellipsis button and popover interaction.
- Current page item rendering.
- Design token color usage.
- Keyboard accessibility (Escape closes popover).
- Focus return to trigger button on popover close.

## API Changes

### BreadcrumbProps (new optional prop)

| Prop | Type | Default | Description |
| --------------- | --------- | ------- | ------------------------------------------------------------------ |
| `middleEllipsis` | `boolean` | `false` | Collapse middle breadcrumb items behind an ellipsis popover on all viewports. |

### HeaderProps (new component)

| Prop | Type | Required | Description |
| ------------------ | -------------------------------------------- | -------- | ---------------------------------- |
| `breadcrumbItems` | `ReadonlyArray<BreadcrumbItem>` | Yes | Breadcrumb items to render in the header. |

### BreadcrumbItem (existing type, unchanged)

| Field | Type | Required | Description |
| ------------ | --------- | -------- | ---------------------------------- |
| `label` | `string` | Yes | Display label for the crumb. |
| `href` | `string` | Yes | Link destination. |
| `isCurrent` | `boolean` | No | Marks the current page (renders as `<span>` with `aria-current="page"`). |

## Visual Behavior

- **Without `middleEllipsis` (default)**: All breadcrumb items visible on desktop; middle items collapse behind an ellipsis only on mobile (≤480px).
- **With `middleEllipsis={true}`**: Middle items are hidden behind an ellipsis on **all viewports** — first and last crumbs always visible, intermediate items accessible via the popover.

## Accessibility

- WCAG 2.1 AA compliant.
- Truncated labels expose their full text via `title` (hover) and `aria-label` (screen reader).
- Popover uses `role="menu"` with `role="menuitem"` links.
- Focus trap: Escape closes popover, focus returns to trigger button.
- Keyboard navigation: ArrowUp/ArrowDown cycle through popover items, Home/End jump to first/last.
185 changes: 184 additions & 1 deletion src/components/Breadcrumb.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -451,8 +451,191 @@ describe("Breadcrumb – middle-ellipsis (maxLabelLength)", () => {
});

// ---------------------------------------------------------------------------
// Focused tests for #726 — Tooltip primitive wired to Breadcrumb icon buttons
// Breadcrumb – middleEllipsis (collapses middle items on all viewports)
// ---------------------------------------------------------------------------
const ELLIPSIS_ITEMS = [
{ label: "Home", href: "/" },
{ label: "Very Long First Middle Segment Name", href: "/home/middleware" },
{ label: "Another Extended Segment", href: "/home/middleware/data" },
{ label: "Target Page", href: "/home/middleware/data/target", isCurrent: true },
];

describe("Breadcrumb – middleEllipsis", () => {
afterEach(() => {
cleanup();
});

it("does not hide middle items when middleEllipsis is false (default)", () => {
const { container } = render(<Breadcrumb items={ELLIPSIS_ITEMS} />);

const middleItems = container.querySelectorAll(".breadcrumb-middle");
expect(middleItems.length).toBe(2);
for (const item of middleItems) {
expect(item).not.toHaveStyle("display: none");
}
});

it("hides middle items when middleEllipsis is true", () => {
const { container } = render(
<Breadcrumb items={ELLIPSIS_ITEMS} middleEllipsis={true} />,
);

const nav = container.querySelector(".breadcrumb-nav--middle-ellipsis");
expect(nav).not.toBeNull();

const middleItems = nav?.querySelectorAll(".breadcrumb-middle");
expect(middleItems?.length).toBe(2);
for (const item of middleItems ?? []) {
expect(window.getComputedStyle(item).display).toBe("none");
}
});

it("renders the ellipsis button inside the first item when middleEllipsis is true", () => {
const { container } = render(
<Breadcrumb items={ELLIPSIS_ITEMS} middleEllipsis={true} />,
);

const button = container.querySelector<HTMLButtonElement>(
".breadcrumb-ellipsis",
);
expect(button).not.toBeNull();
expect(button?.textContent).toBe("...");
expect(button?.getAttribute("aria-label")).toBe(
"Show collapsed breadcrumb items",
);
expect(button?.getAttribute("aria-haspopup")).toBe("menu");
expect(button?.getAttribute("aria-expanded")).toBe("false");
});

it("opens the popover when the ellipsis button is clicked with middleEllipsis", () => {
const { container } = render(
<Breadcrumb items={ELLIPSIS_ITEMS} middleEllipsis={true} />,
);

const button = container.querySelector<HTMLButtonElement>(
".breadcrumb-ellipsis",
);
if (!button) throw new Error("Expected ellipsis button");

fireEvent.click(button);
expect(button.getAttribute("aria-expanded")).toBe("true");

const popover = container.querySelector('[role="menu"]');
expect(popover).not.toBeNull();
});

it("shows all middle items in the popover when opened with middleEllipsis", () => {
const { container } = render(
<Breadcrumb items={ELLIPSIS_ITEMS} middleEllipsis={true} />,
);

const button = container.querySelector<HTMLButtonElement>(
".breadcrumb-ellipsis",
);
if (!button) throw new Error("Expected ellipsis button");
fireEvent.click(button);

const menuItems = Array.from(
container.querySelectorAll<HTMLAnchorElement>('[role="menuitem"]'),
);
expect(menuItems.length).toBe(2);
expect(menuItems[0].textContent).toBe(
"Very Long First Middle Segment Name",
);
expect(menuItems[1].textContent).toBe("Another Extended Segment");
});

it("closes the popover with Escape when middleEllipsis is active", () => {
const { container } = render(
<Breadcrumb items={ELLIPSIS_ITEMS} middleEllipsis={true} />,
);

const button = container.querySelector<HTMLButtonElement>(
".breadcrumb-ellipsis",
);
if (!button) throw new Error("Expected ellipsis button");

fireEvent.click(button);
expect(button.getAttribute("aria-expanded")).toBe("true");

fireEvent.keyDown(document, { key: "Escape" });
expect(button.getAttribute("aria-expanded")).toBe("false");
expect(screen.queryByRole("menu")).toBeNull();
});

it("does not render the ellipsis button or modifier class when there are no middle items (even with middleEllipsis)", () => {
const shortItems = [
{ label: "Home", href: "/" },
{ label: "Settings", href: "/settings", isCurrent: true },
];
const { container } = render(
<Breadcrumb items={shortItems} middleEllipsis={true} />,
);

expect(container.querySelector(".breadcrumb-ellipsis")).toBeNull();
expect(container.querySelector(".breadcrumb-nav--middle-ellipsis")).toBeNull();
});

it("shows the first crumb link and the current page with middleEllipsis active", () => {
render(
<Breadcrumb items={ELLIPSIS_ITEMS} middleEllipsis={true} />,
);

expect(screen.getByRole("link", { name: "Home" })).toBeTruthy();
expect(
screen.getByText("Target Page").getAttribute("aria-current"),
).toBe("page");
});

it("collapses middle items on desktop viewport when middleEllipsis is true", () => {
const { container } = render(
<Breadcrumb items={ELLIPSIS_ITEMS} middleEllipsis={true} />,
);

const nav = container.querySelector<HTMLElement>(
".breadcrumb-nav--middle-ellipsis",
);
expect(nav).not.toBeNull();

const middleItems = nav?.querySelectorAll(".breadcrumb-middle");
expect(middleItems?.length).toBe(2);
for (const item of middleItems ?? []) {
expect(window.getComputedStyle(item).display).toBe("none");
}

const collapsed = nav?.querySelector(".breadcrumb-collapsed");
expect(collapsed).not.toBeNull();
expect(window.getComputedStyle(collapsed!).display).toBe("flex");
});

it("maintains keyboard navigation within the popover when middleEllipsis is active", () => {
const { container } = render(
<Breadcrumb items={ELLIPSIS_ITEMS} middleEllipsis={true} />,
);

const button = container.querySelector<HTMLButtonElement>(
".breadcrumb-ellipsis",
);
if (!button) throw new Error("Expected ellipsis button");
fireEvent.click(button);

const menuItems = Array.from(
container.querySelectorAll<HTMLAnchorElement>('[role="menuitem"]'),
);
expect(menuItems.length).toBe(2);

expect(document.activeElement).toBe(menuItems[0]);

fireEvent.keyDown(menuItems[0].parentElement!, { key: "ArrowDown" });
expect(document.activeElement).toBe(menuItems[1]);

fireEvent.keyDown(menuItems[1].parentElement!, { key: "ArrowUp" });
expect(document.activeElement).toBe(menuItems[0]);

fireEvent.keyDown(menuItems[0].parentElement!, { key: "Escape" });
expect(button.getAttribute("aria-expanded")).toBe("false");
});
});
describe("Breadcrumb — Tooltip on ellipsis button", () => {
afterEach(() => {
vi.useRealTimers();
Expand Down
24 changes: 20 additions & 4 deletions src/components/Breadcrumb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ type BreadcrumbProps = {
* @default 0 (no truncation)
*/
maxLabelLength?: number;
/**
* Collapse middle breadcrumb items behind an ellipsis button when the
* path is long. When enabled, items between the first and last are
* hidden from the main breadcrumb trail on all viewports and exposed
* via a popover triggered by the ellipsis button.
*
* @default false
*/
middleEllipsis?: boolean;
};

/**
Expand Down Expand Up @@ -104,15 +113,14 @@ function BreadcrumbSeparator() {
);
}

export default function Breadcrumb({ items, maxLabelLength = 0 }: BreadcrumbProps) {
export default function Breadcrumb({ items, maxLabelLength = 0, middleEllipsis = false }: BreadcrumbProps) {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const ellipsisButtonRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const popoverId = useId();
const middleItems = useMemo(() => items.slice(1, -1), [items]);
const shouldCollapseMiddle = middleItems.length > 0;


const isMiddleEllipsis = middleEllipsis && shouldCollapseMiddle;

useEffect(() => {
if (!isPopoverOpen) return;
Expand Down Expand Up @@ -186,7 +194,7 @@ export default function Breadcrumb({ items, maxLabelLength = 0 }: BreadcrumbProp
};

return (
<nav aria-label="breadcrumb" className="breadcrumb-nav">
<nav aria-label="breadcrumb" className={isMiddleEllipsis ? "breadcrumb-nav breadcrumb-nav--middle-ellipsis" : "breadcrumb-nav"}>
<style>
{`
.breadcrumb-nav {
Expand Down Expand Up @@ -342,6 +350,14 @@ export default function Breadcrumb({ items, maxLabelLength = 0 }: BreadcrumbProp
max-width: min(38vw, 11rem);
}
}

.breadcrumb-nav--middle-ellipsis .breadcrumb-middle {
display: none;
}

.breadcrumb-nav--middle-ellipsis .breadcrumb-collapsed {
display: flex;
}
`}
</style>

Expand Down
Loading
Loading