Popover
composantA panel opened by a button, on the HTML popover API: it opens and closes, Escape and a click outside close it, without JavaScript; the controller places it next to its button.
Aperçu
Pick a category.
Code
<twig:Popover id="filters">
<twig:Popover:Trigger>
<twig:Button variant="outline" {{ ...popover_trigger_attrs }}>Filters</twig:Button>
</twig:Popover:Trigger>
<twig:Popover:Content aria-label="Filters">Pick a category.</twig:Popover:Content>
</twig:Popover>
Pick a category.
Code
<twig:Popover id="filters">
<twig:Popover:Trigger>
<twig:Button variant="outline" {{ ...popover_trigger_attrs }}>Filters</twig:Button>
</twig:Popover:Trigger>
<twig:Popover:Content aria-label="Filters">Pick a category.</twig:Popover:Content>
</twig:Popover>
Essayer
Changez les propriétés ; le serveur rend l’exemple de nouveau.
Pick a category.
<twig:Popover id="filters">
<twig:Popover:Trigger>
<twig:Button variant="outline" {{ ...popover_trigger_attrs }}>Filters</twig:Button>
</twig:Popover:Trigger>
<twig:Popover:Content aria-label="Filters">Pick a category.</twig:Popover:Content>
</twig:Popover>
<twig:Popover id="filters">
<twig:Popover:Trigger>
<twig:Button variant="outline" {{ ...popover_trigger_attrs }}>Filters</twig:Button>
</twig:Popover:Trigger>
<twig:Popover:Content aria-label="Filters">Pick a category.</twig:Popover:Content>
</twig:Popover>
Variantes
open
Whether the popover is open on initial render.
Pick a category.
Pick a category.
Pick a category.
Pick a category.
Installation
php bin/console ux:install popover --kit=github.com/parler-haut-interagir-librement/ux-kit
Propriétés
| Propriété | Type | Par défaut | Description |
|---|---|---|---|
name |
string|null |
null |
Group name shared by mutually exclusive popovers, so only one is open at a time. |
open |
bool |
false |
Whether the popover is open on initial render. |
id |
string|null |
null |
The identifier of the panel, generated when left out. |
Accessibility
The panel is a dialog named by aria-label. On opening the focus goes to its first control; Escape closes it and gives the focus back to the button.
Fichiers à copier
assets/controllers/popover_controller.js
import { Controller } from '@hotwired/stimulus';
/**
* Where a box goes against its trigger: on the side asked for, or on the other one when it does not fit there and the
* other has more room; aligned along the cross axis, `start` and `end` following the reading direction, and pushed
* back inside the viewport. Every value is in viewport pixels, as `getBoundingClientRect()` gives them.
*/
export function placement(trigger, box, viewport, { side = 'bottom', align = 'center', gap = 6, rtl = false } = {}) {
const vertical = side === 'top' || side === 'bottom';
const size = vertical ? box.height : box.width;
const roomBefore = (vertical ? trigger.top : trigger.left) - gap;
const roomAfter = (vertical ? viewport.height - trigger.bottom : viewport.width - trigger.right) - gap;
const wantsBefore = side === 'top' || side === 'left';
const flipped = wantsBefore
? size > roomBefore && roomAfter > roomBefore
: size > roomAfter && roomBefore > roomAfter;
const before = wantsBefore !== flipped;
const main = before
? (vertical ? trigger.top : trigger.left) - gap - size
: (vertical ? trigger.bottom : trigger.right) + gap;
const crossStart = vertical ? trigger.left : trigger.top;
const crossEnd = vertical ? trigger.right : trigger.bottom;
const crossSize = vertical ? box.width : box.height;
const edge = vertical && rtl ? ({ start: 'end', end: 'start' }[align] ?? align) : align;
const cross =
edge === 'start' ? crossStart : edge === 'end' ? crossEnd - crossSize : (crossStart + crossEnd - crossSize) / 2;
const limit = (vertical ? viewport.width : viewport.height) - crossSize;
const clamped = Math.min(Math.max(0, cross), Math.max(0, limit));
const names = vertical ? ['top', 'bottom'] : ['left', 'right'];
return {
top: Math.round(vertical ? main : clamped),
left: Math.round(vertical ? clamped : main),
side: names[before ? 0 : 1],
};
}
const viewport = () => ({ width: document.documentElement.clientWidth, height: document.documentElement.clientHeight });
const rtl = (element) => getComputedStyle(element).direction === 'rtl';
/*
* A popover on the HTML popover API: the browser opens it from its invoker, closes it on Escape and on a click outside.
* The controller keeps the state of the panel in its value and its attributes, places it next to the trigger, moves
* the focus inside, and closes the other popovers of its group.
*/
export default class extends Controller {
static targets = ['trigger', 'content'];
static values = {
open: { type: Boolean, default: false },
name: { type: String, default: '' },
};
#connected = false;
#follow = () => this.#place();
connect() {
this.updateState();
this.#connected = true;
}
disconnect() {
this.#connected = false;
this.#stopFollowing();
}
toggle(event) {
// An invoker opens and closes the panel itself; opening again what a click outside just closed is avoided.
if (event?.currentTarget?.hasAttribute('popovertarget')) {
return;
}
this.openValue = !this.openValue;
}
close() {
this.openValue = false;
}
sync(event) {
if (event.newState === 'open') {
this.#place();
window.addEventListener('scroll', this.#follow, { passive: true, capture: true });
window.addEventListener('resize', this.#follow, { passive: true });
} else {
this.#stopFollowing();
}
this.openValue = event.newState === 'open';
}
openValueChanged() {
if (!this.#connected) {
return;
}
this.updateState();
if (this.openValue) {
if (this.nameValue) {
window.dispatchEvent(
new CustomEvent('popover:open', { detail: { name: this.nameValue, source: this.element } }),
);
}
this.#focusContent();
}
}
handleGroupOpen(event) {
if (
this.openValue &&
this.nameValue &&
event.detail.name === this.nameValue &&
event.detail.source !== this.element
) {
this.openValue = false;
}
}
handleOutsideClick(event) {
if (this.openValue && !this.element.contains(event.target)) {
this.openValue = false;
}
}
handleEscape() {
if (this.openValue) {
this.openValue = false;
this.triggerTargets[0]?.focus();
}
}
updateState() {
const open = this.openValue;
const state = open ? 'open' : 'closed';
const content = this.contentTargets[0];
this.element.dataset.state = state;
for (const trigger of this.triggerTargets) {
trigger.dataset.state = state;
trigger.setAttribute('aria-expanded', String(open));
}
if (content) {
content.dataset.state = state;
if (open !== content.matches(':popover-open')) {
open ? content.showPopover() : content.hidePopover();
}
}
}
#place() {
const trigger = this.triggerTargets[0];
const content = this.contentTargets[0];
if (!trigger || !content) {
return;
}
const { top, left, side } = placement(
trigger.getBoundingClientRect(),
{ width: content.offsetWidth, height: content.offsetHeight },
viewport(),
{ side: content.dataset.side, align: content.dataset.align, rtl: rtl(content) },
);
content.style.inset = `${top}px auto auto ${left}px`;
content.dataset.placed = side;
}
#focusContent() {
this.contentTargets[0]
?.querySelector(
'[autofocus], input:not([type="hidden"]), select, textarea, button, a[href], [tabindex]:not([tabindex="-1"])',
)
?.focus();
}
#stopFollowing() {
window.removeEventListener('scroll', this.#follow, { capture: true });
window.removeEventListener('resize', this.#follow);
}
}
templates/components/Popover.html.twig
{%- props
## string|null Group name shared by mutually exclusive popovers, so only one is open at a time.
name = null,
## bool Whether the popover is open on initial render.
open = false,
## string|null The identifier of the panel, generated when left out.
id = null
-%}
{%- set _popover_id = id ?? ('popover-' ~ random()) -%}
{%- do provide('popover.id', _popover_id) -%}
{%- do provide('popover.open', open) -%}
<div data-slot="popover" data-state="{{ open ? 'open' : 'closed' }}" data-popover-open-value="{{ open ? 'true' : 'false' }}" data-popover-name-value="{{ name }}"{{ attributes.defaults({class: 'hasPopover', 'data-controller': 'popover', 'data-action': 'popover:open@window->popover#handleGroupOpen click@window->popover#handleOutsideClick keydown.esc@window->popover#handleEscape'}) }}>
{##- A `Popover:Trigger` and a `Popover:Content`. -#}
{%- block content %}{% endblock -%}
</div>
templates/components/Popover/Content.html.twig
{%- props
## 'top'|'right'|'bottom'|'left' Which side of the trigger the content appears on.
side = 'bottom',
## 'start'|'center'|'end' Alignment of the content along the cross axis.
align = 'center'
-%}
<div id="{{ inject('popover.id') }}" popover role="dialog" data-slot="popover-content" data-popover-target="content" data-side="{{ side }}" data-align="{{ align }}" data-state="{{ inject('popover.open', false) ? 'open' : 'closed' }}"{{ attributes.defaults({class: 'isPopover', 'data-action': 'toggle->popover#sync'}) }}>
{##- The content revealed when the popover is open. -#}
{%- block content %}{% endblock -%}
</div>
templates/components/Popover/Trigger.html.twig
{%- set popover_trigger_attrs = {
'data-slot': 'popover-trigger',
'data-popover-target': 'trigger',
'data-action': 'click->popover#toggle',
'aria-haspopup': 'dialog',
'data-state': inject('popover.open', false) ? 'open' : 'closed',
popovertarget: inject('popover.id'),
} -%}
{##- The clickable trigger (e.g., a `Button`) that opens the popover. -#}
{%- block content %}{% endblock -%}