explodorama / files / src / ui / dialog.ts
dialog.ts
a4fab0fb 2026-10-21 Pavel Safronov | 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 | |
e78b7781 2026-10-25 Pavel Safronov | 13 | /** Runs after the dialog closes — or at once, with `keepOpen`. */ |
a4fab0fb 2026-10-21 Pavel Safronov | 14 | onClick?: () => void |
e78b7781 2026-10-25 Pavel Safronov | 15 | /** The dialog stays open (the shop's Buy and Restock). */ |
| 16 | keepOpen?: boolean | |
a4fab0fb 2026-10-21 Pavel Safronov | 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[] | |
52d68b59 2026-10-24 Pavel Safronov | 26 | /** Must be answered from its body: no close button, and neither Esc nor the backdrop closes it. */ |
| 27 | required?: boolean | |
e78b7781 2026-10-25 Pavel Safronov | 28 | /** A class on the panel, for a dialog laid out its own way (the shop). */ |
| 29 | className?: string | |
a4fab0fb 2026-10-21 Pavel Safronov | 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 | ||
ea380ee8 2026-10-22 Pavel Safronov | 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 | |
52d68b59 2026-10-24 Pavel Safronov | 42 | let required = false |
e3384c6b 2026-10-24 Pavel Safronov | 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 | } | |
ea380ee8 2026-10-22 Pavel Safronov | 53 | |
| 54 | /** Close with a short shrink-and-fade, then run `then` (a button's action). */ | |
52d68b59 2026-10-24 Pavel Safronov | 55 | export function closeDialog(then?: () => void): void { |
ea380ee8 2026-10-22 Pavel Safronov | 56 | if (!dialog.open || closing) return |
| 57 | if (matchMedia('(prefers-reduced-motion: reduce)').matches) { | |
| 58 | dialog.close() | |
92cc4ee3 2026-11-02 Pavel Safronov | 59 | releaseFocus() |
ea380ee8 2026-10-22 Pavel Safronov | 60 | then?.() |
| 61 | return | |
| 62 | } | |
| 63 | dialog.classList.add('closing') | |
| 64 | closing = { timer: window.setTimeout(finishClosing, CLOSE_MS), then } | |
| 65 | } | |
| 66 | ||
ebcd0a8f 2026-11-02 Pavel Safronov | 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 | ||
ea380ee8 2026-10-22 Pavel Safronov | 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() | |
92cc4ee3 2026-11-02 Pavel Safronov | 79 | releaseFocus() |
ea380ee8 2026-10-22 Pavel Safronov | 80 | then?.() |
| 81 | } | |
| 82 | ||
92cc4ee3 2026-11-02 Pavel Safronov | 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 | ||
52d68b59 2026-10-24 Pavel Safronov | 88 | const closeButton = document.getElementById('dialog-close')! |
| 89 | closeButton.addEventListener('click', () => closeDialog()) | |
a4fab0fb 2026-10-21 Pavel Safronov | 90 | // A tap on the dimmed backdrop (the dialog element itself, outside its panel) closes it. |
| 91 | dialog.addEventListener('click', (e) => { | |
52d68b59 2026-10-24 Pavel Safronov | 92 | if (e.target === dialog && !required) closeDialog() |
ea380ee8 2026-10-22 Pavel Safronov | 93 | }) |
99869630 2026-10-23 Pavel Safronov | 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 }) | |
33a0256b 2026-10-24 Pavel Safronov | 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 | } | |
92cc4ee3 2026-11-02 Pavel Safronov | 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 | }) | |
ea380ee8 2026-10-22 Pavel Safronov | 117 | // Esc closes it too, animated like the rest. |
| 118 | dialog.addEventListener('cancel', (e) => { | |
| 119 | e.preventDefault() | |
52d68b59 2026-10-24 Pavel Safronov | 120 | if (!required) closeDialog() |
a4fab0fb 2026-10-21 Pavel Safronov | 121 | }) |
| 122 | ||
| 123 | export function openDialog(content: DialogContent): void { | |
ea380ee8 2026-10-22 Pavel Safronov | 124 | // Still closing the last one: finish that first (and run its button's action). |
| 125 | finishClosing() | |
a4fab0fb 2026-10-21 Pavel Safronov | 126 | icon.hidden = !content.icon |
| 127 | if (content.icon) icon.src = content.icon | |
52d68b59 2026-10-24 Pavel Safronov | 128 | required = !!content.required |
| 129 | closeButton.hidden = required | |
| 130 | panel.classList.toggle('required', required) | |
e78b7781 2026-10-25 Pavel Safronov | 131 | panel.className = ['panel', required ? 'required' : '', content.className ?? ''].filter(Boolean).join(' ') |
a4fab0fb 2026-10-21 Pavel Safronov | 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>` | |
e78b7781 2026-10-25 Pavel Safronov | 140 | el.addEventListener('click', () => (b.keepOpen ? b.onClick?.() : closeDialog(b.onClick))) |
a4fab0fb 2026-10-21 Pavel Safronov | 141 | return el |
| 142 | }), | |
| 143 | ) | |
| 144 | if (dialog.open) dialog.close() | |
e3384c6b 2026-10-24 Pavel Safronov | 145 | dialog.classList.toggle('in-demo', inDemo) |
| 146 | if (inDemo) dialog.show() | |
| 147 | else dialog.showModal() | |
a4fab0fb 2026-10-21 Pavel Safronov | 148 | // Focus the panel, not the first button, so nothing looks pre-selected; Tab still reaches the buttons. |
| 149 | panel.focus() | |
e78b7781 2026-10-25 Pavel Safronov | 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 | |
ebcd0a8f 2026-11-02 Pavel Safronov | 153 | // For the controller (padDialog.ts), which starts its selector afresh on a new dialog. |
| 154 | dialog.dispatchEvent(new Event('dialogopen')) | |
a4fab0fb 2026-10-21 Pavel Safronov | 155 | } |
| 156 | ||
ebcd0a8f 2026-11-02 Pavel Safronov | 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 | ||
a4fab0fb 2026-10-21 Pavel Safronov | 160 | export function dialogOpen(): boolean { |
| 161 | return dialog.open | |
| 162 | } |