@tastic/hud 0.1.6 → 0.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/hud",
3
- "version": "0.1.6",
3
+ "version": "0.3.0",
4
4
  "description": "Visual component kit for local-multiplayer React Native games: inline (non-modal) popovers, dropdowns, color pickers, gauges, ready buttons, and dialogs, built to stay scoped to one player's own zone on a shared screen",
5
5
  "keywords": [
6
6
  "react-native",
@@ -68,6 +68,7 @@
68
68
  "@infinitetoken/tsconfig": "^0.3.0",
69
69
  "@rific/auto-paper": "^0.10.3",
70
70
  "@rific/feedback-press": "^0.10.8",
71
+ "@rific/updater": "^0.4.2",
71
72
  "@shopify/react-native-skia": "^2.6.2",
72
73
  "@tastic/core": "^0.1.5",
73
74
  "@testing-library/dom": "^10.4.1",
@@ -91,6 +92,7 @@
91
92
  "peerDependencies": {
92
93
  "@rific/auto-paper": ">=0.9.0",
93
94
  "@rific/feedback-press": ">=0.10.0",
95
+ "@rific/updater": ">=0.4.0",
94
96
  "@shopify/react-native-skia": ">=1.5.0",
95
97
  "@tastic/core": ">=0.1.0",
96
98
  "react": ">=19.0.0",
@@ -0,0 +1,371 @@
1
+ import { AutoAppearancePicker, Dialog, useAutoPaperTheme } from '@rific/auto-paper'
2
+ import { Button, SoundContext, TouchableRipple, useHapticSettings, useSoundSettings, useVibration } from '@rific/feedback-press'
3
+ import { useUpdater } from '@rific/updater'
4
+ import { useIsTouchPrimaryDevice } from '@tastic/core'
5
+ import { ReactNode, useContext, useState } from 'react'
6
+ import { Platform, ScrollView, StyleSheet, View } from 'react-native'
7
+ import { Icon, Portal, Text } from 'react-native-paper'
8
+
9
+ interface SettingIconProps {
10
+ source: string
11
+ color: string
12
+ containerColor: string
13
+ }
14
+
15
+ // Small colored badge per row (icon tinted on its own MD3 container color) — echoes the
16
+ // primary/secondary/tertiary triad that's already each consuming app's own identity, so the
17
+ // settings list picks up that same palette instead of introducing new colors of its own.
18
+ function SettingIcon({ source, color, containerColor }: SettingIconProps) {
19
+ return (
20
+ <View style={[styles.iconBadge, { backgroundColor: containerColor }]}>
21
+ <Icon source={source} size={18} color={color} />
22
+ </View>
23
+ )
24
+ }
25
+
26
+ export interface BaseSettingsDialogProps {
27
+ visible: boolean
28
+ onDismiss: () => void
29
+ // Live physical-hold rotation (see @tastic/split-screen's getViewRotation) — this is a centered,
30
+ // app-wide modal with no per-player zone to match (unlike an in-game round-over dialog), so it
31
+ // just rotates its own content in place; defaults to 0 for a caller that doesn't track one (a
32
+ // game with no face-to-face two-player mode has nothing to stay consistent with anyway).
33
+ rotation?: number
34
+ // Each app's own OTA version (e.g. release.otaVersion) — not something this package can read
35
+ // itself, since every consuming app tracks its own release.ts independently. Accepts a number too
36
+ // since that's what release.otaVersion actually is in most apps (a bare incrementing counter, not
37
+ // a formatted string) — this just interpolates it into the VERSION label same as the original
38
+ // inline JSX did.
39
+ version: string | number
40
+ // Optional, and always passed as a pair — omit both entirely for a game that's portrait-only (or
41
+ // otherwise has no orientation-based reflow at all), where there's nothing to lock against.
42
+ lockOrientation?: boolean
43
+ onLockOrientationChange?: (value: boolean) => void
44
+ // Optional, and always passed as a pair — omit both entirely for a game with no full-screen edge-
45
+ // anchored swipe controls (nothing to guard). iOS-only regardless of whether the pair is passed:
46
+ // @tastic/edge-guard's own swizzle is a no-op on every other platform.
47
+ deferBottomEdgeGestures?: boolean
48
+ onDeferBottomEdgeGestures?: (value: boolean) => void
49
+ // Explicit opt-outs for the rows that pull their own state straight from @rific/feedback-press,
50
+ // @rific/auto-paper, and @rific/updater's own shared contexts/hooks rather than from anything the
51
+ // caller passes in — unlike Lock Orientation/Edge Guard above, there's no "value" to omit for
52
+ // these, so hiding one takes its own explicit flag instead. All default to shown, matching every
53
+ // app's existing behavior before this prop existed.
54
+ hideSound?: boolean
55
+ // Haptics is already native-only (see the row's own Platform.OS check) — this hides it even
56
+ // there, for a game that's deliberately silent on vibration.
57
+ hideHaptics?: boolean
58
+ hideAppearance?: boolean
59
+ // Check for Updates is already native-only (a web build has no OTA update concept at all) — this
60
+ // hides it even there, for a native app that isn't wired up for EAS/OTA updates at all.
61
+ hideUpdateCheck?: boolean
62
+ // Surfaces a failed update check (e.g. via @rific/toaster's error()) — omit for an app with no
63
+ // toast system wired up; the check itself still runs and still reports success/no-update through
64
+ // the ordinary info overlay either way. Meaningless (never called) when hideUpdateCheck is set.
65
+ onUpdateError?: (message: string) => void
66
+ // App-specific extra settings sections (board options, CPU difficulty, stats backup, ...),
67
+ // rendered between Appearance and Check for Updates — this component only owns the settings
68
+ // every game shares, never anything about how a particular game plays.
69
+ children?: ReactNode
70
+ }
71
+
72
+ // The settings shell every @tastic game shares — orientation lock, the optional edge-guard toggle,
73
+ // sound/haptics, appearance, and update checking — extracted after the same six rows were
74
+ // independently copied into LightCycles, AirHockey, BoxHockey, Pong, and Snake and then needed
75
+ // hand-editing in every one of them, twice, for what were meant to be identical fixes. `children`
76
+ // is the seam for whatever isn't shared: a game with its own board/difficulty settings (or a stats
77
+ // backup flow, or anything else genuinely specific to it) renders those below Appearance, not by
78
+ // forking this component.
79
+ export function BaseSettingsDialog({ visible, onDismiss, rotation = 0, version, lockOrientation, onLockOrientationChange, deferBottomEdgeGestures, onDeferBottomEdgeGestures, hideSound = false, hideHaptics = false, hideAppearance = false, hideUpdateCheck = false, onUpdateError, children }: BaseSettingsDialogProps) {
80
+ const { dark, colors } = useAutoPaperTheme()
81
+ // Also gates the Lock Orientation row below (alongside showLockOrientation itself) — true
82
+ // unconditionally on native (see the hook's own doc), so this only actually excludes a
83
+ // desktop/laptop browser, where a mouse-driven window has no physical orientation to lock
84
+ // against; a resize there would just make the setting silently stop reflowing the layout instead
85
+ // of actually locking anything.
86
+ const isTouchPrimary = useIsTouchPrimaryDevice()
87
+ const { settings: hapticSettings, set: setHapticSettings } = useHapticSettings()
88
+ const { settings: soundSettings, set: setSoundSettings } = useSoundSettings()
89
+ // Sound/Haptics toggle themselves: the ripple's automatic press feedback fires on onPressIn,
90
+ // before onPress applies the toggle, so it reflects the OLD enabled value — backwards from what
91
+ // a settings toggle should confirm (turning off would click/buzz, turning on would go silent).
92
+ // Both rows disable their own automatic channel below (soundDisabled/hapticDisabled) and fire it
93
+ // manually here instead, gated on the NEW value so it only plays when switching that channel on.
94
+ const sound = useContext(SoundContext)
95
+ const { forceShort: forceHapticFeedback } = useVibration()
96
+ // Covers check()'s three purely-informational cases (dev-mode disabled, web unsupported, no
97
+ // update found) via onInfo below — a retro card overlay instead of the native Alert.alert those
98
+ // cases fall back to by default.
99
+ const [infoMessage, setInfoMessage] = useState<{ title: string; message: string } | null>(null)
100
+ // autoCheck: false — every consuming app's own root layout already runs the background check via
101
+ // its own useUpdater() instance; a second instance with autoCheck's default (true) would set up a
102
+ // second AppState listener and double every foreground-resume update check. This instance only
103
+ // ever checks on an explicit tap of the button below.
104
+ const { check, checking, updateReady } = useUpdater({
105
+ autoCheck: false,
106
+ autoPrompt: false,
107
+ onError: onUpdateError,
108
+ onInfo: (title, message) => setInfoMessage({ title, message })
109
+ })
110
+
111
+ const showLockOrientation = isTouchPrimary && lockOrientation !== undefined && onLockOrientationChange !== undefined
112
+ const showEdgeGuard = Platform.OS === 'ios' && deferBottomEdgeGestures !== undefined && onDeferBottomEdgeGestures !== undefined
113
+ const showHaptics = Platform.OS !== 'web' && !hideHaptics
114
+ const showSound = !hideSound
115
+ const showAppearance = !hideAppearance
116
+ const showUpdateCheck = Platform.OS !== 'web' && !hideUpdateCheck
117
+
118
+ // High-contrast retro look: literal black/white by appearance, not auto-paper's own (slightly
119
+ // tinted) background role.
120
+ const fg = dark ? '#FFFFFF' : '#000000'
121
+ const cardBg = dark ? '#111111' : '#F2F2F2'
122
+ const cardBorder = dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'
123
+
124
+ return (
125
+ <>
126
+ <Dialog visible={visible} onDismiss={onDismiss} style={[styles.dialog, rotation % 360 !== 0 && { transform: [{ rotate: `${rotation}deg` }] }]}>
127
+ <Dialog.Title>Settings</Dialog.Title>
128
+ {/* react-native-paper's Dialog.ScrollArea has a fixed 24px marginBottom baked in (meant to
129
+ reserve room for a Dialog.Actions row below it) — overridden to 0 since this dialog has
130
+ no actions row, and that gap otherwise reads as unexplained empty footer space. */}
131
+ <Dialog.ScrollArea style={styles.scrollArea}>
132
+ <ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
133
+ {/* Grouped tightly together (styles.toggleGroup's small internal gap, not the 24px gap
134
+ between sections below) — same bare-row shape for all of them, no segmented control
135
+ and no section heading (each row's own label already says what it is). Haptics only
136
+ joins on native — there's nothing for it to control on web. */}
137
+ <View style={styles.toggleGroup}>
138
+ {/* Omitted entirely for a portrait-only game (no lockOrientation/onLockOrientationChange
139
+ passed at all — nothing to lock), and hidden (not just disabled) on a desktop/laptop
140
+ browser even when they are — see isTouchPrimary's own doc above. Still shown on
141
+ native and on a touch-capable mobile browser, where a rotated window really does
142
+ reflect a rotated device. */}
143
+ {showLockOrientation && (
144
+ <TouchableRipple onPress={() => onLockOrientationChange!(!lockOrientation)} style={styles.toggleButton} accessibilityLabel={`Lock orientation ${lockOrientation ? 'on' : 'off'}`}>
145
+ <View style={styles.toggleContent}>
146
+ <SettingIcon source={lockOrientation ? 'lock' : 'lock-open-variant-outline'} color={lockOrientation ? colors.secondary : colors.onSurfaceVariant} containerColor={lockOrientation ? colors.secondaryContainer : colors.surfaceVariant} />
147
+ <View style={styles.flexShrink}>
148
+ <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
149
+ Lock Orientation
150
+ </Text>
151
+ <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
152
+ Pins the current layout
153
+ </Text>
154
+ </View>
155
+ </View>
156
+ </TouchableRipple>
157
+ )}
158
+
159
+ {/* iOS only — mirrors into UserDefaults for @tastic/edge-guard's own config plugin
160
+ swizzle to read. Guards both edges — the top (Notification Center/Control Center)
161
+ and the bottom (home-indicator swipe, incidentally Reachability too), per that
162
+ package's own doc. Off by default: a deliberate opt-in for whoever's actually hit an
163
+ accidental-swipe-near-an-edge problem mid-game and come looking for a fix, not a
164
+ surprise every player gets by default. */}
165
+ {showEdgeGuard && (
166
+ <TouchableRipple onPress={() => onDeferBottomEdgeGestures!(!deferBottomEdgeGestures)} style={styles.toggleButton} accessibilityLabel={`Edge guard ${deferBottomEdgeGestures ? 'on' : 'off'}`}>
167
+ <View style={styles.toggleContent}>
168
+ <SettingIcon source={deferBottomEdgeGestures ? 'shield-check-outline' : 'shield-off-outline'} color={deferBottomEdgeGestures ? colors.secondary : colors.onSurfaceVariant} containerColor={deferBottomEdgeGestures ? colors.secondaryContainer : colors.surfaceVariant} />
169
+ <View style={styles.flexShrink}>
170
+ <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
171
+ Edge Guard
172
+ </Text>
173
+ <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
174
+ Requires a second swipe
175
+ </Text>
176
+ </View>
177
+ </View>
178
+ </TouchableRipple>
179
+ )}
180
+
181
+ {/* Side by side, not stacked — neither needs a full row's width (single-line label,
182
+ no description under it the way Lock Orientation has), so sharing one row reads
183
+ just as clearly and takes half the vertical space. toggleRow carries the same
184
+ edge-alignment negative margin toggleButton normally carries itself; the buttons
185
+ inside it use toggleButtonInRow (flex: 1) instead so the two don't double up on it.
186
+ The row itself only renders when at least one of the two will — an empty flex row
187
+ still eats its own gap/margin otherwise. */}
188
+ {(showSound || showHaptics) && (
189
+ <View style={styles.toggleRow}>
190
+ {showSound && (
191
+ <TouchableRipple
192
+ soundDisabled
193
+ onPress={() => {
194
+ const enabled = !soundSettings.enabled
195
+ setSoundSettings({ enabled })
196
+ if (enabled) sound.selection?.()
197
+ }}
198
+ style={styles.toggleButtonInRow}
199
+ accessibilityLabel={`Sound ${soundSettings.enabled ? 'on' : 'off'}`}
200
+ >
201
+ <View style={styles.toggleContent}>
202
+ <SettingIcon source={soundSettings.enabled ? 'volume-high' : 'volume-off'} color={soundSettings.enabled ? colors.tertiary : colors.onSurfaceVariant} containerColor={soundSettings.enabled ? colors.tertiaryContainer : colors.surfaceVariant} />
203
+ <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
204
+ Sound
205
+ </Text>
206
+ </View>
207
+ </TouchableRipple>
208
+ )}
209
+
210
+ {/* Already native-only (see showHaptics's own definition) — hideHaptics can hide
211
+ it there too, for a game that's deliberately silent on vibration. */}
212
+ {showHaptics && (
213
+ <TouchableRipple
214
+ hapticDisabled
215
+ onPress={() => {
216
+ const vibrate = !hapticSettings.vibrate
217
+ setHapticSettings({ vibrate })
218
+ if (vibrate) forceHapticFeedback()
219
+ }}
220
+ style={styles.toggleButtonInRow}
221
+ accessibilityLabel={`Haptics ${hapticSettings.vibrate ? 'on' : 'off'}`}
222
+ >
223
+ <View style={styles.toggleContent}>
224
+ <SettingIcon source={hapticSettings.vibrate ? 'vibrate' : 'vibrate-off'} color={hapticSettings.vibrate ? colors.tertiary : colors.onSurfaceVariant} containerColor={hapticSettings.vibrate ? colors.tertiaryContainer : colors.surfaceVariant} />
225
+ <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
226
+ Haptics
227
+ </Text>
228
+ </View>
229
+ </TouchableRipple>
230
+ )}
231
+ </View>
232
+ )}
233
+ </View>
234
+
235
+ {showAppearance && (
236
+ <View style={styles.section}>
237
+ <Text variant='labelMedium' style={[styles.sectionLabel, { color: colors.onSurfaceVariant }]}>
238
+ APPEARANCE
239
+ </Text>
240
+ <AutoAppearancePicker showLabels={false} />
241
+ </View>
242
+ )}
243
+
244
+ {children}
245
+
246
+ {/* Native only (a web build has no OTA update concept at all, desktop or mobile browser
247
+ alike) — hideUpdateCheck can hide it there too, for a native app that isn't wired up
248
+ for EAS/OTA updates at all. */}
249
+ {showUpdateCheck && (
250
+ <View style={styles.section}>
251
+ <Text variant='labelSmall' style={[styles.sectionLabel, { color: colors.onSurfaceVariant }]}>
252
+ VERSION {version}
253
+ {updateReady ? ' · UPDATE READY' : ''}
254
+ </Text>
255
+ <Button mode='outlined' onPress={check} loading={checking} disabled={checking}>
256
+ Check for Updates
257
+ </Button>
258
+ </View>
259
+ )}
260
+ </ScrollView>
261
+ </Dialog.ScrollArea>
262
+ </Dialog>
263
+ {infoMessage && (
264
+ <Portal>
265
+ <View style={styles.overlay}>
266
+ <View style={[styles.overlayCard, { backgroundColor: cardBg, borderColor: cardBorder }, rotation % 360 !== 0 && { transform: [{ rotate: `${rotation}deg` }] }]}>
267
+ <Icon source='information-outline' size={64} color={colors.secondary} />
268
+ <Text variant='headlineLarge' style={[styles.overlayTitle, { color: colors.secondary }]}>
269
+ {infoMessage.title}
270
+ </Text>
271
+ <Text variant='bodyLarge' style={[styles.overlayBody, { color: fg }]}>
272
+ {infoMessage.message}
273
+ </Text>
274
+ <Button mode='contained' onPress={() => setInfoMessage(null)} style={styles.overlayButton} buttonColor={colors.primary} textColor={colors.onPrimary}>
275
+ OK
276
+ </Button>
277
+ </View>
278
+ </View>
279
+ </Portal>
280
+ )}
281
+ </>
282
+ )
283
+ }
284
+
285
+ const styles = StyleSheet.create({
286
+ content: {
287
+ gap: 24,
288
+ paddingVertical: 20
289
+ },
290
+ // Caps the card so it never grows past the screen — without this the dialog just keeps
291
+ // growing to fit its content and the overflow gets clipped by the screen edge, which is
292
+ // what happened in landscape where there's less height to work with. Dialog.ScrollArea +
293
+ // ScrollView below then take over and let the content scroll within that bound.
294
+ dialog: {
295
+ maxHeight: '90%'
296
+ },
297
+ flexShrink: {
298
+ flexShrink: 1
299
+ },
300
+ iconBadge: {
301
+ alignItems: 'center',
302
+ borderRadius: 10,
303
+ height: 36,
304
+ justifyContent: 'center',
305
+ width: 36
306
+ },
307
+ overlay: {
308
+ alignItems: 'center',
309
+ backgroundColor: 'rgba(0,0,0,0.72)',
310
+ bottom: 0,
311
+ justifyContent: 'center',
312
+ left: 0,
313
+ position: 'absolute',
314
+ right: 0,
315
+ top: 0
316
+ },
317
+ overlayBody: { textAlign: 'center' },
318
+ overlayButton: { width: 160 },
319
+ overlayCard: {
320
+ alignItems: 'center',
321
+ borderRadius: 20,
322
+ borderWidth: 1,
323
+ gap: 16,
324
+ maxWidth: 360,
325
+ padding: 32
326
+ },
327
+ overlayTitle: { fontWeight: 'bold' },
328
+ scrollArea: {
329
+ marginBottom: 0
330
+ },
331
+ section: {
332
+ gap: 12
333
+ },
334
+ sectionLabel: {
335
+ letterSpacing: 2
336
+ },
337
+ // Negative margin cancels the padding so the icon still lines up with APPEARANCE/SOUND &
338
+ // HAPTICS below, while the ripple/hover highlight itself gets room to breathe on both sides
339
+ // instead of a flush edge-to-edge slab. overflow: 'hidden' makes sure that highlight actually
340
+ // clips to borderRadius instead of drawing as a plain rectangle.
341
+ toggleButton: {
342
+ borderRadius: 12,
343
+ marginHorizontal: -12,
344
+ overflow: 'hidden',
345
+ paddingHorizontal: 12,
346
+ paddingVertical: 10
347
+ },
348
+ // Same shape as toggleButton but `flex: 1` instead of its own `marginHorizontal: -12` — two of
349
+ // these side by side in toggleRow would otherwise both pull inward and collide in the middle.
350
+ // toggleRow carries that edge-alignment margin once, for the row as a whole, instead.
351
+ toggleButtonInRow: {
352
+ borderRadius: 12,
353
+ flex: 1,
354
+ overflow: 'hidden',
355
+ paddingHorizontal: 12,
356
+ paddingVertical: 10
357
+ },
358
+ toggleContent: {
359
+ alignItems: 'center',
360
+ flexDirection: 'row',
361
+ gap: 12
362
+ },
363
+ toggleGroup: {
364
+ gap: 4
365
+ },
366
+ toggleRow: {
367
+ flexDirection: 'row',
368
+ gap: 4,
369
+ marginHorizontal: -12
370
+ }
371
+ })
@@ -0,0 +1,39 @@
1
+ import { IconButton } from '@rific/feedback-press'
2
+ import { StyleSheet } from 'react-native'
3
+
4
+ interface Props {
5
+ onBack: () => void
6
+ onSettings: () => void
7
+ // Full-contrast foreground — same convention as every other component keying off a `dark` prop
8
+ // (BaseSettingsDialog, SectionedDropdown, etc.), just resolved by the caller since this component
9
+ // never reads theme state itself.
10
+ fg: string
11
+ // Safe-area insets already remapped onto the screen's real visual edges (see
12
+ // @tastic/split-screen's rotateInsets) — this component just adds the fixed 8px margin on top,
13
+ // it doesn't know anything about physical device orientation itself.
14
+ insets: { top: number; left: number; right: number }
15
+ }
16
+
17
+ // Back (top-left) + Settings (top-right) icon buttons, fixed at the screen's rotated corners — the
18
+ // default reachability answer for a two-player screen: correct any time both players share one
19
+ // screen but only one of them (or neither, in vs-CPU) is on the far side of a rotated zone. Not the
20
+ // right answer for a screen where the top corners themselves fall *inside* one player's own rotated
21
+ // zone (two-player face-to-face) — see SharedActionBand for that case instead, which callers select
22
+ // between based on their own game-mode/orientation state (this component has no opinion on which).
23
+ export function CornerActionButtons({ onBack, onSettings, fg, insets }: Props) {
24
+ return (
25
+ <>
26
+ <IconButton icon='arrow-left' iconColor={fg} size={24} style={[styles.back, { top: 8 + insets.top, left: 8 + insets.left }]} onPress={onBack} accessibilityLabel='Back' />
27
+ <IconButton icon='cog' iconColor={fg} size={24} style={[styles.topRight, { top: 8 + insets.top, right: 8 + insets.right }]} onPress={onSettings} accessibilityLabel='Settings' />
28
+ </>
29
+ )
30
+ }
31
+
32
+ const styles = StyleSheet.create({
33
+ back: {
34
+ position: 'absolute'
35
+ },
36
+ topRight: {
37
+ position: 'absolute'
38
+ }
39
+ })
@@ -0,0 +1,175 @@
1
+ import { getContrastColor } from '@rific/auto-paper'
2
+ import { TouchableRipple } from '@rific/feedback-press'
3
+ import { RefObject } from 'react'
4
+ import { ScrollView, StyleSheet, View } from 'react-native'
5
+ import { Icon, Text } from 'react-native-paper'
6
+
7
+ import { PopoverBody } from './PopoverBody'
8
+ import { PopoverAlign, PopoverVerticalAlign, useAutoAlign } from './useAutoAlign'
9
+ import { PopoverHost } from './usePopoverHost'
10
+
11
+ const TRIGGER_HEIGHT = 28
12
+ const POPOVER_WIDTH = 180
13
+ const ROW_HEIGHT = 36
14
+ const LIST_PADDING = 8
15
+
16
+ // Exported so a caller that needs to independently compute this popover's own placement — to feed
17
+ // alignOverride with, say, a @tastic/split-screen-zone-aware hook's result — can match its real
18
+ // content dimensions exactly, rather than hardcoding or re-deriving this component's own internal
19
+ // sizing (which alignOverride itself deliberately doesn't take a dependency on any particular
20
+ // zone-awareness package to compute).
21
+ export const LABELED_DROPDOWN_POPOVER_WIDTH = POPOVER_WIDTH
22
+ export function getLabeledDropdownContentHeight(optionCount: number): number {
23
+ return LIST_PADDING * 2 + optionCount * ROW_HEIGHT
24
+ }
25
+
26
+ export interface LabeledDropdownOption<T extends string> {
27
+ value: T
28
+ label: string
29
+ icon?: string
30
+ }
31
+
32
+ // Same shape useAutoAlign itself returns — lets a caller inside a @tastic/split-screen zone (or
33
+ // any other layout this package doesn't know about) substitute its own alignment decision via
34
+ // `alignOverride` below, without this package taking a dependency on that caller's own hook.
35
+ interface AlignResult {
36
+ align: PopoverAlign
37
+ verticalAlign: PopoverVerticalAlign
38
+ maxHeight: number
39
+ measured: boolean
40
+ triggerRef: RefObject<View | null>
41
+ }
42
+
43
+ interface Props<T extends string> {
44
+ id: string
45
+ host: PopoverHost
46
+ options: LabeledDropdownOption<T>[]
47
+ value: T
48
+ onChange: (value: T) => void
49
+ color: string
50
+ dark: boolean
51
+ // Forces a specific horizontal alignment instead of letting whichever placement result applies
52
+ // (see alignOverride below) decide. Matches PopoverBody's own align prop meaning.
53
+ align?: 'left' | 'right' | 'center'
54
+ // Substitutes this package's own plain useAutoAlign-based placement wholesale (align,
55
+ // verticalAlign, maxHeight, measured, triggerRef) with a caller-supplied one — e.g. a popover
56
+ // living inside a @tastic/split-screen zone, where the plain window-relative decision can pick a
57
+ // direction that overflows into the shared row instead of the actual zone boundary. Omit for the
58
+ // ordinary case (a popover with nothing but the screen edge to avoid).
59
+ alignOverride?: AlignResult
60
+ }
61
+
62
+ // A name-trigger + selection popover in the same visual style as @tastic/profile's own
63
+ // ProfilePicker trigger — an uppercase text label + chevron, opening a list whose selected row
64
+ // fills entirely with the seat's own accent color — rather than this package's own
65
+ // SectionedDropdown, whose trigger is a fixed icon with a gauge ring around it. For a choice that
66
+ // reads more like "who/what you're playing" than "a game setting" (a CPU seat's own difficulty,
67
+ // occupying the same slot a human seat's ProfilePicker would), the always-visible text label
68
+ // carries more of the meaning than an icon would on its own.
69
+ export function LabeledDropdown<T extends string>({ id, host, options, value, onChange, color, dark, align: forcedAlign, alignOverride }: Props<T>) {
70
+ const menuBg = dark ? '#000000' : '#FFFFFF'
71
+ const fg = dark ? '#FFFFFF' : '#000000'
72
+
73
+ const open = host.openId === id
74
+ const selected = options.find((o) => o.value === value) ?? null
75
+ const contentHeight = LIST_PADDING * 2 + options.length * ROW_HEIGHT
76
+ // Always called, even when alignOverride is supplied and this result goes unused — hooks can't be
77
+ // called conditionally. A caller passing alignOverride (e.g. LightCycles' own useZoneClampedAlign,
78
+ // which already wraps this same hook) does end up measuring the trigger twice; an acceptable
79
+ // tradeoff for keeping this component's own hook usage unconditional and override-shaped rather
80
+ // than needing a second, hook-free code path.
81
+ const auto = useAutoAlign(open, POPOVER_WIDTH, contentHeight)
82
+ const placement = alignOverride ?? auto
83
+ const { maxHeight, measured, triggerRef, verticalAlign } = placement
84
+ const align = forcedAlign ?? placement.align
85
+
86
+ const handleSelect = (option: LabeledDropdownOption<T>) => {
87
+ onChange(option.value)
88
+ host.close()
89
+ }
90
+
91
+ return (
92
+ <View style={[styles.anchor, open && styles.anchorOpen]}>
93
+ <TouchableRipple onPress={() => host.toggle(id)} style={styles.trigger}>
94
+ <View ref={triggerRef} collapsable={false} style={styles.triggerInner}>
95
+ <Text style={[styles.triggerLabel, { color }]} numberOfLines={1}>
96
+ {(selected?.label ?? '').toUpperCase()}
97
+ </Text>
98
+ <Icon source='menu-down' size={16} color={color} />
99
+ </View>
100
+ </TouchableRipple>
101
+
102
+ {/* Gated on `measured`, not just `open` — see InlineColorPicker's identical fix. */}
103
+ <PopoverBody visible={open && measured} align={align} verticalAlign={verticalAlign} caretColor={menuBg} caretBorderColor={color} caretBorderWidth={2} triggerSize={TRIGGER_HEIGHT}>
104
+ <ScrollView style={[styles.menu, { backgroundColor: menuBg, borderColor: color, width: POPOVER_WIDTH, maxHeight }]} contentContainerStyle={styles.menuContent} showsVerticalScrollIndicator={false}>
105
+ {options.map((option) => {
106
+ const isSelected = option.value === value
107
+ const onColor = getContrastColor(color)
108
+ return (
109
+ <TouchableRipple key={option.value} onPress={() => handleSelect(option)} style={[styles.row, isSelected && { backgroundColor: color }]}>
110
+ <View style={styles.rowInner}>
111
+ {option.icon && <Icon source={option.icon} size={18} color={isSelected ? onColor : fg} />}
112
+ <Text style={[styles.rowLabel, { color: isSelected ? onColor : fg }]} numberOfLines={1}>
113
+ {option.label}
114
+ </Text>
115
+ </View>
116
+ </TouchableRipple>
117
+ )
118
+ })}
119
+ </ScrollView>
120
+ </PopoverBody>
121
+ </View>
122
+ )
123
+ }
124
+
125
+ const styles = StyleSheet.create({
126
+ anchor: {
127
+ position: 'relative'
128
+ },
129
+ // React Native Web gives every position:'relative' view its own stacking context, so the
130
+ // elevation has to live on the anchor itself — see PopoverBody's own stacking-context notes.
131
+ anchorOpen: {
132
+ zIndex: 100
133
+ },
134
+ menu: {
135
+ borderRadius: 12,
136
+ borderWidth: 2,
137
+ elevation: 8,
138
+ shadowColor: '#000000',
139
+ shadowOffset: { height: 2, width: 0 },
140
+ shadowOpacity: 0.3,
141
+ shadowRadius: 8
142
+ },
143
+ menuContent: {
144
+ padding: LIST_PADDING
145
+ },
146
+ row: {
147
+ borderRadius: 8,
148
+ height: ROW_HEIGHT
149
+ },
150
+ rowInner: {
151
+ alignItems: 'center',
152
+ flexDirection: 'row',
153
+ flex: 1,
154
+ gap: 8,
155
+ paddingHorizontal: 12
156
+ },
157
+ rowLabel: {
158
+ flex: 1,
159
+ fontSize: 14
160
+ },
161
+ trigger: {
162
+ alignSelf: 'flex-start'
163
+ },
164
+ triggerInner: {
165
+ alignItems: 'center',
166
+ flexDirection: 'row',
167
+ gap: 2,
168
+ height: TRIGGER_HEIGHT
169
+ },
170
+ triggerLabel: {
171
+ fontSize: 13,
172
+ fontWeight: '700',
173
+ letterSpacing: 1
174
+ }
175
+ })
@@ -0,0 +1,61 @@
1
+ import { IconButton } from '@rific/feedback-press'
2
+ import { ReactNode } from 'react'
3
+ import { StyleSheet, View } from 'react-native'
4
+
5
+ interface Props {
6
+ onBack: () => void
7
+ onSettings: () => void
8
+ // Optional, and expected to be passed together in practice (either a screen has both a randomize
9
+ // and a reset action, or neither) — kept as two independently-optional props rather than one pair
10
+ // flag so a caller with only one of the two isn't forced to fake the other.
11
+ onRandomize?: () => void
12
+ onReset?: () => void
13
+ fg: string
14
+ // Whether one of THIS band's own popovers (i.e. something rendered inside `children`) is
15
+ // currently open — the caller computes this (typically `host.openId !== null &&
16
+ // OWN_IDS.includes(host.openId)`), since only the caller knows which popover ids belong to its
17
+ // own children. Elevates the whole column above sibling panels when true — React Native Web gives
18
+ // every position:'relative' view its own stacking context, so a popover escaping `children` needs
19
+ // this wrapper itself elevated, not just the popover content, to paint above a later sibling
20
+ // (e.g. a player panel) rather than underneath it.
21
+ popoverOpen: boolean
22
+ // The screen's own per-round option row (grid size, arena, etc.) — rendered directly below the
23
+ // action row, inside the same elevating column.
24
+ children: ReactNode
25
+ }
26
+
27
+ // The reachability fix for a two-player screen where the plain top-left/top-right corners (see
28
+ // CornerActionButtons) would land inside one player's own rotated zone — most concretely, two-
29
+ // player face-to-face, where the far player's zone always covers the top half of the screen. Folds
30
+ // back/randomize/reset/settings into one row that sits on neutral ground instead (the shared band
31
+ // between the two players' zones), directly above the caller's own per-round option row. The
32
+ // caller decides *when* this applies (it owns gameMode/orientationMode) — this component only
33
+ // renders the result once that decision's already been made.
34
+ export function SharedActionBand({ onBack, onSettings, onRandomize, onReset, fg, popoverOpen, children }: Props) {
35
+ return (
36
+ <View style={[styles.column, popoverOpen && styles.columnOpen]}>
37
+ <View style={styles.actionsRow}>
38
+ <IconButton icon='arrow-left' iconColor={fg} size={20} onPress={onBack} accessibilityLabel='Back' />
39
+ {onRandomize && <IconButton icon='dice-multiple' iconColor={fg} size={18} onPress={onRandomize} accessibilityLabel='Randomize match settings' />}
40
+ {onReset && <IconButton icon='restore' iconColor={fg} size={18} onPress={onReset} accessibilityLabel='Reset match settings to defaults' />}
41
+ <IconButton icon='cog' iconColor={fg} size={20} onPress={onSettings} accessibilityLabel='Settings' />
42
+ </View>
43
+ {children}
44
+ </View>
45
+ )
46
+ }
47
+
48
+ const styles = StyleSheet.create({
49
+ actionsRow: {
50
+ alignItems: 'center',
51
+ flexDirection: 'row',
52
+ gap: 16
53
+ },
54
+ column: {
55
+ alignItems: 'center',
56
+ gap: 8
57
+ },
58
+ columnOpen: {
59
+ zIndex: 100
60
+ }
61
+ })