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.
Summary
A transient panel for content that needs a click — the test that separates it from a Tooltip.
| APG pattern | none |
| Built on | native elements |
| Status | stable |
| Since | 0.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.
| Part | Element | Required |
|---|---|---|
floater | <div> | yes |
Properties
| Property | Values | Default | What it does |
|---|---|---|---|
trigger | React.ReactElement | required | The trigger element — usually a <Button>. Cloned to receive aria-expanded and onClick. |
label | string | required | Accessible name for the panel. |
children | React.ReactNode | required | The panel's contents. Reachable by Tab once open; the popover moves no focus of its own. |
States
| State | Expressed as | |
|---|---|---|
| Open or closed | data-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
| Key | Result |
|---|---|
Escape | Closes and returns focus to the trigger |
Roles, states and properties
| Attribute | Set by |
|---|---|
aria-label | set at render |
role | always 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
| Token | Used for |
|---|---|
--se-border | border |
--se-border-width | border |
--se-duration-fast | animation |
--se-ease-decelerate | animation |
--se-layer-floating | z-index |
--se-radius-lg | border-radius |
--se-shadow-floating | box-shadow |
--se-space-2 | inset-block-start |
--se-space-4 | padding |
--se-surface | background |
--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.