diff --git a/.changeset/tall-donkeys-jam.md b/.changeset/tall-donkeys-jam.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/tall-donkeys-jam.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 966e912370b..b2e3726fe36 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -21,11 +21,7 @@ import { WithIcon as BadgeWithIcon, } from '../stories/badge.stories'; import { Disabled, meta as buttonMeta, Primary, Sizes } from '../stories/button.stories'; -import { - Centered as CardCentered, - Default as CardDefault, - meta as cardComponentMeta, -} from '../stories/card.component.stories'; +import { Default as CardDefault, meta as cardComponentMeta } from '../stories/card.component.stories'; import { meta as collapsibleMeta } from '../stories/collapsible.stories'; import { Default as DestructiveDefault, @@ -192,7 +188,7 @@ const alertDialogComponentModule: StoryModule = { DiscardChanges: AlertDialogDiscardChanges, }; -const cardComponentModule: StoryModule = { meta: cardComponentMeta, Default: CardDefault, Centered: CardCentered }; +const cardComponentModule: StoryModule = { meta: cardComponentMeta, Default: CardDefault }; const avatarModule: StoryModule = { meta: avatarMeta, diff --git a/packages/swingset/src/stories/card.component.mdx b/packages/swingset/src/stories/card.component.mdx index 38444cd4842..ae98f07cd01 100644 --- a/packages/swingset/src/stories/card.component.mdx +++ b/packages/swingset/src/stories/card.component.mdx @@ -21,19 +21,11 @@ A styled surface container that groups related content into Header, Content, and import { Card } from '@clerk/ui/mosaic/components/card'; - Heading + + Title + Description + Body content. Footer actions ``` - ---- - -## Examples - -### Centered - - diff --git a/packages/swingset/src/stories/card.component.stories.tsx b/packages/swingset/src/stories/card.component.stories.tsx index 600afdbcb52..cda03df4463 100644 --- a/packages/swingset/src/stories/card.component.stories.tsx +++ b/packages/swingset/src/stories/card.component.stories.tsx @@ -1,8 +1,8 @@ import { Button } from '@clerk/ui/mosaic/components/button'; import type { CardProps } from '@clerk/ui/mosaic/components/card'; import { Card } from '@clerk/ui/mosaic/components/card'; -import { Heading } from '@clerk/ui/mosaic/components/heading'; -import { Text } from '@clerk/ui/mosaic/components/text'; +import { Field } from '@clerk/ui/mosaic/components/field'; +import { Input } from '@clerk/ui/mosaic/components/input'; import type { StoryMeta } from '@/lib/types'; @@ -16,12 +16,10 @@ export const meta: StoryMeta = { source: 'packages/ui/src/mosaic/components/card/card.tsx', styles: { _variants: { - alignment: { start: {}, center: {} }, elevation: { card: {}, flush: {}, overlay: {} }, renderBranding: { true: {}, false: {} }, }, _defaultVariants: { - alignment: 'start', elevation: 'card', renderBranding: true, }, @@ -39,31 +37,18 @@ export function Default(props: Record) { style={{ maxWidth: 400 }} > - Login to your account - Enter your email below to login to your account + Login to your account + Enter your email below to login to your account - Card body content goes here. + + + Email address + + + ); } - -export function Centered() { - return ( - - - Verify your email - We sent a verification code to your email address - - Enter the code below to continue. - - - - - ); -} diff --git a/packages/swingset/src/stories/dialog.component.stories.tsx b/packages/swingset/src/stories/dialog.component.stories.tsx index 1ae1e21e39f..fb107e05ac0 100644 --- a/packages/swingset/src/stories/dialog.component.stories.tsx +++ b/packages/swingset/src/stories/dialog.component.stories.tsx @@ -541,10 +541,9 @@ export function CardSurface() { }> - - }>Sign in - }>Continue to your account. + Sign in + Continue to your account. @@ -555,12 +554,13 @@ export function CardSurface() { )} /> - + @@ -586,7 +586,6 @@ export function OutsideScroll() { }> - }>Terms of service }> diff --git a/packages/ui/src/mosaic/components/card/card.styles.ts b/packages/ui/src/mosaic/components/card/card.styles.ts index 7bf03fa4126..803a97ff991 100644 --- a/packages/ui/src/mosaic/components/card/card.styles.ts +++ b/packages/ui/src/mosaic/components/card/card.styles.ts @@ -1,32 +1,84 @@ import * as stylex from '@stylexjs/stylex'; -import { colorVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; +import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; -export const styles = stylex.create({ - root: { +export const root = stylex.create({ + base: { color: colorVars['--cl-color-card-foreground'], display: 'flex', flexDirection: 'column', width: '100%', }, - header: { - paddingInline: space['4'], + card: { + borderRadius: radiusVars['--cl-radius-xl'], + overflow: 'hidden', + backgroundColor: colorVars['--cl-color-card'], + boxShadow: `0 12px 12px -7px light-dark(oklch(0.2046 0 0 / 12%), transparent), + 0 24px 24px -10px light-dark(oklch(0.2046 0 0 / 4%), transparent), + 0 0 0 1px light-dark(oklch(0.2046 0 0 / 4%), oklch(1 0 0 / 10%))`, + }, + flush: { + borderRadius: radiusVars['--cl-radius-xl'], + overflow: 'visible', + backgroundColor: 'transparent', + boxShadow: 'none', + }, + overlay: { + borderRadius: radiusVars['--cl-radius-xl'], + overflow: 'hidden', + backgroundColor: colorVars['--cl-color-card'], + boxShadow: `0 12px 12px -7px light-dark(oklch(0.2046 0 0 / 12%), transparent), + 0 24px 24px -10px light-dark(oklch(0.2046 0 0 / 4%), transparent), + 0 0 0 1px light-dark(oklch(0.2046 0 0 / 4%), oklch(1 0 0 / 10%))`, + }, +}); + +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'], display: 'flex', - flexDirection: 'column', - paddingBlockEnd: space['5'], - paddingBlockStart: space['5'], + flexDirection: 'row-reverse', + paddingBlockStart: space['4'], }, content: { - paddingInline: space['4'], + display: 'flex', + flexDirection: 'column', + flexGrow: '1', + rowGap: space['1'], + }, + title: { + color: colorVars['--cl-color-card-foreground'], + fontSize: typeScaleVars['--cl-text-base-size'], + fontWeight: fontWeightVars['--cl-font-semibold'], + lineHeight: typeScaleVars['--cl-text-base-leading'], + textWrap: 'balance', + }, + description: { + color: colorVars['--cl-color-neutral-faded'], + fontSize: typeScaleVars['--cl-text-sm-size'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + textWrap: 'pretty', + }, +}); + +export const content = stylex.create({ + base: { + paddingBlock: space['4'], + paddingInline: space['5'], flexBasis: 'auto', flexGrow: '1', flexShrink: '1', - paddingBlockEnd: space['5'], }, - footer: { +}); + +export const footer = stylex.create({ + base: { gap: space['2'], paddingBlock: space['4'], - paddingInline: space['6'], + paddingInline: space['5'], alignItems: 'center', display: 'flex', flexShrink: 0, @@ -36,22 +88,25 @@ export const styles = stylex.create({ borderTopWidth: '1px', width: '100%', }, - branding: { +}); + +export const branding = stylex.create({ + base: { paddingBlock: space['3'], paddingInline: space['6'], - borderBlockStartColor: colorVars['--cl-color-border-faded'], + borderBlockStartColor: colorVars['--cl-color-border'], borderBlockStartStyle: 'solid', borderBlockStartWidth: '1px', textAlign: 'center', }, - brandingText: { + text: { color: colorVars['--cl-color-neutral-faded'], display: 'inline-block', fontSize: typeScaleVars['--cl-text-xs-size'], lineHeight: typeScaleVars['--cl-text-xs-leading'], textWrap: 'pretty', }, - brandingLink: { + link: { borderRadius: radiusVars['--cl-radius-sm'], alignItems: 'center', color: 'inherit', @@ -60,39 +115,3 @@ export const styles = stylex.create({ height: space['4'], }, }); - -export const elevations = stylex.create({ - card: { - borderRadius: radiusVars['--cl-radius-xl'], - overflow: 'hidden', - backgroundColor: colorVars['--cl-color-card'], - boxShadow: `0 12px 12px -7px light-dark(oklch(0.2046 0 0 / 12%), transparent), - 0 24px 24px -10px light-dark(oklch(0.2046 0 0 / 4%), transparent), - 0 0 0 1px light-dark(oklch(0.2046 0 0 / 4%), oklch(1 0 0 / 10%))`, - }, - flush: { - borderRadius: radiusVars['--cl-radius-xl'], - overflow: 'visible', - backgroundColor: 'transparent', - boxShadow: 'none', - }, - overlay: { - borderRadius: radiusVars['--cl-radius-xl'], - overflow: 'hidden', - backgroundColor: colorVars['--cl-color-card'], - boxShadow: `0 12px 12px -7px light-dark(oklch(0.2046 0 0 / 12%), transparent), - 0 24px 24px -10px light-dark(oklch(0.2046 0 0 / 4%), transparent), - 0 0 0 1px light-dark(oklch(0.2046 0 0 / 4%), oklch(1 0 0 / 10%))`, - }, -}); - -export const headerAlignments = stylex.create({ - start: { - alignItems: 'flex-start', - textAlign: 'start', - }, - center: { - alignItems: 'center', - textAlign: 'center', - }, -}); diff --git a/packages/ui/src/mosaic/components/card/card.test.tsx b/packages/ui/src/mosaic/components/card/card.test.tsx index cab47f09934..92751bac5e2 100644 --- a/packages/ui/src/mosaic/components/card/card.test.tsx +++ b/packages/ui/src/mosaic/components/card/card.test.tsx @@ -1,12 +1,13 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React from 'react'; import { describe, expect, it } from 'vitest'; -import { Text } from '../text'; +import { Dialog } from '../dialog'; import { Card } from './card'; describe('Mosaic Card', () => { - it('renders each compound slot with the default alignment', () => { + it('renders each compound slot with its stable class', () => { render( Header @@ -16,29 +17,13 @@ describe('Mosaic Card', () => { ); expect(screen.getByTestId('root')).toHaveClass('cl-card-root'); - expect(screen.getByTestId('root')).toHaveAttribute('data-alignment', 'start'); expect(screen.getByTestId('root')).toHaveAttribute('data-elevation', 'card'); expect(screen.getByTestId('header')).toHaveClass('cl-card-header'); - expect(screen.getByTestId('header')).toHaveAttribute('data-alignment', 'start'); expect(screen.getByTestId('content')).toHaveClass('cl-card-content'); expect(screen.getByTestId('footer')).toHaveClass('cl-card-footer'); expect(screen.getByTestId('footer')).toHaveAttribute('data-elevation', 'card'); }); - it('reflects centered alignment on the root and header', () => { - render( - - Header - , - ); - - expect(screen.getByTestId('root')).toHaveAttribute('data-alignment', 'center'); - expect(screen.getByTestId('header')).toHaveAttribute('data-alignment', 'center'); - }); - it('reflects flush elevation on the root and footer', () => { render( { expect(screen.getByTestId('footer')).toHaveAttribute('data-elevation', 'overlay'); }); - it('provides the neutral text color to header copy', () => { - render( - - - Supporting copy - - , - ); - - expect(screen.getByText('Supporting copy')).toHaveAttribute('data-color', 'neutral'); - }); - it('lets consumer className and style win on every slot', () => { render( { expect(screen.queryByRole('link', { name: 'Clerk' })).toBeNull(); }); + it('renders the title and description slots', () => { + render( + + + Review terms + Accept before you continue. + + , + ); + + expect(screen.getByTestId('title').tagName).toBe('H2'); + expect(screen.getByTestId('title')).toHaveClass('cl-card-title'); + expect(screen.getByTestId('description').tagName).toBe('P'); + expect(screen.getByTestId('description')).toHaveClass('cl-card-description'); + }); + + // Nothing above named the card, so the parts carry no borrowed id. + it('leaves the title and description unidentified outside a labelled surface', () => { + render( + + Review terms + Accept before you continue. + , + ); + + expect(screen.getByTestId('title')).not.toHaveAttribute('id'); + expect(screen.getByTestId('description')).not.toHaveAttribute('id'); + }); + + it('names and describes the dialog it is rendered inside', () => { + render( + + + + Review terms + Accept before you continue. + + + , + ); + + const popup = screen.getByRole('dialog'); + expect(popup).toHaveAttribute('aria-labelledby', screen.getByTestId('title').id); + expect(popup).toHaveAttribute('aria-describedby', screen.getByTestId('description').id); + expect(popup).toHaveAccessibleName('Review terms'); + expect(popup).toHaveAccessibleDescription('Accept before you continue.'); + }); + + // `Dialog.Root` spans the trigger as well as the popup, so only the popup may hand out its ids. + it('withholds the dialog ids from a card outside the popup', async () => { + const user = userEvent.setup(); + render( + + + Terms + + Open + + + + Review terms + + + + , + ); + + expect(screen.getByTestId('outside-title')).not.toHaveAttribute('id'); + + await user.click(screen.getByRole('button', { name: 'Open' })); + + expect(screen.getByRole('dialog')).toHaveAccessibleName('Review terms'); + }); + + // The id is load-bearing inside a dialog: the popup points `aria-labelledby` at it, so a caller + // id that displaced it would silently leave the dialog unnamed. + it('keeps the dialog id over an explicit one, and stays named', () => { + render( + + + + Review terms + + + Read them before you continue. + + + , + ); + + const dialog = screen.getByRole('dialog'); + + expect(screen.getByTestId('title')).not.toHaveAttribute('id', 'custom-title'); + expect(screen.getByTestId('description')).not.toHaveAttribute('id', 'custom-description'); + expect(dialog).toHaveAttribute('aria-labelledby', screen.getByTestId('title').id); + expect(dialog).toHaveAttribute('aria-describedby', screen.getByTestId('description').id); + expect(dialog).toHaveAccessibleName('Review terms'); + expect(dialog).toHaveAccessibleDescription('Read them before you continue.'); + }); + + it('takes an explicit id outside a dialog, where no surface claims one', () => { + render( + + + Review terms + + , + ); + + expect(screen.getByTestId('title')).toHaveAttribute('id', 'custom-title'); + }); + + it('carries the dialog dismiss button in the header', async () => { + const user = userEvent.setup(); + render( + + + + Review terms + + + , + ); + + const close = screen.getByRole('button', { name: 'Close' }); + // First in the DOM, so it takes the dialog's opening focus. + await waitFor(() => expect(close).toHaveFocus()); + + await user.click(close); + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('carries no dismiss button in a header outside a dialog', () => { + render( + + + Review terms + + , + ); + + expect(screen.queryByRole('button', { name: 'Close' })).toBeNull(); + }); + it('supports custom elements through render on every slot', () => { render(
}>
Header
} /> +

Title

} /> + Description} />
Content
} />
Footer
} /> , ); expect(screen.getByText('Header').tagName).toBe('HEADER'); + expect(screen.getByText('Title').tagName).toBe('H3'); + expect(screen.getByText('Description').tagName).toBe('SPAN'); expect(screen.getByText('Content').tagName).toBe('MAIN'); expect(screen.getByText('Footer').tagName).toBe('FOOTER'); expect(screen.getByText('Header').closest('section')).toHaveClass('cl-card-root'); diff --git a/packages/ui/src/mosaic/components/card/card.tsx b/packages/ui/src/mosaic/components/card/card.tsx index 2e84b83aa29..8dbdea009ca 100644 --- a/packages/ui/src/mosaic/components/card/card.tsx +++ b/packages/ui/src/mosaic/components/card/card.tsx @@ -6,32 +6,28 @@ import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { focusOutline } from '../../utils/focus-outline.styles'; import { reset } from '../../utils/reset.styles'; +import { Button } from '../button'; import { ClerkLogo } from '../clerk-logo'; -import { TextContext } from '../text'; -import { elevations, headerAlignments, styles } from './card.styles'; +import { Dialog, DialogContext } from '../dialog'; +import { Icon } from '../icon'; +import * as slots from './card.styles'; -type CardAlignment = 'start' | 'center'; type CardElevation = 'card' | 'flush' | 'overlay'; -const DEFAULT_ALIGNMENT: CardAlignment = 'start'; const DEFAULT_ELEVATION: CardElevation = 'card'; -const CardVariantContext = React.createContext<{ alignment: CardAlignment; elevation: CardElevation }>({ - alignment: DEFAULT_ALIGNMENT, - elevation: DEFAULT_ELEVATION, -}); +const CardElevationContext = React.createContext(DEFAULT_ELEVATION); -/** Signs the foot of the card with "Secured by Clerk". */ function Branding() { return ( -
- +
+ Secured by{' '} @@ -40,10 +36,7 @@ function Branding() { ); } -/** Props for `Card.Root`, including native `div` props and the Mosaic `render` escape hatch. */ export interface CardProps extends MosaicComponentProps<'div'> { - /** Alignment applied to `Card.Header`. @default 'start' */ - alignment?: CardAlignment; /** Surface treatment applied to the card. @default 'card' */ elevation?: CardElevation; /** @@ -56,16 +49,7 @@ export interface CardProps extends MosaicComponentProps<'div'> { } const Root = React.forwardRef(function CardRoot( - { - alignment = DEFAULT_ALIGNMENT, - elevation = DEFAULT_ELEVATION, - renderBranding = true, - render, - className, - style, - children, - ...rest - }, + { elevation = DEFAULT_ELEVATION, renderBranding = true, render, className, style, children, ...rest }, ref, ) { const element = useRender({ @@ -74,8 +58,8 @@ const Root = React.forwardRef(function CardRoot( ref, props: { ...mergeStyleProps( - themeProps('card-root', { alignment, elevation }), - stylex.props(reset.base, styles.root, elevations[elevation]), + themeProps('card-root', { elevation }), + stylex.props(reset.base, slots.root.base, slots.root[elevation]), className, style, ), @@ -89,30 +73,102 @@ const Root = React.forwardRef(function CardRoot( }, }); - return {element}; + return {element}; }); +/** + * Dismisses the dialog from inside the header, in flow, so the header reserves the width it takes + * and a long title cannot run under it. `Dialog.CloseButton` stays the corner affordance, for + * dialogs that hold no card. + */ +function HeaderCloseButton() { + return ( + ( +