explodorama / files / src / ui / padDialog.ts
padDialog.ts
ebcd0a8f 2026-11-02 Pavel Safronov | 1 | import { dismissDialog } from './dialog' |
92cc4ee3 2026-11-02 Pavel Safronov | 2 | import { faceGlyph, shoulderGlyph } from './padGlyph' |
| 3 | import { assignFaces, dismissIndex, faceAction, startIndex, padMode, restoreIndex, scrollDelta, showsTapHint, tabStep, type Face, type FooterButton } from './padDialogMap' | |
| 4 | import { cancelFace, confirmFace, type PadFamily } from './padMap' | |
ebcd0a8f 2026-11-02 Pavel Safronov | 5 | import { nextFocus, type Step } from './spatial' |
| 6 | import { spriteUrl } from '../render/sprites' | |
92cc4ee3 2026-11-02 Pavel Safronov | 7 | import { showToast } from './toast' |
ebcd0a8f 2026-11-02 Pavel Safronov | 8 | |
| 9 | /** | |
| 10 | * The controller in dialogs and the banner (controller support, stage 3): DOM glue around the | |
| 11 | * rules in padDialogMap.ts (those are tested; this reads and clicks the page, which node tests | |
| 12 | * cannot). The footer's buttons get face buttons and their pictures; where that does not fit, a | |
| 13 | * selector (outline, only under body.pad) moves over everything with the d-pad. It watches the | |
| 14 | * dialog, so a body rebuilt in place (the shop after a purchase, an almanac tab) is picked up | |
| 15 | * again with the selector kept at the same place. | |
| 16 | */ | |
| 17 | const dialog = document.getElementById('dialog') as HTMLDialogElement | |
| 18 | const panel = dialog.querySelector<HTMLElement>('.panel')! | |
| 19 | const body = document.getElementById('dialog-body')! | |
| 20 | const footer = document.getElementById('dialog-buttons')! | |
| 21 | const closeButton = document.getElementById('dialog-close')! | |
| 22 | const banner = document.getElementById('banner')! | |
| 23 | ||
| 24 | let family: PadFamily = 'generic' | |
| 25 | let sel: HTMLElement | null = null | |
| 26 | let selIndex = 0 | |
| 27 | let faces: (Face | null)[] | null = null | |
| 28 | let mode: 'faces' | 'selector' = 'faces' | |
| 29 | /** The footer button Circle presses (-1: none, the dialog just closes). */ | |
| 30 | let dismiss = -1 | |
92cc4ee3 2026-11-02 Pavel Safronov | 31 | /** The confirm picture on the selected item in selector mode (it carries the hold ring), or null. */ |
| 32 | let selGlyph: HTMLImageElement | null = null | |
| 33 | /** The "hold to choose" toast was shown for this dialog already. */ | |
| 34 | let tapHinted = false | |
| 35 | /** `refresh` is running (it has the observer off already). */ | |
| 36 | let refreshing = false | |
ebcd0a8f 2026-11-02 Pavel Safronov | 37 | |
720cc9d3 2026-11-03 Pavel Safronov | 38 | const SELECTABLE = 'button, [role="button"], a[href], input[type="checkbox"], input[type="radio"]' |
ebcd0a8f 2026-11-02 Pavel Safronov | 39 | |
| 40 | const isCard = (el: HTMLElement): boolean => el.classList.contains('offer-choice') | |
| 41 | ||
| 42 | function glyph(face: Face): HTMLImageElement { | |
| 43 | const img = document.createElement('img') | |
| 44 | img.className = 'pad-glyph' | |
| 45 | img.alt = '' | |
| 46 | img.draggable = false | |
| 47 | img.dataset.face = String(face) | |
| 48 | img.src = spriteUrl(faceGlyph(family, face)) | |
| 49 | return img | |
| 50 | } | |
| 51 | ||
| 52 | /** What the selector can be on in the body: cards, buttons, links and switches (a checkbox by its label). A text field is skipped on purpose. */ | |
| 53 | function bodyItems(): HTMLElement[] { | |
| 54 | const out: HTMLElement[] = [] | |
| 55 | for (const raw of body.querySelectorAll<HTMLElement>(SELECTABLE)) { | |
| 56 | if ((raw as HTMLButtonElement).disabled || raw.closest('[hidden]') || raw.classList.contains('sold')) continue | |
| 57 | // Inside a card (the shop's Buy): Cross reaches it through the card. | |
| 58 | const card = raw.closest<HTMLElement>('.offer-choice') | |
| 59 | if (card && card !== raw) continue | |
| 60 | const el = raw instanceof HTMLInputElement ? (raw.closest<HTMLElement>('label') ?? raw) : raw | |
| 61 | if (!out.includes(el)) out.push(el) | |
| 62 | } | |
| 63 | return out | |
| 64 | } | |
| 65 | ||
| 66 | function footerButtons(): HTMLButtonElement[] { | |
| 67 | return [...footer.querySelectorAll<HTMLButtonElement>('button')] | |
| 68 | } | |
| 69 | ||
| 70 | /** What the selector moves over now. */ | |
| 71 | function list(): HTMLElement[] { | |
| 72 | return mode === 'faces' ? bodyItems() : [...bodyItems(), ...footerButtons().filter((b) => !b.disabled)] | |
| 73 | } | |
| 74 | ||
| 75 | function activate(el: HTMLElement): void { | |
| 76 | const input = el instanceof HTMLLabelElement ? el.querySelector('input') : null | |
| 77 | ;(input ?? el).click() | |
| 78 | } | |
| 79 | ||
| 80 | /** Scrolls the body just enough to show `el`, clear of the sticky tabs, clip or coin line at its top. */ | |
| 81 | function reveal(el: HTMLElement): void { | |
| 82 | if (el.closest('.almanac-tabs, .offer-clip, .shop-coins')) return | |
| 83 | let sticky = 0 | |
| 84 | for (const c of body.children) if (getComputedStyle(c).position === 'sticky') sticky += (c as HTMLElement).offsetHeight | |
| 85 | const box = body.getBoundingClientRect() | |
| 86 | const r = el.getBoundingClientRect() | |
| 87 | const top = box.top + sticky + 4 | |
| 88 | if (r.top < top) body.scrollBy(0, r.top - top) | |
| 89 | else if (r.bottom > box.bottom - 4) body.scrollBy(0, r.bottom - box.bottom + 4) | |
| 90 | } | |
| 91 | ||
92cc4ee3 2026-11-02 Pavel Safronov | 92 | /** Runs `fn` (which changes the dialog's DOM) without the change counting as a rebuild. */ |
| 93 | function quietly(fn: () => void): void { | |
| 94 | if (refreshing) return fn() | |
| 95 | // A rebuild that happened but was not seen yet (a tab's click) must still be read afterwards. | |
| 96 | const pending = observer.takeRecords().length > 0 | |
| 97 | observer.disconnect() | |
| 98 | try { | |
| 99 | fn() | |
| 100 | } finally { | |
| 101 | observe() | |
| 102 | observer.takeRecords() | |
| 103 | } | |
| 104 | if (pending) refresh() | |
| 105 | } | |
| 106 | ||
ebcd0a8f 2026-11-02 Pavel Safronov | 107 | function setSel(el: HTMLElement | null, items: HTMLElement[], scroll = false): void { |
| 108 | sel = el | |
| 109 | selIndex = el ? Math.max(0, items.indexOf(el)) : selIndex | |
92cc4ee3 2026-11-02 Pavel Safronov | 110 | quietly(() => { |
| 111 | for (const old of dialog.querySelectorAll('.pad-sel')) old.classList.remove('pad-sel') | |
| 112 | selGlyph?.remove() | |
| 113 | selGlyph = null | |
| 114 | el?.classList.add('pad-sel') | |
| 115 | // In selector mode the selected item shows the confirm picture (so the hold ring has a picture to go round), unless it has a picture already (the dismissing button's, a shop item's Buy). | |
| 116 | if (el && mode === 'selector' && !el.querySelector('.pad-glyph[data-face]')) { | |
| 117 | selGlyph = glyph(confirmFace(family)) | |
| 118 | selGlyph.classList.add('sel-glyph') | |
| 119 | el.append(selGlyph) | |
| 120 | } | |
| 121 | }) | |
ebcd0a8f 2026-11-02 Pavel Safronov | 122 | // Only when the player moved it: a rebuilt dialog may be about to scroll itself back (Back from an entry). |
| 123 | if (scroll && el) reveal(el) | |
| 124 | } | |
| 125 | ||
| 126 | /** Reads the dialog afresh: the face buttons and their pictures, and the selector kept (or put on the first element). */ | |
| 127 | function refresh(fresh = false): void { | |
| 128 | observer.disconnect() | |
92cc4ee3 2026-11-02 Pavel Safronov | 129 | refreshing = true |
| 130 | if (fresh) tapHinted = false | |
ebcd0a8f 2026-11-02 Pavel Safronov | 131 | try { |
| 132 | const buttons = footerButtons() | |
| 133 | const info: FooterButton[] = buttons.map((b) => ({ | |
| 134 | label: b.textContent?.trim() ?? '', | |
| 135 | kind: b.classList.contains('primary') ? 'primary' : b.classList.contains('danger') ? 'danger' : 'plain', | |
| 136 | })) | |
| 137 | const dismissable = !panel.classList.contains('required') | |
| 138 | const hasBuy = body.querySelector('.shop-item .buy') !== null | |
92cc4ee3 2026-11-02 Pavel Safronov | 139 | faces = assignFaces(info, dismissable, hasBuy, family) |
ebcd0a8f 2026-11-02 Pavel Safronov | 140 | dismiss = dismissIndex(info, dismissable) |
| 141 | const plainControls = bodyItems().filter((el) => !isCard(el)).length | |
| 142 | mode = padMode(faces, plainControls) | |
| 143 | for (const img of dialog.querySelectorAll('.pad-glyph')) img.remove() | |
92cc4ee3 2026-11-02 Pavel Safronov | 144 | selGlyph = null |
| 145 | // The almanac's tabs: LB goes to the tab before the shown one, RB to the one after. | |
| 146 | const tabs = [...body.querySelectorAll<HTMLElement>('[data-tab]')] | |
| 147 | const shown = tabs.findIndex((t) => t.getAttribute('aria-pressed') === 'true') | |
| 148 | if (shown >= 0) { | |
| 149 | for (const [at, side] of [[shown - 1, 'l'], [shown + 1, 'r']] as const) { | |
| 150 | const tab = tabs[at] | |
| 151 | if (!tab) continue | |
| 152 | const img = glyph(0) | |
| 153 | delete img.dataset.face | |
| 154 | img.classList.add('tab-glyph') | |
| 155 | img.src = spriteUrl(shoulderGlyph(family, side)) | |
| 156 | tab.append(img) | |
| 157 | } | |
| 158 | } | |
ebcd0a8f 2026-11-02 Pavel Safronov | 159 | // Pictures: in faces mode on every footer button; in selector mode only on the one Circle presses. |
| 160 | // A shop item's Buy shows Cross (CSS shows it on the selected card only). | |
| 161 | buttons.forEach((b, i) => { | |
| 162 | const face = faces?.[i] | |
| 163 | if (mode === 'faces' && face != null) b.append(glyph(face)) | |
92cc4ee3 2026-11-02 Pavel Safronov | 164 | else if (mode === 'selector' && i === dismiss) b.append(glyph(cancelFace(family))) |
ebcd0a8f 2026-11-02 Pavel Safronov | 165 | }) |
92cc4ee3 2026-11-02 Pavel Safronov | 166 | for (const buy of body.querySelectorAll('.shop-item .buy')) buy.append(glyph(confirmFace(family))) |
ebcd0a8f 2026-11-02 Pavel Safronov | 167 | // The X has the Circle picture when no footer button has it. |
92cc4ee3 2026-11-02 Pavel Safronov | 168 | if (dismissable && dismiss < 0) closeButton.append(glyph(cancelFace(family))) |
ebcd0a8f 2026-11-02 Pavel Safronov | 169 | const items = list() |
| 170 | let el: HTMLElement | null | |
| 171 | if (!fresh && sel?.isConnected && items.includes(sel)) el = sel | |
| 172 | else if (fresh) { | |
| 173 | const primary = mode === 'selector' ? buttons.find((b) => b.classList.contains('primary') && !b.disabled) : undefined | |
| 174 | el = items[startIndex(items.map((x) => ({ primary: x === primary, pressed: x.getAttribute('aria-pressed') === 'true' })))] ?? null | |
| 175 | } | |
| 176 | // Rebuilt in place: the card the dialog has selected (the shop moves on when one sells), else the same place. | |
| 177 | else el = items.find((x) => isCard(x) && x.getAttribute('aria-pressed') === 'true') ?? items[restoreIndex(items.length, selIndex)] ?? null | |
| 178 | setSel(el, items) | |
| 179 | } finally { | |
92cc4ee3 2026-11-02 Pavel Safronov | 180 | refreshing = false |
ebcd0a8f 2026-11-02 Pavel Safronov | 181 | observe() |
| 182 | observer.takeRecords() | |
| 183 | } | |
| 184 | } | |
| 185 | ||
| 186 | const observer = new MutationObserver(() => refresh()) | |
| 187 | function observe(): void { | |
| 188 | observer.observe(body, { childList: true, subtree: true }) | |
| 189 | observer.observe(footer, { childList: true, subtree: true, characterData: true }) | |
| 190 | } | |
| 191 | observe() | |
| 192 | // A card chosen with the mouse or keyboard moves the outline to it, so the pad and the click agree. | |
| 193 | body.addEventListener('click', (e) => { | |
| 194 | const card = (e.target as Element).closest<HTMLElement>('.offer-choice') | |
| 195 | if (card && card !== sel && !card.classList.contains('sold')) setSel(card, list()) | |
| 196 | }) | |
| 197 | dialog.addEventListener('dialogopen', () => refresh(true)) | |
| 198 | ||
| 199 | /** The banner's visible buttons, in order: Cross, then Triangle, then Square (nothing dismisses it). */ | |
| 200 | function bannerButtons(): HTMLButtonElement[] { | |
| 201 | return ['banner-button', 'banner-button-2', 'banner-scores'] | |
| 202 | .map((id) => document.getElementById(id) as HTMLButtonElement) | |
| 203 | .filter((b) => !b.hidden) | |
| 204 | } | |
| 205 | ||
| 206 | function bannerFaces(buttons: HTMLButtonElement[]): (Face | null)[] { | |
92cc4ee3 2026-11-02 Pavel Safronov | 207 | return assignFaces(buttons.map((b, i) => ({ label: b.textContent?.trim() ?? '', kind: i === 0 ? 'primary' : 'plain' })), false, false, family) ?? [] |
ebcd0a8f 2026-11-02 Pavel Safronov | 208 | } |
| 209 | ||
| 210 | let bannerBusy = false | |
| 211 | function refreshBanner(): void { | |
| 212 | if (bannerBusy || banner.hidden) return | |
| 213 | bannerBusy = true | |
| 214 | bannerObserver.disconnect() | |
| 215 | try { | |
| 216 | for (const img of banner.querySelectorAll('.pad-glyph')) img.remove() | |
| 217 | const buttons = bannerButtons() | |
| 218 | const f = bannerFaces(buttons) | |
| 219 | buttons.forEach((b, i) => (f[i] != null ? b.append(glyph(f[i]!)) : undefined)) | |
| 220 | } finally { | |
| 221 | bannerObserver.observe(banner, { childList: true, subtree: true, attributes: true, attributeFilter: ['hidden'] }) | |
| 222 | bannerObserver.takeRecords() | |
| 223 | bannerBusy = false | |
| 224 | } | |
| 225 | } | |
| 226 | const bannerObserver = new MutationObserver(refreshBanner) | |
| 227 | bannerObserver.observe(banner, { childList: true, subtree: true, attributes: true, attributeFilter: ['hidden'] }) | |
| 228 | ||
| 229 | /** True while a dialog is closing (it ignores the pad until it has gone). */ | |
| 230 | const closing = (): boolean => dialog.classList.contains('closing') | |
| 231 | ||
| 232 | export const padDialog = { | |
| 233 | /** The pictures follow the kind of pad. */ | |
| 234 | setFamily(next: PadFamily): void { | |
92cc4ee3 2026-11-02 Pavel Safronov | 235 | if (next === family) return |
ebcd0a8f 2026-11-02 Pavel Safronov | 236 | family = next |
92cc4ee3 2026-11-02 Pavel Safronov | 237 | // The face buttons themselves depend on the kind of pad (a Nintendo pad swaps confirm and back), so what is on show is assigned again, not just redrawn. |
| 238 | if (dialog.open) refresh() | |
| 239 | refreshBanner() | |
ebcd0a8f 2026-11-02 Pavel Safronov | 240 | }, |
| 241 | /** The d-pad: the selector moves; on a card, that selects it (as a click does). */ | |
| 242 | move(dx: Step, dy: Step): void { | |
| 243 | if (closing()) return | |
| 244 | const items = list() | |
| 245 | const from = sel ? items.indexOf(sel) : -1 | |
| 246 | const to = nextFocus(items.map((el) => { | |
| 247 | const r = el.getBoundingClientRect() | |
| 248 | return { x: r.x, y: r.y, w: r.width, h: r.height } | |
| 249 | }), from, dx, dy) | |
| 250 | const el = items[to] | |
| 251 | if (!el || el === sel) return | |
| 252 | setSel(el, items, true) | |
| 253 | if (isCard(el)) el.click() | |
| 254 | }, | |
| 255 | /** A face button in a dialog. */ | |
| 256 | face(face: Face): void { | |
| 257 | if (closing()) return | |
| 258 | const dismissable = !panel.classList.contains('required') | |
| 259 | // The shop's selected card is the one marked pressed, however it got selected (pad, mouse or key). | |
| 260 | const pressed = body.querySelector<HTMLElement>('.shop-item[aria-pressed="true"]') | |
92cc4ee3 2026-11-02 Pavel Safronov | 261 | const action = faceAction(face, faces, mode, dismissable, !!pressed?.querySelector('.buy'), dismiss, family) |
ebcd0a8f 2026-11-02 Pavel Safronov | 262 | if (!action) return |
| 263 | if (action.kind === 'close') dismissDialog() | |
| 264 | else if (action.kind === 'footer') footerButtons()[action.index]?.click() | |
| 265 | else if (action.kind === 'buy') pressed?.querySelector<HTMLElement>('.buy')?.click() | |
| 266 | else if (sel?.isConnected) activate(sel) | |
| 267 | }, | |
92cc4ee3 2026-11-02 Pavel Safronov | 268 | /** A face button was let go before its ring filled. Confirm on a selected item says what to do instead (once per dialog), so a tap is never silent. */ |
| 269 | tap(face: Face): void { | |
| 270 | if (closing() || !showsTapHint(face, mode, !!sel?.isConnected, tapHinted, family)) return | |
| 271 | tapHinted = true | |
| 272 | showToast(spriteUrl(faceGlyph(family, face)), 'Hold to choose', 'Keep the button down until the ring is full') | |
| 273 | }, | |
ebcd0a8f 2026-11-02 Pavel Safronov | 274 | /** LB / RB (delta -1 / 1): the previous or next tab, where the dialog has tabs. */ |
| 275 | tab(delta: -1 | 1): void { | |
| 276 | if (closing()) return | |
| 277 | const tabs = [...body.querySelectorAll<HTMLElement>('[data-tab]')] | |
| 278 | if (tabs.length === 0) return | |
| 279 | const now = tabs.findIndex((t) => t.getAttribute('aria-pressed') === 'true') | |
| 280 | const next = tabStep(tabs.length, now, delta) | |
| 281 | if (next === now) return | |
| 282 | tabs[next]!.click() | |
| 283 | // The tabs were rebuilt by the click: the selector goes to the new tab's button. | |
| 284 | setSel(body.querySelector<HTMLElement>(`[data-tab="${tabs[next]!.dataset.tab}"]`), list(), true) | |
| 285 | }, | |
| 286 | /** The right stick scrolls the body. */ | |
| 287 | scroll(dy: number, seconds: number): void { | |
| 288 | if (!closing()) body.scrollBy(0, scrollDelta(dy, seconds)) | |
| 289 | }, | |
| 290 | /** A face button while the banner shows: true if it pressed one of the banner's buttons. */ | |
| 291 | banner(face: Face): boolean { | |
| 292 | if (banner.hidden) return false | |
| 293 | const buttons = bannerButtons() | |
| 294 | const index = bannerFaces(buttons).indexOf(face) | |
| 295 | if (index < 0) return false | |
| 296 | buttons[index]!.click() | |
| 297 | return true | |
| 298 | }, | |
| 299 | } |