Skip to content

feat: add PlacementController to Gen2#6337

Open
rubencarvalho wants to merge 26 commits into
mainfrom
ruben/feat-placement-controller-swc-1996
Open

feat: add PlacementController to Gen2#6337
rubencarvalho wants to merge 26 commits into
mainfrom
ruben/feat-placement-controller-swc-1996

Conversation

@rubencarvalho
Copy link
Copy Markdown
Contributor

@rubencarvalho rubencarvalho commented May 26, 2026

Description

Adds PlacementController — a Lit reactive controller that positions a floating element relative to a trigger using Floating UI's computePosition + autoUpdate. It's the 2nd-gen replacement for the 1st-gen PlacementController baked 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

import { PlacementController } from '@spectrum-web-components/core/controllers/placement-controller';

class MyPopover extends LitElement {
  private placement = new PlacementController(this);

  open(): void {
    this.placement.start(this.trigger, this.panel, {
      placement: 'bottom-start',
      offset: 8,
      shouldFlip: true,
      onPlacementChange: (p) => {
        this.actualPlacement = p;
      },
    });
  }

  close(): void {
    this.placement.stop();
  }
}
Member Description
start(trigger, floating, options?) Begin positioning; tear down any prior session and subscribe to autoUpdate.
stop() Tear down positioning, clear inline max-height / max-width written by size and the tip styles written by arrow, reset state. Called automatically from hostDisconnected.
recompute() Force one computePosition pass outside autoUpdate.
actualPlacement Readonly. Computed placement after flip (hyphenated), or null when stopped. Refreshed on every compute.
isConstrained Readonly. true while size middleware is applying max-height because content would otherwise overflow.

Options: placement (22 hyphenated values including logical start / end), offset, crossOffset, containerPadding, shouldFlip, tipElement, tipPadding, onPlacementChange.

VirtualTrigger is 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:

  • Middleware order is the same: offset → shift → flip → size → arrow. shift runs before flip so the panel slides along the current side before any decision to flip; size runs after the final placement is known; arrow runs last (only when a tipElement is provided) so it positions the tip after every other middleware has settled.
  • size middleware always installed. Writes max-width on every compute. Writes max-height only when content would otherwise overflow — the controller tracks an initialHeight baseline from the first un-constrained compute (1st-gen's pattern) to detect when content is currently being clamped, and exposes that as isConstrained.
  • arrow middleware (opt-in via tipElement) positions a caller-provided tip so it points at the trigger's center, inset from the panel corners by tipPadding. After every compute it pins the tip to the relevant floating edge (top / left) and writes inline translate to slide it along that edge; the tip styles are cleared on stop().
  • iOS WebKit visualViewport listeners + offset compensation for URL-bar collapse / pinch-zoom / virtual-keyboard scenarios.
  • DPR rounding (roundByDPR) so translated coordinates stay sharp on high-DPR displays.
  • document.fonts.ready await + WebKit rAF wait before measuring.
  • Fallback-placement table for virtual triggers.

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:

Area 1st-gen 2nd-gen
Scope Overlay lifecycle + ARIA + positioning Geometry only
autoUpdate channels Two channels (one closed the overlay on ancestor scroll) One channel — just repositions. Caller decides whether ancestor scroll should close the surface.
DOM mutation on trigger Wrote placement / actual-placement attributes on host elements None. Computed placement surfaces via the actualPlacement property and onPlacementChange callback.
Host coupling Required host.elements: OpenableElement[] Plain ReactiveControllerHost
sp-update-overlays global event Listened to it for reflow triggers Removed. The caller invokes recompute() directly when needed.
placeOverlay(target, options) Single async method Split into start(trigger, floating, options) / stop() / recompute() with separated lifecycle.
Options shape OverlayOptionsV1 (abortPromise, delayed, notImmediatelyClosable, receivesFocus, root, type, …) PlacementOptions — positioning fields only (including tipElement / tipPadding for arrow support). Trigger is a separate first argument.
Offset shape number | [number, number] (array for multi-axis) Separate offset and crossOffset fields.

Public API additions

  • Placement union (22 values including logical start / end and physical sub-alignment variants), exported with ALL_PLACEMENTS list.
  • VirtualTrigger interface.
  • PlacementOptions interface, including tipElement / tipPadding for opt-in arrow middleware.
  • Pure conversion helpers toFloatingPlacement / fromFloatingPlacement — exported and unit-tested. The SWC Placement union doesn't map 1:1 to Floating UI's 12 values; these bridge the two:
    • Logical sides (start, end) → physical sides (left, right)
    • Logical sub-alignments (start-top, end-bottom) → Floating UI's start / end suffixes on the resolved physical side
    • Physical alignments (bottom-left, left-top) → Floating UI's start / end suffixes
    • Reverse: Floating UI's left-start / left-end / right-start / right-end (not in the SWC union) map back to left-top / left-bottom / right-top / right-bottom

Storybook

Docs page at Controllers / Placement controller with 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)

  • SWC-1996

Author's checklist

  • I have read the CONTRIBUTING and PULL_REQUESTS documents.
  • I have reviewed the Accessibility Practices for this feature.
  • I have added automated tests to cover my changes.
  • I have included a well-written changeset if my change needs to be published. (internal-only controller for now; no changeset)
  • I have included updated documentation if my change required it.

Reviewer's checklist

  • Includes a Github Issue with appropriate flag or Jira ticket number without a link
  • Includes thoughtfully written changeset if changes suggested include patch, minor, or major features
  • Automated tests cover all use cases and follow best practices for writing
  • Validated on all supported browsers
  • All VRTs are approved before the author can update Golden Hash

Manual review test cases

  • Storybook docs page renders cleanly

    1. Open Storybook → Controllers / Placement controller
    2. Expect a docs page with Overview, Usage, Behaviors (Requested placement, Offset, Container padding, Should flip, Size always clamps, On placement change, Virtual trigger, Arrow), API, Accessibility, and Appendix sections
    3. No "Component not found in manifest" warnings should surface for controller stories
  • Playground reacts to every option

    1. Open the Playground story
    2. Toggle each option (placement, offset, cross-offset, container padding, should-flip) and confirm the floating panel responds immediately
  • Offset demo shows the difference

    1. Open Behaviors → Offset
    2. Compare the default (8 px) and 48 px variants; the larger-offset panel sits visibly further from the trigger
    3. Same check for crossOffset in the lower row
  • Size always clamps the list

    1. Open Behaviors → Size always clamps
    2. The 24-item list is visibly clamped; host.floatingEl.style.maxHeight reads as a numeric px value
    3. host.isConstrained is true
  • Arrow tip points at the trigger

    1. Open Behaviors → Arrow
    2. The triangular tip on the floating panel points at the trigger's center
    3. Resize the surface so the panel shifts to stay inside the viewport; the tip continues to point at the trigger's center (inset from the panel corners by tipPadding)
  • Virtual trigger follows clicks

    1. Open Behaviors → Virtual trigger
    2. Click anywhere inside the 320×320 surface
    3. The floating panel repositions near the click point with a 12 px offset; coordinates in the panel update
  • 29 placement-controller unit tests pass

    1. From 2nd-gen/packages/swc, run yarn test "core/controllers/placement-controller"
    2. All 29 tests should pass — covers conversion functions, all options, flip behaviour both ways, size middleware lifecycle, arrow tip positioning, rapid start() replacement, and disconnect cleanup

Device review

  • Did it pass in Desktop?
  • Did it pass in (emulated) Mobile? — iOS WebKit visualViewport compensation is included; verify popovers don't drift when toggling the simulated URL bar
  • Did it pass in (emulated) iPad?

Accessibility 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.

  • Keyboard — N/A. No focusable parts in the controller itself; consuming components wire their own keyboard handling.
  • Screen reader — N/A. The controller writes top / left / translate inline styles only (on the floating element and, when provided, the tip element); no roles, names, or live regions. Consumers attach aria-controls, aria-expanded, and labelling as appropriate.

Accessibility guidance for consumers is documented in the Storybook Accessibility story (under Controllers / Placement controller).

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
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 26, 2026

⚠️ No Changeset found

Latest commit: 9a3cca8

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

- 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.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 26, 2026

📚 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-6337

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.

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.
@rubencarvalho rubencarvalho changed the title Ruben/feat placement controller swc 1996 feat: add PlacementController to Gen2 May 30, 2026
@rubencarvalho rubencarvalho marked this pull request as ready for review May 30, 2026 06:59
@rubencarvalho rubencarvalho requested a review from a team as a code owner May 30, 2026 06:59
@rubencarvalho rubencarvalho added the Status:Ready for review PR ready for review or re-review. label May 30, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Status:Ready for review PR ready for review or re-review.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant