Skip to content
Open
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-popover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
48 changes: 47 additions & 1 deletion .claude/skills/mosaic/references/stylex.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,50 @@ engine's `hover()`/`motionSafe()` utils don't translate). `*.styles.ts` files ar
therefore exempted from the repo's `no-restricted-syntax` media-query rule
(`eslint.config.mjs`); the `@stylexjs/*` rules still apply.

**Every condition is a value key, never a top-level object.** A pseudo/at-rule
goes _inside_ the property it modifies (`transitionProperty: { default: …, '@media …': … }`),
not as a bare key on the style object. A top-level `'@media …': { … }` block is
legacy syntax and the `@stylexjs/no-legacy-contextual-styles` +
`@stylexjs/valid-styles` rules reject it (only `::before`/`::after` may sit at the
top level). Reduced-motion is the common case:

```ts
transitionProperty: { default: 'opacity, transform', '@media (prefers-reduced-motion: reduce)': 'none' },
```

### Reacting to `data-*` state (the headless-transition case)

Headless primitives drive animation off `data-*` attributes — e.g. the popover
popup carries its own `data-starting-style` (entering frame) and
`data-ending-style` (exiting). You can style off these in StyleX; it depends on
_whose_ attribute you're reading:

- **The element's own attribute → wrap in `:where(...)`.** Conditional keys must
start with `:` or `@`, so a bare `[data-*]` is rejected — but `:where([data-*])`
is a valid pseudo-class string that matches the same element (zero specificity;
StyleX self-doubles the atom class so the conditional still wins):

```ts
popup: {
opacity: { default: 1, ':where([data-starting-style], [data-ending-style])': 0 },
transform: { default: 'scale(1)', ':where([data-starting-style], [data-ending-style])': 'scale(0.98)' },
transitionProperty: { default: 'opacity, transform', '@media (prefers-reduced-motion: reduce)': 'none' },
transitionDuration: '150ms',
},
```

- **Another element's attribute → `stylex.when.*`.** For relational state use
`stylex.when.ancestor(sel)` / `.descendant(sel)` / `.siblingBefore(sel)` /
`.siblingAfter(sel)` / `.anySibling(sel)`, each taking a `:${string}` or
`[${string}]` selector and returning a valid conditional key
(`:where-ancestor(...)` etc.). Use this when a parent/sibling owns the state
(e.g. a `[data-open]` container theming its children); use `:where([data-*])`
when the element owns it.

So: `:where(...)` for self-state, `stylex.when.*` for relational state. Both
compile to real attribute selectors in `styles.css`, so animation stays
CSS-native — no JS state plumbing through the component.

## Public contract (`props.ts`)

The element carries three things, and nothing else is a contract:
Expand Down Expand Up @@ -128,7 +172,9 @@ token colors aren't down-leveled into an invalid polyfill.
## CSS features we lean on / caveats

- YES: `var()`, `calc()`, `color-mix()`, `light-dark()`, nested `@media`+pseudo,
`:hover`/`:active`/`:focus-visible`/`:disabled`, `::before`/`::after`.
`:hover`/`:active`/`:focus-visible`/`:disabled`, `::before`/`::after`,
`data-*` state via `:where([data-*])` (self) or `stylex.when.*` (relational) —
see "Reacting to `data-*` state" above.
- Prefer CSS-native solutions over JS workarounds for anything StyleX supports.
- Avoid manual `@layer` / `@property` inside `create` (StyleX owns layering;
`@property` compiles but emits invalid output).
Expand Down
1 change: 1 addition & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
dialog: dynamic(() => import('../stories/dialog.component.mdx')),
heading: dynamic(() => import('../stories/heading.mdx')),
icon: dynamic(() => import('../stories/icon.mdx')),
popover: dynamic(() => import('../stories/popover.component.mdx')),
tabs: dynamic(() => import('../stories/tabs.component.mdx')),
text: dynamic(() => import('../stories/text.mdx')),
},
Expand Down
4 changes: 4 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import {
meta as organizationProfileProfileSectionMeta,
} from '../stories/organization-profile-profile-section.stories';
import { meta as otpMeta } from '../stories/otp.stories';
import { Default as PopoverComponentDefault, meta as popoverComponentMeta } from '../stories/popover.component.stories';
import { meta as popoverMeta } from '../stories/popover.stories';
import { meta as selectMeta } from '../stories/select.stories';
import { Default as TabsComponentDefault, meta as tabsComponentMeta } from '../stories/tabs.component.stories';
Expand Down Expand Up @@ -121,6 +122,8 @@ const inputModule: StoryModule = { meta: inputMeta, Default, Sizes: InputSizes,

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

const popoverComponentModule: StoryModule = { meta: popoverComponentMeta, Default: PopoverComponentDefault };

const headingModule: StoryModule = {
meta: headingMeta,
Default: HeadingDefault,
Expand Down Expand Up @@ -177,6 +180,7 @@ export const registry: StoryModule[] = [
dialogComponentModule,
headingModule,
iconModule,
popoverComponentModule,
tabsComponentModule,
textModule,
// Primitives — alphabetical within the group.
Expand Down
115 changes: 115 additions & 0 deletions packages/swingset/src/stories/popover.component.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import * as PopoverStories from './popover.component.stories';

# Popover

The Mosaic `Popover` — the styled Mosaic component composed from the `@clerk/headless` popover
primitive and themed with StyleX. It is a reusable floating card anchored to a trigger: drop any
inner content into `Popover.Content` and an optional `Popover.Footer`. It inherits the primitive's
positioning, focus management, and ARIA wiring.

## Example

<Story
name='Default'
storyModule={PopoverStories}
/>
Comment on lines +5 to +15

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add the composed-layer documentation metadata.

Document which layer owns open/placement state and which lower-layer pieces are wired together, then pass a composition array to this sole <Story>.

As per coding guidelines, “For Archetype C (composed layer) MDX files, lead with a single paragraph on what state is owned and which lower-layer pieces are wired together, then render a single <Story> with a composition array naming each direct dependency.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/swingset/src/stories/popover.component.mdx` around lines 5 - 15,
Update the introductory paragraph in the Popover MDX to state that the composed
Mosaic layer owns open/placement state and wires together the `@clerk/headless`
popover primitive with StyleX theming. Add a composition array to the sole Story
component naming each direct dependency.

Source: Coding guidelines


## Usage

```tsx
import { Button } from '@clerk/ui/mosaic/components/button';
import { Popover } from '@clerk/ui/mosaic/components/popover';

<Popover trigger={props => <Button {...props}>Open popover</Button>}>
<Popover.Content>Flexible inner content.</Popover.Content>
<Popover.Footer>
<Button
intent='destructive'
fullWidth
>
Sign out of all accounts
</Button>
</Popover.Footer>
</Popover>;
```

The `trigger` render prop receives the interaction props (ARIA attributes, click handler) from the
headless layer and should spread them onto whatever element opens the popover.

### Controlled

```tsx
const [open, setOpen] = useState(false);
Comment on lines +41 to +42

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="packages/swingset/src/stories/popover.component.mdx"

echo "== File outline =="
wc -l "$file"
echo
nl -ba "$file" | sed -n '1,220p'

echo
echo "== Search for composition usage in similar stories =="
rg -n "composition\s*:" packages/swingset/src/stories -g '*.mdx' || true

echo
echo "== Search for useState import in this file =="
rg -n "useState|from 'react'|from \"react\"" "$file" || true

Repository: clerk/javascript

Length of output: 270


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="packages/swingset/src/stories/popover.component.mdx"

echo "== File contents =="
cat -n "$file"

echo
echo "== MDX stories with composition arrays =="
rg -n "composition\s*:" packages/swingset/src/stories -g '*.mdx' || true

echo
echo "== Popover references elsewhere =="
rg -n "popover" packages/swingset/src/stories -g '*.mdx' -g '*.ts' -g '*.tsx' || true

Repository: clerk/javascript

Length of output: 9929


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="packages/swingset/src/stories/popover.component.mdx"

echo "== File contents =="
cat -n "$file"

Repository: clerk/javascript

Length of output: 5749


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,140p' packages/swingset/src/stories/popover.component.mdx

Repository: clerk/javascript

Length of output: 4924


Import useState and add the composition metadata

  • The controlled example uses useState without importing it, so the snippet won’t compile as written.
  • This composed-layer story also needs a composition array on <Story> to document the direct dependencies.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/swingset/src/stories/popover.component.mdx` around lines 41 - 42,
Update the controlled popover example in the story to import useState from
React, and add a composition array to its Story declaration listing the direct
dependencies required by the composed layer.


<Popover
open={open}
onOpenChange={setOpen}
trigger={props => <Button {...props}>Open</Button>}
>
<Popover.Content>Flexible inner content.</Popover.Content>
</Popover>;
```

### Placement

`placement` sets the preferred side and alignment; `sideOffset` sets the gap from the trigger. The
popup flips and shifts automatically to stay in view.

```tsx
<Popover
placement='bottom-end'
sideOffset={8}
trigger={props => <Button {...props}>Open</Button>}
>
<Popover.Content>Aligned to the trigger's end edge.</Popover.Content>
</Popover>;
```

## Parts

The convenience `Popover` composes the trigger and a portalled, positioned popup. For custom
layouts, compose the compound parts directly.

| Part | Slot | Description |
| --------------------- | -------------------- | -------------------------------------------------------------- |
| `Popover.Root` | — | State provider; owns open/close and placement. |
| `Popover.Trigger` | — | Anchor element; accepts a `render` prop. |
| `Popover.Portal` | — | Portals the popup out to the document body. |
| `Popover.Positioner` | `popover-positioner` | Floating wrapper; owns positioning, stacking, `data-side`. |
| `Popover.Popup` | `popover-popup` | The card surface; holds content + footer, runs the enter/exit. |
| `Popover.Content` | `popover-content` | Flexible inner content region; scrolls on overflow. |
| `Popover.Footer` | `popover-footer` | Footer region, separated from content by a top border. |
| `Popover.Close` | — | Dismisses the popover; accepts a `render` prop. |
| `Popover.Title` | — | Heading; wired to the popup's `aria-labelledby`. |
| `Popover.Description` | — | Description; wired to the popup's `aria-describedby`. |
| `Popover.Arrow` | — | Optional arrow pointing at the trigger. |

## Styling

Unlike the slot-recipe components, the Mosaic popover is themed with **StyleX**. Each styled part
carries a stable `.cl-<slot>` class (the slots in the table above) alongside the StyleX atoms.
Consumers never target the hashed atomic classes — override by targeting the `.cl-*` slot from a
CSS layer that wins over `@clerk/ui/styles.css`:

```css
@import '@clerk/ui/styles.css' layer(components);

@layer overrides {
.cl-popover-popup {
border-radius: 20px;
}
}
```

State attributes from the headless layer are available for CSS targeting:

| Attribute | Applies To | Description |
| --------------------- | -------------- | --------------------------------------------------- |
| `data-open` | Trigger, Popup | Present when the popover is open |
| `data-closed` | Trigger, Popup | Present when closed (during exit) |
| `data-starting-style` | Popup | Present on the entering frame |
| `data-ending-style` | Popup | Present during the exit animation |
| `data-side` | Positioner | Resolved side (`top` / `bottom` / `left` / `right`) |

The popup's default enter/exit transition (opacity + scale) is driven off `data-starting-style` /
`data-ending-style` and is disabled under `prefers-reduced-motion: reduce`.
38 changes: 38 additions & 0 deletions packages/swingset/src/stories/popover.component.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/** @jsxImportSource @emotion/react */
import { Button } from '@clerk/ui/mosaic/components/button';
import { Popover } from '@clerk/ui/mosaic/components/popover';

import type { StoryMeta } from '@/lib/types';

// Exposes this file's own source (via the `?raw` webpack rule) so each `<Story>` example
// renders a code footer with its function's source. See `StoryModule.__source`.
export { default as __source } from './popover.component.stories?raw';

export const meta: StoryMeta = {
group: 'Components',
title: 'Popover',
source: 'packages/ui/src/mosaic/components/popover/popover.tsx',
};

const popoverTrigger = (props: React.HTMLAttributes<HTMLElement>) => <Button {...props}>Open popover</Button>;

export function Default() {
return (
<Popover trigger={popoverTrigger}>
<Popover.Content>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<strong>Ada Lovelace</strong>
<span style={{ color: 'var(--cl-color-muted-foreground)' }}>ada@example.com</span>
</div>
</Popover.Content>
<Popover.Footer>
<Button
intent='destructive'
fullWidth
>
Sign out of all accounts
</Button>
</Popover.Footer>
</Popover>
);
}
2 changes: 2 additions & 0 deletions packages/ui/src/mosaic/components/popover/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { Popover } from './popover';
export type { PopoverProps } from './popover';
68 changes: 68 additions & 0 deletions packages/ui/src/mosaic/components/popover/popover.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import * as stylex from '@stylexjs/stylex';

import { colorVars, radiusVars, space } from '../../tokens.stylex';

export const styles = stylex.create({
// Floating wrapper. Positioning styles are applied inline by the headless
// positioner; this only owns stacking and clears the focus outline the
// FloatingFocusManager places here.
positioner: {
outline: 'none',
zIndex: 50,
},

// The popup card: the flexible container that holds content + footer.
popup: {
borderColor: colorVars['--cl-color-border'],
borderRadius: radiusVars['--cl-radius-container'],
borderStyle: 'solid',
borderWidth: '1px',
outline: 'none',
overflow: 'hidden',
backgroundColor: colorVars['--cl-color-card'],
boxShadow: '0 10px 30px rgba(0, 0, 0, 0.12)',
color: colorVars['--cl-color-card-foreground'],
display: 'flex',
flexDirection: 'column',
opacity: {
default: 1,
':where([data-starting-style], [data-ending-style])': 0,
},
transform: {
default: 'scale(1)',
':where([data-starting-style], [data-ending-style])': 'scale(0.98)',
},
transitionDuration: '150ms',
// Enter/exit transition. The headless popup sets `data-starting-style` on the
// entering frame and `data-ending-style` while exiting — both are the element's
// OWN attributes. A bare `[data-*]` key is rejected by StyleX (conditional keys
// must start with `:` or `@`), so wrap it in `:where(...)`, a valid pseudo-class
// string that targets the same element. `stylex.when.*` covers ancestor/sibling
// state; this covers self-state.
transitionProperty: {
default: 'opacity, transform',
'@media (prefers-reduced-motion: reduce)': 'none',
},
transitionTimingFunction: 'ease-out',
maxWidth: 'calc(100vw - 2rem)',
minWidth: '18rem',
},

// Flexible inner content region. Scrolls on overflow so tall content never
// pushes the footer out of view.
content: {
padding: space['4'],
display: 'flex',
flexDirection: 'column',
minHeight: 0,
overflowY: 'auto',
Comment on lines +47 to +58

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the target file and surrounding popover styles.
FILE="packages/ui/src/mosaic/components/popover/popover.styles.ts"
echo "== file outline =="
ast-grep outline "$FILE" --view expanded || true

echo
echo "== relevant lines =="
nl -ba "$FILE" | sed -n '1,220p'

echo
echo "== search for related popover sizing/positioning styles =="
rg -n "maxHeight|maxWidth|minWidth|overflowY|popover|content:" packages/ui/src/mosaic/components/popover -S || true

Repository: clerk/javascript

Length of output: 337


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="packages/ui/src/mosaic/components/popover/popover.styles.ts"

echo "== file with line numbers =="
awk '{printf "%4d: %s\n", NR, $0}' "$FILE" | sed -n '1,220p'

echo
echo "== surrounding popover files =="
fd -t f . packages/ui/src/mosaic/components/popover

echo
echo "== related references =="
rg -n "maxHeight|maxWidth|minWidth|overflowY|overflow|popover" packages/ui/src/mosaic/components/popover -S || true

Repository: clerk/javascript

Length of output: 6065


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="packages/ui/src/mosaic/components/popover/popover.tsx"

echo "== file with line numbers =="
awk '{printf "%4d: %s\n", NR, $0}' "$FILE" | sed -n '1,220p'

echo
echo "== popover tests =="
awk '{printf "%4d: %s\n", NR, $0}' "packages/ui/src/mosaic/components/popover/popover.test.tsx" | sed -n '1,220p'

Repository: clerk/javascript

Length of output: 8904


Constrain the popup to the viewport.
overflowY: 'auto' on the content won’t help unless the popup itself has a height cap, so tall content can still push the footer off-screen. On narrow screens, minWidth: '18rem' also overrides maxWidth: 'calc(100vw - 2rem)' once the viewport drops below 20rem.

Proposed fix
     transitionTimingFunction: 'ease-out',
+    maxHeight: 'calc(100dvh - 2rem)',
     maxWidth: 'calc(100vw - 2rem)',
-    minWidth: '18rem',
+    minWidth: 'min(18rem, calc(100vw - 2rem))',
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
maxWidth: 'calc(100vw - 2rem)',
minWidth: '18rem',
},
// Flexible inner content region. Scrolls on overflow so tall content never
// pushes the footer out of view.
content: {
padding: space['4'],
display: 'flex',
flexDirection: 'column',
minHeight: 0,
overflowY: 'auto',
transitionTimingFunction: 'ease-out',
maxHeight: 'calc(100dvh - 2rem)',
maxWidth: 'calc(100vw - 2rem)',
minWidth: 'min(18rem, calc(100vw - 2rem))',
},
// Flexible inner content region. Scrolls on overflow so tall content never
// pushes the footer out of view.
content: {
padding: space['4'],
display: 'flex',
flexDirection: 'column',
minHeight: 0,
overflowY: 'auto',
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/mosaic/components/popover/popover.styles.ts` around lines 47
- 58, Update the popup style container around the visible maxWidth/minWidth
declarations to add a viewport-relative maxHeight, ensuring the overall popup
remains within the viewport so the content overflow can scroll without
displacing the footer. Adjust the minWidth constraint to remain compatible with
maxWidth on narrow viewports, using a responsive width constraint rather than
forcing 18rem below the available viewport width.

},

// Footer region, visually separated from content by a top border.
footer: {
padding: space['4'],
borderTopColor: colorVars['--cl-color-border'],
borderTopStyle: 'solid',
borderTopWidth: '1px',
},
});
Loading
Loading