@tastic/hud 0.8.4 → 0.12.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.8.4",
3
+ "version": "0.12.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",
@@ -71,14 +71,17 @@
71
71
  "prettier": "@infinitetoken/eslint-config/prettier",
72
72
  "devDependencies": {
73
73
  "@infinitetoken/eslint-config": "^0.2.0",
74
- "@infinitetoken/jest-config": "^0.3.0",
75
- "@infinitetoken/tsconfig": "^0.4.1",
76
- "@rific/auto-paper": "^0.11.0",
77
- "@rific/core": "^0.1.1",
78
- "@rific/feedback-press": "^0.11.1",
74
+ "@infinitetoken/jest-config": "^0.4.0",
75
+ "@infinitetoken/tsconfig": "^0.5.0",
76
+ "@rific/auto-paper": "^0.12.0",
77
+ "@rific/core": "^0.2.0",
78
+ "@rific/feedback-press": "^0.12.0",
79
79
  "@rific/updater": "^0.6.0",
80
80
  "@shopify/react-native-skia": "^2.6.2",
81
- "@tastic/core": "^0.4.1",
81
+ "@tastic/achievements": "^0.2.0",
82
+ "@tastic/core": "^0.8.0",
83
+ "@tastic/profile": "^0.7.0",
84
+ "@tastic/split-screen": "^0.5.0",
82
85
  "@testing-library/dom": "^10.4.1",
83
86
  "@testing-library/react": "^16.3.2",
84
87
  "@types/jest": "^30.0.0",
@@ -94,6 +97,7 @@
94
97
  "react-native": "^0.85.3",
95
98
  "react-native-paper": "^5.15.2",
96
99
  "react-native-reanimated": "^4.5.1",
100
+ "react-native-safe-area-context": "^5.10.0",
97
101
  "tsup": "^8.0.0",
98
102
  "typescript": "^6.0.3"
99
103
  },
@@ -102,11 +106,15 @@
102
106
  "@rific/feedback-press": ">=0.10.0",
103
107
  "@rific/updater": ">=0.4.0",
104
108
  "@shopify/react-native-skia": ">=1.5.0",
109
+ "@tastic/achievements": ">=0.1.2",
105
110
  "@tastic/core": ">=0.3.0",
111
+ "@tastic/profile": ">=0.4.0",
112
+ "@tastic/split-screen": ">=0.5.0",
106
113
  "react": ">=19.0.0",
107
114
  "react-native": ">=0.76.0",
108
115
  "react-native-paper": ">=5.0.0",
109
- "react-native-reanimated": ">=3.0.0"
116
+ "react-native-reanimated": ">=3.0.0",
117
+ "react-native-safe-area-context": ">=5.0.0"
110
118
  },
111
119
  "publishConfig": {
112
120
  "access": "public"
@@ -0,0 +1,60 @@
1
+ import { useAutoPaperTheme } from '@rific/auto-paper'
2
+ import type { AchievementCatalogRow } from '@tastic/achievements'
3
+ import { Fragment } from 'react'
4
+ import { StyleSheet } from 'react-native'
5
+ import { Text } from 'react-native-paper'
6
+
7
+ import { AchievementRow, LOCKED_BADGE_COLOR } from './AchievementRow'
8
+ import { MONO_FONT } from './fonts'
9
+
10
+ // AchievementCatalogRow now comes straight from @tastic/achievements' own getAchievementCatalogRows
11
+ // (added in the same 2026-09-18 achievements-scaffolding pass this component belongs to) — no
12
+ // longer duplicated locally now that package has actually published the export (confirmed the two
13
+ // shapes match exactly before wiring this real, type-only peer dependency).
14
+
15
+ interface Props {
16
+ rows: AchievementCatalogRow[]
17
+ label?: string
18
+ // Independently optional overrides for this section's own heading/row colors - default to the
19
+ // same useAutoPaperTheme()-derived formula as AchievementRow/StatSection/StatRow/BaseStatsScreen
20
+ // (see BaseStatsScreen's own doc for why) when omitted, so every existing caller renders
21
+ // identically to before. Passed straight through to each <AchievementRow>.
22
+ fg?: string
23
+ fgMuted?: string
24
+ }
25
+
26
+ // The "ALL ACHIEVEMENTS" heading + one <AchievementRow> per row - extracted verbatim from every
27
+ // fleet app's own achievements.tsx (Snake, AirHockey, BoxHockey, Pong, LightCycles), where this
28
+ // exact heading/list pair backed the bottom of the stats screen. Returns a Fragment, not a View:
29
+ // every existing call site rendered the heading and each row as flat siblings inside
30
+ // BaseStatsScreen's own gap-spaced ScrollView content, and wrapping them in a container here would
31
+ // change that spacing (BaseStatsScreen's own `content` style already supplies the gap between
32
+ // every child, this component included).
33
+ //
34
+ // Resolves each row's own locked-vs-unlocked badgeColor (tierColor when unlocked,
35
+ // LOCKED_BADGE_COLOR when not) here rather than upstream in row computation, matching
36
+ // AchievementRow's own "badge color is a rendering decision" doc comment - @tastic/achievements'
37
+ // (pending) getAchievementCatalogRows deliberately leaves tierColor unresolved-to-badgeColor for
38
+ // the same reason.
39
+ export function AchievementCatalogSection({ rows, label = 'ALL ACHIEVEMENTS', fg, fgMuted: fgMutedOverride }: Props) {
40
+ const { dark } = useAutoPaperTheme()
41
+ const fgMuted = fgMutedOverride ?? (dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)')
42
+
43
+ return (
44
+ <Fragment>
45
+ <Text variant='labelMedium' style={[styles.label, { color: fgMuted, fontFamily: MONO_FONT }]}>
46
+ {label}
47
+ </Text>
48
+ {rows.map((row) => (
49
+ <AchievementRow key={row.id} icon={row.icon} title={row.title} description={row.description} badgeColor={row.unlockedAt !== undefined ? row.tierColor : LOCKED_BADGE_COLOR} checkColor={row.tierColor} unlockedLabel={row.unlockedLabel} progress={row.progress} deviceMarker={row.deviceMarker} fg={fg} fgMuted={fgMutedOverride} />
50
+ ))}
51
+ </Fragment>
52
+ )
53
+ }
54
+
55
+ const styles = StyleSheet.create({
56
+ label: {
57
+ letterSpacing: 2,
58
+ marginTop: 8
59
+ }
60
+ })
@@ -0,0 +1,29 @@
1
+ import { StatRow } from './StatRow'
2
+ import { StatSection } from './StatSection'
3
+
4
+ interface ActivityStats {
5
+ distinctDaysPlayed: number
6
+ currentDayStreak: number
7
+ bestDayStreak: number
8
+ }
9
+
10
+ interface Props {
11
+ stats: ActivityStats
12
+ }
13
+
14
+ // The "ACTIVITY" StatSection (Days Played / Day Streak / Best Day Streak) - extracted verbatim
15
+ // from every fleet app's own achievements.tsx (Snake, AirHockey, BoxHockey, Pong, LightCycles),
16
+ // where this exact three-row block was byte-for-byte identical. ActivityStats is a plain
17
+ // structural type (matching @tastic/achievements' own DayStreakState shape - distinctDaysPlayed/
18
+ // currentDayStreak/bestDayStreak) rather than importing DayStreakState itself, so this component
19
+ // has no dependency on @tastic/achievements at all: any caller's own stats/statsView object that
20
+ // happens to carry these three fields already satisfies it structurally.
21
+ export function ActivityStatSection({ stats }: Props) {
22
+ return (
23
+ <StatSection label='ACTIVITY'>
24
+ <StatRow label='Days Played' value={String(stats.distinctDaysPlayed)} />
25
+ <StatRow label='Day Streak' value={String(stats.currentDayStreak)} />
26
+ <StatRow label='Best Day Streak' value={String(stats.bestDayStreak)} />
27
+ </StatSection>
28
+ )
29
+ }
@@ -42,6 +42,7 @@ export function ConfirmDialog({ visible, title, message, confirmLabel, cancelLab
42
42
  const { fg, cardBg, cardBorder } = getOverlayCardColors(dark)
43
43
  const accent = destructive ? colors.danger : colors.primary
44
44
  const onAccent = destructive ? colors.onDanger : colors.onPrimary
45
+ const showCancel = cancelLabel !== undefined && onCancel !== undefined
45
46
 
46
47
  return (
47
48
  <Portal>
@@ -55,12 +56,12 @@ export function ConfirmDialog({ visible, title, message, confirmLabel, cancelLab
55
56
  {message}
56
57
  </Text>
57
58
  <View style={styles.actions}>
58
- {cancelLabel !== undefined && onCancel !== undefined && (
59
+ {showCancel && (
59
60
  <Button mode='outlined' onPress={onCancel} style={styles.actionButton}>
60
61
  {cancelLabel}
61
62
  </Button>
62
63
  )}
63
- <Button mode='contained' onPress={onConfirm} style={styles.actionButton} buttonColor={accent} textColor={onAccent}>
64
+ <Button mode='contained' onPress={onConfirm} style={showCancel ? styles.actionButton : styles.actionButtonSingle} buttonColor={accent} textColor={onAccent}>
64
65
  {confirmLabel}
65
66
  </Button>
66
67
  </View>
@@ -71,25 +72,43 @@ export function ConfirmDialog({ visible, title, message, confirmLabel, cancelLab
71
72
  }
72
73
 
73
74
  const styles = StyleSheet.create({
74
- // flexBasis (not flex: 1's own 0%) is each button's real floor: whenever both fit side by side,
75
- // flexGrow still splits any leftover width evenly beyond that floor, same visual result as the
76
- // old flex: 1 on a roomy screen - but a button can never be squeezed narrower than 128, only
77
- // wrap to its own row instead (see actions' flexWrap below). react-native-paper's Button
75
+ // minWidth (not flexBasis) is each button's real floor - whenever both fit side by side, flexGrow
76
+ // still splits any leftover width evenly beyond that floor, same visual result as a plain flex: 1
77
+ // on a roomy screen, but a button can never be squeezed narrower than 128, only wrap to its own
78
+ // row instead (see actions' flexWrap below). This used to be flexBasis: 128, which measured
79
+ // correctly on web but rendered every button roughly SQUARE on iOS - confirmed live (see the
80
+ // "Quit Match?" Cancel/Quit buttons and the single-button "OK" dialogs both coming out ~128pt
81
+ // tall instead of wide). Root cause: react-native-paper's Button renders its Surface as two
82
+ // nested views on iOS only (Surface.tsx's SurfaceIOS, for shadow rendering) - only a curated
83
+ // allowlist of style keys (flex/flexGrow/flexShrink/width/height/position/... - notably NOT
84
+ // flexBasis) reaches the outer view, which is the real flex item inside this row; every other
85
+ // key, flexBasis included, falls through to an inner view whose own unspecified flexDirection
86
+ // defaults to column. flexGrow: 1 landed on the (row-context) outer view and grew width
87
+ // correctly; flexBasis: 128 landed on the (column-context) inner view instead, where a flexBasis
88
+ // sets HEIGHT, not width. minWidth isn't axis-relative like flexBasis, so it constrains width
89
+ // correctly regardless of which of the two views it ends up on. react-native-paper's Button also
78
90
  // hardcodes numberOfLines={1} on its label with no override, so a button squeezed under its
79
91
  // label's natural width silently ellipsizes ("Cancel" -> "Can...") instead of wrapping - assumed
80
92
  // fixable by just giving the row more width, until confirmed live that these two-word labels
81
93
  // (Cancel/Reset/New Game) can still lose the truncation fight even with the extra room card's own
82
- // width fix below provides, on a narrow enough screen. flexBasis is the real fix: past a certain
83
- // width there's no side-by-side arrangement that fits both buttons at their natural label size,
84
- // so the layout has to give (stack) rather than the label (truncate).
94
+ // width fix below provides, on a narrow enough screen.
85
95
  actionButton: {
86
- flexBasis: 128,
87
- flexGrow: 1
96
+ flexGrow: 1,
97
+ minWidth: 128
98
+ },
99
+ // A lone confirm button (no cancel - BaseSettingsDialog's info-only "OK" dialogs, e.g. the
100
+ // update-check "no update found" message) has nothing left to share actions' width with, so
101
+ // actionButton's own flexGrow: 1 would stretch it across the entire row instead of reading as a
102
+ // normal button. Same 128 floor, no flexGrow, so it stays at that natural size instead of filling
103
+ // the card - centered by actions' own justifyContent below.
104
+ actionButtonSingle: {
105
+ minWidth: 128
88
106
  },
89
107
  actions: {
90
108
  flexDirection: 'row',
91
109
  flexWrap: 'wrap',
92
110
  gap: 12,
111
+ justifyContent: 'center',
93
112
  width: '100%'
94
113
  },
95
114
  body: {
@@ -2,6 +2,7 @@ import { useIsTouchPrimaryDevice } from '@tastic/core'
2
2
  import { Platform } from 'react-native'
3
3
 
4
4
  import { MenuOption, SectionedDropdown } from './SectionedDropdown'
5
+ import { AlignResult, PopoverRotation } from './useAutoAlign'
5
6
  import { PopoverHost } from './usePopoverHost'
6
7
 
7
8
  export interface ControlSchemePickerProps<T extends string | number> {
@@ -21,6 +22,14 @@ export interface ControlSchemePickerProps<T extends string | number> {
21
22
  onAccentColor?: string
22
23
  dark: boolean
23
24
  align?: 'left' | 'right' | 'center'
25
+ // Passed straight through to the underlying SectionedDropdown — see that component's own
26
+ // `alignOverride` doc. Lets a caller inside a @tastic/split-screen zone substitute
27
+ // useZoneClampedAlign's result for this picker's own popover placement.
28
+ alignOverride?: AlignResult
29
+ // Passed straight through to the underlying SectionedDropdown — see that component's own
30
+ // `rotation` doc. Defaults to a live ambient useRotation() read when omitted, same as
31
+ // SectionedDropdown itself.
32
+ rotation?: PopoverRotation
24
33
  labelFontFamily?: string
25
34
  }
26
35
 
@@ -45,9 +54,9 @@ export interface ControlSchemePickerProps<T extends string | number> {
45
54
  // no keys of their own (a "keep using pointer input" option, say) are purely a per-app label — so a
46
55
  // caller always supplies its own `options` (and typically its own `T`) rather than this component
47
56
  // importing `@tastic/input` and choosing a set for every consumer.
48
- export function ControlSchemePicker<T extends string | number>({ id, host, value, onChange, options, takenValue, accentColor, mutedColor, onAccentColor, dark, align, labelFontFamily }: ControlSchemePickerProps<T>) {
57
+ export function ControlSchemePicker<T extends string | number>({ id, host, value, onChange, options, takenValue, accentColor, mutedColor, onAccentColor, dark, align, alignOverride, rotation, labelFontFamily }: ControlSchemePickerProps<T>) {
49
58
  const isTouchPrimary = useIsTouchPrimaryDevice()
50
59
  if (Platform.OS !== 'web' || isTouchPrimary) return null
51
60
 
52
- return <SectionedDropdown id={id} host={host} icon='keyboard-outline' accessibilityLabel='Control scheme' sections={[{ kind: 'single', id: 'controls', options, value, onChange, takenValue }]} accentColor={accentColor} mutedColor={mutedColor} onAccentColor={onAccentColor} dark={dark} align={align} labelFontFamily={labelFontFamily} />
61
+ return <SectionedDropdown id={id} host={host} icon='keyboard-outline' accessibilityLabel='Control scheme' sections={[{ kind: 'single', id: 'controls', options, value, onChange, takenValue }]} accentColor={accentColor} mutedColor={mutedColor} onAccentColor={onAccentColor} dark={dark} align={align} alignOverride={alignOverride} rotation={rotation} labelFontFamily={labelFontFamily} />
53
62
  }
@@ -6,7 +6,7 @@ import { Icon, Text } from 'react-native-paper'
6
6
 
7
7
  import { MONO_FONT } from './fonts'
8
8
  import { PopoverBody } from './PopoverBody'
9
- import { PopoverRotation, useAutoAlign } from './useAutoAlign'
9
+ import { AlignResult, PopoverRotation, useAutoAlign } from './useAutoAlign'
10
10
  import { PopoverHost } from './usePopoverHost'
11
11
 
12
12
  // Presence check, not a full-string "is this exactly one emoji" validation — this only decides a
@@ -35,6 +35,26 @@ function widthForColumns(columns: number) {
35
35
  return SWATCHES_BORDER_WIDTH * 2 + SWATCHES_PADDING * 2 + SWATCH_SIZE * columns + SWATCHES_GAP * (columns - 1)
36
36
  }
37
37
 
38
+ // Exported so a caller that needs to independently compute this popover's own content size — to
39
+ // feed useZoneClampedAlign's contentWidth/contentHeight params with, ahead of producing a
40
+ // colorAlignOverride for a picker living inside a @tastic/split-screen zone — can match this
41
+ // component's real rendered size exactly, rather than re-deriving or hardcoding its internal
42
+ // column/row math. Same job as LabeledDropdown's own getLabeledDropdownContentHeight, but this
43
+ // picker's size also depends on window width (via the auto-column clamp below), so windowWidth is
44
+ // a required parameter here rather than an ambient useWindowDimensions() read — a caller computing
45
+ // this ahead of render (to decide alignment before the picker itself has even mounted) has no
46
+ // component instance to read that hook from, so it has to pass the value in instead. The component
47
+ // below calls this same function with its own live useWindowDimensions() read, so there is exactly
48
+ // one place this formula lives.
49
+ export function getInlineColorPickerContentSize(swatchCount: number, windowWidth: number, columns?: number): { width: number; height: number } {
50
+ const autoColumns = clamp(Math.floor((windowWidth - 2 * SCREEN_MARGIN + SWATCHES_GAP) / (SWATCH_SIZE + SWATCHES_GAP)), MIN_COLUMNS, MAX_COLUMNS)
51
+ const resolvedColumns = columns ?? autoColumns
52
+ const width = widthForColumns(resolvedColumns)
53
+ const rows = Math.ceil(swatchCount / resolvedColumns)
54
+ const height = SWATCHES_BORDER_WIDTH * 2 + SWATCHES_PADDING * 2 + SWATCH_SIZE * rows + SWATCHES_GAP * (rows - 1)
55
+ return { width, height }
56
+ }
57
+
38
58
  interface Props {
39
59
  id: string
40
60
  host: PopoverHost
@@ -59,9 +79,16 @@ interface Props {
59
79
  // tradeoff.
60
80
  allowSwapTaken?: boolean
61
81
  dark: boolean
62
- // Manual override — omit to let the popover measure its own trigger and pick whichever alignment
63
- // keeps it from overflowing the screen edge (see useAutoAlign).
82
+ // Forces a specific horizontal alignment instead of letting whichever placement result applies
83
+ // (see alignOverride below) decide.
64
84
  align?: 'left' | 'right' | 'center'
85
+ // Substitutes this package's own plain useAutoAlign-based placement wholesale (align,
86
+ // verticalAlign, maxHeight, measured, triggerRef) with a caller-supplied one — e.g.
87
+ // useZoneClampedAlign's result, for a picker living inside a @tastic/split-screen zone, where the
88
+ // plain window-relative decision can pick a direction that overflows into the shared row instead
89
+ // of the zone's own boundary. Same shape LabeledDropdown's/SectionedDropdown's own alignOverride
90
+ // takes; omit for the ordinary case (a popover with nothing but the screen edge to avoid).
91
+ alignOverride?: AlignResult
65
92
  // Explicit override for the rotation a @tastic/split-screen-style FakeLandscapeView (or
66
93
  // equivalent) ancestor is currently applying — see SectionedDropdown's identical prop for the
67
94
  // full reasoning. Defaults to a live ambient read via @tastic/core's useRotation() when omitted.
@@ -93,20 +120,20 @@ interface Props {
93
120
  // Renders inline rather than as a full-screen modal, scoped to its own panel — a modal color
94
121
  // picker would block the whole screen for one player while another can't touch their own panel at
95
122
  // the same time, which defeats the point of a split-screen lobby.
96
- export function InlineColorPicker({ id, host, value, onChange, previewValue, swatches = defaultColors, takenValue, allowSwapTaken, dark, align: alignOverride, rotation: rotationOverride, icon = 'palette', tag, labelFontFamily = MONO_FONT, autoDismiss = true, columns, size = DEFAULT_SIZE }: Props) {
123
+ export function InlineColorPicker({ id, host, value, onChange, previewValue, swatches = defaultColors, takenValue, allowSwapTaken, dark, align: forcedAlign, alignOverride, rotation: rotationOverride, icon = 'palette', tag, labelFontFamily = MONO_FONT, autoDismiss = true, columns, size = DEFAULT_SIZE }: Props) {
97
124
  const ambientRotation = useRotation()
98
125
  const rotation = rotationOverride ?? ambientRotation
99
126
  const menuBg = dark ? '#000000' : '#FFFFFF'
100
127
  const { width: windowWidth } = useWindowDimensions()
101
- const autoColumns = clamp(Math.floor((windowWidth - 2 * SCREEN_MARGIN + SWATCHES_GAP) / (SWATCH_SIZE + SWATCHES_GAP)), MIN_COLUMNS, MAX_COLUMNS)
102
- const resolvedColumns = columns ?? autoColumns
103
- const swatchesWidth = widthForColumns(resolvedColumns)
104
- const swatchRows = Math.ceil(swatches.length / resolvedColumns)
105
- const swatchesHeight = SWATCHES_BORDER_WIDTH * 2 + SWATCHES_PADDING * 2 + SWATCH_SIZE * swatchRows + SWATCHES_GAP * (swatchRows - 1)
128
+ const { width: swatchesWidth, height: swatchesHeight } = getInlineColorPickerContentSize(swatches.length, windowWidth, columns)
106
129
 
107
130
  const open = host.openId === id
108
- const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, swatchesWidth, swatchesHeight, rotation)
109
- const align = alignOverride ?? autoAlign
131
+ // Always called, even when alignOverride is supplied and this result goes unused — hooks can't be
132
+ // called conditionally. See LabeledDropdown's identical comment for the same tradeoff.
133
+ const auto = useAutoAlign(open, swatchesWidth, swatchesHeight, rotation)
134
+ const placement = alignOverride ?? auto
135
+ const { maxHeight, measured, triggerRef, verticalAlign } = placement
136
+ const align = forcedAlign ?? placement.align
110
137
  const displayFor = previewValue ?? ((hex: string) => hex)
111
138
  const displayValue = displayFor(value)
112
139
  const contrastColor = getContrastColor(displayValue)
@@ -1,11 +1,10 @@
1
1
  import { getContrastColor } from '@rific/auto-paper'
2
2
  import { TouchableRipple } from '@rific/feedback-press'
3
- import { RefObject } from 'react'
4
3
  import { ScrollView, StyleSheet, View } from 'react-native'
5
4
  import { Icon, Text } from 'react-native-paper'
6
5
 
7
6
  import { PopoverBody } from './PopoverBody'
8
- import { PopoverAlign, PopoverVerticalAlign, useAutoAlign } from './useAutoAlign'
7
+ import { AlignResult, useAutoAlign } from './useAutoAlign'
9
8
  import { PopoverHost } from './usePopoverHost'
10
9
 
11
10
  const TRIGGER_HEIGHT = 28
@@ -29,17 +28,6 @@ export interface LabeledDropdownOption<T extends string> {
29
28
  icon?: string
30
29
  }
31
30
 
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
31
  interface Props<T extends string> {
44
32
  id: string
45
33
  host: PopoverHost
@@ -74,10 +62,10 @@ export function LabeledDropdown<T extends string>({ id, host, options, value, on
74
62
  const selected = options.find((o) => o.value === value) ?? null
75
63
  const contentHeight = LIST_PADDING * 2 + options.length * ROW_HEIGHT
76
64
  // 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.
65
+ // called conditionally. A caller passing alignOverride (e.g. this package's own
66
+ // useZoneClampedAlign, which already wraps this same hook) does end up measuring the trigger
67
+ // twice; an acceptable tradeoff for keeping this component's own hook usage unconditional and
68
+ // override-shaped rather than needing a second, hook-free code path.
81
69
  const auto = useAutoAlign(open, POPOVER_WIDTH, contentHeight)
82
70
  const placement = alignOverride ?? auto
83
71
  const { maxHeight, measured, triggerRef, verticalAlign } = placement
@@ -0,0 +1,196 @@
1
+ import { SeedColor } from '@rific/auto-paper'
2
+ import { Profile, ProfilePicker } from '@tastic/profile'
3
+ import { ReactNode } from 'react'
4
+ import { StyleSheet, View } from 'react-native'
5
+ import { Text } from 'react-native-paper'
6
+
7
+ import { InlineColorPicker } from './InlineColorPicker'
8
+ import { LabeledDropdown, LabeledDropdownOption } from './LabeledDropdown'
9
+ import { ReadyButton } from './ReadyButton'
10
+ import { AlignResult, PopoverRotation } from './useAutoAlign'
11
+ import { PopoverHost, usePopoverHost } from './usePopoverHost'
12
+
13
+ // This panel's own internal color/CPU-difficulty popover ids, derived from idPrefix — exported so a
14
+ // caller that needs to know whether one of them is currently open (e.g. to compute the `open`
15
+ // argument useZoneClampedAlign's own first parameter needs, ahead of handing its result to
16
+ // colorAlignOverride/cpuDifficultyAlignOverride below) doesn't have to hardcode or guess this
17
+ // panel's own internal naming convention. Matches secondPickerId's own job for a caller's own
18
+ // externally-constructed picker — this is the equivalent for the two pickers this panel owns
19
+ // itself, which have no prop of their own to name their id with.
20
+ export function getColorPopoverId(idPrefix: string): string {
21
+ return `${idPrefix}-color`
22
+ }
23
+ export function getCpuDifficultyPopoverId(idPrefix: string): string {
24
+ return `${idPrefix}-difficulty`
25
+ }
26
+
27
+ export interface PlayerSetupPanelProps<C extends string = string> {
28
+ // Namespaces this panel's popover ids ('color', whatever secondPickerId names, 'difficulty') so
29
+ // two panels can safely share one host (see `host` below) without their ids colliding.
30
+ idPrefix: string
31
+ // Shared popover host, when this panel's own popovers should be mutually exclusive with another
32
+ // panel's or a shared-controls row's (e.g. vs-CPU: only one human is ever driving both slots, so
33
+ // having both YOU's and CPU's pickers open at once is just clutter, not a useful simultaneous-edit
34
+ // case). Omit to fall back to this panel's own independent host — the two-player case, where two
35
+ // real people editing at once is the point.
36
+ host?: PopoverHost
37
+ label?: string
38
+ // Applied only to the plain `label` fallback text below (ProfilePicker/LabeledDropdown render
39
+ // their own fixed chrome regardless of this prop) — omit to inherit whichever font the calling
40
+ // screen's own theme resolves for a labelSmall Text, rather than this package forcing its own
41
+ // MONO_FONT onto what's really the host app's own copy.
42
+ labelFontFamily?: string
43
+ color: string
44
+ onColorChange: (hex: string) => void
45
+ swatches: SeedColor[]
46
+ // The other player's current color — stays visible in the swatch grid but disabled, rather than
47
+ // removed from it entirely. Unless allowSwapTaken is set — see InlineColorPicker's own doc.
48
+ takenColor?: string
49
+ allowSwapTaken?: boolean
50
+ // Passed straight through to the internal InlineColorPicker's own `alignOverride` — this panel
51
+ // has no way for a caller to reach that picker directly (it's not a ReactNode slot the way
52
+ // secondPicker is, see that prop's own doc), so a caller inside a @tastic/split-screen zone
53
+ // needs this to substitute useZoneClampedAlign's result for the color picker's own popover.
54
+ colorAlignOverride?: AlignResult
55
+ // Passed straight through to the internal InlineColorPicker's own `rotation` — same reasoning as
56
+ // colorAlignOverride above (no other way for a caller to reach that picker directly), for a
57
+ // caller whose own rotation reading needs to differ from InlineColorPicker's default ambient
58
+ // useRotation() read (see that component's own `rotation` doc for when that's worth doing).
59
+ colorRotation?: PopoverRotation
60
+ isHuman: boolean
61
+ // Whatever sits beside the color picker in pickerRow, if anything — a ControlSchemePicker, a raw
62
+ // SectionedDropdown, or nothing at all. The five apps this component was extracted from each
63
+ // duplicated this entire panel and differed only in what (if anything) occupied this one slot,
64
+ // each wanting a genuinely different concept there (which physical input drives a seat, vs. a
65
+ // pull/push "feel" preference, vs. no such concept at all for a game with no per-seat setting to
66
+ // pick) — a ReactNode slot the caller renders its own picker into is what actually generalizes
67
+ // across all of them, rather than this component baking in one shape (e.g. ControlSchemePicker's
68
+ // own prop set) that most of its consumers don't need at all.
69
+ secondPicker?: ReactNode
70
+ // The popover id secondPicker opens under, if it opens one at all — lets pickerRow's own
71
+ // "elevate above later siblings while one of my own popovers is open" logic (see pickerRowOpen)
72
+ // react to secondPicker's popover the same way it already does for the color picker's, without
73
+ // this component needing any opinion on what secondPicker actually renders. Omit when secondPicker
74
+ // has no popover of its own, or isn't provided at all.
75
+ secondPickerId?: string
76
+ ready?: boolean
77
+ onToggleReady?: () => void
78
+ dark: boolean
79
+ // vs-CPU only has one human player, so some loadout screens render their Ready toggle standalone,
80
+ // centered below both slots, instead of embedded in this panel.
81
+ showReadyButton?: boolean
82
+ profiles?: Profile[]
83
+ selectedProfileId?: string | null
84
+ takenProfileId?: string | null
85
+ // What the name trigger shows before any profile's selected — this seat's own 'P1'/'P2' (see
86
+ // ProfilePicker's own doc for why it's never "Player"). Required whenever profiles is provided,
87
+ // since the trigger always needs some idle text.
88
+ guestLabel?: string
89
+ onProfileSelect?: (profile: Profile | null) => void
90
+ // Navigates to a profile-management screen — typically only ever wired up for one seat (a rotated
91
+ // second zone often can't host a working text keyboard).
92
+ onManageProfiles?: () => void
93
+ // Generic over the calling app's own CPU-difficulty union, matching LabeledDropdownOption<T>'s own
94
+ // genericness — this package has no opinion on what difficulties exist.
95
+ cpuDifficulty?: C
96
+ cpuDifficultyOptions?: LabeledDropdownOption<C>[]
97
+ onCpuDifficultyChange?: (value: C) => void
98
+ // Passed straight through to the internal LabeledDropdown's own `alignOverride` — same reasoning
99
+ // as colorAlignOverride above, for the CPU-difficulty picker instead of the color picker.
100
+ cpuDifficultyAlignOverride?: AlignResult
101
+ // InlineColorPicker's own `icon` for a human vs. a non-human seat (overridden by a selected
102
+ // profile's own `tag`, same precedence InlineColorPicker itself applies) — defaults match every
103
+ // consumer's own convention. A caller with its own extra CPU states (e.g. a "no CPU" slot) computes
104
+ // its own icon string and passes it through cpuIcon rather than this component needing any opinion
105
+ // on what those states are.
106
+ humanIcon?: string
107
+ cpuIcon?: string
108
+ }
109
+
110
+ // One panel per player slot in a local-multiplayer loadout/lobby screen: a name header (a saved
111
+ // profile switcher for a human seat, a CPU-difficulty picker for a non-human one, or a plain label
112
+ // when neither applies), a color picker optionally paired with a second, per-app picker (see
113
+ // `secondPicker`'s own doc), and a Ready toggle for human seats. Ported from five apps' own
114
+ // near-identical PlayerSetupPanel.tsx/LobbyPlayerPanel.tsx (AirHockey, BoxHockey, Pong, LightCycles,
115
+ // Snake), which had all independently converged on this exact skeleton.
116
+ //
117
+ // Owns its own popover host shared by its own color/profile/difficulty pickers unless a `host` is
118
+ // passed in to share with another panel (or a shared-controls row) instead — same convention as
119
+ // every popover-hosting component in this package.
120
+ export function PlayerSetupPanel<C extends string = string>({ idPrefix, host, label, labelFontFamily, color, onColorChange, swatches, takenColor, allowSwapTaken, colorAlignOverride, colorRotation, isHuman, secondPicker, secondPickerId, ready, onToggleReady, dark, showReadyButton = true, profiles, selectedProfileId, takenProfileId, guestLabel, onProfileSelect, onManageProfiles, cpuDifficulty, cpuDifficultyOptions, onCpuDifficultyChange, cpuDifficultyAlignOverride, humanIcon = 'face-man', cpuIcon = 'robot' }: PlayerSetupPanelProps<C>) {
121
+ const ownHost = usePopoverHost()
122
+ const popover = host ?? ownHost
123
+ // Checking *this panel's own* ids, not just "is anything open on the host" — when `host` is
124
+ // shared, another panel's (or a shared-controls row's) popovers also live on it, and elevating
125
+ // this panel for those too would tie it with whichever of them actually has the open popover,
126
+ // letting DOM order (wrongly) decide which one paints on top.
127
+ const ownPopoverOpen = popover.openId?.startsWith(`${idPrefix}-`) ?? false
128
+ // Narrower than ownPopoverOpen on purpose — only true for a popover actually inside pickerRow
129
+ // itself (color, or secondPicker's own popover when it has one and secondPickerId names it). See
130
+ // pickerRowOpen's own style comment for why pickerRow needs this in addition to panelOpen above.
131
+ const pickerRowPopoverOpen = popover.openId === getColorPopoverId(idPrefix) || (secondPickerId !== undefined && popover.openId === secondPickerId)
132
+ const showProfilePicker = isHuman && profiles !== undefined && !!onProfileSelect
133
+ const showCpuDifficulty = !isHuman && cpuDifficulty !== undefined && cpuDifficultyOptions !== undefined && !!onCpuDifficultyChange
134
+ // The color button's own tag display (see InlineColorPicker) — read live from the selected
135
+ // profile rather than snapshotted at selection time, since a caller typically has no other
136
+ // editing surface for it on this same screen.
137
+ const selectedProfile = profiles?.find((p) => p.id === selectedProfileId) ?? null
138
+
139
+ return (
140
+ <View style={[styles.panel, ownPopoverOpen && styles.panelOpen]}>
141
+ {showProfilePicker ? (
142
+ <ProfilePicker idPrefix={idPrefix} host={popover} profiles={profiles!} selectedId={selectedProfileId ?? null} takenId={takenProfileId} color={color} dark={dark} guestLabel={guestLabel ?? 'GUEST'} onSelect={(profile: Profile | null) => onProfileSelect?.(profile)} onManage={onManageProfiles} />
143
+ ) : showCpuDifficulty ? (
144
+ <LabeledDropdown id={getCpuDifficultyPopoverId(idPrefix)} host={popover} options={cpuDifficultyOptions!} value={cpuDifficulty!} onChange={onCpuDifficultyChange!} color={color} dark={dark} alignOverride={cpuDifficultyAlignOverride} />
145
+ ) : (
146
+ label && (
147
+ <Text variant='labelSmall' style={{ color, fontFamily: labelFontFamily }}>
148
+ {label}
149
+ </Text>
150
+ )
151
+ )}
152
+
153
+ <View style={[styles.pickerRow, pickerRowPopoverOpen && styles.pickerRowOpen]}>
154
+ {/* Human slots show the selected profile's own tag when it has one, falling back to
155
+ humanIcon; a non-human slot always falls back to cpuIcon — this is what actually
156
+ distinguishes "you" from "the CPU" now, not a fixed icon. */}
157
+ <InlineColorPicker id={getColorPopoverId(idPrefix)} host={popover} value={color} onChange={onColorChange} swatches={swatches} takenValue={takenColor} allowSwapTaken={allowSwapTaken} alignOverride={colorAlignOverride} rotation={colorRotation} dark={dark} tag={isHuman ? selectedProfile?.tag : undefined} icon={isHuman ? humanIcon : cpuIcon} />
158
+
159
+ {secondPicker}
160
+ </View>
161
+
162
+ {isHuman && showReadyButton && onToggleReady && <ReadyButton color={color} ready={ready ?? false} onToggleReady={onToggleReady} />}
163
+ </View>
164
+ )
165
+ }
166
+
167
+ const styles = StyleSheet.create({
168
+ panel: {
169
+ alignItems: 'center',
170
+ gap: 12
171
+ },
172
+ // See SectionedDropdown's anchorOpen comment — this panel is typically a sibling of another
173
+ // panel (and/or a shared-controls row), so a popover escaping this panel's bounds needs the panel
174
+ // itself elevated, not just the popover content, to paint above a later sibling.
175
+ panelOpen: {
176
+ zIndex: 100
177
+ },
178
+ // Same gap as `panel`'s own vertical rhythm, reused horizontally — the color picker and (when
179
+ // present) secondPicker sit side by side within this row instead of stacked in the panel's own
180
+ // column.
181
+ pickerRow: {
182
+ alignItems: 'center',
183
+ flexDirection: 'row',
184
+ gap: 12
185
+ },
186
+ // A popover is an absolutely-positioned sibling of its own trigger — so a tall one (the color
187
+ // swatch grid) can extend down far enough to overlap the ReadyButton below, which paints on top of
188
+ // it by default DOM order since it's a later sibling within `panel`. `panel`'s own panelOpen
189
+ // elevation only helps this panel paint above OTHER panels (see its own comment) — it does nothing
190
+ // for stacking *within* this panel, since React Native Web gives each View its own stacking
191
+ // context. This is what actually fixes that: elevate pickerRow itself above its own later sibling
192
+ // whenever one of its own popovers is open.
193
+ pickerRowOpen: {
194
+ zIndex: 100
195
+ }
196
+ })