Skip to content

feat(popover): lifecycle, positioning, a11y + styling (phase 4+5, WIP)#6357

Draft
rubencarvalho wants to merge 46 commits into
ruben/popover-migrationfrom
ruben/feat-popover-a11y-styling-swc-1993
Draft

feat(popover): lifecycle, positioning, a11y + styling (phase 4+5, WIP)#6357
rubencarvalho wants to merge 46 commits into
ruben/popover-migrationfrom
ruben/feat-popover-a11y-styling-swc-1993

Conversation

@rubencarvalho

@rubencarvalho rubencarvalho commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Description

Combined Phase 4 (a11y) + Phase 5 (styling) of the Popover migration (Epic SWC-1993), plus the runtime lifecycle that was deferred out of Phase 3. Combining them was deliberate: the component was invisible because the open/show lifecycle wasn't implemented (a <div popover="auto"> that never gets showPopover()), and CSS alone can't reveal it — a11y, positioning, and CSS are all coupled through that lifecycle.

⚠️ Work in progress (draft). This establishes a functional, visible, anchored baseline for phases 4+5. It is not review-final — Phase 6 (test depth), Phase 7 (docs MDX), and a changeset are still owed. See "Remaining work".

What's implemented

  • Lifecycle: openshowPopover() (default) / showModal() (modal), and hidePopover() / close(), with a _syncingOpen guard against setter↔listener loops. Events swc-open / swc-after-open / swc-close / swc-after-close (0-duration transition guard) via beforetoggle (default) and cancel / close (modal). Modal backdrop-click via pointerdown + event.target === dialog. swc-close.detail.source reports escape / outside / programmatic.
  • Positioning: lazy PlacementController start/stop anchored to the resolved trigger; tip wired through the arrow middleware. The surface is position: absolute to match the controller's Floating UI strategy: 'absolute' — a mismatch (fixed + absolute coordinates) previously made the popover drift on page scroll.
  • Computed placement (matches Tooltip): the flip-resolved physical side is reflected as an internal actual-placement host attribute (set via setAttribute, removed on close) and styled via :host([actual-placement]). It is not a public property — the readonly actualPlacement property was dropped during review on feat(popover): scaffold + API contract (phases 2+3) #6354 (a readonly property is still writable at runtime and could desync from the controller).
  • Utils (real impls): dismissible-stack (LIFO) and resolve-trigger (for= / trigger-element + open-shadow inner-<button> discovery).
  • a11y: durable ariaControlsElements, aria-expanded, aria-haspopup="dialog" (modal); dismissibleStack Escape coordination. The Q4 accessibility-analysis amendment is included.
  • CSS: S2 surface (background-layer-2, popover border, corner-radius-100, elevated drop-shadow) + UA-chrome resets so the controller anchors the element; content padding; modal ::backdrop; tip orientation per physical side via :host([actual-placement]); forced-colors.
  • Stories: Playground, Overview (anchored, open, with tip), and a Custom anchor behaviors story that decouples the toggle control from the positioning anchor (button drives open; popover anchors to a link via triggerElement with manual).
  • Events manifest fix: after-events are dispatched from caller-built CustomEvents with string-literal types, so the CEM no longer records a spurious type event.
  • Fixes SWC-1336 / [Bug]: Overlay in focus region dismissed when click into its content #5731 (overlay in a focus region dismissed when clicking into its content). Gen2 relies on native popover="auto" light-dismiss, which treats the popover's own flat-tree (including slotted) content as "inside," so clicking or focusing content no longer dismisses it — even when the popover sits inside an ancestor focus region (tabindex="0"). Covered by the FocusRegionContentClickTest regression test.

Review changes carried in from #6354

This branch merges the API branch (#6354), so it includes the review responses there: the dropped public actualPlacement property, and the migration-plan B8 / computed-placement updates describing the actual-placement attribute strategy.

Remaining work (not exhaustive)

  • Changeset — not yet added; required before merge (publishable component).
  • Phase 6 (testing): behavioral coverage per mode (open/close, source detection, focus trap, backdrop-click, ARIA wiring), plus dismissible-stack / resolve-trigger unit tests. Current tests cover the API contract, render shape, and the actual-placement reflection.
  • Phase 7 (docs): per-component popover.mdx docs page and full Anatomy / Options / States / Behaviors / Accessibility stories.
  • Pixel-accurate tip geometry vs Figma; RTL physical-side tip parity (SWC-917) — marked @todo in popover.css.
  • swc-close.detail.source escape-vs-outside detection in default mode uses a keydown heuristic — revisit.
  • Visual review / VRT, screen-reader passes per mode.

Motivation and context

Phase 3 left the component API-complete but invisible (no lifecycle). This PR makes it functional and visible, lands the shared positioning/dismissal utilities, and brings the a11y and styling surfaces to a reviewable baseline for the remaining phases.

Related issue(s)

Notes

Accessibility testing checklist

  • Keyboard — default mode: Escape + click-outside dismiss (native popover="auto"); no focus trap. Modal mode: native <dialog> focus trap + Escape (cancel); backdrop-click closes. Trigger receives aria-expanded. Manual keyboard + screen-reader passes pending in Phase 6.
  • Screen reader — trigger announces the controls relationship (ariaControlsElements) and expanded state; modal announces role="dialog" with aria-haspopup="dialog" on the trigger. Pending verification in Phase 6.

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
…hase 4+5)

Combine a11y + styling and pull in the deferred lifecycle so the component
is visible and functional (work in progress; polish remains):

- utils: real dismissible-stack (LIFO) and resolve-trigger (for= /
  trigger-element + open-shadow inner-button discovery)
- lifecycle: open -> showPopover()/showModal() and hidePopover()/close()
  with a syncing guard; swc-open/after-open/close/after-close dispatch
  (0-duration transition guard) via beforetoggle (default) and cancel/close
  (modal); modal backdrop-click; swc-close.detail.source detection
- positioning: lazy PlacementController start/stop, tip arrow wiring,
  actualPlacement + reactive .swc-Popover--<placement> class
- a11y: durable ariaControlsElements, aria-expanded, aria-haspopup (modal);
  dismissibleStack Escape coordination; Q4 a11y-analysis amendment
- css: S2 surface + UA-chrome resets, content padding, modal ::backdrop,
  tip orientation per side group, forced-colors
- stories: trigger + anchored open popover with tip
- plan/status: completion notes; status table through Render & Style

Deferred polish (todo in popover.css): pixel-accurate tip geometry vs Figma
and RTL logical-placement tip parity (SWC-917).
@changeset-bot

changeset-bot Bot commented Jun 1, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: f7ab72e

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

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 and others added 23 commits June 1, 2026 11:51
The component now opens/closes on trigger click instead of being purely
property-driven:

- attach a click listener to the resolved trigger that toggles open; a
  #lastDismissAt guard prevents the native light-dismiss from immediately
  reopening when clicking an open popover's trigger
- new 'manual' property (reflected, mirrors tooltip) suppresses the auto
  wiring so Picker/Menu drive open themselves; ARIA wiring still applies
- listener lifecycle managed on trigger/manual change and disconnect
- Playground story starts closed with a Toggle button to demo the interaction
- ClickToggleTest verifies open-on-click and close-on-click (aria-expanded)
- plan: Invocation pattern rewritten, manual added to the property table and
  TL;DR additions, completion note updated
Remove end-user-facing references to internal consumers (Picker/Menu) and
to the popover API; `open` is the public surface for controlling visibility.
Applies to the manual property JSDoc and the migration plan.
- public API mirrors the S2 Popover: add `size` (s/m/l), `offset` default 8,
  `should-flip` public, and replace `tip` with `hide-arrow` (arrow shown by
  default). `container-padding`/`tip-padding` stay @internal.
- styling: fixed size widths, viewport max-width, and a 200ms opacity fade via
  @starting-style (tokenized duration + easing) with a reduced-motion guard.
- fix: defer PlacementController teardown until the close transition ends so
  the arrow no longer snaps to the edge during the close fade.
- convention: convert #private members to the private _underscore form.
- remove code comments that referenced React; tokenize transition timings.
- plan: property table, internal-set note, B7, and architecture/checklist
  references updated to the S2-aligned surface.
The arrow is a 45deg-rotated square, whose visible width is side × √2. It
was sized at the full tip width, rendering ~√2 too wide. Size the square at
tip-width ÷ √2 so the visible arrow is popover-tip-width (16px) wide by
popover-tip-height (8px) tall, and derive the edge insets from the same size.
Replace the single-layer drop-shadow with the elevated token's three
layers (ambient, transition, key). Arrow shown (default): filter drop-shadow
so the arrow casts a matching shadow; hide-arrow: box-shadow form of
`--swc-drop-shadow-elevated`, which composites cleanly with nested popovers.
Drive the Storybook controls from Popover.VALID_PLACEMENTS (22 values) and
Popover.VALID_SIZES (s/m/l, plus undefined to fit contents) instead of the
default text inputs.
Swap the native <button> trigger for <swc-button> in the Playground and
Overview stories, exercising the cross-shadow inner-<button> discovery in
resolveTrigger (ARIA wiring lands on the button inside swc-button's shadow root).
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.
…r-a11y-styling-swc-1993

# Conflicts:
#	2nd-gen/packages/swc/components/popover/Popover.ts
…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
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.
… into ruben/feat-popover-a11y-styling-swc-1993

# Conflicts:
#	2nd-gen/packages/core/utils/dismissible-stack.ts
#	2nd-gen/packages/core/utils/resolve-trigger.ts
#	2nd-gen/packages/swc/components/popover/Popover.ts
#	2nd-gen/packages/swc/components/popover/stories/popover.stories.ts
#	2nd-gen/packages/swc/components/popover/test/popover.test.ts
#	CONTRIBUTOR-DOCS/03_project-planning/03_components/popover/migration-plan.md
- Re-enable the Playwright a11y suite (this branch implements the lifecycle, so
  the overview story mounts a real popover).
- `hide-arrow` is the decided attribute name; drop the "under active discussion"
  / "name may change" notes from the migration plan (B7 row and the hide-arrow
  property row).
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.
The PlacementController computes coordinates with Floating UI strategy
'absolute', but the surface was 'position: fixed'. Applying absolute
(document-relative) coordinates to a fixed element made the popover drift
with page scroll instead of staying anchored. Align with the landed tooltip
convention (position: absolute) so the strategy and CSS position match.
_dispatchAfter built its event as new CustomEvent(type, ...) from a parameter,
so the manifest analyzer recorded a spurious 'type' event. Callers now build
the CustomEvent with a string literal and pass it in, so the manifest records
swc-after-open / swc-after-close and no 'type' row.
Demonstrate decoupling the toggle control from the positioning anchor: a
button drives open while the popover anchors to a link via triggerElement,
with manual set so the link's own click does not toggle.
…r-a11y-styling-swc-1993

Brings in origin/main (tooltip, button-group) and the API-branch change that
drops the public actualPlacement property. Resolving the latter, the popover now
adopts Tooltip's strategy: the computed placement is reflected as an internal
`actual-placement` host attribute (set via setAttribute, removed on close) and
styled via :host([actual-placement]), replacing the .swc-Popover--<placement>
modifier classes. Adds an actual-placement test and the Storybook argTypes guard,
and updates the migration plan B8 row accordingly.
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.
@rubencarvalho rubencarvalho force-pushed the ruben/feat-popover-a11y-styling-swc-1993 branch from 6569866 to b59a396 Compare June 17, 2026 18:54
Move the docs page to a per-component popover.mdx (Phase 7 start) referencing
the existing stories: Overview via DocsHeader and the Custom anchor behaviors
story via Canvas. Anatomy / Options / States / Accessibility sections are rough
scaffolds to be authored and polished later. Drop 'autodocs' from the Playground
tags so the MDX page is the single Docs entry.
…ben/feat-popover-a11y-styling-swc-1993

# Conflicts:
#	2nd-gen/packages/core/utils/dismissible-stack.ts
#	2nd-gen/packages/core/utils/resolve-trigger.ts
#	2nd-gen/packages/swc/components/popover/Popover.ts
#	2nd-gen/packages/swc/components/popover/popover.css
#	2nd-gen/packages/swc/components/popover/stories/popover.stories.ts
#	2nd-gen/packages/swc/components/popover/test/popover.a11y.spec.ts
#	2nd-gen/packages/swc/components/popover/test/popover.test.ts
#	CONTRIBUTOR-DOCS/03_project-planning/02_workstreams/02_2nd-gen-component-migration/01_status.md
#	CONTRIBUTOR-DOCS/03_project-planning/03_components/popover/migration-plan.md
The popover had its dialog lifecycle, trigger/ARIA wiring, PlacementController
integration, dismissal coordination, and event dispatch in the SWC concrete
class — the inverse of the base-vs-concrete style guide and of Tooltip (whose
base owns the behavior). Move all of it into PopoverBase; the concrete class now
supplies only styles, the render template, and the internalElement/tipElement
getter overrides the base reads (mirroring Tooltip's tipElement). Public API
(attributes, events) is unchanged. Updates the migration plan's core/SWC split,
which had prescribed the behavior-in-concrete layout.
@rubencarvalho rubencarvalho force-pushed the ruben/feat-popover-a11y-styling-swc-1993 branch from c822e2b to 37365e6 Compare June 17, 2026 20:27
transitionDuration is comma-separated when multiple properties transition
(opacity, overlay, display). The previous parseFloat(...) === 0 check only read
the first entry, so a zero first value with a non-zero later value (e.g.
"0s, 0.2s") would dispatch the after-event early. Match Tooltip: split on comma
and require every value to be 0s before dispatching immediately.
…ning teardown

- Add a shared core/utils `hasActiveTransition(element)` helper that does the
  comma-split, every-zero transition-duration check in one tested place, so the
  logic is consistent across components (tooltip can adopt in a follow-up, like
  resolve-trigger / dismissible-stack).
- Replace the event-aware `_dispatchAfter(event)` with a generic
  `_afterTransition(callback)` that runs after transitionend (or immediately when
  there is no transition). The after-open/after-close events and the
  `_stopPositioningWhenClosed()` teardown now live in the callers: teardown is an
  explicit peer of the after-close dispatch rather than being gated inside the
  helper by `event.type`. It stays tied to transition completion (tearing down
  mid-fade would snap the surface to 0,0).
…rationMs

Use the new core/utils transition helpers in Tooltip's toggle handler so the
comma-separated transition-duration parsing lives in one place across components.
Adds maxTransitionDurationMs to the util for Tooltip's allow-discrete fallback
timer, removing its inline parsing. Also tidies the popover teardown comment to
describe the code rather than the change.
Gen1 (#5731 / SWC-1336) dismissed an auto overlay when clicking into its own
content if it sat inside a focus region (an ancestor with tabindex=0). Add a
regression test placing the popover inside such a region and asserting that
clicking and focusing content inside the popover keeps it open — Gen2 relies on
native popover=auto light-dismiss, which excludes the popover's own flat-tree
(including slotted) content from 'outside', so the bug does not reproduce.
…comment

The trigger-click guard is unchanged in behavior — a short time window is the
robust, browser-agnostic way to read a light-dismiss + reopen as a single click
gesture (native popovertarget correlation can't reach the shadow-DOM surface
across roots). Extract the 200ms magic number to a named constant and rewrite the
comment to explain the platform gap rather than narrate the mechanism.
The trigger-click reopen guard was stamped on every close, so an Escape or
programmatic close followed by a quick trigger click was wrongly suppressed.
Pass the close source through to _closeTeardown and stamp _lastDismissAt only
when source is 'outside' — the only close a trigger click can have caused. Adds
a regression test that a programmatic close still reopens on click.
The token-based arrow gap was a CSS margin keyed off actual-placement, but a
margin only shifts the absolutely-positioned surface on its block-start /
inline-start sides — so top and left placements got no gap (the surface sat
flush against the trigger). Read the token-derived --_swc-popover-tip-height and
add it to the PlacementController's main-axis offset instead, which is applied
direction-aware on every side. Keeps the gap token-synced; removes the margin
rules.
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.

1 participant