@tastic/profile 0.16.2 → 0.17.0
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/README.md +19 -10
- package/dist/index.d.mts +4 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.js +64 -18
- package/dist/index.mjs +64 -18
- package/package.json +9 -8
- package/src/OptionPicker.tsx +2 -0
- package/src/PickerMenu.tsx +45 -13
- package/src/ProfileColorContext.tsx +2 -2
- package/src/ProfilePicker.tsx +2 -1
- package/src/ProfilesManager.tsx +77 -51
- package/src/sharedProfileStore.ts +35 -5
package/README.md
CHANGED
|
@@ -57,7 +57,10 @@ see its own doc comment for why that component is generic over your own `Profile
|
|
|
57
57
|
(`{ value, onChange, previewValue?, swatches?, label?, caption? }`) puts a Color row at the top of
|
|
58
58
|
the popover that opens a page of swatches (with a back row), and turns the trigger into one
|
|
59
59
|
button that leads with a chip in `color` with the selected profile's tag; `triggerStyle` styles
|
|
60
|
-
that button.
|
|
60
|
+
that button. A `flexShrink` in `triggerStyle` lets the button give way in a row too narrow for
|
|
61
|
+
it: its label is cut off with an ellipsis while the chip and chevron keep their size, and it never
|
|
62
|
+
gets narrower than those. With room it lays out as before. `OptionPicker` takes the same
|
|
63
|
+
`triggerStyle`. Also exports
|
|
61
64
|
`getProfilePickerContentSize(profileCount, hasManageRow?, colorSection?)` - the popover's
|
|
62
65
|
`{ width, height }`, for sizing your own placement hook.
|
|
63
66
|
- **`ProfilesManager`**: the roster screen itself: create/rename/recolor/retag/delete, editing
|
|
@@ -84,7 +87,10 @@ see its own doc comment for why that component is generic over your own `Profile
|
|
|
84
87
|
in that app's own local extension table yet - e.g. it was created on a different app in the
|
|
85
88
|
group). This split is deliberate, not a limitation to work around: it's this package's own
|
|
86
89
|
base-fields-only `Profile` boundary (see above), just applied to storage instead of just to the
|
|
87
|
-
TypeScript type.
|
|
90
|
+
TypeScript type. A read drops any stored entry that this version's `isValidProfile` rejects (a
|
|
91
|
+
profile saved by a newer app under a rule loosened since), and `saveSharedProfiles` writes those
|
|
92
|
+
entries back after your roster as they were, so an older app can't delete a newer app's profile
|
|
93
|
+
just by saving.
|
|
88
94
|
|
|
89
95
|
```ts
|
|
90
96
|
// Conceptual sketch of the pattern - see "Redux helpers for the shared roster" below for a
|
|
@@ -174,14 +180,16 @@ rect). Pass a placement **hook** as `useColorPickerAlign`:
|
|
|
174
180
|
<ProfilesScreen useColorPickerAlign={useFrameAutoAlign} /* ...the usual props */ />
|
|
175
181
|
```
|
|
176
182
|
|
|
177
|
-
It is called as `useColorPickerAlign(open, contentWidth, contentHeight)` - `open` is whether the swatch
|
|
178
|
-
popover is showing,
|
|
179
|
-
`getInlineColorPickerContentSize` for the default swatch list at the raw window width)
|
|
183
|
+
It is called as `useColorPickerAlign(open, contentWidth, contentHeight, column)` - `open` is whether the swatch
|
|
184
|
+
popover is showing, the size is what `InlineColorPicker` itself computes (`@tastic/hud`'s
|
|
185
|
+
`getInlineColorPickerContentSize` for the default swatch list at the raw window width), and `column` is a ref
|
|
186
|
+
to the roster's scroll view, which clips the popover at its edges: measure it to keep the popover inside a
|
|
187
|
+
roster narrower than the frame (a column capped in a wide window) - and must return a
|
|
180
188
|
`ProfilePickerAlignResult`, which is handed to `InlineColorPicker` as its `alignOverride` (its `triggerRef`
|
|
181
189
|
is the ref that gets measured). It is a hook, not a plain function: give it a stable identity for the life of
|
|
182
190
|
the mount (a module-level function), since switching it at runtime remounts the swatch. Omit it and behaviour is
|
|
183
|
-
identical to before
|
|
184
|
-
|
|
191
|
+
identical to before, and without a hook the popover is kept inside that column for you (`InlineColorPicker`'s
|
|
192
|
+
`bounds`, `@tastic/hud` 0.25.0+).
|
|
185
193
|
|
|
186
194
|
### Routed screens: `ProfilesScreen`
|
|
187
195
|
|
|
@@ -327,9 +335,10 @@ const renderSwatch: ProfileSwatchRenderer = (color, size, place) => (place === '
|
|
|
327
335
|
```
|
|
328
336
|
|
|
329
337
|
(`TriadPie` is your own component, e.g. three `react-native-svg` arcs.) Give it a stable identity
|
|
330
|
-
(module level, or `useCallback`).
|
|
331
|
-
|
|
332
|
-
|
|
338
|
+
(module level, or `useCallback`). `ProfilesManager`'s edit row hands it to `@tastic/hud`'s
|
|
339
|
+
`InlineColorPicker` too: its trigger (the profile's color with its tag on it) as a `'chip'`, its grid
|
|
340
|
+
as the `'colorPage'`. Rows that fill with a color (a selected picker row) stay flat.
|
|
341
|
+
`useProfileSwatchRenderer()` returns it (or `undefined`).
|
|
333
342
|
|
|
334
343
|
## Per-seat color persistence
|
|
335
344
|
|
package/dist/index.d.mts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
2
|
import { RefObject, ReactNode, Ref } from 'react';
|
|
3
|
-
import { PopoverHost, PopoverRotation } from '@tastic/hud/chrome';
|
|
4
|
-
import {
|
|
3
|
+
import { ViewInstance, PopoverHost, PopoverRotation } from '@tastic/hud/chrome';
|
|
4
|
+
import { StyleProp, ViewStyle } from 'react-native';
|
|
5
5
|
import { SeedColor } from '@rific/auto-paper';
|
|
6
6
|
|
|
7
7
|
interface ProfilePickerColorSection {
|
|
@@ -19,7 +19,7 @@ interface ProfilePickerAlignResult {
|
|
|
19
19
|
verticalAlign: 'below' | 'above';
|
|
20
20
|
maxHeight: number;
|
|
21
21
|
measured: boolean;
|
|
22
|
-
triggerRef: RefObject<
|
|
22
|
+
triggerRef: RefObject<ViewInstance | null>;
|
|
23
23
|
}
|
|
24
24
|
interface ProfilePickerContentSize {
|
|
25
25
|
width: number;
|
|
@@ -123,7 +123,7 @@ interface ProfilesManagerProps {
|
|
|
123
123
|
fgMuted?: string;
|
|
124
124
|
titleVariant?: MD3TextVariant$1;
|
|
125
125
|
colorPreview?: (hex: string) => string;
|
|
126
|
-
useColorPickerAlign?: (open: boolean, contentWidth: number, contentHeight: number) => ProfilePickerAlignResult;
|
|
126
|
+
useColorPickerAlign?: (open: boolean, contentWidth: number, contentHeight: number, column: RefObject<ViewInstance | null>) => ProfilePickerAlignResult;
|
|
127
127
|
}
|
|
128
128
|
declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, titleVariant, colorPreview: colorPreviewProp, useColorPickerAlign }: ProfilesManagerProps): react.JSX.Element;
|
|
129
129
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
2
|
import { RefObject, ReactNode, Ref } from 'react';
|
|
3
|
-
import { PopoverHost, PopoverRotation } from '@tastic/hud/chrome';
|
|
4
|
-
import {
|
|
3
|
+
import { ViewInstance, PopoverHost, PopoverRotation } from '@tastic/hud/chrome';
|
|
4
|
+
import { StyleProp, ViewStyle } from 'react-native';
|
|
5
5
|
import { SeedColor } from '@rific/auto-paper';
|
|
6
6
|
|
|
7
7
|
interface ProfilePickerColorSection {
|
|
@@ -19,7 +19,7 @@ interface ProfilePickerAlignResult {
|
|
|
19
19
|
verticalAlign: 'below' | 'above';
|
|
20
20
|
maxHeight: number;
|
|
21
21
|
measured: boolean;
|
|
22
|
-
triggerRef: RefObject<
|
|
22
|
+
triggerRef: RefObject<ViewInstance | null>;
|
|
23
23
|
}
|
|
24
24
|
interface ProfilePickerContentSize {
|
|
25
25
|
width: number;
|
|
@@ -123,7 +123,7 @@ interface ProfilesManagerProps {
|
|
|
123
123
|
fgMuted?: string;
|
|
124
124
|
titleVariant?: MD3TextVariant$1;
|
|
125
125
|
colorPreview?: (hex: string) => string;
|
|
126
|
-
useColorPickerAlign?: (open: boolean, contentWidth: number, contentHeight: number) => ProfilePickerAlignResult;
|
|
126
|
+
useColorPickerAlign?: (open: boolean, contentWidth: number, contentHeight: number, column: RefObject<ViewInstance | null>) => ProfilePickerAlignResult;
|
|
127
127
|
}
|
|
128
128
|
declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, titleVariant, colorPreview: colorPreviewProp, useColorPickerAlign }: ProfilesManagerProps): react.JSX.Element;
|
|
129
129
|
|
package/dist/index.js
CHANGED
|
@@ -138,6 +138,12 @@ var MENU_BORDER_WIDTH = 2;
|
|
|
138
138
|
var ROW_INSET = 12;
|
|
139
139
|
var CHIP_SIZE = 32;
|
|
140
140
|
var CHIP_TRIGGER_PADDING = 6;
|
|
141
|
+
var CHIP_TRIGGER_END_PADDING = CHIP_TRIGGER_PADDING + 8;
|
|
142
|
+
var CHIP_TRIGGER_GAP = 8;
|
|
143
|
+
var PLAIN_TRIGGER_GAP = 2;
|
|
144
|
+
var CHEVRON_SIZE = 16;
|
|
145
|
+
var CHIP_TRIGGER_FIXED_WIDTH = CHIP_TRIGGER_PADDING + CHIP_SIZE + 2 * CHIP_TRIGGER_GAP + CHEVRON_SIZE + CHIP_TRIGGER_END_PADDING;
|
|
146
|
+
var PLAIN_TRIGGER_FIXED_WIDTH = PLAIN_TRIGGER_GAP + CHEVRON_SIZE;
|
|
141
147
|
var SWATCH_SIZE = 28;
|
|
142
148
|
var SWATCH_MIN_GAP = 6;
|
|
143
149
|
var SWATCH_RING_WIDTH = 2;
|
|
@@ -182,11 +188,15 @@ function PickerMenu({ id, host, color, dark, label, chip, colorSection, homeColo
|
|
|
182
188
|
};
|
|
183
189
|
const triggerLabel = label.toUpperCase();
|
|
184
190
|
const showFooter = !!footer && !showColorPage;
|
|
185
|
-
|
|
186
|
-
|
|
191
|
+
const flatTriggerStyle = import_react_native3.StyleSheet.flatten(triggerStyle) ?? {};
|
|
192
|
+
const { flexShrink, ...pillStyle } = flatTriggerStyle;
|
|
193
|
+
const shrinks = typeof flexShrink === "number" && flexShrink > 0;
|
|
194
|
+
const anchorShrink = shrinks ? { flexShrink, minWidth: chipTrigger ? CHIP_TRIGGER_FIXED_WIDTH : PLAIN_TRIGGER_FIXED_WIDTH } : void 0;
|
|
195
|
+
return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_react_native3.View, { style: [styles3.anchor, anchorShrink, open && styles3.anchorOpen], onLayout: handleAnchorLayout, children: [
|
|
196
|
+
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_feedback_press.TouchableRipple, { onPress: handleTriggerPress, borderless: chipTrigger, style: [styles3.trigger, chipTrigger && styles3.chipTrigger, shrinks && styles3.triggerShrinks, shrinks ? pillStyle : triggerStyle], accessibilityRole: "button", accessibilityLabel: triggerLabel, children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_react_native3.View, { ref: triggerRef, collapsable: false, style: chipTrigger ? styles3.chipTriggerInner : styles3.triggerInner, children: [
|
|
187
197
|
chip && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(IdentityChip, { color, tag: chip.tag, icon: chip.icon, size: CHIP_SIZE }),
|
|
188
198
|
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native_paper2.Text, { style: [styles3.triggerLabel, { color: chipTrigger ? fg : color }], numberOfLines: 1, children: triggerLabel }),
|
|
189
|
-
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native_paper2.Icon, { source: "menu-down", size:
|
|
199
|
+
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native_paper2.Icon, { source: "menu-down", size: CHEVRON_SIZE, color: chipTrigger ? fg : color })
|
|
190
200
|
] }) }),
|
|
191
201
|
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_chrome.PopoverBody, { visible: open && measured, align, verticalAlign, caretColor: menuBg, caretBorderColor: color, caretBorderWidth: MENU_BORDER_WIDTH, triggerSize: chipTrigger ? triggerWidth : TRIGGER_HEIGHT, children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
|
|
192
202
|
import_react_native3.View,
|
|
@@ -304,10 +314,10 @@ var styles3 = import_react_native3.StyleSheet.create({
|
|
|
304
314
|
chipTriggerInner: {
|
|
305
315
|
alignItems: "center",
|
|
306
316
|
flexDirection: "row",
|
|
307
|
-
gap:
|
|
317
|
+
gap: CHIP_TRIGGER_GAP,
|
|
308
318
|
height: CHIP_SIZE + 2 * CHIP_TRIGGER_PADDING,
|
|
309
319
|
paddingLeft: CHIP_TRIGGER_PADDING,
|
|
310
|
-
paddingRight:
|
|
320
|
+
paddingRight: CHIP_TRIGGER_END_PADDING
|
|
311
321
|
},
|
|
312
322
|
colorRowCaption: {
|
|
313
323
|
fontSize: 12,
|
|
@@ -416,13 +426,22 @@ var styles3 = import_react_native3.StyleSheet.create({
|
|
|
416
426
|
triggerInner: {
|
|
417
427
|
alignItems: "center",
|
|
418
428
|
flexDirection: "row",
|
|
419
|
-
gap:
|
|
429
|
+
gap: PLAIN_TRIGGER_GAP,
|
|
420
430
|
height: TRIGGER_HEIGHT
|
|
421
431
|
},
|
|
432
|
+
// Lets the label, alone in its row, give way to a trigger narrower than its name (see `shrinks`).
|
|
433
|
+
// With room it keeps its natural width, so this changes nothing there.
|
|
422
434
|
triggerLabel: {
|
|
435
|
+
flexShrink: 1,
|
|
423
436
|
fontSize: 13,
|
|
424
437
|
fontWeight: "700",
|
|
425
|
-
letterSpacing: 1
|
|
438
|
+
letterSpacing: 1,
|
|
439
|
+
minWidth: 0
|
|
440
|
+
},
|
|
441
|
+
// A shrinking trigger (see `shrinks`): no wider than its anchor. Its row is then laid out at that
|
|
442
|
+
// width, which is what cuts the label off.
|
|
443
|
+
triggerShrinks: {
|
|
444
|
+
maxWidth: "100%"
|
|
426
445
|
}
|
|
427
446
|
});
|
|
428
447
|
|
|
@@ -571,6 +590,7 @@ function isValidProfile(value) {
|
|
|
571
590
|
// src/ProfilesManager.tsx
|
|
572
591
|
var import_jsx_runtime8 = require("react/jsx-runtime");
|
|
573
592
|
var CHIP_SIZE2 = 40;
|
|
593
|
+
var textInputRef = (ref) => ref;
|
|
574
594
|
function deriveTag(name) {
|
|
575
595
|
const trimmed = name.trim();
|
|
576
596
|
return trimmed ? trimmed[0].toUpperCase() : "";
|
|
@@ -581,6 +601,8 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
|
|
|
581
601
|
const { dark } = (0, import_auto_paper4.useAutoPaperTheme)();
|
|
582
602
|
const insets = (0, import_react_native_safe_area_context.useSafeAreaInsets)();
|
|
583
603
|
const { error: showErrorToast } = (0, import_toaster.useToast)();
|
|
604
|
+
const columnRef = (0, import_react4.useRef)(null);
|
|
605
|
+
const renderSwatch = useProfileSwatchRenderer();
|
|
584
606
|
const host = (0, import_chrome2.usePopoverHost)();
|
|
585
607
|
const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
|
|
586
608
|
const fgMuted = fgMutedOverride ?? (dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)");
|
|
@@ -651,7 +673,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
|
|
|
651
673
|
headerLeft,
|
|
652
674
|
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react_native_paper4.Text, { variant: titleVariant, style: [styles5.title, { color: fg }], children: "Profiles" })
|
|
653
675
|
] }),
|
|
654
|
-
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react_native5.KeyboardAvoidingView, { behavior: import_react_native5.Platform.OS === "ios" ? "padding" : void 0, style: styles5.keyboardAvoiding, children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_auto_paper4.FadingScrollView, { style: styles5.scrollView, contentContainerStyle: [styles5.content, { paddingBottom: 32 + insets.bottom }], showsVerticalScrollIndicator: false, keyboardShouldPersistTaps: "handled", children: rows.map((row) => {
|
|
676
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react_native5.KeyboardAvoidingView, { behavior: import_react_native5.Platform.OS === "ios" ? "padding" : void 0, style: styles5.keyboardAvoiding, children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react_native5.View, { ref: columnRef, collapsable: false, style: styles5.column, children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_auto_paper4.FadingScrollView, { style: styles5.scrollView, contentContainerStyle: [styles5.content, { paddingBottom: 32 + insets.bottom }], showsVerticalScrollIndicator: false, keyboardShouldPersistTaps: "handled", children: rows.map((row) => {
|
|
655
677
|
if (row.kind === "new") {
|
|
656
678
|
if (editingId !== "new") {
|
|
657
679
|
return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_feedback_press3.TouchableRipple, { onPress: () => startEdit("new"), style: styles5.row, children: /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(import_react_native5.View, { style: styles5.rowInner, children: [
|
|
@@ -659,27 +681,28 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
|
|
|
659
681
|
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react_native_paper4.Text, { variant: "bodyLarge", style: { color: fg }, children: "New Profile" })
|
|
660
682
|
] }) }, "new");
|
|
661
683
|
}
|
|
662
|
-
return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark, colorPreview, useColorPickerAlign }, "new");
|
|
684
|
+
return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark, colorPreview, useColorPickerAlign, column: columnRef, renderSwatch }, "new");
|
|
663
685
|
}
|
|
664
686
|
const { profile } = row;
|
|
665
687
|
if (editingId === profile.id) {
|
|
666
|
-
return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, onDelete: () => setConfirmDeleteId(profile.id), fgMuted, host, dark, colorPreview, useColorPickerAlign }, profile.id);
|
|
688
|
+
return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, onDelete: () => setConfirmDeleteId(profile.id), fgMuted, host, dark, colorPreview, useColorPickerAlign, column: columnRef, renderSwatch }, profile.id);
|
|
667
689
|
}
|
|
668
690
|
return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_feedback_press3.TouchableRipple, { onPress: () => startEdit(profile), style: styles5.row, children: /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(import_react_native5.View, { style: styles5.rowInner, children: [
|
|
669
691
|
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)(ProfileChip, { profile, size: CHIP_SIZE2 }),
|
|
670
692
|
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react_native_paper4.Text, { variant: "bodyLarge", style: [styles5.rowName, { color: fg }], numberOfLines: 1, children: profile.name })
|
|
671
693
|
] }) }, profile.id);
|
|
672
|
-
}) }) }),
|
|
694
|
+
}) }) }) }),
|
|
673
695
|
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_chrome2.ConfirmDialog, { visible: confirmDeleteProfile !== null, title: "Delete Profile?", message: confirmDeleteProfile ? `This permanently deletes ${confirmDeleteProfile.name}\u2019s profile. This cannot be undone.` : "", confirmLabel: "Delete", cancelLabel: "Cancel", icon: "alert-outline", destructive: true, onConfirm: handleConfirmDelete, onCancel: () => setConfirmDeleteId(null) })
|
|
674
696
|
] });
|
|
675
697
|
}
|
|
676
|
-
function AlignedColorPicker({ useAlign, ...props }) {
|
|
698
|
+
function AlignedColorPicker({ useAlign, column, ...props }) {
|
|
677
699
|
const { width: windowWidth } = (0, import_react_native5.useWindowDimensions)();
|
|
678
700
|
const { width, height } = (0, import_chrome2.getInlineColorPickerContentSize)(import_auto_paper4.defaultColors.length, windowWidth);
|
|
679
|
-
const alignOverride = useAlign(props.host.openId === props.id, width, height);
|
|
701
|
+
const alignOverride = useAlign(props.host.openId === props.id, width, height, column);
|
|
680
702
|
return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_chrome2.InlineColorPicker, { ...props, alignOverride });
|
|
681
703
|
}
|
|
682
|
-
function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview, useColorPickerAlign }) {
|
|
704
|
+
function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview, useColorPickerAlign, column, renderSwatch }) {
|
|
705
|
+
const pickerSwatch = (0, import_react4.useMemo)(() => renderSwatch ? (color, size, place) => renderSwatch(color, size, place === "trigger" ? "chip" : "colorPage") : void 0, [renderSwatch]);
|
|
683
706
|
const register = (0, import_focus_chain.useFocusChain)();
|
|
684
707
|
const tag = register();
|
|
685
708
|
const name = register();
|
|
@@ -691,13 +714,17 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
|
|
|
691
714
|
if (host.openId === "color") tagFocusRef.current();
|
|
692
715
|
}, [host.openId]);
|
|
693
716
|
return /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(import_react_native5.View, { style: styles5.editRow, children: [
|
|
694
|
-
useColorPickerAlign ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(AlignedColorPicker, { useAlign: useColorPickerAlign, id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark }) : /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_chrome2.InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark }),
|
|
695
|
-
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react_native_paper4.TextInput, { ref: tag.ref, ...tag.props, value: draftTag, onChangeText: onTagChange, accessibilityLabel: "Tag", autoCapitalize: "characters", selectTextOnFocus: true, returnKeyType: "next", style: styles5.hiddenTagInput, pointerEvents: "none" }),
|
|
696
|
-
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react_native_paper4.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: styles5.nameInput }),
|
|
717
|
+
useColorPickerAlign ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(AlignedColorPicker, { useAlign: useColorPickerAlign, column, id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark, renderSwatch: pickerSwatch }) : /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_chrome2.InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark, renderSwatch: pickerSwatch, bounds: column }),
|
|
718
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react_native_paper4.TextInput, { ref: textInputRef(tag.ref), ...tag.props, value: draftTag, onChangeText: onTagChange, accessibilityLabel: "Tag", autoCapitalize: "characters", selectTextOnFocus: true, returnKeyType: "next", style: styles5.hiddenTagInput, pointerEvents: "none" }),
|
|
719
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react_native_paper4.TextInput, { ref: textInputRef(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: styles5.nameInput }),
|
|
697
720
|
onDelete && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_feedback_press3.TouchableRipple, { onPress: onDelete, style: styles5.iconButton, children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react_native_paper4.Icon, { source: "trash-can-outline", size: 18, color: fgMuted }) })
|
|
698
721
|
] });
|
|
699
722
|
}
|
|
700
723
|
var styles5 = import_react_native5.StyleSheet.create({
|
|
724
|
+
// The roster's column, around the scroll view (see columnRef).
|
|
725
|
+
column: {
|
|
726
|
+
flex: 1
|
|
727
|
+
},
|
|
701
728
|
content: {
|
|
702
729
|
gap: 4,
|
|
703
730
|
paddingHorizontal: 20
|
|
@@ -971,10 +998,29 @@ async function readSharedProfiles(groupId) {
|
|
|
971
998
|
async function loadSharedProfiles(groupId) {
|
|
972
999
|
return await readSharedProfiles(groupId) ?? [];
|
|
973
1000
|
}
|
|
1001
|
+
var hasId = (entry) => typeof entry === "object" && entry !== null && typeof entry.id === "string" && entry.id !== "";
|
|
1002
|
+
async function unreadableEntries(groupId, profiles) {
|
|
1003
|
+
let json;
|
|
1004
|
+
try {
|
|
1005
|
+
json = web ? web.storage.getItem(webKey(groupId, PROFILES_KEY)) : await native?.getSharedJSON(groupId, PROFILES_KEY);
|
|
1006
|
+
} catch {
|
|
1007
|
+
return [];
|
|
1008
|
+
}
|
|
1009
|
+
if (!json) return [];
|
|
1010
|
+
try {
|
|
1011
|
+
const parsed = JSON.parse(json);
|
|
1012
|
+
if (!Array.isArray(parsed)) return [];
|
|
1013
|
+
const saved = new Set(profiles.map((profile) => profile.id));
|
|
1014
|
+
return parsed.filter((entry) => !isValidProfile(entry) && hasId(entry) && !saved.has(entry.id));
|
|
1015
|
+
} catch {
|
|
1016
|
+
return [];
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
974
1019
|
async function saveSharedProfiles(groupId, profiles) {
|
|
975
1020
|
if (!native) return;
|
|
976
1021
|
try {
|
|
977
|
-
await
|
|
1022
|
+
const kept = await unreadableEntries(groupId, profiles);
|
|
1023
|
+
await native.setSharedJSON(groupId, PROFILES_KEY, JSON.stringify([...profiles, ...kept]));
|
|
978
1024
|
} catch {
|
|
979
1025
|
}
|
|
980
1026
|
}
|
package/dist/index.mjs
CHANGED
|
@@ -90,6 +90,12 @@ var MENU_BORDER_WIDTH = 2;
|
|
|
90
90
|
var ROW_INSET = 12;
|
|
91
91
|
var CHIP_SIZE = 32;
|
|
92
92
|
var CHIP_TRIGGER_PADDING = 6;
|
|
93
|
+
var CHIP_TRIGGER_END_PADDING = CHIP_TRIGGER_PADDING + 8;
|
|
94
|
+
var CHIP_TRIGGER_GAP = 8;
|
|
95
|
+
var PLAIN_TRIGGER_GAP = 2;
|
|
96
|
+
var CHEVRON_SIZE = 16;
|
|
97
|
+
var CHIP_TRIGGER_FIXED_WIDTH = CHIP_TRIGGER_PADDING + CHIP_SIZE + 2 * CHIP_TRIGGER_GAP + CHEVRON_SIZE + CHIP_TRIGGER_END_PADDING;
|
|
98
|
+
var PLAIN_TRIGGER_FIXED_WIDTH = PLAIN_TRIGGER_GAP + CHEVRON_SIZE;
|
|
93
99
|
var SWATCH_SIZE = 28;
|
|
94
100
|
var SWATCH_MIN_GAP = 6;
|
|
95
101
|
var SWATCH_RING_WIDTH = 2;
|
|
@@ -134,11 +140,15 @@ function PickerMenu({ id, host, color, dark, label, chip, colorSection, homeColo
|
|
|
134
140
|
};
|
|
135
141
|
const triggerLabel = label.toUpperCase();
|
|
136
142
|
const showFooter = !!footer && !showColorPage;
|
|
137
|
-
|
|
138
|
-
|
|
143
|
+
const flatTriggerStyle = StyleSheet3.flatten(triggerStyle) ?? {};
|
|
144
|
+
const { flexShrink, ...pillStyle } = flatTriggerStyle;
|
|
145
|
+
const shrinks = typeof flexShrink === "number" && flexShrink > 0;
|
|
146
|
+
const anchorShrink = shrinks ? { flexShrink, minWidth: chipTrigger ? CHIP_TRIGGER_FIXED_WIDTH : PLAIN_TRIGGER_FIXED_WIDTH } : void 0;
|
|
147
|
+
return /* @__PURE__ */ jsxs2(View3, { style: [styles3.anchor, anchorShrink, open && styles3.anchorOpen], onLayout: handleAnchorLayout, children: [
|
|
148
|
+
/* @__PURE__ */ jsx4(TouchableRipple, { onPress: handleTriggerPress, borderless: chipTrigger, style: [styles3.trigger, chipTrigger && styles3.chipTrigger, shrinks && styles3.triggerShrinks, shrinks ? pillStyle : triggerStyle], accessibilityRole: "button", accessibilityLabel: triggerLabel, children: /* @__PURE__ */ jsxs2(View3, { ref: triggerRef, collapsable: false, style: chipTrigger ? styles3.chipTriggerInner : styles3.triggerInner, children: [
|
|
139
149
|
chip && /* @__PURE__ */ jsx4(IdentityChip, { color, tag: chip.tag, icon: chip.icon, size: CHIP_SIZE }),
|
|
140
150
|
/* @__PURE__ */ jsx4(Text2, { style: [styles3.triggerLabel, { color: chipTrigger ? fg : color }], numberOfLines: 1, children: triggerLabel }),
|
|
141
|
-
/* @__PURE__ */ jsx4(Icon2, { source: "menu-down", size:
|
|
151
|
+
/* @__PURE__ */ jsx4(Icon2, { source: "menu-down", size: CHEVRON_SIZE, color: chipTrigger ? fg : color })
|
|
142
152
|
] }) }),
|
|
143
153
|
/* @__PURE__ */ jsx4(PopoverBody, { visible: open && measured, align, verticalAlign, caretColor: menuBg, caretBorderColor: color, caretBorderWidth: MENU_BORDER_WIDTH, triggerSize: chipTrigger ? triggerWidth : TRIGGER_HEIGHT, children: /* @__PURE__ */ jsxs2(
|
|
144
154
|
View3,
|
|
@@ -256,10 +266,10 @@ var styles3 = StyleSheet3.create({
|
|
|
256
266
|
chipTriggerInner: {
|
|
257
267
|
alignItems: "center",
|
|
258
268
|
flexDirection: "row",
|
|
259
|
-
gap:
|
|
269
|
+
gap: CHIP_TRIGGER_GAP,
|
|
260
270
|
height: CHIP_SIZE + 2 * CHIP_TRIGGER_PADDING,
|
|
261
271
|
paddingLeft: CHIP_TRIGGER_PADDING,
|
|
262
|
-
paddingRight:
|
|
272
|
+
paddingRight: CHIP_TRIGGER_END_PADDING
|
|
263
273
|
},
|
|
264
274
|
colorRowCaption: {
|
|
265
275
|
fontSize: 12,
|
|
@@ -368,13 +378,22 @@ var styles3 = StyleSheet3.create({
|
|
|
368
378
|
triggerInner: {
|
|
369
379
|
alignItems: "center",
|
|
370
380
|
flexDirection: "row",
|
|
371
|
-
gap:
|
|
381
|
+
gap: PLAIN_TRIGGER_GAP,
|
|
372
382
|
height: TRIGGER_HEIGHT
|
|
373
383
|
},
|
|
384
|
+
// Lets the label, alone in its row, give way to a trigger narrower than its name (see `shrinks`).
|
|
385
|
+
// With room it keeps its natural width, so this changes nothing there.
|
|
374
386
|
triggerLabel: {
|
|
387
|
+
flexShrink: 1,
|
|
375
388
|
fontSize: 13,
|
|
376
389
|
fontWeight: "700",
|
|
377
|
-
letterSpacing: 1
|
|
390
|
+
letterSpacing: 1,
|
|
391
|
+
minWidth: 0
|
|
392
|
+
},
|
|
393
|
+
// A shrinking trigger (see `shrinks`): no wider than its anchor. Its row is then laid out at that
|
|
394
|
+
// width, which is what cuts the label off.
|
|
395
|
+
triggerShrinks: {
|
|
396
|
+
maxWidth: "100%"
|
|
378
397
|
}
|
|
379
398
|
});
|
|
380
399
|
|
|
@@ -523,6 +542,7 @@ function isValidProfile(value) {
|
|
|
523
542
|
// src/ProfilesManager.tsx
|
|
524
543
|
import { Fragment as Fragment2, jsx as jsx8, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
525
544
|
var CHIP_SIZE2 = 40;
|
|
545
|
+
var textInputRef = (ref) => ref;
|
|
526
546
|
function deriveTag(name) {
|
|
527
547
|
const trimmed = name.trim();
|
|
528
548
|
return trimmed ? trimmed[0].toUpperCase() : "";
|
|
@@ -533,6 +553,8 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
|
|
|
533
553
|
const { dark } = useAutoPaperTheme();
|
|
534
554
|
const insets = useSafeAreaInsets();
|
|
535
555
|
const { error: showErrorToast } = useToast();
|
|
556
|
+
const columnRef = useRef(null);
|
|
557
|
+
const renderSwatch = useProfileSwatchRenderer();
|
|
536
558
|
const host = usePopoverHost();
|
|
537
559
|
const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
|
|
538
560
|
const fgMuted = fgMutedOverride ?? (dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)");
|
|
@@ -603,7 +625,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
|
|
|
603
625
|
headerLeft,
|
|
604
626
|
/* @__PURE__ */ jsx8(Text4, { variant: titleVariant, style: [styles5.title, { color: fg }], children: "Profiles" })
|
|
605
627
|
] }),
|
|
606
|
-
/* @__PURE__ */ jsx8(KeyboardAvoidingView, { behavior: Platform.OS === "ios" ? "padding" : void 0, style: styles5.keyboardAvoiding, children: /* @__PURE__ */ jsx8(FadingScrollView2, { style: styles5.scrollView, contentContainerStyle: [styles5.content, { paddingBottom: 32 + insets.bottom }], showsVerticalScrollIndicator: false, keyboardShouldPersistTaps: "handled", children: rows.map((row) => {
|
|
628
|
+
/* @__PURE__ */ jsx8(KeyboardAvoidingView, { behavior: Platform.OS === "ios" ? "padding" : void 0, style: styles5.keyboardAvoiding, children: /* @__PURE__ */ jsx8(View5, { ref: columnRef, collapsable: false, style: styles5.column, children: /* @__PURE__ */ jsx8(FadingScrollView2, { style: styles5.scrollView, contentContainerStyle: [styles5.content, { paddingBottom: 32 + insets.bottom }], showsVerticalScrollIndicator: false, keyboardShouldPersistTaps: "handled", children: rows.map((row) => {
|
|
607
629
|
if (row.kind === "new") {
|
|
608
630
|
if (editingId !== "new") {
|
|
609
631
|
return /* @__PURE__ */ jsx8(TouchableRipple3, { onPress: () => startEdit("new"), style: styles5.row, children: /* @__PURE__ */ jsxs4(View5, { style: styles5.rowInner, children: [
|
|
@@ -611,27 +633,28 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
|
|
|
611
633
|
/* @__PURE__ */ jsx8(Text4, { variant: "bodyLarge", style: { color: fg }, children: "New Profile" })
|
|
612
634
|
] }) }, "new");
|
|
613
635
|
}
|
|
614
|
-
return /* @__PURE__ */ jsx8(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark, colorPreview, useColorPickerAlign }, "new");
|
|
636
|
+
return /* @__PURE__ */ jsx8(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark, colorPreview, useColorPickerAlign, column: columnRef, renderSwatch }, "new");
|
|
615
637
|
}
|
|
616
638
|
const { profile } = row;
|
|
617
639
|
if (editingId === profile.id) {
|
|
618
|
-
return /* @__PURE__ */ jsx8(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, onDelete: () => setConfirmDeleteId(profile.id), fgMuted, host, dark, colorPreview, useColorPickerAlign }, profile.id);
|
|
640
|
+
return /* @__PURE__ */ jsx8(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, onDelete: () => setConfirmDeleteId(profile.id), fgMuted, host, dark, colorPreview, useColorPickerAlign, column: columnRef, renderSwatch }, profile.id);
|
|
619
641
|
}
|
|
620
642
|
return /* @__PURE__ */ jsx8(TouchableRipple3, { onPress: () => startEdit(profile), style: styles5.row, children: /* @__PURE__ */ jsxs4(View5, { style: styles5.rowInner, children: [
|
|
621
643
|
/* @__PURE__ */ jsx8(ProfileChip, { profile, size: CHIP_SIZE2 }),
|
|
622
644
|
/* @__PURE__ */ jsx8(Text4, { variant: "bodyLarge", style: [styles5.rowName, { color: fg }], numberOfLines: 1, children: profile.name })
|
|
623
645
|
] }) }, profile.id);
|
|
624
|
-
}) }) }),
|
|
646
|
+
}) }) }) }),
|
|
625
647
|
/* @__PURE__ */ jsx8(ConfirmDialog, { visible: confirmDeleteProfile !== null, title: "Delete Profile?", message: confirmDeleteProfile ? `This permanently deletes ${confirmDeleteProfile.name}\u2019s profile. This cannot be undone.` : "", confirmLabel: "Delete", cancelLabel: "Cancel", icon: "alert-outline", destructive: true, onConfirm: handleConfirmDelete, onCancel: () => setConfirmDeleteId(null) })
|
|
626
648
|
] });
|
|
627
649
|
}
|
|
628
|
-
function AlignedColorPicker({ useAlign, ...props }) {
|
|
650
|
+
function AlignedColorPicker({ useAlign, column, ...props }) {
|
|
629
651
|
const { width: windowWidth } = useWindowDimensions();
|
|
630
652
|
const { width, height } = getInlineColorPickerContentSize(defaultColors2.length, windowWidth);
|
|
631
|
-
const alignOverride = useAlign(props.host.openId === props.id, width, height);
|
|
653
|
+
const alignOverride = useAlign(props.host.openId === props.id, width, height, column);
|
|
632
654
|
return /* @__PURE__ */ jsx8(InlineColorPicker, { ...props, alignOverride });
|
|
633
655
|
}
|
|
634
|
-
function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview, useColorPickerAlign }) {
|
|
656
|
+
function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview, useColorPickerAlign, column, renderSwatch }) {
|
|
657
|
+
const pickerSwatch = useMemo3(() => renderSwatch ? (color, size, place) => renderSwatch(color, size, place === "trigger" ? "chip" : "colorPage") : void 0, [renderSwatch]);
|
|
635
658
|
const register = useFocusChain();
|
|
636
659
|
const tag = register();
|
|
637
660
|
const name = register();
|
|
@@ -643,13 +666,17 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
|
|
|
643
666
|
if (host.openId === "color") tagFocusRef.current();
|
|
644
667
|
}, [host.openId]);
|
|
645
668
|
return /* @__PURE__ */ jsxs4(View5, { style: styles5.editRow, children: [
|
|
646
|
-
useColorPickerAlign ? /* @__PURE__ */ jsx8(AlignedColorPicker, { useAlign: useColorPickerAlign, id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark }) : /* @__PURE__ */ jsx8(InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark }),
|
|
647
|
-
/* @__PURE__ */ jsx8(TextInput, { ref: tag.ref, ...tag.props, value: draftTag, onChangeText: onTagChange, accessibilityLabel: "Tag", autoCapitalize: "characters", selectTextOnFocus: true, returnKeyType: "next", style: styles5.hiddenTagInput, pointerEvents: "none" }),
|
|
648
|
-
/* @__PURE__ */ jsx8(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: styles5.nameInput }),
|
|
669
|
+
useColorPickerAlign ? /* @__PURE__ */ jsx8(AlignedColorPicker, { useAlign: useColorPickerAlign, column, id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark, renderSwatch: pickerSwatch }) : /* @__PURE__ */ jsx8(InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark, renderSwatch: pickerSwatch, bounds: column }),
|
|
670
|
+
/* @__PURE__ */ jsx8(TextInput, { ref: textInputRef(tag.ref), ...tag.props, value: draftTag, onChangeText: onTagChange, accessibilityLabel: "Tag", autoCapitalize: "characters", selectTextOnFocus: true, returnKeyType: "next", style: styles5.hiddenTagInput, pointerEvents: "none" }),
|
|
671
|
+
/* @__PURE__ */ jsx8(TextInput, { ref: textInputRef(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: styles5.nameInput }),
|
|
649
672
|
onDelete && /* @__PURE__ */ jsx8(TouchableRipple3, { onPress: onDelete, style: styles5.iconButton, children: /* @__PURE__ */ jsx8(Icon4, { source: "trash-can-outline", size: 18, color: fgMuted }) })
|
|
650
673
|
] });
|
|
651
674
|
}
|
|
652
675
|
var styles5 = StyleSheet5.create({
|
|
676
|
+
// The roster's column, around the scroll view (see columnRef).
|
|
677
|
+
column: {
|
|
678
|
+
flex: 1
|
|
679
|
+
},
|
|
653
680
|
content: {
|
|
654
681
|
gap: 4,
|
|
655
682
|
paddingHorizontal: 20
|
|
@@ -923,10 +950,29 @@ async function readSharedProfiles(groupId) {
|
|
|
923
950
|
async function loadSharedProfiles(groupId) {
|
|
924
951
|
return await readSharedProfiles(groupId) ?? [];
|
|
925
952
|
}
|
|
953
|
+
var hasId = (entry) => typeof entry === "object" && entry !== null && typeof entry.id === "string" && entry.id !== "";
|
|
954
|
+
async function unreadableEntries(groupId, profiles) {
|
|
955
|
+
let json;
|
|
956
|
+
try {
|
|
957
|
+
json = web ? web.storage.getItem(webKey(groupId, PROFILES_KEY)) : await native?.getSharedJSON(groupId, PROFILES_KEY);
|
|
958
|
+
} catch {
|
|
959
|
+
return [];
|
|
960
|
+
}
|
|
961
|
+
if (!json) return [];
|
|
962
|
+
try {
|
|
963
|
+
const parsed = JSON.parse(json);
|
|
964
|
+
if (!Array.isArray(parsed)) return [];
|
|
965
|
+
const saved = new Set(profiles.map((profile) => profile.id));
|
|
966
|
+
return parsed.filter((entry) => !isValidProfile(entry) && hasId(entry) && !saved.has(entry.id));
|
|
967
|
+
} catch {
|
|
968
|
+
return [];
|
|
969
|
+
}
|
|
970
|
+
}
|
|
926
971
|
async function saveSharedProfiles(groupId, profiles) {
|
|
927
972
|
if (!native) return;
|
|
928
973
|
try {
|
|
929
|
-
await
|
|
974
|
+
const kept = await unreadableEntries(groupId, profiles);
|
|
975
|
+
await native.setSharedJSON(groupId, PROFILES_KEY, JSON.stringify([...profiles, ...kept]));
|
|
930
976
|
} catch {
|
|
931
977
|
}
|
|
932
978
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tastic/profile",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
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,16 +62,17 @@
|
|
|
62
62
|
},
|
|
63
63
|
"prettier": "@infinitetoken/eslint-config/prettier",
|
|
64
64
|
"devDependencies": {
|
|
65
|
-
"@infinitetoken/eslint-config": "0.2.
|
|
65
|
+
"@infinitetoken/eslint-config": "0.2.1",
|
|
66
66
|
"@infinitetoken/jest-config": "^0.5.2",
|
|
67
67
|
"@infinitetoken/tsconfig": "^0.6.0",
|
|
68
|
-
"@rific/auto-paper": "^0.13.
|
|
69
|
-
"@rific/core": "^0.
|
|
70
|
-
"@rific/feedback-press": "^0.13.
|
|
68
|
+
"@rific/auto-paper": "^0.13.4",
|
|
69
|
+
"@rific/core": "^0.5.0",
|
|
70
|
+
"@rific/feedback-press": "^0.13.1",
|
|
71
71
|
"@rific/focus-chain": "^0.4.8",
|
|
72
|
-
"@rific/
|
|
72
|
+
"@rific/gestures": "^0.1.3",
|
|
73
|
+
"@rific/toaster": "^0.10.1",
|
|
73
74
|
"@tastic/core": "^0.14.0",
|
|
74
|
-
"@tastic/hud": "^0.
|
|
75
|
+
"@tastic/hud": "^0.25.1",
|
|
75
76
|
"@testing-library/dom": "^10.4.1",
|
|
76
77
|
"@testing-library/react": "^16.3.2",
|
|
77
78
|
"@types/jest": "^30.0.0",
|
|
@@ -97,7 +98,7 @@
|
|
|
97
98
|
"@rific/focus-chain": ">=0.4.0",
|
|
98
99
|
"@rific/toaster": ">=0.4.0",
|
|
99
100
|
"@tastic/core": ">=0.1.0",
|
|
100
|
-
"@tastic/hud": ">=0.
|
|
101
|
+
"@tastic/hud": ">=0.25.1",
|
|
101
102
|
"expo-modules-core": ">=1.0.0",
|
|
102
103
|
"react": ">=19.0.0",
|
|
103
104
|
"react-native": ">=0.76.0",
|
package/src/OptionPicker.tsx
CHANGED
|
@@ -41,6 +41,8 @@ interface OptionPickerProps<T extends string> {
|
|
|
41
41
|
align?: 'left' | 'right' | 'center'
|
|
42
42
|
alignOverride?: ProfilePickerAlignResult
|
|
43
43
|
rotation?: PopoverRotation
|
|
44
|
+
// The pill's own style (a background), as ProfilePicker's; a `flexShrink` lets it give way in a
|
|
45
|
+
// narrow row, cutting its label off.
|
|
44
46
|
triggerStyle?: StyleProp<ViewStyle>
|
|
45
47
|
}
|
|
46
48
|
|
package/src/PickerMenu.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defaultColors, FadingScrollView, getContrastColor, type SeedColor } from '@rific/auto-paper'
|
|
2
2
|
import { TouchableRipple } from '@rific/feedback-press'
|
|
3
|
-
import type { PopoverHost, PopoverRotation } from '@tastic/hud/chrome'
|
|
3
|
+
import type { PopoverHost, PopoverRotation, ViewInstance } from '@tastic/hud/chrome'
|
|
4
4
|
import { PopoverBody, useAutoAlign } from '@tastic/hud/chrome'
|
|
5
5
|
import { ReactNode, RefObject, useState } from 'react'
|
|
6
6
|
import { type LayoutChangeEvent, type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native'
|
|
@@ -25,6 +25,15 @@ const ROW_INSET = 12
|
|
|
25
25
|
// The chip trigger (see `colorSection`): a CHIP_SIZE circle inside a pill padded by CHIP_TRIGGER_PADDING.
|
|
26
26
|
const CHIP_SIZE = 32
|
|
27
27
|
const CHIP_TRIGGER_PADDING = 6
|
|
28
|
+
const CHIP_TRIGGER_END_PADDING = CHIP_TRIGGER_PADDING + 8
|
|
29
|
+
const CHIP_TRIGGER_GAP = 8
|
|
30
|
+
const PLAIN_TRIGGER_GAP = 2
|
|
31
|
+
const CHEVRON_SIZE = 16
|
|
32
|
+
// Everything in a trigger but its label: the chip, the chevron, the gaps and the padding (84pt for the
|
|
33
|
+
// chip pill, 18 for the plain trigger). A shrinking trigger (see `triggerStyle`) stops here, so its
|
|
34
|
+
// chip and chevron always fit inside it.
|
|
35
|
+
const CHIP_TRIGGER_FIXED_WIDTH = CHIP_TRIGGER_PADDING + CHIP_SIZE + 2 * CHIP_TRIGGER_GAP + CHEVRON_SIZE + CHIP_TRIGGER_END_PADDING
|
|
36
|
+
const PLAIN_TRIGGER_FIXED_WIDTH = PLAIN_TRIGGER_GAP + CHEVRON_SIZE
|
|
28
37
|
|
|
29
38
|
// The color row and page (see `colorSection`). Every height here is fixed (explicit line heights),
|
|
30
39
|
// so pickerContentSize can size them exactly. Swatches are the same 28pt circles as
|
|
@@ -74,16 +83,16 @@ export interface ProfilePickerColorSection {
|
|
|
74
83
|
|
|
75
84
|
// A complete placement decision for the popover: which way it opens, how tall it may grow, whether
|
|
76
85
|
// this open's measurement has landed yet, and the ref attached to the measured trigger.
|
|
77
|
-
// Structurally identical to @tastic/hud's own `AlignResult` (
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
86
|
+
// Structurally identical to @tastic/hud's own `AlignResult` (the same shape `useAutoAlign` itself
|
|
87
|
+
// returns), and a hud `AlignResult` (e.g. from `useZoneClampedAlign`) is assignable to this as-is.
|
|
88
|
+
// The ref holds hud's `ViewInstance`, a View's instance under React Native's legacy types and its
|
|
89
|
+
// strict ones alike (a bare `RefObject<View>` is a ref to the View function under the strict ones).
|
|
81
90
|
export interface ProfilePickerAlignResult {
|
|
82
91
|
align: 'left' | 'right' | 'center'
|
|
83
92
|
verticalAlign: 'below' | 'above'
|
|
84
93
|
maxHeight: number
|
|
85
94
|
measured: boolean
|
|
86
|
-
triggerRef: RefObject<
|
|
95
|
+
triggerRef: RefObject<ViewInstance | null>
|
|
87
96
|
}
|
|
88
97
|
|
|
89
98
|
export interface ProfilePickerContentSize {
|
|
@@ -134,6 +143,8 @@ interface PickerMenuProps {
|
|
|
134
143
|
align?: 'left' | 'right' | 'center'
|
|
135
144
|
alignOverride?: ProfilePickerAlignResult
|
|
136
145
|
rotation: PopoverRotation
|
|
146
|
+
// The pressable trigger's own style (a host's background behind the pill). A `flexShrink` in it lets
|
|
147
|
+
// the trigger give way in the host's row: see `shrinks` below.
|
|
137
148
|
triggerStyle?: StyleProp<ViewStyle>
|
|
138
149
|
// Pinned under the list page's rows, behind a divider (ProfilePicker's Manage row).
|
|
139
150
|
footer?: ReactNode
|
|
@@ -172,9 +183,21 @@ export function PickerMenu({ id, host, color, dark, label, chip, colorSection, h
|
|
|
172
183
|
// Shown on the list page only (see the footer below).
|
|
173
184
|
const showFooter = !!footer && !showColorPage
|
|
174
185
|
|
|
186
|
+
// A host's `flexShrink` is how far the trigger gives way in the host's row, so it goes on the anchor,
|
|
187
|
+
// which is the host's flex item (on the pressable it would only squeeze the pill's height), down to
|
|
188
|
+
// the trigger's fixed parts. The pressable is then capped at the anchor's width, so its label is cut
|
|
189
|
+
// off with an ellipsis and the chip and chevron keep their size. With room, the anchor and the pill
|
|
190
|
+
// are their natural width, as before. The pill's edges, and so the measured trigger, the caret and
|
|
191
|
+
// the popover's alignment, follow the shrunk width. Without a flexShrink nothing here changes.
|
|
192
|
+
const flatTriggerStyle: ViewStyle = StyleSheet.flatten(triggerStyle) ?? {}
|
|
193
|
+
const { flexShrink, ...pillStyle } = flatTriggerStyle
|
|
194
|
+
const shrinks = typeof flexShrink === 'number' && flexShrink > 0
|
|
195
|
+
const anchorShrink = shrinks ? { flexShrink, minWidth: chipTrigger ? CHIP_TRIGGER_FIXED_WIDTH : PLAIN_TRIGGER_FIXED_WIDTH } : undefined
|
|
196
|
+
|
|
175
197
|
return (
|
|
176
|
-
<View style={[styles.anchor, open && styles.anchorOpen]} onLayout={handleAnchorLayout}>
|
|
177
|
-
|
|
198
|
+
<View style={[styles.anchor, anchorShrink, open && styles.anchorOpen]} onLayout={handleAnchorLayout}>
|
|
199
|
+
{/* The cap goes before the host's style, so a host's own maxWidth still wins. */}
|
|
200
|
+
<TouchableRipple onPress={handleTriggerPress} borderless={chipTrigger} style={[styles.trigger, chipTrigger && styles.chipTrigger, shrinks && styles.triggerShrinks, shrinks ? pillStyle : triggerStyle]} accessibilityRole='button' accessibilityLabel={triggerLabel}>
|
|
178
201
|
<View ref={triggerRef} collapsable={false} style={chipTrigger ? styles.chipTriggerInner : styles.triggerInner}>
|
|
179
202
|
{chip && <IdentityChip color={color} tag={chip.tag} icon={chip.icon} size={CHIP_SIZE} />}
|
|
180
203
|
{/* The chip trigger's name and chevron are plain fg: the chip carries the color, and a muted
|
|
@@ -183,7 +206,7 @@ export function PickerMenu({ id, host, color, dark, label, chip, colorSection, h
|
|
|
183
206
|
<Text style={[styles.triggerLabel, { color: chipTrigger ? fg : color }]} numberOfLines={1}>
|
|
184
207
|
{triggerLabel}
|
|
185
208
|
</Text>
|
|
186
|
-
<Icon source='menu-down' size={
|
|
209
|
+
<Icon source='menu-down' size={CHEVRON_SIZE} color={chipTrigger ? fg : color} />
|
|
187
210
|
</View>
|
|
188
211
|
</TouchableRipple>
|
|
189
212
|
|
|
@@ -395,10 +418,10 @@ const styles = StyleSheet.create({
|
|
|
395
418
|
chipTriggerInner: {
|
|
396
419
|
alignItems: 'center',
|
|
397
420
|
flexDirection: 'row',
|
|
398
|
-
gap:
|
|
421
|
+
gap: CHIP_TRIGGER_GAP,
|
|
399
422
|
height: CHIP_SIZE + 2 * CHIP_TRIGGER_PADDING,
|
|
400
423
|
paddingLeft: CHIP_TRIGGER_PADDING,
|
|
401
|
-
paddingRight:
|
|
424
|
+
paddingRight: CHIP_TRIGGER_END_PADDING
|
|
402
425
|
},
|
|
403
426
|
colorRowCaption: {
|
|
404
427
|
fontSize: 12,
|
|
@@ -507,12 +530,21 @@ const styles = StyleSheet.create({
|
|
|
507
530
|
triggerInner: {
|
|
508
531
|
alignItems: 'center',
|
|
509
532
|
flexDirection: 'row',
|
|
510
|
-
gap:
|
|
533
|
+
gap: PLAIN_TRIGGER_GAP,
|
|
511
534
|
height: TRIGGER_HEIGHT
|
|
512
535
|
},
|
|
536
|
+
// Lets the label, alone in its row, give way to a trigger narrower than its name (see `shrinks`).
|
|
537
|
+
// With room it keeps its natural width, so this changes nothing there.
|
|
513
538
|
triggerLabel: {
|
|
539
|
+
flexShrink: 1,
|
|
514
540
|
fontSize: 13,
|
|
515
541
|
fontWeight: '700',
|
|
516
|
-
letterSpacing: 1
|
|
542
|
+
letterSpacing: 1,
|
|
543
|
+
minWidth: 0
|
|
544
|
+
},
|
|
545
|
+
// A shrinking trigger (see `shrinks`): no wider than its anchor. Its row is then laid out at that
|
|
546
|
+
// width, which is what cuts the label off.
|
|
547
|
+
triggerShrinks: {
|
|
548
|
+
maxWidth: '100%'
|
|
517
549
|
}
|
|
518
550
|
})
|
|
@@ -29,8 +29,8 @@ interface ProfileColorProviderProps {
|
|
|
29
29
|
// where it is (ProfileSwatchPlace) and kept flat where it returns null. Hangman draws a pie of each
|
|
30
30
|
// color's triad on the Color row and page only. The renderer is drawn behind the swatch's mark,
|
|
31
31
|
// clipped to the circle.
|
|
32
|
-
//
|
|
33
|
-
//
|
|
32
|
+
// ProfilesManager hands it to hud's InlineColorPicker too (its trigger as a 'chip', its grid as the
|
|
33
|
+
// 'colorPage'). Rows that fill with a color (a selected picker row) stay flat.
|
|
34
34
|
renderSwatch?: ProfileSwatchRenderer
|
|
35
35
|
children: ReactNode
|
|
36
36
|
}
|
package/src/ProfilePicker.tsx
CHANGED
|
@@ -100,7 +100,8 @@ interface ProfilePickerProps<P extends Profile> {
|
|
|
100
100
|
// exactly as before.
|
|
101
101
|
colorSection?: ProfilePickerColorSection
|
|
102
102
|
// Extra style for the trigger (its pressable box) - e.g. a background behind the chip trigger's
|
|
103
|
-
// pill. The chip trigger already pads itself and rounds into a pill.
|
|
103
|
+
// pill. The chip trigger already pads itself and rounds into a pill. A `flexShrink` here lets the
|
|
104
|
+
// trigger give way in a row too narrow for it, cutting its label off (see PickerMenu).
|
|
104
105
|
triggerStyle?: StyleProp<ViewStyle>
|
|
105
106
|
}
|
|
106
107
|
|
package/src/ProfilesManager.tsx
CHANGED
|
@@ -2,15 +2,15 @@ import { defaultColors, FadingScrollView, useAutoPaperTheme } from '@rific/auto-
|
|
|
2
2
|
import { TouchableRipple } from '@rific/feedback-press'
|
|
3
3
|
import { useFocusChain } from '@rific/focus-chain'
|
|
4
4
|
import { useToast } from '@rific/toaster'
|
|
5
|
-
import type { PopoverHost } from '@tastic/hud/chrome'
|
|
5
|
+
import type { InlineColorPickerSwatchRenderer, PopoverHost, ViewInstance } from '@tastic/hud/chrome'
|
|
6
6
|
import { ConfirmDialog, getInlineColorPickerContentSize, InlineColorPicker, usePopoverHost } from '@tastic/hud/chrome'
|
|
7
|
-
import { ComponentProps, ReactNode, Ref, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
|
7
|
+
import { ComponentProps, ReactNode, Ref, RefObject, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
|
8
8
|
import { KeyboardAvoidingView, Platform, StyleSheet, useWindowDimensions, View } from 'react-native'
|
|
9
9
|
import { Icon, Text, TextInput } from 'react-native-paper'
|
|
10
10
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
11
11
|
|
|
12
12
|
import { ProfileChip } from './ProfileChip'
|
|
13
|
-
import { useProfileColorPreview } from './ProfileColorContext'
|
|
13
|
+
import { type ProfileSwatchRenderer, useProfileColorPreview, useProfileSwatchRenderer } from './ProfileColorContext'
|
|
14
14
|
import { type ProfilePickerAlignResult } from './ProfilePicker'
|
|
15
15
|
import { clampTag, isValidTag, MAX_PROFILE_NAME_LENGTH } from './profilesValidation'
|
|
16
16
|
import { Profile } from './types'
|
|
@@ -88,12 +88,21 @@ export interface ProfilesManagerProps {
|
|
|
88
88
|
// measured). It is a hook, not a plain function: give it a STABLE identity for the life of the mount (a
|
|
89
89
|
// module-level function) - switching it at runtime remounts the swatch. Omit for the ordinary case;
|
|
90
90
|
// behaviour is then identical to before. Needs @tastic/hud >= 0.12.0 to have any effect (older hud has no
|
|
91
|
-
// `alignOverride` and ignores it).
|
|
92
|
-
|
|
91
|
+
// `alignOverride` and ignores it). Its fourth argument, `column`, is the roster's scroll view: the popover
|
|
92
|
+
// opens inside it and is clipped at its edges, so where it's narrower than the frame (a column capped in a
|
|
93
|
+
// wide window) the placement should keep the popover inside it. A hook that measures it never has to be
|
|
94
|
+
// told the column's width. A hook that ignores it behaves as before.
|
|
95
|
+
useColorPickerAlign?: (open: boolean, contentWidth: number, contentHeight: number, column: RefObject<ViewInstance | null>) => ProfilePickerAlignResult
|
|
93
96
|
}
|
|
94
97
|
|
|
95
98
|
type Row = { kind: 'profile'; profile: Profile } | { kind: 'new' }
|
|
96
99
|
|
|
100
|
+
// A focus-chain ref as a Paper TextInput takes it. Paper types its input's handle as
|
|
101
|
+
// Pick<TextInput, 'focus' | ...>, which React Native 0.88's strict types read as `focus: unknown`
|
|
102
|
+
// (TextInput is a function there, not a class), so focus-chain's ref, which wants `focus(): void`,
|
|
103
|
+
// no longer fits the prop. The handle does have focus() at run time.
|
|
104
|
+
const textInputRef = (ref: (el: { focus: () => void } | null) => void) => ref as (el: unknown) => void
|
|
105
|
+
|
|
97
106
|
// First letter of the name, uppercased - the tag's own default for as long as it hasn't been typed
|
|
98
107
|
// into directly (see the tagManuallySet state below). Empty name -> empty tag, same as typing
|
|
99
108
|
// nothing into the tag field yourself would leave it.
|
|
@@ -120,6 +129,11 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
|
|
|
120
129
|
const { dark } = useAutoPaperTheme()
|
|
121
130
|
const insets = useSafeAreaInsets()
|
|
122
131
|
const { error: showErrorToast } = useToast()
|
|
132
|
+
// The roster's scroll view, which clips the color popover: the color picker keeps its popover
|
|
133
|
+
// inside it (InlineColorPicker's `bounds`), and a host's useColorPickerAlign is handed it.
|
|
134
|
+
const columnRef = useRef<ViewInstance>(null)
|
|
135
|
+
// ProfileColorProvider's swatch renderer, for the color picker's trigger and swatches.
|
|
136
|
+
const renderSwatch = useProfileSwatchRenderer()
|
|
123
137
|
// Only one row is ever in edit mode at a time (see commitEdit, called before any new row opens),
|
|
124
138
|
// so every EditRow instance can safely share this one host instead of each needing its own.
|
|
125
139
|
const host = usePopoverHost()
|
|
@@ -247,46 +261,50 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
|
|
|
247
261
|
fade out at an edge while there is more past it, the same cue as the Settings sheet and the
|
|
248
262
|
Achievements screen (Jay, 2026-10-16: every scrolling screen alike). */}
|
|
249
263
|
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.keyboardAvoiding}>
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
264
|
+
{/* A plain View around the scroll view, the same size, for the column the color popover
|
|
265
|
+
is kept inside (see columnRef): the scroll view takes no ref. */}
|
|
266
|
+
<View ref={columnRef} collapsable={false} style={styles.column}>
|
|
267
|
+
<FadingScrollView style={styles.scrollView} contentContainerStyle={[styles.content, { paddingBottom: 32 + insets.bottom }]} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps='handled'>
|
|
268
|
+
{rows.map((row) => {
|
|
269
|
+
if (row.kind === 'new') {
|
|
270
|
+
if (editingId !== 'new') {
|
|
271
|
+
return (
|
|
272
|
+
<TouchableRipple key='new' onPress={() => startEdit('new')} style={styles.row}>
|
|
273
|
+
<View style={styles.rowInner}>
|
|
274
|
+
<View style={[styles.rowChip, styles.newChip, { borderColor: fg }]}>
|
|
275
|
+
<Icon source='plus' size={20} color={fg} />
|
|
276
|
+
</View>
|
|
277
|
+
<Text variant='bodyLarge' style={{ color: fg }}>
|
|
278
|
+
New Profile
|
|
279
|
+
</Text>
|
|
259
280
|
</View>
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
</TouchableRipple>
|
|
265
|
-
)
|
|
281
|
+
</TouchableRipple>
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
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} useColorPickerAlign={useColorPickerAlign} column={columnRef} renderSwatch={renderSwatch} />
|
|
266
285
|
}
|
|
267
|
-
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} useColorPickerAlign={useColorPickerAlign} />
|
|
268
|
-
}
|
|
269
286
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
287
|
+
const { profile } = row
|
|
288
|
+
if (editingId === profile.id) {
|
|
289
|
+
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} useColorPickerAlign={useColorPickerAlign} column={columnRef} renderSwatch={renderSwatch} />
|
|
290
|
+
}
|
|
274
291
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
292
|
+
// No delete affordance at rest - tap the row to start editing, which is the one place
|
|
293
|
+
// delete shows (see EditRow): a deliberate extra step before a destructive action is
|
|
294
|
+
// even reachable, not just before it's confirmed.
|
|
295
|
+
return (
|
|
296
|
+
<TouchableRipple key={profile.id} onPress={() => startEdit(profile)} style={styles.row}>
|
|
297
|
+
<View style={styles.rowInner}>
|
|
298
|
+
<ProfileChip profile={profile} size={CHIP_SIZE} />
|
|
299
|
+
<Text variant='bodyLarge' style={[styles.rowName, { color: fg }]} numberOfLines={1}>
|
|
300
|
+
{profile.name}
|
|
301
|
+
</Text>
|
|
302
|
+
</View>
|
|
303
|
+
</TouchableRipple>
|
|
304
|
+
)
|
|
305
|
+
})}
|
|
306
|
+
</FadingScrollView>
|
|
307
|
+
</View>
|
|
290
308
|
</KeyboardAvoidingView>
|
|
291
309
|
|
|
292
310
|
{/* hud's ConfirmDialog, the one confirm card every app in the fleet uses, so this reads like the
|
|
@@ -314,6 +332,8 @@ interface EditRowProps {
|
|
|
314
332
|
dark: boolean
|
|
315
333
|
colorPreview?: (hex: string) => string
|
|
316
334
|
useColorPickerAlign?: ProfilesManagerProps['useColorPickerAlign']
|
|
335
|
+
column: RefObject<ViewInstance | null>
|
|
336
|
+
renderSwatch?: ProfileSwatchRenderer
|
|
317
337
|
}
|
|
318
338
|
|
|
319
339
|
// InlineColorPicker with its placement supplied by a host hook (see ProfilesManagerProps.useColorPickerAlign).
|
|
@@ -321,10 +341,10 @@ interface EditRowProps {
|
|
|
321
341
|
// unconditionally here, and the plain path below stays byte-identical to what it was. Sized exactly as
|
|
322
342
|
// InlineColorPicker sizes itself (the default swatch list at the raw window width), so the host's placement and
|
|
323
343
|
// the popover it positions agree on its footprint.
|
|
324
|
-
function AlignedColorPicker({ useAlign, ...props }: ComponentProps<typeof InlineColorPicker> & { useAlign: NonNullable<ProfilesManagerProps['useColorPickerAlign']
|
|
344
|
+
function AlignedColorPicker({ useAlign, column, ...props }: ComponentProps<typeof InlineColorPicker> & { useAlign: NonNullable<ProfilesManagerProps['useColorPickerAlign']>; column: EditRowProps['column'] }) {
|
|
325
345
|
const { width: windowWidth } = useWindowDimensions()
|
|
326
346
|
const { width, height } = getInlineColorPickerContentSize(defaultColors.length, windowWidth)
|
|
327
|
-
const alignOverride = useAlign(props.host.openId === props.id, width, height)
|
|
347
|
+
const alignOverride = useAlign(props.host.openId === props.id, width, height, column)
|
|
328
348
|
return <InlineColorPicker {...props} alignOverride={alignOverride} />
|
|
329
349
|
}
|
|
330
350
|
|
|
@@ -340,7 +360,11 @@ function AlignedColorPicker({ useAlign, ...props }: ComponentProps<typeof Inline
|
|
|
340
360
|
// own - submitting the *name* field is what actually commits the row, so its registration's own
|
|
341
361
|
// onSubmitEditing (a no-op - it's last in the chain) is overridden with onSubmit below, matching
|
|
342
362
|
// how a normal multi-field form reads: fill fields in order, the last one finishes it.
|
|
343
|
-
function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview, useColorPickerAlign }: EditRowProps) {
|
|
363
|
+
function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview, useColorPickerAlign, column, renderSwatch }: EditRowProps) {
|
|
364
|
+
// ProfileColorProvider's renderer in the color picker: its trigger is the profile's color with its
|
|
365
|
+
// tag on it, an identity like a chip, and its grid is where a color is chosen, like a picker's color
|
|
366
|
+
// page (Hangman draws its triad pies on the grid only).
|
|
367
|
+
const pickerSwatch = useMemo<InlineColorPickerSwatchRenderer | undefined>(() => (renderSwatch ? (color, size, place) => renderSwatch(color, size, place === 'trigger' ? 'chip' : 'colorPage') : undefined), [renderSwatch])
|
|
344
368
|
const register = useFocusChain()
|
|
345
369
|
const tag = register()
|
|
346
370
|
const name = register()
|
|
@@ -367,7 +391,7 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
|
|
|
367
391
|
|
|
368
392
|
return (
|
|
369
393
|
<View style={styles.editRow}>
|
|
370
|
-
{useColorPickerAlign ? <AlignedColorPicker useAlign={useColorPickerAlign} id='color' host={host} value={draftColor} onChange={onColorChange} previewValue={colorPreview} tag={draftTag} dark={dark} /> : <InlineColorPicker id='color' host={host} value={draftColor} onChange={onColorChange} previewValue={colorPreview} tag={draftTag} dark={dark} />}
|
|
394
|
+
{useColorPickerAlign ? <AlignedColorPicker useAlign={useColorPickerAlign} column={column} id='color' host={host} value={draftColor} onChange={onColorChange} previewValue={colorPreview} tag={draftTag} dark={dark} renderSwatch={pickerSwatch} /> : <InlineColorPicker id='color' host={host} value={draftColor} onChange={onColorChange} previewValue={colorPreview} tag={draftTag} dark={dark} renderSwatch={pickerSwatch} bounds={column} />}
|
|
371
395
|
{/* Invisible on purpose (styles.hiddenTagInput: zero footprint, position: 'absolute' so it
|
|
372
396
|
doesn't reserve space in the row's own flex layout) - the swatch above is the only place this
|
|
373
397
|
value is ever meant to be seen. pointerEvents='none' is what actually keeps it out of the
|
|
@@ -379,12 +403,10 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
|
|
|
379
403
|
find it as "Tag" the same way they find the Name field by its placeholder. selectTextOnFocus
|
|
380
404
|
so re-tapping the swatch to revise an already-set tag replaces it wholesale rather than
|
|
381
405
|
inserting mid-string. */}
|
|
382
|
-
{
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
<TextInput ref={
|
|
386
|
-
{/* eslint-disable-next-line react-hooks/refs -- see the tag field's identical note above */}
|
|
387
|
-
<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} />
|
|
406
|
+
{}
|
|
407
|
+
<TextInput ref={textInputRef(tag.ref)} {...tag.props} value={draftTag} onChangeText={onTagChange} accessibilityLabel='Tag' autoCapitalize='characters' selectTextOnFocus returnKeyType='next' style={styles.hiddenTagInput} pointerEvents='none' />
|
|
408
|
+
{}
|
|
409
|
+
<TextInput ref={textInputRef(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} />
|
|
388
410
|
{onDelete && (
|
|
389
411
|
<TouchableRipple onPress={onDelete} style={styles.iconButton}>
|
|
390
412
|
<Icon source='trash-can-outline' size={18} color={fgMuted} />
|
|
@@ -395,6 +417,10 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
|
|
|
395
417
|
}
|
|
396
418
|
|
|
397
419
|
const styles = StyleSheet.create({
|
|
420
|
+
// The roster's column, around the scroll view (see columnRef).
|
|
421
|
+
column: {
|
|
422
|
+
flex: 1
|
|
423
|
+
},
|
|
398
424
|
content: {
|
|
399
425
|
gap: 4,
|
|
400
426
|
paddingHorizontal: 20
|
|
@@ -122,15 +122,45 @@ export async function loadSharedProfiles(groupId: string): Promise<Profile[]> {
|
|
|
122
122
|
return (await readSharedProfiles(groupId)) ?? []
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
const hasId = (entry: unknown): entry is { id: string } => typeof entry === 'object' && entry !== null && typeof (entry as { id?: unknown }).id === 'string' && (entry as { id: string }).id !== ''
|
|
126
|
+
|
|
127
|
+
// The stored roster's entries this version can't read (they fail isValidProfile: a newer app's
|
|
128
|
+
// profile under a rule loosened since, say) and that aren't in `profiles`, so saveSharedProfiles can
|
|
129
|
+
// write them back as they are. This app never had them in its roster, so a save that left them out
|
|
130
|
+
// would delete them for every app in the group. Only entries with an id are kept (anything else is
|
|
131
|
+
// unusable to every app). [] when the stored roster can't be read at all. The web store is read
|
|
132
|
+
// directly, past its failed-write latch: that latch is there so a stale roster can't replace a
|
|
133
|
+
// local one, and nothing read here replaces anything.
|
|
134
|
+
async function unreadableEntries(groupId: string, profiles: Profile[]): Promise<unknown[]> {
|
|
135
|
+
let json: string | null | undefined
|
|
136
|
+
try {
|
|
137
|
+
json = web ? web.storage.getItem(webKey(groupId, PROFILES_KEY)) : await native?.getSharedJSON(groupId, PROFILES_KEY)
|
|
138
|
+
} catch {
|
|
139
|
+
return []
|
|
140
|
+
}
|
|
141
|
+
if (!json) return []
|
|
142
|
+
try {
|
|
143
|
+
const parsed: unknown = JSON.parse(json)
|
|
144
|
+
if (!Array.isArray(parsed)) return []
|
|
145
|
+
const saved = new Set(profiles.map((profile) => profile.id))
|
|
146
|
+
return parsed.filter((entry) => !isValidProfile(entry) && hasId(entry) && !saved.has(entry.id))
|
|
147
|
+
} catch {
|
|
148
|
+
return []
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
125
152
|
// Overwrites the entire shared roster - same whole-blob-write discipline a host app's own
|
|
126
|
-
// AsyncStorage-backed profile store already uses, not an incremental per-profile update
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
153
|
+
// AsyncStorage-backed profile store already uses, not an incremental per-profile update - except
|
|
154
|
+
// for the stored entries this version can't read, which it keeps (see unreadableEntries), after
|
|
155
|
+
// `profiles`. A write failure (store unavailable, or some other native error) is swallowed rather
|
|
156
|
+
// than thrown: the caller's own local state is already the source of truth for what just changed,
|
|
157
|
+
// and this is a best-effort mirror of it, not the other way around. A failed read before it writes
|
|
158
|
+
// `profiles` alone, as every save did before entries were kept.
|
|
130
159
|
export async function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void> {
|
|
131
160
|
if (!native) return
|
|
132
161
|
try {
|
|
133
|
-
await
|
|
162
|
+
const kept = await unreadableEntries(groupId, profiles)
|
|
163
|
+
await native.setSharedJSON(groupId, PROFILES_KEY, JSON.stringify([...profiles, ...kept]))
|
|
134
164
|
} catch {
|
|
135
165
|
// Best-effort - see doc comment above.
|
|
136
166
|
}
|