@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.
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/dist/TriggerGauge-HIPRGEWJ.mjs +117 -0
- package/dist/index.d.mts +127 -0
- package/dist/index.d.ts +127 -0
- package/dist/index.js +649 -0
- package/dist/index.mjs +475 -0
- package/package.json +103 -0
- package/src/InlineColorPicker.tsx +183 -0
- package/src/PopoverBody.tsx +131 -0
- package/src/PressAwayOverlay.tsx +20 -0
- package/src/ReadyButton.tsx +43 -0
- package/src/SectionedDropdown.tsx +387 -0
- package/src/TriggerGauge.tsx +285 -0
- package/src/TriggerGaugeHost.tsx +27 -0
- package/src/fonts.ts +6 -0
- package/src/index.ts +18 -0
- package/src/loadSkiaWeb.ts +14 -0
- package/src/useAutoAlign.ts +88 -0
- package/src/usePopoverHost.ts +24 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { defaultColors, getContrastColor, SeedColor } from '@rific/auto-paper'
|
|
2
|
+
import { TouchableRipple } from '@rific/feedback-press'
|
|
3
|
+
import { ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'
|
|
4
|
+
import { Icon } from 'react-native-paper'
|
|
5
|
+
|
|
6
|
+
import { PopoverBody } from './PopoverBody'
|
|
7
|
+
import { useAutoAlign } from './useAutoAlign'
|
|
8
|
+
import { PopoverHost } from './usePopoverHost'
|
|
9
|
+
|
|
10
|
+
const SIZE = 48
|
|
11
|
+
const SWATCH_SIZE = 28
|
|
12
|
+
const SWATCHES_PADDING = 8
|
|
13
|
+
const SWATCHES_GAP = 6
|
|
14
|
+
const SWATCHES_BORDER_WIDTH = 2
|
|
15
|
+
// However wide the actual screen is, the grid never goes narrower than 3 columns or wider than 6 —
|
|
16
|
+
// below 3 it reads as a cramped single-file list, above 6 the swatches themselves get lost in a
|
|
17
|
+
// wall of tiny color chips instead of reading as a deliberate palette.
|
|
18
|
+
const MIN_COLUMNS = 3
|
|
19
|
+
const MAX_COLUMNS = 6
|
|
20
|
+
// How much screen width the grid leaves alone on either side when computing how many columns fit —
|
|
21
|
+
// this trigger can sit anywhere (including flush against a player's own screen edge in split-screen
|
|
22
|
+
// mode), so the budget is against the whole window, not this trigger's own local space.
|
|
23
|
+
const SCREEN_MARGIN = 40
|
|
24
|
+
|
|
25
|
+
function widthForColumns(columns: number) {
|
|
26
|
+
return SWATCHES_BORDER_WIDTH * 2 + SWATCHES_PADDING * 2 + SWATCH_SIZE * columns + SWATCHES_GAP * (columns - 1)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface Props {
|
|
30
|
+
id: string
|
|
31
|
+
host: PopoverHost
|
|
32
|
+
value: string
|
|
33
|
+
onChange: (hex: string) => void
|
|
34
|
+
swatches?: SeedColor[]
|
|
35
|
+
// The other player's current color, if any — stays visible in the grid (so the full palette
|
|
36
|
+
// reads consistently for both players) but renders disabled with an X, rather than being
|
|
37
|
+
// silently removed from the list. Unless allowSwapTaken is set — see that prop.
|
|
38
|
+
takenValue?: string
|
|
39
|
+
// Lets a tap on the taken swatch swap the two colors (this picker takes it, the other slot takes
|
|
40
|
+
// this picker's old color) instead of being disabled — useful when the "other" slot is CPU-
|
|
41
|
+
// controlled, not a second real person's choice to step on. Leave unset when the other slot is a
|
|
42
|
+
// human player: swapping their color out from under them without their input isn't the same
|
|
43
|
+
// tradeoff.
|
|
44
|
+
allowSwapTaken?: boolean
|
|
45
|
+
dark: boolean
|
|
46
|
+
// Manual override — omit to let the popover measure its own trigger and pick whichever alignment
|
|
47
|
+
// keeps it from overflowing the screen edge (see useAutoAlign).
|
|
48
|
+
align?: 'left' | 'right' | 'center'
|
|
49
|
+
// Trigger glyph — defaults to a plain palette. Pass a distinct icon per slot (e.g. a face for a
|
|
50
|
+
// human, a robot for CPU) to convey identity through the icon itself.
|
|
51
|
+
icon?: string
|
|
52
|
+
// Whether picking a swatch closes the popover. Defaults to true — a color pick is a single,
|
|
53
|
+
// complete choice the same way a single-select dropdown row is (see SectionedDropdown's identical
|
|
54
|
+
// prop), so press-away isn't the only way out, but doesn't have to be either.
|
|
55
|
+
autoDismiss?: boolean
|
|
56
|
+
// Overrides the auto-computed column count (see MIN_COLUMNS/MAX_COLUMNS/SCREEN_MARGIN above).
|
|
57
|
+
columns?: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Renders inline rather than as a full-screen modal, scoped to its own panel — a modal color
|
|
61
|
+
// picker would block the whole screen for one player while another can't touch their own panel at
|
|
62
|
+
// the same time, which defeats the point of a split-screen lobby.
|
|
63
|
+
export function InlineColorPicker({ id, host, value, onChange, swatches = defaultColors, takenValue, allowSwapTaken, dark, align: alignOverride, icon = 'palette', autoDismiss = true, columns }: Props) {
|
|
64
|
+
const menuBg = dark ? '#000000' : '#FFFFFF'
|
|
65
|
+
const { width: windowWidth } = useWindowDimensions()
|
|
66
|
+
const autoColumns = Math.min(MAX_COLUMNS, Math.max(MIN_COLUMNS, Math.floor((windowWidth - 2 * SCREEN_MARGIN + SWATCHES_GAP) / (SWATCH_SIZE + SWATCHES_GAP))))
|
|
67
|
+
const resolvedColumns = columns ?? autoColumns
|
|
68
|
+
const swatchesWidth = widthForColumns(resolvedColumns)
|
|
69
|
+
const swatchRows = Math.ceil(swatches.length / resolvedColumns)
|
|
70
|
+
const swatchesHeight = SWATCHES_BORDER_WIDTH * 2 + SWATCHES_PADDING * 2 + SWATCH_SIZE * swatchRows + SWATCHES_GAP * (swatchRows - 1)
|
|
71
|
+
|
|
72
|
+
const open = host.openId === id
|
|
73
|
+
const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, swatchesWidth, swatchesHeight)
|
|
74
|
+
const align = alignOverride ?? autoAlign
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<View style={[styles.anchor, open && styles.anchorOpen]}>
|
|
78
|
+
<TouchableRipple onPress={() => host.toggle(id)} borderless style={[styles.trigger, { backgroundColor: value }]}>
|
|
79
|
+
<View ref={triggerRef} collapsable={false} style={styles.triggerMeasure}>
|
|
80
|
+
<Icon source={icon} size={20} color={getContrastColor(value)} />
|
|
81
|
+
</View>
|
|
82
|
+
</TouchableRipple>
|
|
83
|
+
|
|
84
|
+
{/* Gated on `measured`, not just `open` — see useAutoAlign's own comment and
|
|
85
|
+
SectionedDropdown's identical fix: without it, the popover mounts for one frame at a stale or
|
|
86
|
+
guessed alignment and visibly jumps once this open's own measurement lands. */}
|
|
87
|
+
<PopoverBody visible={open && measured} align={align} verticalAlign={verticalAlign} caretColor={menuBg} caretBorderColor={value} caretBorderWidth={SWATCHES_BORDER_WIDTH} triggerSize={SIZE}>
|
|
88
|
+
{/* Border matches this trigger's own current color (not a neutral gray) — two triggers can
|
|
89
|
+
sit close together, so the popover needs a clear visual tie back to which one opened it,
|
|
90
|
+
not just its screen position. maxHeight (see useAutoAlign) only actually clamps on a screen
|
|
91
|
+
short enough that the full grid can't fit above or below the trigger either way — a short
|
|
92
|
+
landscape screen with the trigger row near the top, same case SectionedDropdown's own
|
|
93
|
+
ScrollView exists for. */}
|
|
94
|
+
<ScrollView style={[styles.swatches, { backgroundColor: menuBg, borderColor: value, width: swatchesWidth, maxHeight }]} contentContainerStyle={styles.swatchesContent} showsVerticalScrollIndicator={false}>
|
|
95
|
+
{swatches.map((swatch) => {
|
|
96
|
+
const selected = swatch.value.toLowerCase() === value.toLowerCase()
|
|
97
|
+
const taken = !selected && !!takenValue && swatch.value.toLowerCase() === takenValue.toLowerCase()
|
|
98
|
+
const swappable = taken && allowSwapTaken
|
|
99
|
+
return (
|
|
100
|
+
<TouchableRipple
|
|
101
|
+
key={swatch.value}
|
|
102
|
+
disabled={taken && !swappable}
|
|
103
|
+
onPress={() => {
|
|
104
|
+
onChange(swatch.value)
|
|
105
|
+
if (autoDismiss) host.close()
|
|
106
|
+
}}
|
|
107
|
+
borderless
|
|
108
|
+
style={[styles.swatch, { backgroundColor: swatch.value }, selected && styles.swatchSelected, taken && !swappable && styles.swatchTaken]}
|
|
109
|
+
>
|
|
110
|
+
{selected ? <Icon source='check' size={16} color={getContrastColor(swatch.value)} /> : swappable ? <Icon source='swap-horizontal' size={16} color={getContrastColor(swatch.value)} /> : taken ? <Icon source='close' size={16} color={getContrastColor(swatch.value)} /> : <View />}
|
|
111
|
+
</TouchableRipple>
|
|
112
|
+
)
|
|
113
|
+
})}
|
|
114
|
+
</ScrollView>
|
|
115
|
+
</PopoverBody>
|
|
116
|
+
</View>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const styles = StyleSheet.create({
|
|
121
|
+
anchor: {
|
|
122
|
+
position: 'relative'
|
|
123
|
+
},
|
|
124
|
+
// See SectionedDropdown's identical comment — React Native Web gives every position:'relative' view
|
|
125
|
+
// its own stacking context, so the elevation has to live on the anchor itself, not just the
|
|
126
|
+
// popover content nested inside it, to correctly paint above this anchor's own later siblings.
|
|
127
|
+
anchorOpen: {
|
|
128
|
+
zIndex: 100
|
|
129
|
+
},
|
|
130
|
+
swatch: {
|
|
131
|
+
alignItems: 'center',
|
|
132
|
+
borderRadius: SWATCH_SIZE / 2,
|
|
133
|
+
height: SWATCH_SIZE,
|
|
134
|
+
justifyContent: 'center',
|
|
135
|
+
width: SWATCH_SIZE
|
|
136
|
+
},
|
|
137
|
+
swatchSelected: {
|
|
138
|
+
borderColor: '#ffffff',
|
|
139
|
+
borderWidth: 2
|
|
140
|
+
},
|
|
141
|
+
swatchTaken: {
|
|
142
|
+
opacity: 0.35
|
|
143
|
+
},
|
|
144
|
+
swatches: {
|
|
145
|
+
borderRadius: 12,
|
|
146
|
+
borderWidth: SWATCHES_BORDER_WIDTH,
|
|
147
|
+
elevation: 8,
|
|
148
|
+
shadowColor: '#000000',
|
|
149
|
+
shadowOffset: { height: 2, width: 0 },
|
|
150
|
+
shadowOpacity: 0.3,
|
|
151
|
+
shadowRadius: 8
|
|
152
|
+
// width is set inline above — border-box sizing means it has to include the border too
|
|
153
|
+
// (2*SWATCHES_BORDER_WIDTH), not just padding+content, or exactly enough room goes missing that
|
|
154
|
+
// the last column silently wraps to a new row. Anything wider than the exact sum just shows as
|
|
155
|
+
// dead space on the right edge of each row instead.
|
|
156
|
+
},
|
|
157
|
+
// Separate from `swatches` itself — see SectionedDropdown's identical menu/menuContent split.
|
|
158
|
+
// The scroll frame's own bounds (including maxHeight) live on the ScrollView; the actual grid
|
|
159
|
+
// wrapping belongs to its contentContainerStyle, which sizes to the unclamped content instead.
|
|
160
|
+
swatchesContent: {
|
|
161
|
+
flexDirection: 'row',
|
|
162
|
+
flexWrap: 'wrap',
|
|
163
|
+
gap: SWATCHES_GAP,
|
|
164
|
+
padding: SWATCHES_PADDING
|
|
165
|
+
},
|
|
166
|
+
trigger: {
|
|
167
|
+
alignItems: 'center',
|
|
168
|
+
alignSelf: 'flex-start',
|
|
169
|
+
borderRadius: SIZE / 2,
|
|
170
|
+
height: SIZE,
|
|
171
|
+
justifyContent: 'center',
|
|
172
|
+
width: SIZE
|
|
173
|
+
},
|
|
174
|
+
// Wraps just the icon, inside the TouchableRipple, purely so useAutoAlign has a plain View to
|
|
175
|
+
// attach its measurement ref to — TouchableRipple itself doesn't forward a ref to a measurable
|
|
176
|
+
// native node.
|
|
177
|
+
triggerMeasure: {
|
|
178
|
+
alignItems: 'center',
|
|
179
|
+
height: '100%',
|
|
180
|
+
justifyContent: 'center',
|
|
181
|
+
width: '100%'
|
|
182
|
+
}
|
|
183
|
+
})
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { ReactNode } from 'react'
|
|
2
|
+
import { StyleSheet, View, ViewStyle } from 'react-native'
|
|
3
|
+
|
|
4
|
+
const CARET_SIZE = 7
|
|
5
|
+
// How far the fill triangle's base sits past the border triangle's own base, producing the ring of
|
|
6
|
+
// caretBorderColor around it — kept fixed regardless of caretBorderWidth; tuned by eye.
|
|
7
|
+
const CARET_RING_OFFSET = 2
|
|
8
|
+
// How far both triangles dip past the box's own edge (top edge when below the trigger, bottom edge
|
|
9
|
+
// when above it — see verticalAlign). The caret renders after (on top of) the box, so this overlap
|
|
10
|
+
// lets it paint over the box's own border there instead of butting up against it — a seam shows if
|
|
11
|
+
// the two edges only touch, since neither is guaranteed to land on the exact same subpixel.
|
|
12
|
+
const CARET_DIP = 1
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
visible: boolean
|
|
16
|
+
children: ReactNode
|
|
17
|
+
// Which edge of the trigger the popover aligns to — 'center' (default) centers the popover
|
|
18
|
+
// under/over its trigger regardless of either one's width; 'left'/'right' instead align that
|
|
19
|
+
// edge flush with the trigger's matching edge, growing away from it. A trigger sitting right at
|
|
20
|
+
// the screen's edge (e.g. the rightmost of a row of icons, or a right-side player's panel) needs
|
|
21
|
+
// an explicit 'left' or 'right' — centering alone can still overflow there — to guarantee which
|
|
22
|
+
// direction it grows.
|
|
23
|
+
align?: 'left' | 'right' | 'center'
|
|
24
|
+
// Whether the popover opens below the trigger (default — the usual case) or above it. Flips both
|
|
25
|
+
// the content's own position and the caret's direction, so it still reads as pointing back at the
|
|
26
|
+
// trigger either way. Meant for a trigger that doesn't have enough room below it to fit — a short
|
|
27
|
+
// landscape screen with the trigger row near the top is the case this actually shows up in.
|
|
28
|
+
verticalAlign?: 'below' | 'above'
|
|
29
|
+
// Small triangle pointing back at the trigger, reinforcing the visual link beyond just position
|
|
30
|
+
// (two triggers can sit close together). Omit both to skip it. caretBorderColor defaults to
|
|
31
|
+
// caretColor (a solid, borderless caret) when only caretColor is given.
|
|
32
|
+
caretColor?: string
|
|
33
|
+
caretBorderColor?: string
|
|
34
|
+
// Must match the popover box's own borderWidth — a fixed ring thickness would only happen to
|
|
35
|
+
// match whichever box first used it, and look like a mismatched outline on any other consumer.
|
|
36
|
+
caretBorderWidth?: number
|
|
37
|
+
// The trigger's own width, in px — required whenever a caret is shown, so the caret can be pinned
|
|
38
|
+
// to the trigger's actual center via a fixed offset rather than the (usually much wider) content
|
|
39
|
+
// box's own midpoint, which only coincides with the trigger's center by coincidence, if at all.
|
|
40
|
+
triggerSize?: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Rendered inline as an absolutely-positioned sibling of its own trigger (never via Portal), so it
|
|
44
|
+
// inherits any rotation transform the trigger's zone applies — a Portal-based popover renders at
|
|
45
|
+
// the app root, outside that transform, and would end up upside-down relative to its own trigger
|
|
46
|
+
// once a face-to-face zone gets flipped 180°. Positioned via percentage (`top:'100%'`/`bottom:'100%'`)
|
|
47
|
+
// against the trigger's own wrapper rather than a measured pixel rect, so no onLayout/measurement
|
|
48
|
+
// plumbing is needed here and it resolves correctly regardless of how deep the trigger sits in the
|
|
49
|
+
// tree — the caller (useAutoAlign) is the one that actually measures, to decide which side to grow
|
|
50
|
+
// toward in the first place.
|
|
51
|
+
export function PopoverBody({ visible, children, align = 'center', verticalAlign = 'below', caretColor, caretBorderColor, caretBorderWidth = 1, triggerSize = 0 }: Props) {
|
|
52
|
+
if (!visible) return null
|
|
53
|
+
|
|
54
|
+
const above = verticalAlign === 'above'
|
|
55
|
+
const alignStyle = align === 'left' ? styles.contentLeft : align === 'right' ? styles.contentRight : styles.contentCenter
|
|
56
|
+
const verticalStyle = above ? styles.contentAbove : styles.contentBelow
|
|
57
|
+
const ringSize = CARET_SIZE + caretBorderWidth
|
|
58
|
+
|
|
59
|
+
// The caret is a width:0 box whose rendered footprint is purely its (symmetric) borders, so
|
|
60
|
+
// pinning its *center* at some offset means placing its own `left`/`right` half a footprint short
|
|
61
|
+
// of that offset. Every align pins one edge of `content` flush with the matching edge of the
|
|
62
|
+
// trigger-sized anchor (contentCenter stretches left:0/right:0 to do this too, rather than
|
|
63
|
+
// centering via a percentage `transform` — those need the child's own auto-resolved width fed
|
|
64
|
+
// back into the transform, which native has been unreliable about; plain flexbox centering
|
|
65
|
+
// doesn't), so triggerSize/2 from that shared edge always lands on the trigger's true center,
|
|
66
|
+
// however much wider the actual popover box grows.
|
|
67
|
+
const caretOffset = (halfFootprint: number): ViewStyle => (align === 'right' ? { right: triggerSize / 2 - halfFootprint } : { left: triggerSize / 2 - halfFootprint })
|
|
68
|
+
// Below the trigger: caret sits at content's top edge, apex pointing up (a filled border-bottom
|
|
69
|
+
// makes an apex-up triangle), dipping upward past that edge. Above the trigger: mirrored — caret
|
|
70
|
+
// sits at content's bottom edge, apex pointing down (filled border-top), dipping downward past it.
|
|
71
|
+
const caretFill = above ? 'borderTopColor' : 'borderBottomColor'
|
|
72
|
+
const caretWidthProp = above ? 'borderTopWidth' : 'borderBottomWidth'
|
|
73
|
+
const caretEdge = (size: number): ViewStyle => (above ? { bottom: -size + CARET_DIP } : { top: -size + CARET_DIP })
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<View style={[styles.content, alignStyle, verticalStyle]}>
|
|
77
|
+
{children}
|
|
78
|
+
{/* Painted after (on top of) the box above, dipping into its edge — see CARET_DIP — so it
|
|
79
|
+
reads as one continuous outline flowing from the trigger into the box, not a separate chip
|
|
80
|
+
butted up against it. */}
|
|
81
|
+
{caretColor && (
|
|
82
|
+
<>
|
|
83
|
+
<View style={[styles.caret, caretOffset(ringSize), caretEdge(ringSize), { [caretFill]: caretBorderColor ?? caretColor, [caretWidthProp]: ringSize, borderLeftWidth: ringSize, borderRightWidth: ringSize }]} />
|
|
84
|
+
<View style={[styles.caret, caretOffset(CARET_SIZE), caretEdge(CARET_SIZE - CARET_RING_OFFSET), { [caretFill]: caretColor, [caretWidthProp]: CARET_SIZE, borderLeftWidth: CARET_SIZE, borderRightWidth: CARET_SIZE }]} />
|
|
85
|
+
</>
|
|
86
|
+
)}
|
|
87
|
+
</View>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const styles = StyleSheet.create({
|
|
92
|
+
caret: {
|
|
93
|
+
borderLeftColor: 'transparent',
|
|
94
|
+
borderRightColor: 'transparent',
|
|
95
|
+
height: 0,
|
|
96
|
+
position: 'absolute',
|
|
97
|
+
width: 0,
|
|
98
|
+
// Explicit, not left to DOM order — the box it dips into is itself a position:'relative' view,
|
|
99
|
+
// which React Native Web always gives an implicit zIndex:0 (see PopoverBody's stacking-context
|
|
100
|
+
// notes elsewhere in this codebase), so painting on top needs a real zIndex to beat that
|
|
101
|
+
// reliably rather than relying on being the later sibling.
|
|
102
|
+
zIndex: 1
|
|
103
|
+
},
|
|
104
|
+
content: {
|
|
105
|
+
position: 'absolute',
|
|
106
|
+
zIndex: 50
|
|
107
|
+
},
|
|
108
|
+
contentAbove: {
|
|
109
|
+
bottom: '100%',
|
|
110
|
+
marginBottom: 8
|
|
111
|
+
},
|
|
112
|
+
contentBelow: {
|
|
113
|
+
marginTop: 8,
|
|
114
|
+
top: '100%'
|
|
115
|
+
},
|
|
116
|
+
// Stretched to the anchor's own (trigger) width, with alignItems centering the actual (usually
|
|
117
|
+
// much wider) popover box inside via plain flexbox — not `left:'50%'` plus a percentage
|
|
118
|
+
// `transform`, which depends on native resolving the box's own auto width before applying the
|
|
119
|
+
// transform and has proven unreliable there, visibly shifting the box off-center.
|
|
120
|
+
contentCenter: {
|
|
121
|
+
alignItems: 'center',
|
|
122
|
+
left: 0,
|
|
123
|
+
right: 0
|
|
124
|
+
},
|
|
125
|
+
contentLeft: {
|
|
126
|
+
left: 0
|
|
127
|
+
},
|
|
128
|
+
contentRight: {
|
|
129
|
+
right: 0
|
|
130
|
+
}
|
|
131
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Pressable, StyleProp, StyleSheet, ViewStyle } from 'react-native'
|
|
2
|
+
|
|
3
|
+
interface Props {
|
|
4
|
+
active: boolean
|
|
5
|
+
onPress: () => void
|
|
6
|
+
// Defaults to filling the parent entirely — pass a narrower rect (e.g. just one player's half of
|
|
7
|
+
// the screen) to scope how far "away" reaches for that player's own popovers.
|
|
8
|
+
style?: StyleProp<ViewStyle>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Invisible full-bleed tap catcher for "press away to close." Render one per popover host (or per
|
|
12
|
+
// player's own screen zone — see the package README for the split-screen wiring pattern), as an
|
|
13
|
+
// early sibling of that zone's real content: plain paint order then keeps every real control
|
|
14
|
+
// directly tappable (later siblings paint on top of this), while a tap on genuinely empty space
|
|
15
|
+
// falls through to this and closes whatever's open. Unmounts entirely rather than always rendering
|
|
16
|
+
// an inert Pressable, so an idle zone never sits in the way of anything for no reason.
|
|
17
|
+
export function PressAwayOverlay({ active, onPress, style }: Props) {
|
|
18
|
+
if (!active) return null
|
|
19
|
+
return <Pressable accessibilityLabel='Dismiss' accessibilityRole='button' onPress={onPress} style={[StyleSheet.absoluteFill, style]} />
|
|
20
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { TouchableRipple } from '@rific/feedback-press'
|
|
2
|
+
import { StyleProp, StyleSheet, ViewStyle } from 'react-native'
|
|
3
|
+
import { Text } from 'react-native-paper'
|
|
4
|
+
|
|
5
|
+
import { MONO_FONT } from './fonts'
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
color: string
|
|
9
|
+
ready: boolean
|
|
10
|
+
onToggleReady: () => void
|
|
11
|
+
// Lets a caller fade/hide a standalone Ready button (e.g. while a popover is open elsewhere on
|
|
12
|
+
// screen, so it doesn't visually collide with the popover's own positioning) without reflowing
|
|
13
|
+
// layout around it — pass `{ opacity: 0, pointerEvents: 'none' }` rather than conditionally
|
|
14
|
+
// unmounting.
|
|
15
|
+
style?: StyleProp<ViewStyle>
|
|
16
|
+
// Defaults to the system monospace font — pass your own game's registered font to match its UI.
|
|
17
|
+
labelFontFamily?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Standalone toggle so it can render either embedded in a per-player panel or centered on its own
|
|
21
|
+
// below a shared row — e.g. a vs-CPU mode with only one human player needs just one Ready toggle,
|
|
22
|
+
// not one tucked under each slot.
|
|
23
|
+
export function ReadyButton({ color, ready, onToggleReady, style, labelFontFamily = MONO_FONT }: Props) {
|
|
24
|
+
return (
|
|
25
|
+
<TouchableRipple onPress={onToggleReady} borderless style={[styles.readyButton, { borderColor: color }, ready && { backgroundColor: color }, style]}>
|
|
26
|
+
<Text variant='labelLarge' style={[{ fontFamily: labelFontFamily, fontWeight: 'bold' }, { color: ready ? '#000000' : color }]}>
|
|
27
|
+
{ready ? 'READY ✓' : 'READY?'}
|
|
28
|
+
</Text>
|
|
29
|
+
</TouchableRipple>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const styles = StyleSheet.create({
|
|
34
|
+
readyButton: {
|
|
35
|
+
alignItems: 'center',
|
|
36
|
+
borderRadius: 12,
|
|
37
|
+
borderWidth: 1,
|
|
38
|
+
justifyContent: 'center',
|
|
39
|
+
minWidth: 120,
|
|
40
|
+
paddingHorizontal: 16,
|
|
41
|
+
paddingVertical: 10
|
|
42
|
+
}
|
|
43
|
+
})
|