From c1c207c1b2f17ac1ef68b028b8a894bb59f0ad92 Mon Sep 17 00:00:00 2001 From: Atul Tameshwari Date: Mon, 13 Jul 2026 19:18:35 +0530 Subject: [PATCH 1/3] feat(breadcrumb): introduce BreadcrumbLink and BreadcrumbPage components - Added `BreadcrumbLink` for navigable crumbs with an optional leading icon and label. - Introduced `BreadcrumbPage` for the current page crumb, also supporting an optional leading icon. - Updated `BreadcrumbMenuTrigger` to use `BreadcrumbTriggerIndicator` for improved visual consistency. - Refactored existing breadcrumb components and stories to accommodate new structures and enhance usability. - Adjusted styles and variants to ensure consistent appearance across breadcrumb elements. --- .../breadcrumb-ellipsis-trigger.tsx | 2 +- .../components/breadcrumb/breadcrumb-link.tsx | 29 +++++ .../breadcrumb/breadcrumb-menu-trigger.tsx | 7 +- .../components/breadcrumb/breadcrumb-page.tsx | 29 +++++ .../breadcrumb/breadcrumb.stories.tsx | 122 +++++++++++++----- .../src/components/breadcrumb/index.tsx | 6 +- .../breadcrumb-trigger-indicator.tsx | 22 ++++ .../breadcrumb/breadcrumb.stories.tsx | 39 +++--- .../propel/src/elements/breadcrumb/index.tsx | 1 + .../src/elements/breadcrumb/variants.ts | 33 ++++- 10 files changed, 224 insertions(+), 66 deletions(-) create mode 100644 packages/propel/src/components/breadcrumb/breadcrumb-link.tsx create mode 100644 packages/propel/src/components/breadcrumb/breadcrumb-page.tsx create mode 100644 packages/propel/src/elements/breadcrumb/breadcrumb-trigger-indicator.tsx diff --git a/packages/propel/src/components/breadcrumb/breadcrumb-ellipsis-trigger.tsx b/packages/propel/src/components/breadcrumb/breadcrumb-ellipsis-trigger.tsx index 3e412acd..4256aeee 100644 --- a/packages/propel/src/components/breadcrumb/breadcrumb-ellipsis-trigger.tsx +++ b/packages/propel/src/components/breadcrumb/breadcrumb-ellipsis-trigger.tsx @@ -26,7 +26,7 @@ export function BreadcrumbEllipsisTrigger({ icon, ...props }: BreadcrumbEllipsis return ( } {...props}> {icon ?? ( - + )} diff --git a/packages/propel/src/components/breadcrumb/breadcrumb-link.tsx b/packages/propel/src/components/breadcrumb/breadcrumb-link.tsx new file mode 100644 index 00000000..3b10609c --- /dev/null +++ b/packages/propel/src/components/breadcrumb/breadcrumb-link.tsx @@ -0,0 +1,29 @@ +import type * as React from "react"; + +import { + BreadcrumbLink as BreadcrumbLinkSlot, + type BreadcrumbLinkProps as BreadcrumbLinkSlotProps, +} from "../../elements/breadcrumb"; + +export type BreadcrumbLinkProps = Omit & { + /** + * Leading icon before the label, e.g. ``. Renders 16×16 — + * the crumb sets `--node-size`, so the `` needs no `magnitude`. + */ + icon?: React.ReactNode; + /** The crumb label. */ + label: string; +}; + +/** + * A navigable crumb — an anchor styled as a hoverable pill, laying out an optional leading icon and + * the label. Pass `render` to use a router link. + */ +export function BreadcrumbLink({ icon, label, ...props }: BreadcrumbLinkProps) { + return ( + + {icon} + {label} + + ); +} diff --git a/packages/propel/src/components/breadcrumb/breadcrumb-menu-trigger.tsx b/packages/propel/src/components/breadcrumb/breadcrumb-menu-trigger.tsx index 755e5a72..77b04f5f 100644 --- a/packages/propel/src/components/breadcrumb/breadcrumb-menu-trigger.tsx +++ b/packages/propel/src/components/breadcrumb/breadcrumb-menu-trigger.tsx @@ -2,8 +2,7 @@ import { Menu as BaseMenu } from "@base-ui/react/menu"; import { ChevronDown } from "lucide-react"; import type * as React from "react"; -import { BreadcrumbTrigger } from "../../elements/breadcrumb"; -import { DisclosureIndicator } from "../../internal/disclosure-indicator"; +import { BreadcrumbTrigger, BreadcrumbTriggerIndicator } from "../../elements/breadcrumb"; export type BreadcrumbMenuTriggerProps = Omit< BaseMenu.Trigger.Props, @@ -21,9 +20,9 @@ export function BreadcrumbMenuTrigger({ icon, label, ...props }: BreadcrumbMenuT } {...props}> {icon} {label} - + - + ); } diff --git a/packages/propel/src/components/breadcrumb/breadcrumb-page.tsx b/packages/propel/src/components/breadcrumb/breadcrumb-page.tsx new file mode 100644 index 00000000..919a929e --- /dev/null +++ b/packages/propel/src/components/breadcrumb/breadcrumb-page.tsx @@ -0,0 +1,29 @@ +import type * as React from "react"; + +import { + BreadcrumbPage as BreadcrumbPageSlot, + type BreadcrumbPageProps as BreadcrumbPageSlotProps, +} from "../../elements/breadcrumb"; + +export type BreadcrumbPageProps = Omit & { + /** + * Leading icon before the label, e.g. ``. Renders 16×16 + * — the crumb sets `--node-size`, so the `` needs no `magnitude`. + */ + icon?: React.ReactNode; + /** The current-page label. */ + label: string; +}; + +/** + * The current page — the last, non-navigable crumb (`aria-current="page"`), laying out an optional + * leading icon and the label. + */ +export function BreadcrumbPage({ icon, label, ...props }: BreadcrumbPageProps) { + return ( + + {icon} + {label} + + ); +} diff --git a/packages/propel/src/components/breadcrumb/breadcrumb.stories.tsx b/packages/propel/src/components/breadcrumb/breadcrumb.stories.tsx index e6c072bd..41ee1cb4 100644 --- a/packages/propel/src/components/breadcrumb/breadcrumb.stories.tsx +++ b/packages/propel/src/components/breadcrumb/breadcrumb.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { Layers } from "lucide-react"; +import { FileText, Layers } from "lucide-react"; +import type * as React from "react"; import { expect, userEvent, waitFor, within } from "storybook/test"; import { Icon } from "../icon"; @@ -49,31 +50,38 @@ type Story = StoryObj; // browser runner — so swallow the default click. Real apps pass a router link here instead. const inertAnchor = () => event.preventDefault()} />; -/** A three-level trail ending in the current page. */ +// Crumb label/href are typed props, not children: `BreadcrumbLink` takes `label` (+ `href`, +// `onClick`, `render` for a router link); `BreadcrumbPage` takes `label`. Both accept an optional +// leading `icon`. This shared handler swallows the demo anchors' navigation (see `inertAnchor`). +const swallow = (event: React.MouseEvent) => event.preventDefault(); + +/** + * A three-level trail ending in the current page. + * + * The trail ends on the current-page crumb: there is **no** trailing separator after the last crumb + * (a separator sits only _between_ crumbs, so a trail of N crumbs has N−1 separators). Figma's + * example frames show a chevron after the final crumb — that is an authoring artifact of chaining + * copies of one crumb instance with its `Arrow` toggle left on, not a spec requirement. Don't + * "restore" a trailing arrow to match the mock. + */ export const Default: Story = { render: () => ( - event.preventDefault()}> - Plane - + - event.preventDefault()}> - Projects - + - event.preventDefault()}> - Design - + - Work items + @@ -96,6 +104,64 @@ export const DefaultInteraction: Story = { }, }; +/** + * Crumbs carry an optional leading `icon`, following the same slot convention as `MenuItem` and the + * menu crumb: pass a public ``. The crumb sizes it to 16×16 via + * its `--node-size`, so no `magnitude` is needed. Works on both a navigable `BreadcrumbLink` and + * the current-page `BreadcrumbPage`. + */ +export const WithIcon: Story = { + render: () => ( + + + + } + label="Plane Design" + /> + + + + } label="Work items" /> + + + + ), +}; + +/** + * A long crumb label stays on a single line. The crumb variants set `whitespace-nowrap`, so a long + * label keeps the trail's fixed `h-6` height and baseline instead of wrapping onto a second line + * and breaking the row's alignment. When horizontal space genuinely runs out, collapse the middle + * crumbs behind a menu (see `WithCollapsedCrumbs`) rather than letting labels wrap — the primitive + * does not truncate on its own, since a sensible clip width is an app-level decision. + */ +export const WithLongLabel: Story = { + render: () => ( + + + + + + + + + + + + + + + + ), +}; + /** * When the trail is too long, collapse the middle crumbs behind a menu. The * `BreadcrumbEllipsisTrigger` crumb opens a `Menu` of the hidden crumbs — there is no separate @@ -106,9 +172,7 @@ export const WithCollapsedCrumbs: Story = { - event.preventDefault()}> - Plane - + @@ -122,13 +186,11 @@ export const WithCollapsedCrumbs: Story = { - event.preventDefault()}> - Components - + - Breadcrumb + @@ -169,15 +231,13 @@ export const WithMenuCrumb: Story = { - event.preventDefault()}> - Plane - + } + icon={} label="Plane Design" /> @@ -191,7 +251,7 @@ export const WithMenuCrumb: Story = { breadcrumb chevron (and rotates down while the menu is open), so a separate separator would render a second, redundant arrow. */} - Components + @@ -208,13 +268,11 @@ export const MenuCrumbSelected: Story = { - event.preventDefault()}> - Plane - + - Work items + @@ -246,15 +304,13 @@ export const KeyboardNavigation: Story = { - event.preventDefault()}> - Plane - + } + icon={} label="Plane Design" /> @@ -265,7 +321,7 @@ export const KeyboardNavigation: Story = { {/* Menu crumb's own chevron is the breadcrumb chevron; no separator after it. */} - Components + diff --git a/packages/propel/src/components/breadcrumb/index.tsx b/packages/propel/src/components/breadcrumb/index.tsx index 6e016953..666a3123 100644 --- a/packages/propel/src/components/breadcrumb/index.tsx +++ b/packages/propel/src/components/breadcrumb/index.tsx @@ -3,13 +3,11 @@ export { type BreadcrumbProps, BreadcrumbItem, type BreadcrumbItemProps, - BreadcrumbLink, - type BreadcrumbLinkProps, BreadcrumbList, type BreadcrumbListProps, - BreadcrumbPage, - type BreadcrumbPageProps, } from "../../elements/breadcrumb"; export * from "./breadcrumb-ellipsis-trigger"; +export * from "./breadcrumb-link"; export * from "./breadcrumb-menu-trigger"; +export * from "./breadcrumb-page"; export * from "./breadcrumb-separator"; diff --git a/packages/propel/src/elements/breadcrumb/breadcrumb-trigger-indicator.tsx b/packages/propel/src/elements/breadcrumb/breadcrumb-trigger-indicator.tsx new file mode 100644 index 00000000..d9d8aed9 --- /dev/null +++ b/packages/propel/src/elements/breadcrumb/breadcrumb-trigger-indicator.tsx @@ -0,0 +1,22 @@ +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; + +import { breadcrumbTriggerIndicatorVariants } from "./variants"; + +export type BreadcrumbTriggerIndicatorProps = Omit< + useRender.ComponentProps<"span">, + "className" | "style" +>; + +/** + * The disclosure caret inside a menu crumb — sizes its chevron-down child, rotates it while the + * crumb's menu is open, and shifts from `icon-secondary` to `icon-primary` when that crumb is the + * active (open) one. Decorative, so it is `aria-hidden`. Composed by `BreadcrumbMenuTrigger`. + */ +export function BreadcrumbTriggerIndicator({ render, ...props }: BreadcrumbTriggerIndicatorProps) { + const defaultProps: useRender.ElementProps<"span"> = { + "aria-hidden": true, + className: breadcrumbTriggerIndicatorVariants(), + }; + return useRender({ defaultTagName: "span", render, props: mergeProps(defaultProps, props) }); +} diff --git a/packages/propel/src/elements/breadcrumb/breadcrumb.stories.tsx b/packages/propel/src/elements/breadcrumb/breadcrumb.stories.tsx index b0c7466b..56117ab8 100644 --- a/packages/propel/src/elements/breadcrumb/breadcrumb.stories.tsx +++ b/packages/propel/src/elements/breadcrumb/breadcrumb.stories.tsx @@ -1,7 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { ChevronDown, ChevronRight, Ellipsis, Layers } from "lucide-react"; -import { DisclosureIndicator } from "../../internal/disclosure-indicator"; import { Icon } from "../../internal/icon"; import { Breadcrumb, @@ -11,6 +10,7 @@ import { BreadcrumbPage, BreadcrumbSeparator, BreadcrumbTrigger, + BreadcrumbTriggerIndicator, } from "./index"; // elements-tier story (rule 2b): a pure UI-configuration showcase of the atomic breadcrumb parts @@ -79,35 +79,36 @@ export const Default: Story = { /** * The menu-crumb chrome assembled from atoms: `BreadcrumbTrigger` (`group` adds the `group/trigger` - * marker) holding an internal `Icon`, the label, and a `DisclosureIndicator` chevron. In the app, - * Base UI's `Menu.Trigger` grafts onto it and sets `data-popup-open` while its menu is open — here - * that attribute is pinned statically, which both shifts the pill to the open palette and rotates - * the chevron down. The icon-only form (no `group`, no indicator) is the collapsed-crumbs ellipsis - * crumb; being icon-only it requires an `aria-label`. + * marker) holding an internal `Icon`, the label, and a `BreadcrumbTriggerIndicator` chevron. In the + * app, Base UI's `Menu.Trigger` grafts onto it and sets `data-popup-open` while its menu is open — + * here that attribute is pinned statically, which shifts the pill to the open palette, rotates the + * chevron down, and shifts the chevron from `icon-secondary` to `icon-primary` (the active crumb). + * The icon-only form (no `group`, no indicator) is the collapsed-crumbs ellipsis crumb; being + * icon-only it requires an `aria-label`. */ export const Trigger: Story = { render: () => (
- + Plane Design - + - + - + Plane Design - + - + - + @@ -144,21 +145,21 @@ export const States: Story = {
Trigger - + - + Trigger hover - + - + Trigger open - + - +
diff --git a/packages/propel/src/elements/breadcrumb/index.tsx b/packages/propel/src/elements/breadcrumb/index.tsx index b0430f99..b17118c1 100644 --- a/packages/propel/src/elements/breadcrumb/index.tsx +++ b/packages/propel/src/elements/breadcrumb/index.tsx @@ -5,3 +5,4 @@ export * from "./breadcrumb-list"; export * from "./breadcrumb-page"; export * from "./breadcrumb-separator"; export * from "./breadcrumb-trigger"; +export * from "./breadcrumb-trigger-indicator"; diff --git a/packages/propel/src/elements/breadcrumb/variants.ts b/packages/propel/src/elements/breadcrumb/variants.ts index e2354dad..3317493a 100644 --- a/packages/propel/src/elements/breadcrumb/variants.ts +++ b/packages/propel/src/elements/breadcrumb/variants.ts @@ -9,15 +9,20 @@ import { nodeSlotClass } from "../../internal/node-slot"; * the text (`icon-secondary`), set on the icon slot, not inherited from `currentColor`. * * Fixed `h-6` (24px, per Figma): every crumb is the same height whether or not it has a leading - * icon — the 16px icon and 14px text center inside the 24px box — so a mixed trail stays flush. + * icon — the 16px icon and 14px text center inside the 24px box — so a mixed trail stays flush. The + * leading-icon slot is sized to 16px via `--node-size`, so a consumer's `` needs no + * `magnitude`. */ export const breadcrumbLinkVariants = cva( - "inline-flex h-6 items-center gap-1.5 rounded-md px-1 text-14 leading-none font-medium text-tertiary transition-colors hover:bg-layer-transparent-hover", + "inline-flex h-6 items-center gap-1.5 rounded-md px-1 text-14 leading-none font-medium whitespace-nowrap text-tertiary transition-colors [--node-size:1rem] hover:bg-layer-transparent-hover", ); -/** Chrome for `BreadcrumbPage` — the current page (non-interactive) crumb. Fixed `h-6` (24px). */ +/** + * Chrome for `BreadcrumbPage` — the current page (non-interactive) crumb. Fixed `h-6` (24px); the + * leading-icon slot is sized to 16px via `--node-size`. + */ export const breadcrumbPageVariants = cva( - "inline-flex h-6 items-center gap-1.5 rounded-md px-1 text-14 leading-none font-medium text-primary", + "inline-flex h-6 items-center gap-1.5 rounded-md px-1 text-14 leading-none font-medium whitespace-nowrap text-primary [--node-size:1rem]", ); /** @@ -27,7 +32,7 @@ export const breadcrumbPageVariants = cva( export const breadcrumbTriggerVariants = cva( // Resting text `tertiary`; hover only adds the fill (no recolor, matching a link crumb). The // OPEN menu is the crumb's active state, so `data-popup-open` shifts text to `primary`. - "inline-flex h-6 cursor-default items-center gap-1.5 rounded-md px-1 text-14 leading-none font-medium text-tertiary transition-colors [--node-size:0.875rem] hover:bg-layer-transparent-hover data-popup-open:bg-layer-transparent-hover data-popup-open:text-primary", + "inline-flex h-6 cursor-default items-center gap-1.5 rounded-md px-1 text-14 leading-none font-medium whitespace-nowrap text-tertiary transition-colors [--node-size:1rem] hover:bg-layer-transparent-hover data-popup-open:bg-layer-transparent-hover data-popup-open:text-primary", { variants: { /** Adds `group/trigger` so descendant elements can use `group-data-popup-open/trigger:*`. */ @@ -39,6 +44,24 @@ export const breadcrumbTriggerVariants = cva( }, ); +/** + * The disclosure caret inside a menu crumb (`BreadcrumbMenuTrigger`). Pass a **chevron-down** + * glyph: it points inline-end while the crumb's menu is closed and rotates down while it is open + * (RTL-mirrored). Its own part — not the shared `DisclosureIndicator` — because it carries a + * family-specific active state: resting `icon-secondary`, shifting to `icon-primary` when the + * crumb's menu is open (`group-data-popup-open/trigger`), matching the trigger text going + * `primary`. Sized to 14px via `--node-size` (smaller than the crumb's 16px leading icon). + */ +export const breadcrumbTriggerIndicatorVariants = cva( + cx( + nodeSlotClass, + "text-icon-secondary transition duration-200 [--node-size:0.875rem]", + "-rotate-90 group-data-popup-open/trigger:rotate-0", + "rtl:rotate-90 rtl:group-data-popup-open/trigger:rotate-0", + "group-data-popup-open/trigger:text-icon-primary", + ), +); + /** The `
  • ` wrapper for every crumb (link, page, trigger). */ export const breadcrumbItemVariants = cva("inline-flex items-center"); From d436a9a57f8cd5198cafd51e4478ea6c1b50155b Mon Sep 17 00:00:00 2001 From: Atul Tameshwari Date: Mon, 13 Jul 2026 19:54:37 +0530 Subject: [PATCH 2/3] refactor(breadcrumb): update props and structure for Breadcrumb components - Modified `BreadcrumbEllipsisTriggerProps` and `BreadcrumbMenuTriggerProps` to include "render" in the omitted props for better flexibility. - Updated the rendering order in `BreadcrumbEllipsisTrigger` and `BreadcrumbMenuTrigger` for improved clarity. - Enhanced documentation for props to clarify usage and requirements. - Added a new `RTLCanary` story to test and demonstrate RTL support in breadcrumb components, ensuring proper visual behavior in both LTR and RTL layouts. --- .../breadcrumb-ellipsis-trigger.tsx | 6 +- .../breadcrumb/breadcrumb-menu-trigger.tsx | 16 ++++-- .../breadcrumb/breadcrumb.stories.tsx | 57 +++++++++++++++++++ .../src/elements/breadcrumb/breadcrumb.tsx | 2 + 4 files changed, 73 insertions(+), 8 deletions(-) diff --git a/packages/propel/src/components/breadcrumb/breadcrumb-ellipsis-trigger.tsx b/packages/propel/src/components/breadcrumb/breadcrumb-ellipsis-trigger.tsx index 4256aeee..26c9d480 100644 --- a/packages/propel/src/components/breadcrumb/breadcrumb-ellipsis-trigger.tsx +++ b/packages/propel/src/components/breadcrumb/breadcrumb-ellipsis-trigger.tsx @@ -7,11 +7,11 @@ import { Icon as IconSlot } from "../../internal/icon"; export type BreadcrumbEllipsisTriggerProps = Omit< BaseMenu.Trigger.Props, - "className" | "style" | "children" + "className" | "style" | "children" | "render" > & { /** Accessible name for the trigger (e.g. "Show more breadcrumbs"). Required (icon-only). */ "aria-label": string; - /** The trigger element. @default an ellipsis */ + /** The glyph shown inside the trigger. @default an ellipsis */ icon?: React.ReactNode; }; @@ -24,7 +24,7 @@ export type BreadcrumbEllipsisTriggerProps = Omit< */ export function BreadcrumbEllipsisTrigger({ icon, ...props }: BreadcrumbEllipsisTriggerProps) { return ( - } {...props}> + }> {icon ?? ( diff --git a/packages/propel/src/components/breadcrumb/breadcrumb-menu-trigger.tsx b/packages/propel/src/components/breadcrumb/breadcrumb-menu-trigger.tsx index 77b04f5f..3d384251 100644 --- a/packages/propel/src/components/breadcrumb/breadcrumb-menu-trigger.tsx +++ b/packages/propel/src/components/breadcrumb/breadcrumb-menu-trigger.tsx @@ -6,18 +6,24 @@ import { BreadcrumbTrigger, BreadcrumbTriggerIndicator } from "../../elements/br export type BreadcrumbMenuTriggerProps = Omit< BaseMenu.Trigger.Props, - "children" | "className" | "style" + "children" | "className" | "style" | "render" > & { - /** Leading element, typically ``. */ + /** + * Leading icon, e.g. `` (renders 16×16 via the crumb's + * `--node-size`). + */ icon?: React.ReactNode; - /** The crumb label. */ - label?: string; + /** + * The crumb label. Required — it is the trigger's accessible name (the icon and caret are + * decorative). + */ + label: string; }; /** The crumb that opens a `BreadcrumbMenu`. */ export function BreadcrumbMenuTrigger({ icon, label, ...props }: BreadcrumbMenuTriggerProps) { return ( - } {...props}> + }> {icon} {label} diff --git a/packages/propel/src/elements/breadcrumb/breadcrumb.stories.tsx b/packages/propel/src/elements/breadcrumb/breadcrumb.stories.tsx index 56117ab8..8b800fde 100644 --- a/packages/propel/src/elements/breadcrumb/breadcrumb.stories.tsx +++ b/packages/propel/src/elements/breadcrumb/breadcrumb.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { ChevronDown, ChevronRight, Ellipsis, Layers } from "lucide-react"; +import { expect } from "storybook/test"; import { Icon } from "../../internal/icon"; import { @@ -165,3 +166,59 @@ export const States: Story = { ), }; + +/** + * CSS canary (rule 2b): asserts the breadcrumb's RTL-mirrored selectors compiled. The separator + * chevron mirrors under `dir="rtl"` (`rtl:[&>svg]:-scale-x-100`) and the menu-crumb caret rotates + * the opposite way (`rtl:rotate-90` vs LTR's `-rotate-90`). Rendered LTR + RTL side by side and + * compared. Tagged out of the sidebar/docs/manifest while still running under the default `test` + * tag. + */ +export const RTLCanary: Story = { + tags: ["!dev", "!autodocs", "!manifest"], + parameters: { controls: { disable: true } }, + render: () => ( + <> + {(["ltr", "rtl"] as const).map((dir) => ( +
    + + + + + {dir.toUpperCase()} trigger + + + + + + + + + + {dir.toUpperCase()} page + + + +
    + ))} + + ), + play: async ({ canvas, canvasElement }) => { + // The compiled `rtl:rotate-90` selector mirrors the closed caret away from LTR's `-rotate-90`. + const caretRotate = (name: string) => { + const caret = canvas.getByRole("button", { name }).querySelector("[aria-hidden]"); + if (!(caret instanceof HTMLElement)) throw new Error(`missing caret in "${name}" trigger`); + return getComputedStyle(caret).rotate; + }; + await expect(caretRotate("RTL trigger")).not.toBe(caretRotate("LTR trigger")); + + // The compiled `rtl:[&>svg]:-scale-x-100` selector mirrors the separator chevron under RTL. + const separatorMirror = (dir: string) => { + const svg = canvasElement.querySelector(`[dir="${dir}"] li[aria-hidden="true"] svg`); + if (!(svg instanceof SVGElement)) throw new Error(`missing ${dir} separator glyph`); + const cs = getComputedStyle(svg); + return `${cs.transform}|${cs.scale}`; + }; + await expect(separatorMirror("rtl")).not.toBe(separatorMirror("ltr")); + }, +}; diff --git a/packages/propel/src/elements/breadcrumb/breadcrumb.tsx b/packages/propel/src/elements/breadcrumb/breadcrumb.tsx index 8792e560..690b9efd 100644 --- a/packages/propel/src/elements/breadcrumb/breadcrumb.tsx +++ b/packages/propel/src/elements/breadcrumb/breadcrumb.tsx @@ -10,5 +10,7 @@ export type BreadcrumbProps = Omit< * landmark name, conventionally "Breadcrumb") — the consumer supplies it so it can be localized. */ export function Breadcrumb({ render, ...props }: BreadcrumbProps) { + // No cva: the nav is an unstyled semantic landmark; the trail's layout lives on BreadcrumbList + // (the `
      `). With no default props there is nothing to merge, so `mergeProps` is unneeded too. return useRender({ defaultTagName: "nav", render, props }); } From 3101a452a72949345a2ec10aec6e0194e8768f38 Mon Sep 17 00:00:00 2001 From: Atul Tameshwari Date: Mon, 13 Jul 2026 20:10:11 +0530 Subject: [PATCH 3/3] refactor(breadcrumb): simplify event handling in BreadcrumbLink components - Replaced the `swallow` function with inline event handling in `BreadcrumbLink` components to prevent default link behavior. - Cleaned up the code by removing unnecessary comments related to the previous event handling approach. - Ensured consistent behavior across all breadcrumb stories by updating the click event handling for navigable crumbs. --- .../breadcrumb/breadcrumb.stories.tsx | 28 ++++++++----------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/packages/propel/src/components/breadcrumb/breadcrumb.stories.tsx b/packages/propel/src/components/breadcrumb/breadcrumb.stories.tsx index 41ee1cb4..64c2ee54 100644 --- a/packages/propel/src/components/breadcrumb/breadcrumb.stories.tsx +++ b/packages/propel/src/components/breadcrumb/breadcrumb.stories.tsx @@ -1,6 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { FileText, Layers } from "lucide-react"; -import type * as React from "react"; import { expect, userEvent, waitFor, within } from "storybook/test"; import { Icon } from "../icon"; @@ -50,11 +49,6 @@ type Story = StoryObj; // browser runner — so swallow the default click. Real apps pass a router link here instead. const inertAnchor = () => event.preventDefault()} />; -// Crumb label/href are typed props, not children: `BreadcrumbLink` takes `label` (+ `href`, -// `onClick`, `render` for a router link); `BreadcrumbPage` takes `label`. Both accept an optional -// leading `icon`. This shared handler swallows the demo anchors' navigation (see `inertAnchor`). -const swallow = (event: React.MouseEvent) => event.preventDefault(); - /** * A three-level trail ending in the current page. * @@ -69,15 +63,15 @@ export const Default: Story = { - + event.preventDefault()} label="Plane" /> - + event.preventDefault()} label="Projects" /> - + event.preventDefault()} label="Design" /> @@ -117,7 +111,7 @@ export const WithIcon: Story = { event.preventDefault()} icon={} label="Plane Design" /> @@ -143,13 +137,13 @@ export const WithLongLabel: Story = { - + event.preventDefault()} label="Plane" /> event.preventDefault()} label="Design System and Component Library Documentation" /> @@ -172,7 +166,7 @@ export const WithCollapsedCrumbs: Story = { - + event.preventDefault()} label="Plane" /> @@ -186,7 +180,7 @@ export const WithCollapsedCrumbs: Story = { - + event.preventDefault()} label="Components" /> @@ -231,7 +225,7 @@ export const WithMenuCrumb: Story = { - + event.preventDefault()} label="Plane" /> @@ -268,7 +262,7 @@ export const MenuCrumbSelected: Story = { - + event.preventDefault()} label="Plane" /> @@ -304,7 +298,7 @@ export const KeyboardNavigation: Story = { - + event.preventDefault()} label="Plane" />