Aller au contenu

Dialog

composant

A 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

Clair

Edit the profile

The changes show at once.

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>

Sombre

Edit the profile

The changes show at once.

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.

Edit the profile

The changes show at once.

<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.

Edit the profile

The changes show at once.

Edit the profile

The changes show at once.

open="false"

Edit the profile

The changes show at once.

Edit the profile

The changes show at once.

open="true"

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éTypePar défautDescription
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">&times;</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 -%}