From 7118fa31cee466cdf76e4d3afe8d3ac3c1a96895 Mon Sep 17 00:00:00 2001 From: Bartek Date: Wed, 30 Sep 2026 12:49:26 +0200 Subject: [PATCH] Add `AlertDialog` to replace `confirm()` and `alert()` `AlertDialog.confirm()` and `AlertDialog.alert()` open a small modal from JavaScript and return a Promise. Options: `title`, `message`, `confirmText`, `cancelText`, `variant`, `icon`, `align` and `focus`. Also fixes `.modal-status` so the top bar follows the rounded corners, raises the `tabler.js` size budget from 58 kB to 60 kB, and adds a docs page, a demo page and a screenshot page. --- .changeset/alert-dialog.md | 7 + .changeset/modal-status-radius.md | 5 + core/js/src/alert-dialog.ts | 90 ++++++++++++ core/js/tabler.ts | 1 + core/js/tests/unit/alert-dialog.spec.ts | 83 +++++++++++ core/package.json | 4 +- core/scss/ui/_modals.scss | 6 +- docs/content/ui/components/alert-dialog.mdx | 137 +++++++++++++++++ preview/pages/alert-dialogs.astro | 154 ++++++++++++++++++++ screenshots/pages/alert-dialog.astro | 86 +++++++++++ shared/data/docs.json | 4 + shared/data/menu.json | 5 + 12 files changed, 578 insertions(+), 4 deletions(-) create mode 100644 .changeset/alert-dialog.md create mode 100644 .changeset/modal-status-radius.md create mode 100644 core/js/src/alert-dialog.ts create mode 100644 core/js/tests/unit/alert-dialog.spec.ts create mode 100644 docs/content/ui/components/alert-dialog.mdx create mode 100644 preview/pages/alert-dialogs.astro create mode 100644 screenshots/pages/alert-dialog.astro diff --git a/.changeset/alert-dialog.md b/.changeset/alert-dialog.md new file mode 100644 index 000000000..526a3b845 --- /dev/null +++ b/.changeset/alert-dialog.md @@ -0,0 +1,7 @@ +--- +"@tabler/core": minor +"@tabler/preview": minor +"@tabler/docs": minor +--- + +Added `AlertDialog` with `confirm()` and `alert()` methods, a modal replacement for `window.confirm()` and `window.alert()`. diff --git a/.changeset/modal-status-radius.md b/.changeset/modal-status-radius.md new file mode 100644 index 000000000..e54f9c2f7 --- /dev/null +++ b/.changeset/modal-status-radius.md @@ -0,0 +1,5 @@ +--- +"@tabler/core": patch +--- + +Fixed `.modal-status` so the colored top bar follows the rounded corners of `.modal-content`. diff --git a/core/js/src/alert-dialog.ts b/core/js/src/alert-dialog.ts new file mode 100644 index 000000000..111fc06ad --- /dev/null +++ b/core/js/src/alert-dialog.ts @@ -0,0 +1,90 @@ +import { Modal } from './bootstrap' + +export type AlertDialogOptions = { + title?: string + message?: string + confirmText?: string + cancelText?: string + variant?: 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'danger' + focus?: 'confirm' | 'cancel' + icon?: string + align?: 'center' | 'start' +} + +const ICONS: Record = { + 'trash': 'M4 7h16M10 11v6M14 11v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3', + 'alert-triangle': 'M12 9v4M10.363 3.591l-8.106 13.534a1.914 1.914 0 0 0 1.636 2.871h16.214a1.914 1.914 0 0 0 1.636-2.87l-8.106-13.536a1.914 1.914 0 0 0-3.274 0M12 16h.01', + 'circle-check': 'M3 12a9 9 0 1 0 18 0a9 9 0 1 0-18 0M9 12l2 2l4-4', + 'info-circle': 'M3 12a9 9 0 1 0 18 0a9 9 0 0 0-18 0M12 9h.01M11 12h1v4h1', + 'help-circle': 'M3 12a9 9 0 1 0 18 0a9 9 0 0 0-18 0M12 16v.01M12 13a2 2 0 0 0 .914-3.782a1.98 1.98 0 0 0-2.414.483', +} + +const iconHtml = (icon: string, variant: string): string => { + const svg = icon.startsWith('<') ? icon : `` + return `${svg}` +} + +let counter = 0 + +const open = (input: string | AlertDialogOptions, withCancel: boolean): Promise => + new Promise((resolve) => { + const { title = withCancel ? 'Are you sure?' : 'Notice', message = '', confirmText = 'OK', cancelText = 'Cancel', variant = 'primary', focus = 'confirm', icon = '', align = 'center' } = typeof input === 'string' ? { title: input } : input + const id = `tblr-alert-dialog-${++counter}` + const root = document.createElement('div') + const start = align === 'start' + const media = icon ? iconHtml(icon, variant) : '' + const text = `

` + const btn = start ? 'btn' : 'btn w-100' + const cancel = withCancel ? `
` : '' + + root.className = 'modal modal-blur fade' + root.tabIndex = -1 + root.setAttribute('aria-labelledby', `${id}-title`) + root.setAttribute('aria-describedby', `${id}-description`) + root.innerHTML = `` + + const set = (selector: string, text: string) => { + root.querySelector(selector)!.textContent = text + } + set('h3', title) + set(`#${id}-description`, message) + set('[data-confirm]', confirmText) + if (withCancel) { + set('[data-cancel]', cancelText) + } + + document.body.append(root) + + const modal = new Modal(root, { backdrop: 'static', keyboard: true }) + let result = false + + root.addEventListener('click', (event) => { + const button = (event.target as Element).closest('[data-confirm], [data-cancel]') + if (button) { + result = 'confirm' in button.dataset + modal.hide() + } + }) + root.addEventListener('shown.bs.modal', () => { + root.setAttribute('role', 'alertdialog') + root.querySelector(focus === 'cancel' ? '[data-cancel]' : '[data-confirm]')?.focus() + }) + root.addEventListener('hidden.bs.modal', () => { + modal.dispose() + root.remove() + resolve(result) + }) + + modal.show() + }) + +// js-docs-start alert-dialog-init +const AlertDialog = { + confirm: (options: string | AlertDialogOptions): Promise => open(options, true), + alert: async (options: string | AlertDialogOptions): Promise => { + await open(options, false) + }, +} +// js-docs-end alert-dialog-init + +export default AlertDialog diff --git a/core/js/tabler.ts b/core/js/tabler.ts index ef8eadf58..b3b8ab544 100644 --- a/core/js/tabler.ts +++ b/core/js/tabler.ts @@ -20,6 +20,7 @@ import './src/strength' export * from './src/bootstrap' // Tabler's own components +export { default as AlertDialog } from './src/alert-dialog' export { default as Autosize } from './src/autosize' export { default as Clipboard } from './src/clipboard' export { default as Confetti } from './src/confetti' diff --git a/core/js/tests/unit/alert-dialog.spec.ts b/core/js/tests/unit/alert-dialog.spec.ts new file mode 100644 index 000000000..f7cd39695 --- /dev/null +++ b/core/js/tests/unit/alert-dialog.spec.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, afterEach } from 'vitest' +import AlertDialog from '../../src/alert-dialog' + +const waitFor = async (selector: string): Promise => { + for (let i = 0; i < 100; i++) { + const el = document.querySelector(selector) + if (el) return el + await new Promise((r) => setTimeout(r, 20)) + } + throw new Error(`missing ${selector}`) +} + +const shown = async () => { + const el = await waitFor('.modal.show') + await new Promise((r) => setTimeout(r, 400)) + return el +} + +describe('AlertDialog', () => { + afterEach(() => { + document.body.innerHTML = '' + document.body.className = '' + }) + + it('confirm resolves true on confirm click', async () => { + const promise = AlertDialog.confirm({ title: 'Delete?', message: 'x', confirmText: 'Delete', variant: 'danger' }) + const el = await shown() + expect(el.getAttribute('role')).toBe('alertdialog') + expect(el.querySelector('b')).toBeNull() + expect(el.querySelector('.btn-danger')?.textContent).toBe('Delete') + el.querySelector('[data-confirm]')!.click() + expect(await promise).toBe(true) + expect(document.querySelector('.modal')).toBeNull() + }) + + it('confirm resolves false on cancel and on Escape', async () => { + const first = AlertDialog.confirm('Sure?') + const el = await shown() + el.querySelector('[data-cancel]')!.click() + expect(await first).toBe(false) + + const second = AlertDialog.confirm('Sure?') + const el2 = await shown() + el2.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + expect(await second).toBe(false) + }) + + it('does not close on backdrop click', async () => { + const promise = AlertDialog.confirm('Sure?') + const el = await shown() + el.click() + await new Promise((r) => setTimeout(r, 400)) + expect(document.querySelector('.modal.show')).not.toBeNull() + el.querySelector('[data-cancel]')!.click() + await promise + }) + + it('alert has a single button', async () => { + const promise = AlertDialog.alert('Saved') + const el = await shown() + expect(el.querySelectorAll('button')).toHaveLength(1) + el.querySelector('button')!.click() + await promise + }) + + it('renders a built-in icon and the start layout', async () => { + const promise = AlertDialog.confirm({ title: 'Delete?', icon: 'trash', variant: 'danger', align: 'start' }) + const el = await shown() + expect(el.querySelector('.avatar.bg-danger-lt svg path')).not.toBeNull() + expect(el.querySelector('.modal-body')?.classList.contains('d-flex')).toBe(true) + expect(el.querySelector('.modal-dialog')?.classList.contains('modal-sm')).toBe(false) + el.querySelector('[data-cancel]')!.click() + await promise + }) + + it('accepts icon markup', async () => { + const promise = AlertDialog.alert({ title: 'Hi', icon: '' }) + const el = await shown() + expect(el.querySelector('#custom-icon')).not.toBeNull() + el.querySelector('button')!.click() + await promise + }) +}) diff --git a/core/package.json b/core/package.json index 8a181cc92..e909d747f 100644 --- a/core/package.json +++ b/core/package.json @@ -212,7 +212,7 @@ }, { "path": "./dist/js/tabler.js", - "maxSize": "58 kB" + "maxSize": "60 kB" }, { "path": "./dist/js/tabler.min.js", @@ -220,7 +220,7 @@ }, { "path": "./dist/js/tabler.esm.js", - "maxSize": "58 kB" + "maxSize": "60 kB" }, { "path": "./dist/js/tabler.esm.min.js", diff --git a/core/scss/ui/_modals.scss b/core/scss/ui/_modals.scss index bb1ae182b..78beff434 100644 --- a/core/scss/ui/_modals.scss +++ b/core/scss/ui/_modals.scss @@ -32,9 +32,11 @@ inset-inline-start: 0; inset-inline-end: 0; top: 0; - height: $modal-status-size; + bottom: 0; + pointer-events: none; background: var(--secondary); - @include border-radius($modal-content-border-radius $modal-content-border-radius 0 0); + clip-path: inset(0 0 calc(100% - #{$modal-status-size}) 0); + @include border-radius($modal-content-inner-border-radius $modal-content-inner-border-radius 0 0); } .modal-header { diff --git a/docs/content/ui/components/alert-dialog.mdx b/docs/content/ui/components/alert-dialog.mdx new file mode 100644 index 000000000..4aaa5c3bb --- /dev/null +++ b/docs/content/ui/components/alert-dialog.mdx @@ -0,0 +1,137 @@ +--- +title: Alert dialog +summary: An alert dialog is a small modal that interrupts the user to confirm an action or to show a short message. Use it instead of the browser's confirm() and alert(). +description: Replace window.confirm() and window.alert() with a styled modal opened from JavaScript that returns a Promise. +added-in: '1.7.0' +related: [/ui/components/modal, /ui/components/alert, /ui/components/toast] +source: core/js/src/alert-dialog.ts +--- + +import Example from '@components/Example.astro' + +## Overview + +`AlertDialog` builds a small modal at run time and gives you the answer as a Promise. There is no markup to write. It is made for two jobs: asking the user to confirm something, and telling the user something they must acknowledge. + +Unlike a normal [modal](/ui/components/modal), an alert dialog does not close when the user clicks the backdrop. The user has to choose a button, or press Esc to cancel. + + + + + +## Usage + +`AlertDialog.confirm()` returns a Promise that resolves to `true` when the user confirms and to `false` when the user cancels or presses Esc. + +```js +const ok = await tabler.AlertDialog.confirm({ + title: 'Delete 84 items?', + message: 'What you have done cannot be undone.', + confirmText: 'Delete', + variant: 'danger', +}) + +if (ok) { + deleteItems() +} +``` + +`AlertDialog.alert()` shows one button and resolves when the user closes the dialog. + +```js +await tabler.AlertDialog.alert({ title: 'Saved', message: 'Your changes are live.', variant: 'success' }) +``` + +Both methods also accept a string. It is used as the title. + +```js +if (await tabler.AlertDialog.confirm('Discard the draft?')) { + discard() +} +``` + +## Variants + +The `variant` option sets the color of the confirm button. It accepts `primary` (default), `secondary`, `success`, `info`, `warning` and `danger`. Use `danger` for actions that destroy data. + + + + + + + +## Icons + +Set `icon` to add a round icon above the text. The icon takes the color of the variant. Use one of the built-in names: `trash`, `alert-triangle`, `circle-check`, `info-circle` or `help-circle`. + + + + + + + + +To use another icon, pass its markup, for example an inline ``. The string is inserted as HTML, so pass only markup you wrote yourself. + +```js +tabler.AlertDialog.alert({ icon: '…', title: 'Done' }) +``` + +## Layout + +By default the dialog is small and centered: the icon is on top, the text is centered and the buttons fill the width. Set `align: 'start'` for a wider dialog with the text aligned to the left, the icon next to the text and the buttons on the right. + + + + + + +The message is optional. A dialog with only a title is fine for a short question. + + + + + +## Options + +| Option | Default | Description | +| ------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| `title` | `Are you sure?` for `confirm()`, `Notice` for `alert()` | The heading of the dialog. | +| `message` | none | A line of text under the title. It is set as plain text, so HTML is not rendered. | +| `confirmText` | `OK` | The label of the confirm button. | +| `cancelText` | `Cancel` | The label of the cancel button. `confirm()` only. | +| `variant` | `primary` | The color of the confirm button. | +| `icon` | none | A built-in icon name or icon markup, shown in a round badge in the color of the variant. | +| `align` | `center` | `center` or `start`. See [Layout](#layout). | +| `focus` | `confirm` | Which button gets focus when the dialog opens: `confirm` or `cancel`. `confirm()` only. | + +For a destructive action, set `focus: 'cancel'` so a stray Enter does not confirm it. + +## Accessibility + +- The dialog has `role="alertdialog"` and is named and described by its title and message with `aria-labelledby` and `aria-describedby`. +- Focus moves into the dialog when it opens and stays inside until it closes, then returns to the element that opened it. +- Esc cancels the dialog. Clicking the backdrop does nothing, so an answer is never given by accident. +- Keep the title short and the button labels specific, for example `Delete` instead of `Yes`. diff --git a/preview/pages/alert-dialogs.astro b/preview/pages/alert-dialogs.astro new file mode 100644 index 000000000..6cc99f81b --- /dev/null +++ b/preview/pages/alert-dialogs.astro @@ -0,0 +1,154 @@ +--- +import DefaultLayout from '@shared/layouts/DefaultLayout.astro' +import CaptureScript from '@shared/components/CaptureScript.astro' +import Card from '@ui/Card.astro' +import CardBody from '@ui/CardBody.astro' +import CardTitle from '@ui/CardTitle.astro' +import CardSubtitle from '@ui/CardSubtitle.astro' +import DocsLink from '@ui/DocsLink.astro' +import Button from '@ui/Button.astro' +import ButtonList from '@ui/ButtonList.astro' +--- + + + + + + + + + +
+
+ + + Basic + Title, message and two buttons. confirm() resolves to true or false. + +
+ +
+ + + Alert + alert() shows one button and resolves when it is closed. + +
+ +
+ + + Title only + The message is optional. + +
+ +
+ + + With icon + Set icon to a built-in icon name. The icon sits above the text. + +
+ +
+ + + Icon on the left + Set align: 'start' for a wider dialog with the icon next to the text and the buttons on the right. + +
+ +
+ + + Left aligned + Without an icon, align: 'start' left aligns the text. + +
+ +
+ + + Variants + Set variant to color the confirm button. + +
+ +
+ + + Variants with icon + The icon takes the color of the variant. + +
+
+ + + Result + The answer of the last dialog. +
No dialog opened yet
+
+
+
+
+ diff --git a/screenshots/pages/alert-dialog.astro b/screenshots/pages/alert-dialog.astro new file mode 100644 index 000000000..b5df330ac --- /dev/null +++ b/screenshots/pages/alert-dialog.astro @@ -0,0 +1,86 @@ +--- +// Alert dialogs drawn in place, not opened: the capture never clicks, and a real +// dialog sits on a backdrop over the page. The markup is the one `AlertDialog` +// builds, so the picture shows four of its layouts side by side: a plain confirm, +// a danger confirm with an icon, a success alert, and the left aligned one. +import ScreenshotLayout from '../layouts/ScreenshotLayout.astro' +import Icon from '@ui/Icon.astro' + +const dialogs = [ + { title: 'Publish this page?', message: 'It will be visible to everyone.', confirm: 'Publish', cancel: 'Cancel', variant: 'primary' }, + { title: 'Delete 84 items?', message: 'What you have done cannot be undone.', confirm: 'Delete', cancel: 'Cancel', variant: 'danger', icon: 'trash' }, + { title: 'Payment received', message: 'Thank you for your order.', confirm: 'OK', variant: 'success', icon: 'circle-check' }, +] +const wide = { title: 'Enable notifications?', message: 'We will only send important updates.', confirm: 'Enable', cancel: 'Not now', variant: 'primary', icon: 'info-circle' } +--- + + +
+ { + dialogs.map((dialog) => ( +
+ +
+ )) + } +
+ +
+
+
diff --git a/shared/data/docs.json b/shared/data/docs.json index 883a93d8b..5e8c1a689 100644 --- a/shared/data/docs.json +++ b/shared/data/docs.json @@ -170,6 +170,10 @@ "title": "Alert", "url": "/ui/components/alert" }, + { + "title": "Alert dialog", + "url": "/ui/components/alert-dialog" + }, { "title": "Avatar", "url": "/ui/components/avatar" diff --git a/shared/data/menu.json b/shared/data/menu.json index f0df71c6d..59269ffa6 100644 --- a/shared/data/menu.json +++ b/shared/data/menu.json @@ -39,6 +39,11 @@ "title": "Alerts", "url": "alerts.html" }, + "alert-dialogs": { + "title": "Alert dialogs", + "url": "alert-dialogs.html", + "badge": "New" + }, "authentication": { "title": "Authentication", "children": {