@tastic/profile 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,275 @@
1
+ import { getContrastColor } from '@rific/auto-paper'
2
+ import { TouchableRipple } from '@rific/feedback-press'
3
+ import { PopoverBody, PopoverHost, useAutoAlign } from '@tastic/hud'
4
+ import { useMemo } from 'react'
5
+ import { ScrollView, StyleSheet, View } from 'react-native'
6
+ import { Icon, Text } from 'react-native-paper'
7
+
8
+ import { ProfileChip } from './ProfileChip'
9
+ import { Profile } from './types'
10
+
11
+ const TRIGGER_HEIGHT = 28
12
+ const POPOVER_WIDTH = 220
13
+ const ROW_HEIGHT = 36
14
+ const LIST_PADDING = 8
15
+
16
+ interface ProfilePickerProps<P extends Profile> {
17
+ // Namespaces this trigger's id — pass something unique per instance whenever more than one of
18
+ // these shares a PopoverHost (e.g. one per seat).
19
+ idPrefix: string
20
+ // The host this trigger's popover opens against — share one host across sibling pickers (a
21
+ // seat's color/tag/profile triggers) so opening one closes any other in the same group.
22
+ host: PopoverHost
23
+ profiles: P[]
24
+ selectedId: string | null
25
+ // Another picker's currently-selected profile id, if any — disabled in this list. Useful when a
26
+ // profile shouldn't be selectable in two places at once (e.g. two seats in a local-multiplayer
27
+ // game, where double-selecting one profile would double-count its stats as both winner and
28
+ // loser) — omit if that doesn't apply.
29
+ takenId?: string | null
30
+ // Tint for the trigger's own label/icon — pass the seat/context's current live color so the
31
+ // trigger reads as one identity with whatever it's paired with.
32
+ color: string
33
+ dark: boolean
34
+ align?: 'left' | 'right' | 'center'
35
+ // Trigger's own idle-state text (uppercased here regardless of casing passed in) — shown until a
36
+ // profile is selected.
37
+ guestLabel: string
38
+ // The null-selection row's own label/icon — defaults to a generic anonymous-player row, but
39
+ // overridable for a caller where null means something else (e.g. an "All Profiles" combined view
40
+ // reusing this same component for its own picker).
41
+ nullLabel?: string
42
+ nullIcon?: string
43
+ onSelect: (profile: P | null) => void
44
+ // Renders a row for navigating to a dedicated create/rename/delete screen — omit to hide it
45
+ // entirely. This component never manages profiles itself (see ProfilesManager for that); this is
46
+ // selection-only.
47
+ onManage?: () => void
48
+ }
49
+
50
+ // Generic over P (an app's own Profile type, richer than this package's base Profile) so that a
51
+ // caller's own onSelect — typed to accept its own richer profile shape — type-checks against the
52
+ // objects this component actually hands back, which are always exactly whatever came in through
53
+ // `profiles` (see handleSelectExisting below). Without this, TypeScript would only ever let
54
+ // onSelect declare the base Profile type, since that's all a non-generic prop signature could
55
+ // promise it receives — even though at runtime it always receives the caller's own richer objects.
56
+ // ProfileChip doesn't need this same treatment: it only ever takes a profile in, never hands one
57
+ // back out, so plain structural covariance already lets a richer object through.
58
+ //
59
+ // A name trigger + selection popover: pick an existing profile, fall back to a null/"guest"
60
+ // selection, or (if onManage is passed) navigate to manage the roster. Creating, renaming, and
61
+ // deleting profiles all live outside this component (see ProfilesManager) — this is selection-only,
62
+ // both because that keeps this component simple enough to drop into a HUD trigger row, and because
63
+ // it keeps this popover from needing its own destructive-delete confirmation. Fully prop-driven —
64
+ // no internal profile-storage access of its own, so the host app stays the one place that reads
65
+ // its own persistence hook.
66
+ export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: alignOverride, guestLabel, nullLabel = 'Player', nullIcon = 'account-off-outline', onSelect, onManage }: ProfilePickerProps<P>) {
67
+ const id = `${idPrefix}-profile`
68
+ const menuBg = dark ? '#000000' : '#FFFFFF'
69
+ const fg = dark ? '#FFFFFF' : '#000000'
70
+
71
+ const open = host.openId === id
72
+ const selectedProfile = profiles.find((p) => p.id === selectedId) ?? null
73
+ // Alphabetical — this is the list that sees the most real use, so it's the one that benefits
74
+ // most from staying scannable once there are more than a handful of saved profiles.
75
+ const sortedProfiles = useMemo(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles])
76
+ // Null row + each saved profile + (if passed) the manage row.
77
+ const rowCount = profiles.length + 1 + (onManage ? 1 : 0)
78
+ const contentHeight = LIST_PADDING * 2 + rowCount * ROW_HEIGHT
79
+ const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, POPOVER_WIDTH, contentHeight)
80
+ const align = alignOverride ?? autoAlign
81
+
82
+ const handleSelectGuest = () => {
83
+ onSelect(null)
84
+ host.close()
85
+ }
86
+
87
+ const handleSelectExisting = (profile: P) => {
88
+ if (profile.id === takenId) return
89
+ onSelect(profile)
90
+ host.close()
91
+ }
92
+
93
+ const handleManage = () => {
94
+ host.close()
95
+ onManage?.()
96
+ }
97
+
98
+ return (
99
+ <View style={[styles.anchor, open && styles.anchorOpen]}>
100
+ <TouchableRipple onPress={() => host.toggle(id)} style={styles.trigger}>
101
+ <View ref={triggerRef} collapsable={false} style={styles.triggerInner}>
102
+ <Text style={[styles.triggerLabel, { color }]} numberOfLines={1}>
103
+ {(selectedProfile?.name ?? guestLabel).toUpperCase()}
104
+ </Text>
105
+ <Icon source='menu-down' size={16} color={color} />
106
+ </View>
107
+ </TouchableRipple>
108
+
109
+ {/* Gated on `measured`, not just `open` — see InlineColorPicker's identical fix. */}
110
+ <PopoverBody visible={open && measured} align={align} verticalAlign={verticalAlign} caretColor={menuBg} caretBorderColor={color} caretBorderWidth={2} triggerSize={TRIGGER_HEIGHT}>
111
+ <ScrollView
112
+ style={[
113
+ styles.menu,
114
+ {
115
+ backgroundColor: menuBg,
116
+ borderColor: color,
117
+ width: POPOVER_WIDTH,
118
+ maxHeight
119
+ }
120
+ ]}
121
+ contentContainerStyle={styles.menuContent}
122
+ showsVerticalScrollIndicator={false}
123
+ >
124
+ {/* Selected row's own background fills entirely with its identity color (this seat's own
125
+ accent for the null row, the profile's own saved color otherwise) — matching how a
126
+ selected row reads elsewhere (see @tastic/hud's SectionedDropdown) rather than a checkmark
127
+ bolted onto an otherwise-plain row. */}
128
+ <TouchableRipple onPress={handleSelectGuest} style={[styles.row, selectedId === null && { backgroundColor: color }]}>
129
+ <View style={styles.rowInner}>
130
+ {/* Unfilled, matching a selected profile row's own ProfileChip treatment — no
131
+ background circle, but still the same fixed-size slot every row's icon/chip sits in
132
+ (see ProfileChip's own doc), so this row's icon stays lined up with every other row
133
+ regardless of selection. */}
134
+ <View style={styles.guestChip}>
135
+ <Icon source={nullIcon} size={13} color={selectedId === null ? getContrastColor(color) : fg} />
136
+ </View>
137
+ <Text style={[styles.rowLabel, { color: selectedId === null ? getContrastColor(color) : fg }]}>{nullLabel}</Text>
138
+ </View>
139
+ </TouchableRipple>
140
+
141
+ {sortedProfiles.map((profile) => {
142
+ const taken = profile.id === takenId
143
+ const selected = selectedId === profile.id
144
+ return (
145
+ <TouchableRipple key={profile.id} disabled={taken} onPress={() => handleSelectExisting(profile)} style={[styles.row, selected && { backgroundColor: profile.color }]}>
146
+ <View style={[styles.rowInner, taken && styles.rowDisabled]}>
147
+ {/* Unfilled on a selected row — its own background already fills with the
148
+ profile's color, so a second circle behind the tag would just be a redundant badge
149
+ where every other selected row in the app has plain icon+text instead. */}
150
+ <ProfileChip profile={profile} filled={!selected} />
151
+ <Text
152
+ style={[
153
+ styles.rowLabel,
154
+ {
155
+ color: selected ? getContrastColor(profile.color) : fg
156
+ }
157
+ ]}
158
+ numberOfLines={1}
159
+ >
160
+ {profile.name}
161
+ </Text>
162
+ </View>
163
+ </TouchableRipple>
164
+ )
165
+ })}
166
+
167
+ {onManage && (
168
+ <>
169
+ <View style={[styles.divider, { backgroundColor: color }]} />
170
+ {/* Tertiary — a navigation action, not another selectable identity in the list above
171
+ it, so it reads more like @tastic/hud's SectionedDropdown All/Clear footer button
172
+ (centered, bold, accent-colored) than a normal row. */}
173
+ <TouchableRipple onPress={handleManage} style={styles.row}>
174
+ <View style={styles.manageRowInner}>
175
+ <Icon source='account-cog-outline' size={16} color={color} />
176
+ <Text style={[styles.manageLabel, { color }]}>Manage</Text>
177
+ </View>
178
+ </TouchableRipple>
179
+ </>
180
+ )}
181
+ </ScrollView>
182
+ </PopoverBody>
183
+ </View>
184
+ )
185
+ }
186
+
187
+ const styles = StyleSheet.create({
188
+ anchor: {
189
+ position: 'relative'
190
+ },
191
+ // See InlineColorPicker's identical comment — React Native Web gives every position:'relative'
192
+ // view its own stacking context, so the elevation has to live on the anchor itself.
193
+ anchorOpen: {
194
+ zIndex: 100
195
+ },
196
+ // Separates Manage (a navigation action) from the selectable rows above it — different kind of
197
+ // action, not just another row in the same list. Tinted with the seat's own color (passed in as
198
+ // backgroundColor) rather than a flat gray, matching the border/caret's own accent-tinted look.
199
+ divider: {
200
+ height: 1,
201
+ marginVertical: 4,
202
+ opacity: 0.25
203
+ },
204
+ // Matches ProfileChip's own default size/shape exactly (see this row's own render-site comment)
205
+ // — no backgroundColor, unlike ProfileChip's own default: this row has no identity color of its
206
+ // own to fill it with, and the render site now colors the icon directly instead.
207
+ guestChip: {
208
+ alignItems: 'center',
209
+ borderRadius: 11,
210
+ height: 22,
211
+ justifyContent: 'center',
212
+ width: 22
213
+ },
214
+ manageLabel: {
215
+ fontSize: 13,
216
+ fontWeight: '700',
217
+ letterSpacing: 0.5
218
+ },
219
+ // Centered rather than left-aligned like a normal row — see this row's own render-site comment
220
+ // for why (a footer action, not another selectable identity).
221
+ manageRowInner: {
222
+ alignItems: 'center',
223
+ flexDirection: 'row',
224
+ gap: 6,
225
+ height: '100%',
226
+ justifyContent: 'center'
227
+ },
228
+ menu: {
229
+ borderRadius: 12,
230
+ borderWidth: 2,
231
+ elevation: 8,
232
+ shadowColor: '#000000',
233
+ shadowOffset: { height: 2, width: 0 },
234
+ shadowOpacity: 0.3,
235
+ shadowRadius: 8
236
+ },
237
+ // Padded on every side (not just top/bottom) — a selected row's own full-color background (see
238
+ // `row`'s borderRadius) needs real margin from the menu's own straight edges to read as a rounded
239
+ // pill instead of looking clipped against them.
240
+ menuContent: {
241
+ padding: LIST_PADDING
242
+ },
243
+ row: {
244
+ borderRadius: 8,
245
+ height: ROW_HEIGHT
246
+ },
247
+ rowDisabled: {
248
+ opacity: 0.35
249
+ },
250
+ rowInner: {
251
+ alignItems: 'center',
252
+ flexDirection: 'row',
253
+ flex: 1,
254
+ gap: 8,
255
+ paddingHorizontal: 12
256
+ },
257
+ rowLabel: {
258
+ flex: 1,
259
+ fontSize: 14
260
+ },
261
+ trigger: {
262
+ alignSelf: 'flex-start'
263
+ },
264
+ triggerInner: {
265
+ alignItems: 'center',
266
+ flexDirection: 'row',
267
+ gap: 2,
268
+ height: TRIGGER_HEIGHT
269
+ },
270
+ triggerLabel: {
271
+ fontSize: 13,
272
+ fontWeight: '700',
273
+ letterSpacing: 1
274
+ }
275
+ })