@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
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
|
+
})
|