@tastic/profile 0.3.5 → 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 +25 -32
- package/dist/index.mjs +25 -32
- package/package.json +1 -1
- package/src/ProfilesManager.tsx +41 -45
- package/src/profilesValidation.ts +33 -1
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;
|
|
@@ -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,
|
|
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,
|
|
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,7 +396,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
|
|
|
386
396
|
] }) }) })
|
|
387
397
|
] });
|
|
388
398
|
}
|
|
389
|
-
function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange,
|
|
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();
|
|
@@ -399,7 +409,7 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
|
|
|
399
409
|
}, [host.openId]);
|
|
400
410
|
return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native3.View, { style: styles3.editRow, children: [
|
|
401
411
|
/* @__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,
|
|
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" }),
|
|
403
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 }),
|
|
404
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 }) })
|
|
405
415
|
] });
|
|
@@ -482,31 +492,14 @@ var styles3 = import_react_native3.StyleSheet.create({
|
|
|
482
492
|
scrollView: {
|
|
483
493
|
flex: 1
|
|
484
494
|
},
|
|
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"
|
|
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
|
|
510
503
|
},
|
|
511
504
|
title: {
|
|
512
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
|
|
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,
|
|
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,
|
|
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,7 +368,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
|
|
|
358
368
|
] }) }) })
|
|
359
369
|
] });
|
|
360
370
|
}
|
|
361
|
-
function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange,
|
|
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();
|
|
@@ -371,7 +381,7 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
|
|
|
371
381
|
}, [host.openId]);
|
|
372
382
|
return /* @__PURE__ */ jsxs2(View3, { style: styles3.editRow, children: [
|
|
373
383
|
/* @__PURE__ */ jsx3(InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, tag: draftTag, dark }),
|
|
374
|
-
/* @__PURE__ */ jsx3(TextInput, { ref: tag.ref, ...tag.props,
|
|
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" }),
|
|
375
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 }),
|
|
376
386
|
onDelete && /* @__PURE__ */ jsx3(TouchableRipple2, { onPress: onDelete, style: styles3.iconButton, children: /* @__PURE__ */ jsx3(Icon3, { source: "trash-can-outline", size: 18, color: fgMuted }) })
|
|
377
387
|
] });
|
|
@@ -454,31 +464,14 @@ var styles3 = StyleSheet3.create({
|
|
|
454
464
|
scrollView: {
|
|
455
465
|
flex: 1
|
|
456
466
|
},
|
|
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"
|
|
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
|
|
482
475
|
},
|
|
483
476
|
title: {
|
|
484
477
|
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.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",
|
package/src/ProfilesManager.tsx
CHANGED
|
@@ -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
|
|
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 {
|
|
@@ -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}
|
|
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}
|
|
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,18 +273,19 @@ interface EditRowProps {
|
|
|
270
273
|
dark: boolean
|
|
271
274
|
}
|
|
272
275
|
|
|
273
|
-
// One row, expanded: the color trigger (@tastic/hud's InlineColorPicker)
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
// one combined action instead
|
|
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.
|
|
280
284
|
// Return-key chains tag -> name via @rific/focus-chain rather than each field submitting on its
|
|
281
285
|
// own — submitting the *name* field is what actually commits the row, so its registration's own
|
|
282
286
|
// onSubmitEditing (a no-op — it's last in the chain) is overridden with onSubmit below, matching
|
|
283
287
|
// 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,
|
|
288
|
+
function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark }: EditRowProps) {
|
|
285
289
|
const register = useFocusChain()
|
|
286
290
|
const tag = register()
|
|
287
291
|
const name = register()
|
|
@@ -309,12 +313,21 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
|
|
|
309
313
|
return (
|
|
310
314
|
<View style={styles.editRow}>
|
|
311
315
|
<InlineColorPicker id='color' host={host} value={draftColor} onChange={onColorChange} tag={draftTag} dark={dark} />
|
|
312
|
-
{/*
|
|
313
|
-
|
|
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. */}
|
|
314
327
|
{/* eslint-disable-next-line react-hooks/refs -- tag.ref/tag.props come from useFocusChain's
|
|
315
328
|
register(), called during render by design (see the hook's own doc); the actual DOM/native
|
|
316
329
|
focus() call it wraps only ever fires later, from an event handler, never synchronously here */}
|
|
317
|
-
<TextInput ref={tag.ref} {...tag.props}
|
|
330
|
+
<TextInput ref={tag.ref} {...tag.props} value={draftTag} onChangeText={onTagChange} accessibilityLabel='Tag' autoCapitalize='characters' selectTextOnFocus returnKeyType='next' style={styles.hiddenTagInput} pointerEvents='none' />
|
|
318
331
|
{/* eslint-disable-next-line react-hooks/refs -- see the tag field's identical note above */}
|
|
319
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} />
|
|
320
333
|
{onDelete && (
|
|
@@ -404,31 +417,14 @@ const styles = StyleSheet.create({
|
|
|
404
417
|
scrollView: {
|
|
405
418
|
flex: 1
|
|
406
419
|
},
|
|
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'
|
|
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
|
|
432
428
|
},
|
|
433
429
|
title: {
|
|
434
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
|
|
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
|