mirror of
https://github.com/tabler/tabler.git
synced 2026-10-12 05:06:55 +04:00
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:
1 parent
628216d02f
commit
c7f5611f58
3 files changed
+148
-26
No files matched your search
+86
-11
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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`.
|
||||
Reference in new issue
Block a user