From 23353f2a04b4ee30debc7a9036defcf63e79f736 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 24 Jul 2026 09:55:43 -0400 Subject: [PATCH 1/2] 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. --- .changeset/mosaic-popover.md | 2 + .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 4 + .../src/stories/popover.component.mdx | 115 ++++++++++++++++ .../src/stories/popover.component.stories.tsx | 38 +++++ .../ui/src/mosaic/components/popover/index.ts | 2 + .../components/popover/popover.styles.ts | 68 +++++++++ .../components/popover/popover.test.tsx | 130 ++++++++++++++++++ .../src/mosaic/components/popover/popover.tsx | 125 +++++++++++++++++ packages/ui/src/mosaic/styles/index.ts | 3 + packages/ui/tsdown.mosaic.config.mts | 5 +- 11 files changed, 492 insertions(+), 1 deletion(-) create mode 100644 .changeset/mosaic-popover.md create mode 100644 packages/swingset/src/stories/popover.component.mdx create mode 100644 packages/swingset/src/stories/popover.component.stories.tsx create mode 100644 packages/ui/src/mosaic/components/popover/index.ts create mode 100644 packages/ui/src/mosaic/components/popover/popover.styles.ts create mode 100644 packages/ui/src/mosaic/components/popover/popover.test.tsx create mode 100644 packages/ui/src/mosaic/components/popover/popover.tsx diff --git a/.changeset/mosaic-popover.md b/.changeset/mosaic-popover.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-popover.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 35bdb3e9da1..0b29347e087 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -34,6 +34,7 @@ const docModules: Record> = { 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')), }, diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index dee445b26f0..f9ad2a691ef 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -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'; @@ -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, @@ -177,6 +180,7 @@ export const registry: StoryModule[] = [ dialogComponentModule, headingModule, iconModule, + popoverComponentModule, tabsComponentModule, textModule, // Primitives — alphabetical within the group. diff --git a/packages/swingset/src/stories/popover.component.mdx b/packages/swingset/src/stories/popover.component.mdx new file mode 100644 index 00000000000..8d3936bb3f9 --- /dev/null +++ b/packages/swingset/src/stories/popover.component.mdx @@ -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 + + + +## Usage + +```tsx +import { Button } from '@clerk/ui/mosaic/components/button'; +import { Popover } from '@clerk/ui/mosaic/components/popover'; + + }> + Flexible inner content. + + + +; +``` + +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); + + } +> + Flexible inner content. +; +``` + +### 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 + } +> + Aligned to the trigger's end edge. +; +``` + +## 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-` 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`. diff --git a/packages/swingset/src/stories/popover.component.stories.tsx b/packages/swingset/src/stories/popover.component.stories.tsx new file mode 100644 index 00000000000..546e15279af --- /dev/null +++ b/packages/swingset/src/stories/popover.component.stories.tsx @@ -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 `` 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) => ; + +export function Default() { + return ( + + +
+ Ada Lovelace + ada@example.com +
+
+ + + +
+ ); +} diff --git a/packages/ui/src/mosaic/components/popover/index.ts b/packages/ui/src/mosaic/components/popover/index.ts new file mode 100644 index 00000000000..1793dd3e32d --- /dev/null +++ b/packages/ui/src/mosaic/components/popover/index.ts @@ -0,0 +1,2 @@ +export { Popover } from './popover'; +export type { PopoverProps } from './popover'; diff --git a/packages/ui/src/mosaic/components/popover/popover.styles.ts b/packages/ui/src/mosaic/components/popover/popover.styles.ts new file mode 100644 index 00000000000..6c027113f73 --- /dev/null +++ b/packages/ui/src/mosaic/components/popover/popover.styles.ts @@ -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', + }, + + // Footer region, visually separated from content by a top border. + footer: { + padding: space['4'], + borderTopColor: colorVars['--cl-color-border'], + borderTopStyle: 'solid', + borderTopWidth: '1px', + }, +}); diff --git a/packages/ui/src/mosaic/components/popover/popover.test.tsx b/packages/ui/src/mosaic/components/popover/popover.test.tsx new file mode 100644 index 00000000000..86e6099e8a1 --- /dev/null +++ b/packages/ui/src/mosaic/components/popover/popover.test.tsx @@ -0,0 +1,130 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { Popover } from './popover'; + +afterEach(() => cleanup()); + +describe('Mosaic Popover', () => { + it('renders the trigger and opens the popup on click', async () => { + const user = userEvent.setup(); + render( + ( + + )} + > + Panel body + , + ); + + expect(screen.queryByText('Panel body')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Open' })); + + expect(screen.getByText('Panel body')).toBeInTheDocument(); + }); + + it('carries the mosaic slot classes on popup, content, and footer', () => { + render( + ( + + )} + > + Body + Footer + , + ); + + expect(screen.getByText('Body')).toHaveClass('cl-popover-content'); + expect(screen.getByText('Footer')).toHaveClass('cl-popover-footer'); + expect(document.querySelector('.cl-popover-popup')).toBeInTheDocument(); + expect(document.querySelector('.cl-popover-positioner')).toBeInTheDocument(); + }); + + it('merges consumer className and style onto a part', () => { + render( + ( + + )} + > + + Body + + , + ); + + const content = screen.getByText('Body'); + expect(content).toHaveClass('cl-popover-content', 'my-content'); + expect(content).toHaveStyle({ marginTop: '8px' }); + }); + + it('closes via Popover.Close', async () => { + const user = userEvent.setup(); + render( + ( + + )} + > + Body + Dismiss + , + ); + + expect(screen.getByText('Body')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Dismiss' })); + expect(screen.queryByText('Body')).not.toBeInTheDocument(); + }); + + it('forwards the ref to the content element', () => { + const ref = React.createRef(); + render( + ( + + )} + > + Body + , + ); + + expect(ref.current).toBe(screen.getByText('Body')); + }); +}); diff --git a/packages/ui/src/mosaic/components/popover/popover.tsx b/packages/ui/src/mosaic/components/popover/popover.tsx new file mode 100644 index 00000000000..5d24309e14c --- /dev/null +++ b/packages/ui/src/mosaic/components/popover/popover.tsx @@ -0,0 +1,125 @@ +import type { PopoverProps as HeadlessPopoverProps } from '@clerk/headless/popover'; +import { Popover as Primitive } from '@clerk/headless/popover'; +import * as stylex from '@stylexjs/stylex'; +import type { ReactNode } from 'react'; +import React from 'react'; + +import { mergeStyleProps, themeProps } from '../../props'; +import { styles } from './popover.styles'; + +/** + * Mosaic Popover: a reusable floating panel anchored to a trigger, styled on top + * of the `@clerk/headless` popover primitive. The popup is a flexible card — + * consumers drop in any inner content plus an optional `Popover.Footer`. + * + * Each styled part bridges the matching headless part and spreads + * `themeProps` + `stylex.props` through `mergeStyleProps`, so it carries the + * public `.cl-` class and StyleX atoms while the headless part keeps its + * floating behavior, refs, and ARIA wiring. + */ + +const Positioner = React.forwardRef>( + function PopoverPositioner({ className, style, ...rest }, ref) { + return ( + + ); + }, +); + +const Popup = React.forwardRef>( + function PopoverPopup({ className, style, ...rest }, ref) { + return ( + + ); + }, +); + +const Content = React.forwardRef>(function PopoverContent( + { className, style, ...rest }, + ref, +) { + return ( +
+ ); +}); + +const Footer = React.forwardRef>(function PopoverFooter( + { className, style, ...rest }, + ref, +) { + return ( +
+ ); +}); + +export interface PopoverProps extends Pick< + HeadlessPopoverProps, + 'open' | 'defaultOpen' | 'onOpenChange' | 'modal' | 'placement' | 'sideOffset' +> { + /** Rendered as the popover's anchor. Receives the trigger's props and open state. */ + trigger: React.ComponentProps['render']; + /** Popup contents. Compose `Popover.Content` and `Popover.Footer`, or anything else. */ + children: ReactNode; +} + +/** + * Convenience composition: trigger + portalled, positioned popup. For custom + * layouts, use the compound parts (`Popover.Root`, `Popover.Positioner`, …). + */ +export function Popover({ + trigger, + children, + open, + defaultOpen, + onOpenChange, + modal, + placement, + sideOffset, +}: PopoverProps) { + return ( + + + + + {children} + + + + ); +} + +/** Compound parts for custom popover layouts. */ +Popover.Root = Primitive.Root; +Popover.Trigger = Primitive.Trigger; +Popover.Portal = Primitive.Portal; +Popover.Positioner = Positioner; +Popover.Popup = Popup; +Popover.Content = Content; +Popover.Footer = Footer; +Popover.Arrow = Primitive.Arrow; +Popover.Title = Primitive.Title; +Popover.Description = Primitive.Description; +Popover.Close = Primitive.Close; diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index f1716fae927..67892a98317 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -7,6 +7,9 @@ export { Button } from '../components/button'; export type { ButtonProps } from '../components/button'; +export { Popover } from '../components/popover'; +export type { PopoverProps } from '../components/popover'; + import { colorVars, radiusVars, space, spacingVars, typeScaleVars } from '../tokens.stylex'; export { colorVars, radiusVars, space, spacingVars, typeScaleVars }; diff --git a/packages/ui/tsdown.mosaic.config.mts b/packages/ui/tsdown.mosaic.config.mts index 6241344067b..55280a2bee7 100644 --- a/packages/ui/tsdown.mosaic.config.mts +++ b/packages/ui/tsdown.mosaic.config.mts @@ -37,7 +37,10 @@ export default defineConfig({ minify: false, // Use the standard React JSX runtime, not Emotion's — the Mosaic build must be Emotion-free. tsconfig: './tsconfig.mosaic.json', - external: ['react', 'react-dom', '@stylexjs/stylex'], + // `@clerk/headless` is a separate package (bridged by the Popover). Keep it + // external so its declaration graph isn't pulled into this isolated build, + // whose only shipped artifact is `styles.css`. + external: ['react', 'react-dom', '@stylexjs/stylex', /^@clerk\/headless/], plugins: [ stylexPlugin({ fileName: 'styles.css', From 27e9be79b6d219eef989628deb2306541fd5e229 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 24 Jul 2026 09:57:57 -0400 Subject: [PATCH 2/2] docs(repo): document StyleX data-attribute state styling in mosaic skill --- .claude/skills/mosaic/references/stylex.md | 48 +++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/.claude/skills/mosaic/references/stylex.md b/.claude/skills/mosaic/references/stylex.md index d4f817aa277..8a751a6bf76 100644 --- a/.claude/skills/mosaic/references/stylex.md +++ b/.claude/skills/mosaic/references/stylex.md @@ -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: @@ -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).