diff --git a/.changeset/button-hover-timing.md b/.changeset/button-hover-timing.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/button-hover-timing.md @@ -0,0 +1,2 @@ +--- +--- 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..a8719fd6fe4 --- /dev/null +++ b/.claude/skills/mosaic/references/motion.md @@ -0,0 +1,361 @@ +# 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. + +## Color and state changes (hover, press) + +A state change that only recolors — background, border, text, opacity — takes +**`linear`, always**. Nothing moves, so there is nothing for an ease to sell: +color interpolation is already perceptually non-uniform, an ease on top just drags +the midpoint, and `--cl-ease-default`'s overshoot would extrapolate past the target +color. Reserve the curves for geometry. + +These get asymmetry too, and it costs nothing to express. **The duration an element +carries in a given state governs the transition _into_ that state**, so one +declaration per state yields a different in and out — no doubled values, no +JS: + +| while… | duration | so the transition into it | +| ----------------------- | ----------------------- | ------------------------- | +| at rest | `--cl-duration-base` | hover **out** — 0.15s | +| `:hover` | `--cl-duration-fast` | hover **in** — 0.1s | +| `:active` | `--cl-duration-instant` | press **in** — 0s | +| released, still hovered | `--cl-duration-fast` | press **out** — 0.1s | + +Instant press, soft settle; quick in, slow out. A press is a discrete, deliberate +act, so acknowledging contact with zero delay reads as responsiveness. + +### Density decides whether hover animates at all + +The table above is for an **isolated control** — a button, a card, a standalone +target. Hovering one is deliberate and infrequent, nothing else competes for the +highlight, and 0.1s reads as polish. + +**A dense, traversed collection is the opposite case: hover-in should be `0s`.** +Menu items, list and table rows, command-palette results — anywhere a pointer +crosses many targets in a row on its way somewhere. The highlight's job there is to +answer _which row am I on_, and a transition makes it unable to. Mosaic `Item` rows +are 52px, so at ordinary pointer speeds a 100ms fade leaves several rows partly lit +at once, the brightest of them trailing behind the cursor: + +| pointer speed | ms/row | rows mid-transition | +| ------------- | ------ | ------------------- | +| 300 px/s | 173 | 0.6 | +| 600 px/s | 87 | 1.2 | +| 900 px/s | 58 | 1.7 | +| 1200 px/s | 43 | 2.3 | +| 2000 px/s | 26 | 3.8 | + +Users report this as lag, and they are describing it accurately — the highlight is +behind the pointer. It is a legibility failure, not a matter of polish, and no +duration short enough to fix it is long enough to be worth having. Instant tracking +is also what platform menus have always done. + +`Item` ships `transition-duration: 0s` for this reason. **Do not "improve" it by +porting a button's hover timing onto rows** — a button is the wrong reference case +for a list. + +Keep the exit instant too unless you have checked it in context: animating only the +departure leaves a comet trail of fading rows behind a fast sweep, which +re-introduces the same ambiguity from the other side. + +```ts +transitionProperty: 'background-color, border-color, color, opacity', +transitionTimingFunction: 'linear', +transitionDuration: { + default: durationVars['--cl-duration-base'], + ':enabled:active': durationVars['--cl-duration-instant'], + '@media (hover: hover)': { + default: null, + ':enabled:hover:not(:active)': durationVars['--cl-duration-fast'], + }, +}, +``` + +`:not(:active)` is load-bearing, for the reason `stylex.md` documents: StyleX gives +at-rules extra priority, so a `@media (hover: hover)` `:hover` outranks the bare +`:active` and would steal the press's instant duration. The resting value also +covers release-from-press on a device with no hover at all. + +Worked example: `button.styles.ts`. + +## 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..cc2597ec563 100644 --- a/.claude/skills/mosaic/references/stylex.md +++ b/.claude/skills/mosaic/references/stylex.md @@ -295,6 +295,28 @@ device, while touch devices look correct. then grep `dist-mosaic/styles.css` for the two selectors and compare their specificity. +- **A button that opens something takes the pressed fill while open**, so a + disclosure trigger stays visibly engaged for as long as its surface is. Disclosure + primitives already set `data-open` on the trigger (`popover-trigger.tsx` and + friends), so this is styling-only — no headless change. It needs the _same_ + exclusion as `:active`, or hovering an open trigger lifts it back to the lighter + hover step: + + ```ts + backgroundColor: { + default: 'transparent', + ':enabled:active': neutralStep1, + ':enabled[data-open]': neutralStep1, + '@media (hover: hover)': { + default: null, + ':enabled:hover:not(:active):not([data-open])': neutralStep0, + }, + }, + ``` + + Worked example: `button.styles.ts`, applied across every filled/outline/ghost cell + (`link` opts out — it reads as text, not a control). + Worked example: `packages/ui/src/mosaic/components/button/button.styles.ts`. - **DO** use `:focus-visible` for focus rings (never bare `:focus`). For a @@ -341,6 +363,12 @@ Worked example: `packages/ui/src/mosaic/components/button/button.styles.ts`. drag, and an overshoot extrapolates past the target color for nothing. A transform at `--cl-duration-fast` still wants the curve. +- **DON'T** reuse `--cl-ease-default` for something **leaving**. It is an arrival + curve; run backwards it stalls for most of its duration and its overshoot + becomes a wobble past the target. Departures take `easingVars['--cl-ease-exit']` + at a shorter duration. See `motion.md` — enter/exit asymmetry has its own + reference, with the measurements behind these rules. + - **DO** gate transitions/animations of **motion-bearing** properties on reduced motion — `transform`, `translate`, `scale`, `rotate`, positional insets — in the same object. `prefers-reduced-motion` is a vestibular-safety signal, so color @@ -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: @@ -526,7 +609,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 7bbe0bbacb8..a552b225c1d 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -37,6 +37,7 @@ const docModules: Record> = { dialog: dynamic(() => import('../stories/dialog.component.mdx')), heading: dynamic(() => import('../stories/heading.mdx')), icon: dynamic(() => import('../stories/icon.mdx')), + 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..0939507ef88 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -86,6 +86,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 +161,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, @@ -221,6 +234,7 @@ export const registry: StoryModule[] = [ dialogComponentModule, headingModule, iconModule, + popoverComponentModule, tabsComponentModule, textModule, // Primitives — alphabetical within the group. diff --git a/packages/swingset/src/stories/popover.component.mdx b/packages/swingset/src/stories/popover.component.mdx new file mode 100644 index 00000000000..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/button/button.styles.ts b/packages/ui/src/mosaic/components/button/button.styles.ts index 59da5f26632..306cc0c2293 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: { @@ -70,11 +76,23 @@ export const styles = stylex.create({ fontWeight: fontWeightVars['--cl-font-medium'], justifyContent: 'center', outlineOffset: '2px', - // The press reads as contact, not a fade, so it lands instantly. Release falls back to - // `fast` — `:active` stops matching as the color heads back. Instant press, soft settle. + // The duration a state carries governs the transition INTO it, so asymmetry falls out + // of the selector rather than being declared twice. The press reads as contact, not a + // fade, so it lands instantly; `:active` stops matching on release and the color heads + // back at the hover/resting rate. Hover is quick to acknowledge and unhurried to let + // go — `fast` while hovered, so entering takes 0.1s, and `base` at rest, so leaving + // takes 0.15s. Instant press, soft settle; quick in, slow out. + // + // `:not(:active)` is load-bearing: inside `@media (hover: hover)` the hover branch + // would otherwise outrank the bare `:active` and steal the press's instant duration. + // The resting value also covers release-from-press on a device with no hover at all. transitionDuration: { - default: durationVars['--cl-duration-fast'], + default: durationVars['--cl-duration-base'], ':enabled:active': durationVars['--cl-duration-instant'], + '@media (hover: hover)': { + default: null, + ':enabled:hover:not(:active)': durationVars['--cl-duration-fast'], + }, }, transitionProperty: 'background-color, border-color, color, opacity', // Linear, not `--cl-ease-default`: nothing here moves. An ease on already non-uniform @@ -133,9 +151,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 +163,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 +175,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 +192,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 +205,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 +218,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 +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-primary'], @@ -218,9 +243,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 +257,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..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..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..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..2dc25727c39 --- /dev/null +++ b/packages/ui/src/mosaic/components/popover/popover.tsx @@ -0,0 +1,180 @@ +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 { sizes, styles } 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..fbdb85a8113 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -19,6 +19,9 @@ export type { ItemProps } from '../components/item'; 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, diff --git a/packages/ui/src/mosaic/tokens.stylex.ts b/packages/ui/src/mosaic/tokens.stylex.ts index 4630d8e9a4f..8074da33b50 100644 --- a/packages/ui/src/mosaic/tokens.stylex.ts +++ b/packages/ui/src/mosaic/tokens.stylex.ts @@ -192,8 +192,8 @@ export const durationVars = stylex.defineVars(durationDefaults); // ============================================================================= // Motion Tokens — easing // ============================================================================= -// One curve, named for its role rather than its shape so a consumer can retarget -// it without the name going stale. The default is Swift Out +// Curves are named for their role rather than their shape so a consumer can +// retarget one without the name going stale. The default is Swift Out // (https://www.easing.dev/swift-out, from Lochie Axon's Easing Graphs): // front-loaded, so a change departs fast, and carrying its endpoint ~2% past // target around 85% through before settling. @@ -205,9 +205,19 @@ export const durationVars = stylex.defineVars(durationDefaults); // non-uniform, so an ease on top only makes the midpoint drag, and an overshoot // extrapolates past the target color for no gain. That is a rule about the property, // not the duration — a transform at `fast` still wants this curve. +// +// `--cl-ease-exit` is its counterpart for things LEAVING, In Quad +// (https://www.easing.dev/in-quad). Swift Out run backwards spends 90% of its travel +// in the first three frames and then crawls, and its overshoot inverts into a wobble +// past the target — a departure has nothing to settle into, so it wants to accelerate +// away instead. Deliberately the gentlest of the in-family: an exit moves a small +// distance over few frames, so a sharper curve (In Quart, In Circ) leaves half of them +// below the threshold of visible change and reads as a stall followed by a lurch. +// Pair it with a shorter duration than the matching entrance. const easingDefaults = { '--cl-ease-default': 'cubic-bezier(0.175, 0.885, 0.32, 1.1)', + '--cl-ease-exit': 'cubic-bezier(0.55, 0.085, 0.68, 0.53)', } as const; export const easingVars = stylex.defineVars(easingDefaults);