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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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} />
} />
,
);
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 (
+ (
+
+ )}
+ >
+
+
+ );
+}
+
const Header = React.forwardRef>(function CardHeader(
- { render, className, style, ...rest },
+ { render, className, style, children, ...rest },
ref,
) {
- const { alignment } = React.useContext(CardVariantContext);
- const element = useRender({
+ const dialog = React.useContext(DialogContext);
+ return useRender({
defaultTagName: 'div',
render,
ref,
+ props: {
+ ...mergeStyleProps(themeProps('card-header'), stylex.props(reset.base, slots.header.base), className, style),
+ ...rest,
+ children: (
+ <>
+ {/* First in the DOM, so it is the first tabbable element and takes the dialog's opening
+ focus — the same reason `Dialog.CloseButton` is a part rather than a popup flag. */}
+ {dialog ? : null}
+
+ {children}
+
+ >
+ ),
+ },
+ });
+});
+
+/**
+ * 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.
+ */
+const Title = React.forwardRef>(function CardTitle(
+ { render, className, style, ...rest },
+ ref,
+) {
+ const dialog = React.useContext(DialogContext);
+ return useRender({
+ defaultTagName: 'h2',
+ render,
+ ref,
+ props: {
+ ...mergeStyleProps(themeProps('card-title'), stylex.props(reset.base, slots.header.title), className, style),
+ ...rest,
+ // The popup points `aria-labelledby` at this id, so the surface outranks the caller: an id
+ // that displaced it would leave the dialog with no accessible name.
+ ...(dialog && { id: dialog.labelId }),
+ },
+ });
+});
+
+/** Describes the card. The `aria-describedby` counterpart to {@link Title}. */
+const Description = React.forwardRef>(function CardDescription(
+ { render, className, style, ...rest },
+ ref,
+) {
+ const dialog = React.useContext(DialogContext);
+ return useRender({
+ defaultTagName: 'p',
+ render,
+ ref,
props: {
...mergeStyleProps(
- themeProps('card-header', { alignment }),
- stylex.props(reset.base, styles.header, headerAlignments[alignment]),
+ themeProps('card-description'),
+ stylex.props(reset.base, slots.header.description),
className,
style,
),
...rest,
+ ...(dialog && { id: dialog.descriptionId }),
},
});
-
- return {element};
});
const Content = React.forwardRef>(function CardContent(
@@ -124,7 +180,7 @@ const Content = React.forwardRef>(fu
render,
ref,
props: {
- ...mergeStyleProps(themeProps('card-content'), stylex.props(reset.base, styles.content), className, style),
+ ...mergeStyleProps(themeProps('card-content'), stylex.props(reset.base, slots.content.base), className, style),
...rest,
},
});
@@ -134,7 +190,7 @@ const Footer = React.forwardRef>(fun
{ render, className, style, ...rest },
ref,
) {
- const { elevation } = React.useContext(CardVariantContext);
+ const elevation = React.useContext(CardElevationContext);
return useRender({
defaultTagName: 'div',
render,
@@ -142,7 +198,7 @@ const Footer = React.forwardRef>(fun
props: {
...mergeStyleProps(
themeProps('card-footer', { elevation }),
- stylex.props(reset.base, styles.footer),
+ stylex.props(reset.base, slots.footer.base),
className,
style,
),
@@ -152,7 +208,11 @@ const Footer = React.forwardRef>(fun
});
/**
- * A styled surface composed through `Card.Root`, `Card.Header`, `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.Title`, `Card.Description`,
+ * `Card.Content`, and `Card.Footer`. Every part accepts the Mosaic `render` prop and forwards its
+ * ref.
+ *
+ * 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, Content, Footer };
+export const Card = { Root, Header, Title, Description, Content, Footer };
diff --git a/packages/ui/src/mosaic/components/dialog/dialog.tsx b/packages/ui/src/mosaic/components/dialog/dialog.tsx
index e12b32bffd4..fd1f1aa13da 100644
--- a/packages/ui/src/mosaic/components/dialog/dialog.tsx
+++ b/packages/ui/src/mosaic/components/dialog/dialog.tsx
@@ -1,5 +1,5 @@
import type { DialogFocusTarget, DialogHandle, DialogProps as HeadlessDialogProps } from '@clerk/headless/dialog';
-import { Dialog as Primitive, useDialogContext } from '@clerk/headless/dialog';
+import { Dialog as Primitive, useDialogContext as useHeadlessDialogContext } from '@clerk/headless/dialog';
import * as stylex from '@stylexjs/stylex';
import type { ReactNode } from 'react';
import React from 'react';
@@ -40,6 +40,28 @@ const DialogSizeContext = React.createContext('prompt');
*/
const DialogParentSizeContext = React.createContext('prompt');
+/**
+ * The dialog surface a part is rendered inside, or `null` when there is none.
+ *
+ * The general answer to "am I in a dialog": a part reads it to take an id, or to adapt to the
+ * surface, without branching on where it was rendered. `Card.Title` is the first consumer — it
+ * takes `labelId` and so names the dialog, and carries no id at all outside one.
+ *
+ * Distinct from the headless `DialogContext`, which `Dialog.Root` provides. `Root` also spans the
+ * trigger, so a part reading that one reports a dialog while sitting outside the popup, and would
+ * claim ids that belong to the surface. This is published by the popup, which is the real boundary.
+ */
+export interface DialogContextValue {
+ /** Id the popup points `aria-labelledby` at. The part that names the dialog takes it. */
+ labelId: string;
+ /** Id the popup points `aria-describedby` at. The part that describes the dialog takes it. */
+ descriptionId: string;
+ /** Width, and for `panel` also height, of the surface. */
+ size: DialogSize;
+}
+
+export const DialogContext = React.createContext(null);
+
/**
* The compound component the popup's dev warnings speak in. `AlertDialog` is composed from these
* same parts, so a message hardcoded to `Dialog` would name parts that do not exist at the call
@@ -50,7 +72,7 @@ export const DialogPartNameContext = React.createContext('Dialog');
/** Whether this dialog is a prompt stacked on a prompt — see {@link DialogParentSizeContext}. */
function useIsStacked() {
- const { isStacked } = useDialogContext();
+ const { isStacked } = useHeadlessDialogContext();
const parentSize = React.useContext(DialogParentSizeContext);
return isStacked && parentSize === 'prompt';
}
@@ -268,7 +290,8 @@ const Popup = React.forwardRef(function Dialog
const size = React.useContext(DialogSizeContext);
// The headless flag, not `useIsStacked` — the rule is about opening a dialog inside ANY dialog,
// which is broader than the prompt-on-prompt case the stacking styles cover.
- const { isStacked: isNestedInDialog } = useDialogContext();
+ const { isStacked: isNestedInDialog, labelId, descriptionId } = useHeadlessDialogContext();
+ const surface = React.useMemo(() => ({ labelId, descriptionId, size }), [labelId, descriptionId, size]);
// Observed through state rather than a plain ref, because the warning has to re-run when the
// node arrives and a ref mutation does not re-render.
const [node, setNode] = React.useState(null);
@@ -288,16 +311,18 @@ const Popup = React.forwardRef(function Dialog
);
return (
-
+
+
+
);
});
@@ -324,7 +349,7 @@ export interface DialogProps extends Pick<
* close the same as Escape does — and can decline it.
*/
export function DialogContent({ children }: { children: DialogProps['children'] }) {
- const { setOpen } = useDialogContext();
+ const { setOpen } = useHeadlessDialogContext();
if (typeof children !== 'function') {
return <>{children}>;
}
diff --git a/packages/ui/src/mosaic/components/dialog/index.ts b/packages/ui/src/mosaic/components/dialog/index.ts
index e11a50a40b6..62ad5488198 100644
--- a/packages/ui/src/mosaic/components/dialog/index.ts
+++ b/packages/ui/src/mosaic/components/dialog/index.ts
@@ -1,9 +1,10 @@
-export { Dialog } from './dialog';
+export { Dialog, DialogContext } from './dialog';
export type { DialogFocusTarget, DialogHandle, DialogOpenChangeDetails } from '@clerk/headless/dialog';
export type {
DialogBackdropProps,
DialogCloseButtonProps,
DialogCloseProps,
+ DialogContextValue,
DialogDescriptionProps,
DialogPopupProps,
DialogProps,