@tastic/profile 0.3.6 → 0.3.8

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 {
@@ -35,11 +35,15 @@ interface ProfilePickerProps<P extends Profile> {
35
35
  }
36
36
  declare function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: alignOverride, guestLabel, nullLabel, nullIcon, onSelect, onManage }: ProfilePickerProps<P>): react.JSX.Element;
37
37
 
38
+ type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
38
39
  interface ProfileEditPatch {
39
40
  name: string;
40
41
  color: string;
41
42
  tag: string;
42
43
  }
44
+ interface ProfilesManagerHandle {
45
+ commitPendingEdit: () => void;
46
+ }
43
47
  interface ProfilesManagerProps {
44
48
  profiles: Profile[];
45
49
  defaultColor: string;
@@ -47,8 +51,14 @@ interface ProfilesManagerProps {
47
51
  onSave: (id: string, patch: ProfileEditPatch) => void;
48
52
  onDelete: (id: string) => void;
49
53
  headerLeft?: ReactNode;
54
+ ref?: Ref<ProfilesManagerHandle>;
55
+ fg?: string;
56
+ fgMuted?: string;
57
+ cardBg?: string;
58
+ titleVariant?: MD3TextVariant;
59
+ colorPreview?: (hex: string) => string;
50
60
  }
51
- declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft }: ProfilesManagerProps): react.JSX.Element;
61
+ declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant, colorPreview }: ProfilesManagerProps): react.JSX.Element;
52
62
 
53
63
  declare const MAX_PROFILE_NAME_LENGTH = 20;
54
64
  declare const MAX_TAG_LENGTH = 3;
@@ -59,4 +69,4 @@ declare const isSharedProfileStoreAvailable: boolean;
59
69
  declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
60
70
  declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
61
71
 
62
- export { MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, ProfilePicker, ProfilesManager, type ProfilesManagerProps, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, saveSharedProfiles };
72
+ 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 {
@@ -35,11 +35,15 @@ interface ProfilePickerProps<P extends Profile> {
35
35
  }
36
36
  declare function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: alignOverride, guestLabel, nullLabel, nullIcon, onSelect, onManage }: ProfilePickerProps<P>): react.JSX.Element;
37
37
 
38
+ type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
38
39
  interface ProfileEditPatch {
39
40
  name: string;
40
41
  color: string;
41
42
  tag: string;
42
43
  }
44
+ interface ProfilesManagerHandle {
45
+ commitPendingEdit: () => void;
46
+ }
43
47
  interface ProfilesManagerProps {
44
48
  profiles: Profile[];
45
49
  defaultColor: string;
@@ -47,8 +51,14 @@ interface ProfilesManagerProps {
47
51
  onSave: (id: string, patch: ProfileEditPatch) => void;
48
52
  onDelete: (id: string) => void;
49
53
  headerLeft?: ReactNode;
54
+ ref?: Ref<ProfilesManagerHandle>;
55
+ fg?: string;
56
+ fgMuted?: string;
57
+ cardBg?: string;
58
+ titleVariant?: MD3TextVariant;
59
+ colorPreview?: (hex: string) => string;
50
60
  }
51
- declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft }: ProfilesManagerProps): react.JSX.Element;
61
+ declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant, colorPreview }: ProfilesManagerProps): react.JSX.Element;
52
62
 
53
63
  declare const MAX_PROFILE_NAME_LENGTH = 20;
54
64
  declare const MAX_TAG_LENGTH = 3;
@@ -59,4 +69,4 @@ declare const isSharedProfileStoreAvailable: boolean;
59
69
  declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
60
70
  declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
61
71
 
62
- export { MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, ProfilePicker, ProfilesManager, type ProfilesManagerProps, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, saveSharedProfiles };
72
+ 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
@@ -287,14 +287,14 @@ function deriveTag(name) {
287
287
  const trimmed = name.trim();
288
288
  return trimmed ? trimmed[0].toUpperCase() : "";
289
289
  }
290
- function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft }) {
290
+ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant = "headlineSmall", colorPreview }) {
291
291
  const { dark, colors } = (0, import_auto_paper3.useAutoPaperTheme)();
292
292
  const insets = (0, import_react_native_safe_area_context.useSafeAreaInsets)();
293
293
  const { error: showErrorToast } = (0, import_toaster.useToast)();
294
294
  const host = (0, import_hud2.usePopoverHost)();
295
- const fg = dark ? "#FFFFFF" : "#000000";
296
- const fgMuted = dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)";
297
- const cardBg = dark ? "#111111" : "#F2F2F2";
295
+ const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
296
+ const fgMuted = fgMutedOverride ?? (dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)");
297
+ const cardBg = cardBgOverride ?? (dark ? "#111111" : "#F2F2F2");
298
298
  const cardBorder = dark ? "rgba(255,255,255,0.2)" : "rgba(0,0,0,0.2)";
299
299
  const cancelBg = dark ? "#2A2A2A" : "#E0E0E0";
300
300
  const cancelFg = dark ? "#FFFFFF" : "#000000";
@@ -320,6 +320,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
320
320
  commitEditRef.current = commitEdit;
321
321
  });
322
322
  (0, import_react2.useEffect)(() => () => commitEditRef.current(), []);
323
+ (0, import_react2.useImperativeHandle)(ref, () => ({ commitPendingEdit: () => commitEditRef.current() }), []);
323
324
  const startEdit = (0, import_react2.useCallback)(
324
325
  (target) => {
325
326
  commitEdit();
@@ -362,7 +363,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
362
363
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
363
364
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native3.View, { style: [styles3.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }], children: [
364
365
  headerLeft,
365
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.Text, { variant: "displaySmall", style: [styles3.title, { color: fg }], children: "Profiles" })
366
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.Text, { variant: titleVariant, style: [styles3.title, { color: fg }], children: "Profiles" })
366
367
  ] }),
367
368
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native3.KeyboardAvoidingView, { behavior: import_react_native3.Platform.OS === "ios" ? "padding" : void 0, style: styles3.keyboardAvoiding, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native3.ScrollView, { style: styles3.scrollView, contentContainerStyle: [styles3.content, { paddingBottom: 32 + insets.bottom }], showsVerticalScrollIndicator: false, keyboardShouldPersistTaps: "handled", children: rows.map((row) => {
368
369
  if (row.kind === "new") {
@@ -372,11 +373,11 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
372
373
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.Text, { variant: "bodyLarge", style: { color: fg }, children: "New Profile" })
373
374
  ] }) }, "new");
374
375
  }
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");
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, colorPreview }, "new");
376
377
  }
377
378
  const { profile } = row;
378
379
  if (editingId === 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);
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, colorPreview }, profile.id);
380
381
  }
381
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: [
382
383
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(ProfileChip, { profile, size: CHIP_SIZE }),
@@ -396,7 +397,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
396
397
  ] }) }) })
397
398
  ] });
398
399
  }
399
- function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark }) {
400
+ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview }) {
400
401
  const register = (0, import_focus_chain.useFocusChain)();
401
402
  const tag = register();
402
403
  const name = register();
@@ -408,7 +409,7 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
408
409
  if (host.openId === "color") tagFocusRef.current();
409
410
  }, [host.openId]);
410
411
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native3.View, { style: styles3.editRow, children: [
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_hud2.InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark }),
412
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" }),
413
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 }),
414
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 }) })
@@ -431,6 +432,15 @@ var styles3 = import_react_native3.StyleSheet.create({
431
432
  gap: 4,
432
433
  paddingBottom: 8
433
434
  },
435
+ // 1x1 and fully transparent — there's nothing to see here on purpose (see EditRow's own comment
436
+ // above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
437
+ // entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
438
+ hiddenTagInput: {
439
+ height: 1,
440
+ opacity: 0,
441
+ position: "absolute",
442
+ width: 1
443
+ },
434
444
  iconButton: {
435
445
  alignItems: "center",
436
446
  borderRadius: 8,
@@ -492,15 +502,6 @@ var styles3 = import_react_native3.StyleSheet.create({
492
502
  scrollView: {
493
503
  flex: 1
494
504
  },
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
- },
504
505
  title: {
505
506
  flexShrink: 1,
506
507
  fontWeight: "bold"
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";
@@ -259,14 +259,14 @@ function deriveTag(name) {
259
259
  const trimmed = name.trim();
260
260
  return trimmed ? trimmed[0].toUpperCase() : "";
261
261
  }
262
- function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft }) {
262
+ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant = "headlineSmall", colorPreview }) {
263
263
  const { dark, colors } = useAutoPaperTheme();
264
264
  const insets = useSafeAreaInsets();
265
265
  const { error: showErrorToast } = useToast();
266
266
  const host = usePopoverHost();
267
- const fg = dark ? "#FFFFFF" : "#000000";
268
- const fgMuted = dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)";
269
- const cardBg = dark ? "#111111" : "#F2F2F2";
267
+ const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
268
+ const fgMuted = fgMutedOverride ?? (dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)");
269
+ const cardBg = cardBgOverride ?? (dark ? "#111111" : "#F2F2F2");
270
270
  const cardBorder = dark ? "rgba(255,255,255,0.2)" : "rgba(0,0,0,0.2)";
271
271
  const cancelBg = dark ? "#2A2A2A" : "#E0E0E0";
272
272
  const cancelFg = dark ? "#FFFFFF" : "#000000";
@@ -292,6 +292,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
292
292
  commitEditRef.current = commitEdit;
293
293
  });
294
294
  useEffect(() => () => commitEditRef.current(), []);
295
+ useImperativeHandle(ref, () => ({ commitPendingEdit: () => commitEditRef.current() }), []);
295
296
  const startEdit = useCallback(
296
297
  (target) => {
297
298
  commitEdit();
@@ -334,7 +335,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
334
335
  return /* @__PURE__ */ jsxs2(Fragment2, { children: [
335
336
  /* @__PURE__ */ jsxs2(View3, { style: [styles3.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }], children: [
336
337
  headerLeft,
337
- /* @__PURE__ */ jsx3(Text3, { variant: "displaySmall", style: [styles3.title, { color: fg }], children: "Profiles" })
338
+ /* @__PURE__ */ jsx3(Text3, { variant: titleVariant, style: [styles3.title, { color: fg }], children: "Profiles" })
338
339
  ] }),
339
340
  /* @__PURE__ */ jsx3(KeyboardAvoidingView, { behavior: Platform.OS === "ios" ? "padding" : void 0, style: styles3.keyboardAvoiding, children: /* @__PURE__ */ jsx3(ScrollView2, { style: styles3.scrollView, contentContainerStyle: [styles3.content, { paddingBottom: 32 + insets.bottom }], showsVerticalScrollIndicator: false, keyboardShouldPersistTaps: "handled", children: rows.map((row) => {
340
341
  if (row.kind === "new") {
@@ -344,11 +345,11 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
344
345
  /* @__PURE__ */ jsx3(Text3, { variant: "bodyLarge", style: { color: fg }, children: "New Profile" })
345
346
  ] }) }, "new");
346
347
  }
347
- return /* @__PURE__ */ jsx3(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, 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, colorPreview }, "new");
348
349
  }
349
350
  const { profile } = row;
350
351
  if (editingId === 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);
352
+ return /* @__PURE__ */ jsx3(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, onDelete: () => setConfirmDeleteId(profile.id), fgMuted, host, dark, colorPreview }, profile.id);
352
353
  }
353
354
  return /* @__PURE__ */ jsx3(TouchableRipple2, { onPress: () => startEdit(profile), style: styles3.row, children: /* @__PURE__ */ jsxs2(View3, { style: styles3.rowInner, children: [
354
355
  /* @__PURE__ */ jsx3(ProfileChip, { profile, size: CHIP_SIZE }),
@@ -368,7 +369,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
368
369
  ] }) }) })
369
370
  ] });
370
371
  }
371
- function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark }) {
372
+ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview }) {
372
373
  const register = useFocusChain();
373
374
  const tag = register();
374
375
  const name = register();
@@ -380,7 +381,7 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
380
381
  if (host.openId === "color") tagFocusRef.current();
381
382
  }, [host.openId]);
382
383
  return /* @__PURE__ */ jsxs2(View3, { style: styles3.editRow, children: [
383
- /* @__PURE__ */ jsx3(InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, tag: draftTag, dark }),
384
+ /* @__PURE__ */ jsx3(InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark }),
384
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" }),
385
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 }),
386
387
  onDelete && /* @__PURE__ */ jsx3(TouchableRipple2, { onPress: onDelete, style: styles3.iconButton, children: /* @__PURE__ */ jsx3(Icon3, { source: "trash-can-outline", size: 18, color: fgMuted }) })
@@ -403,6 +404,15 @@ var styles3 = StyleSheet3.create({
403
404
  gap: 4,
404
405
  paddingBottom: 8
405
406
  },
407
+ // 1x1 and fully transparent — there's nothing to see here on purpose (see EditRow's own comment
408
+ // above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
409
+ // entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
410
+ hiddenTagInput: {
411
+ height: 1,
412
+ opacity: 0,
413
+ position: "absolute",
414
+ width: 1
415
+ },
406
416
  iconButton: {
407
417
  alignItems: "center",
408
418
  borderRadius: 8,
@@ -464,15 +474,6 @@ var styles3 = StyleSheet3.create({
464
474
  scrollView: {
465
475
  flex: 1
466
476
  },
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
- },
476
477
  title: {
477
478
  flexShrink: 1,
478
479
  fontWeight: "bold"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/profile",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
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",
@@ -62,15 +62,15 @@
62
62
  },
63
63
  "prettier": "@infinitetoken/eslint-config/prettier",
64
64
  "devDependencies": {
65
- "@infinitetoken/eslint-config": "^0.1.8",
66
- "@infinitetoken/jest-config": "^0.2.1",
67
- "@infinitetoken/tsconfig": "^0.3.0",
65
+ "@infinitetoken/eslint-config": "0.2.0",
66
+ "@infinitetoken/jest-config": "^0.2.4",
67
+ "@infinitetoken/tsconfig": "^0.4.1",
68
68
  "@rific/auto-paper": "^0.10.3",
69
69
  "@rific/feedback-press": "^0.10.8",
70
70
  "@rific/focus-chain": "^0.4.7",
71
71
  "@rific/toaster": "^0.6.3",
72
- "@tastic/core": "^0.1.5",
73
- "@tastic/hud": "^0.1.6",
72
+ "@tastic/core": "^0.2.0",
73
+ "@tastic/hud": "^0.6.0",
74
74
  "@testing-library/dom": "^10.4.1",
75
75
  "@testing-library/react": "^16.3.2",
76
76
  "@types/jest": "^30.0.0",
@@ -3,7 +3,7 @@ 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'
@@ -16,12 +16,29 @@ import { Profile } from './types'
16
16
  // size for every circle on this screen, rather than each picking its own.
17
17
  const CHIP_SIZE = 40
18
18
 
19
+ // react-native-paper's own MD3 Text variant names — mirrored locally rather than importing its
20
+ // internal VariantProp/MD3TypescaleKey (not exported from the package's public entry point), same
21
+ // as @tastic/hud's BaseStatsScreen does for its own identical titleVariant prop.
22
+ type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall'
23
+
19
24
  interface ProfileEditPatch {
20
25
  name: string
21
26
  color: string
22
27
  tag: string
23
28
  }
24
29
 
30
+ export interface ProfilesManagerHandle {
31
+ // Flushes whatever row is currently being edited — the same thing this component's own unmount
32
+ // effect does, but callable explicitly. Exists because "unmount" isn't a reliable "the user is
33
+ // navigating away" signal on every host: React Navigation's web renderer keeps a popped screen
34
+ // mounted-but-hidden instead of unmounting it, so a host relying only on the unmount fallback
35
+ // silently loses an in-progress edit on `router.back()` there. A host whose router doesn't
36
+ // genuinely unmount this component on navigation should hold a ref and call this right before
37
+ // navigating away (e.g. in its own back-button handler). Safe to call any time, including when
38
+ // nothing is being edited — a no-op then, same as the unmount effect already is.
39
+ commitPendingEdit: () => void
40
+ }
41
+
25
42
  export interface ProfilesManagerProps {
26
43
  profiles: Profile[]
27
44
  // The color a brand-new profile's draft starts on — this component doesn't have (and shouldn't
@@ -37,6 +54,28 @@ export interface ProfilesManagerProps {
37
54
  // rendering slot, not an onBack-style callback: this component doesn't know or care that it's a
38
55
  // back button, only that the host wants something shown there. Omit to hide the slot.
39
56
  headerLeft?: ReactNode
57
+ // React 19 accepts `ref` as a plain prop on a function component (no forwardRef needed) — see
58
+ // ProfilesManagerHandle's own doc for why a host may need this.
59
+ ref?: Ref<ProfilesManagerHandle>
60
+ // Independently optional overrides for this screen's text/card colors — each defaults to the same
61
+ // literal-black/white-by-appearance formula as before when omitted, so every existing caller
62
+ // (none of which pass these) renders identically to before. Same convention as @tastic/hud's
63
+ // BaseStatsScreen fg/cardBg overrides, for a caller with its own app-wide chrome palette instead
64
+ // of that literal black/white convention. cardBorder/cancelBg/cancelFg have no override, matching
65
+ // BaseStatsScreen's identical reasoning for cardBorder: their low-alpha/neutral values already
66
+ // read fine against any cardBg, and (for Cancel specifically) are deliberately neutral rather than
67
+ // theme-derived so a host's own live gameplay/seat color can't land there by coincidence.
68
+ fg?: string
69
+ fgMuted?: string
70
+ cardBg?: string
71
+ // 'headlineSmall' (this screen's own default, below), matching BaseStatsScreen's own settled
72
+ // default and the reasoning behind it — this title sits next to a small headerLeft back-button
73
+ // glyph the same way, and the previous hardcoded 'displaySmall' read as oversized there too.
74
+ titleVariant?: MD3TextVariant
75
+ // Transforms the color swatch grid's own preview per-swatch — forwarded straight to the internal
76
+ // InlineColorPicker's identical prop (see its own doc). Leaves draftColor/onCreate/onSave's actual
77
+ // color value untouched; only what's rendered changes.
78
+ colorPreview?: (hex: string) => string
40
79
  }
41
80
 
42
81
  type Row = { kind: 'profile'; profile: Profile } | { kind: 'new' }
@@ -55,16 +94,20 @@ function deriveTag(name: string): string {
55
94
  // would still assume *some* host owns navigation; this component doesn't even go that far), so any
56
95
  // consuming screen just renders it as its body inside its own routed wrapper (back button, native
57
96
  // push/pop transition, whatever persistence hook backs `profiles`/`onCreate`/`onSave`/`onDelete`).
58
- export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft }: ProfilesManagerProps) {
97
+ // Staying router-agnostic means this component can't know for itself whether "navigating away"
98
+ // really unmounts it on a given host's router — see ProfilesManagerHandle's own doc for the
99
+ // react-navigation-web case where it doesn't, and why a host there needs the `ref` escape hatch
100
+ // instead of relying on the unmount fallback alone.
101
+ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant = 'headlineSmall', colorPreview }: ProfilesManagerProps) {
59
102
  const { dark, colors } = useAutoPaperTheme()
60
103
  const insets = useSafeAreaInsets()
61
104
  const { error: showErrorToast } = useToast()
62
105
  // Only one row is ever in edit mode at a time (see commitEdit, called before any new row opens),
63
106
  // so every EditRow instance can safely share this one host instead of each needing its own.
64
107
  const host = usePopoverHost()
65
- const fg = dark ? '#FFFFFF' : '#000000'
66
- const fgMuted = dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)'
67
- const cardBg = dark ? '#111111' : '#F2F2F2'
108
+ const fg = fgOverride ?? (dark ? '#FFFFFF' : '#000000')
109
+ const fgMuted = fgMutedOverride ?? (dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)')
110
+ const cardBg = cardBgOverride ?? (dark ? '#111111' : '#F2F2F2')
68
111
  const cardBorder = dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'
69
112
  // A contained neutral, not an outlined one — reads as "the other button" next to Delete's solid
70
113
  // danger fill, rather than looking unfinished/secondary beside it.
@@ -116,6 +159,11 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
116
159
  commitEditRef.current = commitEdit
117
160
  })
118
161
  useEffect(() => () => commitEditRef.current(), [])
162
+ // The explicit escape hatch for hosts whose "navigate away" doesn't genuinely unmount this
163
+ // component — see ProfilesManagerHandle's own doc. `[]` deps: the handle object itself never
164
+ // needs to change identity, since it always calls through the ref to whatever commitEdit is
165
+ // current at call time, the same indirection the unmount effect above already relies on.
166
+ useImperativeHandle(ref, () => ({ commitPendingEdit: () => commitEditRef.current() }), [])
119
167
 
120
168
  const startEdit = useCallback(
121
169
  (target: 'new' | Profile) => {
@@ -174,7 +222,7 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
174
222
  <>
175
223
  <View style={[styles.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }]}>
176
224
  {headerLeft}
177
- <Text variant='displaySmall' style={[styles.title, { color: fg }]}>
225
+ <Text variant={titleVariant} style={[styles.title, { color: fg }]}>
178
226
  Profiles
179
227
  </Text>
180
228
  </View>
@@ -202,12 +250,12 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
202
250
  </TouchableRipple>
203
251
  )
204
252
  }
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} />
253
+ 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} colorPreview={colorPreview} />
206
254
  }
207
255
 
208
256
  const { profile } = row
209
257
  if (editingId === profile.id) {
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} />
258
+ 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} colorPreview={colorPreview} />
211
259
  }
212
260
 
213
261
  // No delete affordance at rest — tap the row to start editing, which is the one place
@@ -271,6 +319,7 @@ interface EditRowProps {
271
319
  fgMuted: string
272
320
  host: PopoverHost
273
321
  dark: boolean
322
+ colorPreview?: (hex: string) => string
274
323
  }
275
324
 
276
325
  // One row, expanded: the color trigger (@tastic/hud's InlineColorPicker) — doubling as the tag's
@@ -285,7 +334,7 @@ interface EditRowProps {
285
334
  // own — submitting the *name* field is what actually commits the row, so its registration's own
286
335
  // onSubmitEditing (a no-op — it's last in the chain) is overridden with onSubmit below, matching
287
336
  // how a normal multi-field form reads: fill fields in order, the last one finishes it.
288
- function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark }: EditRowProps) {
337
+ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview }: EditRowProps) {
289
338
  const register = useFocusChain()
290
339
  const tag = register()
291
340
  const name = register()
@@ -312,7 +361,7 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
312
361
 
313
362
  return (
314
363
  <View style={styles.editRow}>
315
- <InlineColorPicker id='color' host={host} value={draftColor} onChange={onColorChange} tag={draftTag} dark={dark} />
364
+ <InlineColorPicker id='color' host={host} value={draftColor} onChange={onColorChange} previewValue={colorPreview} tag={draftTag} dark={dark} />
316
365
  {/* Invisible on purpose (styles.hiddenTagInput: zero footprint, position: 'absolute' so it
317
366
  doesn't reserve space in the row's own flex layout) — the swatch above is the only place this
318
367
  value is ever meant to be seen. pointerEvents='none' is what actually keeps it out of the
@@ -356,6 +405,15 @@ const styles = StyleSheet.create({
356
405
  gap: 4,
357
406
  paddingBottom: 8
358
407
  },
408
+ // 1x1 and fully transparent — there's nothing to see here on purpose (see EditRow's own comment
409
+ // above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
410
+ // entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
411
+ hiddenTagInput: {
412
+ height: 1,
413
+ opacity: 0,
414
+ position: 'absolute',
415
+ width: 1
416
+ },
359
417
  iconButton: {
360
418
  alignItems: 'center',
361
419
  borderRadius: 8,
@@ -417,15 +475,6 @@ const styles = StyleSheet.create({
417
475
  scrollView: {
418
476
  flex: 1
419
477
  },
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
428
- },
429
478
  title: {
430
479
  flexShrink: 1,
431
480
  fontWeight: 'bold'
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'