@tastic/hud 0.2.0 → 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.
@@ -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/index.ts CHANGED
@@ -1,11 +1,14 @@
1
1
  export { BaseSettingsDialog, type BaseSettingsDialogProps } from './BaseSettingsDialog'
2
+ export { CornerActionButtons } from './CornerActionButtons'
2
3
  export { MONO_FONT } from './fonts'
3
4
  export { InlineColorPicker } from './InlineColorPicker'
5
+ export { getLabeledDropdownContentHeight, LABELED_DROPDOWN_POPOVER_WIDTH, LabeledDropdown, type LabeledDropdownOption } from './LabeledDropdown'
4
6
  export { loadSkiaWeb } from './loadSkiaWeb'
5
7
  export { PopoverBody } from './PopoverBody'
6
8
  export { PressAwayOverlay } from './PressAwayOverlay'
7
9
  export { ReadyButton } from './ReadyButton'
8
10
  export { type MenuOption, type MenuSection, type MultiSelectSection, SectionedDropdown, type SingleSelectSection } from './SectionedDropdown'
11
+ export { SharedActionBand } from './SharedActionBand'
9
12
  // TriggerGauge itself (the raw Skia-based component) is deliberately NOT exported here, even
10
13
  // though a native-only consumer could safely skip TriggerGaugeHost's Suspense/lazy-load
11
14
  // indirection. A value re-export forces the bundler to fold TriggerGauge.tsx's own `Skia` import