mirror of
https://github.com/simonw/datasette.git
synced 2026-09-18 22:44:07 +02:00
Add reusable modal component, documentation and lifecycle tests, refs #2790
This commit is contained in:
parent
3239d10b9b
commit
e1f89494f1
7 changed files with 616 additions and 16 deletions
|
|
@ -474,6 +474,149 @@ Custom fields are responsible for preserving the accessibility of the form:
|
|||
|
||||
Plugins should not submit the row themselves from inside ``makeColumnField()`` controls. Datasette owns the insert/edit dialog lifecycle, form submission, API call, error handling and row refresh.
|
||||
|
||||
.. _javascript_plugins_modals:
|
||||
|
||||
Reusable modal dialogs
|
||||
----------------------
|
||||
|
||||
Plugins can use ``DatasetteModal`` to create dialogs with the same appearance and keyboard behavior as Datasette's built-in dialogs. The component provides a native modal dialog, shared styles, Escape and backdrop dismissal, busy-state dismissal guards and focus restoration.
|
||||
|
||||
Use the :ref:`javascript_datasette_init` event to set up a dialog, as in this example.
|
||||
|
||||
Creating a dialog
|
||||
~~~~~~~~~~~~~~~~~
|
||||
|
||||
``DatasetteModal.create()`` returns a detached ``<datasette-modal>`` element containing a native ``<dialog>``. Access that native element through ``modal.dialog``. Populate its content before appending the wrapper to the page, then call ``modal.show()`` to open it.
|
||||
|
||||
This example adds a button that opens a reusable dialog:
|
||||
|
||||
.. code-block:: javascript
|
||||
|
||||
document.addEventListener("datasette_init", () => {
|
||||
const trigger = document.createElement("button");
|
||||
trigger.type = "button";
|
||||
trigger.textContent = "Open example dialog";
|
||||
// Indicate that this button opens a dialog:
|
||||
trigger.setAttribute("aria-haspopup", "dialog");
|
||||
// Identify which dialog it controls:
|
||||
trigger.setAttribute("aria-controls", "my-plugin-dialog");
|
||||
|
||||
const modal = DatasetteModal.create();
|
||||
const dialog = modal.dialog;
|
||||
dialog.id = "my-plugin-dialog";
|
||||
dialog.setAttribute("aria-labelledby", "my-plugin-dialog-title");
|
||||
dialog.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title" id="my-plugin-dialog-title">
|
||||
Example dialog
|
||||
</h2>
|
||||
</div>
|
||||
<div style="padding: 1rem; overflow: auto; min-height: 0;">
|
||||
This dialog uses Datasette's shared styles and keyboard behavior.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="modal-btn modal-btn-ghost">Close</button>
|
||||
</div>`;
|
||||
|
||||
const closeButton = dialog.querySelector("button");
|
||||
closeButton.addEventListener("click", () => {
|
||||
modal.requestClose("cancel");
|
||||
});
|
||||
trigger.addEventListener("click", () => {
|
||||
modal.show({ trigger, initialFocus: closeButton });
|
||||
});
|
||||
|
||||
document.body.append(modal);
|
||||
document.querySelector("section.content").append(trigger);
|
||||
});
|
||||
|
||||
The example uses ``innerHTML`` for a static template. Use ``textContent`` when inserting database values or other user-supplied text. Give each dialog and its title unique IDs, and use ``aria-labelledby`` or ``aria-label`` to provide an accessible name.
|
||||
|
||||
Opening and closing
|
||||
~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
``modal.show({trigger, initialFocus})``
|
||||
Opens the native dialog using ``showModal()``. Both options are optional. ``trigger`` is the element to return focus to when the dialog closes; it defaults to the currently focused element, including inside an open shadow root. ``initialFocus`` can be an element to focus or a function that focuses a custom control. Without it, the browser chooses initial focus. Calling ``show()`` while the dialog is already open preserves the original return-focus target.
|
||||
|
||||
``modal.requestClose(reason = "cancel")``
|
||||
Requests dismissal through the busy-state and ``beforeClose`` guards described below. Returns ``true`` if it closes the dialog, or ``false`` if the dialog is already closed or a guard prevents dismissal. Close and Cancel buttons should use this method.
|
||||
|
||||
``modal.close({restoreFocus = true})``
|
||||
Closes the dialog directly, bypassing the guards. Use this after successfully completing an operation. Pass ``restoreFocus: false`` when your code will navigate away or move focus to another element, such as a newly inserted row.
|
||||
|
||||
On normal dismissal, the component restores focus if the trigger is still connected to the document. If the trigger is inside a menu implemented with a closed ``<details>`` element, focus returns to that menu's ``<summary>`` instead.
|
||||
|
||||
Closing a dialog leaves it in the page so it can be reopened. Listen for the native dialog's ``close`` event to clean up resources such as pending requests or custom fields:
|
||||
|
||||
.. code-block:: javascript
|
||||
|
||||
modal.dialog.addEventListener("close", () => {
|
||||
// Clean up content-specific resources here.
|
||||
});
|
||||
|
||||
If the dialog is no longer needed, remove the wrapper with ``modal.remove()``. The component removes its own listeners and pending keyboard-dismissal callbacks when disconnected.
|
||||
|
||||
Dismissal guards and busy state
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Set ``modal.beforeClose`` to a synchronous function that receives a dismissal reason and returns ``false`` to keep the dialog open. The built-in dismissal reasons are ``"escape"`` for Escape or a native cancel event and ``"backdrop"`` for a click outside the dialog. Buttons can pass ``"cancel"`` to ``requestClose()``. Your callback can apply different policies to each reason, such as prompting before discarding edits on Escape while allowing an explicit Cancel button to close immediately.
|
||||
|
||||
The callback must return synchronously: returning a Promise does not delay dismissal. For an asynchronous confirmation, return ``false`` immediately and call ``modal.close()`` yourself if the user later confirms.
|
||||
|
||||
Set ``modal.busy = true`` while saving to prevent user dismissal. This also sets ``aria-busy="true"`` on the native dialog. While busy, ``requestClose()`` returns ``false`` without calling ``beforeClose``. Busy state resets when the dialog closes.
|
||||
|
||||
The plugin remains responsible for disabling its form controls, submitting data and displaying progress and errors. If an operation fails, set ``modal.busy = false`` so the user can retry or close the dialog. A successful operation can call ``modal.close()`` even while busy.
|
||||
|
||||
Escape dismissal waits until the key is released before consulting the guard, so a discard-confirmation prompt remains usable in Safari. Nested controls, such as an autocomplete list, can consume Escape with ``event.preventDefault()`` to keep the containing dialog open.
|
||||
|
||||
.. _javascript_plugins_modal_classes:
|
||||
|
||||
Shared CSS classes
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The classes in the example provide built-in styling. None of the classes you add to the dialog's content is required for opening, closing or focus handling.
|
||||
|
||||
``datasette-modal``
|
||||
Added automatically to the native ``<dialog>`` when the wrapper is connected to the page. Provides the dialog's sizing, background, rounded corners, shadow, backdrop and animations. Keep this class when adding your own styles.
|
||||
|
||||
``modal-header``
|
||||
Adds padding, a bottom border and a horizontal layout for the title and optional metadata.
|
||||
|
||||
``modal-title``
|
||||
Sets the title's font size, weight and color. This class only changes its appearance; use ``aria-labelledby`` to associate the title with the dialog.
|
||||
|
||||
``modal-meta``
|
||||
Styles optional metadata, such as a selected-item count, as small monospace text with a rounded background.
|
||||
|
||||
``modal-footer``
|
||||
Adds padding, a top border and a background to the action area. Arranges its contents horizontally, with buttons aligned to the right.
|
||||
|
||||
``footer-info``
|
||||
Styles supporting text in the footer and lets it fill the space before the action buttons.
|
||||
|
||||
``modal-btn``
|
||||
Provides base button styling, including padding, rounded corners, font and disabled appearance. Use it together with ``modal-btn-primary`` or ``modal-btn-ghost``.
|
||||
|
||||
``modal-btn-primary``
|
||||
Gives a button an accent-colored background and white text, suitable for a primary action such as Save.
|
||||
|
||||
``modal-btn-ghost``
|
||||
Gives a button a transparent background, muted text and a border, suitable for a secondary action such as Close or Cancel.
|
||||
|
||||
These button classes are also used by Datasette's built-in dialogs. The ``modal-btn`` prefix keeps them separate from generic ``btn`` classes used by plugins or CSS frameworks. The shared CSS scopes them to descendants of ``.datasette-modal``, for example ``:where(.datasette-modal) .modal-btn``. Dialog content remains in the caller's DOM tree, so scope custom CSS to the intended component to avoid unintended overrides.
|
||||
|
||||
You can customize layout and sizing without adding extra classes. For example, this CSS uses the dialog's existing ID to widen it while keeping it inside the viewport:
|
||||
|
||||
.. code-block:: css
|
||||
|
||||
dialog#my-plugin-dialog {
|
||||
width: min(720px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
Long content should have a container with ``overflow: auto`` and ``min-height: 0`` so it can scroll while the header and footer remain visible. Keep these styles scoped to your dialog.
|
||||
|
||||
The dialog shell also uses the CSS custom properties ``--modal-border-radius``, ``--modal-shadow``, ``--modal-backdrop-bg``, ``--modal-backdrop-blur`` and ``--modal-animation-duration``. These work for dialogs in both the document and shadow roots. The shared animations respect the user's reduced-motion preference.
|
||||
|
||||
.. _javascript_datasette_manager_selectors:
|
||||
|
||||
Selectors
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue