Components / overlays

Popover

There is no shared floating surface: floater takes --se-border and --se-radius-lg while listbox takes --se-border-strong and --se-radius-md. Four surfaces that ought to agree have already drifted, and whether they collapse into one is open in 3.5.

Rendered from the package · open on its own

Summary

A transient panel for content that needs a click — the test that separates it from a Tooltip.

APG patternnone
Built onnative elements
Statusstable
Since0.1.0
<Popover label="Share this portfolio" trigger={<Button …>Share</Button>}>
  <Field id="link" label="Portfolio link"><input type="text" readOnly /></Field>
</Popover>

Anatomy

A data-se="popover" panel carrying role="dialog", anchored to its trigger.

PartElementRequired
floater<div>yes

Properties

PropertyValuesDefaultWhat it does
triggerReact.ReactElementrequiredThe trigger element — usually a <Button>. Cloned to receive aria-expanded and onClick.
labelstringrequiredAccessible name for the panel.
childrenReact.ReactNoderequiredThe panel's contents. Reachable by Tab once open; the popover moves no focus of its own.

States

StateExpressed as
Open or closeddata-state

Behavior

It opens on click and dismisses on outside click or Escape. It is transient, not modal: it never dims the page and never traps focus.

Accessibility

role="dialog" needs a name, which is what label is for. A dialog with no name announces as a dialog and nothing more.

Closed means gone, not hidden. The floater is display: none at rest, so nothing inside it is reachable or announced — a panel that is merely invisible leaves its contents in the tab order.

Keyboard

KeyResult
EscapeCloses and returns focus to the trigger

Roles, states and properties

AttributeSet by
aria-labelset at render
rolealways dialog

Focus

It never traps: Tab leaves the panel, which is what separates the floating level from the overlay level.

It also never moves focus in. Opening leaves focus on the trigger, so a keyboard reader has to Tab forward into the panel — which works, because the panel follows the trigger in document order. See Known gaps.

Screen reader

Not recorded as verified. Stage 4 of the readiness ladder — driving each component with VoiceOver and NVDA — is unstarted across the package, and no component should claim otherwise until docs/journal/ records the session that did it.

Contrast and target size

npm run check:contrast audits every color pair this component uses across all four skins, light and dark, and fails the build on a shortfall. Two waivers are on the record in docs/audit/DEFERRED.md.

It sets no target size of its own; anything interactive inside it brings its own.

Tested

  • test/browser/popover.spec.mjs

Known gaps

_No upstream requirement is recorded as not applicable._

Popover is not an APG pattern, so there is no upstream suite to be measured against. Two gaps found by reading the code rather than by a requirement:

  • Focus does not move into the panel on open, and does not return to the trigger on close. role="dialog" normally implies both.
  • The trigger carries no aria-haspopup="dialog". A reader is told the state and not what opens.

Both are asserted as-is in test/browser/popover.spec.mjs, so neither can change without the test saying so. See Notes for why fixing them may not be the right move.

Appearance

TokenUsed for
--se-borderborder
--se-border-widthborder
--se-duration-fastanimation
--se-ease-decelerateanimation
--se-layer-floatingz-index
--se-radius-lgborder-radius
--se-shadow-floatingbox-shadow
--se-space-2inset-block-start
--se-space-4padding
--se-surfacebackground

--se-shadow-floating, not the overlay shadow. Height tells the reader how to get rid of something, so borrowing a level it does not answer to is a lie about dismissal.

Motion

It rises over --se-duration-fast on --se-ease-decelerate.

Content

label names what the panel is for — Share this portfolio. It is heard on open, before anything inside.

Usage

Needs a click, so it is not a Tooltip. Anything a reader has to interact with belongs here.

Does not dim or trap, so it is not a Dialog. If the reader must deal with it before continuing, it is a Dialog.

When not to use it. For a list of actions — that is a Menu, which has a keyboard contract this does not.

Notes

Two things it does not do, both recorded rather than fixed. role="dialog" normally implies focus moves into the panel on open and returns to the trigger on close; this one does neither. And the trigger carries aria-expanded but no aria-haspopup="dialog", so a screen reader is told the state without being told what opens.

Neither is a silent gap any more: test/browser/popover.spec.mjs asserts the current behavior in both cases, so implementing either one fails the test and forces this note to be rewritten. Whether they should be implemented is the design owner's call — a transient panel that steals focus is its own defect, and the honest fix may be to drop role="dialog" rather than to add the focus management.

Hand-written, not Radix. Outside-click and Escape are a pointerdown listener and a keydown listener, which is all a transient panel needs — it traps nothing.

There is no shared floating surface: floater takes --se-border and --se-radius-lg while listbox takes --se-border-strong and --se-radius-md. Four surfaces that ought to agree have already drifted, and whether they collapse into one is open in 3.5.