@tastic/hud 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,387 @@
1
+ import { getBlendedColor, getColorRoles } from '@rific/auto-paper'
2
+ import { IconButton, TouchableRipple } from '@rific/feedback-press'
3
+ import { ScrollView, StyleSheet, View } from 'react-native'
4
+ import { Icon, Text } from 'react-native-paper'
5
+
6
+ import { MONO_FONT } from './fonts'
7
+ import { PopoverBody } from './PopoverBody'
8
+ import TriggerGaugeHost from './TriggerGaugeHost'
9
+ import { useAutoAlign } from './useAutoAlign'
10
+ import { PopoverHost } from './usePopoverHost'
11
+
12
+ const MENU_BORDER_WIDTH = 1
13
+ // Matches triggerBox's own height/width below — passed to PopoverBody so its caret can point at
14
+ // this trigger's actual center, not the (usually wider) menu's midpoint.
15
+ const TRIGGER_SIZE = 44
16
+ const MENU_MIN_WIDTH = 200
17
+ // Fed into useAutoAlign as the popover's assumed width — a real render can come in narrower
18
+ // (shrink-to-fit content), but never wider, since `menu` below caps at this same value. Alignment
19
+ // math staying in sync with the actual rendered cap is what keeps it from ever being wrong in a way
20
+ // that lets the popover clip off-screen.
21
+ const MENU_MAX_WIDTH = 220
22
+ const MENU_ITEM_ICON_SIZE = 18
23
+ // Tuned by eye: wide enough that a short one-word-ish label stays on one line, narrow enough that a
24
+ // sentence-length description reliably wraps onto a second line instead.
25
+ const MENU_ITEM_LABEL_MAX_WIDTH = 150
26
+ // Estimated per-row heights fed into useAutoAlign's vertical flip — approximate (item's own
27
+ // padding + a labelLarge line, give or take font metrics), not measured, for the same reason
28
+ // MENU_MAX_WIDTH is a fixed cap rather than a measured value: knowing it before the popover renders
29
+ // is what lets the flip decision happen in the same frame as opening, instead of a measure-then-
30
+ // reposition flicker.
31
+ const MENU_ITEM_HEIGHT = 40
32
+ const MENU_ITEM_HEIGHT_WITH_DESCRIPTION = 58
33
+ const MENU_DIVIDER_HEIGHT = 9
34
+ const MENU_VERTICAL_PADDING = 12
35
+
36
+ export interface MenuOption<T extends string | number> {
37
+ value: T
38
+ label: string
39
+ description?: string
40
+ // Optional per-option icon — when every option in a single-select section has one, the trigger
41
+ // shows the currently selected option's icon instead of the static fallback, so the value is
42
+ // readable at a glance without opening the dropdown. Unused by multi-select sections (there's no
43
+ // single "the" selected option to show on the trigger).
44
+ icon?: string
45
+ iconSize?: number
46
+ }
47
+
48
+ // Exactly one of these can be true at a time — radio-style. Tapping a row selects it; whether that
49
+ // also closes the popover is controlled by the dropdown's own `autoDismiss` prop.
50
+ export interface SingleSelectSection<T extends string | number> {
51
+ kind: 'single'
52
+ id: string
53
+ options: MenuOption<T>[]
54
+ value: T
55
+ onChange: (value: T) => void
56
+ }
57
+
58
+ // Any number of these can be true at once — checkbox-style. Tapping a row toggles just that one and
59
+ // never closes the popover on its own (unlike a single-select row, picking one of several things
60
+ // isn't "done" the way picking the one value in a single-select is).
61
+ export interface MultiSelectSection<T extends string | number> {
62
+ kind: 'multi'
63
+ id: string
64
+ options: MenuOption<T>[]
65
+ value: T[]
66
+ onChange: (value: T[]) => void
67
+ // Renders an alternating All/Clear bulk-toggle button beneath this section's rows — label reflects
68
+ // what tapping it would do next (every option already selected -> "Clear"; anything else -> "All").
69
+ allClear?: boolean
70
+ }
71
+
72
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
73
+ export type MenuSection = SingleSelectSection<any> | MultiSelectSection<any>
74
+
75
+ interface Props {
76
+ id: string
77
+ host: PopoverHost
78
+ // Static fallback trigger icon — used whenever there isn't a single unambiguous "selected option"
79
+ // to show instead (see the trigger-icon logic below).
80
+ icon: string
81
+ accessibilityLabel: string
82
+ sections: MenuSection[]
83
+ accentColor: string
84
+ mutedColor: string
85
+ // Foreground for a selected row's icon/text, painted on top of accentColor's own fill — accentColor
86
+ // isn't guaranteed to be a fixed brand tone (a consumer may derive it from a user-chosen color), so
87
+ // a hardcoded black or a guessed contrast color both risk landing unreadable against it. Omit to
88
+ // derive one automatically (getColorRoles' onColor) from accentColor against this popover's own
89
+ // background — pass a real design-system "on" color explicitly instead when you have one.
90
+ onAccentColor?: string
91
+ dark: boolean
92
+ // Manual override — omit to let the popover measure its own trigger and pick whichever alignment
93
+ // keeps it from overflowing the screen edge (see useAutoAlign). Only pass this to force a specific
94
+ // side regardless of where the trigger actually sits.
95
+ align?: 'left' | 'right' | 'center'
96
+ // Whether picking a value in a *single-select* section closes the popover. Defaults to true
97
+ // (the old, pre-press-away default: pick one thing, you're done). Multi-select rows never
98
+ // auto-close regardless of this — see MultiSelectSection's own comment. Worth setting false for a
99
+ // dropdown with multiple sections, where picking one value while another's still being decided
100
+ // shouldn't snap the whole thing shut; press-away (or the trigger itself) closes it instead.
101
+ autoDismiss?: boolean
102
+ // Defaults to the system monospace font — pass your own game's registered font to match its UI.
103
+ labelFontFamily?: string
104
+ allClearLabels?: { all: string; clear: string }
105
+ }
106
+
107
+ // Single popover shell that can hold any mix of single-select ("pick one") and multi-select ("pick
108
+ // some") sections, divided by rules. A plain single-value picker (grid size, speed, CPU difficulty,
109
+ // control scheme, ...) is just this with one 'single' section; a multi-select toggle list (which
110
+ // powerups are enabled, say) is just this with one 'multi' section (optionally with allClear); a
111
+ // combined menu (e.g. spawn frequency + which types, in one menu) is just two sections in the same
112
+ // array.
113
+ export function SectionedDropdown({ id, host, icon, accessibilityLabel, sections, accentColor, mutedColor, onAccentColor, dark, align: alignOverride, autoDismiss = true, labelFontFamily = MONO_FONT, allClearLabels = { all: 'All', clear: 'Clear' } }: Props) {
114
+ const menuBg = dark ? '#000000' : '#FFFFFF'
115
+ // mutedColor is translucent (fine for the icon/text tints below, each a single paint), but the
116
+ // caret is deliberately drawn overlapping the box's own top border (PopoverBody's CARET_DIP,
117
+ // needed to avoid a subpixel seam between the two separately-drawn edges) — with a translucent
118
+ // color that overlap double-blends into a visibly lighter patch, unlike every other (opaque-
119
+ // bordered) popover in the app. Flattened once against this menu's own solid background so the
120
+ // box border and caret ring paint identically instead of compounding.
121
+ const menuBorderColor = getBlendedColor(mutedColor, menuBg, 0.5)
122
+ const resolvedOnAccentColor = onAccentColor ?? getColorRoles(accentColor, menuBg).onColor
123
+ // A bold, always-legible foreground — the trigger icon's job is category identity ("this is grid
124
+ // size"), not state, so it stays as bold as any other icon on screen (a back arrow, a settings
125
+ // gear) instead of dimmed. State lives entirely in TriggerGaugeHost's ring around it.
126
+ const fg = dark ? '#FFFFFF' : '#000000'
127
+
128
+ const open = host.openId === id
129
+ // Estimated, not measured — see MENU_ITEM_HEIGHT's own comment. Every row, plus a divider between
130
+ // sections, plus a footer row for whichever multi-select sections show one.
131
+ const estimatedHeight =
132
+ MENU_VERTICAL_PADDING +
133
+ sections.reduce((sum, section, sectionIndex) => {
134
+ const rows = section.options.reduce((rowSum, option) => rowSum + (option.description ? MENU_ITEM_HEIGHT_WITH_DESCRIPTION : MENU_ITEM_HEIGHT), 0)
135
+ const footer = section.kind === 'multi' && section.allClear ? MENU_ITEM_HEIGHT : 0
136
+ const divider = sectionIndex > 0 ? MENU_DIVIDER_HEIGHT : 0
137
+ return sum + rows + footer + divider
138
+ }, 0)
139
+ const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, MENU_MAX_WIDTH, estimatedHeight)
140
+ const align = alignOverride ?? autoAlign
141
+
142
+ // Trigger reflects the selected option's own icon only when there's exactly one section and it's
143
+ // single-select — any other shape (multi-select present, or more than one section) has no single
144
+ // unambiguous "the" value to show, so it falls back to the static icon instead.
145
+ const onlySection = sections.length === 1 ? sections[0] : null
146
+ const selectedOption = onlySection?.kind === 'single' ? onlySection.options.find((o) => o.value === onlySection.value) : undefined
147
+ const triggerIcon = selectedOption?.icon ?? icon
148
+ const triggerIconSize = selectedOption?.iconSize ?? 22
149
+ const hasMultiSection = sections.some((s) => s.kind === 'multi')
150
+ const anyMultiSelected = sections.some((s) => s.kind === 'multi' && s.value.length > 0)
151
+ // A single-select trigger always has exactly one active value, so there's no "off" state for its
152
+ // icon to distinguish — plain fg, unconditionally. A multi-select one (powerups, say) genuinely
153
+ // can be fully off (nothing enabled), which is different information than *which* ones are on
154
+ // (the gauge ring's job) — worth a real on/off read on the icon itself, especially once a section
155
+ // like this gains a combined off/few/many dimension alongside its checkboxes.
156
+ const triggerIconColor = hasMultiSection ? (anyMultiSelected ? accentColor : fg) : fg
157
+
158
+ // Unlike the trigger-icon override above (which only makes sense for a single unambiguous "the"
159
+ // value), the gauge ring has no such restriction — it just needs a position per option and which
160
+ // ones are lit, so it flattens every section's options into one combined ring instead of only
161
+ // rendering when there's exactly one section. A two-section dropdown (say, board variant + a
162
+ // rink toggle riding along as its own single-item multi-select) reads as one ring where the first
163
+ // N dashes are the single-select's tiers and the last dash is the toggle, each section's own
164
+ // selection state lighting independently within its own slice.
165
+ const gaugeSegments = sections.reduce((sum, section) => sum + section.options.length, 0)
166
+ const gaugeLitIndices: number[] = []
167
+ let gaugeOffset = 0
168
+ for (const section of sections) {
169
+ if (section.kind === 'single') {
170
+ const index = section.options.findIndex((o) => o.value === section.value)
171
+ if (index >= 0) gaugeLitIndices.push(gaugeOffset + index)
172
+ } else {
173
+ section.options.forEach((o, i) => {
174
+ if (section.value.includes(o.value)) gaugeLitIndices.push(gaugeOffset + i)
175
+ })
176
+ }
177
+ gaugeOffset += section.options.length
178
+ }
179
+
180
+ return (
181
+ <View style={[styles.anchor, open && styles.anchorOpen]}>
182
+ <View ref={triggerRef} collapsable={false} style={styles.triggerBox}>
183
+ <TriggerGaugeHost segments={gaugeSegments} litIndices={gaugeLitIndices} size={TRIGGER_SIZE} accentColor={accentColor} mutedColor={mutedColor} />
184
+ <IconButton icon={triggerIcon} iconColor={triggerIconColor} size={triggerIconSize} accessibilityLabel={accessibilityLabel} onPress={() => host.toggle(id)} />
185
+ </View>
186
+
187
+ {/* Gated on `measured`, not just `open` — see useAutoAlign's own comment. Without it, the
188
+ popover mounts for one frame at whatever align/verticalAlign the *previous* open (or the
189
+ initial center/below guess) left behind, then visibly jumps once this open's own measurement
190
+ lands — most noticeable near a screen edge, where the guess is furthest from correct. */}
191
+ <PopoverBody visible={open && measured} align={align} verticalAlign={verticalAlign} caretColor={menuBg} caretBorderColor={menuBorderColor} caretBorderWidth={MENU_BORDER_WIDTH} triggerSize={TRIGGER_SIZE}>
192
+ {/* maxHeight is the room useAutoAlign actually found in whichever direction it picked — on
193
+ a screen tall enough for the estimated content, this never kicks in (estimatedHeight itself
194
+ is always <= maxHeight then, so nothing scrolls); on a short landscape screen where neither
195
+ direction has enough room, this is what keeps the menu from just running off the screen
196
+ edge instead of flipping only made it run off a *smaller* amount. */}
197
+ <ScrollView style={[styles.menu, { backgroundColor: menuBg, borderColor: menuBorderColor, maxHeight }]} contentContainerStyle={styles.menuContent} showsVerticalScrollIndicator={false}>
198
+ {sections.map((section, sectionIndex) => (
199
+ <View key={section.id}>
200
+ {/* Every section after the first gets a rule above it — the divider belongs to the
201
+ boundary between sections, not to either section itself, so it's keyed off position
202
+ rather than each section carrying its own "divider below me" flag. */}
203
+ {sectionIndex > 0 && <View style={[styles.divider, { backgroundColor: menuBorderColor }]} />}
204
+
205
+ {section.kind === 'single' ? (
206
+ <SingleSection
207
+ section={section}
208
+ accentColor={accentColor}
209
+ onAccentColor={resolvedOnAccentColor}
210
+ mutedColor={mutedColor}
211
+ labelFontFamily={labelFontFamily}
212
+ onSelect={(value) => {
213
+ section.onChange(value)
214
+ if (autoDismiss) host.close()
215
+ }}
216
+ />
217
+ ) : (
218
+ <MultiSection section={section} accentColor={accentColor} onAccentColor={resolvedOnAccentColor} mutedColor={mutedColor} labelFontFamily={labelFontFamily} allClearLabels={allClearLabels} />
219
+ )}
220
+ </View>
221
+ ))}
222
+ </ScrollView>
223
+ </PopoverBody>
224
+ </View>
225
+ )
226
+ }
227
+
228
+ function SingleSection({
229
+ section,
230
+ accentColor,
231
+ onAccentColor,
232
+ mutedColor,
233
+ labelFontFamily,
234
+ onSelect
235
+ }: {
236
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
237
+ section: SingleSelectSection<any>
238
+ accentColor: string
239
+ onAccentColor: string
240
+ mutedColor: string
241
+ labelFontFamily: string
242
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
243
+ onSelect: (value: any) => void
244
+ }) {
245
+ return (
246
+ <>
247
+ {section.options.map((option) => {
248
+ const selected = option.value === section.value
249
+ const rowColor = selected ? onAccentColor : mutedColor
250
+ return (
251
+ <TouchableRipple key={option.value} onPress={() => onSelect(option.value)} style={[styles.item, selected && { backgroundColor: accentColor }]}>
252
+ <View style={styles.itemRow}>
253
+ {option.icon && <Icon source={option.icon} size={MENU_ITEM_ICON_SIZE} color={rowColor} />}
254
+ <View style={styles.itemLabelColumn}>
255
+ <Text variant='labelLarge' style={[{ fontFamily: labelFontFamily }, { color: rowColor, fontWeight: selected ? 'bold' : 'normal' }]}>
256
+ {option.label}
257
+ </Text>
258
+ {option.description && (
259
+ <Text variant='labelSmall' style={[{ fontFamily: labelFontFamily }, { color: rowColor }]}>
260
+ {option.description}
261
+ </Text>
262
+ )}
263
+ </View>
264
+ </View>
265
+ </TouchableRipple>
266
+ )
267
+ })}
268
+ </>
269
+ )
270
+ }
271
+
272
+ function MultiSection({
273
+ section,
274
+ accentColor,
275
+ onAccentColor,
276
+ mutedColor,
277
+ labelFontFamily,
278
+ allClearLabels
279
+ }: {
280
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
281
+ section: MultiSelectSection<any>
282
+ accentColor: string
283
+ onAccentColor: string
284
+ mutedColor: string
285
+ labelFontFamily: string
286
+ allClearLabels: { all: string; clear: string }
287
+ }) {
288
+ const allSelected = section.value.length === section.options.length
289
+
290
+ return (
291
+ <>
292
+ {section.options.map((option, i) => {
293
+ const selected = section.value.includes(option.value)
294
+ const rowColor = selected ? onAccentColor : mutedColor
295
+ // Adjacent rows sit flush against each other (no gap — see styles.item's own lack of
296
+ // margin), so two selected neighbors otherwise each carve a small rounded notch out of what
297
+ // should read as one continuous block. Squaring off just the touching edge is what makes a
298
+ // run of several selected options look like one seamless shape instead of a stack of
299
+ // separately-rounded pills.
300
+ const prevSelected = selected && i > 0 && section.value.includes(section.options[i - 1].value)
301
+ const nextSelected = selected && i < section.options.length - 1 && section.value.includes(section.options[i + 1].value)
302
+ return (
303
+ <TouchableRipple key={option.value} onPress={() => section.onChange(selected ? section.value.filter((v: unknown) => v !== option.value) : [...section.value, option.value])} style={[styles.item, selected && { backgroundColor: accentColor }, prevSelected && { borderTopLeftRadius: 0, borderTopRightRadius: 0 }, nextSelected && { borderBottomLeftRadius: 0, borderBottomRightRadius: 0 }]}>
304
+ <View style={styles.itemRow}>
305
+ {option.icon && <Icon source={option.icon} size={MENU_ITEM_ICON_SIZE} color={rowColor} />}
306
+ <Text variant='labelLarge' style={[styles.itemLabelFlex, { fontFamily: labelFontFamily }, { color: rowColor, fontWeight: selected ? 'bold' : 'normal' }]}>
307
+ {option.label}
308
+ </Text>
309
+ </View>
310
+ </TouchableRipple>
311
+ )
312
+ })}
313
+
314
+ {section.allClear && (
315
+ <TouchableRipple onPress={() => section.onChange(allSelected ? [] : section.options.map((o) => o.value))} style={styles.item}>
316
+ <Text variant='labelLarge' style={[styles.allClearLabel, { fontFamily: labelFontFamily, color: accentColor }]}>
317
+ {allSelected ? allClearLabels.clear : allClearLabels.all}
318
+ </Text>
319
+ </TouchableRipple>
320
+ )}
321
+ </>
322
+ )
323
+ }
324
+
325
+ const styles = StyleSheet.create({
326
+ allClearLabel: {
327
+ fontWeight: 'bold',
328
+ textAlign: 'center'
329
+ },
330
+ anchor: {
331
+ position: 'relative'
332
+ },
333
+ // React Native Web gives every position:'relative' view an explicit zIndex (0, not 'auto'),
334
+ // which makes each one its own stacking context — so a zIndex set only on the popover content
335
+ // below only wins comparisons within this anchor's own box, not against this anchor's own later
336
+ // siblings (e.g. a Ready button), which are otherwise painted on top by DOM-order tiebreak.
337
+ // Elevating the anchor itself, only while its popover is open, fixes that at the right level.
338
+ anchorOpen: {
339
+ zIndex: 100
340
+ },
341
+ divider: {
342
+ height: StyleSheet.hairlineWidth,
343
+ marginVertical: 4
344
+ },
345
+ item: {
346
+ borderRadius: 8,
347
+ paddingHorizontal: 12,
348
+ paddingVertical: 8
349
+ },
350
+ itemLabelColumn: {
351
+ maxWidth: MENU_ITEM_LABEL_MAX_WIDTH
352
+ },
353
+ itemLabelFlex: {
354
+ flex: 1
355
+ },
356
+ itemRow: {
357
+ alignItems: 'center',
358
+ flexDirection: 'row',
359
+ gap: 8
360
+ },
361
+ menu: {
362
+ borderRadius: 12,
363
+ borderWidth: MENU_BORDER_WIDTH,
364
+ elevation: 8,
365
+ maxWidth: MENU_MAX_WIDTH,
366
+ minWidth: MENU_MIN_WIDTH,
367
+ shadowColor: '#000000',
368
+ shadowOffset: { height: 2, width: 0 },
369
+ shadowOpacity: 0.3,
370
+ shadowRadius: 8
371
+ },
372
+ // Separate from `menu` itself — a ScrollView's own style sets the scrolling frame's bounds
373
+ // (including the maxHeight that actually caps it), while padding/gap belong to its
374
+ // contentContainerStyle, which sizes to the *unclamped* content instead of the frame.
375
+ menuContent: {
376
+ gap: 4,
377
+ padding: 6
378
+ },
379
+ // Fixed hit-area regardless of the current option's iconSize, so a small-vs-large trigger icon
380
+ // doesn't shift this picker's position within its row.
381
+ triggerBox: {
382
+ alignItems: 'center',
383
+ height: TRIGGER_SIZE,
384
+ justifyContent: 'center',
385
+ width: TRIGGER_SIZE
386
+ }
387
+ })