@tastic/profile 0.3.5 → 0.3.7
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.d.mts +7 -3
- package/dist/index.d.ts +7 -3
- package/dist/index.js +27 -33
- package/dist/index.mjs +28 -34
- package/package.json +1 -1
- package/src/ProfilesManager.tsx +67 -47
- package/src/index.ts +1 -1
- package/src/profilesValidation.ts +33 -1
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
|
-
import { ReactNode } from 'react';
|
|
2
|
+
import { ReactNode, Ref } from 'react';
|
|
3
3
|
import { PopoverHost } from '@tastic/hud';
|
|
4
4
|
|
|
5
5
|
interface Profile {
|
|
@@ -40,6 +40,9 @@ interface ProfileEditPatch {
|
|
|
40
40
|
color: string;
|
|
41
41
|
tag: string;
|
|
42
42
|
}
|
|
43
|
+
interface ProfilesManagerHandle {
|
|
44
|
+
commitPendingEdit: () => void;
|
|
45
|
+
}
|
|
43
46
|
interface ProfilesManagerProps {
|
|
44
47
|
profiles: Profile[];
|
|
45
48
|
defaultColor: string;
|
|
@@ -47,8 +50,9 @@ interface ProfilesManagerProps {
|
|
|
47
50
|
onSave: (id: string, patch: ProfileEditPatch) => void;
|
|
48
51
|
onDelete: (id: string) => void;
|
|
49
52
|
headerLeft?: ReactNode;
|
|
53
|
+
ref?: Ref<ProfilesManagerHandle>;
|
|
50
54
|
}
|
|
51
|
-
declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft }: ProfilesManagerProps): react.JSX.Element;
|
|
55
|
+
declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref }: ProfilesManagerProps): react.JSX.Element;
|
|
52
56
|
|
|
53
57
|
declare const MAX_PROFILE_NAME_LENGTH = 20;
|
|
54
58
|
declare const MAX_TAG_LENGTH = 3;
|
|
@@ -59,4 +63,4 @@ declare const isSharedProfileStoreAvailable: boolean;
|
|
|
59
63
|
declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
|
|
60
64
|
declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
|
|
61
65
|
|
|
62
|
-
export { MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, ProfilePicker, ProfilesManager, type ProfilesManagerProps, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, saveSharedProfiles };
|
|
66
|
+
export { MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, ProfilePicker, ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, saveSharedProfiles };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
|
-
import { ReactNode } from 'react';
|
|
2
|
+
import { ReactNode, Ref } from 'react';
|
|
3
3
|
import { PopoverHost } from '@tastic/hud';
|
|
4
4
|
|
|
5
5
|
interface Profile {
|
|
@@ -40,6 +40,9 @@ interface ProfileEditPatch {
|
|
|
40
40
|
color: string;
|
|
41
41
|
tag: string;
|
|
42
42
|
}
|
|
43
|
+
interface ProfilesManagerHandle {
|
|
44
|
+
commitPendingEdit: () => void;
|
|
45
|
+
}
|
|
43
46
|
interface ProfilesManagerProps {
|
|
44
47
|
profiles: Profile[];
|
|
45
48
|
defaultColor: string;
|
|
@@ -47,8 +50,9 @@ interface ProfilesManagerProps {
|
|
|
47
50
|
onSave: (id: string, patch: ProfileEditPatch) => void;
|
|
48
51
|
onDelete: (id: string) => void;
|
|
49
52
|
headerLeft?: ReactNode;
|
|
53
|
+
ref?: Ref<ProfilesManagerHandle>;
|
|
50
54
|
}
|
|
51
|
-
declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft }: ProfilesManagerProps): react.JSX.Element;
|
|
55
|
+
declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref }: ProfilesManagerProps): react.JSX.Element;
|
|
52
56
|
|
|
53
57
|
declare const MAX_PROFILE_NAME_LENGTH = 20;
|
|
54
58
|
declare const MAX_TAG_LENGTH = 3;
|
|
@@ -59,4 +63,4 @@ declare const isSharedProfileStoreAvailable: boolean;
|
|
|
59
63
|
declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
|
|
60
64
|
declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
|
|
61
65
|
|
|
62
|
-
export { MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, ProfilePicker, ProfilesManager, type ProfilesManagerProps, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, saveSharedProfiles };
|
|
66
|
+
export { MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, ProfilePicker, ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, saveSharedProfiles };
|
package/dist/index.js
CHANGED
|
@@ -257,11 +257,22 @@ function isValidHexColor(value) {
|
|
|
257
257
|
var VARIATION_SELECTOR = String.fromCodePoint(65039);
|
|
258
258
|
var ZERO_WIDTH_JOINER = String.fromCodePoint(8205);
|
|
259
259
|
var EMOJI_PATTERN2 = new RegExp(`^\\p{Extended_Pictographic}${VARIATION_SELECTOR}?(${ZERO_WIDTH_JOINER}\\p{Extended_Pictographic}${VARIATION_SELECTOR}?)*$`, "u");
|
|
260
|
+
var EMOJI_PRESENT = /\p{Extended_Pictographic}/u;
|
|
260
261
|
function isValidTag(value) {
|
|
261
262
|
if (typeof value !== "string") return false;
|
|
262
263
|
if (value.length === 0) return true;
|
|
263
264
|
if (EMOJI_PATTERN2.test(value)) return true;
|
|
264
|
-
return
|
|
265
|
+
return !EMOJI_PRESENT.test(value) && Array.from(value).length <= MAX_TAG_LENGTH;
|
|
266
|
+
}
|
|
267
|
+
function clampTag(previous, next) {
|
|
268
|
+
const previousCodepoints = Array.from(previous);
|
|
269
|
+
const nextCodepoints = Array.from(next);
|
|
270
|
+
if (nextCodepoints.length <= previousCodepoints.length) return next;
|
|
271
|
+
let sharedPrefixLength = 0;
|
|
272
|
+
while (sharedPrefixLength < previousCodepoints.length && previousCodepoints[sharedPrefixLength] === nextCodepoints[sharedPrefixLength]) sharedPrefixLength++;
|
|
273
|
+
const added = nextCodepoints.slice(sharedPrefixLength).join("");
|
|
274
|
+
if (EMOJI_PRESENT.test(added) || EMOJI_PRESENT.test(previous)) return added;
|
|
275
|
+
return nextCodepoints.length > MAX_TAG_LENGTH ? previous : next;
|
|
265
276
|
}
|
|
266
277
|
function isValidProfile(value) {
|
|
267
278
|
if (!value || typeof value !== "object") return false;
|
|
@@ -276,7 +287,7 @@ function deriveTag(name) {
|
|
|
276
287
|
const trimmed = name.trim();
|
|
277
288
|
return trimmed ? trimmed[0].toUpperCase() : "";
|
|
278
289
|
}
|
|
279
|
-
function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft }) {
|
|
290
|
+
function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref }) {
|
|
280
291
|
const { dark, colors } = (0, import_auto_paper3.useAutoPaperTheme)();
|
|
281
292
|
const insets = (0, import_react_native_safe_area_context.useSafeAreaInsets)();
|
|
282
293
|
const { error: showErrorToast } = (0, import_toaster.useToast)();
|
|
@@ -309,6 +320,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
|
|
|
309
320
|
commitEditRef.current = commitEdit;
|
|
310
321
|
});
|
|
311
322
|
(0, import_react2.useEffect)(() => () => commitEditRef.current(), []);
|
|
323
|
+
(0, import_react2.useImperativeHandle)(ref, () => ({ commitPendingEdit: () => commitEditRef.current() }), []);
|
|
312
324
|
const startEdit = (0, import_react2.useCallback)(
|
|
313
325
|
(target) => {
|
|
314
326
|
commitEdit();
|
|
@@ -335,7 +347,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
|
|
|
335
347
|
[tagManuallySet]
|
|
336
348
|
);
|
|
337
349
|
const handleTagChange = (0, import_react2.useCallback)((tag) => {
|
|
338
|
-
setDraftTag(tag);
|
|
350
|
+
setDraftTag((previous) => clampTag(previous, tag));
|
|
339
351
|
setTagManuallySet(true);
|
|
340
352
|
}, []);
|
|
341
353
|
const confirmDeleteProfile = profiles.find((p) => p.id === confirmDeleteId) ?? null;
|
|
@@ -348,7 +360,6 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
|
|
|
348
360
|
}, [confirmDeleteId, editingId, onDelete]);
|
|
349
361
|
const sortedProfiles = (0, import_react2.useMemo)(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles]);
|
|
350
362
|
const rows = [...sortedProfiles.map((profile) => ({ kind: "profile", profile })), { kind: "new" }];
|
|
351
|
-
const tagInvalid = draftTag.length > 0 && !isValidTag(draftTag);
|
|
352
363
|
return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
|
|
353
364
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native3.View, { style: [styles3.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }], children: [
|
|
354
365
|
headerLeft,
|
|
@@ -362,11 +373,11 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
|
|
|
362
373
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.Text, { variant: "bodyLarge", style: { color: fg }, children: "New Profile" })
|
|
363
374
|
] }) }, "new");
|
|
364
375
|
}
|
|
365
|
-
return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange,
|
|
376
|
+
return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark }, "new");
|
|
366
377
|
}
|
|
367
378
|
const { profile } = row;
|
|
368
379
|
if (editingId === profile.id) {
|
|
369
|
-
return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange,
|
|
380
|
+
return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, onDelete: () => setConfirmDeleteId(profile.id), fgMuted, host, dark }, profile.id);
|
|
370
381
|
}
|
|
371
382
|
return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_feedback_press2.TouchableRipple, { onPress: () => startEdit(profile), style: styles3.row, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native3.View, { style: styles3.rowInner, children: [
|
|
372
383
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(ProfileChip, { profile, size: CHIP_SIZE }),
|
|
@@ -386,7 +397,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
|
|
|
386
397
|
] }) }) })
|
|
387
398
|
] });
|
|
388
399
|
}
|
|
389
|
-
function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange,
|
|
400
|
+
function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark }) {
|
|
390
401
|
const register = (0, import_focus_chain.useFocusChain)();
|
|
391
402
|
const tag = register();
|
|
392
403
|
const name = register();
|
|
@@ -399,7 +410,7 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
|
|
|
399
410
|
}, [host.openId]);
|
|
400
411
|
return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native3.View, { style: styles3.editRow, children: [
|
|
401
412
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_hud2.InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, tag: draftTag, dark }),
|
|
402
|
-
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.TextInput, { ref: tag.ref, ...tag.props,
|
|
413
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.TextInput, { ref: tag.ref, ...tag.props, value: draftTag, onChangeText: onTagChange, accessibilityLabel: "Tag", autoCapitalize: "characters", selectTextOnFocus: true, returnKeyType: "next", style: styles3.hiddenTagInput, pointerEvents: "none" }),
|
|
403
414
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.TextInput, { ref: name.ref, ...name.props, onSubmitEditing: onSubmit, mode: "outlined", dense: true, value: draftName, onChangeText: onNameChange, maxLength: MAX_PROFILE_NAME_LENGTH, placeholder: "Name", autoFocus, returnKeyType: "done", style: styles3.nameInput }),
|
|
404
415
|
onDelete && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_feedback_press2.TouchableRipple, { onPress: onDelete, style: styles3.iconButton, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.Icon, { source: "trash-can-outline", size: 18, color: fgMuted }) })
|
|
405
416
|
] });
|
|
@@ -482,31 +493,14 @@ var styles3 = import_react_native3.StyleSheet.create({
|
|
|
482
493
|
scrollView: {
|
|
483
494
|
flex: 1
|
|
484
495
|
},
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
tagInputContent: {
|
|
494
|
-
// react-native-paper's outlined TextInput sets its own native input's minWidth from the
|
|
495
|
-
// placeholder/label's measured layout width (sized for a real label, not this field's own
|
|
496
|
-
// 3-character placeholder) — left alone, that floor is wider than CHIP_SIZE, so the actual
|
|
497
|
-
// editable box silently overflows past this field's own visible outline and centers within
|
|
498
|
-
// that wider, partly invisible box instead. Zeroing it here lets the input size to CHIP_SIZE
|
|
499
|
-
// instead, which is what actually makes `textAlign: 'center'` below center against the
|
|
500
|
-
// visible box rather than an overflowing invisible one.
|
|
501
|
-
minWidth: 0,
|
|
502
|
-
// Same reasoning as minWidth above, for the horizontal direction: the outlined input's own
|
|
503
|
-
// default padding (16px a side) is sized for a normal-width field, and at CHIP_SIZE's 40px
|
|
504
|
-
// that leaves only 8px total for actual text — not enough to fit the "Tag" placeholder itself
|
|
505
|
-
// (3 characters), which is what was clipping it to just "T" once minWidth stopped letting the
|
|
506
|
-
// box silently overflow wider to compensate.
|
|
507
|
-
paddingHorizontal: 2,
|
|
508
|
-
textAlign: "center",
|
|
509
|
-
textAlignVertical: "center"
|
|
496
|
+
// 1x1 and fully transparent — there's nothing to see here on purpose (see EditRow's own comment
|
|
497
|
+
// above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
|
|
498
|
+
// entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
|
|
499
|
+
hiddenTagInput: {
|
|
500
|
+
height: 1,
|
|
501
|
+
opacity: 0,
|
|
502
|
+
position: "absolute",
|
|
503
|
+
width: 1
|
|
510
504
|
},
|
|
511
505
|
title: {
|
|
512
506
|
flexShrink: 1,
|
package/dist/index.mjs
CHANGED
|
@@ -215,7 +215,7 @@ import { TouchableRipple as TouchableRipple2 } from "@rific/feedback-press";
|
|
|
215
215
|
import { useFocusChain } from "@rific/focus-chain";
|
|
216
216
|
import { useToast } from "@rific/toaster";
|
|
217
217
|
import { InlineColorPicker, usePopoverHost } from "@tastic/hud";
|
|
218
|
-
import { useCallback, useEffect, useMemo as useMemo2, useRef, useState } from "react";
|
|
218
|
+
import { useCallback, useEffect, useImperativeHandle, useMemo as useMemo2, useRef, useState } from "react";
|
|
219
219
|
import { KeyboardAvoidingView, Platform, ScrollView as ScrollView2, StyleSheet as StyleSheet3, View as View3 } from "react-native";
|
|
220
220
|
import { Button, Icon as Icon3, Portal, Text as Text3, TextInput } from "react-native-paper";
|
|
221
221
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
@@ -229,11 +229,22 @@ function isValidHexColor(value) {
|
|
|
229
229
|
var VARIATION_SELECTOR = String.fromCodePoint(65039);
|
|
230
230
|
var ZERO_WIDTH_JOINER = String.fromCodePoint(8205);
|
|
231
231
|
var EMOJI_PATTERN2 = new RegExp(`^\\p{Extended_Pictographic}${VARIATION_SELECTOR}?(${ZERO_WIDTH_JOINER}\\p{Extended_Pictographic}${VARIATION_SELECTOR}?)*$`, "u");
|
|
232
|
+
var EMOJI_PRESENT = /\p{Extended_Pictographic}/u;
|
|
232
233
|
function isValidTag(value) {
|
|
233
234
|
if (typeof value !== "string") return false;
|
|
234
235
|
if (value.length === 0) return true;
|
|
235
236
|
if (EMOJI_PATTERN2.test(value)) return true;
|
|
236
|
-
return
|
|
237
|
+
return !EMOJI_PRESENT.test(value) && Array.from(value).length <= MAX_TAG_LENGTH;
|
|
238
|
+
}
|
|
239
|
+
function clampTag(previous, next) {
|
|
240
|
+
const previousCodepoints = Array.from(previous);
|
|
241
|
+
const nextCodepoints = Array.from(next);
|
|
242
|
+
if (nextCodepoints.length <= previousCodepoints.length) return next;
|
|
243
|
+
let sharedPrefixLength = 0;
|
|
244
|
+
while (sharedPrefixLength < previousCodepoints.length && previousCodepoints[sharedPrefixLength] === nextCodepoints[sharedPrefixLength]) sharedPrefixLength++;
|
|
245
|
+
const added = nextCodepoints.slice(sharedPrefixLength).join("");
|
|
246
|
+
if (EMOJI_PRESENT.test(added) || EMOJI_PRESENT.test(previous)) return added;
|
|
247
|
+
return nextCodepoints.length > MAX_TAG_LENGTH ? previous : next;
|
|
237
248
|
}
|
|
238
249
|
function isValidProfile(value) {
|
|
239
250
|
if (!value || typeof value !== "object") return false;
|
|
@@ -248,7 +259,7 @@ function deriveTag(name) {
|
|
|
248
259
|
const trimmed = name.trim();
|
|
249
260
|
return trimmed ? trimmed[0].toUpperCase() : "";
|
|
250
261
|
}
|
|
251
|
-
function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft }) {
|
|
262
|
+
function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref }) {
|
|
252
263
|
const { dark, colors } = useAutoPaperTheme();
|
|
253
264
|
const insets = useSafeAreaInsets();
|
|
254
265
|
const { error: showErrorToast } = useToast();
|
|
@@ -281,6 +292,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
|
|
|
281
292
|
commitEditRef.current = commitEdit;
|
|
282
293
|
});
|
|
283
294
|
useEffect(() => () => commitEditRef.current(), []);
|
|
295
|
+
useImperativeHandle(ref, () => ({ commitPendingEdit: () => commitEditRef.current() }), []);
|
|
284
296
|
const startEdit = useCallback(
|
|
285
297
|
(target) => {
|
|
286
298
|
commitEdit();
|
|
@@ -307,7 +319,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
|
|
|
307
319
|
[tagManuallySet]
|
|
308
320
|
);
|
|
309
321
|
const handleTagChange = useCallback((tag) => {
|
|
310
|
-
setDraftTag(tag);
|
|
322
|
+
setDraftTag((previous) => clampTag(previous, tag));
|
|
311
323
|
setTagManuallySet(true);
|
|
312
324
|
}, []);
|
|
313
325
|
const confirmDeleteProfile = profiles.find((p) => p.id === confirmDeleteId) ?? null;
|
|
@@ -320,7 +332,6 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
|
|
|
320
332
|
}, [confirmDeleteId, editingId, onDelete]);
|
|
321
333
|
const sortedProfiles = useMemo2(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles]);
|
|
322
334
|
const rows = [...sortedProfiles.map((profile) => ({ kind: "profile", profile })), { kind: "new" }];
|
|
323
|
-
const tagInvalid = draftTag.length > 0 && !isValidTag(draftTag);
|
|
324
335
|
return /* @__PURE__ */ jsxs2(Fragment2, { children: [
|
|
325
336
|
/* @__PURE__ */ jsxs2(View3, { style: [styles3.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }], children: [
|
|
326
337
|
headerLeft,
|
|
@@ -334,11 +345,11 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
|
|
|
334
345
|
/* @__PURE__ */ jsx3(Text3, { variant: "bodyLarge", style: { color: fg }, children: "New Profile" })
|
|
335
346
|
] }) }, "new");
|
|
336
347
|
}
|
|
337
|
-
return /* @__PURE__ */ jsx3(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange,
|
|
348
|
+
return /* @__PURE__ */ jsx3(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark }, "new");
|
|
338
349
|
}
|
|
339
350
|
const { profile } = row;
|
|
340
351
|
if (editingId === profile.id) {
|
|
341
|
-
return /* @__PURE__ */ jsx3(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange,
|
|
352
|
+
return /* @__PURE__ */ jsx3(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, onDelete: () => setConfirmDeleteId(profile.id), fgMuted, host, dark }, profile.id);
|
|
342
353
|
}
|
|
343
354
|
return /* @__PURE__ */ jsx3(TouchableRipple2, { onPress: () => startEdit(profile), style: styles3.row, children: /* @__PURE__ */ jsxs2(View3, { style: styles3.rowInner, children: [
|
|
344
355
|
/* @__PURE__ */ jsx3(ProfileChip, { profile, size: CHIP_SIZE }),
|
|
@@ -358,7 +369,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
|
|
|
358
369
|
] }) }) })
|
|
359
370
|
] });
|
|
360
371
|
}
|
|
361
|
-
function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange,
|
|
372
|
+
function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark }) {
|
|
362
373
|
const register = useFocusChain();
|
|
363
374
|
const tag = register();
|
|
364
375
|
const name = register();
|
|
@@ -371,7 +382,7 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
|
|
|
371
382
|
}, [host.openId]);
|
|
372
383
|
return /* @__PURE__ */ jsxs2(View3, { style: styles3.editRow, children: [
|
|
373
384
|
/* @__PURE__ */ jsx3(InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, tag: draftTag, dark }),
|
|
374
|
-
/* @__PURE__ */ jsx3(TextInput, { ref: tag.ref, ...tag.props,
|
|
385
|
+
/* @__PURE__ */ jsx3(TextInput, { ref: tag.ref, ...tag.props, value: draftTag, onChangeText: onTagChange, accessibilityLabel: "Tag", autoCapitalize: "characters", selectTextOnFocus: true, returnKeyType: "next", style: styles3.hiddenTagInput, pointerEvents: "none" }),
|
|
375
386
|
/* @__PURE__ */ jsx3(TextInput, { ref: name.ref, ...name.props, onSubmitEditing: onSubmit, mode: "outlined", dense: true, value: draftName, onChangeText: onNameChange, maxLength: MAX_PROFILE_NAME_LENGTH, placeholder: "Name", autoFocus, returnKeyType: "done", style: styles3.nameInput }),
|
|
376
387
|
onDelete && /* @__PURE__ */ jsx3(TouchableRipple2, { onPress: onDelete, style: styles3.iconButton, children: /* @__PURE__ */ jsx3(Icon3, { source: "trash-can-outline", size: 18, color: fgMuted }) })
|
|
377
388
|
] });
|
|
@@ -454,31 +465,14 @@ var styles3 = StyleSheet3.create({
|
|
|
454
465
|
scrollView: {
|
|
455
466
|
flex: 1
|
|
456
467
|
},
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
tagInputContent: {
|
|
466
|
-
// react-native-paper's outlined TextInput sets its own native input's minWidth from the
|
|
467
|
-
// placeholder/label's measured layout width (sized for a real label, not this field's own
|
|
468
|
-
// 3-character placeholder) — left alone, that floor is wider than CHIP_SIZE, so the actual
|
|
469
|
-
// editable box silently overflows past this field's own visible outline and centers within
|
|
470
|
-
// that wider, partly invisible box instead. Zeroing it here lets the input size to CHIP_SIZE
|
|
471
|
-
// instead, which is what actually makes `textAlign: 'center'` below center against the
|
|
472
|
-
// visible box rather than an overflowing invisible one.
|
|
473
|
-
minWidth: 0,
|
|
474
|
-
// Same reasoning as minWidth above, for the horizontal direction: the outlined input's own
|
|
475
|
-
// default padding (16px a side) is sized for a normal-width field, and at CHIP_SIZE's 40px
|
|
476
|
-
// that leaves only 8px total for actual text — not enough to fit the "Tag" placeholder itself
|
|
477
|
-
// (3 characters), which is what was clipping it to just "T" once minWidth stopped letting the
|
|
478
|
-
// box silently overflow wider to compensate.
|
|
479
|
-
paddingHorizontal: 2,
|
|
480
|
-
textAlign: "center",
|
|
481
|
-
textAlignVertical: "center"
|
|
468
|
+
// 1x1 and fully transparent — there's nothing to see here on purpose (see EditRow's own comment
|
|
469
|
+
// above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
|
|
470
|
+
// entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
|
|
471
|
+
hiddenTagInput: {
|
|
472
|
+
height: 1,
|
|
473
|
+
opacity: 0,
|
|
474
|
+
position: "absolute",
|
|
475
|
+
width: 1
|
|
482
476
|
},
|
|
483
477
|
title: {
|
|
484
478
|
flexShrink: 1,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tastic/profile",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
4
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
5
|
"keywords": [
|
|
6
6
|
"react-native",
|
package/src/ProfilesManager.tsx
CHANGED
|
@@ -3,18 +3,17 @@ import { TouchableRipple } from '@rific/feedback-press'
|
|
|
3
3
|
import { useFocusChain } from '@rific/focus-chain'
|
|
4
4
|
import { useToast } from '@rific/toaster'
|
|
5
5
|
import { InlineColorPicker, PopoverHost, usePopoverHost } from '@tastic/hud'
|
|
6
|
-
import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
6
|
+
import { ReactNode, Ref, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
|
7
7
|
import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, View } from 'react-native'
|
|
8
8
|
import { Button, Icon, Portal, Text, TextInput } from 'react-native-paper'
|
|
9
9
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
10
10
|
|
|
11
11
|
import { ProfileChip } from './ProfileChip'
|
|
12
|
-
import { isValidTag, MAX_PROFILE_NAME_LENGTH } from './profilesValidation'
|
|
12
|
+
import { clampTag, isValidTag, MAX_PROFILE_NAME_LENGTH } from './profilesValidation'
|
|
13
13
|
import { Profile } from './types'
|
|
14
14
|
|
|
15
|
-
// Shared by the New Profile row's own dashed circle
|
|
16
|
-
//
|
|
17
|
-
// each picking its own.
|
|
15
|
+
// Shared by the New Profile row's own dashed circle and every profile row's own ProfileChip — one
|
|
16
|
+
// size for every circle on this screen, rather than each picking its own.
|
|
18
17
|
const CHIP_SIZE = 40
|
|
19
18
|
|
|
20
19
|
interface ProfileEditPatch {
|
|
@@ -23,6 +22,18 @@ interface ProfileEditPatch {
|
|
|
23
22
|
tag: string
|
|
24
23
|
}
|
|
25
24
|
|
|
25
|
+
export interface ProfilesManagerHandle {
|
|
26
|
+
// Flushes whatever row is currently being edited — the same thing this component's own unmount
|
|
27
|
+
// effect does, but callable explicitly. Exists because "unmount" isn't a reliable "the user is
|
|
28
|
+
// navigating away" signal on every host: React Navigation's web renderer keeps a popped screen
|
|
29
|
+
// mounted-but-hidden instead of unmounting it, so a host relying only on the unmount fallback
|
|
30
|
+
// silently loses an in-progress edit on `router.back()` there. A host whose router doesn't
|
|
31
|
+
// genuinely unmount this component on navigation should hold a ref and call this right before
|
|
32
|
+
// navigating away (e.g. in its own back-button handler). Safe to call any time, including when
|
|
33
|
+
// nothing is being edited — a no-op then, same as the unmount effect already is.
|
|
34
|
+
commitPendingEdit: () => void
|
|
35
|
+
}
|
|
36
|
+
|
|
26
37
|
export interface ProfilesManagerProps {
|
|
27
38
|
profiles: Profile[]
|
|
28
39
|
// The color a brand-new profile's draft starts on — this component doesn't have (and shouldn't
|
|
@@ -38,6 +49,9 @@ export interface ProfilesManagerProps {
|
|
|
38
49
|
// rendering slot, not an onBack-style callback: this component doesn't know or care that it's a
|
|
39
50
|
// back button, only that the host wants something shown there. Omit to hide the slot.
|
|
40
51
|
headerLeft?: ReactNode
|
|
52
|
+
// React 19 accepts `ref` as a plain prop on a function component (no forwardRef needed) — see
|
|
53
|
+
// ProfilesManagerHandle's own doc for why a host may need this.
|
|
54
|
+
ref?: Ref<ProfilesManagerHandle>
|
|
41
55
|
}
|
|
42
56
|
|
|
43
57
|
type Row = { kind: 'profile'; profile: Profile } | { kind: 'new' }
|
|
@@ -56,7 +70,11 @@ function deriveTag(name: string): string {
|
|
|
56
70
|
// would still assume *some* host owns navigation; this component doesn't even go that far), so any
|
|
57
71
|
// consuming screen just renders it as its body inside its own routed wrapper (back button, native
|
|
58
72
|
// push/pop transition, whatever persistence hook backs `profiles`/`onCreate`/`onSave`/`onDelete`).
|
|
59
|
-
|
|
73
|
+
// Staying router-agnostic means this component can't know for itself whether "navigating away"
|
|
74
|
+
// really unmounts it on a given host's router — see ProfilesManagerHandle's own doc for the
|
|
75
|
+
// react-navigation-web case where it doesn't, and why a host there needs the `ref` escape hatch
|
|
76
|
+
// instead of relying on the unmount fallback alone.
|
|
77
|
+
export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref }: ProfilesManagerProps) {
|
|
60
78
|
const { dark, colors } = useAutoPaperTheme()
|
|
61
79
|
const insets = useSafeAreaInsets()
|
|
62
80
|
const { error: showErrorToast } = useToast()
|
|
@@ -117,6 +135,11 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
|
|
|
117
135
|
commitEditRef.current = commitEdit
|
|
118
136
|
})
|
|
119
137
|
useEffect(() => () => commitEditRef.current(), [])
|
|
138
|
+
// The explicit escape hatch for hosts whose "navigate away" doesn't genuinely unmount this
|
|
139
|
+
// component — see ProfilesManagerHandle's own doc. `[]` deps: the handle object itself never
|
|
140
|
+
// needs to change identity, since it always calls through the ref to whatever commitEdit is
|
|
141
|
+
// current at call time, the same indirection the unmount effect above already relies on.
|
|
142
|
+
useImperativeHandle(ref, () => ({ commitPendingEdit: () => commitEditRef.current() }), [])
|
|
120
143
|
|
|
121
144
|
const startEdit = useCallback(
|
|
122
145
|
(target: 'new' | Profile) => {
|
|
@@ -145,8 +168,14 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
|
|
|
145
168
|
[tagManuallySet]
|
|
146
169
|
)
|
|
147
170
|
|
|
171
|
+
// The functional setDraftTag form — reading `previous` from React's own latest-applied state
|
|
172
|
+
// rather than closing over the `draftTag` variable — matters here, not just style: fast/rapid
|
|
173
|
+
// keystrokes can fire their onChangeText calls before a re-render lands, batched into the same
|
|
174
|
+
// update, and clamping every one of them against the same stale `draftTag` (captured once at
|
|
175
|
+
// render time) would let the later ones silently clobber what the earlier ones already clamped
|
|
176
|
+
// to, rather than each building on the last.
|
|
148
177
|
const handleTagChange = useCallback((tag: string) => {
|
|
149
|
-
setDraftTag(tag)
|
|
178
|
+
setDraftTag((previous) => clampTag(previous, tag))
|
|
150
179
|
setTagManuallySet(true)
|
|
151
180
|
}, [])
|
|
152
181
|
|
|
@@ -164,7 +193,6 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
|
|
|
164
193
|
// handful of profiles on one shared device.
|
|
165
194
|
const sortedProfiles = useMemo(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles])
|
|
166
195
|
const rows: Row[] = [...sortedProfiles.map((profile): Row => ({ kind: 'profile', profile })), { kind: 'new' }]
|
|
167
|
-
const tagInvalid = draftTag.length > 0 && !isValidTag(draftTag)
|
|
168
196
|
|
|
169
197
|
return (
|
|
170
198
|
<>
|
|
@@ -198,12 +226,12 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
|
|
|
198
226
|
</TouchableRipple>
|
|
199
227
|
)
|
|
200
228
|
}
|
|
201
|
-
return <EditRow key='new' draftName={draftName} onNameChange={handleNameChange} draftColor={draftColor} onColorChange={setDraftColor} draftTag={draftTag} onTagChange={handleTagChange}
|
|
229
|
+
return <EditRow key='new' draftName={draftName} onNameChange={handleNameChange} draftColor={draftColor} onColorChange={setDraftColor} draftTag={draftTag} onTagChange={handleTagChange} onSubmit={commitEdit} autoFocus fgMuted={fgMuted} host={host} dark={dark} />
|
|
202
230
|
}
|
|
203
231
|
|
|
204
232
|
const { profile } = row
|
|
205
233
|
if (editingId === profile.id) {
|
|
206
|
-
return <EditRow key={profile.id} draftName={draftName} onNameChange={handleNameChange} draftColor={draftColor} onColorChange={setDraftColor} draftTag={draftTag} onTagChange={handleTagChange}
|
|
234
|
+
return <EditRow key={profile.id} draftName={draftName} onNameChange={handleNameChange} draftColor={draftColor} onColorChange={setDraftColor} draftTag={draftTag} onTagChange={handleTagChange} onSubmit={commitEdit} onDelete={() => setConfirmDeleteId(profile.id)} fgMuted={fgMuted} host={host} dark={dark} />
|
|
207
235
|
}
|
|
208
236
|
|
|
209
237
|
// No delete affordance at rest — tap the row to start editing, which is the one place
|
|
@@ -260,7 +288,6 @@ interface EditRowProps {
|
|
|
260
288
|
onColorChange: (color: string) => void
|
|
261
289
|
draftTag: string
|
|
262
290
|
onTagChange: (tag: string) => void
|
|
263
|
-
tagInvalid: boolean
|
|
264
291
|
onSubmit: () => void
|
|
265
292
|
// Omitted for the 'new' row — nothing saved yet to delete.
|
|
266
293
|
onDelete?: () => void
|
|
@@ -270,18 +297,19 @@ interface EditRowProps {
|
|
|
270
297
|
dark: boolean
|
|
271
298
|
}
|
|
272
299
|
|
|
273
|
-
// One row, expanded: the color trigger (@tastic/hud's InlineColorPicker)
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
// one combined action instead
|
|
300
|
+
// One row, expanded: the color trigger (@tastic/hud's InlineColorPicker) — doubling as the tag's
|
|
301
|
+
// own display via its `tag` prop below, since there's no separate visible tag field anymore — an
|
|
302
|
+
// invisible TextInput that exists purely to actually capture what's typed (RN has no way to bring
|
|
303
|
+
// up a keyboard without a real focusable input; this one is just never meant to be *seen*), the
|
|
304
|
+
// name taking up the row's remaining space, and — only for an existing profile — a delete icon at
|
|
305
|
+
// the very end. The two effects below are what wires tapping the swatch to popping the keyboard on
|
|
306
|
+
// the hidden tag field too, so typing a tag and picking a color read as one combined action instead
|
|
307
|
+
// of two separate taps, with the swatch itself as the only place the tag ever visibly shows.
|
|
280
308
|
// Return-key chains tag -> name via @rific/focus-chain rather than each field submitting on its
|
|
281
309
|
// own — submitting the *name* field is what actually commits the row, so its registration's own
|
|
282
310
|
// onSubmitEditing (a no-op — it's last in the chain) is overridden with onSubmit below, matching
|
|
283
311
|
// how a normal multi-field form reads: fill fields in order, the last one finishes it.
|
|
284
|
-
function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange,
|
|
312
|
+
function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark }: EditRowProps) {
|
|
285
313
|
const register = useFocusChain()
|
|
286
314
|
const tag = register()
|
|
287
315
|
const name = register()
|
|
@@ -309,12 +337,21 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
|
|
|
309
337
|
return (
|
|
310
338
|
<View style={styles.editRow}>
|
|
311
339
|
<InlineColorPicker id='color' host={host} value={draftColor} onChange={onColorChange} tag={draftTag} dark={dark} />
|
|
312
|
-
{/*
|
|
313
|
-
|
|
340
|
+
{/* Invisible on purpose (styles.hiddenTagInput: zero footprint, position: 'absolute' so it
|
|
341
|
+
doesn't reserve space in the row's own flex layout) — the swatch above is the only place this
|
|
342
|
+
value is ever meant to be seen. pointerEvents='none' is what actually keeps it out of the
|
|
343
|
+
way: without it, this field — even at 1x1 and fully transparent — still sits in front of the
|
|
344
|
+
swatch for hit-testing purposes and swallows the tap meant to open the color popover, since
|
|
345
|
+
focusing this field is never done by tapping it directly anyway (see the effect above, which
|
|
346
|
+
focuses it programmatically). accessibilityLabel stands in for the placeholder a visible field
|
|
347
|
+
would have had, so a screen reader still identifies it, and so this file's own tests can still
|
|
348
|
+
find it as "Tag" the same way they find the Name field by its placeholder. selectTextOnFocus
|
|
349
|
+
so re-tapping the swatch to revise an already-set tag replaces it wholesale rather than
|
|
350
|
+
inserting mid-string. */}
|
|
314
351
|
{/* eslint-disable-next-line react-hooks/refs -- tag.ref/tag.props come from useFocusChain's
|
|
315
352
|
register(), called during render by design (see the hook's own doc); the actual DOM/native
|
|
316
353
|
focus() call it wraps only ever fires later, from an event handler, never synchronously here */}
|
|
317
|
-
<TextInput ref={tag.ref} {...tag.props}
|
|
354
|
+
<TextInput ref={tag.ref} {...tag.props} value={draftTag} onChangeText={onTagChange} accessibilityLabel='Tag' autoCapitalize='characters' selectTextOnFocus returnKeyType='next' style={styles.hiddenTagInput} pointerEvents='none' />
|
|
318
355
|
{/* eslint-disable-next-line react-hooks/refs -- see the tag field's identical note above */}
|
|
319
356
|
<TextInput ref={name.ref} {...name.props} onSubmitEditing={onSubmit} mode='outlined' dense value={draftName} onChangeText={onNameChange} maxLength={MAX_PROFILE_NAME_LENGTH} placeholder='Name' autoFocus={autoFocus} returnKeyType='done' style={styles.nameInput} />
|
|
320
357
|
{onDelete && (
|
|
@@ -404,31 +441,14 @@ const styles = StyleSheet.create({
|
|
|
404
441
|
scrollView: {
|
|
405
442
|
flex: 1
|
|
406
443
|
},
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
tagInputContent: {
|
|
416
|
-
// react-native-paper's outlined TextInput sets its own native input's minWidth from the
|
|
417
|
-
// placeholder/label's measured layout width (sized for a real label, not this field's own
|
|
418
|
-
// 3-character placeholder) — left alone, that floor is wider than CHIP_SIZE, so the actual
|
|
419
|
-
// editable box silently overflows past this field's own visible outline and centers within
|
|
420
|
-
// that wider, partly invisible box instead. Zeroing it here lets the input size to CHIP_SIZE
|
|
421
|
-
// instead, which is what actually makes `textAlign: 'center'` below center against the
|
|
422
|
-
// visible box rather than an overflowing invisible one.
|
|
423
|
-
minWidth: 0,
|
|
424
|
-
// Same reasoning as minWidth above, for the horizontal direction: the outlined input's own
|
|
425
|
-
// default padding (16px a side) is sized for a normal-width field, and at CHIP_SIZE's 40px
|
|
426
|
-
// that leaves only 8px total for actual text — not enough to fit the "Tag" placeholder itself
|
|
427
|
-
// (3 characters), which is what was clipping it to just "T" once minWidth stopped letting the
|
|
428
|
-
// box silently overflow wider to compensate.
|
|
429
|
-
paddingHorizontal: 2,
|
|
430
|
-
textAlign: 'center',
|
|
431
|
-
textAlignVertical: 'center'
|
|
444
|
+
// 1x1 and fully transparent — there's nothing to see here on purpose (see EditRow's own comment
|
|
445
|
+
// above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
|
|
446
|
+
// entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
|
|
447
|
+
hiddenTagInput: {
|
|
448
|
+
height: 1,
|
|
449
|
+
opacity: 0,
|
|
450
|
+
position: 'absolute',
|
|
451
|
+
width: 1
|
|
432
452
|
},
|
|
433
453
|
title: {
|
|
434
454
|
flexShrink: 1,
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { ProfileChip } from './ProfileChip'
|
|
2
2
|
export { ProfilePicker } from './ProfilePicker'
|
|
3
|
-
export { ProfilesManager, type ProfilesManagerProps } from './ProfilesManager'
|
|
3
|
+
export { ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps } from './ProfilesManager'
|
|
4
4
|
export { isValidProfile, isValidTag, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH } from './profilesValidation'
|
|
5
5
|
export { isSharedProfileStoreAvailable, loadSharedProfiles, saveSharedProfiles } from './sharedProfileStore'
|
|
6
6
|
export type { Profile } from './types'
|
|
@@ -21,6 +21,12 @@ const ZERO_WIDTH_JOINER = String.fromCodePoint(0x200d)
|
|
|
21
21
|
// segmentation (Intl.Segmenter's own Hermes support is inconsistent enough not to lean on here).
|
|
22
22
|
const EMOJI_PATTERN = new RegExp(`^\\p{Extended_Pictographic}${VARIATION_SELECTOR}?(${ZERO_WIDTH_JOINER}\\p{Extended_Pictographic}${VARIATION_SELECTOR}?)*$`, 'u')
|
|
23
23
|
|
|
24
|
+
// Presence check, not the full anchored EMOJI_PATTERN above — clampTag below only needs to know
|
|
25
|
+
// whether a just-typed chunk contains a pictograph at all, not whether an entire value is exactly
|
|
26
|
+
// one (possibly ZWJ-joined) emoji. Same distinction @tastic/hud's InlineColorPicker draws for its
|
|
27
|
+
// own font-sizing check.
|
|
28
|
+
const EMOJI_PRESENT = /\p{Extended_Pictographic}/u
|
|
29
|
+
|
|
24
30
|
// Empty is valid too — a profile can have no tag yet, same as it can go without ever entering an
|
|
25
31
|
// editor's own tag field at all; every place this renders falls back to a generic icon in that case
|
|
26
32
|
// (see ProfileChip).
|
|
@@ -28,7 +34,33 @@ export function isValidTag(value: unknown): value is string {
|
|
|
28
34
|
if (typeof value !== 'string') return false
|
|
29
35
|
if (value.length === 0) return true
|
|
30
36
|
if (EMOJI_PATTERN.test(value)) return true
|
|
31
|
-
return
|
|
37
|
+
return !EMOJI_PRESENT.test(value) && Array.from(value).length <= MAX_TAG_LENGTH
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Clamps a tag field's live value on every keystroke so it's never possible to type your way into
|
|
41
|
+
// a state isValidTag would reject, rather than allowing it and only catching it later (a toast on
|
|
42
|
+
// save, or a swatch preview that overflows/truncates). `previous` is the value before this
|
|
43
|
+
// keystroke, `next` is what the field reports after it.
|
|
44
|
+
export function clampTag(previous: string, next: string): string {
|
|
45
|
+
const previousCodepoints = Array.from(previous)
|
|
46
|
+
const nextCodepoints = Array.from(next)
|
|
47
|
+
|
|
48
|
+
// Shrinking (backspace/delete, or a selectTextOnFocus-driven wholesale replacement — see
|
|
49
|
+
// ProfilesManager's own EditRow — reporting its own replacement text as no longer than what it
|
|
50
|
+
// overwrote) always passes through untouched: there's nothing to clamp about removing content.
|
|
51
|
+
if (nextCodepoints.length <= previousCodepoints.length) return next
|
|
52
|
+
|
|
53
|
+
let sharedPrefixLength = 0
|
|
54
|
+
while (sharedPrefixLength < previousCodepoints.length && previousCodepoints[sharedPrefixLength] === nextCodepoints[sharedPrefixLength]) sharedPrefixLength++
|
|
55
|
+
const added = nextCodepoints.slice(sharedPrefixLength).join('')
|
|
56
|
+
|
|
57
|
+
// A tag is either a handful of plain characters or exactly one emoji, never a mix — typing more
|
|
58
|
+
// of either kind on top of the other one starts fresh with just what's newly typed, rather than
|
|
59
|
+
// producing a combination isValidTag would reject anyway (an emoji followed by letters, or a
|
|
60
|
+
// second emoji tacked onto the first).
|
|
61
|
+
if (EMOJI_PRESENT.test(added) || EMOJI_PRESENT.test(previous)) return added
|
|
62
|
+
|
|
63
|
+
return nextCodepoints.length > MAX_TAG_LENGTH ? previous : next
|
|
32
64
|
}
|
|
33
65
|
|
|
34
66
|
// Validates only the fields this package's own `Profile` type declares — an app that extends
|