feat: add PlacementController to Gen2#6337
Conversation
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.
📚 Branch Preview Links🔍 First Generation Visual Regression Test ResultsWhen 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: If the changes are expected, update the |
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.
Description
Adds
PlacementController— a Lit reactive controller that positions a floating element relative to a trigger using Floating UI'scomputePosition+autoUpdate. It's the 2nd-gen replacement for the 1st-genPlacementControllerbaked into<sp-overlay>, but extracted from the overlay lifecycle so popover, picker, menu, tooltip, coachmark, etc. can each compose it without inheriting overlay machinery.The controller is the first 2nd-gen primitive that downstream popover-shaped components will use for positioning.
Public API
start(trigger, floating, options?)autoUpdate.stop()max-height/max-widthwritten bysizeand the tip styles written byarrow, reset state. Called automatically fromhostDisconnected.recompute()computePositionpass outsideautoUpdate.actualPlacementflip(hyphenated), ornullwhen stopped. Refreshed on every compute.isConstrainedtruewhilesizemiddleware is applyingmax-heightbecause content would otherwise overflow.Options:
placement(22 hyphenated values including logicalstart/end),offset,crossOffset,containerPadding,shouldFlip,tipElement,tipPadding,onPlacementChange.VirtualTriggeris supported — any{ getBoundingClientRect(); contextElement? }shape works as the anchor.Behavioural parity with 1st-gen
Where the controller's behaviour is observable to consumers, it matches 1st-gen:
offset → shift → flip → size → arrow.shiftruns beforeflipso the panel slides along the current side before any decision to flip;sizeruns after the final placement is known;arrowruns last (only when atipElementis provided) so it positions the tip after every other middleware has settled.sizemiddleware always installed. Writesmax-widthon every compute. Writesmax-heightonly when content would otherwise overflow — the controller tracks aninitialHeightbaseline from the first un-constrained compute (1st-gen's pattern) to detect when content is currently being clamped, and exposes that asisConstrained.arrowmiddleware (opt-in viatipElement) positions a caller-provided tip so it points at the trigger's center, inset from the panel corners bytipPadding. After every compute it pins the tip to the relevant floating edge (top/left) and writes inlinetranslateto slide it along that edge; the tip styles are cleared onstop().visualViewportlisteners + offset compensation for URL-bar collapse / pinch-zoom / virtual-keyboard scenarios.roundByDPR) so translated coordinates stay sharp on high-DPR displays.document.fonts.readyawait + WebKit rAF wait before measuring.Where the controller intentionally diverges from 1st-gen
These are scope changes, not behaviour changes. They were called out and confirmed in the popover migration plan:
autoUpdatechannelsplacement/actual-placementattributes on host elementsactualPlacementproperty andonPlacementChangecallback.host.elements: OpenableElement[]ReactiveControllerHostsp-update-overlaysglobal eventrecompute()directly when needed.placeOverlay(target, options)start(trigger, floating, options)/stop()/recompute()with separated lifecycle.OverlayOptionsV1(abortPromise, delayed, notImmediatelyClosable, receivesFocus, root, type, …)PlacementOptions— positioning fields only (includingtipElement/tipPaddingfor arrow support). Trigger is a separate first argument.number | [number, number](array for multi-axis)offsetandcrossOffsetfields.Public API additions
Placementunion (22 values including logicalstart/endand physical sub-alignment variants), exported withALL_PLACEMENTSlist.VirtualTriggerinterface.PlacementOptionsinterface, includingtipElement/tipPaddingfor opt-inarrowmiddleware.toFloatingPlacement/fromFloatingPlacement— exported and unit-tested. The SWCPlacementunion doesn't map 1:1 to Floating UI's 12 values; these bridge the two:start,end) → physical sides (left,right)start-top,end-bottom) → Floating UI'sstart/endsuffixes on the resolved physical sidebottom-left,left-top) → Floating UI'sstart/endsuffixesleft-start/left-end/right-start/right-end(not in the SWC union) map back toleft-top/left-bottom/right-top/right-bottomStorybook
Docs page at
Controllers / Placement controllerwith the playground, behavioral demos (requested placement, offset / cross-offset, container padding, should-flip, size-always-clamps, on-placement-change, virtual trigger, arrow), and an inline API table. All 14 stories tagged appropriately for the new core-controller documentation template.Motivation and context
This is the first geometry primitive shared across the 2nd-gen popover-shaped components. Splitting it out of the 1st-gen overlay model lets popover, picker, menu, tooltip, coachmark, etc. each own their open/close lifecycle and ARIA wiring, while positioning math is centralised here. Aligns with the popover migration plan (
CONTRIBUTOR-DOCS/03_project-planning/03_components/popover/migration-plan.md).Related issue(s)
Author's checklist
Reviewer's checklist
patch,minor, ormajorfeaturesManual review test cases
Storybook docs page renders cleanly
Controllers / Placement controllerPlayground reacts to every option
Offset demo shows the difference
crossOffsetin the lower rowSize always clamps the list
host.floatingEl.style.maxHeightreads as a numeric px valuehost.isConstrainedistrueArrow tip points at the trigger
tipPadding)Virtual trigger follows clicks
29 placement-controller unit tests pass
2nd-gen/packages/swc, runyarn test "core/controllers/placement-controller"start()replacement, and disconnect cleanupDevice review
visualViewportcompensation is included; verify popovers don't drift when toggling the simulated URL barAccessibility testing checklist
The controller handles geometry only — it does not manage focus, ARIA, or keyboard dismissal. Consumer components remain responsible for accessible open/close behaviour. There is no UI to test against directly.
top/left/translateinline styles only (on the floating element and, when provided, the tip element); no roles, names, or live regions. Consumers attacharia-controls,aria-expanded, and labelling as appropriate.Accessibility guidance for consumers is documented in the Storybook Accessibility story (under
Controllers / Placement controller).