From 272149ca2adf225d59934d5f33aee5e1ece02514 Mon Sep 17 00:00:00 2001 From: Preston Booth Date: Tue, 22 Sep 2026 07:40:25 -0600 Subject: [PATCH 1/3] feat(mosaic): support badges in profile navigation --- .changeset/mosaic-profile-nav-badge.md | 5 +++++ .../src/components/profile/profile.styles.ts | 7 ++++++ .../src/components/profile/profile.test.tsx | 22 +++++++++++++++++++ .../mosaic/src/components/profile/profile.tsx | 8 ++++++- .../src/stories/profile.component.mdx | 6 +++-- .../src/stories/profile.component.stories.tsx | 2 ++ 6 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 .changeset/mosaic-profile-nav-badge.md diff --git a/.changeset/mosaic-profile-nav-badge.md b/.changeset/mosaic-profile-nav-badge.md new file mode 100644 index 00000000000..43a5281704e --- /dev/null +++ b/.changeset/mosaic-profile-nav-badge.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': patch +--- + +Allow Profile navigation items to display trailing badge content. diff --git a/packages/mosaic/src/components/profile/profile.styles.ts b/packages/mosaic/src/components/profile/profile.styles.ts index 2b8f0e2a71c..bd657b0bdbe 100644 --- a/packages/mosaic/src/components/profile/profile.styles.ts +++ b/packages/mosaic/src/components/profile/profile.styles.ts @@ -263,6 +263,13 @@ export const styles = stylex.create({ flexShrink: 0, }, + navItemBadge: { + alignItems: 'center', + display: 'inline-flex', + flexShrink: 0, + marginInlineStart: 'auto', + }, + branding: { display: 'block', marginBlockStart: 'auto', diff --git a/packages/mosaic/src/components/profile/profile.test.tsx b/packages/mosaic/src/components/profile/profile.test.tsx index d43ce7d813c..fc6f2948ee8 100644 --- a/packages/mosaic/src/components/profile/profile.test.tsx +++ b/packages/mosaic/src/components/profile/profile.test.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { MosaicProvider } from '../../MosaicProvider'; +import { Badge } from '../badge'; import { Card } from '../card'; import { Dialog } from '../dialog'; import { Icon } from '../icon'; @@ -54,6 +55,27 @@ function atomsOf(style: stylex.StyleXStyles): string[] { } describe('Profile', () => { + it('renders trailing badge content in a navigation item', () => { + render( + + Settings + + 3} + > + Account + + + + Account content + + , + ); + + expect(screen.getByText('3').closest('.cl-profile-nav-item-badge')).toBeInTheDocument(); + }); + it('is a labelled navigation of tabs beside the selected page', () => { renderSurface(); diff --git a/packages/mosaic/src/components/profile/profile.tsx b/packages/mosaic/src/components/profile/profile.tsx index 47a39eb77d5..c04349fe631 100644 --- a/packages/mosaic/src/components/profile/profile.tsx +++ b/packages/mosaic/src/components/profile/profile.tsx @@ -318,12 +318,13 @@ export interface ProfileNavItemProps extends MosaicComponentProps<'button'> { value: string; /** Leads the label. Any node, so a page of the consumer's own can bring its own mark. */ icon?: React.ReactNode; + badge?: React.ReactNode; disabled?: boolean; } /** A destination. Selecting it shows the `Profile.ContentPanel` sharing its `value`. */ const NavItem = React.forwardRef(function ProfileNavItem( - { value, icon, disabled, children, render, xstyle, onClick, ...rest }, + { value, icon, badge, disabled, children, render, xstyle, onClick, ...rest }, ref, ) { const { compact, closeNav } = useProfileContext('Profile.NavItem'); @@ -355,6 +356,11 @@ const NavItem = React.forwardRef(functio ) : null} {children} + {badge != null ? ( + + {badge} + + ) : null} ); }); diff --git a/packages/swingset/src/stories/profile.component.mdx b/packages/swingset/src/stories/profile.component.mdx index 9331cc337d5..e93bb3161ee 100644 --- a/packages/swingset/src/stories/profile.component.mdx +++ b/packages/swingset/src/stories/profile.component.mdx @@ -21,6 +21,7 @@ its dismiss, and fills the popup; standalone it paints the same frame and takes ## Usage ```tsx +import { Badge } from '@clerk/mosaic/components/badge'; import { Icon } from '@clerk/mosaic/components/icon'; import { Profile } from '@clerk/mosaic/components/profile'; @@ -39,6 +40,7 @@ import { Profile } from '@clerk/mosaic/components/profile'; } + badge={2} > Security @@ -54,8 +56,8 @@ import { Profile } from '@clerk/mosaic/components/profile'; `elevation` picks how it sits, the way `Card`'s does: `card` (default) is framed, with a fixed height and the pages scrolling inside; `flush` is the page's own content — no frame, the page scrolls, the columns a gap apart, held to a reading width and centred. A -`NavItem` and a `Page` pair by `value`. `icon` takes any node, so a page of the consumer's own can -bring its own mark. `Title` is a visually hidden heading: it names the navigation, the compact +`NavItem` and a `Page` pair by `value`. `icon` and `badge` take nodes, so a page can supply its own +leading mark and trailing status. `Title` is a visually hidden heading: it names the navigation, the compact sheet, and — inside a dialog — the dialog itself, the way `Card.Title` does. ## Parts diff --git a/packages/swingset/src/stories/profile.component.stories.tsx b/packages/swingset/src/stories/profile.component.stories.tsx index 2356ed90990..cfd09c7c51d 100644 --- a/packages/swingset/src/stories/profile.component.stories.tsx +++ b/packages/swingset/src/stories/profile.component.stories.tsx @@ -1,3 +1,4 @@ +import { Badge } from '@clerk/mosaic/components/badge'; import { Button } from '@clerk/mosaic/components/button'; import { Icon } from '@clerk/mosaic/components/icon'; import type { ProfileRootProps } from '@clerk/mosaic/components/profile'; @@ -169,6 +170,7 @@ function Surface({ size='sm' /> } + badge={item.id === 'security' ? 2 : undefined} > {item.label} From 3585b3532b6209dd08b41c02440b895c24eac31b Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 22 Sep 2026 11:18:44 -0400 Subject: [PATCH 2/3] feat(mosaic): default Profile.NavItem badges to neutral via BadgeContext (#9860) --- .changeset/mosaic-badge-context-defaults.md | 2 ++ .../src/components/badge/badge.context.ts | 7 +++++ .../src/components/badge/badge.test.tsx | 19 ++++++++++++ .../mosaic/src/components/badge/badge.tsx | 5 ++- .../src/components/profile/profile.test.tsx | 31 ++++++++++++++++++- .../mosaic/src/components/profile/profile.tsx | 5 ++- .../src/stories/profile.component.mdx | 4 +-- .../src/stories/profile.component.stories.tsx | 2 +- 8 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 .changeset/mosaic-badge-context-defaults.md create mode 100644 packages/mosaic/src/components/badge/badge.context.ts diff --git a/.changeset/mosaic-badge-context-defaults.md b/.changeset/mosaic-badge-context-defaults.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-badge-context-defaults.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/badge/badge.context.ts b/packages/mosaic/src/components/badge/badge.context.ts new file mode 100644 index 00000000000..c504c45068e --- /dev/null +++ b/packages/mosaic/src/components/badge/badge.context.ts @@ -0,0 +1,7 @@ +'use client'; + +import React from 'react'; + +import type { BadgeProps } from './badge'; + +export const BadgeContext = React.createContext>({}); diff --git a/packages/mosaic/src/components/badge/badge.test.tsx b/packages/mosaic/src/components/badge/badge.test.tsx index d33d1b032a3..716c45408a6 100644 --- a/packages/mosaic/src/components/badge/badge.test.tsx +++ b/packages/mosaic/src/components/badge/badge.test.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { describe, expect, it } from 'vitest'; import { Badge } from './badge'; +import { BadgeContext } from './badge.context'; const atoms = stylex.create({ spaced: { marginTop: '8px' }, @@ -27,6 +28,24 @@ describe('Mosaic Badge', () => { expect(screen.getByText('Active')).toHaveAttribute('data-color', color); }); + it('takes its color from BadgeContext when none is passed', () => { + render( + + Active + , + ); + expect(screen.getByText('Active')).toHaveAttribute('data-color', 'neutral'); + }); + + it('lets an explicit color override the BadgeContext default', () => { + render( + + Active + , + ); + expect(screen.getByText('Active')).toHaveAttribute('data-color', 'positive'); + }); + it('merges xstyle atoms after the slot atoms', () => { render(Active); const badge = screen.getByText('Active'); diff --git a/packages/mosaic/src/components/badge/badge.tsx b/packages/mosaic/src/components/badge/badge.tsx index e951b3f2d99..64c8d72f17f 100644 --- a/packages/mosaic/src/components/badge/badge.tsx +++ b/packages/mosaic/src/components/badge/badge.tsx @@ -5,6 +5,7 @@ import { useRender } from '../../primitives/utils'; import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { reset } from '../../utils/reset.styles'; +import { BadgeContext } from './badge.context'; import { colors, styles } from './badge.styles'; export type BadgeProps = MosaicComponentProps<'span'> & { @@ -30,9 +31,11 @@ export type BadgeProps = MosaicComponentProps<'span'> & { * }>Upgrade */ export const Badge = React.forwardRef(function MosaicBadge( - { color = 'primary', render, xstyle, ...rest }, + { color: colorProp, render, xstyle, ...rest }, ref, ) { + const defaults = React.useContext(BadgeContext); + const color = colorProp ?? defaults.color ?? 'primary'; return useRender({ defaultTagName: 'span', render, diff --git a/packages/mosaic/src/components/profile/profile.test.tsx b/packages/mosaic/src/components/profile/profile.test.tsx index fc6f2948ee8..2cb73be666b 100644 --- a/packages/mosaic/src/components/profile/profile.test.tsx +++ b/packages/mosaic/src/components/profile/profile.test.tsx @@ -62,7 +62,7 @@ describe('Profile', () => { 3} + badge={3} > Account @@ -76,6 +76,35 @@ describe('Profile', () => { expect(screen.getByText('3').closest('.cl-profile-nav-item-badge')).toBeInTheDocument(); }); + it('defaults a navigation item badge to the neutral color', () => { + render( + + Settings + + 3} + > + Account + + 1} + > + Security + + + + Account content + Security content + + , + ); + + expect(screen.getByText('3')).toHaveAttribute('data-color', 'neutral'); + expect(screen.getByText('1')).toHaveAttribute('data-color', 'warning'); + }); + it('is a labelled navigation of tabs beside the selected page', () => { renderSurface(); diff --git a/packages/mosaic/src/components/profile/profile.tsx b/packages/mosaic/src/components/profile/profile.tsx index c04349fe631..91830674a85 100644 --- a/packages/mosaic/src/components/profile/profile.tsx +++ b/packages/mosaic/src/components/profile/profile.tsx @@ -9,6 +9,7 @@ import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { focusOutline } from '../../utils/focus-outline.styles'; import { reset } from '../../utils/reset.styles'; +import { BadgeContext } from '../badge/badge.context'; import { Branding } from '../branding'; import { Dialog, DialogContext, isInDialog } from '../dialog'; import { Drawer } from '../drawer'; @@ -322,6 +323,8 @@ export interface ProfileNavItemProps extends MosaicComponentProps<'button'> { disabled?: boolean; } +const navItemBadgeDefaults = { color: 'neutral' } as const; + /** A destination. Selecting it shows the `Profile.ContentPanel` sharing its `value`. */ const NavItem = React.forwardRef(function ProfileNavItem( { value, icon, badge, disabled, children, render, xstyle, onClick, ...rest }, @@ -358,7 +361,7 @@ const NavItem = React.forwardRef(functio {children} {badge != null ? ( - {badge} + {badge} ) : null} diff --git a/packages/swingset/src/stories/profile.component.mdx b/packages/swingset/src/stories/profile.component.mdx index e93bb3161ee..85e36418797 100644 --- a/packages/swingset/src/stories/profile.component.mdx +++ b/packages/swingset/src/stories/profile.component.mdx @@ -40,7 +40,7 @@ import { Profile } from '@clerk/mosaic/components/profile'; } - badge={2} + badge={2} > Security @@ -57,7 +57,7 @@ import { Profile } from '@clerk/mosaic/components/profile'; height and the pages scrolling inside; `flush` is the page's own content — no frame, the page scrolls, the columns a gap apart, held to a reading width and centred. A `NavItem` and a `Page` pair by `value`. `icon` and `badge` take nodes, so a page can supply its own -leading mark and trailing status. `Title` is a visually hidden heading: it names the navigation, the compact +leading mark and trailing status. A `Badge` passed to `badge` is `neutral` unless it sets its own `color`. `Title` is a visually hidden heading: it names the navigation, the compact sheet, and — inside a dialog — the dialog itself, the way `Card.Title` does. ## Parts diff --git a/packages/swingset/src/stories/profile.component.stories.tsx b/packages/swingset/src/stories/profile.component.stories.tsx index cfd09c7c51d..3a4ff10c6d7 100644 --- a/packages/swingset/src/stories/profile.component.stories.tsx +++ b/packages/swingset/src/stories/profile.component.stories.tsx @@ -170,7 +170,7 @@ function Surface({ size='sm' /> } - badge={item.id === 'security' ? 2 : undefined} + badge={item.id === 'security' ? 2 : undefined} > {item.label} From f75511fd544f9bd7827d291137ef1c6d7614db1c Mon Sep 17 00:00:00 2001 From: Preston Booth <91231503+prestonwebdev@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:02:12 -0600 Subject: [PATCH 3/3] Update .changeset/mosaic-profile-nav-badge.md Co-authored-by: Alex Carpenter --- .changeset/mosaic-profile-nav-badge.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/.changeset/mosaic-profile-nav-badge.md b/.changeset/mosaic-profile-nav-badge.md index 43a5281704e..a845151cc84 100644 --- a/.changeset/mosaic-profile-nav-badge.md +++ b/.changeset/mosaic-profile-nav-badge.md @@ -1,5 +1,2 @@ --- -'@clerk/mosaic': patch --- - -Allow Profile navigation items to display trailing badge content.