@tastic/profile 0.3.4 → 0.3.6

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.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;
@@ -335,7 +346,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
335
346
  [tagManuallySet]
336
347
  );
337
348
  const handleTagChange = (0, import_react2.useCallback)((tag) => {
338
- setDraftTag(tag);
349
+ setDraftTag((previous) => clampTag(previous, tag));
339
350
  setTagManuallySet(true);
340
351
  }, []);
341
352
  const confirmDeleteProfile = profiles.find((p) => p.id === confirmDeleteId) ?? null;
@@ -348,7 +359,6 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
348
359
  }, [confirmDeleteId, editingId, onDelete]);
349
360
  const sortedProfiles = (0, import_react2.useMemo)(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles]);
350
361
  const rows = [...sortedProfiles.map((profile) => ({ kind: "profile", profile })), { kind: "new" }];
351
- const tagInvalid = draftTag.length > 0 && !isValidTag(draftTag);
352
362
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
353
363
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native3.View, { style: [styles3.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }], children: [
354
364
  headerLeft,
@@ -362,11 +372,11 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
362
372
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.Text, { variant: "bodyLarge", style: { color: fg }, children: "New Profile" })
363
373
  ] }) }, "new");
364
374
  }
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");
375
+ 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
376
  }
367
377
  const { profile } = row;
368
378
  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);
379
+ 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
380
  }
371
381
  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
382
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(ProfileChip, { profile, size: CHIP_SIZE }),
@@ -386,13 +396,20 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
386
396
  ] }) }) })
387
397
  ] });
388
398
  }
389
- function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, tagInvalid, onSubmit, onDelete, autoFocus, fgMuted, host, dark }) {
399
+ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark }) {
390
400
  const register = (0, import_focus_chain.useFocusChain)();
391
401
  const tag = register();
392
402
  const name = register();
403
+ const tagFocusRef = (0, import_react2.useRef)(tag.props.focus);
404
+ (0, import_react2.useEffect)(() => {
405
+ tagFocusRef.current = tag.props.focus;
406
+ });
407
+ (0, import_react2.useEffect)(() => {
408
+ if (host.openId === "color") tagFocusRef.current();
409
+ }, [host.openId]);
393
410
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native3.View, { style: styles3.editRow, children: [
394
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_hud2.InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, dark }),
395
- /* @__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 }),
411
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_hud2.InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, tag: draftTag, dark }),
412
+ /* @__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" }),
396
413
  /* @__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 }),
397
414
  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 }) })
398
415
  ] });
@@ -475,31 +492,14 @@ var styles3 = import_react_native3.StyleSheet.create({
475
492
  scrollView: {
476
493
  flex: 1
477
494
  },
478
- // Wide enough for MAX_TAG_LENGTH monospace characters (or one emoji) plus its own padding/border
479
- // — matched to CHIP_SIZE (the same footprint every other circle on this screen uses), not a
480
- // fraction of the row's width, since the name field next to it is what should actually get the
481
- // row's remaining space.
482
- tagInput: {
483
- height: CHIP_SIZE,
484
- width: CHIP_SIZE
485
- },
486
- tagInputContent: {
487
- // react-native-paper's outlined TextInput sets its own native input's minWidth from the
488
- // placeholder/label's measured layout width (sized for a real label, not this field's own
489
- // 3-character placeholder) — left alone, that floor is wider than CHIP_SIZE, so the actual
490
- // editable box silently overflows past this field's own visible outline and centers within
491
- // that wider, partly invisible box instead. Zeroing it here lets the input size to CHIP_SIZE
492
- // instead, which is what actually makes `textAlign: 'center'` below center against the
493
- // visible box rather than an overflowing invisible one.
494
- minWidth: 0,
495
- // Same reasoning as minWidth above, for the horizontal direction: the outlined input's own
496
- // default padding (16px a side) is sized for a normal-width field, and at CHIP_SIZE's 40px
497
- // that leaves only 8px total for actual text — not enough to fit the "Tag" placeholder itself
498
- // (3 characters), which is what was clipping it to just "T" once minWidth stopped letting the
499
- // box silently overflow wider to compensate.
500
- paddingHorizontal: 2,
501
- textAlign: "center",
502
- textAlignVertical: "center"
495
+ // 1x1 and fully transparent — there's nothing to see here on purpose (see EditRow's own comment
496
+ // above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
497
+ // entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
498
+ hiddenTagInput: {
499
+ height: 1,
500
+ opacity: 0,
501
+ position: "absolute",
502
+ width: 1
503
503
  },
504
504
  title: {
505
505
  flexShrink: 1,
package/dist/index.mjs CHANGED
@@ -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;
@@ -307,7 +318,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
307
318
  [tagManuallySet]
308
319
  );
309
320
  const handleTagChange = useCallback((tag) => {
310
- setDraftTag(tag);
321
+ setDraftTag((previous) => clampTag(previous, tag));
311
322
  setTagManuallySet(true);
312
323
  }, []);
313
324
  const confirmDeleteProfile = profiles.find((p) => p.id === confirmDeleteId) ?? null;
@@ -320,7 +331,6 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
320
331
  }, [confirmDeleteId, editingId, onDelete]);
321
332
  const sortedProfiles = useMemo2(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles]);
322
333
  const rows = [...sortedProfiles.map((profile) => ({ kind: "profile", profile })), { kind: "new" }];
323
- const tagInvalid = draftTag.length > 0 && !isValidTag(draftTag);
324
334
  return /* @__PURE__ */ jsxs2(Fragment2, { children: [
325
335
  /* @__PURE__ */ jsxs2(View3, { style: [styles3.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }], children: [
326
336
  headerLeft,
@@ -334,11 +344,11 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
334
344
  /* @__PURE__ */ jsx3(Text3, { variant: "bodyLarge", style: { color: fg }, children: "New Profile" })
335
345
  ] }) }, "new");
336
346
  }
337
- return /* @__PURE__ */ jsx3(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, tagInvalid, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark }, "new");
347
+ return /* @__PURE__ */ jsx3(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark }, "new");
338
348
  }
339
349
  const { profile } = row;
340
350
  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);
351
+ 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
352
  }
343
353
  return /* @__PURE__ */ jsx3(TouchableRipple2, { onPress: () => startEdit(profile), style: styles3.row, children: /* @__PURE__ */ jsxs2(View3, { style: styles3.rowInner, children: [
344
354
  /* @__PURE__ */ jsx3(ProfileChip, { profile, size: CHIP_SIZE }),
@@ -358,13 +368,20 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
358
368
  ] }) }) })
359
369
  ] });
360
370
  }
361
- function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, tagInvalid, onSubmit, onDelete, autoFocus, fgMuted, host, dark }) {
371
+ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark }) {
362
372
  const register = useFocusChain();
363
373
  const tag = register();
364
374
  const name = register();
375
+ const tagFocusRef = useRef(tag.props.focus);
376
+ useEffect(() => {
377
+ tagFocusRef.current = tag.props.focus;
378
+ });
379
+ useEffect(() => {
380
+ if (host.openId === "color") tagFocusRef.current();
381
+ }, [host.openId]);
365
382
  return /* @__PURE__ */ jsxs2(View3, { style: styles3.editRow, children: [
366
- /* @__PURE__ */ jsx3(InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, dark }),
367
- /* @__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 }),
383
+ /* @__PURE__ */ jsx3(InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, tag: draftTag, dark }),
384
+ /* @__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" }),
368
385
  /* @__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 }),
369
386
  onDelete && /* @__PURE__ */ jsx3(TouchableRipple2, { onPress: onDelete, style: styles3.iconButton, children: /* @__PURE__ */ jsx3(Icon3, { source: "trash-can-outline", size: 18, color: fgMuted }) })
370
387
  ] });
@@ -447,31 +464,14 @@ var styles3 = StyleSheet3.create({
447
464
  scrollView: {
448
465
  flex: 1
449
466
  },
450
- // Wide enough for MAX_TAG_LENGTH monospace characters (or one emoji) plus its own padding/border
451
- // — matched to CHIP_SIZE (the same footprint every other circle on this screen uses), not a
452
- // fraction of the row's width, since the name field next to it is what should actually get the
453
- // row's remaining space.
454
- tagInput: {
455
- height: CHIP_SIZE,
456
- width: CHIP_SIZE
457
- },
458
- tagInputContent: {
459
- // react-native-paper's outlined TextInput sets its own native input's minWidth from the
460
- // placeholder/label's measured layout width (sized for a real label, not this field's own
461
- // 3-character placeholder) — left alone, that floor is wider than CHIP_SIZE, so the actual
462
- // editable box silently overflows past this field's own visible outline and centers within
463
- // that wider, partly invisible box instead. Zeroing it here lets the input size to CHIP_SIZE
464
- // instead, which is what actually makes `textAlign: 'center'` below center against the
465
- // visible box rather than an overflowing invisible one.
466
- minWidth: 0,
467
- // Same reasoning as minWidth above, for the horizontal direction: the outlined input's own
468
- // default padding (16px a side) is sized for a normal-width field, and at CHIP_SIZE's 40px
469
- // that leaves only 8px total for actual text — not enough to fit the "Tag" placeholder itself
470
- // (3 characters), which is what was clipping it to just "T" once minWidth stopped letting the
471
- // box silently overflow wider to compensate.
472
- paddingHorizontal: 2,
473
- textAlign: "center",
474
- textAlignVertical: "center"
467
+ // 1x1 and fully transparent — there's nothing to see here on purpose (see EditRow's own comment
468
+ // above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
469
+ // entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
470
+ hiddenTagInput: {
471
+ height: 1,
472
+ opacity: 0,
473
+ position: "absolute",
474
+ width: 1
475
475
  },
476
476
  title: {
477
477
  flexShrink: 1,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/profile",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
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",
@@ -9,12 +9,11 @@ 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 {
@@ -145,8 +144,14 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
145
144
  [tagManuallySet]
146
145
  )
147
146
 
147
+ // The functional setDraftTag form — reading `previous` from React's own latest-applied state
148
+ // rather than closing over the `draftTag` variable — matters here, not just style: fast/rapid
149
+ // keystrokes can fire their onChangeText calls before a re-render lands, batched into the same
150
+ // update, and clamping every one of them against the same stale `draftTag` (captured once at
151
+ // render time) would let the later ones silently clobber what the earlier ones already clamped
152
+ // to, rather than each building on the last.
148
153
  const handleTagChange = useCallback((tag: string) => {
149
- setDraftTag(tag)
154
+ setDraftTag((previous) => clampTag(previous, tag))
150
155
  setTagManuallySet(true)
151
156
  }, [])
152
157
 
@@ -164,7 +169,6 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
164
169
  // handful of profiles on one shared device.
165
170
  const sortedProfiles = useMemo(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles])
166
171
  const rows: Row[] = [...sortedProfiles.map((profile): Row => ({ kind: 'profile', profile })), { kind: 'new' }]
167
- const tagInvalid = draftTag.length > 0 && !isValidTag(draftTag)
168
172
 
169
173
  return (
170
174
  <>
@@ -198,12 +202,12 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
198
202
  </TouchableRipple>
199
203
  )
200
204
  }
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} />
205
+ 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
206
  }
203
207
 
204
208
  const { profile } = row
205
209
  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} />
210
+ 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
211
  }
208
212
 
209
213
  // No delete affordance at rest — tap the row to start editing, which is the one place
@@ -260,7 +264,6 @@ interface EditRowProps {
260
264
  onColorChange: (color: string) => void
261
265
  draftTag: string
262
266
  onTagChange: (tag: string) => void
263
- tagInvalid: boolean
264
267
  onSubmit: () => void
265
268
  // Omitted for the 'new' row — nothing saved yet to delete.
266
269
  onDelete?: () => void
@@ -270,30 +273,61 @@ interface EditRowProps {
270
273
  dark: boolean
271
274
  }
272
275
 
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. No `tag` passed to the
276
- // color trigger here — it keeps its plain palette icon regardless of the row's own tag field, since
277
- // the tag is already right next to it in its own field, so repeating it on the color trigger too
278
- // would be redundant rather than informative.
276
+ // One row, expanded: the color trigger (@tastic/hud's InlineColorPicker) — doubling as the tag's
277
+ // own display via its `tag` prop below, since there's no separate visible tag field anymore — an
278
+ // invisible TextInput that exists purely to actually capture what's typed (RN has no way to bring
279
+ // up a keyboard without a real focusable input; this one is just never meant to be *seen*), the
280
+ // name taking up the row's remaining space, and — only for an existing profile — a delete icon at
281
+ // the very end. The two effects below are what wires tapping the swatch to popping the keyboard on
282
+ // the hidden tag field too, so typing a tag and picking a color read as one combined action instead
283
+ // of two separate taps, with the swatch itself as the only place the tag ever visibly shows.
279
284
  // Return-key chains tag -> name via @rific/focus-chain rather than each field submitting on its
280
285
  // own — submitting the *name* field is what actually commits the row, so its registration's own
281
286
  // onSubmitEditing (a no-op — it's last in the chain) is overridden with onSubmit below, matching
282
287
  // how a normal multi-field form reads: fill fields in order, the last one finishes it.
283
- function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, tagInvalid, onSubmit, onDelete, autoFocus, fgMuted, host, dark }: EditRowProps) {
288
+ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark }: EditRowProps) {
284
289
  const register = useFocusChain()
285
290
  const tag = register()
286
291
  const name = register()
287
292
 
293
+ // tag.props.focus (from @rific/focus-chain's own register(), not tag.ref — that's a callback
294
+ // ref, not a ref object, so it has no .current to read) is the hook's own imperative "focus this
295
+ // exact registered field" call. Mirrored into a ref every render, same as commitEditRef above,
296
+ // rather than read directly in the effect below: register() returns a brand-new props object on
297
+ // every render (it isn't memoized), so depending on tag.props.focus directly would rerun that
298
+ // effect — and refocus an already-focused field — on every keystroke into the tag field itself,
299
+ // re-triggering its own selectTextOnFocus mid-type and wiping out what's being typed.
300
+ const tagFocusRef = useRef(tag.props.focus)
301
+ useEffect(() => {
302
+ tagFocusRef.current = tag.props.focus
303
+ })
304
+
305
+ // Lets tapping the swatch itself hand focus straight to the tag field, so one tap both opens the
306
+ // color popover (host.toggle, inside InlineColorPicker's own onPress) and pops the keyboard on
307
+ // the tag field right next to it. Scoped to host.openId so this only fires on an actual open
308
+ // transition, not on every re-render while it's already open.
309
+ useEffect(() => {
310
+ if (host.openId === 'color') tagFocusRef.current()
311
+ }, [host.openId])
312
+
288
313
  return (
289
314
  <View style={styles.editRow}>
290
- <InlineColorPicker id='color' host={host} value={draftColor} onChange={onColorChange} dark={dark} />
291
- {/* selectTextOnFocus so tapping into an already-set tag selects it for wholesale replacement
292
- — the far more common edit than inserting into the middle of a 1-3 character value. */}
315
+ <InlineColorPicker id='color' host={host} value={draftColor} onChange={onColorChange} tag={draftTag} dark={dark} />
316
+ {/* Invisible on purpose (styles.hiddenTagInput: zero footprint, position: 'absolute' so it
317
+ doesn't reserve space in the row's own flex layout) — the swatch above is the only place this
318
+ value is ever meant to be seen. pointerEvents='none' is what actually keeps it out of the
319
+ way: without it, this field — even at 1x1 and fully transparent — still sits in front of the
320
+ swatch for hit-testing purposes and swallows the tap meant to open the color popover, since
321
+ focusing this field is never done by tapping it directly anyway (see the effect above, which
322
+ focuses it programmatically). accessibilityLabel stands in for the placeholder a visible field
323
+ would have had, so a screen reader still identifies it, and so this file's own tests can still
324
+ find it as "Tag" the same way they find the Name field by its placeholder. selectTextOnFocus
325
+ so re-tapping the swatch to revise an already-set tag replaces it wholesale rather than
326
+ inserting mid-string. */}
293
327
  {/* eslint-disable-next-line react-hooks/refs -- tag.ref/tag.props come from useFocusChain's
294
328
  register(), called during render by design (see the hook's own doc); the actual DOM/native
295
329
  focus() call it wraps only ever fires later, from an event handler, never synchronously here */}
296
- <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} />
330
+ <TextInput ref={tag.ref} {...tag.props} value={draftTag} onChangeText={onTagChange} accessibilityLabel='Tag' autoCapitalize='characters' selectTextOnFocus returnKeyType='next' style={styles.hiddenTagInput} pointerEvents='none' />
297
331
  {/* eslint-disable-next-line react-hooks/refs -- see the tag field's identical note above */}
298
332
  <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} />
299
333
  {onDelete && (
@@ -383,31 +417,14 @@ const styles = StyleSheet.create({
383
417
  scrollView: {
384
418
  flex: 1
385
419
  },
386
- // Wide enough for MAX_TAG_LENGTH monospace characters (or one emoji) plus its own padding/border
387
- // — matched to CHIP_SIZE (the same footprint every other circle on this screen uses), not a
388
- // fraction of the row's width, since the name field next to it is what should actually get the
389
- // row's remaining space.
390
- tagInput: {
391
- height: CHIP_SIZE,
392
- width: CHIP_SIZE
393
- },
394
- tagInputContent: {
395
- // react-native-paper's outlined TextInput sets its own native input's minWidth from the
396
- // placeholder/label's measured layout width (sized for a real label, not this field's own
397
- // 3-character placeholder) — left alone, that floor is wider than CHIP_SIZE, so the actual
398
- // editable box silently overflows past this field's own visible outline and centers within
399
- // that wider, partly invisible box instead. Zeroing it here lets the input size to CHIP_SIZE
400
- // instead, which is what actually makes `textAlign: 'center'` below center against the
401
- // visible box rather than an overflowing invisible one.
402
- minWidth: 0,
403
- // Same reasoning as minWidth above, for the horizontal direction: the outlined input's own
404
- // default padding (16px a side) is sized for a normal-width field, and at CHIP_SIZE's 40px
405
- // that leaves only 8px total for actual text — not enough to fit the "Tag" placeholder itself
406
- // (3 characters), which is what was clipping it to just "T" once minWidth stopped letting the
407
- // box silently overflow wider to compensate.
408
- paddingHorizontal: 2,
409
- textAlign: 'center',
410
- textAlignVertical: 'center'
420
+ // 1x1 and fully transparent — there's nothing to see here on purpose (see EditRow's own comment
421
+ // above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
422
+ // entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
423
+ hiddenTagInput: {
424
+ height: 1,
425
+ opacity: 0,
426
+ position: 'absolute',
427
+ width: 1
411
428
  },
412
429
  title: {
413
430
  flexShrink: 1,
@@ -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