Dialog
composantA modal window, on the native dialog element and its invokers: the browser makes the page inert, keeps the focus inside, closes it on Escape and gives the focus back.
Aperçu
Code
<twig:Dialog id="profile">
<twig:Dialog:Trigger>
<twig:Button variant="outline" {{ ...dialog_trigger_attrs }}>Edit</twig:Button>
</twig:Dialog:Trigger>
<twig:Dialog:Content closeLabel="Close">
<twig:Dialog:Header>
<twig:Dialog:Title>Edit the profile</twig:Dialog:Title>
<twig:Dialog:Description>The changes show at once.</twig:Dialog:Description>
</twig:Dialog:Header>
<twig:Dialog:Footer>
<twig:Dialog:Close><twig:Button variant="outline" {{ ...dialog_close_attrs }}>Cancel</twig:Button></twig:Dialog:Close>
</twig:Dialog:Footer>
</twig:Dialog:Content>
</twig:Dialog>
Code
<twig:Dialog id="profile">
<twig:Dialog:Trigger>
<twig:Button variant="outline" {{ ...dialog_trigger_attrs }}>Edit</twig:Button>
</twig:Dialog:Trigger>
<twig:Dialog:Content closeLabel="Close">
<twig:Dialog:Header>
<twig:Dialog:Title>Edit the profile</twig:Dialog:Title>
<twig:Dialog:Description>The changes show at once.</twig:Dialog:Description>
</twig:Dialog:Header>
<twig:Dialog:Footer>
<twig:Dialog:Close><twig:Button variant="outline" {{ ...dialog_close_attrs }}>Cancel</twig:Button></twig:Dialog:Close>
</twig:Dialog:Footer>
</twig:Dialog:Content>
</twig:Dialog>
Essayer
Changez les propriétés ; le serveur rend l’exemple de nouveau.
<twig:Dialog id="profile">
<twig:Dialog:Trigger>
<twig:Button variant="outline" {{ ...dialog_trigger_attrs }}>Edit</twig:Button>
</twig:Dialog:Trigger>
<twig:Dialog:Content closeLabel="Close">
<twig:Dialog:Header>
<twig:Dialog:Title>Edit the profile</twig:Dialog:Title>
<twig:Dialog:Description>The changes show at once.</twig:Dialog:Description>
</twig:Dialog:Header>
<twig:Dialog:Footer>
<twig:Dialog:Close><twig:Button variant="outline" {{ ...dialog_close_attrs }}>Cancel</twig:Button></twig:Dialog:Close>
</twig:Dialog:Footer>
</twig:Dialog:Content>
</twig:Dialog>
<twig:Dialog id="profile">
<twig:Dialog:Trigger>
<twig:Button variant="outline" {{ ...dialog_trigger_attrs }}>Edit</twig:Button>
</twig:Dialog:Trigger>
<twig:Dialog:Content closeLabel="Close">
<twig:Dialog:Header>
<twig:Dialog:Title>Edit the profile</twig:Dialog:Title>
<twig:Dialog:Description>The changes show at once.</twig:Dialog:Description>
</twig:Dialog:Header>
<twig:Dialog:Footer>
<twig:Dialog:Close><twig:Button variant="outline" {{ ...dialog_close_attrs }}>Cancel</twig:Button></twig:Dialog:Close>
</twig:Dialog:Footer>
</twig:Dialog:Content>
</twig:Dialog>
Variantes
open
Whether the dialog is open on initial render.
Installation
php bin/console ux:install dialog --kit=github.com/parler-haut-interagir-librement/ux-kit
Elle apporte les recettes dont elle a besoin : button
Propriétés
| Propriété | Type | Par défaut | Description |
|---|---|---|---|
open |
bool |
false |
Whether the dialog is open on initial render. |
Accessibility
Named by its title and described by its description. HTML first: the trigger and the close buttons carry command and commandfor, and the dialog closedby="any". Where the browser runs them, the buttons work without JavaScript and a click on the backdrop closes the dialog; the Stimulus controller detects what the browser does and stands in only for the rest, and keeps aria-expanded on the trigger either way.
Fichiers à copier
assets/controllers/dialog_controller.js
import { Controller } from '@hotwired/stimulus';
/** Whether a click fell outside a box, on the backdrop of a modal dialog. */
export function isOutside({ clientX, clientY }, { top, right, bottom, left }) {
return clientX < left || clientX > right || clientY < top || clientY > bottom;
}
/** Whether the browser runs the `command` and `commandfor` of a button itself: the invokers of HTML. */
export function hasInvokers() {
return typeof HTMLButtonElement !== 'undefined' && 'commandForElement' in HTMLButtonElement.prototype;
}
/** Whether the browser closes a dialog on a click on its backdrop itself, as `closedby="any"` asks. */
export function hasLightDismiss() {
return typeof HTMLDialogElement !== 'undefined' && 'closedBy' in HTMLDialogElement.prototype;
}
/** Whether a click came from a button the browser runs itself. */
const invokedNatively = (event) => hasInvokers() && Boolean(event?.currentTarget?.hasAttribute?.('commandfor'));
/*
* A modal on the native dialog element, the browser making the page inert, trapping the focus, closing on Escape and
* giving the focus back. HTML first: the trigger and the close buttons carry `command` and `commandfor`, the dialog
* `closedby="any"`, and where the browser runs them the controller leaves them to it. It stands in where the browser
* does not, opening and closing from those buttons and on a click on the backdrop; everywhere, it opens the dialog
* from its value, says on the trigger whether it is open, and reopens it when Turbo or a Live Component puts it back
* in the page.
*/
export default class extends Controller {
static targets = ['trigger', 'dialog'];
static values = { open: { type: Boolean, default: false } };
#wasOpen = false;
connect() {
if (this.openValue || this.#wasOpen) {
this.open();
}
this.#wasOpen = false;
}
disconnect() {
// A dialog taken out of the page comes back open but no longer modal.
this.#wasOpen = this.dialogTarget.open;
if (this.dialogTarget.open) {
this.dialogTarget.close();
}
}
open(event) {
if (!this.dialogTarget.open && !invokedNatively(event)) {
this.dialogTarget.showModal();
}
this.#expanded(true);
}
close(event) {
if (this.dialogTarget.open && !invokedNatively(event)) {
this.dialogTarget.close();
}
this.#expanded(false);
}
closed() {
this.#expanded(false);
}
closeOnClickOutside(event) {
if (hasLightDismiss()) {
return;
}
if (event.target === this.dialogTarget && isOutside(event, this.dialogTarget.getBoundingClientRect())) {
this.close();
}
}
#expanded(open) {
if (this.hasTriggerTarget) {
this.triggerTarget.setAttribute('aria-expanded', String(open));
}
}
}
templates/components/Dialog.html.twig
{%- props
## string Unique identifier used to generate internal Dialog IDs.
id,
## bool Whether the dialog is open on initial render.
open = false
-%}
{%- set _dialog_id = 'dialog-' ~ id -%}
{%- do provide('dialog.id', _dialog_id) -%}
{%- do provide('dialog.titleId', _dialog_id ~ '-title') -%}
{%- do provide('dialog.descriptionId', _dialog_id ~ '-description') -%}
<div data-slot="dialog" data-dialog-open-value="{{ open ? 'true' : 'false' }}"{{ attributes.defaults({class: 'hasDialog', 'data-controller': 'dialog'}) }}>
{##- The dialog structure, typically includes `Dialog:Trigger` and `Dialog:Content`. -#}
{%- block content %}{% endblock -%}
</div>
templates/components/Dialog/Close.html.twig
{%- set dialog_close_attrs = {
'data-slot': 'dialog-close',
'data-action': 'click->dialog#close',
command: 'close',
commandfor: inject('dialog.id'),
} -%}
{##- The close trigger element (e.g., a `Button`) that closes the dialog when clicked. -#}
{%- block content %}{% endblock -%}
templates/components/Dialog/Content.html.twig
{%- props
## bool Whether to display the close button.
showCloseButton = true,
## string What screen readers read for the close button.
closeLabel = 'Close'
-%}
{%- set _id = inject('dialog.id') -%}
<dialog id="{{ _id }}" closedby="any" aria-labelledby="{{ inject('dialog.titleId') }}" aria-describedby="{{ inject('dialog.descriptionId') }}" data-slot="dialog-content" data-dialog-target="dialog"{{ attributes.defaults({class: 'isDialog', 'data-action': 'click->dialog#closeOnClickOutside close->dialog#closed'}) }}>
{##- The dialog content, typically includes `Dialog:Header` and optionally `Dialog:Footer`. -#}
{%- block content %}{% endblock -%}
{%- if showCloseButton %}
<twig:Button class="isDialogClose" type="button" variant="ghost" size="icon-sm" data-slot="dialog-close" data-action="click->dialog#close" command="close" commandfor="{{ _id }}">
<span aria-hidden="true">×</span><span class="isVisuallyHidden">{{ closeLabel }}</span>
</twig:Button>
{%- endif %}
</dialog>
templates/components/Dialog/Description.html.twig
<p id="{{ inject('dialog.descriptionId') }}" data-slot="dialog-description"{{ attributes.without('id').defaults({class: 'isDialogDescription'}) }}>
{##- The descriptive text explaining the dialog purpose. -#}
{%- block content %}{% endblock -%}
</p>
templates/components/Dialog/Footer.html.twig
<footer data-slot="dialog-footer"{{ attributes.defaults({class: 'isDialogFooter'}) }}>
{##- The footer area, typically contains action buttons. -#}
{%- block content %}{% endblock -%}
</footer>
templates/components/Dialog/Header.html.twig
<header data-slot="dialog-header"{{ attributes.defaults({class: 'isDialogHeader'}) }}>
{##- The header area, typically contains `Dialog:Title` and `Dialog:Description`. -#}
{%- block content %}{% endblock -%}
</header>
templates/components/Dialog/Title.html.twig
<h2 id="{{ inject('dialog.titleId') }}" data-slot="dialog-title"{{ attributes.without('id').defaults({class: 'isDialogTitle'}) }}>
{##- The title text of the dialog. -#}
{%- block content %}{% endblock -%}
</h2>
templates/components/Dialog/Trigger.html.twig
{%- set dialog_trigger_attrs = {
'data-slot': 'dialog-trigger',
'data-action': 'click->dialog#open',
'data-dialog-target': 'trigger',
'aria-haspopup': 'dialog',
command: 'show-modal',
commandfor: inject('dialog.id'),
} -%}
{##- The trigger element (e.g., a `Button`) that opens the dialog when clicked. -#}
{%- block content %}{% endblock -%}