<x-widget.modal>
Modal
Dialogs on <dialog>: centred in four sizes or full screen, drawers from either side or the bottom, and a bottom sheet on phones. Pinned header and footer, Esc and backdrop close, focus return, scroll lock, and modal.confirm() for quick yes/no questions. Works inside Livewire components: it stays open through renders, and opens or closes on modal-open and modal-close events.
php artisan larawell:add modal
- Also adds
- Button, Icon
- Used by
- Dropdown, Pagination
Usage
Livewire
Inside a Livewire component an open modal stays open through every render, so a form in it shows its validation errors where they belong. Open and close it from the component with $this->dispatch('modal-open', id: 'edit-profile') and 'modal-close' (which closes a disable-close modal too: the app asked). With reset-on-close, closing it also empties the bound properties.
<x-widget.button data-modal-open="edit-profile">Edit profile</x-widget.button>
<x-widget.modal id="edit-profile" title="Edit profile">
<form id="profile-form" wire:submit="save" class="space-y-4">
<x-widget.text-input label="Name" wire:model="name" />
<x-widget.text-input label="Email" type="email" wire:model="email" />
</form>
<x-slot:footer>
<x-widget.button variant="neutral" data-modal-close>Cancel</x-widget.button>
<x-widget.button type="submit" form="profile-form">Save</x-widget.button>
</x-slot:footer>
</x-widget.modal>
{{-- In the component, after saving: $this->dispatch('modal-close', id: 'edit-profile'); --}}
Examples
Basic
Any element with data-modal-open="{id}" opens it; data-modal-close closes it. From JS: modal.open(id), modal.close(id). mobile="sheet" turns it into a bottom sheet on phones.
Show code Hide code
<x-widget.button data-modal-open="delete-transaction">Delete transaction</x-widget.button>
<x-widget.modal id="delete-transaction" title="Delete transaction?" close-on-backdrop size="sm" mobile="sheet">
<p class="text-foreground/75 text-sm">This can't be undone. Click outside, press Esc or use a button to close.</p>
<x-slot:footer>
<x-widget.button variant="neutral" data-modal-close>Cancel</x-widget.button>
<x-widget.button variant="danger" data-modal-close>Delete</x-widget.button>
</x-slot:footer>
</x-widget.modal>
Drawer
variant="drawer" slides a panel in from the end edge (side="end", the right in left-to-right pages), the start edge, or the bottom. Same focus, Esc and backdrop behaviour as a dialog; size sets its width.
Show code Hide code
<div class="flex flex-wrap gap-3">
<x-widget.button variant="neutral" data-modal-open="filters-drawer">Filters</x-widget.button>
<x-widget.button variant="neutral" data-modal-open="share-sheet">Share</x-widget.button>
</div>
<x-widget.modal id="filters-drawer" title="Filters" variant="drawer" size="sm" close-on-backdrop>
<div class="flex flex-col gap-3 text-sm">
@foreach (['Deposits', 'Withdrawals', 'Transfers', 'Fees'] as $type)
<label class="flex items-center gap-2"><input type="checkbox" checked class="accent-primary size-4"> {{ $type }}</label>
@endforeach
</div>
<x-slot:footer>
<x-widget.button variant="neutral" data-modal-close>Reset</x-widget.button>
<x-widget.button data-modal-close>Show results</x-widget.button>
</x-slot:footer>
</x-widget.modal>
<x-widget.modal id="share-sheet" label="Share" variant="drawer" side="bottom" close-on-backdrop>
<p class="text-foreground/75 text-sm">A bottom sheet: the usual shape for quick actions on phones.</p>
</x-widget.modal>
Scrollable
scrollable keeps the title and the footer's buttons in view while only the body scrolls, so a long form never hides its Save button.
Show code Hide code
<x-widget.button data-modal-open="edit-profile">Edit profile</x-widget.button>
<x-widget.modal id="edit-profile" title="Edit profile" scrollable>
<div class="flex flex-col gap-4">
@foreach (['Full name', 'Display name', 'Email', 'Phone', 'Company', 'Job title', 'Website', 'Address line 1', 'Address line 2', 'City', 'State or region', 'Postal code', 'Country'] as $field)
<label class="flex flex-col gap-1 text-sm">{{ $field }}<input type="text" class="border-line rounded-xl border px-3 py-2"></label>
@endforeach
<label class="flex flex-col gap-1 text-sm">Bio<textarea rows="4" class="border-line rounded-xl border px-3 py-2"></textarea></label>
</div>
<x-slot:footer>
<x-widget.button variant="neutral" data-modal-close>Cancel</x-widget.button>
<x-widget.button data-modal-close>Save</x-widget.button>
</x-slot:footer>
</x-widget.modal>
Confirm
modal.confirm() asks a yes/no question with no markup and resolves true or false; the script beside this calls it. It starts on Cancel, so Enter can't confirm by accident; danger styles the confirm button for destructive actions.
Show code Hide code
<div class="flex flex-wrap items-center gap-3">
<x-widget.button variant="danger" data-delete-wallet>Delete wallet</x-widget.button>
<span id="confirm-result" class="text-foreground/75 text-sm" aria-live="polite"></span>
</div>
document.addEventListener('click', async (event) => {
if (!event.target.closest('[data-delete-wallet]')) {
return;
}
const confirmed = await modal.confirm({
title: 'Delete this wallet?',
message: 'Its history will be removed for good.',
confirm: 'Delete',
danger: true,
});
document.getElementById('confirm-result').textContent = confirmed ? 'Deleted.' : '';
});
Full screen
size="full" fills the screen, for editors and step-by-step flows. Its title and footer stay pinned.
Show code Hide code
<x-widget.button variant="neutral" data-modal-open="compose">Compose</x-widget.button>
<x-widget.modal id="compose" title="New message" size="full">
<textarea rows="12" placeholder="Write your message..." class="border-line h-full min-h-60 w-full rounded-xl border p-3 text-sm"></textarea>
<x-slot:footer>
<x-widget.button variant="neutral" data-modal-close>Discard</x-widget.button>
<x-widget.button data-modal-close>Send</x-widget.button>
</x-slot:footer>
</x-widget.modal>
Locked
disable-close turns off Esc, the backdrop and the close button; only modal.close(id, { force: true }) closes it, from your own script.
Show code Hide code
<x-widget.button data-modal-open="accept-terms">Review terms</x-widget.button>
<x-widget.modal id="accept-terms" title="Accept the terms" disable-close>
<p class="text-foreground/75 text-sm">Esc, the backdrop and the close button are disabled. Only the button below closes this.</p>
<x-slot:footer>
<x-widget.button data-accept-terms>I accept</x-widget.button>
</x-slot:footer>
</x-widget.modal>
// A locked modal ignores Esc, the backdrop and data-modal-close; your code decides when it's done.
document.addEventListener('click', (event) => {
if (event.target.closest('[data-accept-terms]')) {
modal.close('accept-terms', { force: true });
}
});
Long content
The dialog scrolls as a whole while the page behind it stays locked.
Show code Hide code
<x-widget.button variant="neutral" data-modal-open="terms-of-service">Read the terms</x-widget.button>
<x-widget.modal id="terms-of-service" title="Terms of service" size="lg">
<div class="text-foreground/75 flex flex-col gap-6 text-sm leading-relaxed">
@foreach ([
'Who these terms cover' => [
'These terms are an agreement between you and us. They apply whenever you open an account, add money, send a transfer or receive one, whether you use the website, the mobile app or the API.',
'By creating an account you confirm that you are at least 18 years old and that the details you give us are true and complete. If you use the service for a business, you confirm you are allowed to accept these terms on its behalf.',
],
'Opening your account' => [
'We are required by law to check who you are before you can send money. We may ask for a photo ID, proof of address and, for larger amounts, where the money came from. Until these checks are complete, some features stay limited.',
'You may hold one personal account. Accounts opened with someone else\'s details, or to get around a limit or a closure, will be closed without notice.',
],
'Keeping your account safe' => [
'Keep your password and verification codes to yourself. We will never ask for them by email, text or phone. Turn on two-step verification and keep your email address and phone number up to date.',
'Tell us straight away if you think someone else has accessed your account or if your device is lost or stolen. You will not be responsible for transfers made after you tell us, unless you acted fraudulently.',
],
'Sending money' => [
'Before you confirm a transfer we show you the amount, the fee, the exchange rate and when the money should arrive. Check the recipient\'s details carefully: once a transfer has been paid out, we may not be able to get it back.',
'Most transfers arrive within one working day. Some take longer because of the recipient\'s bank, public holidays or extra checks we must carry out. We will keep you updated in the app while a transfer is in progress.',
'You can cancel a transfer free of charge until the money has been sent to the recipient. After that, cancelling is only possible if the recipient\'s bank agrees to return the funds.',
],
'Fees and exchange rates' => [
'Our fees are listed on the pricing page and are always shown before you confirm. We never hide a margin in the exchange rate: the rate you see is the rate you get, for as long as the quote is valid.',
'If a quote expires before your money reaches us, we will offer a new one. You can accept it or ask for a full refund.',
],
'Limits' => [
'Daily and monthly limits depend on your country, the currency and how much we have verified about you. You can see your current limits in account settings and ask us to raise them.',
],
'Things you must not do' => [
'You must not use the service for anything illegal, to receive money from fraud, or to pay for goods and services that are prohibited in your country or ours. You must not try to interfere with the service or access other people\'s accounts.',
'If we suspect any of this, we may delay or block a transfer, freeze your balance while we investigate and report it to the authorities where the law requires us to.',
],
'Closing your account' => [
'You can close your account at any time from account settings once any transfers in progress have finished. We will send the remaining balance to a bank account in your name.',
'We may close your account with two months\' notice, or immediately if you break these terms or the law requires it. We keep your records for as long as the law requires after the account closes.',
],
'Complaints' => [
'If something goes wrong, contact us through the help centre and we will reply within 15 working days. If you are not happy with our answer, you may be able to take your complaint to an independent ombudsman.',
],
'Changes to these terms' => [
'We may update these terms from time to time. We will email you at least two months before a change affects you. If you do not agree, you can close your account for free before the change takes effect.',
],
] as $heading => $paragraphs)
<section class="flex flex-col gap-2">
<h3 class="text-foreground text-base font-semibold">{{ $loop->iteration }}. {{ $heading }}</h3>
@foreach ($paragraphs as $paragraph)
<p>{{ $paragraph }}</p>
@endforeach
</section>
@endforeach
</div>
</x-widget.modal>
Props
Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.
<x-widget.modal>
| Prop | Default | Description |
|---|---|---|
| id | Required | |
| title |
null
|
|
| label |
null
|
|
| size |
'md'
|
|
| variant |
'dialog'
|
|
| side |
'end'
|
|
| scrollable |
false
|
|
| mobile |
null
|
|
| close-button |
true
|
|
| close-on-backdrop |
false
|
|
| disable-close |
false
|
|
| reset-on-close |
false
|
|
| open |
false
|
Source
What larawell:add modal writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from
the components it also adds , and the theme and base CSS.
resources/views/components/widget/modal/index.blade.php Show
@props([
'id',
'title' => null,
'label' => null,
'size' => 'md',
'variant' => 'dialog',
'side' => 'end',
'scrollable' => false,
'mobile' => null,
'closeButton' => true,
'closeOnBackdrop' => false,
'disableClose' => false,
'resetOnClose' => false,
'open' => false,
])
@php
$drawer = $variant === 'drawer';
$side = in_array($side, ['start', 'end', 'bottom'], true) ? $side : 'end';
$full = $size === 'full' && ! $drawer;
$widths = ['sm' => 'max-w-sm', 'md' => 'max-w-lg', 'lg' => 'max-w-2xl', 'xl' => 'max-w-4xl'];
$width = $widths[$size] ?? $widths['md'];
// Header and footer stay put while only the body scrolls: asked for with `scrollable`, and always so for
// drawers and full-screen dialogs, where the panel is exactly as tall as the screen.
$pinned = $scrollable || $drawer || $full;
// mobile="sheet": a centred dialog becomes a bottom sheet below the sm breakpoint.
$sheet = $mobile === 'sheet' && ! $drawer && ! $full;
// An X that can't close anything is just noise, so a locked modal never shows one.
$closeButton = $closeButton && ! $disableClose;
// Openers find the dialog by this id, so a duplicate must fail loudly rather than be renamed.
$id = app(\App\View\Widget\ElementIds::class)->claim($id, explicit: true);
$hasFooter = isset($footer);
// Drawers slide in on exactly the curve and 300ms they slide out on, so opening is the closing played backwards.
// 300ms is also the ceiling: the exit must stay within the dialog's own closing transition or it gets cut off.
$glide = 'will-change-[translate]';
// Each drawer side slides in from its own edge (the end edge flips in RTL). Classes written out in full for Tailwind.
$drawerPanel = [
'end' => "ms-auto h-full {$width} translate-x-full rtl:-translate-x-full group-open:translate-x-0 rtl:group-open:translate-x-0 starting:group-open:translate-x-full rtl:starting:group-open:-translate-x-full",
'start' => "me-auto h-full {$width} -translate-x-full rtl:translate-x-full group-open:translate-x-0 rtl:group-open:translate-x-0 starting:group-open:-translate-x-full rtl:starting:group-open:translate-x-full",
'bottom' => "mx-auto mt-auto max-h-[85dvh] {$width} rounded-t-3xl translate-y-full group-open:translate-y-0 starting:group-open:translate-y-full",
];
@endphp
{{--
Native <dialog>: the browser traps focus, blocks the page behind it, closes on Esc and returns focus to
the opener. The dialog is the full-screen layer and its empty area is the clickable backdrop; the panel
inside is the modal. Open with data-modal-open="{{ $id }}" or modal.open('{{ $id }}').
--}}
{{-- wire:ignore.self: the browser marks an open dialog with `open`, which a Livewire render would take away again,
closing it under the person (a form's validation errors included). What's inside still updates. Open and close it
from a component with $this->dispatch('modal-open', id: '…') and 'modal-close'. --}}
<dialog
id="{{ $id }}"
data-modal
wire:ignore.self
tabindex="-1"
@if ($title) aria-labelledby="{{ $id }}-title" @elseif ($label) aria-label="{{ $label }}" @endif
@if ($closeOnBackdrop) data-close-on-backdrop @endif
@if ($disableClose) data-disable-close @endif
@if ($resetOnClose) data-reset-on-close @endif
@if ($open) data-open-on-load @endif
@class([
'group fixed inset-0 m-0 h-dvh max-h-none w-full max-w-none flex-col overscroll-contain bg-transparent outline-none transition-all transition-discrete duration-300 open:flex motion-reduce:transition-none backdrop:bg-foreground/40',
// The backdrop fades in and out rather than popping. It sits beside the dialog in the top layer, so the
// dialog's own opacity doesn't reach it. No backdrop blur: re-blurring the whole page as it opens
// stalls rendering for a few hundred milliseconds, and the panel visibly jumps instead of sliding.
'backdrop:opacity-0 backdrop:transition-opacity backdrop:duration-300 open:backdrop:opacity-100 starting:open:backdrop:opacity-0 motion-reduce:backdrop:transition-none',
// A drawer stays opaque and only slides; fading it at the same time hides the motion.
'opacity-0 open:opacity-100 starting:open:opacity-0' => ! $drawer,
'overflow-y-auto px-2.5 py-8' => ! $pinned && ! $drawer && ! $full,
'overflow-hidden px-2.5 py-8' => $pinned && ! $drawer && ! $full,
// Clip, not hidden: hidden still scrolls programmatically, so showModal() focusing the off-screen panel
// scrolls the dialog sideways and the panel lands, then snaps back. Clip can't be scrolled at all.
'overflow-clip p-0' => $drawer,
'overflow-hidden p-0' => $full,
'max-sm:px-0 max-sm:pt-8 max-sm:pb-0' => $sheet,
])
>
<div
{{ $attributes->class([
'bg-surface text-foreground relative w-full shadow-xl transition-transform duration-300 motion-reduce:transition-none',
'flex flex-col' => $pinned,
// Centred dialog: grows in slightly as it opens.
"m-auto rounded-3xl scale-95 group-open:scale-100 starting:group-open:scale-95 {$width}" => ! $drawer && ! $full,
'max-h-full' => $pinned && ! $drawer && ! $full,
'h-full max-w-none' => $full,
$drawerPanel[$side] => $drawer,
$glide => $drawer,
'max-sm:mt-auto max-sm:mb-0 max-sm:max-w-none max-sm:scale-100 max-sm:rounded-b-none max-sm:translate-y-full max-sm:group-open:translate-y-0 max-sm:starting:group-open:translate-y-full max-sm:will-change-[translate] max-sm:group-open:duration-500 max-sm:group-open:ease-[cubic-bezier(0.25,0.46,0.45,0.94)]' => $sheet,
]) }}
>
@if ($title)
<div @class(['px-6 pt-6', 'pe-14' => $closeButton, 'pb-6' => ! $pinned, 'shrink-0 pb-4' => $pinned])>
<h2 id="{{ $id }}-title" class="text-lg font-semibold">{{ $title }}</h2>
</div>
@endif
@if ($closeButton)
<button
type="button"
data-modal-close
aria-label="Close"
class="text-muted hover:text-foreground focus-visible:ring-primary absolute end-4 top-4 z-10 grid size-8 place-items-center rounded-full outline-none focus-visible:ring-2"
>
<x-widget.icon name="x" class="size-5" />
</button>
@endif
<div @class([
'px-6',
'pt-6' => ! $title,
// No title row to hold the X, so the body keeps clear of it.
'pe-14' => ! $title && $closeButton,
'pb-6' => ! $hasFooter,
'min-h-0 flex-1 overflow-y-auto overscroll-contain' => $pinned,
])>
{{ $slot }}
</div>
@if ($hasFooter)
<div @class([
'flex flex-wrap items-center justify-end gap-3 px-6 pb-6',
'pt-6' => ! $pinned,
'border-line shrink-0 border-t pt-4' => $pinned,
])>{{ $footer }}</div>
@endif
</div>
</dialog>
resources/js/widget/modal/index.js Show
// Drives <x-widget.modal>. Open: <button data-modal-open="id"> or modal.open('id').
// Close: any [data-modal-close] inside, Esc, or the backdrop when close-on-backdrop is set. A disable-close modal only
// closes through modal.close(id, { force: true }); closing it any other way (even dialog.close()) opens it again.
// Initial focus: put data-autofocus (not autofocus) on an element inside the dialog.
// Events on the dialog: modal:before-open, modal:opened, modal:closed (detail.returnValue).
// Window events modal-open and modal-close with detail.id open and close one, e.g. from Livewire:
// $this->dispatch('modal-close', id: 'edit'). modal-close closes a disable-close modal too: the app asked.
// modal.confirm({ title, message, confirm, cancel, danger }) asks a yes/no question without any markup.
const MODAL = 'dialog[data-modal]';
function resolve(target) {
return typeof target === 'string' ? document.getElementById(target) : target;
}
function open(target) {
const dialog = resolve(target);
if (!dialog || dialog.open) {
return;
}
// Lets content be built just in time (e.g. the pagination page list) before focus is placed.
dialog.dispatchEvent(new CustomEvent('modal:before-open'));
dialog.showModal();
// data-autofocus rather than the native attribute: the browser also runs page-load autofocus on
// elements inside closed dialogs, and when the URL has a #fragment (e.g. pagination links) it
// blocks that and logs a console warning. [autofocus] is still honoured for existing markup.
// Without a target, focus the dialog itself; the browser would pick the close button.
const initial = dialog.querySelector('[data-autofocus], [autofocus]');
(initial ?? dialog).focus();
dialog.dispatchEvent(new CustomEvent('modal:opened', { bubbles: true }));
}
// `force` closes even a disable-close modal, for when the app itself decides it's done.
function close(target, { force = false } = {}) {
const dialog = resolve(target);
if (!dialog?.open || (dialog.hasAttribute('data-disable-close') && !force)) {
return;
}
// Marks a close the app asked for, which a locked modal lets through (see the close listener).
dialog.dataset.closing = '';
dialog.close();
}
document.addEventListener('click', (event) => {
const opener = event.target.closest?.('[data-modal-open]');
if (opener) {
open(opener.dataset.modalOpen);
return;
}
const closer = event.target.closest?.('[data-modal-close]');
if (closer) {
close(closer.closest(MODAL));
}
});
// Backdrop close only when the press also started on the backdrop: a text selection
// dragged out of the panel ends with a click on the dialog and must not close it.
let pressedOnBackdrop = false;
document.addEventListener('pointerdown', (event) => {
pressedOnBackdrop = event.target.matches?.(`${MODAL}[data-close-on-backdrop]`) ?? false;
});
document.addEventListener('click', (event) => {
if (pressedOnBackdrop && event.target.matches?.(`${MODAL}[data-close-on-backdrop]`)) {
close(event.target);
}
pressedOnBackdrop = false;
});
// A locked modal (disable-close) has to survive Esc. Cancelling the dialog's `cancel` event isn't enough on its
// own: Chromium only honours that after the user has interacted with the page (its CloseWatcher rules). So Esc
// is stopped before it becomes a close request, `cancel` is still cancelled, and as a last resort (the Android
// back gesture) a locked modal that closes without the app asking is opened again.
const topModal = () => [...document.querySelectorAll(`${MODAL}:modal`)].at(-1);
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && topModal()?.hasAttribute('data-disable-close')) {
event.preventDefault();
}
}, true);
// `cancel` (Esc) and `close` don't bubble, so listen in the capture phase.
document.addEventListener('cancel', (event) => {
if (event.target.matches?.(`${MODAL}[data-disable-close]`)) {
event.preventDefault();
}
}, true);
document.addEventListener('close', (event) => {
if (!event.target.matches?.(MODAL)) {
return;
}
const byApp = 'closing' in event.target.dataset;
delete event.target.dataset.closing;
if (event.target.hasAttribute('data-disable-close') && !byApp) {
event.target.showModal();
return;
}
if (event.target.matches('[data-reset-on-close]')) {
event.target.querySelectorAll('form').forEach((form) => {
form.reset();
// reset() fires no events, so wire:model, x-model and the fields' own scripts (counters) would keep the old
// values; tell them, as if the person had cleared each field.
for (const control of form.elements) {
control.dispatchEvent(new Event('input', { bubbles: true }));
control.dispatchEvent(new Event('change', { bubbles: true }));
}
});
}
// e.g. refresh a list after a form modal closes. The native close event doesn't bubble; this does.
event.target.dispatchEvent(new CustomEvent('modal:closed', { bubbles: true, detail: { returnValue: event.target.returnValue } }));
}, true);
// From a Livewire component ($this->dispatch('modal-open', id: 'edit')) or any script. Livewire puts the named
// arguments in detail; a plain CustomEvent can do the same.
window.addEventListener('modal-open', (event) => open(event.detail?.id));
window.addEventListener('modal-close', (event) => close(event.detail?.id, { force: true }));
// --- modal.confirm -------------------------------------------------------------------------
// Same look as <x-widget.modal size="sm"> and <x-widget.button> (neutral, primary, danger), built here so a
// confirmation needs no markup. Text goes in with textContent, so a title or message can't inject HTML.
const CONFIRM_DIALOG = 'group fixed inset-0 m-0 h-dvh max-h-none w-full max-w-none flex-col overflow-y-auto overscroll-contain bg-transparent px-2.5 py-8 opacity-0 outline-none transition-all transition-discrete duration-300 open:flex open:opacity-100 starting:open:opacity-0 motion-reduce:transition-none backdrop:bg-foreground/40 backdrop:opacity-0 backdrop:transition-opacity backdrop:duration-300 open:backdrop:opacity-100 starting:open:backdrop:opacity-0 motion-reduce:backdrop:transition-none';
const CONFIRM_PANEL = 'bg-surface text-foreground relative m-auto w-full max-w-sm scale-95 rounded-3xl p-6 shadow-xl transition-transform duration-300 group-open:scale-100 starting:group-open:scale-95 motion-reduce:transition-none';
const BUTTON = 'relative inline-flex min-w-fit items-center justify-center gap-1.5 rounded-xl border border-transparent px-4 py-3 text-sm font-medium whitespace-nowrap outline-none transition-all focus-visible:ring-primary focus-visible:ring-2 focus-visible:ring-offset-2 active:scale-95';
const BUTTON_LOOK = {
neutral: 'bg-field text-foreground hover:bg-line',
primary: 'bg-primary text-on-primary hover:bg-primary-hover',
danger: 'bg-error text-white hover:brightness-90',
};
let confirmCount = 0;
// Resolves true when confirmed; false on Cancel or Esc. An alertdialog that starts on Cancel, so Enter
// can't confirm a destructive action by accident, and a backdrop click doesn't count as either answer.
function confirm({ title = 'Are you sure?', message = '', confirm: confirmText = 'Confirm', cancel: cancelText = 'Cancel', danger = false } = {}) {
return new Promise((answer) => {
const id = `modal-confirm-${++confirmCount}`;
const make = (tag, className, text) => Object.assign(document.createElement(tag), { className, textContent: text ?? '' });
const dialog = make('dialog', CONFIRM_DIALOG);
dialog.id = id;
dialog.dataset.modal = '';
dialog.setAttribute('role', 'alertdialog');
dialog.setAttribute('aria-labelledby', `${id}-title`);
const panel = make('div', CONFIRM_PANEL);
const heading = make('h2', 'text-lg font-semibold', title);
heading.id = `${id}-title`;
panel.append(heading);
if (message) {
const text = make('p', 'text-foreground/75 mt-3 text-sm', message);
text.id = `${id}-message`;
dialog.setAttribute('aria-describedby', text.id);
panel.append(text);
}
const footer = make('div', 'mt-6 flex flex-wrap items-center justify-end gap-3');
const cancel = make('button', `${BUTTON} ${BUTTON_LOOK.neutral}`, cancelText);
const ok = make('button', `${BUTTON} ${danger ? BUTTON_LOOK.danger : BUTTON_LOOK.primary}`, confirmText);
cancel.type = ok.type = 'button';
cancel.dataset.autofocus = '';
cancel.addEventListener('click', () => dialog.close('cancel'));
ok.addEventListener('click', () => dialog.close('confirm'));
footer.append(cancel, ok);
panel.append(footer);
dialog.append(panel);
dialog.addEventListener('close', () => {
answer(dialog.returnValue === 'confirm');
// Leave time for the closing transition before removing it.
setTimeout(() => dialog.remove(), 350);
}, { once: true });
document.body.append(dialog);
open(dialog);
});
}
export const modal = { open, close, confirm };
// Global so inline handlers and other scripts can call modal.open('id').
window.modal = modal;
// e.g. :open="$errors->any()" to reopen a form modal after failed validation.
document.querySelectorAll(`${MODAL}[data-open-on-load]`).forEach((dialog) => open(dialog));
app/View/Widget/ElementIds.php Show
<?php
declare(strict_types=1);
namespace App\View\Widget;
use Illuminate\Container\Attributes\Scoped;
use LogicException;
/**
* Keeps element ids unique within one response, so labels, aria-describedby and #fragments
* always point at the right element. Scoped: a fresh set per request (and per Octane/queue cycle).
*/
#[Scoped]
final class ElementIds
{
/** @var array<string, true> */
private array $used = [];
/**
* Reserves an id for this response.
*
* A derived id (built from a field name) gets a -2, -3 … suffix when already taken. An explicit
* id is one the caller chose and may reference from JS or CSS, so silently renaming it would
* break that reference; a duplicate throws instead, which surfaces in development and tests.
*/
public function claim(string $id, bool $explicit = false): string
{
if (!isset($this->used[$id])) {
return $this->reserve($id);
}
if ($explicit) {
throw new LogicException("Duplicate element id [{$id}] on this page. Give one of the widgets a different id or name.");
}
$suffix = 2;
while (isset($this->used["{$id}-{$suffix}"])) {
$suffix++;
}
return $this->reserve("{$id}-{$suffix}");
}
private function reserve(string $id): string
{
$this->used[$id] = true;
return $id;
}
}