@tastic/hud 0.2.0 → 0.4.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/dist/index.d.mts +116 -23
- package/dist/index.d.ts +116 -23
- package/dist/index.js +530 -148
- package/dist/index.mjs +483 -111
- package/package.json +1 -1
- package/src/AchievementRow.tsx +124 -0
- package/src/BaseStatsScreen.tsx +158 -0
- package/src/CornerActionButtons.tsx +39 -0
- package/src/LabeledDropdown.tsx +175 -0
- package/src/SharedActionBand.tsx +61 -0
- package/src/StatRow.tsx +44 -0
- package/src/StatSection.tsx +40 -0
- package/src/index.ts +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tastic/hud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
|
+
import { StyleSheet, View } from 'react-native'
|
|
3
|
+
import { Icon, ProgressBar, Text } from 'react-native-paper'
|
|
4
|
+
|
|
5
|
+
// A neutral gray for a locked badge's own background — exported so a caller computing its own
|
|
6
|
+
// badge color (unlocked ? tierColor : LOCKED_BADGE_COLOR, matching LightCycles' own achievements.tsx)
|
|
7
|
+
// doesn't need to invent or duplicate this exact tone.
|
|
8
|
+
export const LOCKED_BADGE_COLOR = 'rgba(128,128,128,0.3)'
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
icon: string
|
|
12
|
+
title: string
|
|
13
|
+
description: string
|
|
14
|
+
// The badge's own background — pass the achievement's tier color when unlocked, or
|
|
15
|
+
// LOCKED_BADGE_COLOR (this package's own export, above) when it isn't; this component doesn't
|
|
16
|
+
// infer that from unlockedLabel itself, since a locked-vs-unlocked badge color scheme is a
|
|
17
|
+
// caller-level design choice (see checkColor below for the identical reasoning on the status
|
|
18
|
+
// icon's own color).
|
|
19
|
+
badgeColor: string
|
|
20
|
+
// undefined = locked. A precomputed display string (e.g. "Unlocked 2 days ago"), not a raw
|
|
21
|
+
// timestamp — this component has no opinion on date formatting or streak conventions, which vary
|
|
22
|
+
// per app (see LightCycles' own calendar-day-difference convention in its achievements.tsx).
|
|
23
|
+
unlockedLabel?: string
|
|
24
|
+
// Color for the unlocked check-circle icon/badge glyph — typically the same tier color passed to
|
|
25
|
+
// badgeColor, kept as its own prop rather than reused internally so a caller can special-case it
|
|
26
|
+
// independently if its own tier scheme ever needs to (matching LightCycles' own call site, which
|
|
27
|
+
// does pass the identical value to both today).
|
|
28
|
+
checkColor?: string
|
|
29
|
+
// Only rendered while locked (unlockedLabel is undefined) — 0..1.
|
|
30
|
+
progress?: number
|
|
31
|
+
// The "Same for every profile" note LightCycles shows for a device-scoped achievement viewed
|
|
32
|
+
// from a specific profile's own tab — optional since only an app with per-profile stat views has
|
|
33
|
+
// this concept at all.
|
|
34
|
+
deviceMarker?: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// One row per achievement — a tier-colored (or locked-gray) badge, title/description, an optional
|
|
38
|
+
// progress bar while locked, and either an unlocked check-mark+label or a lock icon. Extracted
|
|
39
|
+
// verbatim from LightCycles' own achievements.tsx, where this exact row shape backed its entire
|
|
40
|
+
// "ALL ACHIEVEMENTS" list. Derives fg/fgMuted/its own section background from useAutoPaperTheme(),
|
|
41
|
+
// same as StatRow/StatSection/BaseStatsScreen — see BaseStatsScreen's own doc for why. Has no
|
|
42
|
+
// opinion on what an achievement actually IS (id, unlock predicate, catalog) — that stays entirely
|
|
43
|
+
// app-local; this is purely the presentational row.
|
|
44
|
+
export function AchievementRow({ icon, title, description, badgeColor, unlockedLabel, checkColor, progress, deviceMarker }: Props) {
|
|
45
|
+
const { dark } = useAutoPaperTheme()
|
|
46
|
+
const fg = dark ? '#FFFFFF' : '#000000'
|
|
47
|
+
const fgMuted = dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)'
|
|
48
|
+
const sectionBg = dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'
|
|
49
|
+
const unlocked = unlockedLabel !== undefined
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<View style={[styles.row, { backgroundColor: sectionBg }]}>
|
|
53
|
+
<View style={[styles.badge, { backgroundColor: badgeColor }]}>
|
|
54
|
+
<Icon source={icon} size={20} color={unlocked ? '#000000' : fgMuted} />
|
|
55
|
+
</View>
|
|
56
|
+
<View style={styles.text}>
|
|
57
|
+
<Text variant='bodyLarge' style={[styles.boldText, { color: fg }]}>
|
|
58
|
+
{title}
|
|
59
|
+
</Text>
|
|
60
|
+
<Text variant='bodySmall' style={{ color: fgMuted }}>
|
|
61
|
+
{description}
|
|
62
|
+
</Text>
|
|
63
|
+
{deviceMarker && (
|
|
64
|
+
<View style={styles.deviceMarker}>
|
|
65
|
+
<Icon source='earth' size={11} color={fgMuted} />
|
|
66
|
+
<Text variant='labelSmall' style={{ color: fgMuted }}>
|
|
67
|
+
Same for every profile
|
|
68
|
+
</Text>
|
|
69
|
+
</View>
|
|
70
|
+
)}
|
|
71
|
+
{!unlocked && progress !== undefined && <ProgressBar progress={progress} color={checkColor ?? badgeColor} style={styles.progressBar} />}
|
|
72
|
+
</View>
|
|
73
|
+
{unlocked ? (
|
|
74
|
+
<View style={styles.status}>
|
|
75
|
+
<Icon source='check-circle' size={20} color={checkColor ?? badgeColor} />
|
|
76
|
+
<Text variant='labelSmall' style={{ color: fgMuted }}>
|
|
77
|
+
{unlockedLabel}
|
|
78
|
+
</Text>
|
|
79
|
+
</View>
|
|
80
|
+
) : (
|
|
81
|
+
<Icon source='lock-outline' size={20} color={fgMuted} />
|
|
82
|
+
)}
|
|
83
|
+
</View>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const styles = StyleSheet.create({
|
|
88
|
+
badge: {
|
|
89
|
+
alignItems: 'center',
|
|
90
|
+
borderRadius: 10,
|
|
91
|
+
height: 36,
|
|
92
|
+
justifyContent: 'center',
|
|
93
|
+
width: 36
|
|
94
|
+
},
|
|
95
|
+
boldText: {
|
|
96
|
+
fontWeight: 'bold'
|
|
97
|
+
},
|
|
98
|
+
deviceMarker: {
|
|
99
|
+
alignItems: 'center',
|
|
100
|
+
flexDirection: 'row',
|
|
101
|
+
gap: 4
|
|
102
|
+
},
|
|
103
|
+
progressBar: {
|
|
104
|
+
borderRadius: 4,
|
|
105
|
+
height: 6,
|
|
106
|
+
marginTop: 6
|
|
107
|
+
},
|
|
108
|
+
row: {
|
|
109
|
+
alignItems: 'center',
|
|
110
|
+
borderRadius: 12,
|
|
111
|
+
flexDirection: 'row',
|
|
112
|
+
gap: 12,
|
|
113
|
+
padding: 12
|
|
114
|
+
},
|
|
115
|
+
status: {
|
|
116
|
+
alignItems: 'center',
|
|
117
|
+
gap: 2,
|
|
118
|
+
width: 64
|
|
119
|
+
},
|
|
120
|
+
text: {
|
|
121
|
+
flex: 1,
|
|
122
|
+
gap: 2
|
|
123
|
+
}
|
|
124
|
+
})
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
|
+
import { Button, IconButton } from '@rific/feedback-press'
|
|
3
|
+
import { ReactNode, useState } from 'react'
|
|
4
|
+
import { ScrollView, StyleSheet, View } from 'react-native'
|
|
5
|
+
import { Icon, Portal, Text } from 'react-native-paper'
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
// Live physical-hold rotation (see @tastic/split-screen's getViewRotation) — this dialog renders
|
|
9
|
+
// as a centered Portal modal, unaffected by whichever screen's own FakeLandscapeView wraps its
|
|
10
|
+
// trigger, so it has to rotate its own card content to read right-side-up for whichever way the
|
|
11
|
+
// phone is actually being held. Defaults to 0 for a caller that doesn't track one — matches
|
|
12
|
+
// BaseSettingsDialog's own identical prop, though the first apps consuming this screen shell
|
|
13
|
+
// don't wrap it in a FakeLandscapeView at all and so never have a nonzero one to give.
|
|
14
|
+
rotation?: number
|
|
15
|
+
title?: string
|
|
16
|
+
onBack: () => void
|
|
17
|
+
insets: { top: number; left: number; bottom: number; right: number }
|
|
18
|
+
// Optional, and always passed as a pair — a caller with nothing resettable (or that deliberately
|
|
19
|
+
// doesn't want a reset action on this screen) omits both rather than one alone. Confirmation copy
|
|
20
|
+
// is caller-owned since "erases all stats and achievements" is only accurate for a caller that
|
|
21
|
+
// actually tracks both; a caller with only high scores, say, would want its own wording.
|
|
22
|
+
onReset?: () => void
|
|
23
|
+
resetLabel?: string
|
|
24
|
+
resetConfirmTitle?: string
|
|
25
|
+
resetConfirmBody?: string
|
|
26
|
+
children: ReactNode
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The stats/achievements screen shell every @tastic game shares — back button + title header,
|
|
30
|
+
// scrollable content area, and an optional reset-everything action with its own confirm-before-
|
|
31
|
+
// destroying overlay — extracted after the same header/scroll/reset-confirm shape was independently
|
|
32
|
+
// built into LightCycles' own achievements.tsx. Derives its own colors from useAutoPaperTheme(),
|
|
33
|
+
// same as this package's own BaseSettingsDialog, rather than taking fg/bg/cardBg/etc as props — a
|
|
34
|
+
// stats screen is app-wide chrome, not a per-player surface, so there's no reason to make every
|
|
35
|
+
// caller re-derive and thread through the identical dark-mode formula BaseSettingsDialog already
|
|
36
|
+
// hardcodes internally. `children` is the seam for whatever isn't shared: this component has no
|
|
37
|
+
// opinion on what a "stat" is or how achievements are catalogued — see StatRow/StatSection/
|
|
38
|
+
// AchievementRow for the smaller presentational pieces built to go inside it.
|
|
39
|
+
export function BaseStatsScreen({ rotation = 0, title = 'Achievements', onBack, insets, onReset, resetLabel = 'Reset All Stats', resetConfirmTitle = 'Reset Everything?', resetConfirmBody = 'This permanently erases all stats and achievements. This cannot be undone.', children }: Props) {
|
|
40
|
+
const { dark, colors } = useAutoPaperTheme()
|
|
41
|
+
const [confirmResetVisible, setConfirmResetVisible] = useState(false)
|
|
42
|
+
const showReset = !!onReset
|
|
43
|
+
|
|
44
|
+
// High-contrast retro look: literal black/white by appearance, matching every other screen in
|
|
45
|
+
// this ecosystem (title/loadout/settings all use this identical formula) rather than auto-
|
|
46
|
+
// paper's own (slightly tinted) background role — same convention BaseSettingsDialog's own
|
|
47
|
+
// fg/cardBg/cardBorder use internally.
|
|
48
|
+
const fg = dark ? '#FFFFFF' : '#000000'
|
|
49
|
+
const bg = dark ? '#000000' : '#FFFFFF'
|
|
50
|
+
const cardBg = dark ? '#111111' : '#F2F2F2'
|
|
51
|
+
const cardBorder = dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<View style={[styles.container, { backgroundColor: bg }]}>
|
|
55
|
+
<View style={[styles.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }]}>
|
|
56
|
+
<IconButton icon='arrow-left' iconColor={fg} size={24} onPress={onBack} />
|
|
57
|
+
<Text variant='displaySmall' style={[styles.title, { color: fg }]}>
|
|
58
|
+
{title}
|
|
59
|
+
</Text>
|
|
60
|
+
</View>
|
|
61
|
+
|
|
62
|
+
<ScrollView style={styles.scrollView} contentContainerStyle={[styles.content, { paddingBottom: 32 + insets.bottom }]} showsVerticalScrollIndicator={false}>
|
|
63
|
+
{children}
|
|
64
|
+
|
|
65
|
+
{showReset && (
|
|
66
|
+
<Button mode='outlined' onPress={() => setConfirmResetVisible(true)} textColor={colors.secondary} style={styles.resetButton}>
|
|
67
|
+
{resetLabel}
|
|
68
|
+
</Button>
|
|
69
|
+
)}
|
|
70
|
+
</ScrollView>
|
|
71
|
+
|
|
72
|
+
{confirmResetVisible && (
|
|
73
|
+
<Portal>
|
|
74
|
+
<View style={[styles.overlay, rotation % 360 !== 0 && { transform: [{ rotate: `${rotation}deg` }] }]}>
|
|
75
|
+
<View style={[styles.overlayCard, { backgroundColor: cardBg, borderColor: cardBorder }]}>
|
|
76
|
+
<Icon source='alert-outline' size={64} color={colors.secondary} />
|
|
77
|
+
<Text variant='headlineLarge' style={[styles.overlayTitle, { color: colors.secondary }]}>
|
|
78
|
+
{resetConfirmTitle}
|
|
79
|
+
</Text>
|
|
80
|
+
<Text variant='bodyLarge' style={[styles.overlayBody, { color: fg }]}>
|
|
81
|
+
{resetConfirmBody}
|
|
82
|
+
</Text>
|
|
83
|
+
<Button mode='contained' onPress={() => setConfirmResetVisible(false)} style={styles.overlayButton}>
|
|
84
|
+
Cancel
|
|
85
|
+
</Button>
|
|
86
|
+
<Button
|
|
87
|
+
mode='contained'
|
|
88
|
+
onPress={() => {
|
|
89
|
+
onReset?.()
|
|
90
|
+
setConfirmResetVisible(false)
|
|
91
|
+
}}
|
|
92
|
+
style={styles.overlayButton}
|
|
93
|
+
buttonColor={colors.secondary}
|
|
94
|
+
textColor={colors.onSecondary}
|
|
95
|
+
>
|
|
96
|
+
Reset
|
|
97
|
+
</Button>
|
|
98
|
+
</View>
|
|
99
|
+
</View>
|
|
100
|
+
</Portal>
|
|
101
|
+
)}
|
|
102
|
+
</View>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const styles = StyleSheet.create({
|
|
107
|
+
container: {
|
|
108
|
+
flex: 1
|
|
109
|
+
},
|
|
110
|
+
content: {
|
|
111
|
+
gap: 12,
|
|
112
|
+
paddingHorizontal: 20
|
|
113
|
+
},
|
|
114
|
+
header: {
|
|
115
|
+
alignItems: 'center',
|
|
116
|
+
flexDirection: 'row',
|
|
117
|
+
gap: 4,
|
|
118
|
+
paddingBottom: 8
|
|
119
|
+
},
|
|
120
|
+
// Same overlay shape BaseSettingsDialog's own info overlay uses (backdrop + centered card),
|
|
121
|
+
// rotated in place the same way rather than via Portal-root transform for the same reason — see
|
|
122
|
+
// that component's own identical styles for the full rationale.
|
|
123
|
+
overlay: {
|
|
124
|
+
alignItems: 'center',
|
|
125
|
+
backgroundColor: 'rgba(0,0,0,0.72)',
|
|
126
|
+
bottom: 0,
|
|
127
|
+
justifyContent: 'center',
|
|
128
|
+
left: 0,
|
|
129
|
+
position: 'absolute',
|
|
130
|
+
right: 0,
|
|
131
|
+
top: 0
|
|
132
|
+
},
|
|
133
|
+
overlayBody: { textAlign: 'center' },
|
|
134
|
+
overlayButton: { width: 160 },
|
|
135
|
+
overlayCard: {
|
|
136
|
+
alignItems: 'center',
|
|
137
|
+
borderRadius: 20,
|
|
138
|
+
borderWidth: 1,
|
|
139
|
+
gap: 16,
|
|
140
|
+
maxWidth: 360,
|
|
141
|
+
padding: 32
|
|
142
|
+
},
|
|
143
|
+
overlayTitle: { fontWeight: 'bold', marginBottom: -8 },
|
|
144
|
+
resetButton: {
|
|
145
|
+
marginTop: 16
|
|
146
|
+
},
|
|
147
|
+
// Bounds the ScrollView to the space `container`'s flex:1 actually gives it — without this, a
|
|
148
|
+
// ScrollView with only a contentContainerStyle isn't reliably height-constrained on native (it
|
|
149
|
+
// can render at its full unclipped content height instead of the screen's), which leaves the
|
|
150
|
+
// header's back button touch target unreliable underneath it.
|
|
151
|
+
scrollView: {
|
|
152
|
+
flex: 1
|
|
153
|
+
},
|
|
154
|
+
title: {
|
|
155
|
+
flexShrink: 1,
|
|
156
|
+
fontWeight: 'bold'
|
|
157
|
+
}
|
|
158
|
+
})
|
|
@@ -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
|
+
})
|
package/src/StatRow.tsx
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
|
+
import { StyleSheet, View } from 'react-native'
|
|
3
|
+
import { Text } from 'react-native-paper'
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
label: string
|
|
7
|
+
value: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// A single labeled stat — label on the left (muted), value on the right (bold, full-contrast).
|
|
11
|
+
// Extracted from LightCycles' own achievements.tsx, where this exact row shape backed every
|
|
12
|
+
// section (Overall/Vs CPU/Two Player/Activity). Derives its own colors from useAutoPaperTheme(),
|
|
13
|
+
// same as BaseStatsScreen — see that component's own doc for why a stats-screen row doesn't take
|
|
14
|
+
// fg/fgMuted as props. Deliberately just label+value text, not a color swatch or profile chip —
|
|
15
|
+
// an app needing a row with a leading visual builds its own on top of this same dark-mode formula
|
|
16
|
+
// (see LightCycles' own ColorStatRow/ProfileRankingRow for an example), since what that visual
|
|
17
|
+
// even is varies per game.
|
|
18
|
+
export function StatRow({ label, value }: Props) {
|
|
19
|
+
const { dark } = useAutoPaperTheme()
|
|
20
|
+
const fg = dark ? '#FFFFFF' : '#000000'
|
|
21
|
+
const fgMuted = dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)'
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<View style={styles.row}>
|
|
25
|
+
<Text variant='bodyMedium' style={{ color: fgMuted }}>
|
|
26
|
+
{label}
|
|
27
|
+
</Text>
|
|
28
|
+
<Text variant='bodyMedium' style={[styles.value, { color: fg }]}>
|
|
29
|
+
{value}
|
|
30
|
+
</Text>
|
|
31
|
+
</View>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const styles = StyleSheet.create({
|
|
36
|
+
row: {
|
|
37
|
+
alignItems: 'center',
|
|
38
|
+
flexDirection: 'row',
|
|
39
|
+
justifyContent: 'space-between'
|
|
40
|
+
},
|
|
41
|
+
value: {
|
|
42
|
+
fontWeight: 'bold'
|
|
43
|
+
}
|
|
44
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
|
+
import { ReactNode } from 'react'
|
|
3
|
+
import { StyleSheet, View } from 'react-native'
|
|
4
|
+
import { Text } from 'react-native-paper'
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
label: string
|
|
8
|
+
children: ReactNode
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// A labeled, tinted-background grouping box — the "OVERALL"/"VS CPU"/"ACTIVITY"-style section
|
|
12
|
+
// shape from LightCycles' own achievements.tsx, generalized to any content (typically a stack of
|
|
13
|
+
// StatRow). Derives its own colors from useAutoPaperTheme(), same as StatRow/BaseStatsScreen — see
|
|
14
|
+
// BaseStatsScreen's own doc for why. The label itself carries the section's own meaning (what it's
|
|
15
|
+
// a section OF), so this component has no opinion on that beyond rendering the string it's given.
|
|
16
|
+
export function StatSection({ label, children }: Props) {
|
|
17
|
+
const { dark } = useAutoPaperTheme()
|
|
18
|
+
const fgMuted = dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)'
|
|
19
|
+
const sectionBg = dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<View style={[styles.section, { backgroundColor: sectionBg }]}>
|
|
23
|
+
<Text variant='labelMedium' style={[styles.label, { color: fgMuted }]}>
|
|
24
|
+
{label}
|
|
25
|
+
</Text>
|
|
26
|
+
{children}
|
|
27
|
+
</View>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const styles = StyleSheet.create({
|
|
32
|
+
label: {
|
|
33
|
+
letterSpacing: 2
|
|
34
|
+
},
|
|
35
|
+
section: {
|
|
36
|
+
borderRadius: 12,
|
|
37
|
+
gap: 8,
|
|
38
|
+
padding: 16
|
|
39
|
+
}
|
|
40
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
|
+
export { AchievementRow, LOCKED_BADGE_COLOR } from './AchievementRow'
|
|
1
2
|
export { BaseSettingsDialog, type BaseSettingsDialogProps } from './BaseSettingsDialog'
|
|
3
|
+
export { BaseStatsScreen } from './BaseStatsScreen'
|
|
4
|
+
export { CornerActionButtons } from './CornerActionButtons'
|
|
2
5
|
export { MONO_FONT } from './fonts'
|
|
3
6
|
export { InlineColorPicker } from './InlineColorPicker'
|
|
7
|
+
export { getLabeledDropdownContentHeight, LABELED_DROPDOWN_POPOVER_WIDTH, LabeledDropdown, type LabeledDropdownOption } from './LabeledDropdown'
|
|
4
8
|
export { loadSkiaWeb } from './loadSkiaWeb'
|
|
5
9
|
export { PopoverBody } from './PopoverBody'
|
|
6
10
|
export { PressAwayOverlay } from './PressAwayOverlay'
|
|
7
11
|
export { ReadyButton } from './ReadyButton'
|
|
8
12
|
export { type MenuOption, type MenuSection, type MultiSelectSection, SectionedDropdown, type SingleSelectSection } from './SectionedDropdown'
|
|
13
|
+
export { SharedActionBand } from './SharedActionBand'
|
|
14
|
+
export { StatRow } from './StatRow'
|
|
15
|
+
export { StatSection } from './StatSection'
|
|
9
16
|
// TriggerGauge itself (the raw Skia-based component) is deliberately NOT exported here, even
|
|
10
17
|
// though a native-only consumer could safely skip TriggerGaugeHost's Suspense/lazy-load
|
|
11
18
|
// indirection. A value re-export forces the bundler to fold TriggerGauge.tsx's own `Skia` import
|