diff --git a/.changeset/mosaic-card-media.md b/.changeset/mosaic-card-media.md new file mode 100644 index 00000000000..23e3cfc8220 --- /dev/null +++ b/.changeset/mosaic-card-media.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': patch +--- + +Add `Card.Media`, a slot for a logo, icon or image at the top of `Card.Header`, with 24px of space below it. Style it with `.cl-card-media`. `Card.Header` also takes an `align` prop, `start` by default, so `align='center'` centers its content. diff --git a/packages/mosaic/src/components/card/card.browser.test.tsx b/packages/mosaic/src/components/card/card.browser.test.tsx new file mode 100644 index 00000000000..bf420f9bff9 --- /dev/null +++ b/packages/mosaic/src/components/card/card.browser.test.tsx @@ -0,0 +1,81 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { Dialog } from '../dialog'; +import { Card } from './card'; + +afterEach(() => cleanup()); + +describe('Card.Media', () => { + it('leaves 24px between the media and the title', () => { + render( + + + + + + Title + Description + + , + ); + + const mediaBottom = screen.getByTestId('media').getBoundingClientRect().bottom; + const titleTop = screen.getByTestId('title').getBoundingClientRect().top; + + expect(titleTop - mediaBottom).toBe(24); + }); +}); + +describe('Card.Header align', () => { + function CenteredCard() { + return ( + + + + + + + Title + + + + ); + } + + function expectCentered(testId: string) { + const root = screen.getByTestId('root').getBoundingClientRect(); + const part = screen.getByTestId(testId).getBoundingClientRect(); + expect(part.left + part.width / 2).toBeCloseTo(root.left + root.width / 2, 0); + } + + it('centers the media and title in the card', () => { + render(); + + expectCentered('media'); + expectCentered('title'); + }); + + it('keeps them centered in the card beside the dialog close button', () => { + render( + + + + + , + ); + + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument(); + expectCentered('media'); + expectCentered('title'); + }); +}); diff --git a/packages/mosaic/src/components/card/card.styles.ts b/packages/mosaic/src/components/card/card.styles.ts index 6fb69152f01..46a612d8032 100644 --- a/packages/mosaic/src/components/card/card.styles.ts +++ b/packages/mosaic/src/components/card/card.styles.ts @@ -59,12 +59,16 @@ export const sizes = stylex.create({ lg: { width: '36.25rem' }, }); +const HEADER_ROW_GAP = space['0.5']; +const HEADER_COLUMN_GAP = space['1']; +const CLOSE_BUTTON_WIDTH = space['7']; + export const header = stylex.create({ // `row-reverse` so the dismiss button leads in the DOM — and so takes the dialog's opening // focus — while sitting at the inline end. base: { paddingInline: space['5'], - columnGap: space['1'], + columnGap: HEADER_COLUMN_GAP, display: 'flex', flexDirection: 'row-reverse', paddingBlockStart: space['4'], @@ -73,7 +77,14 @@ export const header = stylex.create({ display: 'flex', flexDirection: 'column', flexGrow: '1', - rowGap: space['0.5'], + rowGap: HEADER_ROW_GAP, + }, + contentBesideCloseButton: { + paddingInlineStart: `calc(${CLOSE_BUTTON_WIDTH} + ${HEADER_COLUMN_GAP})`, + }, + media: { + display: 'flex', + marginBlockEnd: `calc(${space['6']} - ${HEADER_ROW_GAP})`, }, title: { color: colorVars['--cl-color-foreground'], @@ -90,6 +101,14 @@ export const header = stylex.create({ }, }); +export const headerAlign = stylex.create({ + start: {}, + center: { + alignItems: 'center', + textAlign: 'center', + }, +}); + const INSET = space['4']; export const banner = stylex.create({ diff --git a/packages/mosaic/src/components/card/card.test.tsx b/packages/mosaic/src/components/card/card.test.tsx index f2a3277c0e5..113285e953d 100644 --- a/packages/mosaic/src/components/card/card.test.tsx +++ b/packages/mosaic/src/components/card/card.test.tsx @@ -454,6 +454,54 @@ describe('Mosaic Card', () => { expect(screen.queryByRole('link', { name: 'Clerk' })).toBeNull(); }); + it('reflects the header alignment, starting by default', () => { + render( + + + + , + ); + + expect(screen.getByTestId('default')).toHaveAttribute('data-align', 'start'); + expect(screen.getByTestId('center')).toHaveAttribute('data-align', 'center'); + }); + + it('renders media in the header, above the title', () => { + render( + + + Logo + Review terms + + , + ); + + const media = screen.getByTestId('media'); + expect(media).toHaveClass('cl-card-media'); + expect(media.tagName).toBe('DIV'); + expect(media.nextElementSibling).toBe(screen.getByTestId('title')); + }); + + it('composes caller xstyle, refs and render onto the media', () => { + const ref = React.createRef(); + render( +
} + />, + ); + + const media = screen.getByTestId('media'); + expect(media.tagName).toBe('FIGURE'); + expect(ref.current).toBe(media); + expect(media).toHaveClass('cl-card-media', stylex.props(callerStyles.header).className ?? ''); + }); + it('renders the title and description slots', () => { render( diff --git a/packages/mosaic/src/components/card/card.tsx b/packages/mosaic/src/components/card/card.tsx index 4fc2766ac6e..74318bd4ce7 100644 --- a/packages/mosaic/src/components/card/card.tsx +++ b/packages/mosaic/src/components/card/card.tsx @@ -24,6 +24,8 @@ type CardElevation = 'card' | 'flush' | 'overlay'; export type CardSize = keyof typeof slots.sizes; +export type CardHeaderAlign = keyof typeof slots.headerAlign; + const DEFAULT_ELEVATION: CardElevation = 'card'; // The enclosing card and the dialog it was rendered in. Context crosses portals, so a card in a @@ -113,17 +115,27 @@ function HeaderCloseButton() { ); } -const Header = React.forwardRef>(function CardHeader( - { render, xstyle, children, ...rest }, +export interface CardHeaderProps extends MosaicComponentProps<'div'> { + /** Aligns the header's content. @default 'start' */ + align?: CardHeaderAlign; +} + +const Header = React.forwardRef(function CardHeader( + { align = 'start', render, xstyle, children, ...rest }, ref, ) { const dialog = React.useContext(DialogContext); + const hasCloseButton = isInDialog(dialog) && dialog.showCloseButton; return useRender({ defaultTagName: 'div', render, ref, props: { - ...mergeStyleProps(themeProps('card-header'), stylex.props(reset.base, slots.header.base, xstyle), rest), + ...mergeStyleProps( + themeProps('card-header', { align }), + stylex.props(reset.base, slots.header.base, xstyle), + rest, + ), children: ( <> {/* First in the DOM, so it is the first tabbable element and takes the dialog's opening @@ -132,8 +144,18 @@ const Header = React.forwardRef>(fun `showCloseButton` is off, which is the default for an alert dialog. */} {/* An alert dialog interrupts to ask for a decision, and a corner X is a way out without answering one. The cancel action in the footer is the way out. */} - {isInDialog(dialog) && dialog.showCloseButton ? : null} -
+ {hasCloseButton ? : null} +
{children}
@@ -142,6 +164,20 @@ const Header = React.forwardRef>(fun }); }); +const Media = React.forwardRef>(function CardMedia( + { render, xstyle, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('card-media'), stylex.props(reset.base, slots.header.media, xstyle), rest), + }, + }); +}); + /** * Names the card. Renders an `

`, and inside a dialog takes the id the popup points * `aria-labelledby` at, so the card names the dialog without knowing it is in one. @@ -271,9 +307,9 @@ const Footer = React.forwardRef>(fun }); /** - * A styled surface composed through `Card.Root`, `Card.Header`, `Card.Title`, `Card.Description`, - * `Card.Banner`, `Card.Content`, and `Card.Footer`. Every part accepts the Mosaic `render` prop and - * forwards its ref. + * A styled surface composed through `Card.Root`, `Card.Header`, `Card.Media`, `Card.Title`, + * `Card.Description`, `Card.Banner`, `Card.Content`, and `Card.Footer`. Every part accepts the Mosaic + * `render` prop and forwards its ref. * * `Card.Banner` is a message slot between the header and the content. It is always rendered, and * a `Banner` expands into it while its children hold a message, collapsing again once they are @@ -282,4 +318,4 @@ const Footer = React.forwardRef>(fun * Rendered as the content of a `Dialog.Popup`, the card reads that surface from `DialogContext`: * the title and description take the popup's ARIA ids, and the header carries the dismiss button. */ -export const Card = { Root, Header, Title, Description, Banner: CardBanner, Content, Footer }; +export const Card = { Root, Header, Media, Title, Description, Banner: CardBanner, Content, Footer }; diff --git a/packages/mosaic/src/components/card/index.ts b/packages/mosaic/src/components/card/index.ts index 8d998d68918..e68e9b40726 100644 --- a/packages/mosaic/src/components/card/index.ts +++ b/packages/mosaic/src/components/card/index.ts @@ -1,2 +1,2 @@ export { Card } from './card'; -export type { CardBannerProps, CardProps, CardSize } from './card'; +export type { CardBannerProps, CardHeaderAlign, CardHeaderProps, CardProps, CardSize } from './card'; diff --git a/packages/swingset/src/stories/card.component.mdx b/packages/swingset/src/stories/card.component.mdx index 3882d6984ef..56e7846a96b 100644 --- a/packages/swingset/src/stories/card.component.mdx +++ b/packages/swingset/src/stories/card.component.mdx @@ -29,6 +29,10 @@ import { Card } from '@clerk/mosaic/components/card'; ``` +`Card.Media` goes first in `Card.Header`, above the title, for a logo, icon or image. It leaves 24px below it. + +`Card.Header` takes `align='center'` to center its content. + `Card.Banner` sits between the header and the content and is always rendered. While its children hold a message, a `Banner` of the given `color` expands into it; once they are empty it collapses again. Give it `role='alert'` so the message is announced. Buttons in `Card.Footer` are full width by default. Pass `fullWidth={false}` to opt out. A `Dialog.Popup` or `Popover.Popup` rendered inside the footer doesn't inherit this. @@ -37,6 +41,22 @@ Buttons in `Card.Footer` are full width by default. Pass `fullWidth={false}` to ## Examples +### Media + + + +### OAuth consent + +`Card.Media` can hold more than one logo. Here it shows the two apps being connected. + + + ### Banner ) { ); } +export function WithMedia() { + return ( + + + + + + + + + Join Clerk + You have been invited to this organization. + + + + + + ); +} + +export function OAuthConsent() { + return ( + + + + + + +
+ + + +
+ + + + +
+ Authorize Acme + Acme wants to access your Clerk account. +
+ + + + +
+ ); +} + export function WithBanner() { const [error, setError] = useState(); return (