@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,417 @@
|
|
|
1
|
+
import { useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
|
+
import { TouchableRipple } from '@rific/feedback-press'
|
|
3
|
+
import { useFocusChain } from '@rific/focus-chain'
|
|
4
|
+
import { useToast } from '@rific/toaster'
|
|
5
|
+
import { InlineColorPicker, PopoverHost, usePopoverHost } from '@tastic/hud'
|
|
6
|
+
import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
7
|
+
import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, View } from 'react-native'
|
|
8
|
+
import { Button, Icon, Portal, Text, TextInput } from 'react-native-paper'
|
|
9
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
10
|
+
|
|
11
|
+
import { ProfileChip } from './ProfileChip'
|
|
12
|
+
import { isValidTag, MAX_PROFILE_NAME_LENGTH } from './profilesValidation'
|
|
13
|
+
import { Profile } from './types'
|
|
14
|
+
|
|
15
|
+
// Shared by the New Profile row's own dashed circle, every profile row's own ProfileChip, the edit
|
|
16
|
+
// row's color trigger, and its tag field — one size for every circle on this screen, rather than
|
|
17
|
+
// each picking its own.
|
|
18
|
+
const CHIP_SIZE = 40
|
|
19
|
+
|
|
20
|
+
interface ProfileEditPatch {
|
|
21
|
+
name: string
|
|
22
|
+
color: string
|
|
23
|
+
tag: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ProfilesManagerProps {
|
|
27
|
+
profiles: Profile[]
|
|
28
|
+
// The color a brand-new profile's draft starts on — this component doesn't have (and shouldn't
|
|
29
|
+
// invent) an opinion about the host app's own palette, so this is required rather than defaulted.
|
|
30
|
+
defaultColor: string
|
|
31
|
+
onCreate: (input: ProfileEditPatch) => void
|
|
32
|
+
onSave: (id: string, patch: ProfileEditPatch) => void
|
|
33
|
+
// Irreversible — this only fires after this component's own confirmation card, not on the first
|
|
34
|
+
// tap. If the host app tracks anything else keyed by profile id (stats, history), clear that on
|
|
35
|
+
// its own side when this fires.
|
|
36
|
+
onDelete: (id: string) => void
|
|
37
|
+
// Rendered inline, before the "Profiles" title, in this component's own header row — a plain
|
|
38
|
+
// rendering slot, not an onBack-style callback: this component doesn't know or care that it's a
|
|
39
|
+
// back button, only that the host wants something shown there. Omit to hide the slot.
|
|
40
|
+
headerLeft?: ReactNode
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type Row = { kind: 'profile'; profile: Profile } | { kind: 'new' }
|
|
44
|
+
|
|
45
|
+
// First letter of the name, uppercased — the tag's own default for as long as it hasn't been typed
|
|
46
|
+
// into directly (see the tagManuallySet state below). Empty name -> empty tag, same as typing
|
|
47
|
+
// nothing into the tag field yourself would leave it.
|
|
48
|
+
function deriveTag(name: string): string {
|
|
49
|
+
const trimmed = name.trim()
|
|
50
|
+
return trimmed ? trimmed[0].toUpperCase() : ''
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The profile roster itself — create/rename/recolor/retag/delete, editing inline right in the row
|
|
54
|
+
// you tapped — with no navigation of its own: just data in (`profiles`) and callbacks out, plus
|
|
55
|
+
// this file's own internal edit-draft state. Deliberately router-agnostic (an `onBack`-style prop
|
|
56
|
+
// would still assume *some* host owns navigation; this component doesn't even go that far), so any
|
|
57
|
+
// consuming screen just renders it as its body inside its own routed wrapper (back button, native
|
|
58
|
+
// push/pop transition, whatever persistence hook backs `profiles`/`onCreate`/`onSave`/`onDelete`).
|
|
59
|
+
export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft }: ProfilesManagerProps) {
|
|
60
|
+
const { dark, colors } = useAutoPaperTheme()
|
|
61
|
+
const insets = useSafeAreaInsets()
|
|
62
|
+
const { error: showErrorToast } = useToast()
|
|
63
|
+
// Only one row is ever in edit mode at a time (see commitEdit, called before any new row opens),
|
|
64
|
+
// so every EditRow instance can safely share this one host instead of each needing its own.
|
|
65
|
+
const host = usePopoverHost()
|
|
66
|
+
const fg = dark ? '#FFFFFF' : '#000000'
|
|
67
|
+
const fgMuted = dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)'
|
|
68
|
+
const cardBg = dark ? '#111111' : '#F2F2F2'
|
|
69
|
+
const cardBorder = dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'
|
|
70
|
+
// A contained neutral, not an outlined one — reads as "the other button" next to Delete's solid
|
|
71
|
+
// danger fill, rather than looking unfinished/secondary beside it.
|
|
72
|
+
const cancelBg = dark ? '#2A2A2A' : '#E0E0E0'
|
|
73
|
+
const cancelFg = dark ? '#FFFFFF' : '#000000'
|
|
74
|
+
|
|
75
|
+
// 'new' clears back to null once a commit resolves — there's only ever one row open at a time.
|
|
76
|
+
const [editingId, setEditingId] = useState<string | 'new' | null>(null)
|
|
77
|
+
const [draftName, setDraftName] = useState('')
|
|
78
|
+
const [draftColor, setDraftColor] = useState(defaultColor)
|
|
79
|
+
const [draftTag, setDraftTag] = useState('')
|
|
80
|
+
// Whether the tag has been typed into directly this session — once true, editing the name stops
|
|
81
|
+
// overwriting it (see deriveTag's own doc). Starts true for an existing profile that already has
|
|
82
|
+
// a tag (don't clobber a deliberate choice just because the name changed), false otherwise — a
|
|
83
|
+
// brand new profile, or one that's never had a tag set at all.
|
|
84
|
+
const [tagManuallySet, setTagManuallySet] = useState(false)
|
|
85
|
+
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
|
|
86
|
+
|
|
87
|
+
// Commits whichever row is currently open, using its own latest draft state, then closes it —
|
|
88
|
+
// always closes, even when there's nothing valid to save (an empty name just discards the draft
|
|
89
|
+
// rather than leaving the row stuck open). This is the only way a row ever closes: submitting the
|
|
90
|
+
// name field (see EditRow's own focus-chain wiring — tag submits to name, name submits this),
|
|
91
|
+
// switching to a different row (see startEdit, which calls this before opening a new one), or
|
|
92
|
+
// leaving the screen entirely (see the unmount effect below) — no explicit save/cancel buttons,
|
|
93
|
+
// just scoped to the whole row instead of one field, since there's more than one field to touch.
|
|
94
|
+
const commitEdit = useCallback(() => {
|
|
95
|
+
const name = draftName.trim()
|
|
96
|
+
if (editingId && name) {
|
|
97
|
+
const tagValid = isValidTag(draftTag)
|
|
98
|
+
// Surfaced here, not just the field's own live `error` outline — that outline disappears the
|
|
99
|
+
// moment this row closes, so without this a still-invalid tag would just silently vanish
|
|
100
|
+
// (saved as empty) with no explanation of why it didn't stick.
|
|
101
|
+
if (draftTag && !tagValid) showErrorToast('Invalid tag', 'Use up to 3 letters or a single emoji')
|
|
102
|
+
const tag = tagValid ? draftTag : ''
|
|
103
|
+
if (editingId === 'new') onCreate({ color: draftColor, name, tag })
|
|
104
|
+
else onSave(editingId, { color: draftColor, name, tag })
|
|
105
|
+
}
|
|
106
|
+
setEditingId(null)
|
|
107
|
+
}, [editingId, draftName, draftColor, draftTag, onCreate, onSave, showErrorToast])
|
|
108
|
+
|
|
109
|
+
// Flushes whatever's currently being edited if this whole screen goes away mid-edit — a hardware
|
|
110
|
+
// back gesture or swipe-back can bypass a host screen's own back button (which should already
|
|
111
|
+
// call commitEdit before navigating away) but still unmounts this component the same as any other
|
|
112
|
+
// exit does. Reads the always-current commitEdit through a ref rather than depending on it
|
|
113
|
+
// directly in the effect — one that re-ran (and re-cleaned-up) on every keystroke would commit on
|
|
114
|
+
// every keystroke too, not just on a genuine unmount.
|
|
115
|
+
const commitEditRef = useRef(commitEdit)
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
commitEditRef.current = commitEdit
|
|
118
|
+
})
|
|
119
|
+
useEffect(() => () => commitEditRef.current(), [])
|
|
120
|
+
|
|
121
|
+
const startEdit = useCallback(
|
|
122
|
+
(target: 'new' | Profile) => {
|
|
123
|
+
commitEdit()
|
|
124
|
+
if (target === 'new') {
|
|
125
|
+
setDraftName('')
|
|
126
|
+
setDraftColor(defaultColor)
|
|
127
|
+
setDraftTag('')
|
|
128
|
+
setTagManuallySet(false)
|
|
129
|
+
} else {
|
|
130
|
+
setDraftName(target.name)
|
|
131
|
+
setDraftColor(target.color)
|
|
132
|
+
setDraftTag(target.tag)
|
|
133
|
+
setTagManuallySet(target.tag !== '')
|
|
134
|
+
}
|
|
135
|
+
setEditingId(target === 'new' ? 'new' : target.id)
|
|
136
|
+
},
|
|
137
|
+
[commitEdit, defaultColor]
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
const handleNameChange = useCallback(
|
|
141
|
+
(name: string) => {
|
|
142
|
+
setDraftName(name)
|
|
143
|
+
if (!tagManuallySet) setDraftTag(deriveTag(name))
|
|
144
|
+
},
|
|
145
|
+
[tagManuallySet]
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
const handleTagChange = useCallback((tag: string) => {
|
|
149
|
+
setDraftTag(tag)
|
|
150
|
+
setTagManuallySet(true)
|
|
151
|
+
}, [])
|
|
152
|
+
|
|
153
|
+
const confirmDeleteProfile = profiles.find((p) => p.id === confirmDeleteId) ?? null
|
|
154
|
+
const handleConfirmDelete = useCallback(() => {
|
|
155
|
+
if (confirmDeleteId) {
|
|
156
|
+
onDelete(confirmDeleteId)
|
|
157
|
+
if (editingId === confirmDeleteId) setEditingId(null)
|
|
158
|
+
}
|
|
159
|
+
setConfirmDeleteId(null)
|
|
160
|
+
}, [confirmDeleteId, editingId, onDelete])
|
|
161
|
+
|
|
162
|
+
// Alphabetical — this list has no other natural order (creation order isn't meaningful to look
|
|
163
|
+
// something up by), and it's the one that actually stays usable once there are more than a
|
|
164
|
+
// handful of profiles on one shared device.
|
|
165
|
+
const sortedProfiles = useMemo(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles])
|
|
166
|
+
const rows: Row[] = [...sortedProfiles.map((profile): Row => ({ kind: 'profile', profile })), { kind: 'new' }]
|
|
167
|
+
const tagInvalid = draftTag.length > 0 && !isValidTag(draftTag)
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<>
|
|
171
|
+
<View style={[styles.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }]}>
|
|
172
|
+
{headerLeft}
|
|
173
|
+
<Text variant='displaySmall' style={[styles.title, { color: fg }]}>
|
|
174
|
+
Profiles
|
|
175
|
+
</Text>
|
|
176
|
+
</View>
|
|
177
|
+
|
|
178
|
+
{/* This screen has no keyboard-avoidance of its own on a screen this tall, so this is what
|
|
179
|
+
actually keeps a focused row's own fields scrollable into view above the keyboard instead of
|
|
180
|
+
stuck behind it. keyboardShouldPersistTaps='handled' on the ScrollView below is what lets
|
|
181
|
+
tapping a *different* row (e.g. a color swatch, or another profile entirely) register on the
|
|
182
|
+
first tap while a field elsewhere is still focused, instead of just dismissing the keyboard. */}
|
|
183
|
+
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.keyboardAvoiding}>
|
|
184
|
+
<ScrollView style={styles.scrollView} contentContainerStyle={[styles.content, { paddingBottom: 32 + insets.bottom }]} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps='handled'>
|
|
185
|
+
{rows.map((row) => {
|
|
186
|
+
if (row.kind === 'new') {
|
|
187
|
+
if (editingId !== 'new') {
|
|
188
|
+
return (
|
|
189
|
+
<TouchableRipple key='new' onPress={() => startEdit('new')} style={styles.row}>
|
|
190
|
+
<View style={styles.rowInner}>
|
|
191
|
+
<View style={[styles.rowChip, styles.newChip, { borderColor: fg }]}>
|
|
192
|
+
<Icon source='plus' size={20} color={fg} />
|
|
193
|
+
</View>
|
|
194
|
+
<Text variant='bodyLarge' style={{ color: fg }}>
|
|
195
|
+
New Profile
|
|
196
|
+
</Text>
|
|
197
|
+
</View>
|
|
198
|
+
</TouchableRipple>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
return <EditRow key='new' draftName={draftName} onNameChange={handleNameChange} draftColor={draftColor} onColorChange={setDraftColor} draftTag={draftTag} onTagChange={handleTagChange} tagInvalid={tagInvalid} onSubmit={commitEdit} autoFocus fgMuted={fgMuted} host={host} dark={dark} />
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const { profile } = row
|
|
205
|
+
if (editingId === profile.id) {
|
|
206
|
+
return <EditRow key={profile.id} draftName={draftName} onNameChange={handleNameChange} draftColor={draftColor} onColorChange={setDraftColor} draftTag={draftTag} onTagChange={handleTagChange} tagInvalid={tagInvalid} onSubmit={commitEdit} onDelete={() => setConfirmDeleteId(profile.id)} fgMuted={fgMuted} host={host} dark={dark} />
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// No delete affordance at rest — tap the row to start editing, which is the one place
|
|
210
|
+
// delete shows (see EditRow): a deliberate extra step before a destructive action is
|
|
211
|
+
// even reachable, not just before it's confirmed.
|
|
212
|
+
return (
|
|
213
|
+
<TouchableRipple key={profile.id} onPress={() => startEdit(profile)} style={styles.row}>
|
|
214
|
+
<View style={styles.rowInner}>
|
|
215
|
+
<ProfileChip profile={profile} size={CHIP_SIZE} />
|
|
216
|
+
<Text variant='bodyLarge' style={[styles.rowName, { color: fg }]} numberOfLines={1}>
|
|
217
|
+
{profile.name}
|
|
218
|
+
</Text>
|
|
219
|
+
</View>
|
|
220
|
+
</TouchableRipple>
|
|
221
|
+
)
|
|
222
|
+
})}
|
|
223
|
+
</ScrollView>
|
|
224
|
+
</KeyboardAvoidingView>
|
|
225
|
+
|
|
226
|
+
{confirmDeleteProfile && (
|
|
227
|
+
<Portal>
|
|
228
|
+
<View style={styles.overlay}>
|
|
229
|
+
<View style={[styles.overlayCard, { backgroundColor: cardBg, borderColor: cardBorder }]}>
|
|
230
|
+
<Icon source='alert-outline' size={64} color={colors.danger} />
|
|
231
|
+
<Text variant='headlineLarge' style={[styles.overlayTitle, { color: colors.danger }]}>
|
|
232
|
+
Delete Profile?
|
|
233
|
+
</Text>
|
|
234
|
+
<Text variant='bodyLarge' style={[styles.overlayBody, { color: fg }]}>
|
|
235
|
+
This permanently deletes {confirmDeleteProfile.name}’s profile. This cannot be undone.
|
|
236
|
+
</Text>
|
|
237
|
+
{/* Contained neutral, not buttonColor={colors.primary} — colors.primary is often a
|
|
238
|
+
live gameplay/seat color in host apps, so a contained Cancel in that color would just
|
|
239
|
+
be whatever's currently painted there, with no relation to "safe to tap". */}
|
|
240
|
+
<Button mode='contained' onPress={() => setConfirmDeleteId(null)} style={styles.overlayButton} buttonColor={cancelBg} textColor={cancelFg}>
|
|
241
|
+
Cancel
|
|
242
|
+
</Button>
|
|
243
|
+
{/* colors.danger/onDanger — auto-paper's actual semantic role for this, not a seat's
|
|
244
|
+
own live color by luck of the default palette. */}
|
|
245
|
+
<Button mode='contained' onPress={handleConfirmDelete} style={styles.overlayButton} buttonColor={colors.danger} textColor={colors.onDanger}>
|
|
246
|
+
Delete
|
|
247
|
+
</Button>
|
|
248
|
+
</View>
|
|
249
|
+
</View>
|
|
250
|
+
</Portal>
|
|
251
|
+
)}
|
|
252
|
+
</>
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
interface EditRowProps {
|
|
257
|
+
draftName: string
|
|
258
|
+
onNameChange: (name: string) => void
|
|
259
|
+
draftColor: string
|
|
260
|
+
onColorChange: (color: string) => void
|
|
261
|
+
draftTag: string
|
|
262
|
+
onTagChange: (tag: string) => void
|
|
263
|
+
tagInvalid: boolean
|
|
264
|
+
onSubmit: () => void
|
|
265
|
+
// Omitted for the 'new' row — nothing saved yet to delete.
|
|
266
|
+
onDelete?: () => void
|
|
267
|
+
autoFocus?: boolean
|
|
268
|
+
fgMuted: string
|
|
269
|
+
host: PopoverHost
|
|
270
|
+
dark: boolean
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// One row, expanded: the color trigger (@tastic/hud's InlineColorPicker, which takes a size prop
|
|
274
|
+
// rather than a fixed one, so it can be clamped to CHIP_SIZE along with every other circle on this
|
|
275
|
+
// screen), a tag field clamped to that same CHIP_SIZE footprint, the name taking up whatever's
|
|
276
|
+
// left, and — only for an existing profile — a delete icon at the very end. No `tag` passed to the
|
|
277
|
+
// color trigger here — it keeps its plain palette icon regardless of the row's own tag field, since
|
|
278
|
+
// the tag is already right next to it in its own field, so repeating it on the color trigger too
|
|
279
|
+
// would be redundant rather than informative.
|
|
280
|
+
// Return-key chains tag -> name (see @rific/focus-chain) rather than each field submitting on its
|
|
281
|
+
// own — submitting the *name* field is what actually commits the row (see the last field's own
|
|
282
|
+
// onSubmitEditing override below), matching how a normal multi-field form reads: fill fields in
|
|
283
|
+
// order, the last one finishes it.
|
|
284
|
+
function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, tagInvalid, onSubmit, onDelete, autoFocus, fgMuted, host, dark }: EditRowProps) {
|
|
285
|
+
const register = useFocusChain()
|
|
286
|
+
const tag = register()
|
|
287
|
+
const name = register()
|
|
288
|
+
|
|
289
|
+
return (
|
|
290
|
+
<View style={styles.editRow}>
|
|
291
|
+
<InlineColorPicker id='color' host={host} value={draftColor} onChange={onColorChange} dark={dark} size={CHIP_SIZE} />
|
|
292
|
+
{/* selectTextOnFocus so tapping into an already-set tag selects it for wholesale replacement
|
|
293
|
+
— the far more common edit than inserting into the middle of a 1-3 character value. */}
|
|
294
|
+
<TextInput ref={tag.ref} {...tag.props} mode='outlined' dense value={draftTag} onChangeText={onTagChange} placeholder='Tag' autoCapitalize='characters' selectTextOnFocus error={tagInvalid} returnKeyType='next' style={styles.tagInput} contentStyle={styles.tagInputContent} />
|
|
295
|
+
{/* tag.props' own onSubmitEditing (focus the next field) applies to every field but this
|
|
296
|
+
one — as the chain's last field, submitting it should finish the row instead of trying to
|
|
297
|
+
focus a field that doesn't exist, so this overrides it with the real commit action. Spread
|
|
298
|
+
order matters: name.props first, the explicit prop after, so this wins. */}
|
|
299
|
+
<TextInput ref={name.ref} {...name.props} mode='outlined' dense value={draftName} onChangeText={onNameChange} maxLength={MAX_PROFILE_NAME_LENGTH} placeholder='Name' autoFocus={autoFocus} returnKeyType='done' style={styles.nameInput} onSubmitEditing={onSubmit} />
|
|
300
|
+
{onDelete && (
|
|
301
|
+
<TouchableRipple onPress={onDelete} style={styles.iconButton}>
|
|
302
|
+
<Icon source='trash-can-outline' size={18} color={fgMuted} />
|
|
303
|
+
</TouchableRipple>
|
|
304
|
+
)}
|
|
305
|
+
</View>
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const styles = StyleSheet.create({
|
|
310
|
+
content: {
|
|
311
|
+
gap: 4,
|
|
312
|
+
paddingHorizontal: 20
|
|
313
|
+
},
|
|
314
|
+
editRow: {
|
|
315
|
+
alignItems: 'center',
|
|
316
|
+
flexDirection: 'row',
|
|
317
|
+
gap: 8,
|
|
318
|
+
paddingVertical: 6
|
|
319
|
+
},
|
|
320
|
+
header: {
|
|
321
|
+
alignItems: 'center',
|
|
322
|
+
flexDirection: 'row',
|
|
323
|
+
gap: 4,
|
|
324
|
+
paddingBottom: 8
|
|
325
|
+
},
|
|
326
|
+
iconButton: {
|
|
327
|
+
alignItems: 'center',
|
|
328
|
+
borderRadius: 8,
|
|
329
|
+
height: 36,
|
|
330
|
+
justifyContent: 'center',
|
|
331
|
+
width: 36
|
|
332
|
+
},
|
|
333
|
+
keyboardAvoiding: {
|
|
334
|
+
flex: 1
|
|
335
|
+
},
|
|
336
|
+
nameInput: {
|
|
337
|
+
flex: 1
|
|
338
|
+
},
|
|
339
|
+
newChip: {
|
|
340
|
+
backgroundColor: 'transparent',
|
|
341
|
+
borderStyle: 'dashed',
|
|
342
|
+
borderWidth: 2
|
|
343
|
+
},
|
|
344
|
+
overlay: {
|
|
345
|
+
alignItems: 'center',
|
|
346
|
+
backgroundColor: 'rgba(0,0,0,0.72)',
|
|
347
|
+
bottom: 0,
|
|
348
|
+
justifyContent: 'center',
|
|
349
|
+
left: 0,
|
|
350
|
+
position: 'absolute',
|
|
351
|
+
right: 0,
|
|
352
|
+
top: 0
|
|
353
|
+
},
|
|
354
|
+
overlayBody: { textAlign: 'center' },
|
|
355
|
+
overlayButton: { width: 160 },
|
|
356
|
+
overlayCard: {
|
|
357
|
+
alignItems: 'center',
|
|
358
|
+
borderRadius: 20,
|
|
359
|
+
borderWidth: 1,
|
|
360
|
+
gap: 16,
|
|
361
|
+
maxWidth: 360,
|
|
362
|
+
padding: 32
|
|
363
|
+
},
|
|
364
|
+
overlayTitle: { fontWeight: 'bold', marginBottom: -8 },
|
|
365
|
+
row: {
|
|
366
|
+
borderRadius: 8,
|
|
367
|
+
paddingVertical: 8
|
|
368
|
+
},
|
|
369
|
+
rowChip: {
|
|
370
|
+
alignItems: 'center',
|
|
371
|
+
borderRadius: CHIP_SIZE / 2,
|
|
372
|
+
height: CHIP_SIZE,
|
|
373
|
+
justifyContent: 'center',
|
|
374
|
+
width: CHIP_SIZE
|
|
375
|
+
},
|
|
376
|
+
rowInner: {
|
|
377
|
+
alignItems: 'center',
|
|
378
|
+
flexDirection: 'row',
|
|
379
|
+
gap: 12
|
|
380
|
+
},
|
|
381
|
+
rowName: {
|
|
382
|
+
flex: 1
|
|
383
|
+
},
|
|
384
|
+
scrollView: {
|
|
385
|
+
flex: 1
|
|
386
|
+
},
|
|
387
|
+
// Wide enough for MAX_TAG_LENGTH monospace characters (or one emoji) plus its own padding/border
|
|
388
|
+
// — matched to CHIP_SIZE (the same footprint every other circle on this screen uses), not a
|
|
389
|
+
// fraction of the row's width, since the name field next to it is what should actually get the
|
|
390
|
+
// row's remaining space.
|
|
391
|
+
tagInput: {
|
|
392
|
+
height: CHIP_SIZE,
|
|
393
|
+
width: CHIP_SIZE
|
|
394
|
+
},
|
|
395
|
+
tagInputContent: {
|
|
396
|
+
// react-native-paper's outlined TextInput sets its own native input's minWidth from the
|
|
397
|
+
// placeholder/label's measured layout width (sized for a real label, not this field's own
|
|
398
|
+
// 3-character placeholder) — left alone, that floor is wider than CHIP_SIZE, so the actual
|
|
399
|
+
// editable box silently overflows past this field's own visible outline and centers within
|
|
400
|
+
// that wider, partly invisible box instead. Zeroing it here lets the input size to CHIP_SIZE
|
|
401
|
+
// instead, which is what actually makes `textAlign: 'center'` below center against the
|
|
402
|
+
// visible box rather than an overflowing invisible one.
|
|
403
|
+
minWidth: 0,
|
|
404
|
+
// Same reasoning as minWidth above, for the horizontal direction: the outlined input's own
|
|
405
|
+
// default padding (16px a side) is sized for a normal-width field, and at CHIP_SIZE's 40px
|
|
406
|
+
// that leaves only 8px total for actual text — not enough to fit the "Tag" placeholder itself
|
|
407
|
+
// (3 characters), which is what was clipping it to just "T" once minWidth stopped letting the
|
|
408
|
+
// box silently overflow wider to compensate.
|
|
409
|
+
paddingHorizontal: 2,
|
|
410
|
+
textAlign: 'center',
|
|
411
|
+
textAlignVertical: 'center'
|
|
412
|
+
},
|
|
413
|
+
title: {
|
|
414
|
+
flexShrink: 1,
|
|
415
|
+
fontWeight: 'bold'
|
|
416
|
+
}
|
|
417
|
+
})
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { ProfileChip } from './ProfileChip'
|
|
2
|
+
export { ProfilePicker } from './ProfilePicker'
|
|
3
|
+
export { ProfilesManager, type ProfilesManagerProps } from './ProfilesManager'
|
|
4
|
+
export { isValidProfile, isValidTag, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH } from './profilesValidation'
|
|
5
|
+
export type { Profile } from './types'
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Profile } from './types'
|
|
2
|
+
|
|
3
|
+
export const MAX_PROFILE_NAME_LENGTH = 20
|
|
4
|
+
// In code points, not JS string length — a single emoji can span more than one UTF-16 code unit
|
|
5
|
+
// (surrogate pairs, variation selectors), which .length would overcount.
|
|
6
|
+
export const MAX_TAG_LENGTH = 3
|
|
7
|
+
|
|
8
|
+
function isValidHexColor(value: unknown): value is string {
|
|
9
|
+
return typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Codepoint 0xFE0F is the variation selector that forces an emoji-style rendering of an otherwise
|
|
13
|
+
// dual-purpose glyph; 0x200D is the zero-width joiner that chains multiple pictographs into one
|
|
14
|
+
// compound emoji (a family, a flag, a skin-tone variant) — built from String.fromCodePoint rather
|
|
15
|
+
// than embedded directly in a regex literal so this file's own source has no invisible characters
|
|
16
|
+
// in it.
|
|
17
|
+
const VARIATION_SELECTOR = String.fromCodePoint(0xfe0f)
|
|
18
|
+
const ZERO_WIDTH_JOINER = String.fromCodePoint(0x200d)
|
|
19
|
+
// Matches one emoji as a single unit, including a ZWJ-joined compound — good enough for telling
|
|
20
|
+
// "the user typed an emoji" from "the user typed letters" without needing full Unicode grapheme
|
|
21
|
+
// segmentation (Intl.Segmenter's own Hermes support is inconsistent enough not to lean on here).
|
|
22
|
+
const EMOJI_PATTERN = new RegExp(`^\\p{Extended_Pictographic}${VARIATION_SELECTOR}?(${ZERO_WIDTH_JOINER}\\p{Extended_Pictographic}${VARIATION_SELECTOR}?)*$`, 'u')
|
|
23
|
+
|
|
24
|
+
// Empty is valid too — a profile can have no tag yet, same as it can go without ever entering an
|
|
25
|
+
// editor's own tag field at all; every place this renders falls back to a generic icon in that case
|
|
26
|
+
// (see ProfileChip).
|
|
27
|
+
export function isValidTag(value: unknown): value is string {
|
|
28
|
+
if (typeof value !== 'string') return false
|
|
29
|
+
if (value.length === 0) return true
|
|
30
|
+
if (EMOJI_PATTERN.test(value)) return true
|
|
31
|
+
return !/\p{Extended_Pictographic}/u.test(value) && Array.from(value).length <= MAX_TAG_LENGTH
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Validates only the fields this package's own `Profile` type declares — an app that extends
|
|
35
|
+
// Profile with its own fields (a control scheme, anything else) needs its own wrapper that runs
|
|
36
|
+
// this first and then checks its own additions, same as it extends the type itself. See this
|
|
37
|
+
// package's own README for the composition pattern.
|
|
38
|
+
export function isValidProfile(value: unknown): value is Profile {
|
|
39
|
+
if (!value || typeof value !== 'object') return false
|
|
40
|
+
const v = value as Partial<Profile>
|
|
41
|
+
return typeof v.id === 'string' && v.id.length > 0 && typeof v.name === 'string' && v.name.trim().length > 0 && v.name.length <= MAX_PROFILE_NAME_LENGTH && isValidHexColor(v.color) && isValidTag(v.tag) && typeof v.createdAt === 'number' && typeof v.updatedAt === 'number'
|
|
42
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// A saved player identity: just enough for a picker/roster UI to show and let someone select "who
|
|
2
|
+
// am I this round" — deliberately nothing else. An app that needs more (a key/control scheme, a
|
|
3
|
+
// stats bucket, anything else keyed by profile id) extends this shape on its own, in its own
|
|
4
|
+
// storage layer, and structural typing carries it straight through every component here that only
|
|
5
|
+
// ever reads these five fields — no wrapper or adapter needed on the app's side.
|
|
6
|
+
export interface Profile {
|
|
7
|
+
id: string
|
|
8
|
+
name: string
|
|
9
|
+
color: string
|
|
10
|
+
// User-typed identity mark shown on a seat's own color trigger and in the profile picker/roster —
|
|
11
|
+
// one emoji, or up to MAX_TAG_LENGTH plain characters (see profilesValidation.ts's isValidTag).
|
|
12
|
+
// Can be empty (no tag yet), which just falls back to a generic icon wherever it's shown.
|
|
13
|
+
tag: string
|
|
14
|
+
createdAt: number
|
|
15
|
+
updatedAt: number
|
|
16
|
+
}
|