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
2 changes: 2 additions & 0 deletions .changeset/mosaic-card-root-align.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
33 changes: 31 additions & 2 deletions packages/mosaic/src/components/card/card.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,15 @@ describe('Card.Media', () => {
});
});

describe('Card.Header align', () => {
describe('Card.Root align', () => {
function CenteredCard() {
return (
<Card.Root
align='center'
renderBranding={false}
data-testid='root'
>
<Card.Header align='center'>
<Card.Header>
<Card.Media data-testid='media'>
<svg
width='32'
Expand Down Expand Up @@ -65,6 +66,34 @@ describe('Card.Header align', () => {
expectCentered('title');
});

it('keeps a description that does not wrap inside the card', () => {
render(
<Card.Root
align='center'
renderBranding={false}
data-testid='root'
>
<Card.Header>
<Card.Title>
<span data-testid='title'>Title</span>
</Card.Title>
<Card.Description
data-testid='description'
style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{'jane@acme.com'.repeat(12)}
</Card.Description>
</Card.Header>
</Card.Root>,
);

const root = screen.getByTestId('root').getBoundingClientRect();
const description = screen.getByTestId('description').getBoundingClientRect();
expect(description.left).toBeGreaterThanOrEqual(root.left);
expect(description.right).toBeLessThanOrEqual(root.right);
expectCentered('title');
});

it('keeps them centered in the card beside the dialog close button', () => {
render(
<Dialog.Root defaultOpen>
Expand Down
9 changes: 8 additions & 1 deletion packages/mosaic/src/components/card/card.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export const header = stylex.create({
flexDirection: 'column',
flexGrow: '1',
rowGap: HEADER_ROW_GAP,
minWidth: 0,
},
contentBesideCloseButton: {
paddingInlineStart: `calc(${CLOSE_BUTTON_WIDTH} + ${HEADER_COLUMN_GAP})`,
Expand All @@ -104,11 +105,17 @@ export const header = stylex.create({
export const headerAlign = stylex.create({
start: {},
center: {
alignItems: 'center',
textAlign: 'center',
},
});

export const mediaAlign = stylex.create({
start: {},
center: {
justifyContent: 'center',
},
});

const INSET = space['4'];

export const banner = stylex.create({
Expand Down
10 changes: 5 additions & 5 deletions packages/mosaic/src/components/card/card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -454,15 +454,15 @@ describe('Mosaic Card', () => {
expect(screen.queryByRole('link', { name: 'Clerk' })).toBeNull();
});

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

expect(screen.getByTestId('default')).toHaveAttribute('data-align', 'start');
Expand Down
48 changes: 30 additions & 18 deletions packages/mosaic/src/components/card/card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,17 @@ type CardElevation = 'card' | 'flush' | 'overlay';

export type CardSize = keyof typeof slots.sizes;

export type CardHeaderAlign = keyof typeof slots.headerAlign;
export type CardAlign = 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
// dialog opened from inside another card is still the dialog's outermost one.
const CardContext = React.createContext<{ elevation: CardElevation; dialog: DialogContextValue | null } | null>(null);
const CardContext = React.createContext<{
elevation: CardElevation;
align: CardAlign;
dialog: DialogContextValue | null;
} | null>(null);

function CardBranding() {
return (
Expand All @@ -45,6 +49,8 @@ export interface CardProps extends MosaicComponentProps<'div'> {
elevation?: CardElevation;
/** The card's width. `lg` is for forms that need the room, like a tag input. @default 'md' */
size?: CardSize;
/** Aligns the content of every `Card.Header` in the card. @default 'start' */
align?: CardAlign;
/**
* Signs the foot of the card with "Secured by Clerk". An instance that has paid the branding off
* carries none of it, so a connected surface passes `displayConfig.branded` here.
Expand All @@ -55,20 +61,29 @@ export interface CardProps extends MosaicComponentProps<'div'> {
}

const Root = React.forwardRef<HTMLDivElement, CardProps>(function CardRoot(
{ elevation = DEFAULT_ELEVATION, size = 'md', renderBranding = true, render, xstyle, children, ...rest },
{
elevation = DEFAULT_ELEVATION,
size = 'md',
align = 'start',
renderBranding = true,
render,
xstyle,
children,
...rest
},
ref,
) {
const dialog = React.useContext(DialogContext);
const enclosing = React.useContext(CardContext);
const isSurface = (enclosing === null || enclosing.dialog !== dialog) && elevation !== 'flush';
const context = React.useMemo(() => ({ elevation, dialog }), [elevation, dialog]);
const context = React.useMemo(() => ({ elevation, align, dialog }), [elevation, align, dialog]);
const element = useRender({
defaultTagName: 'div',
render,
ref,
props: {
...mergeStyleProps(
themeProps('card-root', { elevation, size }),
themeProps('card-root', { elevation, size, align }),
stylex.props(
reset.base,
slots.root.base,
Expand Down Expand Up @@ -115,27 +130,19 @@ function HeaderCloseButton() {
);
}

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 },
const Header = React.forwardRef<HTMLDivElement, MosaicComponentProps<'div'>>(function CardHeader(
{ render, xstyle, children, ...rest },
ref,
) {
const align = React.useContext(CardContext)?.align ?? 'start';
const dialog = React.useContext(DialogContext);
const hasCloseButton = isInDialog(dialog) && dialog.showCloseButton;
return useRender({
defaultTagName: 'div',
render,
ref,
props: {
...mergeStyleProps(
themeProps('card-header', { align }),
stylex.props(reset.base, slots.header.base, xstyle),
rest,
),
...mergeStyleProps(themeProps('card-header'), 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 Down Expand Up @@ -168,12 +175,17 @@ const Media = React.forwardRef<HTMLDivElement, MosaicComponentProps<'div'>>(func
{ render, xstyle, ...rest },
ref,
) {
const align = React.useContext(CardContext)?.align ?? 'start';
return useRender({
defaultTagName: 'div',
render,
ref,
props: {
...mergeStyleProps(themeProps('card-media'), stylex.props(reset.base, slots.header.media, xstyle), rest),
...mergeStyleProps(
themeProps('card-media'),
stylex.props(reset.base, slots.header.media, slots.mediaAlign[align], xstyle),
rest,
),
},
});
});
Expand Down
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, CardHeaderAlign, CardHeaderProps, CardProps, CardSize } from './card';
export type { CardAlign, CardBannerProps, CardProps, CardSize } from './card';
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import * as stylex from '@stylexjs/stylex';

import { Button } from '../../../components/button';
import { Card } from '../../../components/card';
import { Icon } from '../../../components/icon';
import { Text } from '../../../components/text';
import { Pen } from '../../../icons/glyphs/pen';
import { truncationStyles } from '../../../styles/typography.styles';
import { styles } from '../sign-in.styles';
Expand All @@ -16,14 +16,8 @@ export interface SignInIdentityPreviewProps {

export function SignInIdentityPreview({ identifier, editLabel, disabled = false, onEdit }: SignInIdentityPreviewProps) {
return (
<div {...stylex.props(styles.identity)}>
<Text
size='sm'
color='foreground-secondary'
xstyle={[styles.identityText, truncationStyles.singleLine]}
>
{identifier}
</Text>
<Card.Description xstyle={styles.identity}>
<span {...stylex.props(styles.identityText, truncationStyles.singleLine)}>{identifier}</span>
{onEdit ? (
<Button
type='button'
Expand All @@ -37,6 +31,6 @@ export function SignInIdentityPreview({ identifier, editLabel, disabled = false,
<Icon glyph={Pen} />
</Button>
) : null}
</div>
</Card.Description>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,14 @@ export function SignInPassword({
<Card.Header>
<Card.Title>{messages.title}</Card.Title>
<Card.Description>{messages.description}</Card.Description>
</Card.Header>
<Card.Content>
<SignInIdentityPreview
identifier={identifier}
editLabel={messages.identityEditLabel}
disabled={isPending}
onEdit={onEditIdentifier}
/>
</Card.Header>
<Card.Content>
<Form
id={formId}
onSubmit={handleSubmit}
Expand Down
2 changes: 1 addition & 1 deletion packages/swingset/src/stories/card.component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ 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.Root` takes `align='center'` to center the content of every `Card.Header` in the card.

`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.

Expand Down
4 changes: 2 additions & 2 deletions packages/swingset/src/stories/card.component.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,8 @@ export function WithMedia() {

export function OAuthConsent() {
return (
<Card.Root>
<Card.Header align='center'>
<Card.Root align='center'>
<Card.Header>
<Card.Media xstyle={styles.connection}>
<Avatar.Root
shape='square'
Expand Down
2 changes: 1 addition & 1 deletion packages/swingset/src/stories/sign-in.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ The view renders one step at a time inside a `Card.Root` and reports every inter
import { Card } from '@clerk/mosaic/components/card';
import { SignInView } from '@clerk/mosaic/features/sign-in';

<Card.Root>
<Card.Root align='center'>
<SignInView
step={step}
combined
Expand Down
4 changes: 2 additions & 2 deletions packages/swingset/src/stories/sign-in.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ function WorkingExample({ onComplete }: { onComplete: (outcome: Outcome) => void

return (
<>
<Card.Root>
<Card.Root align='center'>
<SignInView
step={step}
direction={direction}
Expand Down Expand Up @@ -379,7 +379,7 @@ function StepPanel({
const methods = stage === 'first' ? firstFactors.slice(1) : secondFactors.slice(1);

return (
<Card.Root>
<Card.Root align='center'>
<SignInView
step={step}
applicationName={APPLICATION_NAME}
Expand Down
Loading