@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,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
+ })