Address AlertDialog review comments

Build the dialog markup from constants only and set the variant through a
whitelist, so no caller input reaches `innerHTML`. `icon` now takes a built-in
name or a DOM element instead of an HTML string.

Queue dialogs so only one is open at a time, hold a dismissal until the dialog
has finished opening, and return focus to the element that opened it.
This commit is contained in:
Bartek committed 2026-09-30 13:08:42 +02:00
1 parent 628216d02f
commit c7f5611f58
3 files changed
+148 -26

No files matched your search

+86 -11
View File
@@ -1,13 +1,15 @@
import { Modal } from './bootstrap'
const VARIANTS = ['primary', 'secondary', 'success', 'info', 'warning', 'danger'] as const
export type AlertDialogOptions = {
title?: string
message?: string
confirmText?: string
cancelText?: string
variant?: 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'danger'
variant?: (typeof VARIANTS)[number]
focus?: 'confirm' | 'cancel'
icon?: string
icon?: string | Element
align?: 'center' | 'start'
}
@@ -19,29 +21,60 @@ const ICONS: Record<string, string> = {
'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 : `<svg class="icon icon-lg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${ICONS[icon] ?? ''}"/></svg>`
return `<span class="avatar avatar-lg bg-${variant}-lt flex-shrink-0">${svg}</span>`
const SVG_NS = 'http://www.w3.org/2000/svg'
const createIcon = (icon: string | Element, variant: string): HTMLElement => {
const badge = document.createElement('span')
badge.className = 'avatar avatar-lg flex-shrink-0'
badge.classList.add(`bg-${variant}-lt`)
if (icon instanceof Element) {
badge.append(icon.cloneNode(true))
return badge
}
const svg = document.createElementNS(SVG_NS, 'svg')
const path = document.createElementNS(SVG_NS, 'path')
const attributes = { 'class': 'icon icon-lg', 'width': '24', 'height': '24', 'viewBox': '0 0 24 24', 'fill': 'none', 'stroke': 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }
for (const [name, value] of Object.entries(attributes)) {
svg.setAttribute(name, value)
}
path.setAttribute('d', ICONS[icon] ?? '')
svg.append(path)
badge.append(svg)
return badge
}
let counter = 0
let queue: Promise<unknown> = Promise.resolve()
const open = (input: string | AlertDialogOptions, withCancel: boolean): Promise<boolean> =>
const waitForOpenModal = (): Promise<void> =>
new Promise((resolve) => {
const openModal = document.querySelector('.modal.show')
if (!openModal) {
resolve()
return
}
openModal.addEventListener('hidden.bs.modal', () => resolve(), { once: true })
})
const show = (input: string | AlertDialogOptions, withCancel: boolean): Promise<boolean> =>
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 color = VARIANTS.includes(variant) ? variant : 'primary'
const id = `tblr-alert-dialog-${++counter}`
const root = document.createElement('div')
const start = align === 'start'
const media = icon ? iconHtml(icon, variant) : ''
const text = `<h3 id="${id}-title"></h3><div id="${id}-description" class="text-secondary"></div>`
const btn = start ? 'btn' : 'btn w-100'
const cancel = withCancel ? `<div class="col${start ? '-auto' : ''}"><button type="button" class="${btn}" data-cancel></button></div>` : ''
const col = start ? 'col-auto' : 'col'
const cancel = withCancel ? `<div class="${col}"><button type="button" class="${btn}" data-cancel></button></div>` : ''
const opener = document.activeElement as HTMLElement | null
root.className = 'modal modal-blur fade'
root.tabIndex = -1
root.setAttribute('aria-labelledby', `${id}-title`)
root.setAttribute('aria-describedby', `${id}-description`)
root.innerHTML = `<div class="modal-dialog ${start ? '' : 'modal-sm '}modal-dialog-centered" style="transition-duration: 0.15s"><div class="modal-content"><div class="modal-body py-4 ${start ? 'd-flex gap-3' : 'text-center'}">${start ? `${media}<div>${text}</div>` : `${media ? `<div class="mb-3">${media}</div>` : ''}${text}`}</div><div class="modal-footer" style="--tblr-modal-footer-padding-y: 0.375rem"><div class="row gx-2 ${start ? 'ms-auto' : 'w-100'}">${cancel}<div class="col${start ? '-auto' : ''}"><button type="button" class="${btn} btn-${variant}" data-confirm></button></div></div></div></div></div>`
root.innerHTML = `<div class="modal-dialog ${start ? '' : 'modal-sm '}modal-dialog-centered" style="transition-duration: 0.15s"><div class="modal-content"><div class="modal-body py-4 ${start ? 'd-flex gap-3' : 'text-center'}">${start ? '<div>' : ''}<h3 id="${id}-title"></h3><div id="${id}-description" class="text-secondary"></div>${start ? '</div>' : ''}</div><div class="modal-footer" style="--tblr-modal-footer-padding-y: 0.375rem"><div class="row gx-2 ${start ? 'ms-auto' : 'w-100'}">${cancel}<div class="${col}"><button type="button" class="${btn}" data-confirm></button></div></div></div></div></div>`
const set = (selector: string, text: string) => {
root.querySelector(selector)!.textContent = text
@@ -49,35 +82,77 @@ const open = (input: string | AlertDialogOptions, withCancel: boolean): Promise<
set('h3', title)
set(`#${id}-description`, message)
set('[data-confirm]', confirmText)
root.querySelector('[data-confirm]')!.classList.add(`btn-${color}`)
if (withCancel) {
set('[data-cancel]', cancelText)
}
if (icon) {
const badge = createIcon(icon, color)
const body = root.querySelector('.modal-body')!
if (start) {
body.prepend(badge)
} else {
const wrapper = document.createElement('div')
wrapper.className = 'mb-3'
wrapper.append(badge)
body.prepend(wrapper)
}
}
document.body.append(root)
const modal = new Modal(root, { backdrop: 'static', keyboard: true })
let result = false
let isShown = false
let dismissRequested = false
const dismiss = () => {
if (isShown) {
modal.hide()
} else {
dismissRequested = true
}
}
root.addEventListener('click', (event) => {
const button = (event.target as Element).closest<HTMLElement>('[data-confirm], [data-cancel]')
if (button) {
result = 'confirm' in button.dataset
modal.hide()
dismiss()
}
})
root.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
dismiss()
}
})
root.addEventListener('shown.bs.modal', () => {
isShown = true
root.setAttribute('role', 'alertdialog')
root.querySelector<HTMLElement>(focus === 'cancel' ? '[data-cancel]' : '[data-confirm]')?.focus()
if (dismissRequested) {
modal.hide()
}
})
root.addEventListener('hidden.bs.modal', () => {
modal.dispose()
root.remove()
if (opener?.isConnected) {
opener.focus()
}
resolve(result)
})
modal.show()
})
const open = (input: string | AlertDialogOptions, withCancel: boolean): Promise<boolean> => {
const result = queue.then(waitForOpenModal).then(() => show(input, withCancel))
queue = result.catch(() => undefined)
return result
}
// js-docs-start alert-dialog-init
const AlertDialog = {
confirm: (options: string | AlertDialogOptions): Promise<boolean> => open(options, true),
+49 -3
View File
@@ -73,11 +73,57 @@ describe('AlertDialog', () => {
await promise
})
it('accepts icon markup', async () => {
const promise = AlertDialog.alert({ title: 'Hi', icon: '<svg id="custom-icon"></svg>' })
it('accepts an icon element', async () => {
const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
icon.id = 'custom-icon'
const promise = AlertDialog.alert({ title: 'Hi', icon })
const el = await shown()
expect(el.querySelector('#custom-icon')).not.toBeNull()
expect(el.querySelector('.avatar #custom-icon')).not.toBeNull()
el.querySelector('button')!.click()
await promise
})
it('does not put an unknown variant into the markup', async () => {
const promise = AlertDialog.confirm({ title: 'Hi', icon: 'trash', variant: 'x" onmouseover="alert(1)' as never })
const el = await shown()
expect(el.querySelector('[data-confirm]')?.className).toBe('btn w-100 btn-primary')
expect(el.querySelector('.avatar')?.className).toBe('avatar avatar-lg flex-shrink-0 bg-primary-lt')
expect(el.querySelector('[onmouseover]')).toBeNull()
el.querySelector<HTMLElement>('[data-cancel]')!.click()
await promise
})
it('opens dialogs one at a time', async () => {
const first = AlertDialog.confirm('First')
const second = AlertDialog.confirm('Second')
const el = await shown()
expect(el.querySelector('h3')?.textContent).toBe('First')
expect(document.querySelectorAll('.modal')).toHaveLength(1)
el.querySelector<HTMLElement>('[data-confirm]')!.click()
expect(await first).toBe(true)
const next = await shown()
expect(next.querySelector('h3')?.textContent).toBe('Second')
next.querySelector<HTMLElement>('[data-cancel]')!.click()
expect(await second).toBe(false)
})
it('settles when dismissed while it is still opening', async () => {
const promise = AlertDialog.confirm('Quick')
const button = await waitFor('[data-confirm]')
button.click()
expect(await promise).toBe(true)
})
it('returns focus to the opener', async () => {
const opener = document.createElement('button')
document.body.append(opener)
opener.focus()
const promise = AlertDialog.confirm('Focus')
const el = await shown()
expect(el.contains(document.activeElement)).toBe(true)
el.querySelector<HTMLElement>('[data-cancel]')!.click()
await promise
expect(document.activeElement).toBe(opener)
})
})
+13 -12
View File
@@ -87,10 +87,10 @@ Set `icon` to add a round icon above the text. The icon takes the color of the v
</button>
</Example>
To use another icon, pass its markup, for example an inline `<svg>`. The string is inserted as HTML, so pass only markup you wrote yourself.
To use another icon, pass a DOM element, for example an `<svg>` you created or found in the page. The element is cloned into the dialog.
```js
tabler.AlertDialog.alert({ icon: '<svg class="icon icon-lg">…</svg>', title: 'Done' })
tabler.AlertDialog.alert({ icon: document.querySelector('#my-icon'), title: 'Done' })
```
## Layout
@@ -116,16 +116,16 @@ 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. |
| 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 a DOM element, 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 <kbd>Enter</kbd> does not confirm it.
@@ -133,5 +133,6 @@ For a destructive action, set `focus: 'cancel'` so a stray <kbd>Enter</kbd> does
- 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.
- Only one dialog is open at a time. If you call `confirm()` or `alert()` while another dialog or modal is open, the new dialog waits and opens after the other one closes.
- <kbd>Esc</kbd> 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`.