From 4aa94268c3a6490c8d4d2ba22d18a05cfe1d0c56 Mon Sep 17 00:00:00 2001 From: nelly439 Date: Wed, 29 Jul 2026 00:07:04 +0100 Subject: [PATCH] feat: add middle-ellipsis breadcrumb to Header for GrantFox FWC26 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `middleEllipsis` prop to Breadcrumb component to collapse intermediate items behind a "…" popover on all viewports - Create Header page component (src/pages/Header.tsx) integrating Breadcrumb with middleEllipsis in the topbar banner - Add focused tests for both Breadcrumb.middleEllipsis and Header - Document API/visible changes in docs/Header-Breadcrumb.md Closes #750 --- docs/Header-Breadcrumb.md | 88 ++++++++++++++ src/components/Breadcrumb.test.tsx | 185 ++++++++++++++++++++++++++++- src/components/Breadcrumb.tsx | 24 +++- src/pages/Header.test.tsx | 142 ++++++++++++++++++++++ src/pages/Header.tsx | 47 ++++++++ 5 files changed, 481 insertions(+), 5 deletions(-) create mode 100644 docs/Header-Breadcrumb.md create mode 100644 src/pages/Header.test.tsx create mode 100644 src/pages/Header.tsx diff --git a/docs/Header-Breadcrumb.md b/docs/Header-Breadcrumb.md new file mode 100644 index 0000000..2c6cec1 --- /dev/null +++ b/docs/Header-Breadcrumb.md @@ -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`. +- Renders a `
` 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` | 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 `` 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. \ No newline at end of file diff --git a/src/components/Breadcrumb.test.tsx b/src/components/Breadcrumb.test.tsx index fe4abad..7884545 100644 --- a/src/components/Breadcrumb.test.tsx +++ b/src/components/Breadcrumb.test.tsx @@ -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(); + + 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( + , + ); + + 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( + , + ); + + const button = container.querySelector( + ".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( + , + ); + + const button = container.querySelector( + ".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( + , + ); + + const button = container.querySelector( + ".breadcrumb-ellipsis", + ); + if (!button) throw new Error("Expected ellipsis button"); + fireEvent.click(button); + + const menuItems = Array.from( + container.querySelectorAll('[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( + , + ); + + const button = container.querySelector( + ".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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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"); + } + + 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( + , + ); + + const button = container.querySelector( + ".breadcrumb-ellipsis", + ); + if (!button) throw new Error("Expected ellipsis button"); + fireEvent.click(button); + + const menuItems = Array.from( + container.querySelectorAll('[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(); diff --git a/src/components/Breadcrumb.tsx b/src/components/Breadcrumb.tsx index 8002ecb..0ff9e68 100644 --- a/src/components/Breadcrumb.tsx +++ b/src/components/Breadcrumb.tsx @@ -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; }; /** @@ -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(null); const popoverRef = useRef(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; @@ -186,7 +194,7 @@ export default function Breadcrumb({ items, maxLabelLength = 0 }: BreadcrumbProp }; return ( -