@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.
- package/README.md +81 -0
- package/dist/index.d.mts +58 -0
- package/dist/index.d.ts +58 -0
- package/dist/index.js +515 -0
- package/dist/index.mjs +482 -0
- package/package.json +107 -0
- package/src/ProfileChip.tsx +60 -0
- package/src/ProfilePicker.tsx +275 -0
- package/src/ProfilesManager.tsx +417 -0
- package/src/index.ts +5 -0
- package/src/profilesValidation.ts +42 -0
- package/src/types.ts +16 -0
|
@@ -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
|
+
})
|