@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 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 !/\p{Extended_Pictographic}/u.test(value) && Array.from(value).length <= MAX_TAG_LENGTH;
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, tagInvalid, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark }, "new");
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, tagInvalid, onSubmit: commitEdit, onDelete: () => setConfirmDeleteId(profile.id), fgMuted, host, dark }, profile.id);
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, tagInvalid, onSubmit, onDelete, autoFocus, fgMuted, host, dark }) {
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, mode: "outlined", dense: true, value: draftTag, onChangeText: onTagChange, placeholder: "Tag", autoCapitalize: "characters", selectTextOnFocus: true, error: tagInvalid, returnKeyType: "next", style: styles3.tagInput, contentStyle: styles3.tagInputContent }),
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
- // Wide enough for MAX_TAG_LENGTH monospace characters (or one emoji) plus its own padding/border
486
- // — matched to CHIP_SIZE (the same footprint every other circle on this screen uses), not a
487
- // fraction of the row's width, since the name field next to it is what should actually get the
488
- // row's remaining space.
489
- tagInput: {
490
- height: CHIP_SIZE,
491
- width: CHIP_SIZE
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 !/\p{Extended_Pictographic}/u.test(value) && Array.from(value).length <= MAX_TAG_LENGTH;
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, tagInvalid, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark }, "new");
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, tagInvalid, onSubmit: commitEdit, onDelete: () => setConfirmDeleteId(profile.id), fgMuted, host, dark }, profile.id);
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, tagInvalid, onSubmit, onDelete, autoFocus, fgMuted, host, dark }) {
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, mode: "outlined", dense: true, value: draftTag, onChangeText: onTagChange, placeholder: "Tag", autoCapitalize: "characters", selectTextOnFocus: true, error: tagInvalid, returnKeyType: "next", style: styles3.tagInput, contentStyle: styles3.tagInputContent }),
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
- // Wide enough for MAX_TAG_LENGTH monospace characters (or one emoji) plus its own padding/border
458
- // — matched to CHIP_SIZE (the same footprint every other circle on this screen uses), not a
459
- // fraction of the row's width, since the name field next to it is what should actually get the
460
- // row's remaining space.
461
- tagInput: {
462
- height: CHIP_SIZE,
463
- width: CHIP_SIZE
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.5",
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",
@@ -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, every profile row's own ProfileChip, the edit
16
- // row's color trigger, and its tag field — one size for every circle on this screen, rather than
17
- // each picking its own.
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
- export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft }: ProfilesManagerProps) {
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} tagInvalid={tagInvalid} onSubmit={commitEdit} autoFocus fgMuted={fgMuted} host={host} dark={dark} />
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} tagInvalid={tagInvalid} onSubmit={commitEdit} onDelete={() => setConfirmDeleteId(profile.id)} fgMuted={fgMuted} host={host} dark={dark} />
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), a tag field clamped to
274
- // CHIP_SIZE, the name taking up whatever's
275
- // left, and — only for an existing profile — a delete icon at the very end. `tag={draftTag}` IS
276
- // passed to the color trigger here (unlike a plain identity badge elsewhere) so it shows the same
277
- // live draft value the tag field itself does — the two effects below are what wires tapping the
278
- // swatch to popping the keyboard on the tag field too, so typing a tag and picking a color read as
279
- // one combined action instead of two separate taps.
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, tagInvalid, onSubmit, onDelete, autoFocus, fgMuted, host, dark }: EditRowProps) {
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
- {/* selectTextOnFocus so tapping into an already-set tag selects it for wholesale replacement
313
- — the far more common edit than inserting into the middle of a 1-3 character value. */}
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} mode='outlined' dense value={draftTag} onChangeText={onTagChange} placeholder='Tag' autoCapitalize='characters' selectTextOnFocus error={tagInvalid} returnKeyType='next' style={styles.tagInput} contentStyle={styles.tagInputContent} />
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
- // Wide enough for MAX_TAG_LENGTH monospace characters (or one emoji) plus its own padding/border
408
- // — matched to CHIP_SIZE (the same footprint every other circle on this screen uses), not a
409
- // fraction of the row's width, since the name field next to it is what should actually get the
410
- // row's remaining space.
411
- tagInput: {
412
- height: CHIP_SIZE,
413
- width: CHIP_SIZE
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 !/\p{Extended_Pictographic}/u.test(value) && Array.from(value).length <= MAX_TAG_LENGTH
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