Skip to content

feat(popover): gen2 popover migration#6356

Draft
rubencarvalho wants to merge 33 commits into
mainfrom
ruben/popover-migration
Draft

feat(popover): gen2 popover migration#6356
rubencarvalho wants to merge 33 commits into
mainfrom
ruben/popover-migration

Conversation

@rubencarvalho

@rubencarvalho rubencarvalho commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Description

Integration branch for the Popover 1st-gen → 2nd-gen migration (Epic SWC-1993). Each migration phase is opened as a separate PR targeting this branch and merges here as it completes. This PR stays in draft until all phases are merged in, then it is readied for main.

Phase PRs (target this branch)

(Former standalone Setup PR #6353 was consolidated into #6354.)

Related issue(s)

Notes

Based at the PlacementController tip (#6337); until that merges to main, this diff overlaps #6337.

Kept in draft until all phase PRs are merged in. The combined Phase 4+5 PR (#6357) is functional but still WIP.

rubencarvalho and others added 26 commits May 22, 2026 17:49
Adds sidebar entries for accessibility migration analysis docs on
action-button, button-group, close-button, grid, and infield-button,
plus the link migration plan, so they appear under their components in
the Storybook navigation.
Extracts Floating UI-based positioning from 1st-gen overlay's
PlacementController into a standalone reactive controller under
core/controllers. Adds start/stop/recompute API, hyphenated Placement
union aligned with Floating UI and swc-popover, opt-in constrainSize
for picker/menu scroll cases, and VirtualTrigger support for virtual
anchors. Wires the floating-ui/dom dependency and package.json export
for the new controller subpath.

Also lands the supporting Storybook ApiTable block plus the
ConditionalAPIReference template change, and migrates the
focusgroup-navigation-controller stories to the new controllerApi
parameter pattern.

SWC-1996
Address correctness bugs, plan-deviating defaults, an iOS regression
risk, and minor structural issues surfaced during code review.

Correctness
  - toFloatingPlacement: logical-side + physical-alignment placements
    (start-top, start-bottom, end-top, end-bottom) previously returned
    invalid Floating UI strings (left-top, left-bottom, right-top,
    right-bottom) because the logical-side branch did not consult
    PHYSICAL_ALIGNMENT_TO_FLOATING. They now resolve correctly to
    left-start / left-end / right-start / right-end.
  - fromFloatingPlacement: Floating UI's left-start, left-end,
    right-start, right-end are not in the SWC Placement union; the
    function now maps them back to the physical equivalents
    (left-top / left-bottom / right-top / right-bottom). The
    previously unchecked cast is gone.

Defaults aligned with the popover migration plan
  - DEFAULT_OFFSET: 8 to 0 (controller is now neutral; each consuming
    component sets its own pattern-specific default).
  - DEFAULT_CONTAINER_PADDING: 12 to 8 (matches 1st-gen
    REQUIRED_DISTANCE_TO_EDGE and Spectrum guidance).
  - Storybook args and argType defaults updated to match.

Behavior
  - Restored an iOS WebKit visualViewport listener (passive,
    rAF-coalesced) so positioning stays correct when the URL bar,
    pinch-zoom, or virtual keyboard shifts the visual viewport without
    triggering events that Floating UI's autoUpdate observes. The
    matching offset compensation in computePlacement was already
    present; only the trigger was missing.
  - Comment on the single autoUpdate channel explaining the
    intentional difference from 1st-gen, which closed the overlay on
    ancestor scroll rather than repositioning.

Structure
  - Added a reserved PlacementHostConfig interface and updated the
    constructor to accept an optional config argument, so future
    integration hooks (e.g. tip-element resolver for arrow middleware)
    can be added without a constructor signature change.
  - Cached isWebKit() per session so computePlacement no longer runs a
    UA regex per autoUpdate tick.
  - Removed the internal-only getFallbackPlacements re-export from
    src/index.ts (it is not part of the public surface).
  - Expanded JSDoc on actualPlacement (initial-value semantics),
    fromFloatingPlacement (invariants), and toPlacementClassSuffix
    (why the indirection exists).

Tests
  - Added play-function stories covering the placement-conversion fix
    end to end, the logical-side placements computing valid
    coordinates, constrainSize applying max-height, shouldFlip: false
    preserving the requested side, and rapid start() calls replacing
    the prior session.

SWC-1996
- Drop toPlacementClassSuffix. It was a pass-through that just returned
  its input; consumers can do the BEM string interpolation themselves.
- Strip {@link Foo} JSDoc annotations across placement-controller
  files; they don't render in this environment. Replaced with plain
  backtick references.
- Remove popover-specific phrasing from controller comments and JSDoc
  so the controller stays host-agnostic. Generic mentions of popover,
  picker, menu, etc. as example use cases in story descriptions are
  retained where they help the reader.
- Revert the focusgroup-navigation-controller stories file. The
  controllerApi parameter pattern was not required for the
  placement-controller API table to render; the focusgroup keeps its
  prior inline-JSDoc API documentation.
Revert the controllerApi infrastructure added to ApiTable.tsx and
DocumentTemplate.mdx. Document the placement-controller's methods,
readonly properties, options, and types as inline markdown tables in
JSDoc on a description-only API story — matching the
focusgroup-navigation-controller pattern.

- ApiTable.tsx: drop the ControllerApiReference type, five
  controller-specific table components, and the tag-based branching
  added for controllers. Component CEM rendering is unchanged.
- DocumentTemplate.mdx: restore ConditionalAPISection (Primary +
  Controls + optional stories tagged 'api') and drop the
  ConditionalAPIReference / ApiTable wiring.
- placement-controller stories: remove parameters.controllerApi,
  add an API story with inline JSDoc tables tagged ['api',
  'description-only'].
Replace the two side-by-side flip demos (demo-placement-flip and
demo-placement-no-flip) with a single demo-placement-should-flip that
has a shouldFlip checkbox. One trigger + one floating panel + one
toggle isolates the feature so readers can verify the placement only
moves because of the flip middleware.

- demo-hosts.ts: delete DemoPlacementFlip and DemoPlacementNoFlip;
  add DemoPlacementShouldFlip with a should-flip attribute, a checkbox
  in the demo template, and an updated() hook that rebinds the
  controller when the toggle flips.
- placement-controller.stories.ts: ShouldFlip story now renders a
  single demo-placement-should-flip element. Story description gained
  a sentence explaining the toggle.
- placement-controller.test.ts: FlipReorients reads from the new
  element (default shouldFlip=true). NoFlipKeepsRequestedSide reuses
  the same element, sets shouldFlip=false, then asserts
  actualPlacement stays at 'bottom'.
- Virtual trigger demo: surface is a square (aspect-ratio 1, max
  320px) so the click area reads as a single anchor canvas. The
  'Click to move anchor' label uses Body sizeL emphasized so it reads
  as the demo's call to action. Added a 12px offset so the floating
  panel sits visibly clear of the clicked point.
- Constrain-size demo: dropped the dashed outline around the surface.
  It implied the floating element was bounded by the surface, but the
  size middleware uses the viewport / clipping ancestors, so the
  scaffolding was misleading.
- Story docs: removed prescriptive lines that named specific consumer
  components (picker / menu / combobox / tooltip / popover) in the
  description for shouldFlip, constrainSize, the meta JSDoc, and the
  subtitle. The controller is host-agnostic.
- Documentation template: replaced the bare <ApiTable /> with a
  ConditionalApi helper. Controllers (tag 'controller') document their
  API via inline JSDoc stories tagged 'api', so they no longer trigger
  the 'Component not found in manifest' fallback. Components are
  unchanged.
Switch the virtual-trigger demo surface from a responsive square
(inline-size: 100%; max-inline-size: 320px; aspect-ratio: 1) to a
fixed 320x320px so the click target is the same size at any container
width.
- Drop the dashed outline on the surface; replace with a subtle tinted
  background and a rounded border for a calmer visual.
- Add 'resize: vertical' so the user can drag the bottom edge of the
  box to grow it. Pushing the trigger toward the bottom of the
  viewport makes the requested 'bottom' placement no longer fit, so
  toggling shouldFlip becomes meaningful — the user can watch the
  panel flip above when enabled and stay below (overflowing) when
  disabled.
- Added a short hint paragraph above the surface explaining what to
  do.
The interactive resize-to-flip demo didn't reliably demonstrate the
feature — flip depends on the trigger's viewport position, which is
fragile across canvas sizes. Documenting the behavior in the story
description is clearer than a demo that may not flip.

- Story: ShouldFlip becomes a description-only story (no render).
- Demo hosts: drop DemoPlacementShouldFlip, its tagname map entry,
  the FLIP_DEMO_ITEMS items, and the flipDemoStyles sheet that was
  only used by it.
- Tests: drop FlipReorients and NoFlipKeepsRequestedSide, plus the
  ShouldFlip / DemoPlacementShouldFlip imports they depended on.
  Pure-function and constrainSize tests still exercise the controller
  surface.
The docs referenced `shift` in seven places as if it were an
opt-in option. It isn't — `shift` runs on every compute and there's
no opt-out (same as 1st-gen). Spell that out in the middleware stack
section so the existing prose references read correctly.
shift is not a configurable option, so listing it alongside flip and
size in option JSDoc and story prose just makes those references read
like undocumented options. Replace the lists with neutral phrasing
("used for collision detection" / "inset from the overflow
boundary") and keep a single shift mention in the middleware-stack
section, where the always-on note belongs.
Adds six tests against a new DemoPlacementTestFixture host, which
pins the trigger to a configurable viewport edge / center and can
optionally swap in a 600px-tall floating panel. The fixture isn't
referenced from any docs story; the test file is the only consumer.

New tests:
- FlipReorients — bottom placement reorients when the panel can't fit
  below a trigger anchored to the viewport bottom.
- NoFlipKeepsRequestedSide — same setup with shouldFlip: false keeps
  the requested side, overflow and all.
- OffsetMovesAlongPlacementAxis — offset: 40 shifts translateY by
  ~40 px for a 'bottom' placement.
- CrossOffsetMovesAlongTriggerEdge — crossOffset: 40 shifts
  translateX without materially affecting translateY.
- ContainerPaddingMovesPanelInward — with the trigger near the right
  edge, a larger containerPadding pulls the panel further inside the
  boundary.
- OnPlacementChangeFiresOnChangeOnly — callback is silent when the
  computed placement matches the requested one and fires once when
  flip reorients.

DemoPlacementTestFixture also exposes placementChanges (records
each onPlacementChange invocation since the last rebind) and the
controller field directly, so tests can read firing semantics or
call recompute() without going through indirection.
…ways-on size

Reconcile two behavioural deviations from 1st-gen that the migration
plan didn't justify against the cost of breaking consumer parity.

Middleware order — restored to 1st-gen: offset → shift → flip → size.
The 2nd-gen had switched to offset → flip → shift → size on the
rationale that it was 'Floating UI's canonical recommended order';
Floating UI doesn't actually prescribe one, and the two orders produce
different positions in edge cases (trigger near a corner, panel close
to viewport edge). The 1st-gen pattern is what downstream consumers
have been seeing for years — no upside to changing it.

size middleware — now always installed. Dropped the constrainSize
option. The apply callback uses 1st-gen semantics: it tracks
initialHeight from the first un-constrained compute as the baseline,
writes max-width on every compute, and writes max-height only when
isConstrained is true (content overflows the available space). stop()
now unconditionally clears max-height / max-width and resets
initialHeight. The ConstrainSize story was renamed to SizeAlwaysClamps
and reframed as documentation of the always-on behaviour rather than
an opt-in toggle.

PlacementOptions.constrainSize is removed; the Playground demo's
constrainSize property + attribute + bind entry are gone; the API
table no longer lists constrainSize; the test that previously asserted
'constrainSize applies max-height' was rewritten to use the test
fixture with tallFloating + triggerPosition='bottom-center' +
shouldFlip=false so the overflow scenario is deterministic.
…-gen)

The controller now installs Floating UI's arrow middleware when a
tipElement is passed in PlacementOptions, matching 1st-gen behaviour.

After every compute the controller writes inline translate on the tip
element using the same pattern as 1st-gen:
- top:0 reset for left/right placements (arrow on a vertical edge)
- left:0 reset for top/bottom placements (arrow on a horizontal edge)
- translate carries the arrow.x / arrow.y from middlewareData

CSS positions the tip element relative to the floating element's edge
(typically with a negative offset for the half-size of the arrow); the
controller only slides it along that edge so it stays pointing at the
trigger's center as shift moves the floating panel.

API additions:
- PlacementOptions.tipElement?: HTMLElement
- PlacementOptions.tipPadding?: number (default 8)
- stop() now clears the tip element's inline translate/top/left

Demo + tests:
- New demo-placement-arrow: trigger + floating panel with a CSS
  triangle tip. The host passes the tip to the controller.
- New Arrow story in the docs page (section-order: 8) under Behaviors.
- New ArrowMiddlewarePositionsTip test asserts the tip receives a
  numeric inline translate after the first compute.
- Middleware-stack docs and API options table updated.
…ed trigger

- Trigger button: drop the fixed 48x48 sizing and use min-block-size +
  inline padding so 'Trigger' fits naturally without overflowing.
- Default placement: switch from 'bottom' to 'bottom-end' so the tip's
  computed offset from the floating panel's center is clearly visible
  (with 'bottom', the tip would sit at the center and there'd be
  nothing visually distinct from a CSS-centered tip).
Renames the references in code comments and story prose only — no
behaviour change. Affects the middleware-stack JSDoc, the controller's
inline comments about the order match and tip-positioning pattern, the
SizeAlwaysClamps story's 'matches gen1 behaviour' note, and the
appendix 'Relationship to gen1 PlacementController' section.
…ghten assertions

Move the behavioral tests off the interactive demo-placement-playground host
onto the lean, property-driven demo-placement-test-fixture so they no longer
depend on the demo's controls, placement picker, or layout.

Tighten loose assertions to the actual expected geometry: start/end alignment
gap, below-trigger position, offset and cross-offset deltas, container-padding
inward shift, and the flip-reorients placement value.
Replace the loose regex check on the tip's serialized translate with direct
style and geometry assertions: the controller pins the tip to the floating
edge (left: 0) and the arrow middleware centers it on the trigger. Read the
tip and trigger rects directly and assert their centers align.
The size middleware's apply callback writes baseline state (isConstrained,
initialHeight) during computePosition. A rapid start()/stop() replacement
could let a stale in-flight compute bleed that state into the new session,
so bail when the session has been replaced.
@changeset-bot

changeset-bot Bot commented Jun 1, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 310e945

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

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

@github-actions

github-actions Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

📚 Branch Preview Links

🔍 First Generation Visual Regression Test Results

When a visual regression test fails (or has previously failed while working on this branch), its results can be found in the following URLs:

Deployed to Azure Blob Storage: pr-6356

If the changes are expected, update the current_golden_images_cache hash in the circleci config to accept the new images. Instructions are included in that file.
If the changes are unexpected, you can investigate the cause of the differences and update the code accordingly.

Addresses PR review: the size middleware no longer writes max-width /
max-height inline (which overrode a component's intended CSS max-size, e.g.
Tooltip wouldn't wrap until viewport width). Instead it exposes
--swc-placement-available-width / --swc-placement-available-height on the
floating element; components opt in via min() so their intended size wins and
overflow stays their concern (resolving the box-sizing overflow too).

- stop() removes the custom properties; isConstrained kept as an informational
  flag (no longer drives an inline max-height)
- document the contract + consumer pattern on the controller and in stories
- demo-placement-constrain-size consumes the props so it clamps and scrolls
- tighten the size test to assert real bounded values; no inline max-* written
Addresses PR review: logical primary sides must resolve to the correct
physical side here, not in CSS, or the panel lands on the wrong side in RTL.

- toFloatingPlacement takes a direction arg; start->right / end->left in RTL
  (and start-top -> right-start, etc.)
- the controller reads getComputedStyle(trigger).direction (falling back to the
  floating element for a VirtualTrigger), honoring a scoped dir, not document.dir
- logical alignment suffixes (bottom-start, top-end) pass through unchanged so
  Floating UI's own RTL handling flips them without double-flipping
- actualPlacement stays physical; add RTL conversion tests
@rubencarvalho rubencarvalho changed the title feat(popover): 2nd-gen migration (integration branch) [SWC-1993] feat(popover): gen2 popover migration Jun 2, 2026
rubencarvalho and others added 5 commits June 15, 2026 21:39
# Conflicts:
#	2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts
#	2nd-gen/packages/core/controllers/placement-controller/src/types.ts
#	2nd-gen/packages/core/controllers/placement-controller/stories/demo-hosts.ts
#	2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts
#	2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts
#	2nd-gen/packages/swc/.storybook/DocumentTemplate.mdx
#	2nd-gen/packages/swc/.storybook/main.ts
#	2nd-gen/packages/swc/.storybook/preview.ts
@coveralls

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 27818294657

Warning

No base build found for commit 8551787 on main.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 96.24%

Details

  • Patch coverage: No coverable lines changed in this PR.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 39108
Covered Lines: 37837
Line Coverage: 96.75%
Relevant Branches: 6449
Covered Branches: 6007
Branch Coverage: 93.15%
Branches in Coverage %: Yes
Coverage Strength: 458.6 hits per line

💛 - Coveralls

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants