fuzzyworld

explodorama / files / src / ui / padDialog.ts

padDialog.ts

13.3 KB · history · blame

1import { dismissDialog } from './dialog'
2import { faceGlyph, shoulderGlyph } from './padGlyph'
3import { assignFaces, dismissIndex, faceAction, startIndex, padMode, restoreIndex, scrollDelta, showsTapHint, tabStep, type Face, type FooterButton } from './padDialogMap'
4import { cancelFace, confirmFace, type PadFamily } from './padMap'
5import { nextFocus, type Step } from './spatial'
6import { spriteUrl } from '../render/sprites'
7import { showToast } from './toast'
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 */
17const dialog = document.getElementById('dialog') as HTMLDialogElement
18const panel = dialog.querySelector<HTMLElement>('.panel')!
19const body = document.getElementById('dialog-body')!
20const footer = document.getElementById('dialog-buttons')!
21const closeButton = document.getElementById('dialog-close')!
22const banner = document.getElementById('banner')!
23
24let family: PadFamily = 'generic'
25let sel: HTMLElement | null = null
26let selIndex = 0
27let faces: (Face | null)[] | null = null
28let mode: 'faces' | 'selector' = 'faces'
29/** The footer button Circle presses (-1: none, the dialog just closes). */
30let dismiss = -1
31/** The confirm picture on the selected item in selector mode (it carries the hold ring), or null. */
32let selGlyph: HTMLImageElement | null = null
33/** The "hold to choose" toast was shown for this dialog already. */
34let tapHinted = false
35/** `refresh` is running (it has the observer off already). */
36let refreshing = false
37
38const SELECTABLE = 'button, [role="button"], a[href], input[type="checkbox"], input[type="radio"]'
39
40const isCard = (el: HTMLElement): boolean => el.classList.contains('offer-choice')
41
42function 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. */
53function 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
66function footerButtons(): HTMLButtonElement[] {
67 return [...footer.querySelectorAll<HTMLButtonElement>('button')]
68}
69
70/** What the selector moves over now. */
71function list(): HTMLElement[] {
72 return mode === 'faces' ? bodyItems() : [...bodyItems(), ...footerButtons().filter((b) => !b.disabled)]
73}
74
75function 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. */
81function 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
92/** Runs `fn` (which changes the dialog's DOM) without the change counting as a rebuild. */
93function 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
107function setSel(el: HTMLElement | null, items: HTMLElement[], scroll = false): void {
108 sel = el
109 selIndex = el ? Math.max(0, items.indexOf(el)) : selIndex
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 })
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). */
127function refresh(fresh = false): void {
128 observer.disconnect()
129 refreshing = true
130 if (fresh) tapHinted = false
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
139 faces = assignFaces(info, dismissable, hasBuy, family)
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()
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 }
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))
164 else if (mode === 'selector' && i === dismiss) b.append(glyph(cancelFace(family)))
165 })
166 for (const buy of body.querySelectorAll('.shop-item .buy')) buy.append(glyph(confirmFace(family)))
167 // The X has the Circle picture when no footer button has it.
168 if (dismissable && dismiss < 0) closeButton.append(glyph(cancelFace(family)))
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 {
180 refreshing = false
181 observe()
182 observer.takeRecords()
183 }
184}
185
186const observer = new MutationObserver(() => refresh())
187function observe(): void {
188 observer.observe(body, { childList: true, subtree: true })
189 observer.observe(footer, { childList: true, subtree: true, characterData: true })
190}
191observe()
192// A card chosen with the mouse or keyboard moves the outline to it, so the pad and the click agree.
193body.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})
197dialog.addEventListener('dialogopen', () => refresh(true))
198
199/** The banner's visible buttons, in order: Cross, then Triangle, then Square (nothing dismisses it). */
200function 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
206function bannerFaces(buttons: HTMLButtonElement[]): (Face | null)[] {
207 return assignFaces(buttons.map((b, i) => ({ label: b.textContent?.trim() ?? '', kind: i === 0 ? 'primary' : 'plain' })), false, false, family) ?? []
208}
209
210let bannerBusy = false
211function 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}
226const bannerObserver = new MutationObserver(refreshBanner)
227bannerObserver.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). */
230const closing = (): boolean => dialog.classList.contains('closing')
231
232export const padDialog = {
233 /** The pictures follow the kind of pad. */
234 setFamily(next: PadFamily): void {
235 if (next === family) return
236 family = next
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()
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"]')
261 const action = faceAction(face, faces, mode, dismissable, !!pressed?.querySelector('.buy'), dismiss, family)
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 },
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 },
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}