From ce5146a03e6549000c5f49e7da399625e2100e53 Mon Sep 17 00:00:00 2001
From: Jesse Box
Date: Mon, 6 Jul 2026 14:11:33 +0200
Subject: [PATCH 01/11] feat(breadcrumbs): Redesign with explicit typed
variants and LeadingGraphics
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replace the flat `Breadcrumbs` + `Crumb[]` API with a typed discriminated
union `BreadcrumbList` + `items` array. Each breadcrumb slot now has an
explicit variant ('link', 'page-title', 'select-projects') so developers
must consciously choose a crumb type rather than relying on position.
Key changes:
- Add `BreadcrumbList` as the new preferred API with typed `BreadcrumbItem`
discriminated union
- Add `BreadcrumbItemLink`, `BreadcrumbItemPageTitle`,
`BreadcrumbItemSelectProjects`, and `BreadcrumbItemMenuBreadcrumbs`
(internal overflow) item components
- Add `BreadcrumbDividerCombo` (internal) to pair items with slash dividers
- Add container-query overflow: link parent items collapse into a menu
below 800px container width
- Add structured `pagination` prop on `BreadcrumbItemPageTitle` replacing
ReactNode — callers pass {previous, next} items with `to`, `disabled`,
`ariaLabel`, and optional `tooltip`; chevron buttons rendered internally
- Extract `LeadingGraphics` shared component absorbing the 0/1/2+ platform
icon logic from `SecondaryNavigationProjectIcon`
- Refactor `SecondaryNavigationProjectIcon` to delegate to `LeadingGraphics`
- Preserve the old `Breadcrumbs` + `Crumb` API as a compatibility shim so
existing call sites need no changes
- Add stories for `BreadcrumbList` and `LeadingGraphics`
Co-Authored-By: Claude Sonnet 4.6
---
static/app/components/breadcrumbs.tsx | 138 +-----------
.../breadcrumbs/breadcrumbDividerCombo.tsx | 32 +++
.../breadcrumbs/breadcrumbList.stories.tsx | 206 ++++++++++++++++++
.../components/breadcrumbs/breadcrumbList.tsx | 112 ++++++++++
static/app/components/breadcrumbs/index.tsx | 89 ++++++++
.../breadcrumbs/items/breadcrumbItemLink.tsx | 67 ++++++
.../items/breadcrumbItemMenuBreadcrumbs.tsx | 49 +++++
.../items/breadcrumbItemPageTitle.tsx | 96 ++++++++
.../items/breadcrumbItemSelectProjects.tsx | 25 +++
.../core/leadingGraphics/index.stories.tsx | 120 ++++++++++
.../components/core/leadingGraphics/index.tsx | 160 ++++++++++++++
.../views/navigation/secondary/components.tsx | 59 +----
12 files changed, 968 insertions(+), 185 deletions(-)
create mode 100644 static/app/components/breadcrumbs/breadcrumbDividerCombo.tsx
create mode 100644 static/app/components/breadcrumbs/breadcrumbList.stories.tsx
create mode 100644 static/app/components/breadcrumbs/breadcrumbList.tsx
create mode 100644 static/app/components/breadcrumbs/index.tsx
create mode 100644 static/app/components/breadcrumbs/items/breadcrumbItemLink.tsx
create mode 100644 static/app/components/breadcrumbs/items/breadcrumbItemMenuBreadcrumbs.tsx
create mode 100644 static/app/components/breadcrumbs/items/breadcrumbItemPageTitle.tsx
create mode 100644 static/app/components/breadcrumbs/items/breadcrumbItemSelectProjects.tsx
create mode 100644 static/app/components/core/leadingGraphics/index.stories.tsx
create mode 100644 static/app/components/core/leadingGraphics/index.tsx
diff --git a/static/app/components/breadcrumbs.tsx b/static/app/components/breadcrumbs.tsx
index cdb3663ccc8b..a79510fd7983 100644
--- a/static/app/components/breadcrumbs.tsx
+++ b/static/app/components/breadcrumbs.tsx
@@ -1,136 +1,6 @@
-import {Fragment} from 'react';
-
-import {Container, Flex} from '@sentry/scraps/layout';
-import type {LinkProps} from '@sentry/scraps/link';
-import {Link} from '@sentry/scraps/link';
-import {Text} from '@sentry/scraps/text';
-
-import {extractSelectionParameters} from 'sentry/components/pageFilters/parse';
-import {IconSlashForward} from 'sentry/icons';
-import {t} from 'sentry/locale';
-import {trackAnalytics} from 'sentry/utils/analytics';
-import {useLocation} from 'sentry/utils/useLocation';
-
-export interface Crumb {
- /**
- * Label of the crumb
- */
- label: NonNullable;
-
- /**
- * It will keep the page filter values (projects, environments, time) in the
- * querystring when navigating using extractSelectionParameters
- */
- preservePageFilters?: boolean;
-
- /**
- * Link of the crumb
- */
- to?: LinkProps['to'] | null;
-}
-
-interface BreadcrumbsProps extends React.HTMLAttributes {
- crumbs: Crumb[];
- as?: 'nav';
-}
-
/**
- * Page breadcrumbs used for navigation, not to be confused with sentry's event breadcrumbs
+ * Re-exports from the breadcrumbs directory.
+ * Import from 'sentry/components/breadcrumbs' resolves here first (file > directory),
+ * so existing call sites continue to work without changes.
*/
-export function Breadcrumbs({crumbs, as, ...props}: BreadcrumbsProps) {
- if (crumbs.length === 0) {
- return null;
- }
-
- return (
-
- {crumbs.map((crumb, index) => {
- return (
-
-
- {index < crumbs.length - 1 ? (
-
-
-
- ) : null}
-
- );
- })}
-
- );
-}
-
-interface BreadCrumbItemProps {
- crumb: Crumb;
- variant: 'primary' | 'muted';
-}
-
-function BreadCrumbItem(props: BreadCrumbItemProps) {
- function onBreadcrumbLinkClick() {
- if (props.crumb.to) {
- trackAnalytics('breadcrumbs.link.clicked', {organization: null});
- }
- }
-
- return (
-
- {styleProps => {
- return props.crumb.to ? (
-
-
- {props.crumb.label}
-
-
- ) : (
-
- {props.crumb.label}
-
- );
- }}
-
- );
-}
-
-interface BreadcrumbLinkProps extends LinkProps {
- children?: React.ReactNode;
- preservePageFilters?: boolean;
-}
-
-function BreadcrumbLink(props: BreadcrumbLinkProps) {
- const {preservePageFilters, to, ...rest} = props;
- const location = useLocation();
-
- if (!to) {
- return ;
- }
-
- const toWithQuery = preservePageFilters
- ? typeof to === 'string'
- ? {pathname: to, query: extractSelectionParameters(location.query)}
- : {...to, query: {...extractSelectionParameters(location.query), ...to.query}}
- : to;
-
- return ;
-}
+export * from './breadcrumbs/index';
diff --git a/static/app/components/breadcrumbs/breadcrumbDividerCombo.tsx b/static/app/components/breadcrumbs/breadcrumbDividerCombo.tsx
new file mode 100644
index 000000000000..28657c656d1a
--- /dev/null
+++ b/static/app/components/breadcrumbs/breadcrumbDividerCombo.tsx
@@ -0,0 +1,32 @@
+import type {Responsive} from '@sentry/scraps/layout';
+import {Flex} from '@sentry/scraps/layout';
+
+import {IconSlashForward} from 'sentry/icons';
+
+interface BreadcrumbDividerComboProps {
+ children: React.ReactNode;
+ /** Controls visibility — use responsive values for container-query toggling. */
+ display?: Responsive<'flex' | 'none'>;
+}
+
+/**
+ * Internal wrapper that pairs a breadcrumb item with a trailing slash divider.
+ * Not exported — only BreadcrumbList should use this to ensure consistent structure.
+ */
+export function BreadcrumbDividerCombo({children, display}: BreadcrumbDividerComboProps) {
+ return (
+
+ {children}
+
+
+
+
+ );
+}
diff --git a/static/app/components/breadcrumbs/breadcrumbList.stories.tsx b/static/app/components/breadcrumbs/breadcrumbList.stories.tsx
new file mode 100644
index 000000000000..f6b8e13aac1f
--- /dev/null
+++ b/static/app/components/breadcrumbs/breadcrumbList.stories.tsx
@@ -0,0 +1,206 @@
+import {Fragment} from 'react';
+
+import {Container} from '@sentry/scraps/layout';
+import {LeadingGraphics} from '@sentry/scraps/leadingGraphics';
+
+import {BreadcrumbList} from 'sentry/components/breadcrumbs';
+import {IconSettings, IconStar} from 'sentry/icons';
+import * as Storybook from 'sentry/stories';
+
+export const documentation =
+ import('!!type-loader!sentry/components/breadcrumbs/breadcrumbList');
+
+export default Storybook.story('BreadcrumbList', story => {
+ story('Basic usage', () => (
+
+
+ renders a typed breadcrumb trail. Pass
+ a discriminated {' '}
+ array where each entry has an explicit{' '}
+ — 'link',{' '}
+ 'page-title', or 'select-projects'. The last item is
+ typically the current page title.
+
+ Both 'link' and 'page-title' items accept an optional{' '}
+ rendered before
+ the label. Pass a node for platform
+ icons, sentry icons, or avatars.
+
+ The 'page-title' item supports an optional structured{' '}
+ {' '}
+ slot (rendered before the label). Pass previous and next{' '}
+ items — each with an ariaLabel, optional to/
+ onClick, disabled, and tooltip. The two
+ chevron buttons are rendered internally. A{' '}
+ slot (up to
+ 52px) is also available after the label.
+
+ When the container is narrower than 800px, all 'link' parent items
+ collapse into a single … overflow button. Resize the window or
+ constrain the container to see this behaviour.
+
+
+
+
Wide (≥ 800px — all items visible)
+
+
+
+
+
+
Narrow ({'<'} 800px — link parents collapse into …)
- } />
+ } />
icon
- } />
+ } />
icon (star)
- } />
+ } />
avatar
diff --git a/static/app/components/core/leadingGraphics/index.tsx b/static/app/components/core/leadingGraphics/index.tsx
index 3a1bd84286be..f6c3b5c6296f 100644
--- a/static/app/components/core/leadingGraphics/index.tsx
+++ b/static/app/components/core/leadingGraphics/index.tsx
@@ -1,4 +1,4 @@
-import {Fragment} from 'react';
+import {cloneElement, Fragment} from 'react';
import PlatformIcon from 'platformicons/build/platformIcon';
import {Container, Stack} from '@sentry/scraps/layout';
@@ -21,7 +21,7 @@ interface LeadingGraphicsBadgeProject {
}
interface LeadingGraphicsIcon {
- icon: React.ReactNode;
+ icon: React.ReactElement<{size?: string}>;
variant: 'icon';
}
@@ -41,6 +41,9 @@ export type LeadingGraphicsProps =
*/
export function LeadingGraphics(props: LeadingGraphicsProps) {
if (props.variant === 'icon') {
+ const icon = cloneElement(props.icon, {
+ size: props.icon.props.size ?? 'md',
+ });
return (
- {props.icon}
+ {icon}
);
}
@@ -83,9 +86,9 @@ function BadgeProjectGraphic({
switch (projectPlatforms.length) {
case 0:
icons = allProjects ? (
-
+
) : (
-
+
);
break;
@@ -93,10 +96,10 @@ function BadgeProjectGraphic({
icons = (
Date: Mon, 6 Jul 2026 17:20:52 +0200
Subject: [PATCH 04/11] fix(breadcrumbs): Fix container-query collapse and
harden a11y
The BreadcrumbList container query never collapsed: the show/hide toggle
was routed through Flex, whose display resolver defaults every unspecified
breakpoint/axis slot to `flex`. That emitted an always-matching
`@media (min-width: 0px)` rule that shadowed the `@container` query and pinned
parent crumbs visible. Route the toggle through Container instead, which skips
unspecified slots so the container query drives the collapse. Non-link parents
(e.g. select-projects) now collapse below 800px too, matching the spec.
Accessibility fixes for the new items:
- Render the current-page crumb as an
(it is the page's primary heading)
while retaining the prior visual styling.
- Mark it with aria-current="page".
- Hide decorative icons (divider slash, overflow ellipsis, pagination chevrons)
from assistive tech via aria-hidden.
- Give the select-projects trigger a descriptive accessible name
("Selected Project: "), since CompactSelect does not forward a
top-level aria-label to its trigger.
- Wrap the overflow button label in t() for i18n.
Add breadcrumbList.spec.tsx covering the collapse behavior and a11y invariants.
Co-Authored-By: Claude
---
.../breadcrumbs/breadcrumbDividerCombo.tsx | 20 +-
.../components/breadcrumbs/breadcrumbList.mdx | 2 +-
.../breadcrumbs/breadcrumbList.spec.tsx | 178 ++++++++++++++++++
.../components/breadcrumbs/breadcrumbList.tsx | 25 ++-
.../items/breadcrumbItemMenuBreadcrumbs.tsx | 6 +-
.../items/breadcrumbItemPageTitle.tsx | 19 +-
.../items/breadcrumbItemSelectProjects.tsx | 26 ++-
7 files changed, 246 insertions(+), 30 deletions(-)
create mode 100644 static/app/components/breadcrumbs/breadcrumbList.spec.tsx
diff --git a/static/app/components/breadcrumbs/breadcrumbDividerCombo.tsx b/static/app/components/breadcrumbs/breadcrumbDividerCombo.tsx
index 6c5f49b86eae..b3df46257ccf 100644
--- a/static/app/components/breadcrumbs/breadcrumbDividerCombo.tsx
+++ b/static/app/components/breadcrumbs/breadcrumbDividerCombo.tsx
@@ -1,5 +1,5 @@
import type {Responsive} from '@sentry/scraps/layout';
-import {Flex} from '@sentry/scraps/layout';
+import {Container, Flex} from '@sentry/scraps/layout';
import {IconSlashForward} from 'sentry/icons';
@@ -12,14 +12,22 @@ interface BreadcrumbDividerComboProps {
/**
* Internal wrapper that pairs a breadcrumb item with a trailing slash divider.
* Not exported — only BreadcrumbList should use this to ensure consistent structure.
+ *
+ * The visibility toggle lives on a `Container`, not the inner `Flex`: `Flex`
+ * defaults every unspecified breakpoint/axis slot to `flex`, which emits an
+ * always-matching `@media (min-width: 0px)` rule that shadows the container
+ * query and pins the element visible. `Container` skips unspecified slots, so
+ * the `@container` query actually drives the collapse.
*/
export function BreadcrumbDividerCombo({children, display}: BreadcrumbDividerComboProps) {
return (
-
- {children}
-
-
+
+
+ {children}
+
+
+
-
+
);
}
diff --git a/static/app/components/breadcrumbs/breadcrumbList.mdx b/static/app/components/breadcrumbs/breadcrumbList.mdx
index 41c1ff2fed88..173ca780b1ea 100644
--- a/static/app/components/breadcrumbs/breadcrumbList.mdx
+++ b/static/app/components/breadcrumbs/breadcrumbList.mdx
@@ -223,7 +223,7 @@ trailingActions={
## Overflow (container query)
-`BreadcrumbList` sets `containerType="inline-size"` on its outer ``. When that container is narrower than 800px, all `'link'` parent items are hidden and replaced with a single `…` overflow button — only the `'page-title'` stays visible. Non-link parent types (e.g. `'select-projects'`) are never collapsed.
+`BreadcrumbList` sets `containerType="inline-size"` on its outer ``. When that container is narrower than 800px, every parent item is hidden and only the `'page-title'` stays visible. `'link'` parents are additionally replaced with a single `…` overflow button; other parent types (e.g. `'select-projects'`) simply collapse out of view — they are not added to the overflow menu.
Drag the sizing handle below to see the transition:
diff --git a/static/app/components/breadcrumbs/breadcrumbList.spec.tsx b/static/app/components/breadcrumbs/breadcrumbList.spec.tsx
new file mode 100644
index 000000000000..dc339d897c84
--- /dev/null
+++ b/static/app/components/breadcrumbs/breadcrumbList.spec.tsx
@@ -0,0 +1,178 @@
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import {BreadcrumbList} from 'sentry/components/breadcrumbs';
+
+function collectCssRules(): string[] {
+ const rules: string[] = [];
+ for (const sheet of Array.from(document.styleSheets)) {
+ let cssRules: CSSRuleList;
+ try {
+ cssRules = sheet.cssRules;
+ } catch {
+ continue;
+ }
+ for (const rule of Array.from(cssRules)) {
+ rules.push(rule.cssText);
+ }
+ }
+ return rules;
+}
+
+/**
+ * True when `element` carries the "hide below sm" container-query toggle:
+ * a base `display: none` plus an `@container (min-width: 800px) { display: flex }`
+ * that reveals it only in wide containers.
+ */
+function hidesBelowSm(element: Element): boolean {
+ const classes = (element.getAttribute('class') ?? '').split(/\s+/).filter(Boolean);
+ const own = collectCssRules().filter(r => classes.some(c => r.includes(`.${c}`)));
+ const hasBaseNone = own.some(
+ r => !r.includes('@container') && /display:\s*none/.test(r)
+ );
+ const revealsAtSm = own.some(
+ r => /@container[^{]*min-width:\s*800px/.test(r) && /display:\s*flex/.test(r)
+ );
+ return hasBaseNone && revealsAtSm;
+}
+
+describe('BreadcrumbList container-query collapse', () => {
+ let consoleError: jest.SpyInstance;
+
+ beforeEach(() => {
+ // Known pre-existing issue: the `containerType` prop leaks a `containertype`
+ // attribute onto the DOM node, which React warns about. That's a bug in the
+ // core Container primitive, unrelated to the collapse behavior under test —
+ // tolerate exactly that warning and re-throw anything else.
+ consoleError = jest.spyOn(console, 'error').mockImplementation((...args) => {
+ // React formats warnings with %s placeholders, so the offending prop name
+ // ("containerType") lands in a later arg — check them all.
+ if (args.some(arg => typeof arg === 'string' && arg.includes('containerType'))) {
+ return;
+ }
+ throw new Error(`Unexpected console.error: ${args.map(String).join(' ')}`);
+ });
+ });
+
+ afterEach(() => {
+ consoleError.mockRestore();
+ });
+
+ it('emits an @container display rule for link crumbs, not an always-on @media shadow', () => {
+ render(
+
+ );
+
+ // Presence check — the nav renders as a labelled landmark.
+ expect(screen.getByRole('navigation', {name: 'Breadcrumbs'})).toBeInTheDocument();
+
+ const rules = collectCssRules();
+
+ // The nav establishes an inline-size query container.
+ // (jsdom's getComputedStyle can't read `container-type`, so assert the rule.)
+ expect(rules.some(r => /container-type:\s*inline-size/.test(r))).toBe(true);
+
+ // The collapse must be driven by a container query at the sm (800px) breakpoint.
+ expect(rules.some(r => /@container[^{]*min-width:\s*800px/.test(r))).toBe(true);
+
+ // Regression guard: the buggy path routed the show/hide toggle through `Flex`,
+ // whose display resolver defaulted every unspecified slot to `flex`. That
+ // emitted an always-matching `@media (min-width: 0px) { display: flex }` that
+ // shadowed the container query and pinned link crumbs visible. It must not exist.
+ const alwaysOnMediaFlex = rules.some(
+ r => /@media[^{]*min-width:\s*0px/.test(r) && /display:\s*flex/.test(r)
+ );
+ expect(alwaysOnMediaFlex).toBe(false);
+ });
+
+ it('marks the current page with aria-current and hides dividers from AT', () => {
+ render(
+
+ );
+
+ // The final crumb is the page's primary heading (
) and signals
+ // "you are here" to assistive tech.
+ const current = screen.getByRole('heading', {level: 1, name: 'General'});
+ expect(current).toHaveAttribute('aria-current', 'page');
+
+ // Parent links must not be marked current.
+ expect(screen.getByRole('link', {name: 'Settings'})).not.toHaveAttribute(
+ 'aria-current'
+ );
+
+ // The decorative slash dividers are hidden from the accessibility tree.
+ const dividers = document.querySelectorAll('svg[role="img"]:not([aria-hidden])');
+ expect(dividers).toHaveLength(0);
+ });
+
+ it('gives the select-projects trigger a descriptive accessible name', async () => {
+ render(
+ {},
+ },
+ },
+ {type: 'page-title', props: {label: 'Client Keys'}},
+ ]}
+ />
+ );
+
+ // The trigger names both its purpose and the current selection.
+ // findBy lets CompactSelect's deferred mount-time state update flush in act.
+ expect(
+ await screen.findByRole('button', {name: 'Selected Project: javascript'})
+ ).toBeInTheDocument();
+ });
+
+ it('collapses non-link parents (select-projects) below the sm breakpoint', async () => {
+ render(
+ {},
+ },
+ },
+ {type: 'page-title', props: {label: 'Client Keys'}},
+ ]}
+ />
+ );
+
+ // The
wrapping the project picker hides below 800px, same as link crumbs.
+ const trigger = await screen.findByRole('button', {
+ name: 'Selected Project: javascript',
+ });
+ const selectItem = trigger.closest('li');
+ expect(selectItem).not.toBeNull();
+ expect(hidesBelowSm(selectItem!)).toBe(true);
+
+ // The last crumb is not wrapped in a hiding
— it always stays visible.
+ const current = screen.getByRole('heading', {level: 1, name: 'Client Keys'});
+ expect(hidesBelowSm(current.closest('li')!)).toBe(false);
+ });
+});
diff --git a/static/app/components/breadcrumbs/breadcrumbList.tsx b/static/app/components/breadcrumbs/breadcrumbList.tsx
index 7da5a529cf7e..bc6445497154 100644
--- a/static/app/components/breadcrumbs/breadcrumbList.tsx
+++ b/static/app/components/breadcrumbs/breadcrumbList.tsx
@@ -43,8 +43,9 @@ function renderItem(item: BreadcrumbItem) {
*
* Overflow behaviour:
* - Wide (≥ 800px): all items render individually
- * - Narrow (< 800px): all 'link' type parent items collapse into a single
- * BreadcrumbItemMenuBreadcrumbs overflow button; other parent types remain visible
+ * - Narrow (< 800px): every parent item is hidden, leaving only the last crumb.
+ * 'link' parents additionally collapse into a single BreadcrumbItemMenuBreadcrumbs
+ * overflow button; non-link parents (e.g. 'select-projects') just hide.
*/
export function BreadcrumbList({items, ...props}: BreadcrumbListProps) {
if (items.length === 0) {
@@ -81,18 +82,14 @@ export function BreadcrumbList({items, ...props}: BreadcrumbListProps) {
{...props}
>
- {parentItems.map((item, index) => {
- const isLinkItem = item.type === 'link';
- return (
- // Wide: show all parent items; Narrow: hide link items (they go in the menu)
-
- {renderItem(item)}
-
- );
- })}
+ {parentItems.map((item, index) => (
+ // Wide: show every parent item. Narrow: hide them all — 'link' parents
+ // reappear in the overflow menu below; other types (e.g. 'select-projects')
+ // simply collapse out of view.
+
+ {renderItem(item)}
+
+ ))}
{/* Overflow menu — only visible in narrow layout when there are link items to collapse */}
{menuItems.length > 0 && (
diff --git a/static/app/components/breadcrumbs/items/breadcrumbItemMenuBreadcrumbs.tsx b/static/app/components/breadcrumbs/items/breadcrumbItemMenuBreadcrumbs.tsx
index e055e35064e0..53713737b959 100644
--- a/static/app/components/breadcrumbs/items/breadcrumbItemMenuBreadcrumbs.tsx
+++ b/static/app/components/breadcrumbs/items/breadcrumbItemMenuBreadcrumbs.tsx
@@ -4,6 +4,7 @@ import type {LinkProps} from '@sentry/scraps/link';
import {DropdownButton} from 'sentry/components/dropdownButton';
import {DropdownMenu} from 'sentry/components/dropdownMenu';
import {IconEllipsis} from 'sentry/icons';
+import {t} from 'sentry/locale';
export interface BreadcrumbMenuLinkItem {
label: string;
@@ -36,11 +37,12 @@ export function BreadcrumbItemMenuBreadcrumbs({
trigger={(triggerProps, isOpen) => (
}
+ icon={}
/>
)}
/>
diff --git a/static/app/components/breadcrumbs/items/breadcrumbItemPageTitle.tsx b/static/app/components/breadcrumbs/items/breadcrumbItemPageTitle.tsx
index 4a773c29e601..b4c024f340d5 100644
--- a/static/app/components/breadcrumbs/items/breadcrumbItemPageTitle.tsx
+++ b/static/app/components/breadcrumbs/items/breadcrumbItemPageTitle.tsx
@@ -2,7 +2,7 @@ import {LinkButton} from '@sentry/scraps/button';
import {Container, Flex} from '@sentry/scraps/layout';
import type {LeadingGraphicsProps} from '@sentry/scraps/leadingGraphics';
import type {LinkProps} from '@sentry/scraps/link';
-import {Text} from '@sentry/scraps/text';
+import {Heading} from '@sentry/scraps/text';
import {Tooltip} from '@sentry/scraps/tooltip';
import {IconChevron} from 'sentry/icons';
@@ -51,7 +51,7 @@ export function BreadcrumbItemPageTitle({
}
+ icon={}
aria-label={pagination.previous.ariaLabel ?? t('Previous')}
disabled={pagination.previous.disabled}
to={pagination.previous.to}
@@ -62,7 +62,7 @@ export function BreadcrumbItemPageTitle({
}
+ icon={}
aria-label={pagination.next.ariaLabel ?? t('Next')}
disabled={pagination.next.disabled}
to={pagination.next.to}
@@ -75,15 +75,22 @@ export function BreadcrumbItemPageTitle({
{styleProps => (
- : the current-page crumb is the page's primary
+ // heading. `Heading` is always medium weight (matching the previous
+ // `Text bold`); `size="md"` retains the prior visual size instead of
+ // the h1 default (2xl).
+
{label}
-
+
)}
diff --git a/static/app/components/breadcrumbs/items/breadcrumbItemSelectProjects.tsx b/static/app/components/breadcrumbs/items/breadcrumbItemSelectProjects.tsx
index 3a9b0d0f6c2f..a3968c3c6f80 100644
--- a/static/app/components/breadcrumbs/items/breadcrumbItemSelectProjects.tsx
+++ b/static/app/components/breadcrumbs/items/breadcrumbItemSelectProjects.tsx
@@ -1,6 +1,9 @@
import {CompactSelect} from '@sentry/scraps/compactSelect';
import type {SelectKey, SelectOption} from '@sentry/scraps/compactSelect';
import {Flex} from '@sentry/scraps/layout';
+import {OverlayTrigger} from '@sentry/scraps/overlayTrigger';
+
+import {t} from 'sentry/locale';
export interface BreadcrumbItemSelectProjectsProps {
onChange: (value: SelectOption) => void;
@@ -13,9 +16,30 @@ export function BreadcrumbItemSelectProjects({
value,
onChange,
}: BreadcrumbItemSelectProjectsProps) {
+ // Prefer the selected option's human-readable label; fall back to the raw value
+ // when the label isn't a plain string (it may be a React node).
+ const selected = options.find(option => option.value === value);
+ const selectedLabel =
+ typeof selected?.label === 'string' ? selected.label : String(value);
+
return (
-
+ (
+
+ )}
+ />
);
}
From 0b32b066936502a08c447fec076f462345280a16 Mon Sep 17 00:00:00 2001
From: Jesse Box
Date: Mon, 6 Jul 2026 17:29:18 +0200
Subject: [PATCH 05/11] docs(leadingGraphics): Convert story to MDX format
Replace the index.stories.tsx story with a leadingGraphics.mdx doc that follows
the core component conventions: frontmatter (title, description, category, source),
an intro, a Usage section with an import block, live Storybook.Demo/Grid examples
with copyable code, auto-generated API tabs via the type-loader, and an
accessibility section.
Co-Authored-By: Claude
---
.../core/leadingGraphics/index.stories.tsx | 120 ---------------
.../core/leadingGraphics/leadingGraphics.mdx | 139 ++++++++++++++++++
2 files changed, 139 insertions(+), 120 deletions(-)
delete mode 100644 static/app/components/core/leadingGraphics/index.stories.tsx
create mode 100644 static/app/components/core/leadingGraphics/leadingGraphics.mdx
diff --git a/static/app/components/core/leadingGraphics/index.stories.tsx b/static/app/components/core/leadingGraphics/index.stories.tsx
deleted file mode 100644
index c299dd5bf31a..000000000000
--- a/static/app/components/core/leadingGraphics/index.stories.tsx
+++ /dev/null
@@ -1,120 +0,0 @@
-import {Fragment} from 'react';
-
-import {Flex} from '@sentry/scraps/layout';
-import {LeadingGraphics} from '@sentry/scraps/leadingGraphics';
-import {Text} from '@sentry/scraps/text';
-
-import {IconSettings, IconStar, IconUser} from 'sentry/icons';
-import * as Storybook from 'sentry/stories';
-
-export const documentation =
- import('!!type-loader!sentry/components/core/leadingGraphics');
-
-export default Storybook.story('LeadingGraphics', story => {
- story('Variants', () => (
-
-
- is a 16×16 leading graphic used by
- breadcrumb items and navigation links. It supports three{' '}
- values:{' '}
- 'icon', 'avatar', and 'badge-project'.
-
- Platform slugs are passed directly to {' '}
- from platformicons.
-
-
- {(
- [
- 'javascript',
- 'python',
- 'ruby',
- 'php',
- 'java',
- 'go',
- 'rust',
- 'react',
- 'node',
- 'dotnet',
- ] as string[]
- ).map(platform => (
-
-
- {platform}
-
- ))}
-
-
- ));
-});
diff --git a/static/app/components/core/leadingGraphics/leadingGraphics.mdx b/static/app/components/core/leadingGraphics/leadingGraphics.mdx
new file mode 100644
index 000000000000..b93669d7ab14
--- /dev/null
+++ b/static/app/components/core/leadingGraphics/leadingGraphics.mdx
@@ -0,0 +1,139 @@
+---
+title: LeadingGraphics
+description: A 16×16 leading graphic — an icon, avatar, or project badge — rendered before breadcrumb and navigation labels.
+category: display
+source: '@sentry/scraps/leadingGraphics'
+resources:
+ js: https://github.com/getsentry/sentry/blob/master/static/app/components/core/leadingGraphics/index.tsx
+---
+
+import {Flex} from '@sentry/scraps/layout';
+import {LeadingGraphics} from '@sentry/scraps/leadingGraphics';
+import {Text} from '@sentry/scraps/text';
+
+import {IconSettings, IconStar, IconUser} from 'sentry/icons';
+import * as Storybook from 'sentry/stories';
+
+export const documentation = import('!!type-loader!@sentry/scraps/leadingGraphics');
+
+`LeadingGraphics` renders a fixed 16×16 visual before a label. It's used by breadcrumb
+items and secondary-navigation links to give a crumb or link a recognizable glyph while
+keeping every leading slot the same size, so labels stay aligned.
+
+A single `variant` prop selects one of three shapes: a Sentry `icon`, an `avatar`, or a
+project `badge-project` that adapts to the number of projects it represents.
+
+## Usage
+
+```tsx
+import {LeadingGraphics} from '@sentry/scraps/leadingGraphics';
+```
+
+Choose a `variant` and pass the matching prop for that variant.
+
+
+ } />
+
+```tsx
+} />
+```
+
+## Icon
+
+The `icon` variant centers a Sentry icon in the 16×16 box. Pass any icon from
+`sentry/icons`; it's sized to fit automatically.
+
+
+
+ } />
+ } />
+
+
+```tsx
+} />
+} />
+```
+
+## Avatar
+
+The `avatar` variant centers any avatar node — a user, team, or organization avatar —
+in the same 16×16 box. Pass the avatar element via the `avatar` prop.
+
+
+ } />
+
+```tsx
+} />
+```
+
+## Project badge
+
+The `badge-project` variant renders project platform icons and adapts to how many
+platform slugs you pass in `projectPlatforms`:
+
+- **0 platforms** — a "my projects" glyph, or an "all projects" glyph when `allProjects`
+ is set.
+- **1 platform** — a single bordered platform icon.
+- **2+ platforms** — the first two platforms as stacked icons (extra platforms are not
+ shown).
+
+
+
+
+ My projects (0)
+
+
+
+ All projects (0)
+
+
+
+ 1 platform
+
+
+
+ 2 platforms
+
+
+
+ 3 platforms (first two shown)
+
+
+```tsx
+{/* No projects selected */}
+
+{/* All projects */}
+
+{/* A single project */}
+
+{/* Multiple projects — first two platforms are stacked */}
+
+```
+
+Platform slugs are passed straight through to `PlatformIcon` from `platformicons`, so any
+supported platform renders:
+
+
+ {['javascript', 'python', 'ruby', 'php', 'java', 'go', 'rust', 'node'].map(platform => (
+
+
+ {platform}
+
+ ))}
+
+
+## Accessibility
+
+Every variant renders `aria-hidden` — the graphic is decorative, and its meaning is
+carried by the label it sits beside.
+
+### Developer responsibilities
+
+- Always pair `LeadingGraphics` with a visible text label. Never rely on the graphic
+ alone to convey which page, project, or item a crumb or link points to.
From f402619659e37067549868deabeb6a0f1d56c403 Mon Sep 17 00:00:00 2001
From: Jesse Box
Date: Mon, 6 Jul 2026 18:25:48 +0200
Subject: [PATCH 06/11] ref(leadingGraphic): Rename LeadingGraphics to
LeadingGraphic
Rename the component, its props/variant types, the directory, the
@sentry/scraps import path, and the MDX story from the plural LeadingGraphics
to the singular LeadingGraphic. Update the breadcrumb items and secondary
navigation that consume it. No behavior change.
Co-Authored-By: Claude
---
.../components/breadcrumbs/breadcrumbList.mdx | 20 +++----
.../breadcrumbs/items/breadcrumbItemLink.tsx | 4 +-
.../items/breadcrumbItemPageTitle.tsx | 4 +-
.../index.tsx | 18 +++---
.../leadingGraphic.mdx} | 55 +++++++++----------
.../views/navigation/secondary/components.tsx | 6 +-
6 files changed, 51 insertions(+), 56 deletions(-)
rename static/app/components/core/{leadingGraphics => leadingGraphic}/index.tsx (92%)
rename static/app/components/core/{leadingGraphics/leadingGraphics.mdx => leadingGraphic/leadingGraphic.mdx} (64%)
diff --git a/static/app/components/breadcrumbs/breadcrumbList.mdx b/static/app/components/breadcrumbs/breadcrumbList.mdx
index 173ca780b1ea..a38a7fd2fc0e 100644
--- a/static/app/components/breadcrumbs/breadcrumbList.mdx
+++ b/static/app/components/breadcrumbs/breadcrumbList.mdx
@@ -8,7 +8,7 @@ resources:
figma: https://www.figma.com/design/a638AEl7pFxj29zMODCiOB/Specs--Page-Frame-v1?node-id=1198-17420
---
-import {LeadingGraphics} from '@sentry/scraps/leadingGraphics';
+import {LeadingGraphic} from '@sentry/scraps/leadingGraphic';
import {BreadcrumbList} from 'sentry/components/breadcrumbs';
import {IconSettings, IconStar} from 'sentry/icons';
@@ -136,10 +136,10 @@ A `CompactSelect` trigger used in settings pages where the user can switch betwe
## Leading graphics
```tsx
-import {LeadingGraphics} from '@sentry/scraps/leadingGraphics';
+import {LeadingGraphic} from '@sentry/scraps/leadingGraphic';
```
-All item types accept an optional `leadingGraphic` prop typed as `React.ReactElement`. Pass a `` element — platform icons, Sentry icons, or avatars.
+All item types accept an optional `leadingGraphic` prop typed as `React.ReactElement`. Pass a `` element — platform icons, Sentry icons, or avatars.
} />
+ } />
),
},
},
@@ -160,7 +160,7 @@ All item types accept an optional `leadingGraphic` prop typed as `React.ReactEle
label: 'javascript',
to: '/settings/sentry/projects/javascript/',
leadingGraphic: (
-
+
),
},
},
@@ -168,19 +168,17 @@ All item types accept an optional `leadingGraphic` prop typed as `React.ReactEle
type: 'page-title',
props: {
label: 'Client Keys (DSN)',
- leadingGraphic: (
- } />
- ),
+ leadingGraphic: } />,
},
},
]}
/>
```tsx leadingGraphic=
-{} />}
+{} />}
leadingGraphic=
-{}
-leadingGraphic={} />}
+{}
+leadingGraphic={} />}
```
## Pagination
diff --git a/static/app/components/breadcrumbs/items/breadcrumbItemLink.tsx b/static/app/components/breadcrumbs/items/breadcrumbItemLink.tsx
index fec9c53b84e9..9b3b4499914b 100644
--- a/static/app/components/breadcrumbs/items/breadcrumbItemLink.tsx
+++ b/static/app/components/breadcrumbs/items/breadcrumbItemLink.tsx
@@ -1,5 +1,5 @@
import {Container, Flex} from '@sentry/scraps/layout';
-import type {LeadingGraphicsProps} from '@sentry/scraps/leadingGraphics';
+import type {LeadingGraphicProps} from '@sentry/scraps/leadingGraphic';
import type {LinkProps} from '@sentry/scraps/link';
import {Link} from '@sentry/scraps/link';
import {Text} from '@sentry/scraps/text';
@@ -14,7 +14,7 @@ export interface BreadcrumbItemLinkProps {
to: LinkProps['to'];
/** Renders a tooltip showing the full label — useful when text is truncated. Defaults to true. */
labelTooltip?: boolean;
- leadingGraphic?: React.ReactElement;
+ leadingGraphic?: React.ReactElement;
preservePageFilters?: boolean;
}
diff --git a/static/app/components/breadcrumbs/items/breadcrumbItemPageTitle.tsx b/static/app/components/breadcrumbs/items/breadcrumbItemPageTitle.tsx
index b4c024f340d5..655f0d3dc3fe 100644
--- a/static/app/components/breadcrumbs/items/breadcrumbItemPageTitle.tsx
+++ b/static/app/components/breadcrumbs/items/breadcrumbItemPageTitle.tsx
@@ -1,6 +1,6 @@
import {LinkButton} from '@sentry/scraps/button';
import {Container, Flex} from '@sentry/scraps/layout';
-import type {LeadingGraphicsProps} from '@sentry/scraps/leadingGraphics';
+import type {LeadingGraphicProps} from '@sentry/scraps/leadingGraphic';
import type {LinkProps} from '@sentry/scraps/link';
import {Heading} from '@sentry/scraps/text';
import {Tooltip} from '@sentry/scraps/tooltip';
@@ -26,7 +26,7 @@ export interface BreadcrumbItemPageTitleProps {
label: string;
/** Renders a tooltip showing the full label — useful when text is truncated. Defaults to true. */
labelTooltip?: boolean;
- leadingGraphic?: React.ReactElement;
+ leadingGraphic?: React.ReactElement;
/** Structured prev/next navigation rendered before the label. */
pagination?: BreadcrumbItemPaginationProps;
/** Trailing action slot (up to 52px wide). */
diff --git a/static/app/components/core/leadingGraphics/index.tsx b/static/app/components/core/leadingGraphic/index.tsx
similarity index 92%
rename from static/app/components/core/leadingGraphics/index.tsx
rename to static/app/components/core/leadingGraphic/index.tsx
index f6c3b5c6296f..a22ecabc49c3 100644
--- a/static/app/components/core/leadingGraphics/index.tsx
+++ b/static/app/components/core/leadingGraphic/index.tsx
@@ -7,7 +7,7 @@ import {IconAllProjects, IconMyProjects} from 'sentry/icons';
// badge-project variant — absorbs the 0/1/2+ platform logic from
// SecondaryNavigationProjectIcon so both nav and breadcrumbs share one component.
-interface LeadingGraphicsBadgeProject {
+interface LeadingGraphicBadgeProject {
/**
* Platform slugs for the project(s) to display.
* - 0 entries: renders an all-projects or my-projects icon
@@ -20,26 +20,26 @@ interface LeadingGraphicsBadgeProject {
allProjects?: boolean;
}
-interface LeadingGraphicsIcon {
+interface LeadingGraphicIcon {
icon: React.ReactElement<{size?: string}>;
variant: 'icon';
}
-interface LeadingGraphicsAvatar {
+interface LeadingGraphicAvatar {
avatar: React.ReactNode;
variant: 'avatar';
}
-export type LeadingGraphicsProps =
- | LeadingGraphicsBadgeProject
- | LeadingGraphicsIcon
- | LeadingGraphicsAvatar;
+export type LeadingGraphicProps =
+ | LeadingGraphicBadgeProject
+ | LeadingGraphicIcon
+ | LeadingGraphicAvatar;
/**
* A 16×16 leading graphic used by breadcrumb items and navigation links.
* Supports three visual variants: badge-project (platform icons), icon, and avatar.
*/
-export function LeadingGraphics(props: LeadingGraphicsProps) {
+export function LeadingGraphic(props: LeadingGraphicProps) {
if (props.variant === 'icon') {
const icon = cloneElement(props.icon, {
size: props.icon.props.size ?? 'md',
@@ -80,7 +80,7 @@ export function LeadingGraphics(props: LeadingGraphicsProps) {
function BadgeProjectGraphic({
projectPlatforms,
allProjects,
-}: LeadingGraphicsBadgeProject) {
+}: LeadingGraphicBadgeProject) {
let icons: React.ReactNode;
switch (projectPlatforms.length) {
diff --git a/static/app/components/core/leadingGraphics/leadingGraphics.mdx b/static/app/components/core/leadingGraphic/leadingGraphic.mdx
similarity index 64%
rename from static/app/components/core/leadingGraphics/leadingGraphics.mdx
rename to static/app/components/core/leadingGraphic/leadingGraphic.mdx
index b93669d7ab14..c476880c3a40 100644
--- a/static/app/components/core/leadingGraphics/leadingGraphics.mdx
+++ b/static/app/components/core/leadingGraphic/leadingGraphic.mdx
@@ -1,22 +1,22 @@
---
-title: LeadingGraphics
+title: LeadingGraphic
description: A 16×16 leading graphic — an icon, avatar, or project badge — rendered before breadcrumb and navigation labels.
category: display
-source: '@sentry/scraps/leadingGraphics'
+source: '@sentry/scraps/leadingGraphic'
resources:
- js: https://github.com/getsentry/sentry/blob/master/static/app/components/core/leadingGraphics/index.tsx
+ js: https://github.com/getsentry/sentry/blob/master/static/app/components/core/leadingGraphic/index.tsx
---
import {Flex} from '@sentry/scraps/layout';
-import {LeadingGraphics} from '@sentry/scraps/leadingGraphics';
+import {LeadingGraphic} from '@sentry/scraps/leadingGraphic';
import {Text} from '@sentry/scraps/text';
import {IconSettings, IconStar, IconUser} from 'sentry/icons';
import * as Storybook from 'sentry/stories';
-export const documentation = import('!!type-loader!@sentry/scraps/leadingGraphics');
+export const documentation = import('!!type-loader!@sentry/scraps/leadingGraphic');
-`LeadingGraphics` renders a fixed 16×16 visual before a label. It's used by breadcrumb
+`LeadingGraphic` renders a fixed 16×16 visual before a label. It's used by breadcrumb
items and secondary-navigation links to give a crumb or link a recognizable glyph while
keeping every leading slot the same size, so labels stay aligned.
@@ -26,16 +26,16 @@ project `badge-project` that adapts to the number of projects it represents.
## Usage
```tsx
-import {LeadingGraphics} from '@sentry/scraps/leadingGraphics';
+import {LeadingGraphic} from '@sentry/scraps/leadingGraphic';
```
Choose a `variant` and pass the matching prop for that variant.
- } />
+ } />
```tsx
-} />
+} />
```
## Icon
@@ -45,13 +45,13 @@ The `icon` variant centers a Sentry icon in the 16×16 box. Pass any icon from
- } />
- } />
+ } />
+ } />
```tsx
-} />
-} />
+} />
+} />
```
## Avatar
@@ -60,10 +60,10 @@ The `avatar` variant centers any avatar node — a user, team, or organization a
in the same 16×16 box. Pass the avatar element via the `avatar` prop.
- } />
+ } />
```tsx
-} />
+} />
```
## Project badge
@@ -79,26 +79,23 @@ platform slugs you pass in `projectPlatforms`:
-
+ My projects (0)
-
+ All projects (0)
-
+ 1 platform
-
+ 2 platforms
-
@@ -107,13 +104,13 @@ platform slugs you pass in `projectPlatforms`:
```tsx
{/* No projects selected */}
-
+
{/* All projects */}
-
+
{/* A single project */}
-
+
{/* Multiple projects — first two platforms are stacked */}
-
+
```
Platform slugs are passed straight through to `PlatformIcon` from `platformicons`, so any
@@ -122,7 +119,7 @@ supported platform renders:
{['javascript', 'python', 'ruby', 'php', 'java', 'go', 'rust', 'node'].map(platform => (
-
+ {platform}
))}
@@ -135,5 +132,5 @@ carried by the label it sits beside.
### Developer responsibilities
-- Always pair `LeadingGraphics` with a visible text label. Never rely on the graphic
+- Always pair `LeadingGraphic` with a visible text label. Never rely on the graphic
alone to convey which page, project, or item a crumb or link points to.
diff --git a/static/app/views/navigation/secondary/components.tsx b/static/app/views/navigation/secondary/components.tsx
index 68b4c4610b40..d9a5cdec016b 100644
--- a/static/app/views/navigation/secondary/components.tsx
+++ b/static/app/views/navigation/secondary/components.tsx
@@ -33,7 +33,7 @@ import {AnimatePresence, motion} from 'framer-motion';
import {Button} from '@sentry/scraps/button';
import {Container, Flex, Grid, Stack} from '@sentry/scraps/layout';
-import {LeadingGraphics} from '@sentry/scraps/leadingGraphics';
+import {LeadingGraphic} from '@sentry/scraps/leadingGraphic';
import {Link, type LinkProps} from '@sentry/scraps/link';
import {Separator} from '@sentry/scraps/separator';
import {Text} from '@sentry/scraps/text';
@@ -495,7 +495,7 @@ interface SecondaryNavigationProjectIconProps {
function SecondaryNavigationProjectIcon(props: SecondaryNavigationProjectIconProps) {
return (
- // Keep the 18×18 nav-specific outer Stack; LeadingGraphics renders at 16×16 inside it.
+ // Keep the 18×18 nav-specific outer Stack; LeadingGraphic renders at 16×16 inside it.
-
Date: Tue, 7 Jul 2026 10:09:06 +0200
Subject: [PATCH 07/11] fix(breadcrumbs): Restore rich labels and green up CI
The legacy Breadcrumbs shim coerced any non-string label to '', silently
dropping crumbs whose label is React content (editable page titles/IDs on
Discover, Issues, Automations, and Detectors). This blanked out the current
crumb on those pages and was the cause of the acceptance-test timeouts.
Rewrite the shim as a faithful port of the original component so ReactNode
labels render as-is, always rendering as a landmark. Because it no
longer delegates to the string-only typed API, the redundant as="nav" at the
two remaining call sites is dropped.
Also fix the container-query collapse leaking containerType onto the DOM:
emotion's as swap bypasses the wrapper that strips the prop, so move
containerType onto a nested plain Container that still establishes the query
container at full width.
Keep the not-yet-consumed typed API (BreadcrumbList/BreadcrumbListProps/
BreadcrumbItem) exported and tagged @public for a downstream migration PR;
keep pure implementation-detail interfaces internal. Drop dead ariaLabel
fallbacks now that the pagination prop is required.
Add a regression test covering React-node crumb labels.
Co-Authored-By: Claude Opus 4.8
---
static/app/components/breadcrumbs.spec.tsx | 15 ++
.../components/breadcrumbs/breadcrumbList.tsx | 56 ++++---
static/app/components/breadcrumbs/index.tsx | 147 ++++++++++++------
.../items/breadcrumbItemMenuBreadcrumbs.tsx | 2 +-
.../items/breadcrumbItemPageTitle.tsx | 9 +-
static/app/components/events/eventDrawer.tsx | 2 +-
.../components/widgetBuilderSlideout.tsx | 2 +-
7 files changed, 158 insertions(+), 75 deletions(-)
diff --git a/static/app/components/breadcrumbs.spec.tsx b/static/app/components/breadcrumbs.spec.tsx
index 0a4087281258..9eb6ebda4d3f 100644
--- a/static/app/components/breadcrumbs.spec.tsx
+++ b/static/app/components/breadcrumbs.spec.tsx
@@ -48,4 +48,19 @@ describe('Breadcrumbs', () => {
await userEvent.click(screen.getByText('Test 3'));
expect(router.location.pathname).toBe(initialPathname);
});
+
+ it('renders non-string (React node) labels', () => {
+ // Many call sites pass rich content as the current crumb — e.g. an editable
+ // name field. The shim must render it as-is, not coerce it away.
+ render(
+ Editable Name, to: null},
+ ]}
+ />
+ );
+
+ expect(screen.getByRole('button', {name: 'Editable Name'})).toBeInTheDocument();
+ });
});
diff --git a/static/app/components/breadcrumbs/breadcrumbList.tsx b/static/app/components/breadcrumbs/breadcrumbList.tsx
index bc6445497154..600ac69d1e30 100644
--- a/static/app/components/breadcrumbs/breadcrumbList.tsx
+++ b/static/app/components/breadcrumbs/breadcrumbList.tsx
@@ -11,11 +11,13 @@ import type {BreadcrumbItemSelectProjectsProps} from './items/breadcrumbItemSele
import {BreadcrumbItemSelectProjects} from './items/breadcrumbItemSelectProjects';
import {BreadcrumbDividerCombo} from './breadcrumbDividerCombo';
+/** @public Public API of the redesigned breadcrumbs; consumers migrate onto it in a downstream PR. */
export type BreadcrumbItem =
| {props: BreadcrumbItemLinkProps; type: 'link'}
| {props: BreadcrumbItemPageTitleProps; type: 'page-title'}
| {props: BreadcrumbItemSelectProjectsProps; type: 'select-projects'};
+/** @public Public API of the redesigned breadcrumbs; consumers migrate onto it in a downstream PR. */
export interface BreadcrumbListProps extends React.HTMLAttributes {
items: BreadcrumbItem[];
}
@@ -71,38 +73,46 @@ export function BreadcrumbList({items, ...props}: BreadcrumbListProps) {
const showNarrow = {sm: 'none', '2xs': 'flex'} as const;
return (
- // containerType="inline-size" makes this element a container for @container queries.
- // Must use the standard children form (not render-prop) to use containerType.
-
- {parentItems.map((item, index) => (
- // Wide: show every parent item. Narrow: hide them all — 'link' parents
- // reappear in the overflow menu below; other types (e.g. 'select-projects')
- // simply collapse out of view.
-
- {renderItem(item)}
-
- ))}
+ {/*
+ * The query container is this inner element, not the above. emotion's
+ * `as` swap on a styled component bypasses the wrapper that strips
+ * `containerType`, so pairing `as="nav"` with `containerType` would leak the
+ * prop onto the DOM node. Keeping `containerType` on an un-swapped Container
+ * (rendered as a plain div) lets the primitive strip it as intended while
+ * still establishing the container at full width — the @container collapse
+ * below resolves against it either way.
+ */}
+
+
+ {parentItems.map((item, index) => (
+ // Wide: show every parent item. Narrow: hide them all — 'link' parents
+ // reappear in the overflow menu below; other types (e.g. 'select-projects')
+ // simply collapse out of view.
+
+ {renderItem(item)}
+
+ ))}
- {/* Overflow menu — only visible in narrow layout when there are link items to collapse */}
- {menuItems.length > 0 && (
-
-
-
- )}
+ {/* Overflow menu — only visible in narrow layout when there are link items to collapse */}
+ {menuItems.length > 0 && (
+
+
+
+ )}
- {/* Page title — always visible, no divider after it */}
-
- {renderItem(lastItem)}
-
-
+ {/* Page title — always visible, no divider after it */}
+
+ {renderItem(lastItem)}
+
+
+
);
}
diff --git a/static/app/components/breadcrumbs/index.tsx b/static/app/components/breadcrumbs/index.tsx
index 1d856f5f79d2..3823292365ab 100644
--- a/static/app/components/breadcrumbs/index.tsx
+++ b/static/app/components/breadcrumbs/index.tsx
@@ -12,35 +12,37 @@
* />
*
* ## Legacy API (compatibility shim)
- * The old `` signature continues to work unchanged.
- * All non-last crumbs become `type: 'link'`; the last crumb becomes `type: 'page-title'`.
- * Migrate call sites to `BreadcrumbList` when making changes in those files.
+ * The old `` signature continues to work unchanged,
+ * including crumbs whose `label` is arbitrary React content (e.g. an editable
+ * name field). Migrate call sites to `BreadcrumbList` when making changes in
+ * those files — but note the new API only accepts string labels.
*/
+import {Fragment} from 'react';
-export {BreadcrumbList} from './breadcrumbList';
-export type {BreadcrumbItem, BreadcrumbListProps} from './breadcrumbList';
-
-export {BreadcrumbItemLink} from './items/breadcrumbItemLink';
-export type {BreadcrumbItemLinkProps} from './items/breadcrumbItemLink';
+import {Container, Flex} from '@sentry/scraps/layout';
+import type {LinkProps} from '@sentry/scraps/link';
+import {Link} from '@sentry/scraps/link';
+import {Text} from '@sentry/scraps/text';
-export {BreadcrumbItemPageTitle} from './items/breadcrumbItemPageTitle';
-export type {
- BreadcrumbItemPaginationProps,
- BreadcrumbItemPageTitleProps,
- BreadcrumbPaginationItem,
-} from './items/breadcrumbItemPageTitle';
+import {extractSelectionParameters} from 'sentry/components/pageFilters/parse';
+import {IconSlashForward} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {trackAnalytics} from 'sentry/utils/analytics';
+import {useLocation} from 'sentry/utils/useLocation';
-export {BreadcrumbItemSelectProjects} from './items/breadcrumbItemSelectProjects';
-export type {BreadcrumbItemSelectProjectsProps} from './items/breadcrumbItemSelectProjects';
+// The new typed API. Individual item components and their prop types are
+// implementation details of `BreadcrumbList` and are intentionally not
+// re-exported until a consumer needs them — re-export them here when migrating
+// a call site to the typed API.
+export {BreadcrumbList} from './breadcrumbList';
+/** @public Consumed once call sites migrate onto the typed API in a downstream PR. */
+export type {BreadcrumbItem, BreadcrumbListProps} from './breadcrumbList';
// ── Legacy compatibility shim ─────────────────────────────────────────────────
// Preserves the old `Breadcrumbs` + `Crumb` API so existing call sites need no
-// changes. Maps the flat crumbs array to `BreadcrumbList` typed items.
-
-import type {LinkProps} from '@sentry/scraps/link';
-
-import type {BreadcrumbItem} from './breadcrumbList';
-import {BreadcrumbList} from './breadcrumbList';
+// changes. Unlike the new `BreadcrumbList`, this renders each crumb's `label`
+// as-is, so callers passing React nodes (editable titles, badges, …) keep
+// working. New code should prefer the typed `BreadcrumbList` API above.
export interface Crumb {
label: NonNullable;
@@ -60,29 +62,86 @@ export function Breadcrumbs({crumbs, ...props}: BreadcrumbsProps) {
return null;
}
- const items: BreadcrumbItem[] = crumbs.map((crumb, index) => {
- const isLast = index === crumbs.length - 1;
- // The new API requires string labels (used for tooltip text).
- // Non-string labels from the legacy API are coerced to empty string;
- // callers passing React nodes should migrate to the new BreadcrumbList API.
- const label = typeof crumb.label === 'string' ? crumb.label : '';
+ return (
+
+ {crumbs.map((crumb, index) => {
+ const isLast = index === crumbs.length - 1;
+ return (
+
+
+ {isLast ? null : (
+
+
+
+ )}
+
+ );
+ })}
+
+ );
+}
- if (isLast) {
- return {
- props: {label},
- type: 'page-title',
- };
- }
+interface BreadcrumbItemProps {
+ crumb: Crumb;
+ variant: 'primary' | 'muted';
+}
+
+function BreadcrumbItem({crumb, variant}: BreadcrumbItemProps) {
+ return (
+
+ {styleProps =>
+ crumb.to ? (
+
+ trackAnalytics('breadcrumbs.link.clicked', {organization: null})
+ }
+ {...styleProps}
+ >
+
+ {crumb.label}
+
+
+ ) : (
+
+ {crumb.label}
+
+ )
+ }
+
+ );
+}
+
+interface BreadcrumbLinkProps extends LinkProps {
+ children?: React.ReactNode;
+ preservePageFilters?: boolean;
+}
+
+function BreadcrumbLink({preservePageFilters, to, ...rest}: BreadcrumbLinkProps) {
+ const location = useLocation();
+
+ if (!to) {
+ return ;
+ }
- return {
- props: {
- label,
- preservePageFilters: crumb.preservePageFilters,
- to: crumb.to ?? '/',
- },
- type: 'link',
- };
- });
+ const toWithQuery = preservePageFilters
+ ? typeof to === 'string'
+ ? {pathname: to, query: extractSelectionParameters(location.query)}
+ : {...to, query: {...extractSelectionParameters(location.query), ...to.query}}
+ : to;
- return ;
+ return ;
}
diff --git a/static/app/components/breadcrumbs/items/breadcrumbItemMenuBreadcrumbs.tsx b/static/app/components/breadcrumbs/items/breadcrumbItemMenuBreadcrumbs.tsx
index 53713737b959..0bf3fd52c98c 100644
--- a/static/app/components/breadcrumbs/items/breadcrumbItemMenuBreadcrumbs.tsx
+++ b/static/app/components/breadcrumbs/items/breadcrumbItemMenuBreadcrumbs.tsx
@@ -6,7 +6,7 @@ import {DropdownMenu} from 'sentry/components/dropdownMenu';
import {IconEllipsis} from 'sentry/icons';
import {t} from 'sentry/locale';
-export interface BreadcrumbMenuLinkItem {
+interface BreadcrumbMenuLinkItem {
label: string;
to: LinkProps['to'];
}
diff --git a/static/app/components/breadcrumbs/items/breadcrumbItemPageTitle.tsx b/static/app/components/breadcrumbs/items/breadcrumbItemPageTitle.tsx
index 655f0d3dc3fe..60ee81864ce5 100644
--- a/static/app/components/breadcrumbs/items/breadcrumbItemPageTitle.tsx
+++ b/static/app/components/breadcrumbs/items/breadcrumbItemPageTitle.tsx
@@ -6,9 +6,8 @@ import {Heading} from '@sentry/scraps/text';
import {Tooltip} from '@sentry/scraps/tooltip';
import {IconChevron} from 'sentry/icons';
-import {t} from 'sentry/locale';
-export interface BreadcrumbPaginationItem {
+interface BreadcrumbPaginationItem {
ariaLabel: string;
to: LinkProps['to'];
disabled?: boolean;
@@ -17,7 +16,7 @@ export interface BreadcrumbPaginationItem {
tooltip?: React.ReactNode;
}
-export interface BreadcrumbItemPaginationProps {
+interface BreadcrumbItemPaginationProps {
next: BreadcrumbPaginationItem;
previous: BreadcrumbPaginationItem;
}
@@ -52,7 +51,7 @@ export function BreadcrumbItemPageTitle({
size="zero"
variant="transparent"
icon={}
- aria-label={pagination.previous.ariaLabel ?? t('Previous')}
+ aria-label={pagination.previous.ariaLabel}
disabled={pagination.previous.disabled}
to={pagination.previous.to}
onClick={pagination.previous.onClick}
@@ -63,7 +62,7 @@ export function BreadcrumbItemPageTitle({
size="zero"
variant="transparent"
icon={}
- aria-label={pagination.next.ariaLabel ?? t('Next')}
+ aria-label={pagination.next.ariaLabel}
disabled={pagination.next.disabled}
to={pagination.next.to}
onClick={pagination.next.onClick}
diff --git a/static/app/components/events/eventDrawer.tsx b/static/app/components/events/eventDrawer.tsx
index 9e93582ce835..8de2a3031337 100644
--- a/static/app/components/events/eventDrawer.tsx
+++ b/static/app/components/events/eventDrawer.tsx
@@ -39,7 +39,7 @@ const StyledNavigationCrumbs = styled(NavigationBreadcrumbs)`
export function NavigationCrumbs(
props: ComponentPropsWithoutRef
) {
- return ;
+ return ;
}
export function CrumbContainer(props: FlexProps) {
diff --git a/static/app/views/dashboards/widgetBuilder/components/widgetBuilderSlideout.tsx b/static/app/views/dashboards/widgetBuilder/components/widgetBuilderSlideout.tsx
index ace2bb29939f..0f8004e3384a 100644
--- a/static/app/views/dashboards/widgetBuilder/components/widgetBuilderSlideout.tsx
+++ b/static/app/views/dashboards/widgetBuilder/components/widgetBuilderSlideout.tsx
@@ -308,7 +308,7 @@ function WidgetBuilderSlideoutInner({
height="44px"
padding="0 2xl"
>
-
+
Date: Tue, 7 Jul 2026 12:34:04 +0200
Subject: [PATCH 08/11] ref(breadcrumbs): Ship BreadcrumbList standalone; keep
legacy as-is
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The earlier legacy shim rerouted the deprecated Breadcrumbs through the
redesigned component and always rendered a , which hijacked
the accessible name of the page
on the ~23 pages that nest breadcrumbs
inside the TopBar title heading. Revert that: legacy Breadcrumbs (and the two
as="nav" call sites) are restored byte-identical to master, with no call-site
migration.
Move the redesigned component to its own directory
(sentry/components/breadcrumbList) and render it as inline content — no
landmark and no own
— so it nests cleanly inside the TopBar title
,
which owns the heading/landmark semantics. The typed API and container-query
collapse are unchanged.
Migrate the detector details header (views/detectors/components/details/common/
header.tsx) to BreadcrumbList as the first production consumer, which is
required for knip:prod to pass.
Co-Authored-By: Claude Opus 4.8
---
.../breadcrumbDividerCombo.tsx | 0
.../breadcrumbList.mdx | 14 +-
.../breadcrumbList.spec.tsx | 22 +--
.../breadcrumbList.tsx | 16 +-
.../app/components/breadcrumbList/index.tsx | 3 +
.../items/breadcrumbItemLink.tsx | 0
.../items/breadcrumbItemMenuBreadcrumbs.tsx | 0
.../items/breadcrumbItemPageTitle.tsx | 18 +--
.../items/breadcrumbItemSelectProjects.tsx | 0
static/app/components/breadcrumbs.spec.tsx | 15 --
static/app/components/breadcrumbs.tsx | 138 +++++++++++++++-
static/app/components/breadcrumbs/index.tsx | 147 ------------------
static/app/components/events/eventDrawer.tsx | 2 +-
.../components/widgetBuilderSlideout.tsx | 2 +-
.../components/details/common/header.tsx | 22 ++-
15 files changed, 186 insertions(+), 213 deletions(-)
rename static/app/components/{breadcrumbs => breadcrumbList}/breadcrumbDividerCombo.tsx (100%)
rename static/app/components/{breadcrumbs => breadcrumbList}/breadcrumbList.mdx (88%)
rename static/app/components/{breadcrumbs => breadcrumbList}/breadcrumbList.spec.tsx (87%)
rename static/app/components/{breadcrumbs => breadcrumbList}/breadcrumbList.tsx (91%)
create mode 100644 static/app/components/breadcrumbList/index.tsx
rename static/app/components/{breadcrumbs => breadcrumbList}/items/breadcrumbItemLink.tsx (100%)
rename static/app/components/{breadcrumbs => breadcrumbList}/items/breadcrumbItemMenuBreadcrumbs.tsx (100%)
rename static/app/components/{breadcrumbs => breadcrumbList}/items/breadcrumbItemPageTitle.tsx (85%)
rename static/app/components/{breadcrumbs => breadcrumbList}/items/breadcrumbItemSelectProjects.tsx (100%)
delete mode 100644 static/app/components/breadcrumbs/index.tsx
diff --git a/static/app/components/breadcrumbs/breadcrumbDividerCombo.tsx b/static/app/components/breadcrumbList/breadcrumbDividerCombo.tsx
similarity index 100%
rename from static/app/components/breadcrumbs/breadcrumbDividerCombo.tsx
rename to static/app/components/breadcrumbList/breadcrumbDividerCombo.tsx
diff --git a/static/app/components/breadcrumbs/breadcrumbList.mdx b/static/app/components/breadcrumbList/breadcrumbList.mdx
similarity index 88%
rename from static/app/components/breadcrumbs/breadcrumbList.mdx
rename to static/app/components/breadcrumbList/breadcrumbList.mdx
index a38a7fd2fc0e..3dcfece30a25 100644
--- a/static/app/components/breadcrumbs/breadcrumbList.mdx
+++ b/static/app/components/breadcrumbList/breadcrumbList.mdx
@@ -2,27 +2,27 @@
title: BreadcrumbList
description: A typed, accessible breadcrumb trail that collapses parent links into an overflow menu in narrow containers.
category: navigation
-source: 'sentry/components/breadcrumbs'
+source: 'sentry/components/breadcrumbList'
resources:
- js: https://github.com/getsentry/sentry/blob/master/static/app/components/breadcrumbs/breadcrumbList.tsx
+ js: https://github.com/getsentry/sentry/blob/master/static/app/components/breadcrumbList/breadcrumbList.tsx
figma: https://www.figma.com/design/a638AEl7pFxj29zMODCiOB/Specs--Page-Frame-v1?node-id=1198-17420
---
import {LeadingGraphic} from '@sentry/scraps/leadingGraphic';
-import {BreadcrumbList} from 'sentry/components/breadcrumbs';
+import {BreadcrumbList} from 'sentry/components/breadcrumbList';
import {IconSettings, IconStar} from 'sentry/icons';
import * as Storybook from 'sentry/stories';
export const documentation =
- import('!!type-loader!sentry/components/breadcrumbs/breadcrumbList');
+ import('!!type-loader!sentry/components/breadcrumbList/breadcrumbList');
-`BreadcrumbList` renders a horizontal breadcrumb trail as a semantic `` element. Each slot in the trail has an explicit `type` — developers must consciously choose a variant rather than having it inferred from position. At container widths below 800px, parent link crumbs automatically collapse into a single overflow (`…`) menu via a container query, keeping the page title always visible.
+`BreadcrumbList` renders a horizontal breadcrumb trail as inline content. It is designed to sit inside the page's heading (e.g. the TopBar title `
`), which owns the landmark and heading semantics — so the trail itself renders no `` landmark and the current-page crumb is inline text, not its own heading. Each slot in the trail has an explicit `type` — developers must consciously choose a variant rather than having it inferred from position. At container widths below 800px, parent link crumbs automatically collapse into a single overflow (`…`) menu via a container query, keeping the page title always visible.
## Usage
```tsx
-import {BreadcrumbList} from 'sentry/components/breadcrumbs';
+import {BreadcrumbList} from 'sentry/components/breadcrumbList';
```
Pass a typed `items` array. Each entry is a discriminated union with a `type` key and a `props` object matching that type's interface.
@@ -221,7 +221,7 @@ trailingActions={
## Overflow (container query)
-`BreadcrumbList` sets `containerType="inline-size"` on its outer ``. When that container is narrower than 800px, every parent item is hidden and only the `'page-title'` stays visible. `'link'` parents are additionally replaced with a single `…` overflow button; other parent types (e.g. `'select-projects'`) simply collapse out of view — they are not added to the overflow menu.
+`BreadcrumbList` sets `containerType="inline-size"` on its outer element. When that container is narrower than 800px, every parent item is hidden and only the `'page-title'` stays visible. `'link'` parents are additionally replaced with a single `…` overflow button; other parent types (e.g. `'select-projects'`) simply collapse out of view — they are not added to the overflow menu.
Drag the sizing handle below to see the transition:
diff --git a/static/app/components/breadcrumbs/breadcrumbList.spec.tsx b/static/app/components/breadcrumbList/breadcrumbList.spec.tsx
similarity index 87%
rename from static/app/components/breadcrumbs/breadcrumbList.spec.tsx
rename to static/app/components/breadcrumbList/breadcrumbList.spec.tsx
index dc339d897c84..2b606f649a3b 100644
--- a/static/app/components/breadcrumbs/breadcrumbList.spec.tsx
+++ b/static/app/components/breadcrumbList/breadcrumbList.spec.tsx
@@ -1,6 +1,6 @@
import {render, screen} from 'sentry-test/reactTestingLibrary';
-import {BreadcrumbList} from 'sentry/components/breadcrumbs';
+import {BreadcrumbList} from 'sentry/components/breadcrumbList';
function collectCssRules(): string[] {
const rules: string[] = [];
@@ -67,12 +67,12 @@ describe('BreadcrumbList container-query collapse', () => {
/>
);
- // Presence check — the nav renders as a labelled landmark.
- expect(screen.getByRole('navigation', {name: 'Breadcrumbs'})).toBeInTheDocument();
+ // Presence check — the list renders (as inline content, not a landmark).
+ expect(screen.getByTestId('breadcrumb-list')).toBeInTheDocument();
const rules = collectCssRules();
- // The nav establishes an inline-size query container.
+ // The list establishes an inline-size query container.
// (jsdom's getComputedStyle can't read `container-type`, so assert the rule.)
expect(rules.some(r => /container-type:\s*inline-size/.test(r))).toBe(true);
@@ -89,7 +89,7 @@ describe('BreadcrumbList container-query collapse', () => {
expect(alwaysOnMediaFlex).toBe(false);
});
- it('marks the current page with aria-current and hides dividers from AT', () => {
+ it('renders the current page as inline text and hides dividers from AT', () => {
render(
{
/>
);
- // The final crumb is the page's primary heading (
) and signals
- // "you are here" to assistive tech.
- const current = screen.getByRole('heading', {level: 1, name: 'General'});
- expect(current).toHaveAttribute('aria-current', 'page');
+ // The current-page crumb renders as inline text, not a heading — the
+ // surrounding context (e.g. the TopBar title
) owns the page heading, so
+ // the crumb's label must not surface as its own heading.
+ expect(screen.getByTestId('breadcrumb-item')).toHaveTextContent('General');
+ expect(screen.queryByRole('heading', {name: 'General'})).not.toBeInTheDocument();
// Parent links must not be marked current.
expect(screen.getByRole('link', {name: 'Settings'})).not.toHaveAttribute(
@@ -172,7 +173,8 @@ describe('BreadcrumbList container-query collapse', () => {
expect(hidesBelowSm(selectItem!)).toBe(true);
// The last crumb is not wrapped in a hiding
— it always stays visible.
- const current = screen.getByRole('heading', {level: 1, name: 'Client Keys'});
+ const current = screen.getByTestId('breadcrumb-item');
+ expect(current).toHaveTextContent('Client Keys');
expect(hidesBelowSm(current.closest('li')!)).toBe(false);
});
});
diff --git a/static/app/components/breadcrumbs/breadcrumbList.tsx b/static/app/components/breadcrumbList/breadcrumbList.tsx
similarity index 91%
rename from static/app/components/breadcrumbs/breadcrumbList.tsx
rename to static/app/components/breadcrumbList/breadcrumbList.tsx
index 600ac69d1e30..ac8073b35a7c 100644
--- a/static/app/components/breadcrumbs/breadcrumbList.tsx
+++ b/static/app/components/breadcrumbList/breadcrumbList.tsx
@@ -1,7 +1,5 @@
import {Container, Flex} from '@sentry/scraps/layout';
-import {t} from 'sentry/locale';
-
import type {BreadcrumbItemLinkProps} from './items/breadcrumbItemLink';
import {BreadcrumbItemLink} from './items/breadcrumbItemLink';
import {BreadcrumbItemMenuBreadcrumbs} from './items/breadcrumbItemMenuBreadcrumbs';
@@ -48,6 +46,8 @@ function renderItem(item: BreadcrumbItem) {
* - Narrow (< 800px): every parent item is hidden, leaving only the last crumb.
* 'link' parents additionally collapse into a single BreadcrumbItemMenuBreadcrumbs
* overflow button; non-link parents (e.g. 'select-projects') just hide.
+ *
+ * @public Consumed once call sites migrate onto the typed API in a downstream PR.
*/
export function BreadcrumbList({items, ...props}: BreadcrumbListProps) {
if (items.length === 0) {
@@ -73,13 +73,11 @@ export function BreadcrumbList({items, ...props}: BreadcrumbListProps) {
const showNarrow = {sm: 'none', '2xs': 'flex'} as const;
return (
-
+ // Renders as inline content (no landmark, no own heading): breadcrumbs
+ // are placed inside the page heading (e.g. the TopBar title
), which owns
+ // the landmark/heading semantics. A `` here would both nest
+ // invalidly and override that
's accessible name.
+
{/*
* The query container is this inner element, not the above. emotion's
* `as` swap on a styled component bypasses the wrapper that strips
diff --git a/static/app/components/breadcrumbList/index.tsx b/static/app/components/breadcrumbList/index.tsx
new file mode 100644
index 000000000000..b7e3b6b00efd
--- /dev/null
+++ b/static/app/components/breadcrumbList/index.tsx
@@ -0,0 +1,3 @@
+export {BreadcrumbList} from './breadcrumbList';
+/** @public Consumed once call sites migrate onto the typed API in a downstream PR. */
+export type {BreadcrumbItem, BreadcrumbListProps} from './breadcrumbList';
diff --git a/static/app/components/breadcrumbs/items/breadcrumbItemLink.tsx b/static/app/components/breadcrumbList/items/breadcrumbItemLink.tsx
similarity index 100%
rename from static/app/components/breadcrumbs/items/breadcrumbItemLink.tsx
rename to static/app/components/breadcrumbList/items/breadcrumbItemLink.tsx
diff --git a/static/app/components/breadcrumbs/items/breadcrumbItemMenuBreadcrumbs.tsx b/static/app/components/breadcrumbList/items/breadcrumbItemMenuBreadcrumbs.tsx
similarity index 100%
rename from static/app/components/breadcrumbs/items/breadcrumbItemMenuBreadcrumbs.tsx
rename to static/app/components/breadcrumbList/items/breadcrumbItemMenuBreadcrumbs.tsx
diff --git a/static/app/components/breadcrumbs/items/breadcrumbItemPageTitle.tsx b/static/app/components/breadcrumbList/items/breadcrumbItemPageTitle.tsx
similarity index 85%
rename from static/app/components/breadcrumbs/items/breadcrumbItemPageTitle.tsx
rename to static/app/components/breadcrumbList/items/breadcrumbItemPageTitle.tsx
index 60ee81864ce5..e2d91b4fca4d 100644
--- a/static/app/components/breadcrumbs/items/breadcrumbItemPageTitle.tsx
+++ b/static/app/components/breadcrumbList/items/breadcrumbItemPageTitle.tsx
@@ -2,7 +2,7 @@ import {LinkButton} from '@sentry/scraps/button';
import {Container, Flex} from '@sentry/scraps/layout';
import type {LeadingGraphicProps} from '@sentry/scraps/leadingGraphic';
import type {LinkProps} from '@sentry/scraps/link';
-import {Heading} from '@sentry/scraps/text';
+import {Text} from '@sentry/scraps/text';
import {Tooltip} from '@sentry/scraps/tooltip';
import {IconChevron} from 'sentry/icons';
@@ -74,22 +74,18 @@ export function BreadcrumbItemPageTitle({
{styleProps => (
- // Rendered as an
: the current-page crumb is the page's primary
- // heading. `Heading` is always medium weight (matching the previous
- // `Text bold`); `size="md"` retains the prior visual size instead of
- // the h1 default (2xl).
- ), not a heading: the surrounding
+ // context (e.g. the TopBar title
) owns the page heading, so the
+ // current-page crumb must be phrasing content that nests cleanly
+ // inside it. Mirrors the legacy breadcrumb's current-crumb rendering.
+
{label}
-
+
)}
diff --git a/static/app/components/breadcrumbs/items/breadcrumbItemSelectProjects.tsx b/static/app/components/breadcrumbList/items/breadcrumbItemSelectProjects.tsx
similarity index 100%
rename from static/app/components/breadcrumbs/items/breadcrumbItemSelectProjects.tsx
rename to static/app/components/breadcrumbList/items/breadcrumbItemSelectProjects.tsx
diff --git a/static/app/components/breadcrumbs.spec.tsx b/static/app/components/breadcrumbs.spec.tsx
index 9eb6ebda4d3f..0a4087281258 100644
--- a/static/app/components/breadcrumbs.spec.tsx
+++ b/static/app/components/breadcrumbs.spec.tsx
@@ -48,19 +48,4 @@ describe('Breadcrumbs', () => {
await userEvent.click(screen.getByText('Test 3'));
expect(router.location.pathname).toBe(initialPathname);
});
-
- it('renders non-string (React node) labels', () => {
- // Many call sites pass rich content as the current crumb — e.g. an editable
- // name field. The shim must render it as-is, not coerce it away.
- render(
- Editable Name, to: null},
- ]}
- />
- );
-
- expect(screen.getByRole('button', {name: 'Editable Name'})).toBeInTheDocument();
- });
});
diff --git a/static/app/components/breadcrumbs.tsx b/static/app/components/breadcrumbs.tsx
index a79510fd7983..cdb3663ccc8b 100644
--- a/static/app/components/breadcrumbs.tsx
+++ b/static/app/components/breadcrumbs.tsx
@@ -1,6 +1,136 @@
+import {Fragment} from 'react';
+
+import {Container, Flex} from '@sentry/scraps/layout';
+import type {LinkProps} from '@sentry/scraps/link';
+import {Link} from '@sentry/scraps/link';
+import {Text} from '@sentry/scraps/text';
+
+import {extractSelectionParameters} from 'sentry/components/pageFilters/parse';
+import {IconSlashForward} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {trackAnalytics} from 'sentry/utils/analytics';
+import {useLocation} from 'sentry/utils/useLocation';
+
+export interface Crumb {
+ /**
+ * Label of the crumb
+ */
+ label: NonNullable;
+
+ /**
+ * It will keep the page filter values (projects, environments, time) in the
+ * querystring when navigating using extractSelectionParameters
+ */
+ preservePageFilters?: boolean;
+
+ /**
+ * Link of the crumb
+ */
+ to?: LinkProps['to'] | null;
+}
+
+interface BreadcrumbsProps extends React.HTMLAttributes {
+ crumbs: Crumb[];
+ as?: 'nav';
+}
+
/**
- * Re-exports from the breadcrumbs directory.
- * Import from 'sentry/components/breadcrumbs' resolves here first (file > directory),
- * so existing call sites continue to work without changes.
+ * Page breadcrumbs used for navigation, not to be confused with sentry's event breadcrumbs
*/
-export * from './breadcrumbs/index';
+export function Breadcrumbs({crumbs, as, ...props}: BreadcrumbsProps) {
+ if (crumbs.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {crumbs.map((crumb, index) => {
+ return (
+
+
+ {index < crumbs.length - 1 ? (
+
+
+
+ ) : null}
+
+ );
+ })}
+
+ );
+}
+
+interface BreadCrumbItemProps {
+ crumb: Crumb;
+ variant: 'primary' | 'muted';
+}
+
+function BreadCrumbItem(props: BreadCrumbItemProps) {
+ function onBreadcrumbLinkClick() {
+ if (props.crumb.to) {
+ trackAnalytics('breadcrumbs.link.clicked', {organization: null});
+ }
+ }
+
+ return (
+
+ {styleProps => {
+ return props.crumb.to ? (
+
+
+ {props.crumb.label}
+
+
+ ) : (
+
+ {props.crumb.label}
+
+ );
+ }}
+
+ );
+}
+
+interface BreadcrumbLinkProps extends LinkProps {
+ children?: React.ReactNode;
+ preservePageFilters?: boolean;
+}
+
+function BreadcrumbLink(props: BreadcrumbLinkProps) {
+ const {preservePageFilters, to, ...rest} = props;
+ const location = useLocation();
+
+ if (!to) {
+ return ;
+ }
+
+ const toWithQuery = preservePageFilters
+ ? typeof to === 'string'
+ ? {pathname: to, query: extractSelectionParameters(location.query)}
+ : {...to, query: {...extractSelectionParameters(location.query), ...to.query}}
+ : to;
+
+ return ;
+}
diff --git a/static/app/components/breadcrumbs/index.tsx b/static/app/components/breadcrumbs/index.tsx
deleted file mode 100644
index 3823292365ab..000000000000
--- a/static/app/components/breadcrumbs/index.tsx
+++ /dev/null
@@ -1,147 +0,0 @@
-/**
- * Page breadcrumbs — not to be confused with Sentry's event breadcrumbs.
- *
- * ## New API (preferred)
- * Use `BreadcrumbList` with explicit typed items:
- *
- *
- *
- * ## Legacy API (compatibility shim)
- * The old `` signature continues to work unchanged,
- * including crumbs whose `label` is arbitrary React content (e.g. an editable
- * name field). Migrate call sites to `BreadcrumbList` when making changes in
- * those files — but note the new API only accepts string labels.
- */
-import {Fragment} from 'react';
-
-import {Container, Flex} from '@sentry/scraps/layout';
-import type {LinkProps} from '@sentry/scraps/link';
-import {Link} from '@sentry/scraps/link';
-import {Text} from '@sentry/scraps/text';
-
-import {extractSelectionParameters} from 'sentry/components/pageFilters/parse';
-import {IconSlashForward} from 'sentry/icons';
-import {t} from 'sentry/locale';
-import {trackAnalytics} from 'sentry/utils/analytics';
-import {useLocation} from 'sentry/utils/useLocation';
-
-// The new typed API. Individual item components and their prop types are
-// implementation details of `BreadcrumbList` and are intentionally not
-// re-exported until a consumer needs them — re-export them here when migrating
-// a call site to the typed API.
-export {BreadcrumbList} from './breadcrumbList';
-/** @public Consumed once call sites migrate onto the typed API in a downstream PR. */
-export type {BreadcrumbItem, BreadcrumbListProps} from './breadcrumbList';
-
-// ── Legacy compatibility shim ─────────────────────────────────────────────────
-// Preserves the old `Breadcrumbs` + `Crumb` API so existing call sites need no
-// changes. Unlike the new `BreadcrumbList`, this renders each crumb's `label`
-// as-is, so callers passing React nodes (editable titles, badges, …) keep
-// working. New code should prefer the typed `BreadcrumbList` API above.
-
-export interface Crumb {
- label: NonNullable;
- preservePageFilters?: boolean;
- to?: LinkProps['to'] | null;
-}
-
-interface BreadcrumbsProps extends React.HTMLAttributes {
- crumbs: Crumb[];
-}
-
-/**
- * @deprecated Use `BreadcrumbList` with typed items instead.
- */
-export function Breadcrumbs({crumbs, ...props}: BreadcrumbsProps) {
- if (crumbs.length === 0) {
- return null;
- }
-
- return (
-
- {crumbs.map((crumb, index) => {
- const isLast = index === crumbs.length - 1;
- return (
-
-
- {isLast ? null : (
-
-
-
- )}
-
- );
- })}
-
- );
-}
-
-interface BreadcrumbItemProps {
- crumb: Crumb;
- variant: 'primary' | 'muted';
-}
-
-function BreadcrumbItem({crumb, variant}: BreadcrumbItemProps) {
- return (
-
- {styleProps =>
- crumb.to ? (
-
- trackAnalytics('breadcrumbs.link.clicked', {organization: null})
- }
- {...styleProps}
- >
-
- {crumb.label}
-
-
- ) : (
-
- {crumb.label}
-
- )
- }
-
- );
-}
-
-interface BreadcrumbLinkProps extends LinkProps {
- children?: React.ReactNode;
- preservePageFilters?: boolean;
-}
-
-function BreadcrumbLink({preservePageFilters, to, ...rest}: BreadcrumbLinkProps) {
- const location = useLocation();
-
- if (!to) {
- return ;
- }
-
- const toWithQuery = preservePageFilters
- ? typeof to === 'string'
- ? {pathname: to, query: extractSelectionParameters(location.query)}
- : {...to, query: {...extractSelectionParameters(location.query), ...to.query}}
- : to;
-
- return ;
-}
diff --git a/static/app/components/events/eventDrawer.tsx b/static/app/components/events/eventDrawer.tsx
index 8de2a3031337..9e93582ce835 100644
--- a/static/app/components/events/eventDrawer.tsx
+++ b/static/app/components/events/eventDrawer.tsx
@@ -39,7 +39,7 @@ const StyledNavigationCrumbs = styled(NavigationBreadcrumbs)`
export function NavigationCrumbs(
props: ComponentPropsWithoutRef
) {
- return ;
+ return ;
}
export function CrumbContainer(props: FlexProps) {
diff --git a/static/app/views/dashboards/widgetBuilder/components/widgetBuilderSlideout.tsx b/static/app/views/dashboards/widgetBuilder/components/widgetBuilderSlideout.tsx
index 0f8004e3384a..ace2bb29939f 100644
--- a/static/app/views/dashboards/widgetBuilder/components/widgetBuilderSlideout.tsx
+++ b/static/app/views/dashboards/widgetBuilder/components/widgetBuilderSlideout.tsx
@@ -308,7 +308,7 @@ function WidgetBuilderSlideoutInner({
height="44px"
padding="0 2xl"
>
-
+
);
From a7860d61d4e300f7c54e3fc77571e8bbb4ab9e46 Mon Sep 17 00:00:00 2001
From: Jesse Box
Date: Tue, 7 Jul 2026 14:19:06 +0200
Subject: [PATCH 09/11] feat(breadcrumbList): Give the page-title crumb medium
weight
Render the current-page crumb as so it reads as the emphasized
page title. This restores the medium weight the crumb had while it was a
before being switched to inline text.
Co-Authored-By: Claude Opus 4.8
---
.../components/breadcrumbList/items/breadcrumbItemPageTitle.tsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/static/app/components/breadcrumbList/items/breadcrumbItemPageTitle.tsx b/static/app/components/breadcrumbList/items/breadcrumbItemPageTitle.tsx
index e2d91b4fca4d..7c7974a5f2c6 100644
--- a/static/app/components/breadcrumbList/items/breadcrumbItemPageTitle.tsx
+++ b/static/app/components/breadcrumbList/items/breadcrumbItemPageTitle.tsx
@@ -80,6 +80,7 @@ export function BreadcrumbItemPageTitle({
// inside it. Mirrors the legacy breadcrumb's current-crumb rendering.
Date: Tue, 7 Jul 2026 15:00:41 +0200
Subject: [PATCH 10/11] fix(topbar): Grow the title slot to fit available width
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The title outlet's
was shrink-to-fit. Any title-slot child that
establishes a container query — e.g. BreadcrumbList's `container-type:
inline-size` — has no definite inline size to resolve against, so size
containment collapses it to 0 width and its container queries always read as
narrow (the redesigned breadcrumb rendered permanently collapsed even with
>1000px available).
Add flexGrow={1} to the title Flex so the
fills the available inline
space, giving container-query children a real width. The header is
justify="between", so this only absorbs the previously-empty middle: content
stays left-aligned and actions stay pinned right.
Co-Authored-By: Claude Opus 4.8
---
static/app/views/navigation/topBar.tsx | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/static/app/views/navigation/topBar.tsx b/static/app/views/navigation/topBar.tsx
index 44b6e69e4f49..78694c8794a1 100644
--- a/static/app/views/navigation/topBar.tsx
+++ b/static/app/views/navigation/topBar.tsx
@@ -76,10 +76,18 @@ function TopBarContent() {
* as the page heading. The Heading uses variant="inherit" so it carries
* the TopBar typography (no visual weight of its own), and Flex's render
* function applies the layout className to that same
element.
+ *
+ * flexGrow={1} lets the
occupy the available inline space (the
+ * header is justify="between", so this just absorbs the empty middle;
+ * content stays left-aligned, actions stay pinned right). This is
+ * required by any title-slot child that establishes a container query
+ * (e.g. BreadcrumbList's `container-type: inline-size`): without a
+ * definite inline size to resolve against, size containment collapses
+ * the child to 0 width and its container queries always read as narrow.
*/}
{props => (
-
+
{flexProps => (
)}
From 4d6c863faf80544533dce13041424e89a8c6edbc Mon Sep 17 00:00:00 2001
From: Jesse Box
Date: Tue, 7 Jul 2026 15:55:18 +0200
Subject: [PATCH 11/11] feat(breadcrumbList): Grow-to-fit truncation with a
min-width floor
Crumbs were capped at a fixed max-width, so labels truncated even with room
to spare. Replace the caps with a flex distribution: crumbs size to their
content and ellipsize only when the row overflows. Parent links get a high
flex-shrink so they give up width before the current page, which truncates
last.
Each crumb's outer Flex carries an explicit min-width floor (32px) so a label
never collapses to a bare ellipsis, and the wrapping
/Flex keep the default
min-width:auto so they can't shrink past that floor to 0 when the current
page's label is very long. The per-crumb Tooltip gets style={{minWidth: 0}} so
its injected wrapper span doesn't block the shrink chain.
Co-Authored-By: Claude Opus 4.8
---
.../breadcrumbList/breadcrumbDividerCombo.tsx | 8 +++--
.../breadcrumbList/breadcrumbList.spec.tsx | 36 +++++++++++++++++++
.../breadcrumbList/breadcrumbList.tsx | 2 +-
.../items/breadcrumbItemLink.tsx | 10 ++++--
.../items/breadcrumbItemPageTitle.tsx | 8 +++--
5 files changed, 55 insertions(+), 9 deletions(-)
diff --git a/static/app/components/breadcrumbList/breadcrumbDividerCombo.tsx b/static/app/components/breadcrumbList/breadcrumbDividerCombo.tsx
index b3df46257ccf..dfe9a35bf72f 100644
--- a/static/app/components/breadcrumbList/breadcrumbDividerCombo.tsx
+++ b/static/app/components/breadcrumbList/breadcrumbDividerCombo.tsx
@@ -21,8 +21,12 @@ interface BreadcrumbDividerComboProps {
*/
export function BreadcrumbDividerCombo({children, display}: BreadcrumbDividerComboProps) {
return (
-
-
+ // flexShrink={999} makes parent crumbs give up width first, so the current
+ // page (a much lower shrink) truncates last. These wrappers keep the default
+ // min-width:auto (no min-width:0) so they can't collapse past the crumb's own
+ // floor — set on the item's outer Flex — down to 0 when the row is very tight.
+
+
{children}
diff --git a/static/app/components/breadcrumbList/breadcrumbList.spec.tsx b/static/app/components/breadcrumbList/breadcrumbList.spec.tsx
index 2b606f649a3b..7a4a2b61d057 100644
--- a/static/app/components/breadcrumbList/breadcrumbList.spec.tsx
+++ b/static/app/components/breadcrumbList/breadcrumbList.spec.tsx
@@ -18,6 +18,12 @@ function collectCssRules(): string[] {
return rules;
}
+/** CSS rule texts (from inserted stylesheets) that target any of `element`'s classes. */
+function rulesForElement(element: Element): string[] {
+ const classes = (element.getAttribute('class') ?? '').split(/\s+/).filter(Boolean);
+ return collectCssRules().filter(r => classes.some(c => r.includes(`.${c}`)));
+}
+
/**
* True when `element` carries the "hide below sm" container-query toggle:
* a base `display: none` plus an `@container (min-width: 800px) { display: flex }`
@@ -177,4 +183,34 @@ describe('BreadcrumbList container-query collapse', () => {
expect(current).toHaveTextContent('Client Keys');
expect(hidesBelowSm(current.closest('li')!)).toBe(false);
});
+
+ it('gives crumbs a visible-width floor and never collapses them to 0', () => {
+ render(
+
+ );
+
+ const link = screen.getByTestId('breadcrumb-link');
+ const current = screen.getByTestId('breadcrumb-item');
+ const parentLi = link.closest('li')!;
+
+ // The fixed max-width caps are gone, so labels size to content when there's
+ // room. (jsdom can't compute layout — this guards the CSS intent, not pixels.)
+ expect(rulesForElement(link).some(r => /max-width:\s*132px/.test(r))).toBe(false);
+ expect(rulesForElement(current).some(r => /max-width:\s*200px/.test(r))).toBe(false);
+
+ // Regression guard: the parent
must not carry min-width:0 — that let it
+ // collapse to 0 width when the current page's label was very long.
+ expect(rulesForElement(parentLi).join(' ')).not.toContain('min-width: 0');
+
+ // A positive-px min-width floor is emitted so a crumb never shrinks to nothing.
+ expect(collectCssRules().some(r => /min-width:\s*[1-9]\d*px/.test(r))).toBe(true);
+
+ // Parents give up width first (high flex-shrink) so the current page truncates last.
+ expect(rulesForElement(parentLi).some(r => /flex-shrink:\s*999/.test(r))).toBe(true);
+ });
});
diff --git a/static/app/components/breadcrumbList/breadcrumbList.tsx b/static/app/components/breadcrumbList/breadcrumbList.tsx
index ac8073b35a7c..3837a503d1e4 100644
--- a/static/app/components/breadcrumbList/breadcrumbList.tsx
+++ b/static/app/components/breadcrumbList/breadcrumbList.tsx
@@ -88,7 +88,7 @@ export function BreadcrumbList({items, ...props}: BreadcrumbListProps) {
* below resolves against it either way.
*/}
-
+
{parentItems.map((item, index) => (
// Wide: show every parent item. Narrow: hide them all — 'link' parents
// reappear in the overflow menu below; other types (e.g. 'select-projects')
diff --git a/static/app/components/breadcrumbList/items/breadcrumbItemLink.tsx b/static/app/components/breadcrumbList/items/breadcrumbItemLink.tsx
index 9b3b4499914b..2f384f956a50 100644
--- a/static/app/components/breadcrumbList/items/breadcrumbItemLink.tsx
+++ b/static/app/components/breadcrumbList/items/breadcrumbItemLink.tsx
@@ -45,10 +45,14 @@ export function BreadcrumbItemLink({
: to;
return (
-
+
{leadingGraphic}
-
-
+ {/* style={{minWidth: 0}} unblocks the Tooltip's wrapper (a flex item
+ whose default min-width:auto would otherwise refuse to shrink). */}
+
+ {/* The visible-width floor lives on the outer Flex above (min-width: 32px).
+ Here the label just fills that floored space and ellipsizes within it. */}
+
{styleProps => (
+
{pagination && (
)}
{leadingGraphic}
-
-
+ {/* style={{minWidth: 0}} unblocks the Tooltip's wrapper so the label
+ can shrink. The visible-width floor lives on the outer Flex above. */}
+
+
{styleProps => (
// Rendered as inline text (a ), not a heading: the surrounding
// context (e.g. the TopBar title