@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/dist/index.mjs ADDED
@@ -0,0 +1,482 @@
1
+ // src/ProfileChip.tsx
2
+ import { getContrastColor } from "@rific/auto-paper";
3
+ import { StyleSheet, View } from "react-native";
4
+ import { Icon, Text } from "react-native-paper";
5
+ import { jsx } from "react/jsx-runtime";
6
+ var EMOJI_PATTERN = /\p{Extended_Pictographic}/u;
7
+ function ProfileChip({ profile, size = 22, filled = true }) {
8
+ const contrastColor = getContrastColor(profile.color);
9
+ const tagFontSize = size * (profile.tag && EMOJI_PATTERN.test(profile.tag) ? 0.6 : 0.4);
10
+ return /* @__PURE__ */ jsx(View, { style: [styles.chip, { borderRadius: size / 2, height: size, width: size }, filled && { backgroundColor: profile.color }], children: profile.tag ? /* @__PURE__ */ jsx(Text, { style: [styles.tag, { color: contrastColor, fontSize: tagFontSize }], numberOfLines: 1, adjustsFontSizeToFit: true, children: profile.tag }) : /* @__PURE__ */ jsx(Icon, { source: "account", size: size * 0.6, color: contrastColor }) });
11
+ }
12
+ var styles = StyleSheet.create({
13
+ chip: {
14
+ alignItems: "center",
15
+ justifyContent: "center"
16
+ },
17
+ tag: {
18
+ fontWeight: "700",
19
+ paddingHorizontal: 2
20
+ }
21
+ });
22
+
23
+ // src/ProfilePicker.tsx
24
+ import { getContrastColor as getContrastColor2 } from "@rific/auto-paper";
25
+ import { TouchableRipple } from "@rific/feedback-press";
26
+ import { PopoverBody, useAutoAlign } from "@tastic/hud";
27
+ import { useMemo } from "react";
28
+ import { ScrollView, StyleSheet as StyleSheet2, View as View2 } from "react-native";
29
+ import { Icon as Icon2, Text as Text2 } from "react-native-paper";
30
+ import { Fragment, jsx as jsx2, jsxs } from "react/jsx-runtime";
31
+ var TRIGGER_HEIGHT = 28;
32
+ var POPOVER_WIDTH = 220;
33
+ var ROW_HEIGHT = 36;
34
+ var LIST_PADDING = 8;
35
+ function ProfilePicker({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: alignOverride, guestLabel, nullLabel = "Player", nullIcon = "account-off-outline", onSelect, onManage }) {
36
+ const id = `${idPrefix}-profile`;
37
+ const menuBg = dark ? "#000000" : "#FFFFFF";
38
+ const fg = dark ? "#FFFFFF" : "#000000";
39
+ const open = host.openId === id;
40
+ const selectedProfile = profiles.find((p) => p.id === selectedId) ?? null;
41
+ const sortedProfiles = useMemo(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles]);
42
+ const rowCount = profiles.length + 1 + (onManage ? 1 : 0);
43
+ const contentHeight = LIST_PADDING * 2 + rowCount * ROW_HEIGHT;
44
+ const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, POPOVER_WIDTH, contentHeight);
45
+ const align = alignOverride ?? autoAlign;
46
+ const handleSelectGuest = () => {
47
+ onSelect(null);
48
+ host.close();
49
+ };
50
+ const handleSelectExisting = (profile) => {
51
+ if (profile.id === takenId) return;
52
+ onSelect(profile);
53
+ host.close();
54
+ };
55
+ const handleManage = () => {
56
+ host.close();
57
+ onManage?.();
58
+ };
59
+ return /* @__PURE__ */ jsxs(View2, { style: [styles2.anchor, open && styles2.anchorOpen], children: [
60
+ /* @__PURE__ */ jsx2(TouchableRipple, { onPress: () => host.toggle(id), style: styles2.trigger, children: /* @__PURE__ */ jsxs(View2, { ref: triggerRef, collapsable: false, style: styles2.triggerInner, children: [
61
+ /* @__PURE__ */ jsx2(Text2, { style: [styles2.triggerLabel, { color }], numberOfLines: 1, children: (selectedProfile?.name ?? guestLabel).toUpperCase() }),
62
+ /* @__PURE__ */ jsx2(Icon2, { source: "menu-down", size: 16, color })
63
+ ] }) }),
64
+ /* @__PURE__ */ jsx2(PopoverBody, { visible: open && measured, align, verticalAlign, caretColor: menuBg, caretBorderColor: color, caretBorderWidth: 2, triggerSize: TRIGGER_HEIGHT, children: /* @__PURE__ */ jsxs(
65
+ ScrollView,
66
+ {
67
+ style: [
68
+ styles2.menu,
69
+ {
70
+ backgroundColor: menuBg,
71
+ borderColor: color,
72
+ width: POPOVER_WIDTH,
73
+ maxHeight
74
+ }
75
+ ],
76
+ contentContainerStyle: styles2.menuContent,
77
+ showsVerticalScrollIndicator: false,
78
+ children: [
79
+ /* @__PURE__ */ jsx2(TouchableRipple, { onPress: handleSelectGuest, style: [styles2.row, selectedId === null && { backgroundColor: color }], children: /* @__PURE__ */ jsxs(View2, { style: styles2.rowInner, children: [
80
+ /* @__PURE__ */ jsx2(View2, { style: styles2.guestChip, children: /* @__PURE__ */ jsx2(Icon2, { source: nullIcon, size: 13, color: selectedId === null ? getContrastColor2(color) : fg }) }),
81
+ /* @__PURE__ */ jsx2(Text2, { style: [styles2.rowLabel, { color: selectedId === null ? getContrastColor2(color) : fg }], children: nullLabel })
82
+ ] }) }),
83
+ sortedProfiles.map((profile) => {
84
+ const taken = profile.id === takenId;
85
+ const selected = selectedId === profile.id;
86
+ return /* @__PURE__ */ jsx2(TouchableRipple, { disabled: taken, onPress: () => handleSelectExisting(profile), style: [styles2.row, selected && { backgroundColor: profile.color }], children: /* @__PURE__ */ jsxs(View2, { style: [styles2.rowInner, taken && styles2.rowDisabled], children: [
87
+ /* @__PURE__ */ jsx2(ProfileChip, { profile, filled: !selected }),
88
+ /* @__PURE__ */ jsx2(
89
+ Text2,
90
+ {
91
+ style: [
92
+ styles2.rowLabel,
93
+ {
94
+ color: selected ? getContrastColor2(profile.color) : fg
95
+ }
96
+ ],
97
+ numberOfLines: 1,
98
+ children: profile.name
99
+ }
100
+ )
101
+ ] }) }, profile.id);
102
+ }),
103
+ onManage && /* @__PURE__ */ jsxs(Fragment, { children: [
104
+ /* @__PURE__ */ jsx2(View2, { style: [styles2.divider, { backgroundColor: color }] }),
105
+ /* @__PURE__ */ jsx2(TouchableRipple, { onPress: handleManage, style: styles2.row, children: /* @__PURE__ */ jsxs(View2, { style: styles2.manageRowInner, children: [
106
+ /* @__PURE__ */ jsx2(Icon2, { source: "account-cog-outline", size: 16, color }),
107
+ /* @__PURE__ */ jsx2(Text2, { style: [styles2.manageLabel, { color }], children: "Manage" })
108
+ ] }) })
109
+ ] })
110
+ ]
111
+ }
112
+ ) })
113
+ ] });
114
+ }
115
+ var styles2 = StyleSheet2.create({
116
+ anchor: {
117
+ position: "relative"
118
+ },
119
+ // See InlineColorPicker's identical comment — React Native Web gives every position:'relative'
120
+ // view its own stacking context, so the elevation has to live on the anchor itself.
121
+ anchorOpen: {
122
+ zIndex: 100
123
+ },
124
+ // Separates Manage (a navigation action) from the selectable rows above it — different kind of
125
+ // action, not just another row in the same list. Tinted with the seat's own color (passed in as
126
+ // backgroundColor) rather than a flat gray, matching the border/caret's own accent-tinted look.
127
+ divider: {
128
+ height: 1,
129
+ marginVertical: 4,
130
+ opacity: 0.25
131
+ },
132
+ // Matches ProfileChip's own default size/shape exactly (see this row's own render-site comment)
133
+ // — no backgroundColor, unlike ProfileChip's own default: this row has no identity color of its
134
+ // own to fill it with, and the render site now colors the icon directly instead.
135
+ guestChip: {
136
+ alignItems: "center",
137
+ borderRadius: 11,
138
+ height: 22,
139
+ justifyContent: "center",
140
+ width: 22
141
+ },
142
+ manageLabel: {
143
+ fontSize: 13,
144
+ fontWeight: "700",
145
+ letterSpacing: 0.5
146
+ },
147
+ // Centered rather than left-aligned like a normal row — see this row's own render-site comment
148
+ // for why (a footer action, not another selectable identity).
149
+ manageRowInner: {
150
+ alignItems: "center",
151
+ flexDirection: "row",
152
+ gap: 6,
153
+ height: "100%",
154
+ justifyContent: "center"
155
+ },
156
+ menu: {
157
+ borderRadius: 12,
158
+ borderWidth: 2,
159
+ elevation: 8,
160
+ shadowColor: "#000000",
161
+ shadowOffset: { height: 2, width: 0 },
162
+ shadowOpacity: 0.3,
163
+ shadowRadius: 8
164
+ },
165
+ // Padded on every side (not just top/bottom) — a selected row's own full-color background (see
166
+ // `row`'s borderRadius) needs real margin from the menu's own straight edges to read as a rounded
167
+ // pill instead of looking clipped against them.
168
+ menuContent: {
169
+ padding: LIST_PADDING
170
+ },
171
+ row: {
172
+ borderRadius: 8,
173
+ height: ROW_HEIGHT
174
+ },
175
+ rowDisabled: {
176
+ opacity: 0.35
177
+ },
178
+ rowInner: {
179
+ alignItems: "center",
180
+ flexDirection: "row",
181
+ flex: 1,
182
+ gap: 8,
183
+ paddingHorizontal: 12
184
+ },
185
+ rowLabel: {
186
+ flex: 1,
187
+ fontSize: 14
188
+ },
189
+ trigger: {
190
+ alignSelf: "flex-start"
191
+ },
192
+ triggerInner: {
193
+ alignItems: "center",
194
+ flexDirection: "row",
195
+ gap: 2,
196
+ height: TRIGGER_HEIGHT
197
+ },
198
+ triggerLabel: {
199
+ fontSize: 13,
200
+ fontWeight: "700",
201
+ letterSpacing: 1
202
+ }
203
+ });
204
+
205
+ // src/ProfilesManager.tsx
206
+ import { useAutoPaperTheme } from "@rific/auto-paper";
207
+ import { TouchableRipple as TouchableRipple2 } from "@rific/feedback-press";
208
+ import { useFocusChain } from "@rific/focus-chain";
209
+ import { useToast } from "@rific/toaster";
210
+ import { InlineColorPicker, usePopoverHost } from "@tastic/hud";
211
+ import { useCallback, useEffect, useMemo as useMemo2, useRef, useState } from "react";
212
+ import { KeyboardAvoidingView, Platform, ScrollView as ScrollView2, StyleSheet as StyleSheet3, View as View3 } from "react-native";
213
+ import { Button, Icon as Icon3, Portal, Text as Text3, TextInput } from "react-native-paper";
214
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
215
+
216
+ // src/profilesValidation.ts
217
+ var MAX_PROFILE_NAME_LENGTH = 20;
218
+ var MAX_TAG_LENGTH = 3;
219
+ function isValidHexColor(value) {
220
+ return typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value);
221
+ }
222
+ var VARIATION_SELECTOR = String.fromCodePoint(65039);
223
+ var ZERO_WIDTH_JOINER = String.fromCodePoint(8205);
224
+ var EMOJI_PATTERN2 = new RegExp(`^\\p{Extended_Pictographic}${VARIATION_SELECTOR}?(${ZERO_WIDTH_JOINER}\\p{Extended_Pictographic}${VARIATION_SELECTOR}?)*$`, "u");
225
+ function isValidTag(value) {
226
+ if (typeof value !== "string") return false;
227
+ if (value.length === 0) return true;
228
+ if (EMOJI_PATTERN2.test(value)) return true;
229
+ return !/\p{Extended_Pictographic}/u.test(value) && Array.from(value).length <= MAX_TAG_LENGTH;
230
+ }
231
+ function isValidProfile(value) {
232
+ if (!value || typeof value !== "object") return false;
233
+ const v = value;
234
+ 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";
235
+ }
236
+
237
+ // src/ProfilesManager.tsx
238
+ import { Fragment as Fragment2, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
239
+ var CHIP_SIZE = 40;
240
+ function deriveTag(name) {
241
+ const trimmed = name.trim();
242
+ return trimmed ? trimmed[0].toUpperCase() : "";
243
+ }
244
+ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft }) {
245
+ const { dark, colors } = useAutoPaperTheme();
246
+ const insets = useSafeAreaInsets();
247
+ const { error: showErrorToast } = useToast();
248
+ const host = usePopoverHost();
249
+ const fg = dark ? "#FFFFFF" : "#000000";
250
+ const fgMuted = dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)";
251
+ const cardBg = dark ? "#111111" : "#F2F2F2";
252
+ const cardBorder = dark ? "rgba(255,255,255,0.2)" : "rgba(0,0,0,0.2)";
253
+ const cancelBg = dark ? "#2A2A2A" : "#E0E0E0";
254
+ const cancelFg = dark ? "#FFFFFF" : "#000000";
255
+ const [editingId, setEditingId] = useState(null);
256
+ const [draftName, setDraftName] = useState("");
257
+ const [draftColor, setDraftColor] = useState(defaultColor);
258
+ const [draftTag, setDraftTag] = useState("");
259
+ const [tagManuallySet, setTagManuallySet] = useState(false);
260
+ const [confirmDeleteId, setConfirmDeleteId] = useState(null);
261
+ const commitEdit = useCallback(() => {
262
+ const name = draftName.trim();
263
+ if (editingId && name) {
264
+ const tagValid = isValidTag(draftTag);
265
+ if (draftTag && !tagValid) showErrorToast("Invalid tag", "Use up to 3 letters or a single emoji");
266
+ const tag = tagValid ? draftTag : "";
267
+ if (editingId === "new") onCreate({ color: draftColor, name, tag });
268
+ else onSave(editingId, { color: draftColor, name, tag });
269
+ }
270
+ setEditingId(null);
271
+ }, [editingId, draftName, draftColor, draftTag, onCreate, onSave, showErrorToast]);
272
+ const commitEditRef = useRef(commitEdit);
273
+ useEffect(() => {
274
+ commitEditRef.current = commitEdit;
275
+ });
276
+ useEffect(() => () => commitEditRef.current(), []);
277
+ const startEdit = useCallback(
278
+ (target) => {
279
+ commitEdit();
280
+ if (target === "new") {
281
+ setDraftName("");
282
+ setDraftColor(defaultColor);
283
+ setDraftTag("");
284
+ setTagManuallySet(false);
285
+ } else {
286
+ setDraftName(target.name);
287
+ setDraftColor(target.color);
288
+ setDraftTag(target.tag);
289
+ setTagManuallySet(target.tag !== "");
290
+ }
291
+ setEditingId(target === "new" ? "new" : target.id);
292
+ },
293
+ [commitEdit, defaultColor]
294
+ );
295
+ const handleNameChange = useCallback(
296
+ (name) => {
297
+ setDraftName(name);
298
+ if (!tagManuallySet) setDraftTag(deriveTag(name));
299
+ },
300
+ [tagManuallySet]
301
+ );
302
+ const handleTagChange = useCallback((tag) => {
303
+ setDraftTag(tag);
304
+ setTagManuallySet(true);
305
+ }, []);
306
+ const confirmDeleteProfile = profiles.find((p) => p.id === confirmDeleteId) ?? null;
307
+ const handleConfirmDelete = useCallback(() => {
308
+ if (confirmDeleteId) {
309
+ onDelete(confirmDeleteId);
310
+ if (editingId === confirmDeleteId) setEditingId(null);
311
+ }
312
+ setConfirmDeleteId(null);
313
+ }, [confirmDeleteId, editingId, onDelete]);
314
+ const sortedProfiles = useMemo2(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles]);
315
+ const rows = [...sortedProfiles.map((profile) => ({ kind: "profile", profile })), { kind: "new" }];
316
+ const tagInvalid = draftTag.length > 0 && !isValidTag(draftTag);
317
+ return /* @__PURE__ */ jsxs2(Fragment2, { children: [
318
+ /* @__PURE__ */ jsxs2(View3, { style: [styles3.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }], children: [
319
+ headerLeft,
320
+ /* @__PURE__ */ jsx3(Text3, { variant: "displaySmall", style: [styles3.title, { color: fg }], children: "Profiles" })
321
+ ] }),
322
+ /* @__PURE__ */ jsx3(KeyboardAvoidingView, { behavior: Platform.OS === "ios" ? "padding" : void 0, style: styles3.keyboardAvoiding, children: /* @__PURE__ */ jsx3(ScrollView2, { style: styles3.scrollView, contentContainerStyle: [styles3.content, { paddingBottom: 32 + insets.bottom }], showsVerticalScrollIndicator: false, keyboardShouldPersistTaps: "handled", children: rows.map((row) => {
323
+ if (row.kind === "new") {
324
+ if (editingId !== "new") {
325
+ return /* @__PURE__ */ jsx3(TouchableRipple2, { onPress: () => startEdit("new"), style: styles3.row, children: /* @__PURE__ */ jsxs2(View3, { style: styles3.rowInner, children: [
326
+ /* @__PURE__ */ jsx3(View3, { style: [styles3.rowChip, styles3.newChip, { borderColor: fg }], children: /* @__PURE__ */ jsx3(Icon3, { source: "plus", size: 20, color: fg }) }),
327
+ /* @__PURE__ */ jsx3(Text3, { variant: "bodyLarge", style: { color: fg }, children: "New Profile" })
328
+ ] }) }, "new");
329
+ }
330
+ return /* @__PURE__ */ jsx3(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, tagInvalid, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark }, "new");
331
+ }
332
+ const { profile } = row;
333
+ if (editingId === profile.id) {
334
+ return /* @__PURE__ */ jsx3(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, tagInvalid, onSubmit: commitEdit, onDelete: () => setConfirmDeleteId(profile.id), fgMuted, host, dark }, profile.id);
335
+ }
336
+ return /* @__PURE__ */ jsx3(TouchableRipple2, { onPress: () => startEdit(profile), style: styles3.row, children: /* @__PURE__ */ jsxs2(View3, { style: styles3.rowInner, children: [
337
+ /* @__PURE__ */ jsx3(ProfileChip, { profile, size: CHIP_SIZE }),
338
+ /* @__PURE__ */ jsx3(Text3, { variant: "bodyLarge", style: [styles3.rowName, { color: fg }], numberOfLines: 1, children: profile.name })
339
+ ] }) }, profile.id);
340
+ }) }) }),
341
+ confirmDeleteProfile && /* @__PURE__ */ jsx3(Portal, { children: /* @__PURE__ */ jsx3(View3, { style: styles3.overlay, children: /* @__PURE__ */ jsxs2(View3, { style: [styles3.overlayCard, { backgroundColor: cardBg, borderColor: cardBorder }], children: [
342
+ /* @__PURE__ */ jsx3(Icon3, { source: "alert-outline", size: 64, color: colors.danger }),
343
+ /* @__PURE__ */ jsx3(Text3, { variant: "headlineLarge", style: [styles3.overlayTitle, { color: colors.danger }], children: "Delete Profile?" }),
344
+ /* @__PURE__ */ jsxs2(Text3, { variant: "bodyLarge", style: [styles3.overlayBody, { color: fg }], children: [
345
+ "This permanently deletes ",
346
+ confirmDeleteProfile.name,
347
+ "\u2019s profile. This cannot be undone."
348
+ ] }),
349
+ /* @__PURE__ */ jsx3(Button, { mode: "contained", onPress: () => setConfirmDeleteId(null), style: styles3.overlayButton, buttonColor: cancelBg, textColor: cancelFg, children: "Cancel" }),
350
+ /* @__PURE__ */ jsx3(Button, { mode: "contained", onPress: handleConfirmDelete, style: styles3.overlayButton, buttonColor: colors.danger, textColor: colors.onDanger, children: "Delete" })
351
+ ] }) }) })
352
+ ] });
353
+ }
354
+ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, tagInvalid, onSubmit, onDelete, autoFocus, fgMuted, host, dark }) {
355
+ const register = useFocusChain();
356
+ const tag = register();
357
+ const name = register();
358
+ return /* @__PURE__ */ jsxs2(View3, { style: styles3.editRow, children: [
359
+ /* @__PURE__ */ jsx3(InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, dark, size: CHIP_SIZE }),
360
+ /* @__PURE__ */ jsx3(TextInput, { ref: tag.ref, ...tag.props, mode: "outlined", dense: true, value: draftTag, onChangeText: onTagChange, placeholder: "Tag", autoCapitalize: "characters", selectTextOnFocus: true, error: tagInvalid, returnKeyType: "next", style: styles3.tagInput, contentStyle: styles3.tagInputContent }),
361
+ /* @__PURE__ */ jsx3(TextInput, { ref: name.ref, ...name.props, mode: "outlined", dense: true, value: draftName, onChangeText: onNameChange, maxLength: MAX_PROFILE_NAME_LENGTH, placeholder: "Name", autoFocus, returnKeyType: "done", style: styles3.nameInput, onSubmitEditing: onSubmit }),
362
+ onDelete && /* @__PURE__ */ jsx3(TouchableRipple2, { onPress: onDelete, style: styles3.iconButton, children: /* @__PURE__ */ jsx3(Icon3, { source: "trash-can-outline", size: 18, color: fgMuted }) })
363
+ ] });
364
+ }
365
+ var styles3 = StyleSheet3.create({
366
+ content: {
367
+ gap: 4,
368
+ paddingHorizontal: 20
369
+ },
370
+ editRow: {
371
+ alignItems: "center",
372
+ flexDirection: "row",
373
+ gap: 8,
374
+ paddingVertical: 6
375
+ },
376
+ header: {
377
+ alignItems: "center",
378
+ flexDirection: "row",
379
+ gap: 4,
380
+ paddingBottom: 8
381
+ },
382
+ iconButton: {
383
+ alignItems: "center",
384
+ borderRadius: 8,
385
+ height: 36,
386
+ justifyContent: "center",
387
+ width: 36
388
+ },
389
+ keyboardAvoiding: {
390
+ flex: 1
391
+ },
392
+ nameInput: {
393
+ flex: 1
394
+ },
395
+ newChip: {
396
+ backgroundColor: "transparent",
397
+ borderStyle: "dashed",
398
+ borderWidth: 2
399
+ },
400
+ overlay: {
401
+ alignItems: "center",
402
+ backgroundColor: "rgba(0,0,0,0.72)",
403
+ bottom: 0,
404
+ justifyContent: "center",
405
+ left: 0,
406
+ position: "absolute",
407
+ right: 0,
408
+ top: 0
409
+ },
410
+ overlayBody: { textAlign: "center" },
411
+ overlayButton: { width: 160 },
412
+ overlayCard: {
413
+ alignItems: "center",
414
+ borderRadius: 20,
415
+ borderWidth: 1,
416
+ gap: 16,
417
+ maxWidth: 360,
418
+ padding: 32
419
+ },
420
+ overlayTitle: { fontWeight: "bold", marginBottom: -8 },
421
+ row: {
422
+ borderRadius: 8,
423
+ paddingVertical: 8
424
+ },
425
+ rowChip: {
426
+ alignItems: "center",
427
+ borderRadius: CHIP_SIZE / 2,
428
+ height: CHIP_SIZE,
429
+ justifyContent: "center",
430
+ width: CHIP_SIZE
431
+ },
432
+ rowInner: {
433
+ alignItems: "center",
434
+ flexDirection: "row",
435
+ gap: 12
436
+ },
437
+ rowName: {
438
+ flex: 1
439
+ },
440
+ scrollView: {
441
+ flex: 1
442
+ },
443
+ // Wide enough for MAX_TAG_LENGTH monospace characters (or one emoji) plus its own padding/border
444
+ // — matched to CHIP_SIZE (the same footprint every other circle on this screen uses), not a
445
+ // fraction of the row's width, since the name field next to it is what should actually get the
446
+ // row's remaining space.
447
+ tagInput: {
448
+ height: CHIP_SIZE,
449
+ width: CHIP_SIZE
450
+ },
451
+ tagInputContent: {
452
+ // react-native-paper's outlined TextInput sets its own native input's minWidth from the
453
+ // placeholder/label's measured layout width (sized for a real label, not this field's own
454
+ // 3-character placeholder) — left alone, that floor is wider than CHIP_SIZE, so the actual
455
+ // editable box silently overflows past this field's own visible outline and centers within
456
+ // that wider, partly invisible box instead. Zeroing it here lets the input size to CHIP_SIZE
457
+ // instead, which is what actually makes `textAlign: 'center'` below center against the
458
+ // visible box rather than an overflowing invisible one.
459
+ minWidth: 0,
460
+ // Same reasoning as minWidth above, for the horizontal direction: the outlined input's own
461
+ // default padding (16px a side) is sized for a normal-width field, and at CHIP_SIZE's 40px
462
+ // that leaves only 8px total for actual text — not enough to fit the "Tag" placeholder itself
463
+ // (3 characters), which is what was clipping it to just "T" once minWidth stopped letting the
464
+ // box silently overflow wider to compensate.
465
+ paddingHorizontal: 2,
466
+ textAlign: "center",
467
+ textAlignVertical: "center"
468
+ },
469
+ title: {
470
+ flexShrink: 1,
471
+ fontWeight: "bold"
472
+ }
473
+ });
474
+ export {
475
+ MAX_PROFILE_NAME_LENGTH,
476
+ MAX_TAG_LENGTH,
477
+ ProfileChip,
478
+ ProfilePicker,
479
+ ProfilesManager,
480
+ isValidProfile,
481
+ isValidTag
482
+ };
package/package.json ADDED
@@ -0,0 +1,107 @@
1
+ {
2
+ "name": "@tastic/profile",
3
+ "version": "0.1.0",
4
+ "description": "Player profile identity for local-multiplayer React Native games — a saved name/color/tag a player selects per seat, with a picker, an inline-editable roster screen, and a reusable identity badge, deliberately blind to any app-specific fields (control scheme, stats) an app layers on top",
5
+ "keywords": [
6
+ "react-native",
7
+ "expo",
8
+ "local-multiplayer",
9
+ "couch-multiplayer",
10
+ "profile",
11
+ "player-identity",
12
+ "avatar"
13
+ ],
14
+ "homepage": "https://github.com/jayrdeaton/react-native-game-profile#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/jayrdeaton/react-native-game-profile/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/jayrdeaton/react-native-game-profile.git"
21
+ },
22
+ "license": "MIT",
23
+ "author": "Jay Deaton",
24
+ "sideEffects": false,
25
+ "type": "commonjs",
26
+ "exports": {
27
+ ".": {
28
+ "react-native": "./src/index.ts",
29
+ "browser": "./src/index.ts",
30
+ "types": "./dist/index.d.ts",
31
+ "import": "./dist/index.mjs",
32
+ "require": "./dist/index.js"
33
+ }
34
+ },
35
+ "main": "dist/index.js",
36
+ "module": "dist/index.mjs",
37
+ "react-native": "src/index.ts",
38
+ "types": "dist/index.d.ts",
39
+ "files": [
40
+ "dist",
41
+ "src",
42
+ "!src/__tests__",
43
+ "!src/__mocks__"
44
+ ],
45
+ "scripts": {
46
+ "build": "tsup src/index.ts --format cjs,esm --dts --clean",
47
+ "build:watch": "tsup src/index.ts --format cjs,esm --dts --watch",
48
+ "fix": "eslint --fix",
49
+ "lint": "eslint",
50
+ "prepublishOnly": "npm run build",
51
+ "release": "git push --follow-tags",
52
+ "release:major": "npm version major && git push --follow-tags",
53
+ "release:minor": "npm version minor && git push --follow-tags",
54
+ "release:patch": "npm version patch && git push --follow-tags",
55
+ "test": "jest",
56
+ "test:watch": "jest --watchAll",
57
+ "typecheck": "tsc --noEmit",
58
+ "preversion": "npm run lint && npm test && npm run build"
59
+ },
60
+ "devDependencies": {
61
+ "@rific/auto-paper": "file:.yalc/@rific/auto-paper",
62
+ "@rific/feedback-press": "^0.10.4",
63
+ "@rific/focus-chain": "^0.4.3",
64
+ "@rific/toaster": "^0.4.1",
65
+ "@tastic/core": "^0.1.2",
66
+ "@tastic/hud": "file:.yalc/@tastic/hud",
67
+ "@testing-library/dom": "^10.4.1",
68
+ "@testing-library/react": "^16.3.2",
69
+ "@types/jest": "^30.0.0",
70
+ "@types/react": "^19.0.0",
71
+ "@typescript-eslint/parser": "^8.59.3",
72
+ "eslint": "^9.39.4",
73
+ "eslint-config-prettier": "^10.1.8",
74
+ "eslint-plugin-package-json": "^1.0.0",
75
+ "eslint-plugin-prettier": "^5.5.5",
76
+ "eslint-plugin-react-hooks": "^7.1.1",
77
+ "eslint-plugin-react-native": "^5.0.0",
78
+ "eslint-plugin-simple-import-sort": "^13.0.0",
79
+ "jest": "^30.4.2",
80
+ "jest-environment-jsdom": "^30.4.1",
81
+ "prettier": "^3.8.3",
82
+ "react": "^19.0.0",
83
+ "react-dom": "^19.0.0",
84
+ "react-native": "^0.85.3",
85
+ "react-native-paper": "^5.15.2",
86
+ "react-native-safe-area-context": "^5.0.0",
87
+ "ts-jest": "^29.4.9",
88
+ "tsup": "^8.0.0",
89
+ "typescript": "^6.0.3",
90
+ "typescript-eslint": "^8.59.3"
91
+ },
92
+ "peerDependencies": {
93
+ "@rific/auto-paper": ">=0.9.0",
94
+ "@rific/feedback-press": ">=0.10.0",
95
+ "@rific/focus-chain": ">=0.4.0",
96
+ "@rific/toaster": ">=0.4.0",
97
+ "@tastic/core": ">=0.1.0",
98
+ "@tastic/hud": ">=0.1.3",
99
+ "react": ">=19.0.0",
100
+ "react-native": ">=0.76.0",
101
+ "react-native-paper": ">=5.0.0",
102
+ "react-native-safe-area-context": ">=4.0.0"
103
+ },
104
+ "publishConfig": {
105
+ "access": "public"
106
+ }
107
+ }
@@ -0,0 +1,60 @@
1
+ import { getContrastColor } from '@rific/auto-paper'
2
+ import { StyleSheet, View } from 'react-native'
3
+ import { Icon, Text } from 'react-native-paper'
4
+
5
+ import { Profile } from './types'
6
+
7
+ // Presence check, not isValidTag's own full-string emoji validation (profilesValidation.ts) — this
8
+ // only decides a font-size ratio, so it doesn't need that check's ZWJ/variation-selector precision,
9
+ // just "does this tag contain a pictograph at all."
10
+ const EMOJI_PATTERN = /\p{Extended_Pictographic}/u
11
+
12
+ interface Props {
13
+ profile: Profile
14
+ size?: number
15
+ // False on a selected row, whose own background already fills with the profile's color (see
16
+ // ProfilePicker's own selected-row handling) — a second circle behind the tag there would either
17
+ // double up decoratively or, filled some other flat color, look like a stray badge next to the
18
+ // plain icon+text every other selected row in the app uses. Contrast is still computed against
19
+ // the profile's own color either way (that's what's actually behind the glyph whether this chip
20
+ // paints it itself or the row already has), and the chip keeps the same size/shape footprint
21
+ // filled or not, so a row's tag/icon stays lined up with every other row in the list regardless
22
+ // of which one happens to be selected.
23
+ filled?: boolean
24
+ }
25
+
26
+ // A profile's identity at a glance — its own color as a filled circle, with its tag (or a generic
27
+ // fallback icon, for a profile with none set) in a contrast-safe color on top. The same combination
28
+ // @tastic/hud's InlineColorPicker trigger shows for live color selection, just as a small static
29
+ // badge for a list row (ProfilePicker's dropdown, ProfilesManager's roster) rather than an
30
+ // interactive picker trigger.
31
+ export function ProfileChip({ profile, size = 22, filled = true }: Props) {
32
+ const contrastColor = getContrastColor(profile.color)
33
+ // An emoji glyph reads visually smaller than a bold letter at the same fontSize (the system emoji
34
+ // font leaves more of its own em-box empty), so a plain-text ratio that looks right for "J" still
35
+ // looks small for "🎮" at the identical size — this bumps emoji up to the icon's own ratio instead,
36
+ // since a single emoji is closer in visual weight to an icon glyph than to a line of text.
37
+ const tagFontSize = size * (profile.tag && EMOJI_PATTERN.test(profile.tag) ? 0.6 : 0.4)
38
+ return (
39
+ <View style={[styles.chip, { borderRadius: size / 2, height: size, width: size }, filled && { backgroundColor: profile.color }]}>
40
+ {profile.tag ? (
41
+ <Text style={[styles.tag, { color: contrastColor, fontSize: tagFontSize }]} numberOfLines={1} adjustsFontSizeToFit>
42
+ {profile.tag}
43
+ </Text>
44
+ ) : (
45
+ <Icon source='account' size={size * 0.6} color={contrastColor} />
46
+ )}
47
+ </View>
48
+ )
49
+ }
50
+
51
+ const styles = StyleSheet.create({
52
+ chip: {
53
+ alignItems: 'center',
54
+ justifyContent: 'center'
55
+ },
56
+ tag: {
57
+ fontWeight: '700',
58
+ paddingHorizontal: 2
59
+ }
60
+ })