fuzzyworld

explodorama / files / src / ui / dialog.ts

dialog.ts

Blame: the commit that last changed each line · file · history

a4fab0fb 2026-10-21 Pavel Safronov1/**
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
7export 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 Safronov13 /** Runs after the dialog closes — or at once, with `keepOpen`. */
a4fab0fb 2026-10-21 Pavel Safronov14 onClick?: () => void
e78b7781 2026-10-25 Pavel Safronov15 /** The dialog stays open (the shop's Buy and Restock). */
16 keepOpen?: boolean
a4fab0fb 2026-10-21 Pavel Safronov17}
18
19export 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 Safronov26 /** 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 Safronov28 /** A class on the panel, for a dialog laid out its own way (the shop). */
29 className?: string
a4fab0fb 2026-10-21 Pavel Safronov30}
31
32const dialog = document.getElementById('dialog') as HTMLDialogElement
33const icon = document.getElementById('dialog-icon') as HTMLImageElement
34const title = document.getElementById('dialog-title')!
35const body = document.getElementById('dialog-body')!
36const buttons = document.getElementById('dialog-buttons')!
37const panel = dialog.querySelector<HTMLElement>('.panel')!
38
ea380ee8 2026-10-22 Pavel Safronov39/** How long the panel takes to shrink away and the backdrop to clear (style.css `.closing`). */
40const CLOSE_MS = 150
41let closing: { timer: number; then?: () => void } | null = null
52d68b59 2026-10-24 Pavel Safronov42let required = false
e3384c6b 2026-10-24 Pavel Safronov43let 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 */
50export function setDialogInDemo(on: boolean): void {
51 inDemo = on
52}
ea380ee8 2026-10-22 Pavel Safronov53
54/** Close with a short shrink-and-fade, then run `then` (a button's action). */
52d68b59 2026-10-24 Pavel Safronov55export function closeDialog(then?: () => void): void {
ea380ee8 2026-10-22 Pavel Safronov56 if (!dialog.open || closing) return
57 if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
58 dialog.close()
92cc4ee3 2026-11-02 Pavel Safronov59 releaseFocus()
ea380ee8 2026-10-22 Pavel Safronov60 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 Safronov67/** Closes the dialog if it can be dismissed: what Esc does, for the controller's Circle. */
68export function dismissDialog(): void {
69 if (!required) closeDialog()
70}
71
ea380ee8 2026-10-22 Pavel Safronov72function 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 Safronov79 releaseFocus()
ea380ee8 2026-10-22 Pavel Safronov80 then?.()
81}
82
92cc4ee3 2026-11-02 Pavel Safronov83/** 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). */
84function releaseFocus(): void {
85 if (document.activeElement instanceof HTMLButtonElement) document.activeElement.blur()
86}
87
52d68b59 2026-10-24 Pavel Safronov88const closeButton = document.getElementById('dialog-close')!
89closeButton.addEventListener('click', () => closeDialog())
a4fab0fb 2026-10-21 Pavel Safronov90// A tap on the dimmed backdrop (the dialog element itself, outside its panel) closes it.
91dialog.addEventListener('click', (e) => {
52d68b59 2026-10-24 Pavel Safronov92 if (e.target === dialog && !required) closeDialog()
ea380ee8 2026-10-22 Pavel Safronov93})
99869630 2026-10-23 Pavel Safronov94// 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.
96dialog.addEventListener('wheel', (e) => e.stopPropagation(), { passive: true })
33a0256b 2026-10-24 Pavel Safronov97// 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.
100for (const type of ['touchstart', 'touchmove', 'touchend', 'touchcancel'] as const) {
101 dialog.addEventListener(type, (e) => e.stopPropagation(), { passive: true })
102}
92cc4ee3 2026-11-02 Pavel Safronov103// 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.
107panel.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 Safronov117// Esc closes it too, animated like the rest.
118dialog.addEventListener('cancel', (e) => {
119 e.preventDefault()
52d68b59 2026-10-24 Pavel Safronov120 if (!required) closeDialog()
a4fab0fb 2026-10-21 Pavel Safronov121})
122
123export function openDialog(content: DialogContent): void {
ea380ee8 2026-10-22 Pavel Safronov124 // Still closing the last one: finish that first (and run its button's action).
125 finishClosing()
a4fab0fb 2026-10-21 Pavel Safronov126 icon.hidden = !content.icon
127 if (content.icon) icon.src = content.icon
52d68b59 2026-10-24 Pavel Safronov128 required = !!content.required
129 closeButton.hidden = required
130 panel.classList.toggle('required', required)
e78b7781 2026-10-25 Pavel Safronov131 panel.className = ['panel', required ? 'required' : '', content.className ?? ''].filter(Boolean).join(' ')
a4fab0fb 2026-10-21 Pavel Safronov132 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 Safronov140 el.addEventListener('click', () => (b.keepOpen ? b.onClick?.() : closeDialog(b.onClick)))
a4fab0fb 2026-10-21 Pavel Safronov141 return el
142 }),
143 )
144 if (dialog.open) dialog.close()
e3384c6b 2026-10-24 Pavel Safronov145 dialog.classList.toggle('in-demo', inDemo)
146 if (inDemo) dialog.show()
147 else dialog.showModal()
a4fab0fb 2026-10-21 Pavel Safronov148 // 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 Safronov150 // 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 Safronov153 // 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 Safronov155}
156
ebcd0a8f 2026-11-02 Pavel Safronov157/** The menu's title: the controller's Start button closes a dialog only if it is this one. */
158export const MENU_TITLE = 'Menu'
159
a4fab0fb 2026-10-21 Pavel Safronov160export function dialogOpen(): boolean {
161 return dialog.open
162}