diff --git a/core/js/src/alert-dialog.ts b/core/js/src/alert-dialog.ts index 111fc06ad..eb86de8dd 100644 --- a/core/js/src/alert-dialog.ts +++ b/core/js/src/alert-dialog.ts @@ -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 = { '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}` +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 = Promise.resolve() -const open = (input: string | AlertDialogOptions, withCancel: boolean): Promise => +const waitForOpenModal = (): Promise => + 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 => 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 = `

` const btn = start ? 'btn' : 'btn w-100' - const cancel = withCancel ? `
` : '' + const col = start ? 'col-auto' : 'col' + const cancel = withCancel ? `
` : '' + 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 = `` + root.innerHTML = `` 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('[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(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 => { + 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 => open(options, true), diff --git a/core/js/tests/unit/alert-dialog.spec.ts b/core/js/tests/unit/alert-dialog.spec.ts index f7cd39695..2bfd78fa3 100644 --- a/core/js/tests/unit/alert-dialog.spec.ts +++ b/core/js/tests/unit/alert-dialog.spec.ts @@ -73,11 +73,57 @@ describe('AlertDialog', () => { await promise }) - it('accepts icon markup', async () => { - const promise = AlertDialog.alert({ title: 'Hi', icon: '' }) + 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('[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('[data-confirm]')!.click() + expect(await first).toBe(true) + + const next = await shown() + expect(next.querySelector('h3')?.textContent).toBe('Second') + next.querySelector('[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('[data-cancel]')!.click() + await promise + expect(document.activeElement).toBe(opener) + }) }) diff --git a/docs/content/ui/components/alert-dialog.mdx b/docs/content/ui/components/alert-dialog.mdx index 4aaa5c3bb..d3c931ea7 100644 --- a/docs/content/ui/components/alert-dialog.mdx +++ b/docs/content/ui/components/alert-dialog.mdx @@ -87,10 +87,10 @@ Set `icon` to add a round icon above the text. The icon takes the color of the v -To use another icon, pass its markup, for example an inline ``. The string is inserted as HTML, so pass only markup you wrote yourself. +To use another icon, pass a DOM element, for example an `` you created or found in the page. The element is cloned into the dialog. ```js -tabler.AlertDialog.alert({ icon: '…', 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 Enter does not confirm it. @@ -133,5 +133,6 @@ For a destructive action, set `focus: 'cancel'` so a stray Enter 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. - 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`.