diff --git a/.changeset/mosaic-card-image.md b/.changeset/mosaic-card-image.md
new file mode 100644
index 00000000000..33e5ca286ad
--- /dev/null
+++ b/.changeset/mosaic-card-image.md
@@ -0,0 +1,5 @@
+---
+'@clerk/mosaic': patch
+---
+
+Add `Card.Image`, a part for the application's logo on its own row at the top of a `Card.Header`. It renders the image at a height derived from its proportions and links home when given an `href`. `Card.Header` now takes `align`, where `center` stacks the image, title, and description down the middle.
diff --git a/packages/mosaic/src/components/card/card.styles.ts b/packages/mosaic/src/components/card/card.styles.ts
index 7489b903a19..746bf1baa47 100644
--- a/packages/mosaic/src/components/card/card.styles.ts
+++ b/packages/mosaic/src/components/card/card.styles.ts
@@ -8,6 +8,7 @@ import {
radiusVars,
shadowVars,
space,
+ targetVars,
typeScaleVars,
} from '../../tokens.stylex';
import { cardContentMarker } from './card.markers.stylex';
@@ -66,6 +67,13 @@ export const header = stylex.create({
flexGrow: '1',
rowGap: space['0.5'],
},
+ centered: {
+ alignItems: 'center',
+ textAlign: 'center',
+ },
+ centeredWithClose: {
+ marginInlineStart: `calc(${space['7']} + ${space['1']})`,
+ },
title: {
color: colorVars['--cl-color-foreground'],
fontSize: typeScaleVars['--cl-text-base-size'],
@@ -81,6 +89,40 @@ export const header = stylex.create({
},
});
+export const image = stylex.create({
+ base: {
+ alignSelf: 'flex-start',
+ display: 'inline-flex',
+ marginBlockEnd: space['3.5'],
+ maxWidth: '100%',
+ },
+ centered: {
+ alignSelf: 'center',
+ },
+ interactive: {
+ borderRadius: radiusVars['--cl-radius-sm'],
+ textDecoration: 'none',
+ color: 'inherit',
+ },
+ touchTarget: {
+ position: { default: null, '@media (pointer: coarse)': 'relative' },
+ '::after': {
+ insetBlock: `min(0px, (100% - ${targetVars['--cl-target-coarse']}) / 2)`,
+ insetInline: `min(0px, (100% - ${targetVars['--cl-target-coarse']}) / 2)`,
+ content: { default: null, '@media (pointer: coarse)': '""' },
+ position: 'absolute',
+ },
+ },
+ scale: (scale: number) => ({ height: `calc(${space['6']} * ${scale})` }),
+ image: {
+ display: 'block',
+ objectFit: 'contain',
+ height: '100%',
+ maxWidth: '100%',
+ width: 'auto',
+ },
+});
+
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 5a65e2953be..93465663d37 100644
--- a/packages/mosaic/src/components/card/card.test.tsx
+++ b/packages/mosaic/src/components/card/card.test.tsx
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { Dialog } from '../dialog';
import { Card } from './card';
+import * as slots from './card.styles';
const compactCard = '@container card (max-width: 20rem)' as const;
@@ -347,6 +348,201 @@ describe('Mosaic Card', () => {
expect(screen.queryByRole('link', { name: 'Clerk' })).toBeNull();
});
+ it('places the application logo in the header, named by its alt text', () => {
+ render(
+
+
+
+ Sign in to Acme
+
+ ,
+ );
+
+ const logo = screen.getByTestId('logo');
+ expect(logo.tagName).toBe('SPAN');
+ expect(logo).toHaveClass('cl-card-image');
+ expect(logo).not.toHaveAttribute('data-interactive');
+ expect(screen.getByTestId('header')).toContainElement(logo);
+ expect(logo.nextElementSibling).toHaveClass('cl-card-title');
+
+ const image = screen.getByRole('img', { name: 'Acme' });
+ expect(logo).toContainElement(image);
+ expect(image).toHaveClass('cl-card-image-img');
+ expect(image).toHaveAttribute('src', 'https://example.com/logo.png');
+ expect(screen.queryByRole('link', { name: 'Acme' })).toBeNull();
+ });
+
+ it('links the logo home when given an href', () => {
+ render(
+
+
+
+
+ ,
+ );
+
+ const link = screen.getByRole('link', { name: 'Acme' });
+ expect(link).toHaveClass('cl-card-image');
+ expect(link).toHaveClass(stylex.props(slots.image.touchTarget).className ?? '');
+ expect(link).toHaveAttribute('href', 'https://acme.example');
+ expect(link).toHaveAttribute('data-interactive');
+ });
+
+ it('routes the logo through a render source', () => {
+ render(
+
+
+ (
+
+ {children}
+
+ )}
+ />
+
+ ,
+ );
+
+ const link = screen.getByRole('link', { name: 'Acme' });
+ expect(link).toHaveClass('cl-card-image');
+ expect(link).toHaveAttribute('href', '/home');
+ expect(link).toHaveAttribute('data-interactive');
+ });
+
+ const expectScale = (element: HTMLElement, scale: number): void => {
+ const { style } = stylex.props(slots.image.scale(scale));
+ for (const [property, value] of Object.entries(style ?? {})) {
+ expect(element.style.getPropertyValue(property)).toBe(String(value));
+ }
+ };
+
+ const loadLogo = (image: HTMLImageElement, naturalWidth: number, naturalHeight: number): void => {
+ Object.defineProperty(image, 'naturalWidth', { configurable: true, value: naturalWidth });
+ Object.defineProperty(image, 'naturalHeight', { configurable: true, value: naturalHeight });
+ act(() => {
+ image.dispatchEvent(new Event('load'));
+ });
+ };
+
+ it('sizes the logo from the proportions of its image', () => {
+ render(
+
+
+
+
+ ,
+ );
+
+ const logo = screen.getByTestId('logo');
+ const image = screen.getByRole('img', { name: 'Acme' });
+ expectScale(logo, 1);
+
+ loadLogo(image, 600, 200);
+ expectScale(logo, 1);
+
+ loadLogo(image, 300, 200);
+ expectScale(logo, 2 / 1.5);
+
+ loadLogo(image, 200, 200);
+ expectScale(logo, 2);
+
+ loadLogo(image, 100, 200);
+ expectScale(logo, 2);
+ });
+
+ it('sizes an already-loaded logo without waiting for a load event', () => {
+ stubPrototype(HTMLImageElement.prototype, 'complete', { get: () => true });
+ stubPrototype(HTMLImageElement.prototype, 'naturalWidth', { get: () => 200 });
+ stubPrototype(HTMLImageElement.prototype, 'naturalHeight', { get: () => 200 });
+
+ render(
+
+
+
+
+ ,
+ );
+
+ expectScale(screen.getByTestId('logo'), 2);
+ });
+
+ it('reflects the header alignment, and centers the image with it', () => {
+ render(
+ <>
+
+
+
+
+
+
+ >,
+ );
+
+ const atoms = (style: stylex.StyleXStyles): string[] =>
+ (stylex.props(style).className ?? '').split(' ').filter(name => /^x[a-z0-9]+$/.test(name));
+
+ expect(screen.getByTestId('start')).toHaveAttribute('data-align', 'start');
+ expect(screen.getByTestId('start').querySelector('.cl-card-header-content')).not.toHaveClass(
+ ...atoms(slots.header.centered),
+ );
+ expect(screen.getByTestId('start-image')).not.toHaveClass(...atoms(slots.image.centered));
+
+ expect(screen.getByTestId('center')).toHaveAttribute('data-align', 'center');
+ expect(screen.getByTestId('center').querySelector('.cl-card-header-content')).toHaveClass(
+ ...atoms(slots.header.centered),
+ );
+ expect(screen.getByTestId('center-image')).toHaveClass(...atoms(slots.image.centered));
+ });
+
+ it('keeps centered header content centered when a dialog adds its close button', () => {
+ render(
+
+
+
+
+ Sign in
+
+
+
+ ,
+ );
+
+ const content = screen.getByRole('dialog').querySelector('.cl-card-header-content');
+ expect(content).toHaveClass(stylex.props(slots.header.centeredWithClose).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 1b8012f8d7b..5a1db7ebd69 100644
--- a/packages/mosaic/src/components/card/card.tsx
+++ b/packages/mosaic/src/components/card/card.tsx
@@ -1,3 +1,4 @@
+import { useSafeLayoutEffect } from '@clerk/shared/react';
import * as stylex from '@stylexjs/stylex';
import React from 'react';
@@ -5,6 +6,7 @@ import { useTransition } from '../../primitives/hooks';
import { useRender } from '../../primitives/utils';
import type { MosaicComponentProps } from '../../props';
import { mergeStyleProps, themeProps } from '../../props';
+import { focusOutline } from '../../styles/focus-outline.styles';
import { reset } from '../../styles/reset.styles';
import { hasMessage, useHeldMessage } from '../../utils/feedback';
import type { BannerRootProps } from '../banner';
@@ -24,6 +26,10 @@ const DEFAULT_ELEVATION: CardElevation = 'card';
const CardElevationContext = React.createContext(DEFAULT_ELEVATION);
+type CardHeaderAlign = 'start' | 'center';
+
+const CardHeaderAlignContext = React.createContext('start');
+
function CardBranding() {
return (
@@ -95,17 +101,34 @@ function HeaderCloseButton() {
);
}
-const Header = React.forwardRef>(function CardHeader(
- { render, xstyle, children, ...rest },
+/** Props for the card header, including alignment and native `div` props. */
+export interface CardHeaderProps extends MosaicComponentProps<'div'> {
+ /**
+ * How the header's parts line up. `center` stacks the image, title, and description down the
+ * middle, the shape of a sign-in or sign-up card.
+ *
+ * @default 'start'
+ */
+ align?: CardHeaderAlign;
+}
+
+const Header = React.forwardRef(function CardHeader(
+ { align = 'start', render, xstyle, children, ...rest },
ref,
-) {
+): React.ReactElement {
const dialog = React.useContext(DialogContext);
- return useRender({
+ const centered = align === 'center';
+ const hasCloseButton = isInDialog(dialog) && dialog.role !== 'alertdialog';
+ const element = 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
@@ -113,14 +136,109 @@ const Header = React.forwardRef>(fun
Not outside a dialog, where there is nothing to close, and not in 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.role !== 'alertdialog' ? : null}
-