explodorama / files / src / ui / dialog.ts
dialog.ts
| 1 | /** |
| 2 | * The one modal dialog: info about something tapped, the menu, and confirmations. A native |
| 3 | * `<dialog>` shown with `showModal()`, so it blocks the board, traps focus and closes on Esc. |
| 4 | * Styled with Kenney's UI packs (style.css). |
| 5 | */ |
| 6 | |
| 7 | export interface DialogButton { |
| 8 | label: string |
| 9 | /** primary: green; danger: red; plain: grey. */ |
| 10 | kind: 'primary' | 'danger' | 'plain' |
| 11 | /** An image URL shown before the label. */ |
| 12 | icon?: string |
| 13 | /** Runs after the dialog closes — or at once, with `keepOpen`. */ |
| 14 | onClick?: () => void |
| 15 | /** The dialog stays open (the shop's Buy and Restock). */ |
| 16 | keepOpen?: boolean |
| 17 | } |
| 18 | |
| 19 | export interface DialogContent { |
| 20 | /** Image URL for the picture at the top. */ |
| 21 | icon?: string |
| 22 | title: string |
| 23 | /** Trusted HTML built by the game itself (never from user input). */ |
| 24 | body: string |
| 25 | buttons: DialogButton[] |
| 26 | /** Must be answered from its body: no close button, and neither Esc nor the backdrop closes it. */ |
| 27 | required?: boolean |
| 28 | /** A class on the panel, for a dialog laid out its own way (the shop). */ |
| 29 | className?: string |
| 30 | } |
| 31 | |
| 32 | const dialog = document.getElementById('dialog') as HTMLDialogElement |
| 33 | const icon = document.getElementById('dialog-icon') as HTMLImageElement |
| 34 | const title = document.getElementById('dialog-title')! |
| 35 | const body = document.getElementById('dialog-body')! |
| 36 | const buttons = document.getElementById('dialog-buttons')! |
| 37 | const panel = dialog.querySelector<HTMLElement>('.panel')! |
| 38 | |
| 39 | /** How long the panel takes to shrink away and the backdrop to clear (style.css `.closing`). */ |
| 40 | const CLOSE_MS = 150 |
| 41 | let closing: { timer: number; then?: () => void } | null = null |
| 42 | let required = false |
| 43 | let inDemo = false |
| 44 | |
| 45 | /** |
| 46 | * In the demo (main.ts) the dialog opens over the demo's scene only, not as a modal over the |
| 47 | * whole page: the caption panel below stays undimmed (user request, 2026-10-24). Shown with |
| 48 | * `show()`, placed and dimmed by style.css (`.in-demo`), so the demo's finger can go over it. |
| 49 | */ |
| 50 | export function setDialogInDemo(on: boolean): void { |
| 51 | inDemo = on |
| 52 | } |
| 53 | |
| 54 | /** Close with a short shrink-and-fade, then run `then` (a button's action). */ |
| 55 | export function closeDialog(then?: () => void): void { |
| 56 | if (!dialog.open || closing) return |
| 57 | if (matchMedia('(prefers-reduced-motion: reduce)').matches) { |
| 58 | dialog.close() |
| 59 | releaseFocus() |
| 60 | then?.() |
| 61 | return |
| 62 | } |
| 63 | dialog.classList.add('closing') |
| 64 | closing = { timer: window.setTimeout(finishClosing, CLOSE_MS), then } |
| 65 | } |
| 66 | |
| 67 | /** Closes the dialog if it can be dismissed: what Esc does, for the controller's Circle. */ |
| 68 | export function dismissDialog(): void { |
| 69 | if (!required) closeDialog() |
| 70 | } |
| 71 | |
| 72 | function finishClosing(): void { |
| 73 | if (!closing) return |
| 74 | const { timer, then } = closing |
| 75 | closing = null |
| 76 | clearTimeout(timer) |
| 77 | dialog.classList.remove('closing') |
| 78 | dialog.close() |
| 79 | releaseFocus() |
| 80 | then?.() |
| 81 | } |
| 82 | |
| 83 | /** The browser hands the focus back to what had it (an action button, the menu button), which would then take Space and Enter from the game: the keyboard plays the board again (Q65). */ |
| 84 | function releaseFocus(): void { |
| 85 | if (document.activeElement instanceof HTMLButtonElement) document.activeElement.blur() |
| 86 | } |
| 87 | |
| 88 | const closeButton = document.getElementById('dialog-close')! |
| 89 | closeButton.addEventListener('click', () => closeDialog()) |
| 90 | // A tap on the dimmed backdrop (the dialog element itself, outside its panel) closes it. |
| 91 | dialog.addEventListener('click', (e) => { |
| 92 | if (e.target === dialog && !required) closeDialog() |
| 93 | }) |
| 94 | // LittleJS cancels every wheel event on the page (so the game never scrolls it), which also |
| 95 | // stopped a long dialog, like the almanac, scrolling with a mouse wheel or trackpad. Kept here. |
| 96 | dialog.addEventListener('wheel', (e) => e.stopPropagation(), { passive: true }) |
| 97 | // The same for touch: LittleJS cancels every touch on the page that isn't on a control, so a |
| 98 | // finger couldn't scroll a long dialog, like the almanac, on a phone (user report, 2026-10-24). |
| 99 | // Touches in the dialog don't reach it; the board is behind the dialog anyway. |
| 100 | for (const type of ['touchstart', 'touchmove', 'touchend', 'touchcancel'] as const) { |
| 101 | dialog.addEventListener(type, (e) => e.stopPropagation(), { passive: true }) |
| 102 | } |
| 103 | // Enter with the focus on the panel itself (where a dialog opens, so nothing looks pre-selected) |
| 104 | // presses its green button (or its only one), so a keyboard player need not Tab first (Q65). A |
| 105 | // button that has the focus is pressed by the browser as ever; a dialog with several buttons and |
| 106 | // no green one needs a choice. |
| 107 | panel.addEventListener('keydown', (e) => { |
| 108 | if (e.key !== 'Enter' || e.target !== panel) return |
| 109 | const all = buttons.querySelectorAll<HTMLButtonElement>('button') |
| 110 | const primary = buttons.querySelector<HTMLButtonElement>('button.primary') ?? (all.length === 1 ? all[0] : undefined) |
| 111 | if (!primary) return |
| 112 | // The game's own Enter handler (main.ts) must not also see this key once the dialog has closed. |
| 113 | e.preventDefault() |
| 114 | e.stopPropagation() |
| 115 | primary.click() |
| 116 | }) |
| 117 | // Esc closes it too, animated like the rest. |
| 118 | dialog.addEventListener('cancel', (e) => { |
| 119 | e.preventDefault() |
| 120 | if (!required) closeDialog() |
| 121 | }) |
| 122 | |
| 123 | export function openDialog(content: DialogContent): void { |
| 124 | // Still closing the last one: finish that first (and run its button's action). |
| 125 | finishClosing() |
| 126 | icon.hidden = !content.icon |
| 127 | if (content.icon) icon.src = content.icon |
| 128 | required = !!content.required |
| 129 | closeButton.hidden = required |
| 130 | panel.classList.toggle('required', required) |
| 131 | panel.className = ['panel', required ? 'required' : '', content.className ?? ''].filter(Boolean).join(' ') |
| 132 | title.textContent = content.title |
| 133 | body.innerHTML = content.body |
| 134 | buttons.replaceChildren( |
| 135 | ...content.buttons.map((b) => { |
| 136 | const el = document.createElement('button') |
| 137 | el.type = 'button' |
| 138 | el.className = `ui-button ${b.kind}` |
| 139 | el.innerHTML = (b.icon ? `<img src="${b.icon}" alt="">` : '') + `<span>${b.label}</span>` |
| 140 | el.addEventListener('click', () => (b.keepOpen ? b.onClick?.() : closeDialog(b.onClick))) |
| 141 | return el |
| 142 | }), |
| 143 | ) |
| 144 | if (dialog.open) dialog.close() |
| 145 | dialog.classList.toggle('in-demo', inDemo) |
| 146 | if (inDemo) dialog.show() |
| 147 | else dialog.showModal() |
| 148 | // Focus the panel, not the first button, so nothing looks pre-selected; Tab still reaches the buttons. |
| 149 | panel.focus() |
| 150 | // The body is one element for every dialog, so it kept the last one's scroll: each opens at the |
| 151 | // top (user report, 2026-10-24). After showing, as a closed dialog has no layout to scroll. |
| 152 | body.scrollTop = 0 |
| 153 | // For the controller (padDialog.ts), which starts its selector afresh on a new dialog. |
| 154 | dialog.dispatchEvent(new Event('dialogopen')) |
| 155 | } |
| 156 | |
| 157 | /** The menu's title: the controller's Start button closes a dialog only if it is this one. */ |
| 158 | export const MENU_TITLE = 'Menu' |
| 159 | |
| 160 | export function dialogOpen(): boolean { |
| 161 | return dialog.open |
| 162 | } |