From 636dd7ea051497d1e38a7e7858d612a2daacf5ce Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 24 Jul 2026 09:55:43 -0400 Subject: [PATCH 01/12] 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 + 10 files changed, 488 insertions(+) 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 82d9e39eb83..d7b7a89ef06 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -36,6 +36,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 bfa22e2ee2b..b129e26aa85 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -80,6 +80,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'; @@ -149,6 +150,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, @@ -207,6 +210,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 64242a1374d..4508d3d3f23 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -17,6 +17,9 @@ export type { IconProps } from '../components/icon'; export { Text, TextContext } from '../components/text'; export type { TextProps } from '../components/text'; +export { Popover } from '../components/popover'; +export type { PopoverProps } from '../components/popover'; + import { colorVars, fontWeightVars, radiusVars, space, spacingVars, typeScaleVars } from '../tokens.stylex'; export { colorVars, fontWeightVars, radiusVars, space, spacingVars, typeScaleVars }; From fccd08add39ba65bba5da6be4c45ad6b7f4bf4e6 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 24 Jul 2026 09:57:57 -0400 Subject: [PATCH 02/12] docs(repo): document StyleX data-attribute state styling in mosaic skill --- .claude/skills/mosaic/references/stylex.md | 47 +++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/.claude/skills/mosaic/references/stylex.md b/.claude/skills/mosaic/references/stylex.md index 0f68b16fec5..d5a05ae2633 100644 --- a/.claude/skills/mosaic/references/stylex.md +++ b/.claude/skills/mosaic/references/stylex.md @@ -310,6 +310,50 @@ value, sub-pattern A collapses it to a single `--var` atom; reach for a raw inli > values, and even then the first move is usually to write a single `--cl`/`--_cl` > var rather than a raw inline style. +**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 & composition (`props.ts`) The element carries three things, and nothing else is a contract: @@ -367,7 +411,8 @@ token colors aren't down-leveled into an invalid polyfill. `::before`/`::after`/`::backdrop`, `@starting-style` (enter animations), `stylex.keyframes(...)`, `anchor-size(width|height)` (popover/menu matching its trigger), CSS counters, `@media (hover: hover)` / `(prefers-reduced-motion)` / - `(pointer: coarse)`. + `(pointer: coarse)`, `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). From e0dfc5f5b42afa374bea3aab5e9976088edcca12 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 30 Jul 2026 15:52:41 -0600 Subject: [PATCH 03/12] refactor(ui): make the Mosaic popover a chrome-free floating box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The popover now owns only what it means to float: trigger wiring, ARIA, focus management, positioning, stacking, viewport clamps, and the enter/exit transition. It paints no surface of its own, so the content inside it — usually a Card — supplies the background, border, radius, shadow and padding, and only one element ever draws a border. Popover.Content and Popover.Footer are removed; they duplicated Card.Content and Card.Footer. Popover.Arrow is removed as unused. The popup gains a `size` variant (sm/md/lg), reflected as `data-size`. `md` reproduces the width the legacy PopoverCard uses (theme.sizes.$94) so popovers migrating onto Mosaic keep their footprint. It also wraps long unbroken strings, which would otherwise push past that width. The positioner is always role="dialog" but only gains aria-labelledby once a Popover.Title mounts, so a popover with neither a Title nor a label was an unnamed dialog. PopoverProps now accepts aria-label and aria-labelledby, and a dev-only check warns when the rendered dialog has neither. Both are spread conditionally: an explicit `undefined` would delete the Title's aria-labelledby through the primitive's mergeProps. Motion moves onto the theme tokens — `--cl-duration-base`, with `linear` for the fade and `--cl-ease-default` for the scale. Under prefers-reduced-motion only `transform` leaves the transition; the fade still runs, since the movement is the concern. --- .../ui/src/mosaic/components/popover/index.ts | 2 +- .../components/popover/popover.styles.ts | 58 +++-- .../components/popover/popover.test.tsx | 222 +++++++++++++----- .../src/mosaic/components/popover/popover.tsx | 133 ++++++++--- packages/ui/src/mosaic/styles/index.ts | 2 +- 5 files changed, 281 insertions(+), 136 deletions(-) diff --git a/packages/ui/src/mosaic/components/popover/index.ts b/packages/ui/src/mosaic/components/popover/index.ts index 1793dd3e32d..c5c65598310 100644 --- a/packages/ui/src/mosaic/components/popover/index.ts +++ b/packages/ui/src/mosaic/components/popover/index.ts @@ -1,2 +1,2 @@ export { Popover } from './popover'; -export type { PopoverProps } from './popover'; +export type { PopoverPopupProps, PopoverProps, PopoverSize } from './popover'; diff --git a/packages/ui/src/mosaic/components/popover/popover.styles.ts b/packages/ui/src/mosaic/components/popover/popover.styles.ts index 6c027113f73..7cbe7b69537 100644 --- a/packages/ui/src/mosaic/components/popover/popover.styles.ts +++ b/packages/ui/src/mosaic/components/popover/popover.styles.ts @@ -1,6 +1,6 @@ import * as stylex from '@stylexjs/stylex'; -import { colorVars, radiusVars, space } from '../../tokens.stylex'; +import { durationVars, easingVars } from '../../tokens.stylex'; export const styles = stylex.create({ // Floating wrapper. Positioning styles are applied inline by the headless @@ -11,58 +11,56 @@ export const styles = stylex.create({ zIndex: 50, }, - // The popup card: the flexible container that holds content + footer. + // The floating box, deliberately chrome-free: background, border, radius, + // shadow and padding belong to the surface rendered inside (e.g. `Card`), so + // the two never both paint a border. This owns only what it means to float — + // viewport clamps and the enter/exit transition. A single `width: 100%` child + // stretches to the size below via the column flex box. 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, }, + // A popover holds prose — an email, an org slug, an API key — and a long + // unbroken string would otherwise push past the width clamp. + overflowWrap: 'anywhere', transform: { default: 'scale(1)', ':where([data-starting-style], [data-ending-style])': 'scale(0.98)', }, - transitionDuration: '150ms', + transitionDuration: durationVars['--cl-duration-base'], // 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. + // + // Reduced motion drops `transform` from the list rather than killing the whole + // transition: the vestibular concern is the movement, so the gate belongs on the + // moving property and the fade survives. The duration is never gated. transitionProperty: { default: 'opacity, transform', - '@media (prefers-reduced-motion: reduce)': 'none', + '@media (prefers-reduced-motion: reduce)': 'opacity', }, - transitionTimingFunction: 'ease-out', + // Positional, in the order of `transitionProperty`. Opacity takes `linear` — + // its interpolation is already perceptually non-uniform, and the overshoot in + // `--cl-ease-default` would extrapolate past the target for no gain. The scale + // is what moves, so it gets the overshoot. + transitionTimingFunction: `linear, ${easingVars['--cl-ease-default']}`, + maxHeight: 'min(80dvh, 36rem)', 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', - }, +// `md` reproduces the width the legacy `PopoverCard` uses (`theme.sizes.$94`), so +// popovers migrating onto Mosaic keep their current footprint. +export const sizes = stylex.create({ + sm: { width: 'min(18rem, calc(100vw - 2rem))' }, + md: { width: 'min(23.5rem, calc(100vw - 2rem))' }, + lg: { width: 'min(26rem, calc(100vw - 2rem))' }, }); diff --git a/packages/ui/src/mosaic/components/popover/popover.test.tsx b/packages/ui/src/mosaic/components/popover/popover.test.tsx index 86e6099e8a1..4e88a3d575e 100644 --- a/packages/ui/src/mosaic/components/popover/popover.test.tsx +++ b/packages/ui/src/mosaic/components/popover/popover.test.tsx @@ -1,27 +1,33 @@ -import { cleanup, render, screen } from '@testing-library/react'; +import { act, 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 { afterEach, describe, expect, it, vi } from 'vitest'; import { Popover } from './popover'; afterEach(() => cleanup()); +// The accessible-name warning is deferred by a task so `Popover.Title` can report itself. +const settle = () => + act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + +const trigger = (props: React.HTMLAttributes) => ( + +); + describe('Mosaic Popover', () => { it('renders the trigger and opens the popup on click', async () => { const user = userEvent.setup(); render( - ( - - )} - > - Panel body + +
Panel body
, ); @@ -32,55 +38,67 @@ describe('Mosaic Popover', () => { expect(screen.getByText('Panel body')).toBeInTheDocument(); }); - it('carries the mosaic slot classes on popup, content, and footer', () => { + it('carries the mosaic slot classes on the positioner and popup', () => { render( ( - - )} + trigger={trigger} > - Body - Footer +
Body
, ); - 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(); + expect(document.querySelector('.cl-popover-popup')).toBeInTheDocument(); }); - it('merges consumer className and style onto a part', () => { + it('defaults the popup to the md size and reflects it as data-size', () => { + render( + +
Body
+
, + ); + + expect(document.querySelector('.cl-popover-popup')).toHaveAttribute('data-size', 'md'); + }); + + it('reflects an explicit size as data-size', () => { render( ( - - )} + size='lg' + trigger={trigger} > - - Body - +
Body
, ); - const content = screen.getByText('Body'); - expect(content).toHaveClass('cl-popover-content', 'my-content'); - expect(content).toHaveStyle({ marginTop: '8px' }); + expect(document.querySelector('.cl-popover-popup')).toHaveAttribute('data-size', 'lg'); + }); + + it('merges consumer className and style onto a part', () => { + render( + + + + + + Body + + + + , + ); + + const popup = screen.getByText('Body'); + expect(popup).toHaveClass('cl-popover-popup', 'my-popup'); + expect(popup).toHaveStyle({ marginTop: '8px' }); }); it('closes via Popover.Close', async () => { @@ -88,16 +106,9 @@ describe('Mosaic Popover', () => { render( ( - - )} + trigger={trigger} > - Body +
Body
Dismiss
, ); @@ -107,24 +118,105 @@ describe('Mosaic Popover', () => { expect(screen.queryByText('Body')).not.toBeInTheDocument(); }); - it('forwards the ref to the content element', () => { - const ref = React.createRef(); + it('closes on an outside click and does not reopen from the same gesture', async () => { + const user = userEvent.setup(); + render( +
+ +
Body
+
+
outside
+
, + ); + + await user.click(screen.getByRole('button', { name: 'Open' })); + expect(screen.getByText('Body')).toBeInTheDocument(); + + await user.click(screen.getByTestId('outside')); + expect(screen.queryByText('Body')).not.toBeInTheDocument(); + }); + + it('closes when the trigger is clicked while open', async () => { + const user = userEvent.setup(); + render( + +
Body
+
, + ); + + const button = screen.getByRole('button', { name: 'Open' }); + await user.click(button); + expect(screen.getByText('Body')).toBeInTheDocument(); + + // The dismiss that closes the popup must not let the same click reopen it. + await user.click(button); + expect(screen.queryByText('Body')).not.toBeInTheDocument(); + }); + + it('names the dialog from aria-label and does not warn', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render( + +
Body
+
, + ); + + expect(screen.getByRole('dialog', { name: 'Account' })).toBeInTheDocument(); + await settle(); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('warns when the dialog has no accessible name', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); render( ( - - )} + trigger={trigger} > - Body +
Body
, ); + await settle(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('no accessible name')); + warn.mockRestore(); + }); + + it('does not warn when a Popover.Title supplies the name', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render( + + Account + , + ); + + expect(screen.getByRole('dialog', { name: 'Account' })).toBeInTheDocument(); + await settle(); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('forwards the ref to the popup 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 index 5d24309e14c..2dc25727c39 100644 --- a/packages/ui/src/mosaic/components/popover/popover.tsx +++ b/packages/ui/src/mosaic/components/popover/popover.tsx @@ -5,12 +5,17 @@ import type { ReactNode } from 'react'; import React from 'react'; import { mergeStyleProps, themeProps } from '../../props'; -import { styles } from './popover.styles'; +import { sizes, 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`. + * Mosaic Popover: a floating box anchored to a trigger, built on the + * `@clerk/headless` popover primitive. + * + * The popover owns only what it means to float — trigger wiring, ARIA, focus + * management, positioning, stacking, viewport clamps, and the enter/exit + * transition. It paints no surface of its own: background, border, radius, + * shadow and padding come from whatever is rendered inside it (typically a + * `Card`), so the two never both draw a border. * * Each styled part bridges the matching headless part and spreads * `themeProps` + `stylex.props` through `mergeStyleProps`, so it carries the @@ -18,51 +23,84 @@ import { styles } from './popover.styles'; * floating behavior, refs, and ARIA wiring. */ +export type PopoverSize = 'sm' | 'md' | 'lg'; + +/** + * The headless positioner is always `role="dialog"`, but it only gains + * `aria-labelledby` once a `Popover.Title` mounts — so a popover with neither a + * Title nor an `aria-label` is an unnamed dialog. The check has to run after mount + * and read the DOM: `hasTitle` starts `false` in the primitive's root state, so a + * render-time check would warn on every popover that does use a Title. + */ +function useAccessibleNameWarning(node: HTMLElement | null) { + React.useEffect(() => { + if (process.env.NODE_ENV === 'production' || !node) { + return; + } + // Deferred by a task, not checked inline: `Popover.Title` reports itself through an + // effect, so on the commit that mounts the positioner the label is legitimately not + // there yet. Checking immediately would warn on every popover that does have a Title. + const timer = setTimeout(() => { + if (!node.isConnected || node.getAttribute('role') !== 'dialog') { + return; + } + if (node.hasAttribute('aria-label') || node.hasAttribute('aria-labelledby')) { + return; + } + console.warn( + '[clerk] renders a dialog with no accessible name. Pass `aria-label`, or render a `` inside it.', + ); + }, 0); + + return () => clearTimeout(timer); + }, [node]); +} + const Positioner = React.forwardRef>( function PopoverPositioner({ className, style, ...rest }, ref) { - return ( - + const [node, setNode] = React.useState(null); + useAccessibleNameWarning(node); + + const setRefs = React.useCallback( + (value: HTMLDivElement | null) => { + setNode(value); + if (typeof ref === 'function') { + ref(value); + } else if (ref) { + ref.current = value; + } + }, + [ref], ); - }, -); -const Popup = React.forwardRef>( - function PopoverPopup({ className, style, ...rest }, ref) { return ( - ); }, ); -const Content = React.forwardRef>(function PopoverContent( - { className, style, ...rest }, - ref, -) { - return ( -
- ); -}); +export interface PopoverPopupProps extends React.ComponentPropsWithoutRef { + /** Width of the floating box. */ + size?: PopoverSize; +} -const Footer = React.forwardRef>(function PopoverFooter( - { className, style, ...rest }, +const Popup = React.forwardRef(function PopoverPopup( + { className, style, size = 'md', ...rest }, ref, ) { return ( -
); @@ -74,8 +112,17 @@ export interface PopoverProps extends Pick< > { /** 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. */ + /** Popup contents. Supply the surface — usually a `Card`. */ children: ReactNode; + /** Width of the floating box. */ + size?: PopoverSize; + /** + * Names the dialog for assistive technology. Required unless the contents render a + * `Popover.Title`, which wires `aria-labelledby` instead. + */ + 'aria-label'?: string; + /** Names the dialog from an existing element. Alternative to `aria-label`. */ + 'aria-labelledby'?: string; } /** @@ -91,6 +138,9 @@ export function Popover({ modal, placement, sideOffset, + size, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, }: PopoverProps) { return ( - - {children} + {/* + Spread conditionally: the headless positioner merges these over its own + `aria-labelledby` (set once a `Popover.Title` mounts), so passing an explicit + `undefined` would delete the Title's label rather than leave it alone. + */} + + {children} @@ -117,9 +175,6 @@ 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 96d4b9b9c43..fbdb85a8113 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -20,7 +20,7 @@ export { Text, TextContext } from '../components/text'; export type { TextProps } from '../components/text'; export { Popover } from '../components/popover'; -export type { PopoverProps } from '../components/popover'; +export type { PopoverPopupProps, PopoverProps, PopoverSize } from '../components/popover'; import { colorVars, From 316a6347cc65f56f6980301aa944ab319e5698a3 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 30 Jul 2026 15:52:52 -0600 Subject: [PATCH 04/12] docs(swingset): document the Mosaic popover surface split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The examples now compose a Card inside the popover, making that the canonical shape so the docs supply the consistency the component no longer enforces on its own. Adds placement and alignment demos, a section on naming the dialog, and drops the removed Content, Footer, and Arrow parts from the parts table. The placement demos reserve vertical room around the trigger: without it the flip middleware bounces a `top` popover back below the trigger inside the short preview frame, so the demo would contradict its label. The story moves onto the remapped Button API. The trigger render prop is typed as Omit, 'color'> — the native `color` is a string and collides with Button's narrowed variant, which is the same omission props.ts makes for the same reason. --- packages/swingset/src/lib/registry.ts | 14 +- .../src/stories/popover.component.mdx | 152 +++++++++++++----- .../src/stories/popover.component.stories.tsx | 146 +++++++++++++++-- 3 files changed, 258 insertions(+), 54 deletions(-) diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 6c9b860273e..0939507ef88 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -86,7 +86,12 @@ 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 { + Alignment as PopoverComponentAlignment, + Default as PopoverComponentDefault, + meta as popoverComponentMeta, + Placement as PopoverComponentPlacement, +} 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'; @@ -156,7 +161,12 @@ const inputModule: StoryModule = { meta: inputMeta, Default, Sizes: InputSizes, const dialogComponentModule: StoryModule = { meta: dialogComponentMeta, Default: DialogDefault }; -const popoverComponentModule: StoryModule = { meta: popoverComponentMeta, Default: PopoverComponentDefault }; +const popoverComponentModule: StoryModule = { + meta: popoverComponentMeta, + Default: PopoverComponentDefault, + Placement: PopoverComponentPlacement, + Alignment: PopoverComponentAlignment, +}; const itemModule: StoryModule = { meta: itemMeta, diff --git a/packages/swingset/src/stories/popover.component.mdx b/packages/swingset/src/stories/popover.component.mdx index 8d3936bb3f9..007ca04f1fb 100644 --- a/packages/swingset/src/stories/popover.component.mdx +++ b/packages/swingset/src/stories/popover.component.mdx @@ -3,9 +3,10 @@ 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. +primitive and themed with StyleX. It owns only what it means to float: trigger wiring, ARIA, focus +management, positioning, stacking, viewport clamps, and the enter/exit transition. It paints **no +surface of its own** — background, border, radius, shadow and padding come from whatever you render +inside it, usually a `Card`. Keeping the two apart means only one element ever draws the border. ## Example @@ -16,25 +17,52 @@ positioning, focus management, and ARIA wiring. ## Usage +Compose the surface inside the popover. `size` sets the width of the floating box; everything you +see comes from the `Card`. + ```tsx import { Button } from '@clerk/ui/mosaic/components/button'; +import { Card } from '@clerk/ui/mosaic/components/card'; import { Popover } from '@clerk/ui/mosaic/components/popover'; - }> - Flexible inner content. - - - + } +> + + 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. +The `trigger` render prop receives the interaction props (ARIA attributes, click handler, and the +`data-open` / `data-closed` state attributes) from the headless layer and should spread them onto +whatever element opens the popover. + +### Accessible name + +The popup is a `role="dialog"`, so it needs a name. Either pass `aria-label`, or render a +`Popover.Title` inside it — the title wires `aria-labelledby` for you. A popover with neither logs a +development warning. + +```tsx + } +> + + Flexible inner content. + +; +``` ### Controlled @@ -46,14 +74,52 @@ const [open, setOpen] = useState(false); onOpenChange={setOpen} trigger={props => } > - Flexible inner content. + + Flexible inner content. + +; +``` + +### Size + +`size` sets the width of the floating box: `sm` (18rem), `md` (23.5rem, the default), or `lg` +(26rem). Each clamps to `calc(100vw - 2rem)` on narrow viewports. The popup also caps its height at +`min(80dvh, 36rem)` — for taller content, give the surface inside a scrolling region. + +```tsx + } +> + + A wider panel. + ; ``` ### 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. +`placement` sets the preferred side: `top`, `right`, `bottom` (the default), or `left`. `sideOffset` +sets the gap from the trigger. + + + +Placement is a preference, not a guarantee — the popup flips to the opposite side when it would +overflow, and shifts along the cross axis to stay in view. `data-side` on the positioner always +reports the side actually used. + +### Alignment + +Append `-start` or `-end` to a placement to align the popup with that edge of the trigger instead of +centering on it. + + ```tsx } > - Aligned to the trigger's end edge. + + Aligned to the trigger's end edge. + ; ``` +Cross-axis flipping is only enabled for aligned placements, so `bottom-start` may become +`bottom-end` near a viewport edge while a plain `bottom` will not. + ## 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. | +| 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 floating box; owns `size`, viewport clamps, and the enter/exit run. | +| `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.Title` and `Popover.Description` are unstyled passthroughs from the headless layer — render +them through your own typography (`Heading`, `Text`) inside the surface. ## Styling @@ -95,12 +166,15 @@ CSS layer that wins over `@clerk/ui/styles.css`: @import '@clerk/ui/styles.css' layer(components); @layer overrides { - .cl-popover-popup { - border-radius: 20px; + .cl-popover-popup[data-size='lg'] { + width: 30rem; } } ``` +The popup is intentionally transparent. To restyle the panel's background, border, radius or +shadow, style the surface you render inside it (e.g. `.cl-card-root`) rather than the popup. + State attributes from the headless layer are available for CSS targeting: | Attribute | Applies To | Description | @@ -110,6 +184,12 @@ State attributes from the headless layer are available for CSS targeting: | `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`) | +| `data-size` | Popup | Resolved size (`sm` / `md` / `lg`) | + +The popup's enter/exit transition (opacity + scale) is driven off `data-starting-style` / +`data-ending-style`, runs for `--cl-duration-base`, and eases per property: `linear` for the fade, +`--cl-ease-default` for the scale. Under `prefers-reduced-motion: reduce` only `transform` drops out +of the transition — the fade still runs, since the vestibular concern is the movement. -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`. +`data-open` on the trigger is what a trigger component styles to hold a pressed/active look while +the popover is open. diff --git a/packages/swingset/src/stories/popover.component.stories.tsx b/packages/swingset/src/stories/popover.component.stories.tsx index 546e15279af..c4d2e64097f 100644 --- a/packages/swingset/src/stories/popover.component.stories.tsx +++ b/packages/swingset/src/stories/popover.component.stories.tsx @@ -1,6 +1,9 @@ /** @jsxImportSource @emotion/react */ import { Button } from '@clerk/ui/mosaic/components/button'; +import { Card } from '@clerk/ui/mosaic/components/card'; +import { Heading } from '@clerk/ui/mosaic/components/heading'; import { Popover } from '@clerk/ui/mosaic/components/popover'; +import { Text } from '@clerk/ui/mosaic/components/text'; import type { StoryMeta } from '@/lib/types'; @@ -12,27 +15,138 @@ export const meta: StoryMeta = { group: 'Components', title: 'Popover', source: 'packages/ui/src/mosaic/components/popover/popover.tsx', + styleEngine: 'stylex', }; -const popoverTrigger = (props: React.HTMLAttributes) => ; +const popoverTrigger = (props: Omit, 'color'>) => ( + +); export function Default() { return ( - - -
- Ada Lovelace - ada@example.com -
-
- - - + + + + Ada Lovelace + ada@example.com + + + + + ); } + +// Each placement demo shares the same trigger and panel so the example reads as the +// placement it sets. The wrapper reserves vertical room — without it the `flip` +// middleware bounces a `top` popover back to the bottom inside a short preview. +const labelledTrigger = (label: string) => (props: Omit, 'color'>) => ( + +); + +const panel = (label: string) => ( + + + {label} + + +); + +export function Placement() { + return ( +
+ + {panel('Placed above the trigger.')} + + + {panel('Placed below the trigger.')} + + + {panel('Placed to the inline start.')} + + + {panel('Placed to the inline end.')} + +
+ ); +} + +export function Alignment() { + return ( +
+ + {panel('Aligned to the trigger’s start edge.')} + + + {panel('Centered on the trigger.')} + + + {panel('Aligned to the trigger’s end edge.')} + +
+ ); +} From 579fcaf59ccacb1214351cbb6039e4d994edc6a0 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 30 Jul 2026 17:17:39 -0600 Subject: [PATCH 05/12] feat(headless): expose the anchor's center as --cl-anchor-origin The `cssVars` middleware already computed the anchor's center to derive `--cl-transform-origin`, then discarded one axis: for top/bottom it pins Y to the floating element's own edge, and for left/right it pins X. That makes it a hybrid point rather than the anchor's center, so scaling about it grows the element from its own edge instead of out of the trigger. Emit the unmodified center as a second variable rather than changing the first. `--cl-transform-origin` is consumed elsewhere with its edge semantics, so redefining it would silently retarget those animations. Unlike `--cl-transform-origin`, this one ignores the arrow: the goal is the anchor's bounding box, not the visual connector. Co-Authored-By: Claude Opus 5 (1M context) --- packages/headless/src/utils/css-vars.test.ts | 62 +++++++++++++++++++- packages/headless/src/utils/css-vars.ts | 14 ++++- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/packages/headless/src/utils/css-vars.test.ts b/packages/headless/src/utils/css-vars.test.ts index 4b8d70e8c3a..07ddcf01d45 100644 --- a/packages/headless/src/utils/css-vars.test.ts +++ b/packages/headless/src/utils/css-vars.test.ts @@ -238,6 +238,63 @@ describe('cssVars middleware', () => { }); }); + describe('--cl-anchor-origin', () => { + it("keeps both axes of the anchor's center", async () => { + const mw = cssVars({ sideOffset: 8 }); + const state = createMockState({ + placement: 'bottom', + referenceWidth: 100, + referenceHeight: 40, + }); + await mw.fn(state); + + const vars = getVars(state); + // Unlike --cl-transform-origin, the cross axis is the anchor's center (40/2), not + // the floating element's own edge. + expect(vars.get('--cl-anchor-origin')).toBe('50px 20px'); + expect(vars.get('--cl-transform-origin')).toBe('50px -8px'); + }); + + it('is the same point on every side', async () => { + const mw = cssVars({ sideOffset: 8 }); + + for (const placement of ['top', 'bottom', 'left', 'right', 'bottom-end']) { + const state = createMockState({ placement, referenceWidth: 100, referenceHeight: 40 }); + await mw.fn(state); + expect(getVars(state).get('--cl-anchor-origin')).toBe('50px 20px'); + } + }); + + it('is relative to the floating element', async () => { + const mw = cssVars(); + const state = createMockState({ referenceWidth: 100, referenceHeight: 40 }); + // Floating element positioned away from the anchor. + (state as { x: number }).x = 30; + (state as { y: number }).y = 60; + await mw.fn(state); + + const vars = getVars(state); + expect(vars.get('--cl-anchor-origin')).toBe('20px -40px'); + }); + + it('ignores the arrow', async () => { + const mw = cssVars({ sideOffset: 4 }); + const state = createMockState({ + placement: 'bottom', + referenceWidth: 100, + referenceHeight: 40, + arrowX: 50, + arrowElWidth: 12, + }); + await mw.fn(state); + + const vars = getVars(state); + // The arrow moves --cl-transform-origin but not the anchor's own center. + expect(vars.get('--cl-transform-origin')).toBe('56px -4px'); + expect(vars.get('--cl-anchor-origin')).toBe('50px 20px'); + }); + }); + describe('return value', () => { it('returns empty object (no position changes)', async () => { const mw = cssVars(); @@ -247,8 +304,8 @@ describe('cssVars middleware', () => { }); }); - describe('all five CSS vars are set', () => { - it('sets exactly 5 CSS custom properties', async () => { + describe('all six CSS vars are set', () => { + it('sets exactly 6 CSS custom properties', async () => { const mw = cssVars({ sideOffset: 4 }); const state = createMockState({ placement: 'bottom' }); await mw.fn(state); @@ -263,6 +320,7 @@ describe('cssVars middleware', () => { '--cl-available-width', '--cl-available-height', '--cl-transform-origin', + '--cl-anchor-origin', ]); }); }); diff --git a/packages/headless/src/utils/css-vars.ts b/packages/headless/src/utils/css-vars.ts index 200184cba61..b2f51c2259f 100644 --- a/packages/headless/src/utils/css-vars.ts +++ b/packages/headless/src/utils/css-vars.ts @@ -8,6 +8,7 @@ import { detectOverflow, type Middleware } from '@floating-ui/react'; * - `--cl-available-width` – available width between anchor and viewport edge (px) * - `--cl-available-height` – available height between anchor and viewport edge (px) * - `--cl-transform-origin` – CSS transform-origin pointing back toward the anchor + * - `--cl-anchor-origin` – CSS transform-origin at the anchor's own center * * Place **after** `arrow()` so arrow position data is available for transform-origin. */ @@ -48,6 +49,10 @@ export function cssVars(opts?: { sideOffset?: number }): Middleware { // The arrow is the only FloatingArrow descendant carrying data-side. const arrowEl = elements.floating.querySelector('svg[data-side]'); + // The anchor's center, relative to the floating element. + const anchorX = rects.reference.x + rects.reference.width / 2 - state.x; + const anchorY = rects.reference.y + rects.reference.height / 2 - state.y; + let transformX: number; let transformY: number; @@ -57,9 +62,8 @@ export function cssVars(opts?: { sideOffset?: number }): Middleware { transformX = arrowX + arrowEl.clientWidth / 2; transformY = arrowY + arrowEl.clientHeight / 2; } else { - // No arrow — use the anchor's center relative to the floating element - transformX = rects.reference.x + rects.reference.width / 2 - state.x; - transformY = rects.reference.y + rects.reference.height / 2 - state.y; + transformX = anchorX; + transformY = anchorY; } const originMap: Record = { @@ -70,6 +74,10 @@ export function cssVars(opts?: { sideOffset?: number }): Middleware { }; style.setProperty('--cl-transform-origin', originMap[side]); + // Keeps both axes, where `--cl-transform-origin` pins the cross axis to the floating + // element's own edge. Scaling about this point makes the popup travel out of the + // anchor instead of growing in place. + style.setProperty('--cl-anchor-origin', `${anchorX}px ${anchorY}px`); return {}; }, From f4a401ad56460536f2fff5cfac3f93d110384634 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 30 Jul 2026 17:17:48 -0600 Subject: [PATCH 06/12] feat(ui): add --cl-ease-exit for departing motion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--cl-ease-default` is Swift Out: front-loaded, carrying its endpoint ~2% past target before settling. That is an arrival curve, and reusing it for an exit fails in three ways. It spends 90% of its travel in the first three frames and then crawls, so most of the run is perceptually static — reported as choppy or dropped frames, though a profile correctly shows none. Its overshoot inverts into a wobble past the target, with nothing left to settle into. And a departure has no reason to decelerate. In Quad is deliberately the gentlest of the in-family. An exit covers a small distance over few frames, so a sharper curve (In Quart, In Circ) leaves half of them below the threshold of visible change and reproduces the same stall. Co-Authored-By: Claude Opus 5 (1M context) --- packages/ui/src/mosaic/tokens.stylex.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/mosaic/tokens.stylex.ts b/packages/ui/src/mosaic/tokens.stylex.ts index 4630d8e9a4f..8074da33b50 100644 --- a/packages/ui/src/mosaic/tokens.stylex.ts +++ b/packages/ui/src/mosaic/tokens.stylex.ts @@ -192,8 +192,8 @@ export const durationVars = stylex.defineVars(durationDefaults); // ============================================================================= // Motion Tokens — easing // ============================================================================= -// One curve, named for its role rather than its shape so a consumer can retarget -// it without the name going stale. The default is Swift Out +// Curves are named for their role rather than their shape so a consumer can +// retarget one without the name going stale. The default is Swift Out // (https://www.easing.dev/swift-out, from Lochie Axon's Easing Graphs): // front-loaded, so a change departs fast, and carrying its endpoint ~2% past // target around 85% through before settling. @@ -205,9 +205,19 @@ export const durationVars = stylex.defineVars(durationDefaults); // non-uniform, so an ease on top only makes the midpoint drag, and an overshoot // extrapolates past the target color for no gain. That is a rule about the property, // not the duration — a transform at `fast` still wants this curve. +// +// `--cl-ease-exit` is its counterpart for things LEAVING, In Quad +// (https://www.easing.dev/in-quad). Swift Out run backwards spends 90% of its travel +// in the first three frames and then crawls, and its overshoot inverts into a wobble +// past the target — a departure has nothing to settle into, so it wants to accelerate +// away instead. Deliberately the gentlest of the in-family: an exit moves a small +// distance over few frames, so a sharper curve (In Quart, In Circ) leaves half of them +// below the threshold of visible change and reads as a stall followed by a lurch. +// Pair it with a shorter duration than the matching entrance. const easingDefaults = { '--cl-ease-default': 'cubic-bezier(0.175, 0.885, 0.32, 1.1)', + '--cl-ease-exit': 'cubic-bezier(0.55, 0.085, 0.68, 0.53)', } as const; export const easingVars = stylex.defineVars(easingDefaults); From 94b97d3d658f4c7766660bb312aae84fdda1960b Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 30 Jul 2026 17:18:03 -0600 Subject: [PATCH 07/12] feat(ui): scale the Mosaic popover out of its trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The popup had no transform-origin, so it grew from its own center and read as appearing beside the trigger rather than out of it. It now scales about `--cl-anchor-origin`, the trigger's bounding-box center. A pixel-precise point is what makes this hold: keyword origins anchor to the popup's own box, so they drift off the trigger as soon as `shift()` or `flip()` moves it. Start scale goes 0.98 -> 0.94. Travel is `(1 - startScale) x distance(origin, popup center)`, so origin and start scale only work as a pair — at 0.98 the effect is about 2px and invisible. The enter and exit now differ in duration and curve. The exit is shorter, because an arrival earns a moment to settle and a dismissal should get out of the way, and takes `--cl-ease-exit`. Entering, opacity is given the shorter duration so it lands opaque as the scale reaches full size, leaving the settle to play at full strength instead of through a fade; leaving, both share one duration so the shape does not finish ahead of the fade and leave a motionless rectangle behind. Reduced motion drops the scale itself, not just its transition. Removing `transform` from `transitionProperty` stops it animating but not changing, so the 0.94 was still applied — instantly. Entering that is invisible (it happens at `opacity: 0`), but exiting it snapped to 94% at full opacity and then faded. The override is restated inside the media query rather than added as a sibling key: both would otherwise compile to the same specificity and the tiebreak would be source order, which `@stylexjs/sort-keys` reorders on autofix. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/popover/popover.styles.ts | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/mosaic/components/popover/popover.styles.ts b/packages/ui/src/mosaic/components/popover/popover.styles.ts index 7cbe7b69537..c5983722aea 100644 --- a/packages/ui/src/mosaic/components/popover/popover.styles.ts +++ b/packages/ui/src/mosaic/components/popover/popover.styles.ts @@ -27,11 +27,38 @@ export const styles = stylex.create({ // A popover holds prose — an email, an org slug, an API key — and a long // unbroken string would otherwise push past the width clamp. overflowWrap: 'anywhere', + // Reduced motion has to drop the scale itself, not just its transition. Dropping it + // from `transitionProperty` alone leaves the value change, which then applies + // instantly — invisible entering (it happens at `opacity: 0`) but a hard snap to 94% + // before the fade on the way out. transform: { default: 'scale(1)', - ':where([data-starting-style], [data-ending-style])': 'scale(0.98)', + ':where([data-starting-style], [data-ending-style])': 'scale(0.94)', + // Restated inside the media query rather than left to a bare sibling key: both + // would otherwise compile to the same specificity and the tiebreak would be source + // order, which `@stylexjs/sort-keys` reorders on autofix. + '@media (prefers-reduced-motion: reduce)': { + default: 'scale(1)', + ':where([data-starting-style], [data-ending-style])': 'scale(1)', + }, + }, + // Scale about the trigger's center, not the popup's, so the popup travels out of the + // trigger as it grows. The positioner sets this per position update and it inherits + // down; a keyword origin would drift off the trigger once `shift()` or `flip()` moves + // the popup. Falls back to `center` for the first frame, which is still `opacity: 0`. + transformOrigin: 'var(--cl-anchor-origin, center)', + // The exit is shorter than the entrance: an arrival earns a moment to settle, a + // dismissal is an acknowledgement and wants to be out of the way. + // + // On the way in the fade finishes first (positional, so `fast` is opacity and `base` + // is transform). It lands opaque just as the scale reaches full size, leaving the + // settle to play at full strength instead of through a fade — a popup that is still + // arriving while it moves reads as washed out. Leaving is the reverse case and wants + // them to land together, so the exit keeps one duration for both. + transitionDuration: { + default: `${durationVars['--cl-duration-fast']}, ${durationVars['--cl-duration-base']}`, + ':where([data-ending-style])': durationVars['--cl-duration-fast'], }, - transitionDuration: durationVars['--cl-duration-base'], // 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 @@ -49,8 +76,13 @@ export const styles = stylex.create({ // Positional, in the order of `transitionProperty`. Opacity takes `linear` — // its interpolation is already perceptually non-uniform, and the overshoot in // `--cl-ease-default` would extrapolate past the target for no gain. The scale - // is what moves, so it gets the overshoot. - transitionTimingFunction: `linear, ${easingVars['--cl-ease-default']}`, + // is what moves, so it gets the overshoot on the way in and accelerates away on + // the way out; running the entrance curve backwards stalls the exit for most of + // its duration and turns the overshoot into a wobble past the target. + transitionTimingFunction: { + default: `linear, ${easingVars['--cl-ease-default']}`, + ':where([data-ending-style])': `linear, ${easingVars['--cl-ease-exit']}`, + }, maxHeight: 'min(80dvh, 36rem)', maxWidth: 'calc(100vw - 2rem)', minHeight: 0, From 5d5ef803a6cc4cd19eb1d3a35d1afd4a0eeff6bd Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 30 Jul 2026 17:18:10 -0600 Subject: [PATCH 08/12] feat(ui): keep a disclosure trigger pressed while its surface is open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A button that opens a popover returned to its resting fill the moment the pointer left, so nothing on the trigger showed that the surface it controls is still open. `[data-open]` now takes the same pressed fill as `:active` across every filled/outline/ghost cell. `link` opts out — it reads as text, not a control. Styling only: the disclosure primitives already set `data-open` on the trigger, and a plain button never carries it. `[data-open]` is excluded from hover for the same reason `:active` is. StyleX gives at-rules extra priority, so a `@media (hover: hover)` `:hover` outranks it, and moving the pointer over an open trigger would otherwise lift it back to the lighter hover step. Co-Authored-By: Claude Opus 5 (1M context) --- .../mosaic/components/button/button.styles.ts | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/mosaic/components/button/button.styles.ts b/packages/ui/src/mosaic/components/button/button.styles.ts index 59da5f26632..25e6c7f99f3 100644 --- a/packages/ui/src/mosaic/components/button/button.styles.ts +++ b/packages/ui/src/mosaic/components/button/button.styles.ts @@ -44,6 +44,12 @@ const negativeActive = `color-mix(in oklab, ${colorVars['--cl-color-negative']}, // `@media (hover: hover)` `:hover` would outrank a bare `:active` and win while pressing. // Both selectors are written out per cell rather than hoisted to a const: `@stylexjs/sort-keys` // reads a computed key as its identifier name and fails the ordering. +// +// `[data-open]` takes the pressed fill too, so a button acting as a disclosure trigger stays +// visibly engaged for as long as its surface is open. Disclosure primitives set it on the +// trigger (`popover-trigger.tsx` and friends); a plain button never carries it. It is excluded +// from hover for the same reason `:active` is — otherwise moving the pointer over an open +// trigger would lift it back to the lighter hover step. export const styles = stylex.create({ base: { @@ -133,9 +139,10 @@ export const variants = stylex.create({ backgroundColor: { default: colorVars['--cl-color-primary'], ':enabled:active': primaryActive, + ':enabled[data-open]': primaryActive, '@media (hover: hover)': { default: null, - ':enabled:hover:not(:active)': primaryHover, + ':enabled:hover:not(:active):not([data-open])': primaryHover, }, }, color: colorVars['--cl-color-primary-foreground'], @@ -144,9 +151,10 @@ export const variants = stylex.create({ backgroundColor: { default: neutralStep0, ':enabled:active': neutralStep2, + ':enabled[data-open]': neutralStep2, '@media (hover: hover)': { default: null, - ':enabled:hover:not(:active)': neutralStep1, + ':enabled:hover:not(:active):not([data-open])': neutralStep1, }, }, color: colorVars['--cl-color-neutral-foreground'], @@ -155,9 +163,10 @@ export const variants = stylex.create({ backgroundColor: { default: colorVars['--cl-color-negative'], ':enabled:active': negativeActive, + ':enabled[data-open]': negativeActive, '@media (hover: hover)': { default: null, - ':enabled:hover:not(:active)': negativeHover, + ':enabled:hover:not(:active):not([data-open])': negativeHover, }, }, color: colorVars['--cl-color-negative-foreground'], @@ -171,9 +180,10 @@ export const variants = stylex.create({ backgroundColor: { default: 'transparent', ':enabled:active': neutralStep1, + ':enabled[data-open]': neutralStep1, '@media (hover: hover)': { default: null, - ':enabled:hover:not(:active)': neutralStep0, + ':enabled:hover:not(:active):not([data-open])': neutralStep0, }, }, color: colorVars['--cl-color-primary'], @@ -183,9 +193,10 @@ export const variants = stylex.create({ backgroundColor: { default: 'transparent', ':enabled:active': neutralStep1, + ':enabled[data-open]': neutralStep1, '@media (hover: hover)': { default: null, - ':enabled:hover:not(:active)': neutralStep0, + ':enabled:hover:not(:active):not([data-open])': neutralStep0, }, }, color: colorVars['--cl-color-neutral-foreground'], @@ -195,9 +206,10 @@ export const variants = stylex.create({ backgroundColor: { default: 'transparent', ':enabled:active': neutralStep1, + ':enabled[data-open]': neutralStep1, '@media (hover: hover)': { default: null, - ':enabled:hover:not(:active)': neutralStep0, + ':enabled:hover:not(:active):not([data-open])': neutralStep0, }, }, color: colorVars['--cl-color-negative'], @@ -207,9 +219,10 @@ export const variants = stylex.create({ backgroundColor: { default: 'transparent', ':enabled:active': neutralStep1, + ':enabled[data-open]': neutralStep1, '@media (hover: hover)': { default: null, - ':enabled:hover:not(:active)': neutralStep0, + ':enabled:hover:not(:active):not([data-open])': neutralStep0, }, }, color: colorVars['--cl-color-primary'], @@ -218,9 +231,10 @@ export const variants = stylex.create({ backgroundColor: { default: 'transparent', ':enabled:active': neutralStep1, + ':enabled[data-open]': neutralStep1, '@media (hover: hover)': { default: null, - ':enabled:hover:not(:active)': neutralStep0, + ':enabled:hover:not(:active):not([data-open])': neutralStep0, }, }, color: colorVars['--cl-color-neutral-foreground'], @@ -231,9 +245,10 @@ export const variants = stylex.create({ backgroundColor: { default: 'transparent', ':enabled:active': `color-mix(in oklab, ${colorVars['--cl-color-negative-faded']}, ${colorVars['--cl-color-negative']} 8%)`, + ':enabled[data-open]': `color-mix(in oklab, ${colorVars['--cl-color-negative-faded']}, ${colorVars['--cl-color-negative']} 8%)`, '@media (hover: hover)': { default: null, - ':enabled:hover:not(:active)': colorVars['--cl-color-negative-faded'], + ':enabled:hover:not(:active):not([data-open])': colorVars['--cl-color-negative-faded'], }, }, color: colorVars['--cl-color-negative'], From 12f4a87f462aae373625f8872c1373c8886f4a10 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 30 Jul 2026 17:18:19 -0600 Subject: [PATCH 09/12] docs(repo): document Mosaic motion rules in the mosaic skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `references/motion.md`, covering the rules that came out of the popover enter/exit work: curves have a direction and an arrival curve must not be reused for a departure, the three axes of enter/exit asymmetry, the dead-frame test for choosing a curve against a given delta and duration, the two transform-origin variables and the geometry that decides how far an element travels, and the reduced-motion trap where gating `transitionProperty` alone leaves the value change behind. Includes the measurements each rule came from, plus a rAF sampler for checking a transition — reading the CSS will not tell you that one stalls. `stylex.md` gains a pointer to it, a DON'T against reusing `--cl-ease-default` for exits, and the disclosure-trigger pattern beside the `:hover`/`:active` cascade rule it extends. Its worked example was refreshed: it still showed a literal `150ms`, the pre-0.94 start scale, and a reduced-motion branch that killed the fade rather than the transform, contradicting the house rule a section above it. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/mosaic/SKILL.md | 1 + .claude/skills/mosaic/references/motion.md | 240 +++++++++++++++++++++ .claude/skills/mosaic/references/stylex.md | 45 +++- 3 files changed, 283 insertions(+), 3 deletions(-) create mode 100644 .claude/skills/mosaic/references/motion.md diff --git a/.claude/skills/mosaic/SKILL.md b/.claude/skills/mosaic/SKILL.md index fd0bb8db9dd..80165faf255 100644 --- a/.claude/skills/mosaic/SKILL.md +++ b/.claude/skills/mosaic/SKILL.md @@ -46,6 +46,7 @@ this skill is the _how-to_. | -------------------------------------------------------------------- | ------------------------------------------------------ | | Building on / authoring a headless primitive (`@clerk/headless`) | `references/headless.md` | | Styling a component with StyleX (tokens, `stylex.create`, CSS build) | `references/stylex.md` | +| Building an enter/exit transition, or any motion that reads as wrong | `references/motion.md` | | Styling a component the legacy way (slot recipes, `useRecipe`) | `references/styling.md` | | Authoring or debugging a state machine, or wiring one to React | `references/machines.md` → in-tree `machine/README.md` | | Writing the controller (Clerk adapter, permissions, revalidate) | `references/controllers.md` | diff --git a/.claude/skills/mosaic/references/motion.md b/.claude/skills/mosaic/references/motion.md new file mode 100644 index 00000000000..032b49f81e4 --- /dev/null +++ b/.claude/skills/mosaic/references/motion.md @@ -0,0 +1,240 @@ +# Motion: entrances and exits + +Token semantics live in `packages/ui/src/mosaic/tokens.stylex.ts`, above +`durationDefaults` / `easingDefaults` — read those comments first. This file is the +how-to layer: the rules that decide a transition's shape, and how to check one +rather than eyeball it. + +| Token | Value | For | +| ----------------------- | --------------------------------------- | --------------------------- | +| `--cl-duration-instant` | `0s` | press feedback | +| `--cl-duration-fast` | `0.1s` | exits, hover | +| `--cl-duration-base` | `0.15s` | entrances | +| `--cl-duration-slow` | `0.25s` | larger surfaces | +| `--cl-duration-slower` | `0.35s` | — | +| `--cl-ease-default` | `cubic-bezier(0.175, 0.885, 0.32, 1.1)` | things ARRIVING (Swift Out) | +| `--cl-ease-exit` | `cubic-bezier(0.55, 0.085, 0.68, 0.53)` | things LEAVING (In Quad) | + +Named curves come from [easing.dev](https://www.easing.dev) (Lochie Axon's Easing +Graphs). Take one from there rather than inventing a bezier, so the catalog stays +the shared vocabulary. + +## A curve has a direction — don't run the entrance curve backwards + +The single most common motion bug in this codebase. `--cl-ease-default` is +front-loaded and carries its endpoint ~2% past target before settling: a change +departs fast and lands soft. That is exactly right for an entrance and wrong in +three separate ways for an exit. + +Measured on the Mosaic popover when both directions shared `--cl-ease-default` at +`--cl-duration-base` (scale `1 → 0.94`, 60fps): + +``` +ms scale opacity Δscale/frame +0 1.0000 1.000 — +33.2 0.9718 0.889 -0.0282 ┐ 90% of the shrink, 3 frames +50.7 0.9552 0.778 -0.0166 │ +66.7 0.9465 0.667 -0.0087 ┘ +83.4 0.9419 0.555 -0.0046 +100.2 0.9397 0.445 -0.0022 +117.4 0.9388 0.333 -0.0009 ← dipped BELOW the 0.94 target +133.4 0.9387 0.222 -0.0001 +150.5 0.9392 0.111 +0.0005 ← and came back up +167.4 0.9400 0.000 +0.0008 +186.0 REMOVED +``` + +1. **The velocity dies.** 90% of the travel happens in three frames, then six + frames (~two-thirds of the run) move a combined 0.008. Users report this as + "choppy" or "dropping frames" — and a performance profile will correctly show + zero dropped frames. Every frame renders; they are just nearly identical. +2. **The overshoot inverts.** Progress peaks at 1.023, so the value travels _past_ + the target and returns. Arriving, that reads as settling. Leaving, it is a + wobble with nothing to cover it. +3. **Shape and fade decouple.** Transform finished by ~50ms while a linear opacity + ran the full 150ms, leaving a motionless fading rectangle. + +Use `--cl-ease-exit` on `:where([data-ending-style])`. Same properties, opposite +direction, so both duration and timing function branch: + +```ts +transitionDuration: { + default: `${durationVars['--cl-duration-fast']}, ${durationVars['--cl-duration-base']}`, + ':where([data-ending-style])': durationVars['--cl-duration-fast'], +}, +transitionTimingFunction: { + default: `linear, ${easingVars['--cl-ease-default']}`, + ':where([data-ending-style])': `linear, ${easingVars['--cl-ease-exit']}`, +}, +``` + +Both lists are **positional against `transitionProperty`** (`opacity, transform`). + +## Asymmetry, in three places + +**Duration.** Exits are shorter than entrances — an arrival earns a moment to +settle, a dismissal is an acknowledgement. `--cl-duration-base` in, +`--cl-duration-fast` out, a 1.5:1 ratio. Stay on the duration scale; a ratio that +needs an off-scale value is not worth a new token for one component. + +**Opacity leads on the way in.** Give opacity the shorter duration so it lands +opaque as the transform reaches full size, and the settle plays at full strength. +A popup still fading while it moves reads as washed out. On the popover this moved +opacity-at-overshoot-peak from 0.78 to 1.00. + +**Exits land together.** Do _not_ split them going out. Matching durations are +what stop an exit reading as a lingering ghost. + +## Small deltas constrain the curve (the dead-frame test) + +A transition's usable curves depend on how much it actually moves. A scale delta +of 0.06 over 100ms is six frames; a sharply back-loaded curve puts half of them +below the threshold of visible change and re-creates the stall-then-lurch above. +Scored for that exit — _dead_ = frames moving <0.003, _ramp_ = largest frame step +÷ smallest: + +| curve | dead | ramp | +| --------------------------------------- | ---- | ----- | +| linear | 0 | 1.0× | +| In `(0.42, 0, 1, 1)` — the CSS keyword | 1 | 5.9× | +| **In Quad `(0.55, 0.085, 0.68, 0.53)`** | 1 | 6.6× | +| In Cubic `(0.55, 0.055, 0.675, 0.19)` | 2 | 17.9× | +| In Quart `(0.895, 0.03, 0.685, 0.22)` | 3 | >50× | +| In Circ `(0.6, 0.04, 0.98, 0.335)` | 2 | 32.0× | + +Hence In Quad, the gentlest of the in-family. Counterintuitively, a **longer** +duration makes this worse, not better: the delta is fixed, so more frames means +smaller steps and more of them below threshold (In Cubic goes from 2 dead frames +at 100ms to 4 at 150ms). If a curve stalls, shorten the duration or increase the +delta — don't stretch it. + +## `transform-origin`: anchor it to the trigger + +For anything anchored to a trigger, scale about the **trigger**, not the element's +own center, so it reads as emerging from what opened it. `cssVars` in +`packages/headless/src/utils/css-vars.ts` emits two origins on the floating +element; custom properties inherit, so a popup one level down reads them directly. + +| var | meaning | +| ----------------------- | -------------------------------------------------------------- | +| `--cl-transform-origin` | nearest **edge**, cross axis tracking the anchor (arrow-aware) | +| `--cl-anchor-origin` | the anchor's bounding-box **center**, both axes | + +```ts +transformOrigin: 'var(--cl-anchor-origin, center)', +``` + +**Do not redefine `--cl-transform-origin`** — Menu branches consume it with the +edge semantics. Add a var instead. + +Why a var and not a keyword: keyword origins (`top left`) anchor to the element's +own box, so they drift off the trigger the moment `shift()` or `flip()` moves it. +These are recomputed per position update and stay correct. + +**Timing is safe.** On a cold mount the var is unset for the mutation frame — but +the element is `opacity: 0` with `transition: none` then. Both the position and +the var settle by rAF 1; the transition arms at rAF 2. Origin is always correct +before anything animates. + +**Geometry.** Travel = `(1 − startScale) × distance(origin, element center)`, so +origin and start scale must be chosen together — at `scale(0.98)` a trigger-center +origin moves ~2px and is invisible; the popover uses `0.94` (~6px). Note the +distance, not the trigger's size, is what matters: on a centered placement the +trigger's center sits directly above/below the popup's, so trigger width cancels +out entirely no matter how wide it is. The one bad case is a **wide trigger with a +much narrower `-start`/`-end` popup**; matching the popup's width to the trigger +puts the origin back on its center. + +## Reduced motion + +Gate the **moving property**, not the duration — the signal is about vestibular +safety, so the fade should survive: + +```ts +transitionProperty: { + default: 'opacity, transform', + '@media (prefers-reduced-motion: reduce)': 'opacity', +}, +``` + +With a positional duration list, the collapsed single-property list takes the +first value — check that it's the one you want for opacity. + +### That alone is not enough — also drop the value + +Removing `transform` from `transitionProperty` stops it _animating_; it does not +stop it _changing_. The `data-starting-style` / `data-ending-style` branch still +applies `scale(0.94)`, now instantly. Entering that is invisible (it happens at +`opacity: 0`), so this reads as correct in review and in a diff — but **exiting**, +the element snaps to 94% at full opacity and then fades. The reported symptom is +"reduced motion still animates, but only on the way out." + +Restate the state branch **inside** the media query. Note the nesting direction: +StyleX only accepts at-rule outer / pseudo inner, so `:where(...)` containing an +`@media` key fails `@stylexjs/valid-styles` ("Invalid Pseudo class or At Rule used +for conditional style value"). + +```ts +transform: { + default: 'scale(1)', + ':where([data-starting-style], [data-ending-style])': 'scale(0.94)', + '@media (prefers-reduced-motion: reduce)': { + default: 'scale(1)', + ':where([data-starting-style], [data-ending-style])': 'scale(1)', + }, +}, +``` + +A bare sibling `'@media (prefers-reduced-motion: reduce)': 'scale(1)'` also works +today, but it compiles to `(0,2,0)` — the same as the branch it needs to beat — so +the tiebreak is source order, which `@stylexjs/sort-keys` reorders on autofix. +Repeating the selector inside the at-rule earns a third class and wins outright: + +```css +.a.a:where([data-starting-style], [data-ending-style]) { + transform: scale(0.94); +} /* 0,2,0 */ +@media (prefers-reduced-motion: reduce) { + .b.b.b:where([data-starting-style], [data-ending-style]) { + transform: scale(1); + } /* 0,3,0 */ +} +``` + +Verify all four combinations — enter and exit, reduced and normal. Under reduced +motion both directions should hold the scale flat at `1.0000` for every frame. + +## How to check a transition + +Reading CSS will not tell you a transition stalls. Two cheap techniques: + +**Score the curve offline** before writing it — sample `cubic-bezier` at 16.7ms +intervals across the duration, convert to the property's real values, and count +frames whose step is below ~0.003 of the total delta. + +**Record the real thing** with a rAF sampler (see `references/stylex.md` for the +`data-*` attributes that drive enter/exit): + +```js +const el = document.querySelector('.cl-popover-popup'); +const t0 = performance.now(), + rows = []; +(function tick() { + const e = document.querySelector('.cl-popover-popup'); + if (!e) return console.table(rows); + const cs = getComputedStyle(e); + rows.push({ + ms: +(performance.now() - t0).toFixed(1), + scale: +new DOMMatrix(cs.transform).a.toFixed(4), + opacity: +(+cs.opacity).toFixed(3), + }); + requestAnimationFrame(tick); +})(); +``` + +Gotchas when driving this from a browser-automation CLI: a round-trip outlasts a +150ms transition, so slow it with a `transition-duration` override or pause via +`getAnimations()` and set `currentTime`; pausing at `currentTime = 0` also freezes +opacity at 0, so hold the opacity animation at its end if you want a visible +frame; and light dismiss listens on pointerdown, so a synthetic `.click()` will +not close a popover — send a real key instead. diff --git a/.claude/skills/mosaic/references/stylex.md b/.claude/skills/mosaic/references/stylex.md index 41b3080b0b1..cc2597ec563 100644 --- a/.claude/skills/mosaic/references/stylex.md +++ b/.claude/skills/mosaic/references/stylex.md @@ -295,6 +295,28 @@ device, while touch devices look correct. then grep `dist-mosaic/styles.css` for the two selectors and compare their specificity. +- **A button that opens something takes the pressed fill while open**, so a + disclosure trigger stays visibly engaged for as long as its surface is. Disclosure + primitives already set `data-open` on the trigger (`popover-trigger.tsx` and + friends), so this is styling-only — no headless change. It needs the _same_ + exclusion as `:active`, or hovering an open trigger lifts it back to the lighter + hover step: + + ```ts + backgroundColor: { + default: 'transparent', + ':enabled:active': neutralStep1, + ':enabled[data-open]': neutralStep1, + '@media (hover: hover)': { + default: null, + ':enabled:hover:not(:active):not([data-open])': neutralStep0, + }, + }, + ``` + + Worked example: `button.styles.ts`, applied across every filled/outline/ghost cell + (`link` opts out — it reads as text, not a control). + Worked example: `packages/ui/src/mosaic/components/button/button.styles.ts`. - **DO** use `:focus-visible` for focus rings (never bare `:focus`). For a @@ -341,6 +363,12 @@ Worked example: `packages/ui/src/mosaic/components/button/button.styles.ts`. drag, and an overshoot extrapolates past the target color for nothing. A transform at `--cl-duration-fast` still wants the curve. +- **DON'T** reuse `--cl-ease-default` for something **leaving**. It is an arrival + curve; run backwards it stalls for most of its duration and its overshoot + becomes a wobble past the target. Departures take `easingVars['--cl-ease-exit']` + at a shorter duration. See `motion.md` — enter/exit asymmetry has its own + reference, with the measurements behind these rules. + - **DO** gate transitions/animations of **motion-bearing** properties on reduced motion — `transform`, `translate`, `scale`, `rotate`, positional insets — in the same object. `prefers-reduced-motion` is a vestibular-safety signal, so color @@ -494,9 +522,20 @@ _whose_ attribute you're reading: ```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', + transform: { default: 'scale(1)', ':where([data-starting-style], [data-ending-style])': 'scale(0.94)' }, + // Reduced motion drops `transform` and keeps the fade — the gate belongs on the + // moving property, not the whole transition. + transitionProperty: { default: 'opacity, transform', '@media (prefers-reduced-motion: reduce)': 'opacity' }, + // Positional against `transitionProperty`, and branched by direction: the exit is + // shorter and takes the departure curve. See `motion.md`. + transitionDuration: { + default: `${durationVars['--cl-duration-fast']}, ${durationVars['--cl-duration-base']}`, + ':where([data-ending-style])': durationVars['--cl-duration-fast'], + }, + transitionTimingFunction: { + default: `linear, ${easingVars['--cl-ease-default']}`, + ':where([data-ending-style])': `linear, ${easingVars['--cl-ease-exit']}`, + }, }, ``` From 65af7f7833f3496182c702d2a0029034b5769c31 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 30 Jul 2026 17:26:44 -0600 Subject: [PATCH 10/12] docs(repo): target constant travel, not constant scale, in motion.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records ~6px as the house target for how far an anchored surface travels as it opens, and the rule for hitting it: travel is `(1 - s) x distance(origin, center)`, so a taller surface pushes its own center further from the trigger and the same start scale overshoots. Solve for the scale rather than fixing it. Notes the three limits — a floor around 0.90 so a surface close to its origin does not get an attention-drawing zoom to manufacture the target, that travel is measured at the center by convention since points move in proportion to their own distance from the origin, and that content-driven height makes any static scale an estimate for a typical size rather than an exact normalization. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/mosaic/references/motion.md | 44 ++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/.claude/skills/mosaic/references/motion.md b/.claude/skills/mosaic/references/motion.md index 032b49f81e4..6ac66dc32fa 100644 --- a/.claude/skills/mosaic/references/motion.md +++ b/.claude/skills/mosaic/references/motion.md @@ -145,6 +145,50 @@ out entirely no matter how wide it is. The one bad case is a **wide trigger with much narrower `-start`/`-end` popup**; matching the popup's width to the trigger puts the origin back on its center. +### Hold the travel constant, not the scale + +**~6px of travel is the target.** It reads as emerging from the trigger without +becoming a visible arc. The popover hits it at `0.94` because its origin sits +~103px from the popup's center. + +Scale is the dial, not the constant. Because travel is `(1 − s) × d`, a taller +popup pushes its own center further from the trigger, `d` grows, and the same +`0.94` overshoots the target — a large surface swinging 15px reads as +overexaggerated. Solve for the scale instead: + +``` +s = 1 − (6 / d) d = distance from --cl-anchor-origin to the element's center +``` + +| `d` | scale | +| ----- | ------ | +| 60px | `0.90` | +| 100px | `0.94` | +| 150px | `0.96` | +| 200px | `0.97` | +| 300px | `0.98` | + +Two effects push the same way, which is convenient: the absolute size change is +`(1 − s) ×` the element's own dimensions, so a big surface at a fixed scale is +already shrinking by more px than a small one. Scaling toward 1 as things grow +fixes both at once. + +Three limits on the rule: + +- **Floor the scale around `0.90`.** For an element whose center is very close to + its origin, the formula demands an aggressive scale to manufacture 6px — at + `d = 30px` it asks for `0.80`, which reads as a zoom, not an emergence. Accept + less travel rather than a scale that draws attention to itself. +- **Travel is measured at the element's center, by convention.** Scaling about a + point moves every other point in proportion to _its own_ distance from that + origin, so the far edge always travels further than the center and the near edge + barely moves. Keep the center as the yardstick so numbers stay comparable. +- **Content-driven height makes this an estimate.** A popover's width comes from + its `size` variant but its height comes from whatever is inside it, so `d` is + only known at runtime. Pick the scale for the typical height of that surface and + accept the spread; a component whose height genuinely varies by multiples wants + a scale per size variant, not one constant. + ## Reduced motion Gate the **moving property**, not the duration — the signal is about vestibular From 79da9951f7cfbc322884dce954bc78cd0cc5cfa5 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 31 Jul 2026 08:56:09 -0400 Subject: [PATCH 11/12] refactor(ui): drop native color from the render prop's argument MosaicComponentProps omitted the non-standard HTML `color` attribute from a part's props but not from the `render` callback's argument, so every consumer spreading those props into a Mosaic component with a `color` variant had to strip it by hand. --- .claude/skills/mosaic/references/stylex.md | 33 ++++++++++++++++++++++ packages/ui/src/mosaic/props.ts | 26 +++++++++++------ packages/ui/src/mosaic/styles/index.ts | 12 +++++++- 3 files changed, 61 insertions(+), 10 deletions(-) diff --git a/.claude/skills/mosaic/references/stylex.md b/.claude/skills/mosaic/references/stylex.md index cc2597ec563..38f86642269 100644 --- a/.claude/skills/mosaic/references/stylex.md +++ b/.claude/skills/mosaic/references/stylex.md @@ -584,6 +584,39 @@ className left-to-right and merges `style` with the consumer object spread last: - **DON'T** call `stylex.props` twice on one element or spread `{...props}` after the merge result — fuse everything through the one `mergeStyleProps` call. +### Type every part with `MosaicComponentProps` + +`MosaicComponentProps` is the native props for `Tag` minus the non-standard HTML +`color` attribute, plus `render`. It drops `color` from the props **and** from the +`render` callback's argument, so a callback's props spread straight into a Mosaic +component whose own `color` is a variant union (`Button`, `Heading`, `Text`). + +```tsx +export interface PopoverPopupProps extends MosaicComponentProps<'div'> { … } +``` + +- **DON'T** type a Mosaic part with the headless `ComponentProps` (or + `React.ComponentPropsWithoutRef`). Those keep `color: string`, and + every consumer then has to strip it: `props: Omit, 'color'>`. +- **DON'T** re-export a headless part straight onto the Mosaic namespace object + (`Popover.Trigger = Primitive.Trigger`) — that leaks the wide type. Bridge it: + + ```tsx + const Trigger = React.forwardRef>( + function PopoverTrigger(props, ref) { + return ( + + ); + }, + ); + ``` + +- If a consumer needs to annotate a `render` callback, the API is wrong — fix the + part's props type instead. Inline callbacks infer with no annotation. + ## Build & CSS delivery (two contexts, same babel) - **Published** (`build:mosaic` → `@stylexjs/rollup-plugin`): compiles the diff --git a/packages/ui/src/mosaic/props.ts b/packages/ui/src/mosaic/props.ts index ff31214b8bb..75d8e88248c 100644 --- a/packages/ui/src/mosaic/props.ts +++ b/packages/ui/src/mosaic/props.ts @@ -1,19 +1,27 @@ -import type { RenderPropOrElement } from '@clerk/headless/utils'; +import type { RenderProp } from '@clerk/headless/utils'; import type React from 'react'; +/** + * The native props for a tag, minus the non-standard HTML `color` attribute. That + * attribute is typed `string`, so leaving it in widens any component that exposes + * `color` as a variant union. + */ +export type MosaicElementProps = Omit< + React.ComponentPropsWithRef, + 'color' +>; + /** * The base props every Mosaic component accepts: the native props for its default * tag, plus the `render` escape hatch that swaps the rendered element. * - * `color` is omitted because it is a non-standard HTML attribute typed `string`, - * which would widen any component that exposes `color` as a variant. Omitting it - * here rather than per component means a new component inherits the narrowing. + * `color` is dropped from both the props and the `render` callback's argument, so + * the props a `render` callback receives spread straight into another Mosaic + * component. Doing it here rather than per component means a new component + * inherits the narrowing. */ -export type MosaicComponentProps = Omit< - React.ComponentPropsWithRef, - 'color' -> & { - render?: RenderPropOrElement; +export type MosaicComponentProps = MosaicElementProps & { + render?: RenderProp> | React.ReactElement; }; // The public styling contract, emitted onto a component's root element: diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index fbdb85a8113..69258a200a0 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -4,6 +4,8 @@ // static `styles.css`. Keep it isolated from Emotion/un-migrated code — grow it // as components migrate. +export type { MosaicComponentProps, MosaicElementProps } from '../props'; + export { Avatar } from '../components/avatar'; export type { AvatarProps, AvatarImageProps, AvatarFallbackProps } from '../components/avatar'; export { Badge } from '../components/badge'; @@ -20,7 +22,15 @@ export { Text, TextContext } from '../components/text'; export type { TextProps } from '../components/text'; export { Popover } from '../components/popover'; -export type { PopoverPopupProps, PopoverProps, PopoverSize } from '../components/popover'; +export type { + PopoverCloseProps, + PopoverDescriptionProps, + PopoverPopupProps, + PopoverRootProps, + PopoverSize, + PopoverTitleProps, + PopoverTriggerProps, +} from '../components/popover'; import { colorVars, From f41b6b6acbe14d3471bbce29ddb2cef9a34a08d3 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 31 Jul 2026 08:56:22 -0400 Subject: [PATCH 12/12] refactor(ui): compose the Mosaic popover from Root, Trigger and Popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the single `` component with the dot-syntax parts every other Mosaic component uses. `Popover.Popup` renders the portal and the floating positioner itself, so neither is a part consumers compose. --- .../src/stories/popover.component.mdx | 146 +++++------ .../src/stories/popover.component.stories.tsx | 170 +++++++------ .../ui/src/mosaic/components/popover/index.ts | 10 +- .../components/popover/popover.test.tsx | 166 +++++++------ .../src/mosaic/components/popover/popover.tsx | 234 ++++++++++-------- 5 files changed, 378 insertions(+), 348 deletions(-) diff --git a/packages/swingset/src/stories/popover.component.mdx b/packages/swingset/src/stories/popover.component.mdx index 007ca04f1fb..08dc13b078a 100644 --- a/packages/swingset/src/stories/popover.component.mdx +++ b/packages/swingset/src/stories/popover.component.mdx @@ -17,7 +17,8 @@ inside it, usually a `Card`. Keeping the two apart means only one element ever d ## Usage -Compose the surface inside the popover. `size` sets the width of the floating box; everything you +Compose the parts: `Popover.Root` owns the open state and placement, `Popover.Trigger` is the +anchor, and `Popover.Popup` is the floating box. Put the surface inside the popup — everything you see comes from the `Card`. ```tsx @@ -25,43 +26,46 @@ import { Button } from '@clerk/ui/mosaic/components/button'; import { Card } from '@clerk/ui/mosaic/components/card'; import { Popover } from '@clerk/ui/mosaic/components/popover'; - } -> - - Flexible inner content. - - - - -; + + } /> + + + Flexible inner content. + + + + + +; ``` -The `trigger` render prop receives the interaction props (ARIA attributes, click handler, and the -`data-open` / `data-closed` state attributes) from the headless layer and should spread them onto -whatever element opens the popover. +`Popover.Trigger` renders a `
); } @@ -123,30 +116,33 @@ export function Alignment() { paddingBlockEnd: '7rem', }} > - - {panel('Aligned to the trigger’s start edge.')} - - - {panel('Centered on the trigger.')} - - - {panel('Aligned to the trigger’s end edge.')} - + + + + {panel('Aligned to the trigger’s start edge.')} + + + + + + {panel('Centered on the trigger.')} + + + + + + {panel('Aligned to the trigger’s end edge.')} + +
); } diff --git a/packages/ui/src/mosaic/components/popover/index.ts b/packages/ui/src/mosaic/components/popover/index.ts index c5c65598310..8ac0cb8c76b 100644 --- a/packages/ui/src/mosaic/components/popover/index.ts +++ b/packages/ui/src/mosaic/components/popover/index.ts @@ -1,2 +1,10 @@ export { Popover } from './popover'; -export type { PopoverPopupProps, PopoverProps, PopoverSize } from './popover'; +export type { + PopoverCloseProps, + PopoverDescriptionProps, + PopoverPopupProps, + PopoverRootProps, + PopoverSize, + PopoverTitleProps, + PopoverTriggerProps, +} from './popover'; diff --git a/packages/ui/src/mosaic/components/popover/popover.test.tsx b/packages/ui/src/mosaic/components/popover/popover.test.tsx index 4e88a3d575e..fff4fbe55b3 100644 --- a/packages/ui/src/mosaic/components/popover/popover.test.tsx +++ b/packages/ui/src/mosaic/components/popover/popover.test.tsx @@ -13,22 +13,16 @@ const settle = () => await new Promise(resolve => setTimeout(resolve, 0)); }); -const trigger = (props: React.HTMLAttributes) => ( - -); - describe('Mosaic Popover', () => { it('renders the trigger and opens the popup on click', async () => { const user = userEvent.setup(); render( - -
Panel body
-
, + + Open + +
Panel body
+
+
, ); expect(screen.queryByText('Panel body')).not.toBeInTheDocument(); @@ -38,14 +32,32 @@ describe('Mosaic Popover', () => { expect(screen.getByText('Panel body')).toBeInTheDocument(); }); + it('renders a custom trigger through the render prop', () => { + render( + + ( + + Open + + )} + /> + Body + , + ); + + expect(screen.getByRole('link', { name: 'Open' })).toHaveAttribute('aria-haspopup', 'dialog'); + }); + it('carries the mosaic slot classes on the positioner and popup', () => { render( - -
Body
-
, + + Open + Body + , ); expect(document.querySelector('.cl-popover-positioner')).toBeInTheDocument(); @@ -54,12 +66,10 @@ describe('Mosaic Popover', () => { it('defaults the popup to the md size and reflects it as data-size', () => { render( - -
Body
-
, + + Open + Body + , ); expect(document.querySelector('.cl-popover-popup')).toHaveAttribute('data-size', 'md'); @@ -67,32 +77,25 @@ describe('Mosaic Popover', () => { it('reflects an explicit size as data-size', () => { render( - -
Body
-
, + + Open + Body + , ); expect(document.querySelector('.cl-popover-popup')).toHaveAttribute('data-size', 'lg'); }); - it('merges consumer className and style onto a part', () => { + it('merges consumer className and style onto the popup', () => { render( - - - - - Body - - - + Open + + Body + , ); @@ -104,13 +107,13 @@ describe('Mosaic Popover', () => { it('closes via Popover.Close', async () => { const user = userEvent.setup(); render( - -
Body
- Dismiss -
, + + Open + +
Body
+ Dismiss +
+
, ); expect(screen.getByText('Body')).toBeInTheDocument(); @@ -122,9 +125,12 @@ describe('Mosaic Popover', () => { const user = userEvent.setup(); render(
- -
Body
-
+ + Open + +
Body
+
+
outside
, ); @@ -139,9 +145,12 @@ describe('Mosaic Popover', () => { it('closes when the trigger is clicked while open', async () => { const user = userEvent.setup(); render( - -
Body
-
, + + Open + +
Body
+
+
, ); const button = screen.getByRole('button', { name: 'Open' }); @@ -156,13 +165,12 @@ describe('Mosaic Popover', () => { it('names the dialog from aria-label and does not warn', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); render( - -
Body
-
, + + Open + +
Body
+
+
, ); expect(screen.getByRole('dialog', { name: 'Account' })).toBeInTheDocument(); @@ -174,12 +182,12 @@ describe('Mosaic Popover', () => { it('warns when the dialog has no accessible name', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); render( - -
Body
-
, + + Open + +
Body
+
+
, ); await settle(); @@ -190,12 +198,12 @@ describe('Mosaic Popover', () => { it('does not warn when a Popover.Title supplies the name', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); render( - - Account - , + + Open + + Account + + , ); expect(screen.getByRole('dialog', { name: 'Account' })).toBeInTheDocument(); @@ -208,12 +216,8 @@ describe('Mosaic Popover', () => { const ref = React.createRef(); render( - - - - Body - - + Open + Body , ); diff --git a/packages/ui/src/mosaic/components/popover/popover.tsx b/packages/ui/src/mosaic/components/popover/popover.tsx index 2dc25727c39..16bc7b01edf 100644 --- a/packages/ui/src/mosaic/components/popover/popover.tsx +++ b/packages/ui/src/mosaic/components/popover/popover.tsx @@ -1,15 +1,75 @@ 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 type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { sizes, styles } from './popover.styles'; +export type PopoverSize = 'sm' | 'md' | 'lg'; + +export type PopoverRootProps = HeadlessPopoverProps; + +/** + * The headless parts type their props (and the `render` callback's argument) against + * the raw tag props, which carry the non-standard HTML `color` attribute typed + * `string`. Re-typing them through `MosaicComponentProps` drops it, so a `render` + * callback can spread straight into a Mosaic component whose own `color` is a narrow + * variant union. + */ +export type PopoverTriggerProps = MosaicComponentProps<'button'>; +export type PopoverCloseProps = MosaicComponentProps<'button'>; +export type PopoverTitleProps = MosaicComponentProps<'h2'>; +export type PopoverDescriptionProps = MosaicComponentProps<'p'>; + +/** The anchor. Renders a `