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/.changeset/mosaic-profile-nav-badge.md b/.changeset/mosaic-profile-nav-badge.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-profile-nav-badge.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.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..2cb73be666b 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,56 @@ 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('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 47a39eb77d5..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'; @@ -318,12 +319,15 @@ 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; } +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, disabled, children, render, xstyle, onClick, ...rest }, + { value, icon, badge, disabled, children, render, xstyle, onClick, ...rest }, ref, ) { const { compact, closeNav } = useProfileContext('Profile.NavItem'); @@ -355,6 +359,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..85e36418797 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. 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 2356ed90990..3a4ff10c6d7 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}