Skip to content

feat(popover): scaffold + API contract (phases 2+3)#6354

Merged
rubencarvalho merged 16 commits into
ruben/popover-migrationfrom
ruben/feat-popover-api-swc-1993
Jun 17, 2026
Merged

feat(popover): scaffold + API contract (phases 2+3)#6354
rubencarvalho merged 16 commits into
ruben/popover-migrationfrom
ruben/feat-popover-api-swc-1993

Conversation

@rubencarvalho

@rubencarvalho rubencarvalho commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Description

Foundation for the Popover migration (Epic SWC-1993): Phase 2 (Setup) + Phase 3 (API contract). No runtime behavior — the lifecycle, positioning, ARIA wiring, and styling land in #6357.

Phase 2 — Setup

  • core: PopoverBase (property surface), Popover.types, and the resolve-trigger / dismissible-stack util stubs wired into utils/index + package.json.
  • swc: Popover render stub (branches <div popover="auto"> vs <dialog> on modal), popover.css stub, swc-popover registration, and empty-but-valid stories + tests.

Phase 3 — API contract (aligned with React Spectrum S2 Popover)

Public surface: placement, size (s/m/l), offset (default 8), cross-offset, should-flip, hide-arrow (arrow shown by default), plus the web-component-specific open, modal, for, trigger-element, manual, and the readonly actualPlacement.

  • types: Placement (re-exported from the PlacementController), POPOVER_VALID_PLACEMENTS, POPOVER_VALID_SIZES, PopoverSize, PopoverCloseSource, PopoverCloseEventDetail.
  • base: full property surface; container-padding / tip-padding marked @internal (positioning details, excluded from the public API table); dev-only placement validation via the VALID_PLACEMENTS static.
  • swc: @fires for swc-open / swc-after-open / swc-close / swc-after-close (surfaced in the CEM; @internal props excluded).

Related issue(s)

  • Epic SWC-1993

Notes

Accessibility testing checklist

Set up the core and SWC structure for the <swc-popover> migration:

- core: PopoverBase property-surface stub, Popover.types (re-exports
  Placement from the placement-controller), plus resolve-trigger and
  dismissible-stack util stubs wired into utils/index and package.json
- swc: Popover render stub branching <div popover="auto"> (non-modal) vs
  <dialog> (modal), popover.css stub, swc-popover registration, and
  empty-but-valid stories + test scaffolds (3/3 vitest storybook pass)
- plan: record div/dialog render decision, mark container-padding,
  should-flip and tip-padding @internal, arrow positioning in v1 scope,
  and check off the Setup phase; update workstream status table
Define the typed public API contract for <swc-popover> (contract only;
runtime lifecycle deferred to phases 4-5):

- types: add PopoverCloseSource docs and PopoverCloseEventDetail for the
  swc-close event
- base: runtime placement validation in update() via the VALID_PLACEMENTS
  static (dev-only window.__swc.warn, supports the proxy pattern); fix
  @internal JSDoc format on container-padding / should-flip / tip-padding
- swc: @fires JSDoc for swc-open / swc-after-open / swc-close /
  swc-after-close (verified surfaced in the CEM; @internal props excluded)
- plan: check off API contract items, note lifecycle deferral; status table
@changeset-bot

changeset-bot Bot commented Jun 1, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 1718f78

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-6354

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.

@rubencarvalho rubencarvalho changed the title feat(popover): API contract (phase 3) feat(popover): scaffold + API contract (phases 2+3) Jun 1, 2026
Bring the foundation PR's public surface up to the final S2-aligned
contract so reviewers see what actually ships: add `size` and `manual`,
replace `tip` with `hide-arrow` (arrow shown by default), default `offset`
to 8, and make `should-flip` public. `container-padding` / `tip-padding`
stay @internal. Types gain PopoverSize and PopoverCloseEventDetail. Runtime
behavior still lands in the styling/a11y PR.
@rubencarvalho rubencarvalho marked this pull request as ready for review June 2, 2026 14:42
@rubencarvalho rubencarvalho requested a review from a team as a code owner June 2, 2026 14:42
@rubencarvalho rubencarvalho added ready-for-review Status:Ready for review PR ready for review or re-review. and removed ready-for-review labels Jun 2, 2026

@Rajdeepc Rajdeepc 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.

Some of my findings:

  1. tiphide-arrow rename is undocumented in the migration plan API table (B7 breaking change needs updating)
  2. offset default is 8 in code but 0 in the plan. This needs reconciliation
  3. size and manual are new properties not yet in the plan API table
  4. Stub utilities (dismissible-stack, resolve-trigger) are exported but non-functional
  5. actualPlacement is writable by consumers despite being documented as readonly

* Readonly; not reflected as an attribute.
*/
@property({ attribute: false })
public actualPlacement: Placement | null = null;

@Rajdeepc Rajdeepc Jun 3, 2026

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.

The migration plan says this is readonly updated by the controller via onPlacementChange callback, but @property + public means consumers can freely write to it:

popover.actualPlacement = 'top'; // silently desyncs from controller

Would it be ok to add a @readonly JSDoc so that the CEM communicates the intent.

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.

For consideration - tooltip ended up setting this via regular attribute methods and not exposing here. Maybe @readonly accomplishes the same thing, but it felt like it should be more protected. I may have had the wrong train of thought, though!

* @default false
*/
@property({ type: Boolean, reflect: true, attribute: 'hide-arrow' })
public hideArrow = false;

@Rajdeepc Rajdeepc Jun 3, 2026

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.

The migration plan API table still documents tip as a boolean (opt-in, default false). Your code implements hideArrow with attribute hide-arrow. You need to document this as breaking change and update the API table.

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.

For consideration: Tooltip was tentatively planning a no-tip property (additive, not yet implemented). Either way, it would be good to have consistent naming.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I have an active thread with Nadeen on this, because design calls it "tip" and React an arrow. Trying to figure out which way to go. We called "tip" on Gen1 too.

* @default 8
*/
@property({ type: Number })
public offset = 8;

@Rajdeepc Rajdeepc Jun 3, 2026

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.

The migration plan API table says:

offset | number | 0 | ... we default to 0 to make the controller-host contract neutral

But the code defaults to 8. If 8 is correct kindly document it.

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.

You will likely have to take a cue from Tooltip as far as styling is concerned where I adjusted it to use the offset via a custom property in the CSS with a token fallback, that way the distance stays in sync and the CSS didn't add on top.

* @default true
*/
@property({ type: Boolean, reflect: true, attribute: 'should-flip' })
public shouldFlip = true;

@Rajdeepc Rajdeepc Jun 3, 2026

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.

The migration plan marks should-flip as Internal (@internal) but the JSDoc here has no @internal tag, unlike containerPadding and tipPadding which do.
Without the tag, the CEM will surface shouldFlip in the public API table.

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.

I recommend keeping this public - the flipping behavior is rather aggressive and may be unwanted in cases such as long menus or pickers (unless we will be blocking page scroll while open).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, 1) I tried aligning with React here and 2) Steph's comment regarding aggressive flipping. I also have an active thread with Nadeen regarding this "aggressive flipping" concern.

* (`s` → 336px, `m` → 416px, `l` → 576px); when unset, it fits its contents.
*/
@property({ type: String, reflect: true })
public size?: PopoverSize;

@Rajdeepc Rajdeepc Jun 3, 2026

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.

Kindly add size as is not documented in the migration plan API table

* Resolve the trigger for a popover-like host and discover the AT-facing inner
* button across shadow boundaries.
*
* @todo Phase 3 (API): implement `for=` resolution via

@Rajdeepc Rajdeepc Jun 3, 2026

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.

@todo Phase 3 (API): implement for= resolution...

This PR is Phase 3. The todo should reference Phase 4/5 (lifecycle implementation).

Also: the function returns { trigger: null, interactiveElement: null } unconditionally. Any code importing and calling it will get a null trigger with no warning. Since the types and interface are published to core/utils/index.ts, this could be consumed by a parallel work.


// The render shape branches on `modal`: a `<div popover="auto">` in the
// default (non-modal) mode, a `<dialog>`(.showModal()) in modal mode.
return this.modal

@Rajdeepc Rajdeepc Jun 3, 2026

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.

If modal changes at runtime while the popover is open, Lit will swap the entire internal element (<div><dialog>) and will destroy the existing top-layer session (showPopover() / showModal() state
and all attached event listeners (beforetoggle, cancel, etc.)
Phase 4/5 should guard against this: if open is true when modal changes, you should close the current element first, let Lit re-render, then re-open with the new lifecycle. A dev-mode warning would also help.

@Rajdeepc Rajdeepc self-assigned this Jun 3, 2026
Rajdeep Chandra and others added 8 commits June 10, 2026 17:18
…se ref

- registerDismissible/unregisterDismissible were no-op stubs exported
from core/utils; any parallel work importing them would silently get
broken stack coordination
- add duplicate-guard push and lastIndexOf-based removal so the stack
actually tracks open dismissibles
- correct @todo in resolve-trigger from Phase 3 to Phase 4/5 since this
PR is Phase 3
… guard

- mark actualPlacement as @readonly so CEM and docs communicate the
contract that consumers should never write to it
- add @todo Phase 4/5 comment in Popover.ts documenting the need to
guard against runtime modal toggles while the popover is open
- replace tip row with hide-arrow documenting the inverted semantics and
breaking change; note active naming discussion with design
- update offset default from 0 to 8 to match Popover.base.ts
- add size and manual properties to the API table
- promote should-flip from @internal to public per reviewer feedback on
aggressive flipping behavior
- document runtime modal toggle edge case and close/reopen guard
- property defaults: verify all 11 public properties match the
documented API contract (open, modal, placement, size, offset, etc.)
- default mode render shape: assert div[popover=auto] with content
wrapper and arrow tip
- modal mode render shape: assert dialog without popover attribute
- hide-arrow: confirm tip suppression when hide-arrow is set
- property reflection: placement, modal, size, and should-flip reflect
to attributes after mutation
- dev-mode warnings: invalid placement triggers window.__swc.warn;
valid placement does not
# 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/core/package.json
#	2nd-gen/packages/swc/.storybook/DocumentTemplate.mdx
#	2nd-gen/packages/swc/.storybook/main.ts
#	2nd-gen/packages/swc/.storybook/preview.ts
Aligns the shared PlacementController with the swc-2017/tooltip branch's
unlanded refinements, which the popover (top-layer) surface also depends on:

- Use Floating UI strategy 'absolute' instead of 'fixed' for correct
  top-layer element placement.
- stop() no longer removes inline styles (translate/top/left and the
  --swc-placement-available-* custom properties); cleanup is left to the
  caller so exit transitions can complete before the properties are removed.
- Update the multi-controller test to assert on actualPlacement only, since
  stop() no longer clears the available-space custom property.
…ben/feat-popover-api-swc-1993

# Conflicts:
#	2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts
@rubencarvalho rubencarvalho requested review from 5t3ph and Rajdeepc June 15, 2026 21:05
CEM event tags name a custom event (e.g. `swc-open`), not a JS namepath, so
the hyphen tripped jsdoc/valid-types. Add a structuredTags setting so `fires`
and `event` names are parsed as free text with no bracketed type, clearing the
warnings repo-wide for popover (and tooltip) without altering the JSDoc.
…abel

- Rename the section separator to "PLAYGROUND STORY" per the updated
  stories-format rule.
- Drop the redundant per-story render functions that just re-called the meta
  render; use args directly so the default render applies.
- Remove the now-unused lit `html` import.
With no open/close lifecycle yet, the overview story renders an unopened
top-layer surface with no meaningful accessibility tree to assert against, so
the Playwright a11y spec fails. Mark the suite `.skip` with a note to re-enable
once `showPopover()`/`showModal()`, ARIA wiring, and modal semantics land in
Phase 4/5.

@Rajdeepc Rajdeepc 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.

Approving since most of the concerns are addressed.
Few nits

  1. You can add unit tests for dismissible-stack.ts (register/unregister/isTop)
  2. It would be great if you can document closest('[dir]') shadow boundary limitation in PlacementController JSDoc.

@5t3ph 5t3ph 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.

Looks good! Just some suggestions to consider for the later phases.

* Readonly; not reflected as an attribute.
*/
@property({ attribute: false })
public actualPlacement: Placement | null = null;

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.

For consideration - tooltip ended up setting this via regular attribute methods and not exposing here. Maybe @readonly accomplishes the same thing, but it felt like it should be more protected. I may have had the wrong train of thought, though!

* @default 8
*/
@property({ type: Number })
public offset = 8;

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.

You will likely have to take a cue from Tooltip as far as styling is concerned where I adjusted it to use the offset via a custom property in the CSS with a token fallback, that way the distance stays in sync and the CSS didn't add on top.


const { events, args, argTypes, template } = getStorybookHelpers('swc-popover');

/**

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 at this stage] reminder to switch to MDX for final docs


/* @todo Phase 5 (styling): port S2 source from spectrum-css and add placement modifiers. */

* {

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.

[FYI] You'll probably want to make the agent aware of tooltip, and copy most if not all of the :host styles onto .swc-Popover to correctly place and transition the element, plus take its cues to resolve some of the other positioning styles.

@@ -69,12 +69,12 @@

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] consider adding a decision log to capture the update to change from a div[popover] for both modal and non-modal to instead the swap to dialog

rubencarvalho added a commit that referenced this pull request Jun 17, 2026
Address Steph's review on PR #6354:

- Remove the public readonly `actualPlacement` property from PopoverBase.
  Mirroring Tooltip, the computed placement is internal state that drives
  the `.swc-Popover--<placement>` modifier classes; it is not exposed as a
  property or attribute. A public readonly property is still writable at
  runtime and risked desyncing the component from the controller.
- Drop the corresponding default-value assertion from the popover test.
- Update the migration plan: B8 row, public API table/intro, the computed
  placement section, and add a Decision log capturing this change and the
  div[popover] -> dialog swap for modal mode.
Address Steph's review on PR #6354:

- Remove the public readonly `actualPlacement` property from PopoverBase. A
  readonly property is still writable at runtime and risked desyncing the
  component from the controller. The computed placement stays internal and
  drives the `.swc-Popover--<placement>` modifier classes for styling.
- Drop the corresponding default-value assertion from the popover test.
- Update the migration plan (B8 row, public API table/intro, computed
  placement section) to reflect the removal. Note that Tooltip reaches the
  same "no writable property" outcome by reflecting an `actual-placement`
  host attribute, whereas the popover styles its internal surface element
  with modifier classes.
@rubencarvalho rubencarvalho force-pushed the ruben/feat-popover-api-swc-1993 branch from 4127ce7 to 1718f78 Compare June 17, 2026 08:59
@rubencarvalho rubencarvalho merged commit bcf04d8 into ruben/popover-migration Jun 17, 2026
17 of 26 checks passed
@rubencarvalho rubencarvalho deleted the ruben/feat-popover-api-swc-1993 branch June 17, 2026 09:09
rubencarvalho added a commit that referenced this pull request Jun 17, 2026
Per Steph's review (#6354), mirror Tooltip: the arrow's clearance is now a
token-based margin (popover-tip-height) on the trigger-facing side of the
surface, keyed off the actual-placement host attribute, instead of a hard-coded
ARROW_SPACE constant added to the controller offset. The consumer's offset is
passed through unchanged, keeping a single source of truth in CSS. Also carries
the no-arrow rename through Popover.ts/popover.css and reconciles the migration
plan (B7/B8, API table, S2/S5, computed-placement section, checklists).
rubencarvalho added a commit that referenced this pull request Jun 17, 2026
Per Steph's review (#6354), mirror Tooltip: the arrow's clearance is now a
token-based margin (popover-tip-height) on the trigger-facing side of the
surface, keyed off the actual-placement host attribute, instead of a hard-coded
ARROW_SPACE constant added to the controller offset. The consumer's offset is
passed through unchanged, keeping a single source of truth in CSS. Adds an
actual-placement reflection test and reconciles the migration plan (B8, API
table, S2/S5, computed-placement section, checklists) with the actual-placement
attribute mechanism. The hide-arrow attribute name is unchanged.
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.

3 participants