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/SKILL.md b/.claude/skills/mosaic/SKILL.md index fd0bb8db9dd..80165faf255 100644 --- a/.claude/skills/mosaic/SKILL.md +++ b/.claude/skills/mosaic/SKILL.md @@ -46,6 +46,7 @@ this skill is the _how-to_. | -------------------------------------------------------------------- | ------------------------------------------------------ | | Building on / authoring a headless primitive (`@clerk/headless`) | `references/headless.md` | | Styling a component with StyleX (tokens, `stylex.create`, CSS build) | `references/stylex.md` | +| Building an enter/exit transition, or any motion that reads as wrong | `references/motion.md` | | Styling a component the legacy way (slot recipes, `useRecipe`) | `references/styling.md` | | Authoring or debugging a state machine, or wiring one to React | `references/machines.md` → in-tree `machine/README.md` | | Writing the controller (Clerk adapter, permissions, revalidate) | `references/controllers.md` | diff --git a/.claude/skills/mosaic/references/motion.md b/.claude/skills/mosaic/references/motion.md new file mode 100644 index 00000000000..6ac66dc32fa --- /dev/null +++ b/.claude/skills/mosaic/references/motion.md @@ -0,0 +1,284 @@ +# Motion: entrances and exits + +Token semantics live in `packages/ui/src/mosaic/tokens.stylex.ts`, above +`durationDefaults` / `easingDefaults` — read those comments first. This file is the +how-to layer: the rules that decide a transition's shape, and how to check one +rather than eyeball it. + +| Token | Value | For | +| ----------------------- | --------------------------------------- | --------------------------- | +| `--cl-duration-instant` | `0s` | press feedback | +| `--cl-duration-fast` | `0.1s` | exits, hover | +| `--cl-duration-base` | `0.15s` | entrances | +| `--cl-duration-slow` | `0.25s` | larger surfaces | +| `--cl-duration-slower` | `0.35s` | — | +| `--cl-ease-default` | `cubic-bezier(0.175, 0.885, 0.32, 1.1)` | things ARRIVING (Swift Out) | +| `--cl-ease-exit` | `cubic-bezier(0.55, 0.085, 0.68, 0.53)` | things LEAVING (In Quad) | + +Named curves come from [easing.dev](https://www.easing.dev) (Lochie Axon's Easing +Graphs). Take one from there rather than inventing a bezier, so the catalog stays +the shared vocabulary. + +## A curve has a direction — don't run the entrance curve backwards + +The single most common motion bug in this codebase. `--cl-ease-default` is +front-loaded and carries its endpoint ~2% past target before settling: a change +departs fast and lands soft. That is exactly right for an entrance and wrong in +three separate ways for an exit. + +Measured on the Mosaic popover when both directions shared `--cl-ease-default` at +`--cl-duration-base` (scale `1 → 0.94`, 60fps): + +``` +ms scale opacity Δscale/frame +0 1.0000 1.000 — +33.2 0.9718 0.889 -0.0282 ┐ 90% of the shrink, 3 frames +50.7 0.9552 0.778 -0.0166 │ +66.7 0.9465 0.667 -0.0087 ┘ +83.4 0.9419 0.555 -0.0046 +100.2 0.9397 0.445 -0.0022 +117.4 0.9388 0.333 -0.0009 ← dipped BELOW the 0.94 target +133.4 0.9387 0.222 -0.0001 +150.5 0.9392 0.111 +0.0005 ← and came back up +167.4 0.9400 0.000 +0.0008 +186.0 REMOVED +``` + +1. **The velocity dies.** 90% of the travel happens in three frames, then six + frames (~two-thirds of the run) move a combined 0.008. Users report this as + "choppy" or "dropping frames" — and a performance profile will correctly show + zero dropped frames. Every frame renders; they are just nearly identical. +2. **The overshoot inverts.** Progress peaks at 1.023, so the value travels _past_ + the target and returns. Arriving, that reads as settling. Leaving, it is a + wobble with nothing to cover it. +3. **Shape and fade decouple.** Transform finished by ~50ms while a linear opacity + ran the full 150ms, leaving a motionless fading rectangle. + +Use `--cl-ease-exit` on `:where([data-ending-style])`. Same properties, opposite +direction, so both duration and timing function branch: + +```ts +transitionDuration: { + default: `${durationVars['--cl-duration-fast']}, ${durationVars['--cl-duration-base']}`, + ':where([data-ending-style])': durationVars['--cl-duration-fast'], +}, +transitionTimingFunction: { + default: `linear, ${easingVars['--cl-ease-default']}`, + ':where([data-ending-style])': `linear, ${easingVars['--cl-ease-exit']}`, +}, +``` + +Both lists are **positional against `transitionProperty`** (`opacity, transform`). + +## Asymmetry, in three places + +**Duration.** Exits are shorter than entrances — an arrival earns a moment to +settle, a dismissal is an acknowledgement. `--cl-duration-base` in, +`--cl-duration-fast` out, a 1.5:1 ratio. Stay on the duration scale; a ratio that +needs an off-scale value is not worth a new token for one component. + +**Opacity leads on the way in.** Give opacity the shorter duration so it lands +opaque as the transform reaches full size, and the settle plays at full strength. +A popup still fading while it moves reads as washed out. On the popover this moved +opacity-at-overshoot-peak from 0.78 to 1.00. + +**Exits land together.** Do _not_ split them going out. Matching durations are +what stop an exit reading as a lingering ghost. + +## Small deltas constrain the curve (the dead-frame test) + +A transition's usable curves depend on how much it actually moves. A scale delta +of 0.06 over 100ms is six frames; a sharply back-loaded curve puts half of them +below the threshold of visible change and re-creates the stall-then-lurch above. +Scored for that exit — _dead_ = frames moving <0.003, _ramp_ = largest frame step +÷ smallest: + +| curve | dead | ramp | +| --------------------------------------- | ---- | ----- | +| linear | 0 | 1.0× | +| In `(0.42, 0, 1, 1)` — the CSS keyword | 1 | 5.9× | +| **In Quad `(0.55, 0.085, 0.68, 0.53)`** | 1 | 6.6× | +| In Cubic `(0.55, 0.055, 0.675, 0.19)` | 2 | 17.9× | +| In Quart `(0.895, 0.03, 0.685, 0.22)` | 3 | >50× | +| In Circ `(0.6, 0.04, 0.98, 0.335)` | 2 | 32.0× | + +Hence In Quad, the gentlest of the in-family. Counterintuitively, a **longer** +duration makes this worse, not better: the delta is fixed, so more frames means +smaller steps and more of them below threshold (In Cubic goes from 2 dead frames +at 100ms to 4 at 150ms). If a curve stalls, shorten the duration or increase the +delta — don't stretch it. + +## `transform-origin`: anchor it to the trigger + +For anything anchored to a trigger, scale about the **trigger**, not the element's +own center, so it reads as emerging from what opened it. `cssVars` in +`packages/headless/src/utils/css-vars.ts` emits two origins on the floating +element; custom properties inherit, so a popup one level down reads them directly. + +| var | meaning | +| ----------------------- | -------------------------------------------------------------- | +| `--cl-transform-origin` | nearest **edge**, cross axis tracking the anchor (arrow-aware) | +| `--cl-anchor-origin` | the anchor's bounding-box **center**, both axes | + +```ts +transformOrigin: 'var(--cl-anchor-origin, center)', +``` + +**Do not redefine `--cl-transform-origin`** — Menu branches consume it with the +edge semantics. Add a var instead. + +Why a var and not a keyword: keyword origins (`top left`) anchor to the element's +own box, so they drift off the trigger the moment `shift()` or `flip()` moves it. +These are recomputed per position update and stay correct. + +**Timing is safe.** On a cold mount the var is unset for the mutation frame — but +the element is `opacity: 0` with `transition: none` then. Both the position and +the var settle by rAF 1; the transition arms at rAF 2. Origin is always correct +before anything animates. + +**Geometry.** Travel = `(1 − startScale) × distance(origin, element center)`, so +origin and start scale must be chosen together — at `scale(0.98)` a trigger-center +origin moves ~2px and is invisible; the popover uses `0.94` (~6px). Note the +distance, not the trigger's size, is what matters: on a centered placement the +trigger's center sits directly above/below the popup's, so trigger width cancels +out entirely no matter how wide it is. The one bad case is a **wide trigger with a +much narrower `-start`/`-end` popup**; matching the popup's width to the trigger +puts the origin back on its center. + +### Hold the travel constant, not the scale + +**~6px of travel is the target.** It reads as emerging from the trigger without +becoming a visible arc. The popover hits it at `0.94` because its origin sits +~103px from the popup's center. + +Scale is the dial, not the constant. Because travel is `(1 − s) × d`, a taller +popup pushes its own center further from the trigger, `d` grows, and the same +`0.94` overshoots the target — a large surface swinging 15px reads as +overexaggerated. Solve for the scale instead: + +``` +s = 1 − (6 / d) d = distance from --cl-anchor-origin to the element's center +``` + +| `d` | scale | +| ----- | ------ | +| 60px | `0.90` | +| 100px | `0.94` | +| 150px | `0.96` | +| 200px | `0.97` | +| 300px | `0.98` | + +Two effects push the same way, which is convenient: the absolute size change is +`(1 − s) ×` the element's own dimensions, so a big surface at a fixed scale is +already shrinking by more px than a small one. Scaling toward 1 as things grow +fixes both at once. + +Three limits on the rule: + +- **Floor the scale around `0.90`.** For an element whose center is very close to + its origin, the formula demands an aggressive scale to manufacture 6px — at + `d = 30px` it asks for `0.80`, which reads as a zoom, not an emergence. Accept + less travel rather than a scale that draws attention to itself. +- **Travel is measured at the element's center, by convention.** Scaling about a + point moves every other point in proportion to _its own_ distance from that + origin, so the far edge always travels further than the center and the near edge + barely moves. Keep the center as the yardstick so numbers stay comparable. +- **Content-driven height makes this an estimate.** A popover's width comes from + its `size` variant but its height comes from whatever is inside it, so `d` is + only known at runtime. Pick the scale for the typical height of that surface and + accept the spread; a component whose height genuinely varies by multiples wants + a scale per size variant, not one constant. + +## Reduced motion + +Gate the **moving property**, not the duration — the signal is about vestibular +safety, so the fade should survive: + +```ts +transitionProperty: { + default: 'opacity, transform', + '@media (prefers-reduced-motion: reduce)': 'opacity', +}, +``` + +With a positional duration list, the collapsed single-property list takes the +first value — check that it's the one you want for opacity. + +### That alone is not enough — also drop the value + +Removing `transform` from `transitionProperty` stops it _animating_; it does not +stop it _changing_. The `data-starting-style` / `data-ending-style` branch still +applies `scale(0.94)`, now instantly. Entering that is invisible (it happens at +`opacity: 0`), so this reads as correct in review and in a diff — but **exiting**, +the element snaps to 94% at full opacity and then fades. The reported symptom is +"reduced motion still animates, but only on the way out." + +Restate the state branch **inside** the media query. Note the nesting direction: +StyleX only accepts at-rule outer / pseudo inner, so `:where(...)` containing an +`@media` key fails `@stylexjs/valid-styles` ("Invalid Pseudo class or At Rule used +for conditional style value"). + +```ts +transform: { + default: 'scale(1)', + ':where([data-starting-style], [data-ending-style])': 'scale(0.94)', + '@media (prefers-reduced-motion: reduce)': { + default: 'scale(1)', + ':where([data-starting-style], [data-ending-style])': 'scale(1)', + }, +}, +``` + +A bare sibling `'@media (prefers-reduced-motion: reduce)': 'scale(1)'` also works +today, but it compiles to `(0,2,0)` — the same as the branch it needs to beat — so +the tiebreak is source order, which `@stylexjs/sort-keys` reorders on autofix. +Repeating the selector inside the at-rule earns a third class and wins outright: + +```css +.a.a:where([data-starting-style], [data-ending-style]) { + transform: scale(0.94); +} /* 0,2,0 */ +@media (prefers-reduced-motion: reduce) { + .b.b.b:where([data-starting-style], [data-ending-style]) { + transform: scale(1); + } /* 0,3,0 */ +} +``` + +Verify all four combinations — enter and exit, reduced and normal. Under reduced +motion both directions should hold the scale flat at `1.0000` for every frame. + +## How to check a transition + +Reading CSS will not tell you a transition stalls. Two cheap techniques: + +**Score the curve offline** before writing it — sample `cubic-bezier` at 16.7ms +intervals across the duration, convert to the property's real values, and count +frames whose step is below ~0.003 of the total delta. + +**Record the real thing** with a rAF sampler (see `references/stylex.md` for the +`data-*` attributes that drive enter/exit): + +```js +const el = document.querySelector('.cl-popover-popup'); +const t0 = performance.now(), + rows = []; +(function tick() { + const e = document.querySelector('.cl-popover-popup'); + if (!e) return console.table(rows); + const cs = getComputedStyle(e); + rows.push({ + ms: +(performance.now() - t0).toFixed(1), + scale: +new DOMMatrix(cs.transform).a.toFixed(4), + opacity: +(+cs.opacity).toFixed(3), + }); + requestAnimationFrame(tick); +})(); +``` + +Gotchas when driving this from a browser-automation CLI: a round-trip outlasts a +150ms transition, so slow it with a `transition-duration` override or pause via +`getAnimations()` and set `currentTime`; pausing at `currentTime = 0` also freezes +opacity at 0, so hold the opacity animation at its end if you want a visible +frame; and light dismiss listens on pointerdown, so a synthetic `.click()` will +not close a popover — send a real key instead. diff --git a/.claude/skills/mosaic/references/stylex.md b/.claude/skills/mosaic/references/stylex.md index 8ab5bac8569..38f86642269 100644 --- a/.claude/skills/mosaic/references/stylex.md +++ b/.claude/skills/mosaic/references/stylex.md @@ -295,6 +295,28 @@ device, while touch devices look correct. then grep `dist-mosaic/styles.css` for the two selectors and compare their specificity. +- **A button that opens something takes the pressed fill while open**, so a + disclosure trigger stays visibly engaged for as long as its surface is. Disclosure + primitives already set `data-open` on the trigger (`popover-trigger.tsx` and + friends), so this is styling-only — no headless change. It needs the _same_ + exclusion as `:active`, or hovering an open trigger lifts it back to the lighter + hover step: + + ```ts + backgroundColor: { + default: 'transparent', + ':enabled:active': neutralStep1, + ':enabled[data-open]': neutralStep1, + '@media (hover: hover)': { + default: null, + ':enabled:hover:not(:active):not([data-open])': neutralStep0, + }, + }, + ``` + + Worked example: `button.styles.ts`, applied across every filled/outline/ghost cell + (`link` opts out — it reads as text, not a control). + Worked example: `packages/ui/src/mosaic/components/button/button.styles.ts`. - **DO** use `:focus-visible` for focus rings (never bare `:focus`). For a @@ -341,6 +363,12 @@ Worked example: `packages/ui/src/mosaic/components/button/button.styles.ts`. drag, and an overshoot extrapolates past the target color for nothing. A transform at `--cl-duration-fast` still wants the curve. +- **DON'T** reuse `--cl-ease-default` for something **leaving**. It is an arrival + curve; run backwards it stalls for most of its duration and its overshoot + becomes a wobble past the target. Departures take `easingVars['--cl-ease-exit']` + at a shorter duration. See `motion.md` — enter/exit asymmetry has its own + reference, with the measurements behind these rules. + - **DO** gate transitions/animations of **motion-bearing** properties on reduced motion — `transform`, `translate`, `scale`, `rotate`, positional insets — in the same object. `prefers-reduced-motion` is a vestibular-safety signal, so color @@ -468,6 +496,61 @@ 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.94)' }, + // Reduced motion drops `transform` and keeps the fade — the gate belongs on the + // moving property, not the whole transition. + transitionProperty: { default: 'opacity, transform', '@media (prefers-reduced-motion: reduce)': 'opacity' }, + // Positional against `transitionProperty`, and branched by direction: the exit is + // shorter and takes the departure curve. See `motion.md`. + transitionDuration: { + default: `${durationVars['--cl-duration-fast']}, ${durationVars['--cl-duration-base']}`, + ':where([data-ending-style])': durationVars['--cl-duration-fast'], + }, + transitionTimingFunction: { + default: `linear, ${easingVars['--cl-ease-default']}`, + ':where([data-ending-style])': `linear, ${easingVars['--cl-ease-exit']}`, + }, + }, + ``` + +- **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: @@ -501,6 +584,39 @@ className left-to-right and merges `style` with the consumer object spread last: - **DON'T** call `stylex.props` twice on one element or spread `{...props}` after the merge result — fuse everything through the one `mergeStyleProps` call. +### Type every part with `MosaicComponentProps` + +`MosaicComponentProps` is the native props for `Tag` minus the non-standard HTML +`color` attribute, plus `render`. It drops `color` from the props **and** from the +`render` callback's argument, so a callback's props spread straight into a Mosaic +component whose own `color` is a variant union (`Button`, `Heading`, `Text`). + +```tsx +export interface PopoverPopupProps extends MosaicComponentProps<'div'> { … } +``` + +- **DON'T** type a Mosaic part with the headless `ComponentProps` (or + `React.ComponentPropsWithoutRef`). Those keep `color: string`, and + every consumer then has to strip it: `props: Omit, 'color'>`. +- **DON'T** re-export a headless part straight onto the Mosaic namespace object + (`Popover.Trigger = Primitive.Trigger`) — that leaks the wide type. Bridge it: + + ```tsx + const Trigger = React.forwardRef>( + function PopoverTrigger(props, ref) { + return ( + + ); + }, + ); + ``` + +- If a consumer needs to annotate a `render` callback, the API is wrong — fix the + part's props type instead. Inline callbacks infer with no annotation. + ## Build & CSS delivery (two contexts, same babel) - **Published** (`build:mosaic` → `@stylexjs/rollup-plugin`): compiles the @@ -526,7 +642,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/headless/src/utils/css-vars.test.ts b/packages/headless/src/utils/css-vars.test.ts index 4b8d70e8c3a..07ddcf01d45 100644 --- a/packages/headless/src/utils/css-vars.test.ts +++ b/packages/headless/src/utils/css-vars.test.ts @@ -238,6 +238,63 @@ describe('cssVars middleware', () => { }); }); + describe('--cl-anchor-origin', () => { + it("keeps both axes of the anchor's center", async () => { + const mw = cssVars({ sideOffset: 8 }); + const state = createMockState({ + placement: 'bottom', + referenceWidth: 100, + referenceHeight: 40, + }); + await mw.fn(state); + + const vars = getVars(state); + // Unlike --cl-transform-origin, the cross axis is the anchor's center (40/2), not + // the floating element's own edge. + expect(vars.get('--cl-anchor-origin')).toBe('50px 20px'); + expect(vars.get('--cl-transform-origin')).toBe('50px -8px'); + }); + + it('is the same point on every side', async () => { + const mw = cssVars({ sideOffset: 8 }); + + for (const placement of ['top', 'bottom', 'left', 'right', 'bottom-end']) { + const state = createMockState({ placement, referenceWidth: 100, referenceHeight: 40 }); + await mw.fn(state); + expect(getVars(state).get('--cl-anchor-origin')).toBe('50px 20px'); + } + }); + + it('is relative to the floating element', async () => { + const mw = cssVars(); + const state = createMockState({ referenceWidth: 100, referenceHeight: 40 }); + // Floating element positioned away from the anchor. + (state as { x: number }).x = 30; + (state as { y: number }).y = 60; + await mw.fn(state); + + const vars = getVars(state); + expect(vars.get('--cl-anchor-origin')).toBe('20px -40px'); + }); + + it('ignores the arrow', async () => { + const mw = cssVars({ sideOffset: 4 }); + const state = createMockState({ + placement: 'bottom', + referenceWidth: 100, + referenceHeight: 40, + arrowX: 50, + arrowElWidth: 12, + }); + await mw.fn(state); + + const vars = getVars(state); + // The arrow moves --cl-transform-origin but not the anchor's own center. + expect(vars.get('--cl-transform-origin')).toBe('56px -4px'); + expect(vars.get('--cl-anchor-origin')).toBe('50px 20px'); + }); + }); + describe('return value', () => { it('returns empty object (no position changes)', async () => { const mw = cssVars(); @@ -247,8 +304,8 @@ describe('cssVars middleware', () => { }); }); - describe('all five CSS vars are set', () => { - it('sets exactly 5 CSS custom properties', async () => { + describe('all six CSS vars are set', () => { + it('sets exactly 6 CSS custom properties', async () => { const mw = cssVars({ sideOffset: 4 }); const state = createMockState({ placement: 'bottom' }); await mw.fn(state); @@ -263,6 +320,7 @@ describe('cssVars middleware', () => { '--cl-available-width', '--cl-available-height', '--cl-transform-origin', + '--cl-anchor-origin', ]); }); }); diff --git a/packages/headless/src/utils/css-vars.ts b/packages/headless/src/utils/css-vars.ts index 200184cba61..b2f51c2259f 100644 --- a/packages/headless/src/utils/css-vars.ts +++ b/packages/headless/src/utils/css-vars.ts @@ -8,6 +8,7 @@ import { detectOverflow, type Middleware } from '@floating-ui/react'; * - `--cl-available-width` – available width between anchor and viewport edge (px) * - `--cl-available-height` – available height between anchor and viewport edge (px) * - `--cl-transform-origin` – CSS transform-origin pointing back toward the anchor + * - `--cl-anchor-origin` – CSS transform-origin at the anchor's own center * * Place **after** `arrow()` so arrow position data is available for transform-origin. */ @@ -48,6 +49,10 @@ export function cssVars(opts?: { sideOffset?: number }): Middleware { // The arrow is the only FloatingArrow descendant carrying data-side. const arrowEl = elements.floating.querySelector('svg[data-side]'); + // The anchor's center, relative to the floating element. + const anchorX = rects.reference.x + rects.reference.width / 2 - state.x; + const anchorY = rects.reference.y + rects.reference.height / 2 - state.y; + let transformX: number; let transformY: number; @@ -57,9 +62,8 @@ export function cssVars(opts?: { sideOffset?: number }): Middleware { transformX = arrowX + arrowEl.clientWidth / 2; transformY = arrowY + arrowEl.clientHeight / 2; } else { - // No arrow — use the anchor's center relative to the floating element - transformX = rects.reference.x + rects.reference.width / 2 - state.x; - transformY = rects.reference.y + rects.reference.height / 2 - state.y; + transformX = anchorX; + transformY = anchorY; } const originMap: Record = { @@ -70,6 +74,10 @@ export function cssVars(opts?: { sideOffset?: number }): Middleware { }; style.setProperty('--cl-transform-origin', originMap[side]); + // Keeps both axes, where `--cl-transform-origin` pins the cross axis to the floating + // element's own edge. Scaling about this point makes the popup travel out of the + // anchor instead of growing in place. + style.setProperty('--cl-anchor-origin', `${anchorX}px ${anchorY}px`); return {}; }, diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 85343f16103..d54119bbc35 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -38,6 +38,7 @@ const docModules: Record> = { 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 627ed708da4..3b1e386414c 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -87,6 +87,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'; @@ -156,6 +162,13 @@ const inputModule: StoryModule = { meta: inputMeta, Default, Sizes: InputSizes, const dialogComponentModule: StoryModule = { meta: dialogComponentMeta, Default: DialogDefault }; +const popoverComponentModule: StoryModule = { + meta: popoverComponentMeta, + Default: PopoverComponentDefault, + Placement: PopoverComponentPlacement, + Alignment: PopoverComponentAlignment, +}; + const itemModule: StoryModule = { meta: itemMeta, Default: ItemDefault, @@ -225,6 +238,7 @@ export const registry: StoryModule[] = [ headingModule, iconModule, menuComponentModule, + 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..08dc13b078a --- /dev/null +++ b/packages/swingset/src/stories/popover.component.mdx @@ -0,0 +1,203 @@ +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 parts: `Popover.Root` owns the open state and placement, `Popover.Trigger` is the +anchor, and `Popover.Popup` is the floating box. Put the surface inside the popup — everything you +see comes from the `Card`. + +```tsx +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. + + + + + +; +``` + +`Popover.Trigger` renders a `
+ + + + {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/button/button.styles.ts b/packages/ui/src/mosaic/components/button/button.styles.ts index 59da5f26632..25e6c7f99f3 100644 --- a/packages/ui/src/mosaic/components/button/button.styles.ts +++ b/packages/ui/src/mosaic/components/button/button.styles.ts @@ -44,6 +44,12 @@ const negativeActive = `color-mix(in oklab, ${colorVars['--cl-color-negative']}, // `@media (hover: hover)` `:hover` would outrank a bare `:active` and win while pressing. // Both selectors are written out per cell rather than hoisted to a const: `@stylexjs/sort-keys` // reads a computed key as its identifier name and fails the ordering. +// +// `[data-open]` takes the pressed fill too, so a button acting as a disclosure trigger stays +// visibly engaged for as long as its surface is open. Disclosure primitives set it on the +// trigger (`popover-trigger.tsx` and friends); a plain button never carries it. It is excluded +// from hover for the same reason `:active` is — otherwise moving the pointer over an open +// trigger would lift it back to the lighter hover step. export const styles = stylex.create({ base: { @@ -133,9 +139,10 @@ export const variants = stylex.create({ backgroundColor: { default: colorVars['--cl-color-primary'], ':enabled:active': primaryActive, + ':enabled[data-open]': primaryActive, '@media (hover: hover)': { default: null, - ':enabled:hover:not(:active)': primaryHover, + ':enabled:hover:not(:active):not([data-open])': primaryHover, }, }, color: colorVars['--cl-color-primary-foreground'], @@ -144,9 +151,10 @@ export const variants = stylex.create({ backgroundColor: { default: neutralStep0, ':enabled:active': neutralStep2, + ':enabled[data-open]': neutralStep2, '@media (hover: hover)': { default: null, - ':enabled:hover:not(:active)': neutralStep1, + ':enabled:hover:not(:active):not([data-open])': neutralStep1, }, }, color: colorVars['--cl-color-neutral-foreground'], @@ -155,9 +163,10 @@ export const variants = stylex.create({ backgroundColor: { default: colorVars['--cl-color-negative'], ':enabled:active': negativeActive, + ':enabled[data-open]': negativeActive, '@media (hover: hover)': { default: null, - ':enabled:hover:not(:active)': negativeHover, + ':enabled:hover:not(:active):not([data-open])': negativeHover, }, }, color: colorVars['--cl-color-negative-foreground'], @@ -171,9 +180,10 @@ export const variants = stylex.create({ backgroundColor: { default: 'transparent', ':enabled:active': neutralStep1, + ':enabled[data-open]': neutralStep1, '@media (hover: hover)': { default: null, - ':enabled:hover:not(:active)': neutralStep0, + ':enabled:hover:not(:active):not([data-open])': neutralStep0, }, }, color: colorVars['--cl-color-primary'], @@ -183,9 +193,10 @@ export const variants = stylex.create({ backgroundColor: { default: 'transparent', ':enabled:active': neutralStep1, + ':enabled[data-open]': neutralStep1, '@media (hover: hover)': { default: null, - ':enabled:hover:not(:active)': neutralStep0, + ':enabled:hover:not(:active):not([data-open])': neutralStep0, }, }, color: colorVars['--cl-color-neutral-foreground'], @@ -195,9 +206,10 @@ export const variants = stylex.create({ backgroundColor: { default: 'transparent', ':enabled:active': neutralStep1, + ':enabled[data-open]': neutralStep1, '@media (hover: hover)': { default: null, - ':enabled:hover:not(:active)': neutralStep0, + ':enabled:hover:not(:active):not([data-open])': neutralStep0, }, }, color: colorVars['--cl-color-negative'], @@ -207,9 +219,10 @@ export const variants = stylex.create({ backgroundColor: { default: 'transparent', ':enabled:active': neutralStep1, + ':enabled[data-open]': neutralStep1, '@media (hover: hover)': { default: null, - ':enabled:hover:not(:active)': neutralStep0, + ':enabled:hover:not(:active):not([data-open])': neutralStep0, }, }, color: colorVars['--cl-color-primary'], @@ -218,9 +231,10 @@ export const variants = stylex.create({ backgroundColor: { default: 'transparent', ':enabled:active': neutralStep1, + ':enabled[data-open]': neutralStep1, '@media (hover: hover)': { default: null, - ':enabled:hover:not(:active)': neutralStep0, + ':enabled:hover:not(:active):not([data-open])': neutralStep0, }, }, color: colorVars['--cl-color-neutral-foreground'], @@ -231,9 +245,10 @@ export const variants = stylex.create({ backgroundColor: { default: 'transparent', ':enabled:active': `color-mix(in oklab, ${colorVars['--cl-color-negative-faded']}, ${colorVars['--cl-color-negative']} 8%)`, + ':enabled[data-open]': `color-mix(in oklab, ${colorVars['--cl-color-negative-faded']}, ${colorVars['--cl-color-negative']} 8%)`, '@media (hover: hover)': { default: null, - ':enabled:hover:not(:active)': colorVars['--cl-color-negative-faded'], + ':enabled:hover:not(:active):not([data-open])': colorVars['--cl-color-negative-faded'], }, }, color: colorVars['--cl-color-negative'], 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..8ac0cb8c76b --- /dev/null +++ b/packages/ui/src/mosaic/components/popover/index.ts @@ -0,0 +1,10 @@ +export { Popover } from './popover'; +export type { + PopoverCloseProps, + PopoverDescriptionProps, + PopoverPopupProps, + PopoverRootProps, + PopoverSize, + PopoverTitleProps, + PopoverTriggerProps, +} 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..c5983722aea --- /dev/null +++ b/packages/ui/src/mosaic/components/popover/popover.styles.ts @@ -0,0 +1,98 @@ +import * as stylex from '@stylexjs/stylex'; + +import { durationVars, easingVars } 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 floating box, deliberately chrome-free: background, border, radius, + // shadow and padding belong to the surface rendered inside (e.g. `Card`), so + // the two never both paint a border. This owns only what it means to float — + // viewport clamps and the enter/exit transition. A single `width: 100%` child + // stretches to the size below via the column flex box. + popup: { + outline: 'none', + display: 'flex', + flexDirection: 'column', + opacity: { + default: 1, + ':where([data-starting-style], [data-ending-style])': 0, + }, + // A popover holds prose — an email, an org slug, an API key — and a long + // unbroken string would otherwise push past the width clamp. + overflowWrap: 'anywhere', + // Reduced motion has to drop the scale itself, not just its transition. Dropping it + // from `transitionProperty` alone leaves the value change, which then applies + // instantly — invisible entering (it happens at `opacity: 0`) but a hard snap to 94% + // before the fade on the way out. + transform: { + default: 'scale(1)', + ':where([data-starting-style], [data-ending-style])': 'scale(0.94)', + // Restated inside the media query rather than left to a bare sibling key: both + // would otherwise compile to the same specificity and the tiebreak would be source + // order, which `@stylexjs/sort-keys` reorders on autofix. + '@media (prefers-reduced-motion: reduce)': { + default: 'scale(1)', + ':where([data-starting-style], [data-ending-style])': 'scale(1)', + }, + }, + // Scale about the trigger's center, not the popup's, so the popup travels out of the + // trigger as it grows. The positioner sets this per position update and it inherits + // down; a keyword origin would drift off the trigger once `shift()` or `flip()` moves + // the popup. Falls back to `center` for the first frame, which is still `opacity: 0`. + transformOrigin: 'var(--cl-anchor-origin, center)', + // The exit is shorter than the entrance: an arrival earns a moment to settle, a + // dismissal is an acknowledgement and wants to be out of the way. + // + // On the way in the fade finishes first (positional, so `fast` is opacity and `base` + // is transform). It lands opaque just as the scale reaches full size, leaving the + // settle to play at full strength instead of through a fade — a popup that is still + // arriving while it moves reads as washed out. Leaving is the reverse case and wants + // them to land together, so the exit keeps one duration for both. + transitionDuration: { + default: `${durationVars['--cl-duration-fast']}, ${durationVars['--cl-duration-base']}`, + ':where([data-ending-style])': durationVars['--cl-duration-fast'], + }, + // 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 on the way in and accelerates away on + // the way out; running the entrance curve backwards stalls the exit for most of + // its duration and turns the overshoot into a wobble past the target. + transitionTimingFunction: { + default: `linear, ${easingVars['--cl-ease-default']}`, + ':where([data-ending-style])': `linear, ${easingVars['--cl-ease-exit']}`, + }, + maxHeight: 'min(80dvh, 36rem)', + maxWidth: 'calc(100vw - 2rem)', + minHeight: 0, + }, +}); + +// `md` reproduces the width the legacy `PopoverCard` uses (`theme.sizes.$94`), so +// popovers migrating onto Mosaic keep their current footprint. +export const sizes = stylex.create({ + sm: { width: 'min(18rem, calc(100vw - 2rem))' }, + md: { width: 'min(23.5rem, calc(100vw - 2rem))' }, + lg: { width: 'min(26rem, calc(100vw - 2rem))' }, +}); diff --git a/packages/ui/src/mosaic/components/popover/popover.test.tsx b/packages/ui/src/mosaic/components/popover/popover.test.tsx new file mode 100644 index 00000000000..fff4fbe55b3 --- /dev/null +++ b/packages/ui/src/mosaic/components/popover/popover.test.tsx @@ -0,0 +1,226 @@ +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)); + }); + +describe('Mosaic Popover', () => { + it('renders the trigger and opens the popup on click', async () => { + const user = userEvent.setup(); + render( + + Open + +
Panel body
+
+
, + ); + + expect(screen.queryByText('Panel body')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Open' })); + + expect(screen.getByText('Panel body')).toBeInTheDocument(); + }); + + it('renders a custom trigger through the render prop', () => { + render( + + ( + + Open + + )} + /> + Body + , + ); + + expect(screen.getByRole('link', { name: 'Open' })).toHaveAttribute('aria-haspopup', 'dialog'); + }); + + it('carries the mosaic slot classes on the positioner and popup', () => { + render( + + Open + 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( + + Open + Body + , + ); + + expect(document.querySelector('.cl-popover-popup')).toHaveAttribute('data-size', 'md'); + }); + + it('reflects an explicit size as data-size', () => { + render( + + Open + Body + , + ); + + expect(document.querySelector('.cl-popover-popup')).toHaveAttribute('data-size', 'lg'); + }); + + it('merges consumer className and style onto the popup', () => { + render( + + Open + + 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( + + Open + +
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( +
+ + Open + +
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( + + Open + +
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( + + Open + +
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( + + Open + +
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( + + Open + + 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( + + Open + 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..16bc7b01edf --- /dev/null +++ b/packages/ui/src/mosaic/components/popover/popover.tsx @@ -0,0 +1,194 @@ +import type { PopoverProps as HeadlessPopoverProps } from '@clerk/headless/popover'; +import { Popover as Primitive } from '@clerk/headless/popover'; +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import type { MosaicComponentProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { sizes, styles } from './popover.styles'; + +export type PopoverSize = 'sm' | 'md' | 'lg'; + +export type PopoverRootProps = HeadlessPopoverProps; + +/** + * The headless parts type their props (and the `render` callback's argument) against + * the raw tag props, which carry the non-standard HTML `color` attribute typed + * `string`. Re-typing them through `MosaicComponentProps` drops it, so a `render` + * callback can spread straight into a Mosaic component whose own `color` is a narrow + * variant union. + */ +export type PopoverTriggerProps = MosaicComponentProps<'button'>; +export type PopoverCloseProps = MosaicComponentProps<'button'>; +export type PopoverTitleProps = MosaicComponentProps<'h2'>; +export type PopoverDescriptionProps = MosaicComponentProps<'p'>; + +/** The anchor. Renders a `