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/.claude/skills/mosaic/references/stylex.md b/.claude/skills/mosaic/references/stylex.md index 8ab5bac8569..41b3080b0b1 100644 --- a/.claude/skills/mosaic/references/stylex.md +++ b/.claude/skills/mosaic/references/stylex.md @@ -468,6 +468,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: @@ -526,7 +570,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). diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 7bbe0bbacb8..d54119bbc35 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -37,6 +37,8 @@ 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 62388ba0985..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,6 +92,12 @@ import { meta as organizationProfileProfileSectionMeta, } from '../stories/organization-profile-profile-section.stories'; import { meta as otpMeta } from '../stories/otp.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'; @@ -155,6 +167,20 @@ const inputModule: StoryModule = { meta: inputMeta, Default, Sizes: InputSizes, const dialogComponentModule: StoryModule = { meta: dialogComponentMeta, Default: DialogDefault }; +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, Default: ItemDefault, @@ -221,6 +247,8 @@ export const registry: StoryModule[] = [ dialogComponentModule, headingModule, iconModule, + menuComponentModule, + popoverComponentModule, tabsComponentModule, textModule, // Primitives — alphabetical within the group. 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 new file mode 100644 index 00000000000..007ca04f1fb --- /dev/null +++ b/packages/swingset/src/stories/popover.component.mdx @@ -0,0 +1,195 @@ +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 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 + + + +## 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. + + + + +; +``` + +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 + +```tsx +const [open, setOpen] = useState(false); + + } +> + + 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: `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. + +; +``` + +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 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 + +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[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 | +| --------------------- | -------------- | --------------------------------------------------- | +| `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`) | +| `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. + +`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 new file mode 100644 index 00000000000..c4d2e64097f --- /dev/null +++ b/packages/swingset/src/stories/popover.component.stories.tsx @@ -0,0 +1,152 @@ +/** @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'; + +// 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', + styleEngine: 'stylex', +}; + +const popoverTrigger = (props: Omit, 'color'>) => ( + +); + +export function Default() { + return ( + + + + 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.')} + +
+ ); +} 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/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/components/popover/index.ts b/packages/ui/src/mosaic/components/popover/index.ts new file mode 100644 index 00000000000..c5c65598310 --- /dev/null +++ b/packages/ui/src/mosaic/components/popover/index.ts @@ -0,0 +1,2 @@ +export { Popover } 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 new file mode 100644 index 00000000000..5e2992fc5a2 --- /dev/null +++ b/packages/ui/src/mosaic/components/popover/popover.styles.ts @@ -0,0 +1,23 @@ +import * as stylex from '@stylexjs/stylex'; + +// 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', + }, +}); + +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 new file mode 100644 index 00000000000..4e88a3d575e --- /dev/null +++ b/packages/ui/src/mosaic/components/popover/popover.test.tsx @@ -0,0 +1,222 @@ +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, 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
+
, + ); + + 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 the positioner and popup', () => { + render( + +
Body
+
, + ); + + expect(document.querySelector('.cl-popover-positioner')).toBeInTheDocument(); + expect(document.querySelector('.cl-popover-popup')).toBeInTheDocument(); + }); + + 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( + +
Body
+
, + ); + + 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 () => { + 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('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( + +
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 new file mode 100644 index 00000000000..2425072953e --- /dev/null +++ b/packages/ui/src/mosaic/components/popover/popover.tsx @@ -0,0 +1,181 @@ +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 { floating } from '../floating.styles'; +import { popup as popupStyles, sizes } from './popover.styles'; + +/** + * 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 + * public `.cl-` class and StyleX atoms while the headless part keeps its + * 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) { + 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], + ); + + return ( + + ); + }, +); + +export interface PopoverPopupProps extends React.ComponentPropsWithoutRef { + /** Width of the floating box. */ + size?: PopoverSize; +} + +const Popup = React.forwardRef(function PopoverPopup( + { className, style, size = 'md', ...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. 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; +} + +/** + * 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, + size, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, +}: PopoverProps) { + return ( + + + + {/* + 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} + + + + ); +} + +/** 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.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 643906b40b4..e2653290df5 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -16,9 +16,14 @@ 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'; +export { Popover } from '../components/popover'; +export type { PopoverPopupProps, PopoverProps, PopoverSize } from '../components/popover'; + import { colorVars, durationVars,