From 636dd7ea051497d1e38a7e7858d612a2daacf5ce Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 24 Jul 2026 09:55:43 -0400 Subject: [PATCH 1/5] 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 2/5] 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 5bce752d486c096912c290c71b9eac2a663fa52a Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 30 Jul 2026 15:19:32 -0600 Subject: [PATCH 3/5] 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. 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. The positioner and popup rules move to a shared floating.styles.ts so other floating surfaces can reuse them, and use the motion theme tokens — `--cl-duration-base`, `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. --- .../src/mosaic/components/floating.styles.ts | 63 +++++ .../ui/src/mosaic/components/popover/index.ts | 2 +- .../components/popover/popover.styles.ts | 81 ++----- .../components/popover/popover.test.tsx | 222 +++++++++++++----- .../src/mosaic/components/popover/popover.tsx | 134 ++++++++--- packages/ui/src/mosaic/styles/index.ts | 2 +- 6 files changed, 335 insertions(+), 169 deletions(-) create mode 100644 packages/ui/src/mosaic/components/floating.styles.ts diff --git a/packages/ui/src/mosaic/components/floating.styles.ts b/packages/ui/src/mosaic/components/floating.styles.ts new file mode 100644 index 00000000000..61a4780d3ec --- /dev/null +++ b/packages/ui/src/mosaic/components/floating.styles.ts @@ -0,0 +1,63 @@ +import * as stylex from '@stylexjs/stylex'; + +import { durationVars, easingVars } from '../tokens.stylex'; + +/** + * Styles shared by every Mosaic floating surface (Popover, Menu). They cover only + * what it means to float: stacking, viewport clamps, and the enter/exit transition. + * + * The transition works for both components because the popover and menu primitives + * drive their popups from the same `useTransition` hook, so both emit + * `data-starting-style` / `data-ending-style` on the same element. + * + * Chrome is deliberately absent. Whether a popup paints a surface is per component: + * a Menu's popup *is* its surface, while a Popover's stays transparent so the + * arbitrary content inside it (usually a `Card`) owns the border and background. + */ +export const floating = 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 floating box. A single `width: 100%` child stretches via the column flex box. + popup: { + outline: 'none', + 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: 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)': 'opacity', + }, + // 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)', + minHeight: 0, + }, +}); 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..5e2992fc5a2 100644 --- a/packages/ui/src/mosaic/components/popover/popover.styles.ts +++ b/packages/ui/src/mosaic/components/popover/popover.styles.ts @@ -1,68 +1,23 @@ 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', +// The popover's floating box is `floating.popup` unchanged — chrome-free by design, +// since the surface comes from whatever is rendered inside (usually a `Card`). Only +// the width scale is popover-specific. +// +// `md` reproduces the width the legacy `PopoverCard` uses (`theme.sizes.$94`), so +// popovers migrating onto Mosaic keep their current footprint. +// Kept out of `floating.styles.ts` on purpose: this is a content decision, not a +// floating one. A popover holds prose — an email, an org slug, an API key — and a +// long unbroken string would otherwise push past the width clamp. A menu wants the +// opposite for its labels, so it does not inherit this. +export const popup = stylex.create({ + base: { + overflowWrap: 'anywhere', }, +}); - // Footer region, visually separated from content by a top border. - footer: { - padding: space['4'], - borderTopColor: colorVars['--cl-color-border'], - borderTopStyle: 'solid', - borderTopWidth: '1px', - }, +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..2425072953e 100644 --- a/packages/ui/src/mosaic/components/popover/popover.tsx +++ b/packages/ui/src/mosaic/components/popover/popover.tsx @@ -5,12 +5,18 @@ import type { ReactNode } from 'react'; import React from 'react'; import { mergeStyleProps, themeProps } from '../../props'; -import { styles } from './popover.styles'; +import { floating } from '../floating.styles'; +import { popup as popupStyles, sizes } 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 +24,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 +113,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 +139,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 +176,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 664bf1ea54fbc34908dc2fdba5c4fb1e3a78f613 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 30 Jul 2026 15:22:10 -0600 Subject: [PATCH 4/5] feat(ui): add Mosaic Menu built on the headless menu primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A styled menu over @clerk/headless/menu, inheriting its roving focus, typeahead, nested submenus, and role="menu" / role="menuitem" wiring. Deliberately not built on Popover. A menu's list semantics — arrow-key navigation, typeahead, the submenu tree — have no equivalent in a role="dialog" popover, and Popover.Positioner throws outside a Popover.Root, so the styled parts cannot be reused across the two. What they do share is the floating box, now in floating.styles.ts: stacking, viewport clamps, and the enter/exit transition. The chrome-free rule does not carry over. A menu's popup is its surface, so it owns background, border, radius, shadow and inset padding, sizes to its content with a floor rather than a fixed width scale, and scrolls itself. Rows reuse the Item geometry so a menu row and a list row are the same shape. Only the background lives here, because a row highlights on data-active — the roving-focus position — as well as on hover, and StyleX merges by property, so one declaration has to cover every state. Rows take the coarse-pointer target floor while staying compact for a mouse. Menu.SubTrigger opens a nested submenu. It exists because a nested Menu.Trigger already registers itself as a menuitem in the parent list, so wrapping a Menu.Item in one would register a second entry and desync arrow-key navigation from what is on screen. Menu.Item falls back to rendering its `label` when given no children; the primitive keeps them separate because `label` drives typeahead, but they are almost always the same string. --- .../ui/src/mosaic/components/menu/index.ts | 2 + .../src/mosaic/components/menu/menu.styles.ts | 76 +++++++ .../src/mosaic/components/menu/menu.test.tsx | 208 ++++++++++++++++++ .../ui/src/mosaic/components/menu/menu.tsx | 161 ++++++++++++++ packages/ui/src/mosaic/styles/index.ts | 2 + 5 files changed, 449 insertions(+) create mode 100644 packages/ui/src/mosaic/components/menu/index.ts create mode 100644 packages/ui/src/mosaic/components/menu/menu.styles.ts create mode 100644 packages/ui/src/mosaic/components/menu/menu.test.tsx create mode 100644 packages/ui/src/mosaic/components/menu/menu.tsx diff --git a/packages/ui/src/mosaic/components/menu/index.ts b/packages/ui/src/mosaic/components/menu/index.ts new file mode 100644 index 00000000000..9542c0dcd4b --- /dev/null +++ b/packages/ui/src/mosaic/components/menu/index.ts @@ -0,0 +1,2 @@ +export { Menu } from './menu'; +export type { MenuItemProps, MenuProps } from './menu'; diff --git a/packages/ui/src/mosaic/components/menu/menu.styles.ts b/packages/ui/src/mosaic/components/menu/menu.styles.ts new file mode 100644 index 00000000000..6a7075990af --- /dev/null +++ b/packages/ui/src/mosaic/components/menu/menu.styles.ts @@ -0,0 +1,76 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, radiusVars, space, targetVars } from '../../tokens.stylex'; + +// Unlike the popover, a menu's popup *is* its surface — there is no inner card to +// paint the chrome, so the popup owns background, border, radius and shadow. Width +// is intrinsic (menus size to their longest label) with a floor, rather than the +// popover's fixed size scale. +export const popup = stylex.create({ + base: { + padding: space['1'], + borderColor: colorVars['--cl-color-border'], + borderRadius: radiusVars['--cl-radius-container'], + borderStyle: 'solid', + borderWidth: '1px', + backgroundColor: colorVars['--cl-color-card'], + boxShadow: '0 10px 30px rgba(0, 0, 0, 0.12)', + color: colorVars['--cl-color-card-foreground'], + minWidth: '12rem', + overflowY: 'auto', + }, +}); + +const hoverBackground = `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`; +const pressedBackground = `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 8%, transparent)`; + +// Rows reuse `item.styles.ts` for geometry and typography; only the background +// belongs here, because a menu row highlights on `data-active` (the roving-focus +// position the primitive moves with the arrow keys) as well as on hover. StyleX +// merges by property, so a single `backgroundColor` declaration has to cover every +// state — splitting it across two style objects would drop whichever lost. +export const item = stylex.create({ + base: { + backgroundColor: { + default: null, + ':where([data-active])': hoverBackground, + ':active': pressedBackground, + '@media (hover: hover)': { ':hover:not(:active)': hoverBackground }, + }, + cursor: 'pointer', + // A row is a control, so its hit area takes the coarse-pointer floor even though + // the row itself is deliberately compact. + minHeight: { default: null, '@media (pointer: coarse)': targetVars['--cl-target-coarse'] }, + }, + disabled: { + backgroundColor: null, + cursor: 'default', + opacity: 0.5, + }, +}); + +// A nested submenu's trigger is a row like any other, but it never receives +// `data-active` — the primitive only maps `open` on a trigger. It stays highlighted +// while its submenu is open instead, so `data-open` joins the same declaration. +export const subTrigger = stylex.create({ + base: { + backgroundColor: { + default: null, + ':where([data-active], [data-open])': hoverBackground, + ':active': pressedBackground, + '@media (hover: hover)': { ':hover:not(:active)': hoverBackground }, + }, + cursor: 'pointer', + }, +}); + +export const separator = stylex.create({ + base: { + borderStyle: 'none', + marginBlock: space['1'], + marginInline: `calc(${space['1']} * -1)`, + backgroundColor: colorVars['--cl-color-border-faded'], + blockSize: '1px', + flexShrink: 0, + }, +}); diff --git a/packages/ui/src/mosaic/components/menu/menu.test.tsx b/packages/ui/src/mosaic/components/menu/menu.test.tsx new file mode 100644 index 00000000000..7ff84b79dcf --- /dev/null +++ b/packages/ui/src/mosaic/components/menu/menu.test.tsx @@ -0,0 +1,208 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { Menu } from './menu'; + +afterEach(() => cleanup()); + +const trigger = (props: React.HTMLAttributes) => ( + +); + +describe('Mosaic Menu', () => { + it('renders the trigger and opens the menu on click', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Actions' })); + + expect(screen.getByRole('menu')).toBeInTheDocument(); + }); + + it('exposes menu semantics rather than the popover dialog role', () => { + render( + + + + , + ); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.getByRole('menu')).toBeInTheDocument(); + expect(screen.getAllByRole('menuitem')).toHaveLength(2); + }); + + it('falls back to the label when an item has no children', () => { + render( + + + , + ); + + expect(screen.getByRole('menuitem', { name: 'Duplicate' })).toBeInTheDocument(); + }); + + it('renders children over the label when both are supplied', () => { + render( + + Delete account + , + ); + + expect(screen.getByRole('menuitem', { name: 'Delete account' })).toBeInTheDocument(); + }); + + it('carries the mosaic slot classes on the positioner, popup, and item', () => { + render( + + + + , + ); + + expect(document.querySelector('.cl-menu-positioner')).toBeInTheDocument(); + expect(document.querySelector('.cl-menu-popup')).toBeInTheDocument(); + expect(document.querySelector('.cl-menu-separator')).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: 'Edit' })).toHaveClass('cl-menu-item'); + }); + + it('marks disabled items with aria-disabled and a data attribute, and skips their onClick', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render( + + + , + ); + + const item = screen.getByRole('menuitem', { name: 'Delete' }); + expect(item).toHaveAttribute('aria-disabled', 'true'); + expect(item).toHaveAttribute('data-disabled'); + + await user.click(item); + expect(onClick).not.toHaveBeenCalled(); + }); + + it('moves roving focus with the arrow keys', async () => { + const user = userEvent.setup(); + render( + + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Actions' })); + await user.keyboard('{ArrowDown}'); + + expect(screen.getByRole('menuitem', { name: 'Edit' })).toHaveFocus(); + + await user.keyboard('{ArrowDown}'); + + expect(screen.getByRole('menuitem', { name: 'Duplicate' })).toHaveFocus(); + }); + + it('fires onClick and closes the menu when an item is activated', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render( + + + , + ); + + await user.click(screen.getByRole('menuitem', { name: 'Edit' })); + + expect(onClick).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + }); + + it('registers a submenu trigger as a single menuitem in the parent list', () => { + render( + + + + + + + + Share + + + + + + + + + + + + , + ); + + // Two rows on screen, so exactly two `menuitem`s — the submenu trigger must not + // register itself twice, or arrow-key navigation desyncs from what is visible. + const items = screen.getAllByRole('menuitem'); + expect(items).toHaveLength(2); + expect(items[1]).toHaveTextContent('Share'); + expect(items[1]).toHaveClass('cl-menu-sub-trigger'); + }); + + it('merges consumer className and style onto a part', () => { + render( + + + , + ); + + const item = screen.getByRole('menuitem', { name: 'Edit' }); + expect(item).toHaveClass('cl-menu-item', 'my-item'); + expect(item).toHaveStyle({ marginTop: '8px' }); + }); +}); diff --git a/packages/ui/src/mosaic/components/menu/menu.tsx b/packages/ui/src/mosaic/components/menu/menu.tsx new file mode 100644 index 00000000000..22332d2177f --- /dev/null +++ b/packages/ui/src/mosaic/components/menu/menu.tsx @@ -0,0 +1,161 @@ +import type { MenuProps as HeadlessMenuProps } from '@clerk/headless/menu'; +import { Menu as Primitive } from '@clerk/headless/menu'; +import * as stylex from '@stylexjs/stylex'; +import type { ReactNode } from 'react'; +import React from 'react'; + +import { mergeStyleProps, themeProps } from '../../props'; +import { floating } from '../floating.styles'; +import { item as itemSlots } from '../item/item.styles'; +import * as styles from './menu.styles'; + +/** + * Mosaic Menu: a list of actions anchored to a trigger, built on the + * `@clerk/headless` menu primitive. + * + * Distinct from `Popover` on purpose. A menu is `role="menu"` with roving focus, + * typeahead, and nested submenus; a popover is a `role="dialog"` holding arbitrary + * content. They share only the floating box (`floating.styles.ts`) — stacking, + * viewport clamps, and the enter/exit transition. + * + * Rows reuse the `Item` geometry so a menu row and a list row are the same shape. + */ + +const Positioner = React.forwardRef>( + function MenuPositioner({ className, style, ...rest }, ref) { + return ( + + ); + }, +); + +const Popup = React.forwardRef>( + function MenuPopup({ className, style, ...rest }, ref) { + return ( + + ); + }, +); + +export interface MenuItemProps extends React.ComponentPropsWithoutRef { + /** Row text. Also drives typeahead matching, so it is required. */ + label: string; +} + +/** + * A single action. Falls back to rendering `label` when no children are supplied — + * the primitive keeps the two separate because `label` feeds typeahead, but the + * common case is that they are the same string. + */ +const Item = React.forwardRef(function MenuItem( + { className, style, children, label, disabled, ...rest }, + ref, +) { + return ( + + {children ?? label} + + ); +}); + +/** + * Trigger for a nested submenu, styled as a row. Use this instead of wrapping a + * `Menu.Item` in a nested `Menu.Trigger` — the trigger already registers itself as a + * `menuitem` in the parent list, so nesting an `Item` inside it would register a + * second entry and desync arrow-key navigation from what is on screen. + */ +const SubTrigger = React.forwardRef>( + function MenuSubTrigger({ className, style, ...rest }, ref) { + return ( + + ); + }, +); + +const Separator = React.forwardRef>( + function MenuSeparator({ className, style, ...rest }, ref) { + return ( + + ); + }, +); + +export interface MenuProps extends Pick< + HeadlessMenuProps, + 'open' | 'defaultOpen' | 'onOpenChange' | 'placement' | 'sideOffset' +> { + /** Rendered as the menu's anchor. Receives the trigger's props and open state. */ + trigger: React.ComponentProps['render']; + /** Menu contents. Compose `Menu.Item` and `Menu.Separator`. */ + children: ReactNode; +} + +/** + * Convenience composition: trigger + portalled, positioned popup. For nested + * submenus, compose the parts directly and nest a `Menu.Root` inside a popup. + */ +export function Menu({ trigger, children, open, defaultOpen, onOpenChange, placement, sideOffset }: MenuProps) { + return ( + + + + + {children} + + + + ); +} + +/** Compound parts for custom menu layouts, including nested submenus. */ +Menu.Root = Primitive.Root; +Menu.Trigger = Primitive.Trigger; +Menu.Portal = Primitive.Portal; +Menu.Positioner = Positioner; +Menu.Popup = Popup; +Menu.SubTrigger = SubTrigger; +Menu.Item = Item; +Menu.Separator = Separator; diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index fbdb85a8113..e2653290df5 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -16,6 +16,8 @@ export { Icon } from '../components/icon'; export type { IconProps } from '../components/icon'; export { Item } from '../components/item'; export type { ItemProps } from '../components/item'; +export { Menu } from '../components/menu'; +export type { MenuItemProps, MenuProps } from '../components/menu'; export { Text, TextContext } from '../components/text'; export type { TextProps } from '../components/text'; From 634c3daad98cb074d23143fd85bb3d21e2a90a6a Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 30 Jul 2026 15:22:22 -0600 Subject: [PATCH 5/5] docs(swingset): document the Mosaic popover and menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Menu page and rewrites the Popover page for the surface split. The popover examples now compose a Card inside the popover, making that the canonical shape so the docs supply the consistency the primitive no longer enforces on its own. Adds placement and alignment demos, an accessible-name section, 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 Menu page documents the list semantics the popover does not have, and explains when to reach for each. Both story files move onto the remapped Button API. The trigger render props are 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. --- .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 28 +++- .../swingset/src/stories/menu.component.mdx | 137 ++++++++++++++++ .../src/stories/menu.component.stories.tsx | 83 ++++++++++ .../src/stories/popover.component.mdx | 152 +++++++++++++----- .../src/stories/popover.component.stories.tsx | 146 +++++++++++++++-- 6 files changed, 493 insertions(+), 54 deletions(-) create mode 100644 packages/swingset/src/stories/menu.component.mdx create mode 100644 packages/swingset/src/stories/menu.component.stories.tsx diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index a552b225c1d..d54119bbc35 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -37,6 +37,7 @@ const docModules: Record> = { dialog: dynamic(() => import('../stories/dialog.component.mdx')), heading: dynamic(() => import('../stories/heading.mdx')), icon: dynamic(() => import('../stories/icon.mdx')), + menu: dynamic(() => import('../stories/menu.component.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 6c9b860273e..29755b928fe 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -52,6 +52,12 @@ import { Interactive as ItemInteractive, meta as itemMeta, } from '../stories/item.stories'; +import { + Default as MenuComponentDefault, + Disabled as MenuComponentDisabled, + meta as menuComponentMeta, + Submenu as MenuComponentSubmenu, +} from '../stories/menu.component.stories'; import { meta as menuMeta } from '../stories/menu.stories'; import { Default as OrganizationProfileDefault, @@ -86,7 +92,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 +167,19 @@ const inputModule: StoryModule = { meta: inputMeta, Default, Sizes: InputSizes, const dialogComponentModule: StoryModule = { meta: dialogComponentMeta, Default: DialogDefault }; -const popoverComponentModule: StoryModule = { meta: popoverComponentMeta, Default: PopoverComponentDefault }; +const menuComponentModule: StoryModule = { + meta: menuComponentMeta, + Default: MenuComponentDefault, + Disabled: MenuComponentDisabled, + Submenu: MenuComponentSubmenu, +}; + +const popoverComponentModule: StoryModule = { + meta: popoverComponentMeta, + Default: PopoverComponentDefault, + Placement: PopoverComponentPlacement, + Alignment: PopoverComponentAlignment, +}; const itemModule: StoryModule = { meta: itemMeta, @@ -224,6 +247,7 @@ export const registry: StoryModule[] = [ dialogComponentModule, headingModule, iconModule, + menuComponentModule, popoverComponentModule, tabsComponentModule, textModule, diff --git a/packages/swingset/src/stories/menu.component.mdx b/packages/swingset/src/stories/menu.component.mdx new file mode 100644 index 00000000000..598076abc4f --- /dev/null +++ b/packages/swingset/src/stories/menu.component.mdx @@ -0,0 +1,137 @@ +import * as MenuStories from './menu.component.stories'; + +# Menu + +The Mosaic `Menu` — the styled Mosaic component composed from the `@clerk/headless` menu primitive +and themed with StyleX. It inherits the primitive's roving focus, typeahead, nested submenus, and +`role="menu"` / `role="menuitem"` wiring, and adds the surface and row styling. + +Reach for `Menu` when the content is a list of actions. Reach for [`Popover`](/components/popover) +when it is arbitrary content — a popover is a `role="dialog"` and has none of the list semantics. + +## Example + + + +## Usage + +`label` is required on every item: it drives typeahead matching. When an item has no children it +renders its `label`, so the common case stays a single prop. + +```tsx +import { Button } from '@clerk/ui/mosaic/components/button'; +import { Menu } from '@clerk/ui/mosaic/components/menu'; + + }> + + + + +; +``` + +### Disabled items + +Disabled items keep `role="menuitem"` and stay focusable — they use `aria-disabled`, not the +`disabled` attribute, so keyboard users can still reach and read them. They are excluded from +typeahead and their `onClick` never fires. + + + +### Submenus + +Nest a `Menu.Root` inside a popup and open it with `Menu.SubTrigger`. Submenus open on hover with a +safe-polygon pointer zone, and `ArrowRight` / `ArrowLeft` move in and out of them. + +Use `Menu.SubTrigger` rather than wrapping a `Menu.Item` in a nested `Menu.Trigger` — the trigger +already registers itself as a `menuitem` in the parent list, so nesting an `Item` inside it would +register a second entry and desync arrow-key navigation from what is on screen. + + + +### Controlled + +```tsx +const [open, setOpen] = useState(false); + + } +> + +; +``` + +## Parts + +The convenience `Menu` composes the trigger and a portalled, positioned popup. For submenus, compose +the compound parts directly. + +| Part | Slot | Description | +| ----------------- | ------------------ | ---------------------------------------------------------- | +| `Menu.Root` | — | State provider; owns open/close and placement. | +| `Menu.Trigger` | — | Anchor element; accepts a `render` prop. | +| `Menu.Portal` | — | Portals the popup out to the document body. | +| `Menu.Positioner` | `menu-positioner` | Floating wrapper; owns positioning, stacking, `data-side`. | +| `Menu.Popup` | `menu-popup` | The menu surface; `role="menu"`. | +| `Menu.Item` | `menu-item` | A single action; `role="menuitem"`. `label` is required. | +| `Menu.SubTrigger` | `menu-sub-trigger` | Row that opens a nested submenu. | +| `Menu.Separator` | `menu-separator` | Divider between groups of items; `role="separator"`. | + +## Styling + +The Mosaic menu is themed with **StyleX**. Each styled part carries a stable `.cl-` class +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-menu-popup { + min-width: 16rem; + } +} +``` + +Unlike the popover — whose popup is transparent so the content inside supplies the surface — a +menu's popup **is** its surface, so it owns the background, border, radius, shadow, and inset +padding. The two components share only the floating box: stacking, viewport clamps, and the +enter/exit transition (`mosaic/components/floating.styles.ts`). + +Rows reuse the `Item` geometry, so a menu row and a list row are the same shape. + +State attributes from the headless layer are available for CSS targeting: + +| Attribute | Applies To | Description | +| --------------------- | ------------------- | --------------------------------------------------- | +| `data-open` | Trigger, SubTrigger | Present when the menu it controls is open | +| `data-closed` | Trigger, SubTrigger | Present when closed | +| `data-active` | Item | The roving-focus position, moved by the arrow keys | +| `data-disabled` | Item | Present on disabled items | +| `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`) | + +Rows highlight on `data-active` as well as on hover, so keyboard and pointer navigation look the +same. The popup's 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/menu.component.stories.tsx b/packages/swingset/src/stories/menu.component.stories.tsx new file mode 100644 index 00000000000..b0a0a617bef --- /dev/null +++ b/packages/swingset/src/stories/menu.component.stories.tsx @@ -0,0 +1,83 @@ +/** @jsxImportSource @emotion/react */ +import { Button } from '@clerk/ui/mosaic/components/button'; +import { Menu } from '@clerk/ui/mosaic/components/menu'; + +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 './menu.component.stories?raw'; + +export const meta: StoryMeta = { + group: 'Components', + title: 'Menu', + source: 'packages/ui/src/mosaic/components/menu/menu.tsx', + styleEngine: 'stylex', +}; + +const menuTrigger = (props: Omit, 'color'>) => ( + +); + +export function Default() { + return ( +
+ + + + + + +
+ ); +} + +export function Disabled() { + return ( +
+ + + + + +
+ ); +} + +export function Submenu() { + return ( +
+ + + + + + + + Share + + + + + + + + + + + + + + + +
+ ); +} 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.')} + +
+ ); +}