Skip to content

Commit 23353f2

Browse files
committed
feat(ui): add Mosaic Popover component
Reusable StyleX-styled popover built on the @clerk/headless popover primitive: a flexible popup card with content + footer slots, enter/exit transition driven off self data-attributes, and swingset docs.
1 parent 1f8d89f commit 23353f2

11 files changed

Lines changed: 492 additions & 1 deletion

File tree

.changeset/mosaic-popover.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

packages/swingset/src/components/DocsViewer.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
3434
dialog: dynamic(() => import('../stories/dialog.component.mdx')),
3535
heading: dynamic(() => import('../stories/heading.mdx')),
3636
icon: dynamic(() => import('../stories/icon.mdx')),
37+
popover: dynamic(() => import('../stories/popover.component.mdx')),
3738
tabs: dynamic(() => import('../stories/tabs.component.mdx')),
3839
text: dynamic(() => import('../stories/text.mdx')),
3940
},

packages/swingset/src/lib/registry.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import {
6767
meta as organizationProfileProfileSectionMeta,
6868
} from '../stories/organization-profile-profile-section.stories';
6969
import { meta as otpMeta } from '../stories/otp.stories';
70+
import { Default as PopoverComponentDefault, meta as popoverComponentMeta } from '../stories/popover.component.stories';
7071
import { meta as popoverMeta } from '../stories/popover.stories';
7172
import { meta as selectMeta } from '../stories/select.stories';
7273
import { Default as TabsComponentDefault, meta as tabsComponentMeta } from '../stories/tabs.component.stories';
@@ -121,6 +122,8 @@ const inputModule: StoryModule = { meta: inputMeta, Default, Sizes: InputSizes,
121122

122123
const dialogComponentModule: StoryModule = { meta: dialogComponentMeta, Default: DialogDefault };
123124

125+
const popoverComponentModule: StoryModule = { meta: popoverComponentMeta, Default: PopoverComponentDefault };
126+
124127
const headingModule: StoryModule = {
125128
meta: headingMeta,
126129
Default: HeadingDefault,
@@ -177,6 +180,7 @@ export const registry: StoryModule[] = [
177180
dialogComponentModule,
178181
headingModule,
179182
iconModule,
183+
popoverComponentModule,
180184
tabsComponentModule,
181185
textModule,
182186
// Primitives — alphabetical within the group.
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import * as PopoverStories from './popover.component.stories';
2+
3+
# Popover
4+
5+
The Mosaic `Popover` — the styled Mosaic component composed from the `@clerk/headless` popover
6+
primitive and themed with StyleX. It is a reusable floating card anchored to a trigger: drop any
7+
inner content into `Popover.Content` and an optional `Popover.Footer`. It inherits the primitive's
8+
positioning, focus management, and ARIA wiring.
9+
10+
## Example
11+
12+
<Story
13+
name='Default'
14+
storyModule={PopoverStories}
15+
/>
16+
17+
## Usage
18+
19+
```tsx
20+
import { Button } from '@clerk/ui/mosaic/components/button';
21+
import { Popover } from '@clerk/ui/mosaic/components/popover';
22+
23+
<Popover trigger={props => <Button {...props}>Open popover</Button>}>
24+
<Popover.Content>Flexible inner content.</Popover.Content>
25+
<Popover.Footer>
26+
<Button
27+
intent='destructive'
28+
fullWidth
29+
>
30+
Sign out of all accounts
31+
</Button>
32+
</Popover.Footer>
33+
</Popover>;
34+
```
35+
36+
The `trigger` render prop receives the interaction props (ARIA attributes, click handler) from the
37+
headless layer and should spread them onto whatever element opens the popover.
38+
39+
### Controlled
40+
41+
```tsx
42+
const [open, setOpen] = useState(false);
43+
44+
<Popover
45+
open={open}
46+
onOpenChange={setOpen}
47+
trigger={props => <Button {...props}>Open</Button>}
48+
>
49+
<Popover.Content>Flexible inner content.</Popover.Content>
50+
</Popover>;
51+
```
52+
53+
### Placement
54+
55+
`placement` sets the preferred side and alignment; `sideOffset` sets the gap from the trigger. The
56+
popup flips and shifts automatically to stay in view.
57+
58+
```tsx
59+
<Popover
60+
placement='bottom-end'
61+
sideOffset={8}
62+
trigger={props => <Button {...props}>Open</Button>}
63+
>
64+
<Popover.Content>Aligned to the trigger's end edge.</Popover.Content>
65+
</Popover>;
66+
```
67+
68+
## Parts
69+
70+
The convenience `Popover` composes the trigger and a portalled, positioned popup. For custom
71+
layouts, compose the compound parts directly.
72+
73+
| Part | Slot | Description |
74+
| --------------------- | -------------------- | -------------------------------------------------------------- |
75+
| `Popover.Root` || State provider; owns open/close and placement. |
76+
| `Popover.Trigger` || Anchor element; accepts a `render` prop. |
77+
| `Popover.Portal` || Portals the popup out to the document body. |
78+
| `Popover.Positioner` | `popover-positioner` | Floating wrapper; owns positioning, stacking, `data-side`. |
79+
| `Popover.Popup` | `popover-popup` | The card surface; holds content + footer, runs the enter/exit. |
80+
| `Popover.Content` | `popover-content` | Flexible inner content region; scrolls on overflow. |
81+
| `Popover.Footer` | `popover-footer` | Footer region, separated from content by a top border. |
82+
| `Popover.Close` || Dismisses the popover; accepts a `render` prop. |
83+
| `Popover.Title` || Heading; wired to the popup's `aria-labelledby`. |
84+
| `Popover.Description` || Description; wired to the popup's `aria-describedby`. |
85+
| `Popover.Arrow` || Optional arrow pointing at the trigger. |
86+
87+
## Styling
88+
89+
Unlike the slot-recipe components, the Mosaic popover is themed with **StyleX**. Each styled part
90+
carries a stable `.cl-<slot>` class (the slots in the table above) alongside the StyleX atoms.
91+
Consumers never target the hashed atomic classes — override by targeting the `.cl-*` slot from a
92+
CSS layer that wins over `@clerk/ui/styles.css`:
93+
94+
```css
95+
@import '@clerk/ui/styles.css' layer(components);
96+
97+
@layer overrides {
98+
.cl-popover-popup {
99+
border-radius: 20px;
100+
}
101+
}
102+
```
103+
104+
State attributes from the headless layer are available for CSS targeting:
105+
106+
| Attribute | Applies To | Description |
107+
| --------------------- | -------------- | --------------------------------------------------- |
108+
| `data-open` | Trigger, Popup | Present when the popover is open |
109+
| `data-closed` | Trigger, Popup | Present when closed (during exit) |
110+
| `data-starting-style` | Popup | Present on the entering frame |
111+
| `data-ending-style` | Popup | Present during the exit animation |
112+
| `data-side` | Positioner | Resolved side (`top` / `bottom` / `left` / `right`) |
113+
114+
The popup's default enter/exit transition (opacity + scale) is driven off `data-starting-style` /
115+
`data-ending-style` and is disabled under `prefers-reduced-motion: reduce`.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/** @jsxImportSource @emotion/react */
2+
import { Button } from '@clerk/ui/mosaic/components/button';
3+
import { Popover } from '@clerk/ui/mosaic/components/popover';
4+
5+
import type { StoryMeta } from '@/lib/types';
6+
7+
// Exposes this file's own source (via the `?raw` webpack rule) so each `<Story>` example
8+
// renders a code footer with its function's source. See `StoryModule.__source`.
9+
export { default as __source } from './popover.component.stories?raw';
10+
11+
export const meta: StoryMeta = {
12+
group: 'Components',
13+
title: 'Popover',
14+
source: 'packages/ui/src/mosaic/components/popover/popover.tsx',
15+
};
16+
17+
const popoverTrigger = (props: React.HTMLAttributes<HTMLElement>) => <Button {...props}>Open popover</Button>;
18+
19+
export function Default() {
20+
return (
21+
<Popover trigger={popoverTrigger}>
22+
<Popover.Content>
23+
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
24+
<strong>Ada Lovelace</strong>
25+
<span style={{ color: 'var(--cl-color-muted-foreground)' }}>ada@example.com</span>
26+
</div>
27+
</Popover.Content>
28+
<Popover.Footer>
29+
<Button
30+
intent='destructive'
31+
fullWidth
32+
>
33+
Sign out of all accounts
34+
</Button>
35+
</Popover.Footer>
36+
</Popover>
37+
);
38+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { Popover } from './popover';
2+
export type { PopoverProps } from './popover';
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import * as stylex from '@stylexjs/stylex';
2+
3+
import { colorVars, radiusVars, space } from '../../tokens.stylex';
4+
5+
export const styles = stylex.create({
6+
// Floating wrapper. Positioning styles are applied inline by the headless
7+
// positioner; this only owns stacking and clears the focus outline the
8+
// FloatingFocusManager places here.
9+
positioner: {
10+
outline: 'none',
11+
zIndex: 50,
12+
},
13+
14+
// The popup card: the flexible container that holds content + footer.
15+
popup: {
16+
borderColor: colorVars['--cl-color-border'],
17+
borderRadius: radiusVars['--cl-radius-container'],
18+
borderStyle: 'solid',
19+
borderWidth: '1px',
20+
outline: 'none',
21+
overflow: 'hidden',
22+
backgroundColor: colorVars['--cl-color-card'],
23+
boxShadow: '0 10px 30px rgba(0, 0, 0, 0.12)',
24+
color: colorVars['--cl-color-card-foreground'],
25+
display: 'flex',
26+
flexDirection: 'column',
27+
opacity: {
28+
default: 1,
29+
':where([data-starting-style], [data-ending-style])': 0,
30+
},
31+
transform: {
32+
default: 'scale(1)',
33+
':where([data-starting-style], [data-ending-style])': 'scale(0.98)',
34+
},
35+
transitionDuration: '150ms',
36+
// Enter/exit transition. The headless popup sets `data-starting-style` on the
37+
// entering frame and `data-ending-style` while exiting — both are the element's
38+
// OWN attributes. A bare `[data-*]` key is rejected by StyleX (conditional keys
39+
// must start with `:` or `@`), so wrap it in `:where(...)`, a valid pseudo-class
40+
// string that targets the same element. `stylex.when.*` covers ancestor/sibling
41+
// state; this covers self-state.
42+
transitionProperty: {
43+
default: 'opacity, transform',
44+
'@media (prefers-reduced-motion: reduce)': 'none',
45+
},
46+
transitionTimingFunction: 'ease-out',
47+
maxWidth: 'calc(100vw - 2rem)',
48+
minWidth: '18rem',
49+
},
50+
51+
// Flexible inner content region. Scrolls on overflow so tall content never
52+
// pushes the footer out of view.
53+
content: {
54+
padding: space['4'],
55+
display: 'flex',
56+
flexDirection: 'column',
57+
minHeight: 0,
58+
overflowY: 'auto',
59+
},
60+
61+
// Footer region, visually separated from content by a top border.
62+
footer: {
63+
padding: space['4'],
64+
borderTopColor: colorVars['--cl-color-border'],
65+
borderTopStyle: 'solid',
66+
borderTopWidth: '1px',
67+
},
68+
});
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { cleanup, render, screen } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import React from 'react';
4+
import { afterEach, describe, expect, it } from 'vitest';
5+
6+
import { Popover } from './popover';
7+
8+
afterEach(() => cleanup());
9+
10+
describe('Mosaic Popover', () => {
11+
it('renders the trigger and opens the popup on click', async () => {
12+
const user = userEvent.setup();
13+
render(
14+
<Popover
15+
trigger={props => (
16+
<button
17+
type='button'
18+
{...props}
19+
>
20+
Open
21+
</button>
22+
)}
23+
>
24+
<Popover.Content>Panel body</Popover.Content>
25+
</Popover>,
26+
);
27+
28+
expect(screen.queryByText('Panel body')).not.toBeInTheDocument();
29+
30+
await user.click(screen.getByRole('button', { name: 'Open' }));
31+
32+
expect(screen.getByText('Panel body')).toBeInTheDocument();
33+
});
34+
35+
it('carries the mosaic slot classes on popup, content, and footer', () => {
36+
render(
37+
<Popover
38+
defaultOpen
39+
trigger={props => (
40+
<button
41+
type='button'
42+
{...props}
43+
>
44+
Open
45+
</button>
46+
)}
47+
>
48+
<Popover.Content>Body</Popover.Content>
49+
<Popover.Footer>Footer</Popover.Footer>
50+
</Popover>,
51+
);
52+
53+
expect(screen.getByText('Body')).toHaveClass('cl-popover-content');
54+
expect(screen.getByText('Footer')).toHaveClass('cl-popover-footer');
55+
expect(document.querySelector('.cl-popover-popup')).toBeInTheDocument();
56+
expect(document.querySelector('.cl-popover-positioner')).toBeInTheDocument();
57+
});
58+
59+
it('merges consumer className and style onto a part', () => {
60+
render(
61+
<Popover
62+
defaultOpen
63+
trigger={props => (
64+
<button
65+
type='button'
66+
{...props}
67+
>
68+
Open
69+
</button>
70+
)}
71+
>
72+
<Popover.Content
73+
className='my-content'
74+
style={{ marginTop: '8px' }}
75+
>
76+
Body
77+
</Popover.Content>
78+
</Popover>,
79+
);
80+
81+
const content = screen.getByText('Body');
82+
expect(content).toHaveClass('cl-popover-content', 'my-content');
83+
expect(content).toHaveStyle({ marginTop: '8px' });
84+
});
85+
86+
it('closes via Popover.Close', async () => {
87+
const user = userEvent.setup();
88+
render(
89+
<Popover
90+
defaultOpen
91+
trigger={props => (
92+
<button
93+
type='button'
94+
{...props}
95+
>
96+
Open
97+
</button>
98+
)}
99+
>
100+
<Popover.Content>Body</Popover.Content>
101+
<Popover.Close>Dismiss</Popover.Close>
102+
</Popover>,
103+
);
104+
105+
expect(screen.getByText('Body')).toBeInTheDocument();
106+
await user.click(screen.getByRole('button', { name: 'Dismiss' }));
107+
expect(screen.queryByText('Body')).not.toBeInTheDocument();
108+
});
109+
110+
it('forwards the ref to the content element', () => {
111+
const ref = React.createRef<HTMLDivElement>();
112+
render(
113+
<Popover
114+
defaultOpen
115+
trigger={props => (
116+
<button
117+
type='button'
118+
{...props}
119+
>
120+
Open
121+
</button>
122+
)}
123+
>
124+
<Popover.Content ref={ref}>Body</Popover.Content>
125+
</Popover>,
126+
);
127+
128+
expect(ref.current).toBe(screen.getByText('Body'));
129+
});
130+
});

0 commit comments

Comments
 (0)