Placement
Ask for a side with placement; the panel takes it when it fits and flips to the opposite side when the viewport says no — a wish, not an order.
A panel of content anchored to a control, with the page behind it still answering.
@pacit/components/popoverpct-popoverImplements the non-modal reading of the W3C ARIA APG Dialog pattern ↗
import { Component } from '@angular/core';
import { PctButton } from '@pacit/components/button';
import { PctPopover, PctPopoverTrigger } from '@pacit/components/popover';
/** A light panel anchored to its button — not modal, the page stays alive behind it. */
@Component({
selector: 'demo-popover',
imports: [PctButton, PctPopover, PctPopoverTrigger],
template: `
<button pctButton variant="outline" [pctPopoverTrigger]="filters">
Filters
</button>
<pct-popover #filters heading="Filters">
<p>Anything can live here — a form, a list, a calendar.</p>
</pct-popover>
`,
})
export class PopoverDemo {}popover.demo.ts — the same file runs here and is highlighted under Code
One import per component, and the shortest possible use — the package itself is installed once, on the start page.
import { PctPopover } from '@pacit/components/popover';| Exported | What it is |
|---|---|
PctPopovercomponentpct-popover | A popover: a panel of content hanging off the control that opened it, with focus inside it and the rest of the page still answering. |
PctPopoverTriggerdirective[pctPopoverTrigger] | The control a popover hangs off: it toggles the panel and says so about itself. |
PctPopoverCloseReasontype'trigger' | 'escape' | 'outside' | 'away' | 'api' | Why a popover closed. open says *that* it is shut and nothing else, and the question an application asks about a panel it did not close itself — did the user answer, or did they walk away from it — is answered by this and by nothing in the boolean. |
<button pctButton [pctPopoverTrigger]="filters">Filters</button> <pct-popover #filters heading="Filters">…</pct-popover>
<!-- the same panel drawn where it stands: no layer, no anchor, and there from the server -->
<pct-popover inline [open]="true" heading="Filters">…</pct-popover>
<!-- one panel with two lives — a column on a wide page, a layer on a narrow one -->
<button pctButton [pctPopoverTrigger]="filters">Filters</button>
<pct-popover #filters [inline]="wide()" [(open)]="filtersOpen" heading="Filters">…</pct-popover>Each example is the component itself, not a screenshot. Open the code under any of them.
Ask for a side with placement; the panel takes it when it fits and flips to the opposite side when the viewport says no — a wish, not an order.
Read from the source at build time — the types, the defaults and the lines of documentation are the library's own.
A popover: a panel of content hanging off the control that opened it, with focus inside it and the rest of the page still answering.
| Name | Type | What it does |
|---|---|---|
opentwo-waydefault false | boolean | Whether the panel is up. A model, because both directions are ordinary: an application opens it, and the popover closes itself on Escape, on a press outside and on the trigger. |
headingdefault '' | string | The visible title, rendered as the panel's heading and used as its accessible name. A panel with no name is announced as "dialog" and nothing else, so an empty one is reported in dev mode rather than passed over. |
ariaLabeldefault '' | string | The accessible name of a popover with no visible heading — an INPUT and not an attribute on the tag, for the dialog's reason: the role sits on the panel inside the overlay, and the host carries none at all (req-a11y-built-in). |
ariaLabelledbydefault '' | string | As ariaLabel, for a name that already stands somewhere inside the content. |
placementdefault 'bottom' | PctPlacement | Which side of the trigger it opens on — logical, so end is the right in an English page and the left in an Arabic one. The window has the last word: a side with no room for the panel falls back to the one across the trigger (pctPlacementPositions). It says nothing inline: a panel drawn in the page is placed by the page. |
inlinedefault false | boolean | Draws the panel in the host, where the consumer wrote the component, instead of in an overlay over the page. The same template, the same classes, the same parts and the same stylesheet — a second panel written out for this case is the defect the input exists to avoid. |
| Name | Payload | When it fires |
|---|---|---|
closed | PctPopoverCloseReason | Why it closed. See PctPopoverCloseReason. |
The hooks a stylesheet or a test can hold. Stable by contract — the parts inventory gate fails the build when one disappears.
| Attribute | Bound to | Note |
|---|---|---|
class | pct-popover | |
style | display: contents | The host draws nothing of its own in either mode: with an overlay everything it draws lives outside the tree, and it stays here because it is where the panel's severed properties are read from. display: contents is also what makes the inline panel land in the consumer's own layout — the host box is not there to be a wrapper between the page's grid and the panel it wrote into it. |
Two ways in, both stable. No class names to depend on.
In a test, PctPopoverHarness, PctPopoverTriggerHarness from @pacit/components/testing read a part by the name below and a state by its attribute — the harness gate holds the names to the package.
| Part | Selector | What it is |
|---|---|---|
content | [data-pct-part="content"] | the projected body |
heading | [data-pct-part="heading"] | the title the panel is named by |
panel | [data-pct-part="panel"] | the floating box |
Every value the component reads, with the light and the dark default. Override a token at any scope and it cascades like any custom property.
| Token | Styles |
|---|---|
--pct-popover-heading-fg{pct.text}#0f172a · #ffffff | Text colour of the heading. |
--pct-popover-heading-font-size{pct.font.size.md}14px | Font size of the heading. |
--pct-popover-heading-font-weight{pct.font.weight.medium}500 | Font weight of the heading. |
--pct-popover-panel-bg{pct.surface}#ffffff · #0f172a | Background of the panel. |
--pct-popover-panel-border{pct.border-strong}#64748b | Edge colour of the panel. |
--pct-popover-panel-fg{pct.text}#0f172a · #ffffff | Text colour of the panel. |
--pct-popover-panel-gap{pct.space.3}8px | Gap between the panel's heading and its content. |
--pct-popover-panel-max-width320px | Maximum width of the panel. |
--pct-popover-panel-padding-x{pct.space.4}12px | Horizontal padding of the panel. |
--pct-popover-panel-padding-y{pct.space.4}12px | Vertical padding of the panel. |
--pct-popover-panel-radius{pct.radius.md}8px | Corner radius of the panel. |
--pct-popover-panel-shadow{pct.shadow-panel}0 8px 24px rgba(15, 23, 42, 0.14) · 0 8px 24px rgba(0, 0, 0, 0.55) | Shadow of the panel. |
A brand override in a few declarations. Scope it to a theme attribute, a page or one container — the component never notices.
[data-theme='brand'] {
--pct-popover-panel-bg: #f0fdfa;
--pct-popover-panel-fg: #134e4a;
--pct-popover-heading-fg: #0f766e;
} Pattern: the non-modal reading of Dialog — role="dialog" with no aria-modal, focus moved into the panel and given back to the trigger, Escape from the closing stack. The trigger is the Disclosure half: aria-expanded on the control, aria-haspopup="dialog", aria-controls while the panel is up
| key | effect | test |
|---|---|---|
Escape | closes it; focus goes back to the trigger if it was in the panel | apps/sandbox-e2e/src/popover.spec.ts |
Tab | from the panel, into its content; from the last control, out — closing it and returning focus to the trigger | apps/sandbox-e2e/src/popover.spec.ts |
Shift+Tab | from the first control, the same way out | apps/sandbox-e2e/src/popover.spec.ts |
Enter/Space on the trigger | toggles it, which is the button's own behaviour and nothing this component adds | libs/components/popover/src/popover.spec.ts |
The panel itself is not in the Tab order (tabindex="-1"); it is focused programmatically on opening, so that its role and name are announced before the user is anywhere inside it.
None of the first three rows is true inline. Escape and the outside press are the closing stack's and never reach a panel that was never attached; Tab walks through the panel and out the far side, which is the whole point of drawing it in the page. Each is measured there as an absence, in libs/components/popover/src/popover.spec.ts.
Every line is a test in three engines or a gate on every commit. The gaps are listed the same way, each with the requirement that will close it.
libs/components/popover/src/popover.ts — the pattern named in the class comment, together with the one attribute it must not carryapps/sandbox-e2e/src/popover.spec.ts — Escape, Tab out in both directions, in three engines; libs/components/popover/src/popover.spec.ts for the edge of the tabbable listapps/sandbox-e2e/src/a11y.spec.ts — the /popover view, and separately an open popover with the whole page: the dialog's audit is scoped to its panel because the rest is inert, and here nothing is — the trigger's aria-expanded/aria-controls only mean something measured together with what they point atapps/sandbox-e2e/src/visual.spec.ts — popover-open, the card rather than the panel: this component draws the page's own surface, so the edge is all there is between the two and a picture of the panel alone would not show it — the sandbox's navigation blanked by the stage, so a row in that list is not a pixel here (req-quality-e2e)forced-colors: activeapps/sandbox-e2e/src/forced-colors.spec.ts — the mode takes the shadow away and the panel keeps a CanvasText edge, which is then the only thing separating it from the pageprefers-reduced-motionapps/sandbox-e2e/src/popover.spec.ts — the duration read from the panel is the reduced one, and the leave still endsreq-api-size is about --pct-control-height-*; the only width here is --pct-popover-panel-max-width, which a consumer overridesapps/sandbox-e2e/src/popover.spec.ts — the gap on the inline axis measured in both writing directions. It is the one thing a screenshot would not catch: the dependency resolves start/end by direction and adds the offset as plain pixels afterwards, so a sign left physical lays the panel over the control it belongs toapps/sandbox-e2e/src/hydration.spec.ts — the /popover view renders and hydrates with no NG05xx; a closed popover contributes no markup, and the trigger's aria-expanded="false" is there from the serverinline)libs/components/popover/src/popover.spec.ts — the panel in the consumer's own tree with no CDK pane beside it, put there by a pass over the host view alone (no afterNextRender, which is the hook a server never runs), plus one case per dropped semantic: no focus taken on opening, Tab left alone in both directions, Escape and the outside press inert, no missing-trigger warning, and a switch between the two modes that moves the panel without closing it. Gap: no e2e and no screenshot — apps/sandbox has no inline view yet, so the three-engine reading and the real hydration are unmeasuredFormValueControl. A form inside one is the consumer's, and it reaches the chrome exactly as it would anywhere elseapps/sandbox-e2e/src/popover.spec.ts — a control beside the panel answers a press while the panel is up, and <html> is not locked. Measured rather than declared: it is the one promise that separates this component from the dialogapps/sandbox-e2e/src/popover.spec.ts (the panel takes focus, Tab reaches the content, Escape hands it back) + libs/components/popover/src/popover.spec.ts (the restore happens only when focus was inside, so Escape pressed from the live page behind moves nothing)apps/sandbox-e2e/src/popover.spec.ts (the panel outlives the decision to close) + libs/components/core/src/core.spec.ts (pctAfterTransition: the event, the timeout floor and the cancel)libs/components/parts.snapshot.md, tools/check-parts.mjs (target check-parts)contrast.policy.jsonlibs/tokens/src/contrast.policy.json — the panel's text, its heading, and the edge measured against the page rather than against a veil, which is where the modal and the non-modal panel part companyPCT_TEXTSnone — deliberately: the component writes no string of its ownlibs/components/size.snapshot.md — the ./popover row, with ./core beside itdocs/acr/at/ — three readers, whole sandbox, and the act that opens it. When the non-modal dialog takes focus, all three speak: NVDA Filters, dialog, VoiceOver Filters web dialog with 5 items heading level 2 Filters, Orca Filters Owner A name, or part of one · Browse mode — the role reaches two of the three, and Orca reads the panel's text with the role unsaid. On Escape the trigger says collapsed (Orca Filters · collapsed button. · opens dialog · Focus mode · collapsed); VoiceOver's Escape closed no panel at all in the pass of 2026-10-08 — the dialog's, the menu's and this one stayed open — which is the walk's reader and not this component. Arrival is as before: every trigger announces that it opens a dialog — start · collapsed button. · opens dialog (Orca), start, button, collapsed, opens dialog (NVDA), and VoiceOver, which reached Filters first in the pass of 2026-10-08, Filters dialog pop up button — VoiceOver alone omitting the collapsed state/components/popover on the published site — prerendered, the demo's own source is the code tabRead at build time from the tracked snapshots, not typed here.
It is the dialog minus one word, and every difference follows from that word: no veil, nothing made inert, no scroll lock — the page behind goes on answering, which is the whole point of a panel that hangs off a control instead of standing over the page. What it keeps is the part a panel with content cannot do without: it takes focus, it says what it is, and it hands focus back to the trigger on the way out (0031).
<dialog> — the platform's modal is not composable with ours(click)="panel.open.set(false)" — which is also why the component carries no string to translate.inline. Over the page, Tab is a way out, so a panel the user is meant to leave and come back to is not this component (0031). Drawn in the page it is exactly that shape, because the tab order it stands in is the page's own. What still sends a reader to the drawer is the other half of 0047: a drawer is docked to an edge of the window and is still text in the document when it is shut (hidden="until-found", so find-in-page reaches it), where an inline popover is in the flow where it was written and is gone entirely while open is false.