Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mosaic-card-media.md
Original file line number Diff line number Diff line change
@@ -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.
81 changes: 81 additions & 0 deletions packages/mosaic/src/components/card/card.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Card.Root renderBranding={false}>
<Card.Header>
<Card.Media data-testid='media'>
<svg
width='32'
height='32'
/>
</Card.Media>
<Card.Title data-testid='title'>Title</Card.Title>
<Card.Description>Description</Card.Description>
</Card.Header>
</Card.Root>,
);

const mediaBottom = screen.getByTestId('media').getBoundingClientRect().bottom;
const titleTop = screen.getByTestId('title').getBoundingClientRect().top;

expect(titleTop - mediaBottom).toBe(24);
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

describe('Card.Header align', () => {
function CenteredCard() {
return (
<Card.Root
renderBranding={false}
data-testid='root'
>
<Card.Header align='center'>
<Card.Media data-testid='media'>
<svg
width='32'
height='32'
/>
</Card.Media>
<Card.Title>
<span data-testid='title'>Title</span>
</Card.Title>
</Card.Header>
</Card.Root>
);
}

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(<CenteredCard />);

expectCentered('media');
expectCentered('title');
});

it('keeps them centered in the card beside the dialog close button', () => {
render(
<Dialog.Root defaultOpen>
<Dialog.Popup>
<CenteredCard />
</Dialog.Popup>
</Dialog.Root>,
);

expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
expectCentered('media');
expectCentered('title');
});
});
23 changes: 21 additions & 2 deletions packages/mosaic/src/components/card/card.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -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'],
Expand All @@ -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({
Expand Down
48 changes: 48 additions & 0 deletions packages/mosaic/src/components/card/card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,54 @@ describe('Mosaic Card', () => {
expect(screen.queryByRole('link', { name: 'Clerk' })).toBeNull();
});

it('reflects the header alignment, starting by default', () => {
render(
<Card.Root>
<Card.Header data-testid='default' />
<Card.Header
align='center'
data-testid='center'
/>
</Card.Root>,
);

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(
<Card.Root>
<Card.Header>
<Card.Media data-testid='media'>Logo</Card.Media>
<Card.Title data-testid='title'>Review terms</Card.Title>
</Card.Header>
</Card.Root>,
);

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<HTMLElement>();
render(
<Card.Media
ref={ref}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
xstyle={callerStyles.header}
data-testid='media'
render={props => <figure {...props} />}
/>,
);

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(
<Card.Root>
Expand Down
54 changes: 45 additions & 9 deletions packages/mosaic/src/components/card/card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -113,17 +115,27 @@ function HeaderCloseButton() {
);
}

const Header = React.forwardRef<HTMLDivElement, MosaicComponentProps<'div'>>(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<HTMLDivElement, CardHeaderProps>(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
Expand All @@ -132,8 +144,18 @@ const Header = React.forwardRef<HTMLDivElement, MosaicComponentProps<'div'>>(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 ? <HeaderCloseButton /> : null}
<div {...mergeStyleProps(themeProps('card-header-content'), stylex.props(reset.base, slots.header.content))}>
{hasCloseButton ? <HeaderCloseButton /> : null}
<div
{...mergeStyleProps(
themeProps('card-header-content'),
stylex.props(
reset.base,
slots.header.content,
slots.headerAlign[align],
hasCloseButton && align === 'center' && slots.header.contentBesideCloseButton,
),
)}
>
{children}
</div>
</>
Expand All @@ -142,6 +164,20 @@ const Header = React.forwardRef<HTMLDivElement, MosaicComponentProps<'div'>>(fun
});
});

const Media = React.forwardRef<HTMLDivElement, MosaicComponentProps<'div'>>(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 `<h2>`, 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.
Expand Down Expand Up @@ -271,9 +307,9 @@ const Footer = React.forwardRef<HTMLDivElement, MosaicComponentProps<'div'>>(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
Expand All @@ -282,4 +318,4 @@ const Footer = React.forwardRef<HTMLDivElement, MosaicComponentProps<'div'>>(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 };
2 changes: 1 addition & 1 deletion packages/mosaic/src/components/card/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { Card } from './card';
export type { CardBannerProps, CardProps, CardSize } from './card';
export type { CardBannerProps, CardHeaderAlign, CardHeaderProps, CardProps, CardSize } from './card';
20 changes: 20 additions & 0 deletions packages/swingset/src/stories/card.component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ import { Card } from '@clerk/mosaic/components/card';
</Card.Root>
```

`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.
Expand All @@ -37,6 +41,22 @@ Buttons in `Card.Footer` are full width by default. Pass `fullWidth={false}` to

## Examples

### Media

<Story
name='WithMedia'
storyModule={CardStories}
/>

### OAuth consent

`Card.Media` can hold more than one logo. Here it shows the two apps being connected.

<Story
name='OAuthConsent'
storyModule={CardStories}
/>

### Banner

<Story
Expand Down
Loading
Loading