Skip to content

feat(popover): export Popover as a public compound component - #1936

Merged
misama-ct merged 42 commits into
mainfrom
FEC-1167-popover-compound-export
Aug 25, 2026
Merged

feat(popover): export Popover as a public compound component#1936
misama-ct merged 42 commits into
mainfrom
FEC-1167-popover-compound-export

Conversation

@misama-ct

@misama-ct misama-ct commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

Popover already existed, but only as an internal single-element wrapper — its
own JSDoc said "this component is only used internally" — sitting behind
Combobox's dropdown and LocalizedField's hint box. This promotes it to a public
compound component (Popover.Root, Popover.Trigger, Popover.Content) and
moves both internal consumers off the old shape.

image

Ships as Beta. Two minor changesets: the new component, and a LocalizedField
accessibility fix that fell out of the migration.

Motivation

There was no anchored-overlay primitive between Tooltip (plain hints) and
Dialog (flows that should block the page). Filter panels, short edit forms and
context-sensitive actions had to be hand-rolled from React Aria's
DialogTrigger + Popover + Dialog — which is exactly what LocalizedField
was doing, and it shipped an unnamed dialog as a result.

Why not split

The recipe registration moves from recipes to slot-recipes: nimbusPopover
becomes a slot recipe the moment the compound lands. Both existing consumers
read the old plain recipe, so they change meaning in the same commit and have to
move with it. The docs set, spec rewrite and consumer migrations are all
downstream of that one switch.

Map

Area Files Purpose
Component popover/components/popover.{root,trigger,content,context}.tsx The compound. Root is the single configuration surface and passes overlay props to Content through context
Styling popover.recipe.tsx, popover.slots.tsx, theme/{recipes,slot-recipes}/index.ts Plain recipe → slot recipe, re-registered
Public API popover.types.ts, components/index.ts Per-part prop contracts; barrel export
Docs popover.{mdx,dev.mdx,a11y.mdx,guidelines.mdx} Standard four-file set
Tests popover.stories.tsx, popover.docs.spec.tsx 19 stories, 8 consumer tests
Consumers combobox/**, localized-field/** Combobox moves to React Aria's Popover directly; LocalizedField adopts the public compound
Spec openspec/specs/nimbus-popover/, openspec/changes/archive/** Spec rewrite plus two archived proposals

How to review

  1. openspec/changes/archive/2026-08-20-popover-compound-export/design.md — the
    API decisions, so the rest reads as consequences.
  2. popover.types.ts, then the four components/*.tsx — contract, then
    implementation.
  3. popover.stories.tsx — the behaviour, in play-function form.
  4. The two consumer migrations. They are the only places existing behaviour
    could regress.

The docs and openspec files are prose — skim or skip.

Test plan

  • pnpm test:dev packages/nimbus/src/components/popover/ — 27 pass (19
    stories + 8 consumer tests)
  • pnpm --filter @commercetools/nimbus typecheck:dev clean
  • build-and-test and chromatic — still running at time of writing
  • Chromatic baseline needs a human accept. LocalizedField's HintDialog
    snapshot changes on purpose: one shadow instead of two stacked, no border
    or background tint of its own, fades in and out like other popovers, and
    no outline around itself when open (matching Dialog and Drawer).

Risk

Medium. The new component is additive behind a new export, but two shipped
components changed rendering path. Combobox is the one to watch: it no longer
routes through the shared recipe, so zIndex: 1 is now declared on its own
popover slot to keep stacking order unchanged.

Rollback is a plain revert — no migration, no infrastructure, no persisted
state. Popover is unreleased, so only the Combobox and LocalizedField changes
have consumer-visible surface.

FEC-1167

@misama-ct misama-ct added the WIP Work is ongoing. You can look, but know it's not perfection. label Aug 19, 2026
@changeset-bot

changeset-bot Bot commented Aug 19, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b18c451

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 5 packages
Name Type
@commercetools/nimbus Minor
@commercetools/nimbus-tokens Minor
@commercetools/nimbus-icons Minor
@commercetools/nimbus-design-token-ts-plugin Minor
@commercetools/nimbus-mcp Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Aug 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nimbus-documentation Ready Ready Preview Aug 25, 2026 6:14am
nimbus-storybook Ready Ready Preview Aug 25, 2026 6:14am

Request Review

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Report

Last updated: 2026-08-25 06:14:39 UTC

Package Format Current Baseline Delta Status
@commercetools/nimbus dist 18993.4 KB 18950.9 KB +0.2% ✅ ok
@commercetools/nimbus-icons dist 4787.6 KB 4787.6 KB +0.0% ✅ ok
@commercetools/nimbus-tokens dist 408.9 KB 408.9 KB +0.0% ✅ ok

Baseline source: comment-chain

@chromatic-com

chromatic-com Bot commented Aug 20, 2026

Copy link
Copy Markdown

Tip

All tests passed and all changes approved!

🟢 UI Tests: 1226 tests unchanged
🟢 UI Review: Approved by Michael Salzmann
Storybook icon Storybook Publish: 1226 stories published

Same audit, the surfaces outside the component directory. Every item below
was checked against a source line, and the ones that turned out to be already
correct are left alone.

openspec/specs/nimbus-popover:
- The exit animation "SHALL use the same duration as the enter animation".
  The recipe uses `fast` to enter and `faster` to exit — deliberately shorter,
  copied from Menu — so the spec described an animation we never shipped.
- The isNonModal scenario required that focus "SHALL NOT be contained". The
  opposite is true and is now asserted by two stories; rewritten around inert
  and the scroll lock, which is what modality actually changes here.
- The accessible-name scenario assumed a visible heading names the dialog. RAC
  falls back to the trigger's name instead.
- The maxHeight carve-out named only `maxH`; `maxBlockSize` is equally accepted
  and equally unable to cap the surface.

openspec/specs/nimbus-localized-field: the info-box scenario still specified
the surface treatment its own recipe used to carry (bg neutral.1, boxShadow 6,
focusRing outside). After the migration onto the Popover compound, infoDialog
carries only sizing and scrolling, the surface comes from the compound's
content slot, and the only focus ring is the hint trigger's.

Archived 2026-08-20-popover-compound-export. These are historical records, so
only present-tense claims about the shipped API are corrected and the
narrative framing is left intact:
- design.md decided LocalizedField would drop to bare React Aria and gain an
  `infoPopover` slot, concluding the compound ships with zero internal
  consumers. It shipped with one: LocalizedField renders the compound, no
  `infoPopover` slot exists, and focusRing was removed rather than relocated.
  Recorded as a Superseded note in the file's existing style rather than by
  rewriting the decision — the ComboBox half still holds, and the paragraph
  above it that reads the old recipe as evidence is correctly in past tense.
- proposal.md's impact table claimed the same three LocalizedField changes.
- tasks.md 5.8 recorded asserting `aria-haspopup="dialog"` on the trigger; the
  story asserts its absence, and the change's own design.md argues for that.
- tasks.md 3.1 described Root splitting recipe variants and extracting style
  props (it does neither, and the recipe declares no variants), and 2.2 named
  a recipe-props layer that popover.types.ts does not have.
- The delta's portal scenario predated `UNSTABLE_portalContainer` landing on
  Root, and its offset/crossOffset scenarios were unqualified as to which part
  they are set on, which now reads as the wrong part.

docs/file-type-guidelines/compound-components.md illustrated flexible child
composition with `Popover.Title`, `Popover.Description` and `Popover.Actions`,
none of which exist, and no trigger. Retargeted to Dialog, which really has
those parts (verified against dialog.tsx: Header/Title/Body/Footer/
CloseTrigger), and added a Popover example showing the opposite case, where
the consumer's own markup fills that role. Line 26 of this file had already
been corrected to three parts; the sample 440 lines down had not.

Untouched deliberately: the earlier serena/openspec metadata, the released
CHANGELOG, and archived prose that is already framed in the past tense.

FEC-1167

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@misama-ct
misama-ct force-pushed the FEC-1167-popover-compound-export branch from 8fc7cbb to 4948673 Compare August 21, 2026 11:22
misama-ct and others added 2 commits August 21, 2026 13:25
Out of scope for this branch, which adds the Popover component.

FEC-1167

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The type-level blocks had grown into essays duplicating popover.dev.mdx and
the nimbus-popover spec. Keep the actionable half — what a part rejects and
where to set it instead — and link the docs site for the rest. Per-property
JSDoc is untouched, so the docgen output is unchanged.

Comments only; the type surface is identical.

FEC-1167

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
misama-ct and others added 2 commits August 21, 2026 13:52
Popover.Content renders a React Aria Dialog, which publishes a ButtonContext
with a close slot, so `<Button slot="close">` works inside it — the same way
it already does in Dialog and Drawer. The docs only showed the function-child
render prop, so the conventional form looked unavailable.

Lead with slot="close" and keep the render prop for the case it is actually
needed: an action that has to run before the popover dismisses. Add a
CloseViaSlot story so the behaviour is covered rather than just claimed.

FEC-1167

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Where configuration goes" restated the API design rather than showing usage:
its prop table duplicated the generated <PropsTable>, its example duplicated
the one in "Positioning" directly above it, and no other component documents
configuration this way — Root taking the configuration is the house pattern.
The exclusion rationale it carried is already in the nimbus-popover spec.

"Styling" was one of only two such sections across 88 dev.mdx files.

Usage examples is now feature sections with live examples throughout, matching
the peer components and the template.

FEC-1167

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@ByronDWall ByronDWall left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One very small nit on the docs. The component behaves as expected in storybook, code looks good. approved, thanks for implementing this!

Comment on lines +89 to +98
`isNonModal` does not lift that containment. What it changes is the page behind
the popover: by default React Aria marks the rest of the page `inert` and locks
page scroll, and `isNonModal` leaves both alone, so assistive technologies can
reach the surrounding content. Focus still cycles inside the dialog and an
outside press still dismisses it — containment comes from the dialog
`Popover.Content` renders, not from modality. React Aria's own guidance is that
most popovers should not use it, because screen readers can announce the
surrounding page in ways that make the popover harder to follow. It exists for
components like combobox, where the trigger must keep focus while the overlay is
open.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this paragraph seems like a better fit for the 'implementation' section?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The `isNonModal` paragraph in the Accessibility tab documented prop
mechanics (`inert`, page scroll, why the prop exists) rather than
accessibility behavior, and `popover.dev.mdx` already carried a
condensed copy of the same claim in its Component requirements section.

Keep the a11y-facing consequence in `popover.a11y.mdx` — containment is
not lifted, and most popovers should not use the prop — and move the
mechanics into the Implementation tab's Dismissal behavior section,
alongside `isKeyboardDismissDisabled` and `shouldCloseOnInteractOutside`
and matching the spec's "dismissal and modality" grouping. The condensed
duplicate becomes a Modality bullet pointing there.
@misama-ct
misama-ct merged commit 3ccc421 into main Aug 25, 2026
12 checks passed
@misama-ct
misama-ct deleted the FEC-1167-popover-compound-export branch August 25, 2026 06:59
@github-actions github-actions Bot added the bundle-sizes Housekeeping for merged PRs - allows the fetch sizes script to find the latest bundle check comment. label Aug 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bundle-sizes Housekeeping for merged PRs - allows the fetch sizes script to find the latest bundle check comment. 👉👈 pick me? 🥺

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants