@tastic/profile 0.11.0 → 0.12.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/LICENSE +21 -0
- package/README.md +87 -63
- package/dist/index.d.mts +4 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +50 -15
- package/dist/index.mjs +48 -15
- package/ios/TasticProfileModule.swift +2 -2
- package/package.json +2 -2
- package/src/ProfileChip.tsx +5 -5
- package/src/ProfilePicker.tsx +31 -31
- package/src/ProfilesManager.tsx +45 -45
- package/src/ProfilesScreen.tsx +8 -8
- package/src/index.ts +2 -1
- package/src/profilesValidation.ts +9 -9
- package/src/redux/createActionCreator.ts +1 -1
- package/src/redux/profileExtensionSlice.ts +3 -3
- package/src/redux/profileSelectionSlice.ts +39 -11
- package/src/redux/resolveInitialProfiles.ts +10 -6
- package/src/redux/seatColorsSlice.ts +10 -10
- package/src/redux/useSharedProfilesSync.ts +11 -10
- package/src/seatColorClash.ts +13 -0
- package/src/sharedProfileStore.ts +27 -12
- package/src/types.ts +3 -3
package/dist/index.mjs
CHANGED
|
@@ -148,12 +148,12 @@ var styles2 = StyleSheet2.create({
|
|
|
148
148
|
anchor: {
|
|
149
149
|
position: "relative"
|
|
150
150
|
},
|
|
151
|
-
// See InlineColorPicker's identical comment
|
|
151
|
+
// See InlineColorPicker's identical comment - React Native Web gives every position:'relative'
|
|
152
152
|
// view its own stacking context, so the elevation has to live on the anchor itself.
|
|
153
153
|
anchorOpen: {
|
|
154
154
|
zIndex: 100
|
|
155
155
|
},
|
|
156
|
-
// Separates Manage (a navigation action) from the selectable rows above it
|
|
156
|
+
// Separates Manage (a navigation action) from the selectable rows above it - different kind of
|
|
157
157
|
// action, not just another row in the same list. Tinted with the seat's own color (passed in as
|
|
158
158
|
// backgroundColor) rather than a flat gray, matching the border/caret's own accent-tinted look.
|
|
159
159
|
divider: {
|
|
@@ -162,7 +162,7 @@ var styles2 = StyleSheet2.create({
|
|
|
162
162
|
opacity: 0.25
|
|
163
163
|
},
|
|
164
164
|
// Matches ProfileChip's own default size/shape exactly (see this row's own render-site comment)
|
|
165
|
-
//
|
|
165
|
+
// - no backgroundColor, unlike ProfileChip's own default: this row has no identity color of its
|
|
166
166
|
// own to fill it with, and the render site now colors the icon directly instead.
|
|
167
167
|
guestChip: {
|
|
168
168
|
alignItems: "center",
|
|
@@ -176,7 +176,7 @@ var styles2 = StyleSheet2.create({
|
|
|
176
176
|
fontWeight: "700",
|
|
177
177
|
letterSpacing: 0.5
|
|
178
178
|
},
|
|
179
|
-
// Centered rather than left-aligned like a normal row
|
|
179
|
+
// Centered rather than left-aligned like a normal row - see this row's own render-site comment
|
|
180
180
|
// for why (a footer action, not another selectable identity).
|
|
181
181
|
manageRowInner: {
|
|
182
182
|
alignItems: "center",
|
|
@@ -194,7 +194,7 @@ var styles2 = StyleSheet2.create({
|
|
|
194
194
|
shadowOpacity: 0.3,
|
|
195
195
|
shadowRadius: 8
|
|
196
196
|
},
|
|
197
|
-
// Padded on every side (not just top/bottom)
|
|
197
|
+
// Padded on every side (not just top/bottom) - a selected row's own full-color background (see
|
|
198
198
|
// `row`'s borderRadius) needs real margin from the menu's own straight edges to read as a rounded
|
|
199
199
|
// pill instead of looking clipped against them.
|
|
200
200
|
menuContent: {
|
|
@@ -437,7 +437,7 @@ var styles3 = StyleSheet3.create({
|
|
|
437
437
|
gap: 4,
|
|
438
438
|
paddingBottom: 8
|
|
439
439
|
},
|
|
440
|
-
// 1x1 and fully transparent
|
|
440
|
+
// 1x1 and fully transparent - there's nothing to see here on purpose (see EditRow's own comment
|
|
441
441
|
// above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
|
|
442
442
|
// entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
|
|
443
443
|
hiddenTagInput: {
|
|
@@ -570,9 +570,27 @@ function createProfileSelectionSlice(namespace, defaultState, mountKey = "profil
|
|
|
570
570
|
function reducer(state = defaultState, action) {
|
|
571
571
|
if (action.type === REHYDRATE) {
|
|
572
572
|
const persisted = action.payload?.[mountKey];
|
|
573
|
-
|
|
573
|
+
const merged = { ...defaultState, ...state, ...persisted };
|
|
574
|
+
const seen = /* @__PURE__ */ new Set();
|
|
575
|
+
for (const seat of Object.keys(merged)) {
|
|
576
|
+
const profileId = merged[seat];
|
|
577
|
+
if (profileId === null) continue;
|
|
578
|
+
if (seen.has(profileId)) merged[seat] = null;
|
|
579
|
+
else seen.add(profileId);
|
|
580
|
+
}
|
|
581
|
+
return merged;
|
|
582
|
+
}
|
|
583
|
+
if (select.match(action)) {
|
|
584
|
+
const { profileId, seat } = action.payload;
|
|
585
|
+
const previous = state[seat] ?? null;
|
|
586
|
+
const next = { ...state, [seat]: profileId };
|
|
587
|
+
if (profileId !== null) {
|
|
588
|
+
for (const other of Object.keys(state)) {
|
|
589
|
+
if (String(other) !== String(seat) && state[other] === profileId) next[other] = previous === profileId ? null : previous;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return next;
|
|
574
593
|
}
|
|
575
|
-
if (select.match(action)) return { ...state, [action.payload.seat]: action.payload.profileId };
|
|
576
594
|
if (clearProfile.match(action)) {
|
|
577
595
|
const next = { ...state };
|
|
578
596
|
for (const seat of Object.keys(state)) {
|
|
@@ -632,22 +650,27 @@ try {
|
|
|
632
650
|
}
|
|
633
651
|
var PROFILES_KEY = "sharedProfiles";
|
|
634
652
|
var isSharedProfileStoreAvailable = native !== null;
|
|
635
|
-
async function
|
|
653
|
+
async function readSharedProfiles(groupId) {
|
|
636
654
|
if (!native) return [];
|
|
637
655
|
let json = null;
|
|
638
656
|
try {
|
|
639
657
|
json = await native.getSharedJSON(groupId, PROFILES_KEY);
|
|
640
658
|
} catch {
|
|
641
|
-
return
|
|
659
|
+
return null;
|
|
642
660
|
}
|
|
643
661
|
if (!json) return [];
|
|
644
662
|
try {
|
|
645
663
|
const parsed = JSON.parse(json);
|
|
646
|
-
|
|
664
|
+
if (!Array.isArray(parsed)) return null;
|
|
665
|
+
const profiles = parsed.filter(isValidProfile);
|
|
666
|
+
return parsed.length > 0 && profiles.length === 0 ? null : profiles;
|
|
647
667
|
} catch {
|
|
648
|
-
return
|
|
668
|
+
return null;
|
|
649
669
|
}
|
|
650
670
|
}
|
|
671
|
+
async function loadSharedProfiles(groupId) {
|
|
672
|
+
return await readSharedProfiles(groupId) ?? [];
|
|
673
|
+
}
|
|
651
674
|
async function saveSharedProfiles(groupId, profiles) {
|
|
652
675
|
if (!native) return;
|
|
653
676
|
try {
|
|
@@ -659,7 +682,8 @@ async function saveSharedProfiles(groupId, profiles) {
|
|
|
659
682
|
// src/redux/resolveInitialProfiles.ts
|
|
660
683
|
async function resolveInitialProfiles(groupId, localFallback) {
|
|
661
684
|
if (!isSharedProfileStoreAvailable) return localFallback;
|
|
662
|
-
const shared = await
|
|
685
|
+
const shared = await readSharedProfiles(groupId);
|
|
686
|
+
if (shared === null) return localFallback;
|
|
663
687
|
if (shared.length === 0 && localFallback.length > 0) {
|
|
664
688
|
saveSharedProfiles(groupId, localFallback).catch(() => {
|
|
665
689
|
});
|
|
@@ -701,8 +725,8 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
|
|
|
701
725
|
let cancelled = false;
|
|
702
726
|
const sub = AppState.addEventListener("change", (next) => {
|
|
703
727
|
if (next !== "active" || pendingWriteRef.current) return;
|
|
704
|
-
|
|
705
|
-
if (!cancelled) onRemoteChangeRef.current(shared);
|
|
728
|
+
readSharedProfiles(groupId).then((shared) => {
|
|
729
|
+
if (!cancelled && shared !== null) onRemoteChangeRef.current(shared);
|
|
706
730
|
});
|
|
707
731
|
});
|
|
708
732
|
return () => {
|
|
@@ -723,6 +747,13 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
|
|
|
723
747
|
);
|
|
724
748
|
return { syncToShared };
|
|
725
749
|
}
|
|
750
|
+
|
|
751
|
+
// src/seatColorClash.ts
|
|
752
|
+
function avoidSeatColorClash(p1Color, p2Color, defaults) {
|
|
753
|
+
if (p2Color.toLowerCase() !== p1Color.toLowerCase()) return p2Color;
|
|
754
|
+
const [p1Default, p2Default] = defaults;
|
|
755
|
+
return p2Color.toLowerCase() === p1Default.toLowerCase() ? p2Default : p1Default;
|
|
756
|
+
}
|
|
726
757
|
export {
|
|
727
758
|
MAX_PROFILE_NAME_LENGTH,
|
|
728
759
|
MAX_TAG_LENGTH,
|
|
@@ -731,6 +762,7 @@ export {
|
|
|
731
762
|
ProfilePicker,
|
|
732
763
|
ProfilesManager,
|
|
733
764
|
ProfilesScreen,
|
|
765
|
+
avoidSeatColorClash,
|
|
734
766
|
createProfileExtensionSlice,
|
|
735
767
|
createProfileRecord,
|
|
736
768
|
createProfileSelectionSlice,
|
|
@@ -742,6 +774,7 @@ export {
|
|
|
742
774
|
loadSharedProfiles,
|
|
743
775
|
profilesActions,
|
|
744
776
|
profilesReducer,
|
|
777
|
+
readSharedProfiles,
|
|
745
778
|
resolveInitialProfiles,
|
|
746
779
|
saveSharedProfiles,
|
|
747
780
|
useProfileColorPreview,
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import ExpoModulesCore
|
|
2
2
|
|
|
3
3
|
// Generic group/key -> JSON-string bridge onto a native iOS App Group's shared UserDefaults suite
|
|
4
|
-
//
|
|
4
|
+
// - the mechanism that actually lets two separate apps (same Apple Developer Team, both declaring
|
|
5
5
|
// the same "com.apple.security.application-groups" entitlement) read and write one common blob of
|
|
6
|
-
// data. Nothing here is @tastic/profile-specific
|
|
6
|
+
// data. Nothing here is @tastic/profile-specific - see src/sharedProfileStore.ts, the one caller,
|
|
7
7
|
// for the profile-roster-shaped API built on top of this. Android/web never register this module
|
|
8
8
|
// at all (see expo-module.config.json's "apple"-only platform list), so a consuming app has to
|
|
9
9
|
// treat "module not present" as a normal, expected case rather than an error.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tastic/profile",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Player profile identity for local-multiplayer React Native games
|
|
3
|
+
"version": "0.12.0",
|
|
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",
|
|
7
7
|
"expo",
|
package/src/ProfileChip.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import { Icon, Text } from 'react-native-paper'
|
|
|
5
5
|
import { useProfileDisplayColor } from './ProfileColorContext'
|
|
6
6
|
import { Profile } from './types'
|
|
7
7
|
|
|
8
|
-
// Presence check, not isValidTag's own full-string emoji validation (profilesValidation.ts)
|
|
8
|
+
// Presence check, not isValidTag's own full-string emoji validation (profilesValidation.ts) - this
|
|
9
9
|
// only decides a font-size ratio, so it doesn't need that check's ZWJ/variation-selector precision,
|
|
10
10
|
// just "does this tag contain a pictograph at all."
|
|
11
11
|
const EMOJI_PATTERN = /\p{Extended_Pictographic}/u
|
|
@@ -14,7 +14,7 @@ interface Props {
|
|
|
14
14
|
profile: Profile
|
|
15
15
|
size?: number
|
|
16
16
|
// False on a selected row, whose own background already fills with the profile's color (see
|
|
17
|
-
// ProfilePicker's own selected-row handling)
|
|
17
|
+
// ProfilePicker's own selected-row handling) - a second circle behind the tag there would either
|
|
18
18
|
// double up decoratively or, filled some other flat color, look like a stray badge next to the
|
|
19
19
|
// plain icon+text every other selected row in the app uses. Contrast is still computed against
|
|
20
20
|
// the profile's own color either way (that's what's actually behind the glyph whether this chip
|
|
@@ -24,18 +24,18 @@ interface Props {
|
|
|
24
24
|
filled?: boolean
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
// A profile's identity at a glance
|
|
27
|
+
// A profile's identity at a glance - its own color as a filled circle, with its tag (or a generic
|
|
28
28
|
// fallback icon, for a profile with none set) in a contrast-safe color on top. The same combination
|
|
29
29
|
// @tastic/hud's InlineColorPicker trigger shows for live color selection, just as a small static
|
|
30
30
|
// badge for a list row (ProfilePicker's dropdown, ProfilesManager's roster) rather than an
|
|
31
31
|
// interactive picker trigger.
|
|
32
32
|
export function ProfileChip({ profile, size = 22, filled = true }: Props) {
|
|
33
|
-
// The drawn color, not necessarily the saved one
|
|
33
|
+
// The drawn color, not necessarily the saved one - see ProfileColorProvider.
|
|
34
34
|
const color = useProfileDisplayColor(profile.color)
|
|
35
35
|
const contrastColor = getContrastColor(color)
|
|
36
36
|
// An emoji glyph reads visually smaller than a bold letter at the same fontSize (the system emoji
|
|
37
37
|
// font leaves more of its own em-box empty), so a plain-text ratio that looks right for "J" still
|
|
38
|
-
// looks small for "🎮" at the identical size
|
|
38
|
+
// looks small for "🎮" at the identical size - this bumps emoji up to the icon's own ratio instead,
|
|
39
39
|
// since a single emoji is closer in visual weight to an icon glyph than to a line of text.
|
|
40
40
|
const tagFontSize = size * (profile.tag && EMOJI_PATTERN.test(profile.tag) ? 0.6 : 0.4)
|
|
41
41
|
return (
|
package/src/ProfilePicker.tsx
CHANGED
|
@@ -17,7 +17,7 @@ const LIST_PADDING = 8
|
|
|
17
17
|
// A complete placement decision for the popover: which way it opens, how tall it may grow, whether
|
|
18
18
|
// this open's measurement has landed yet, and the ref attached to the measured trigger.
|
|
19
19
|
// Structurally identical to @tastic/hud's own `AlignResult` (exported from hud >= 0.12.0, and the
|
|
20
|
-
// same shape `useAutoAlign` itself returns)
|
|
20
|
+
// same shape `useAutoAlign` itself returns) - declared here rather than imported so this package's
|
|
21
21
|
// public types don't start requiring a newer @tastic/hud than its peer range already admits. A
|
|
22
22
|
// hud `AlignResult` (e.g. from `useZoneClampedAlign`) is assignable to this as-is.
|
|
23
23
|
export interface ProfilePickerAlignResult {
|
|
@@ -36,9 +36,9 @@ export interface ProfilePickerContentSize {
|
|
|
36
36
|
// The popover's content footprint for a given roster size: always POPOVER_WIDTH wide, tall enough
|
|
37
37
|
// for the null/"guest" row + one row per profile + (when `hasManageRow`, i.e. when the picker is
|
|
38
38
|
// given an `onManage`) the trailing Manage row, plus the list's own padding. This is the size
|
|
39
|
-
// ProfilePicker hands to `useAutoAlign`
|
|
39
|
+
// ProfilePicker hands to `useAutoAlign` - the ONE formula, used by the component itself (it leaves
|
|
40
40
|
// out the menu's 2pt border and the Manage divider, ~13pt in all, so treat it as a placement
|
|
41
|
-
// estimate, not a pixel-exact measurement)
|
|
41
|
+
// estimate, not a pixel-exact measurement) - exported
|
|
42
42
|
// so a host supplying its own placement through `alignOverride` can size that placement hook (its
|
|
43
43
|
// own useAutoAlign/useZoneClampedAlign call) to match the popover it is actually positioning,
|
|
44
44
|
// instead of copying these constants and drifting whenever they change here.
|
|
@@ -48,32 +48,32 @@ export function getProfilePickerContentSize(profileCount: number, hasManageRow =
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
interface ProfilePickerProps<P extends Profile> {
|
|
51
|
-
// Namespaces this trigger's id
|
|
51
|
+
// Namespaces this trigger's id - pass something unique per instance whenever more than one of
|
|
52
52
|
// these shares a PopoverHost (e.g. one per seat).
|
|
53
53
|
idPrefix: string
|
|
54
|
-
// The host this trigger's popover opens against
|
|
54
|
+
// The host this trigger's popover opens against - share one host across sibling pickers (a
|
|
55
55
|
// seat's color/tag/profile triggers) so opening one closes any other in the same group.
|
|
56
56
|
host: PopoverHost
|
|
57
57
|
profiles: P[]
|
|
58
58
|
selectedId: string | null
|
|
59
|
-
// Another picker's currently-selected profile id, if any
|
|
59
|
+
// Another picker's currently-selected profile id, if any - disabled in this list. Useful when a
|
|
60
60
|
// profile shouldn't be selectable in two places at once (e.g. two seats in a local-multiplayer
|
|
61
61
|
// game, where double-selecting one profile would double-count its stats as both winner and
|
|
62
|
-
// loser)
|
|
62
|
+
// loser) - omit if that doesn't apply.
|
|
63
63
|
takenId?: string | null
|
|
64
|
-
// Tint for the trigger's own label/icon
|
|
64
|
+
// Tint for the trigger's own label/icon - pass the seat/context's current live color so the
|
|
65
65
|
// trigger reads as one identity with whatever it's paired with.
|
|
66
66
|
color: string
|
|
67
67
|
dark: boolean
|
|
68
68
|
// Forces a specific horizontal alignment, beating whatever the placement (the automatic
|
|
69
69
|
// measurement, or `alignOverride` below) would have picked. Omit to let placement decide.
|
|
70
70
|
align?: 'left' | 'right' | 'center'
|
|
71
|
-
// Substitutes the popover's whole placement
|
|
72
|
-
// `measured` gate and the trigger ref
|
|
71
|
+
// Substitutes the popover's whole placement - horizontal/vertical alignment, max height, the
|
|
72
|
+
// `measured` gate and the trigger ref - for the automatic `useAutoAlign` one, for a host whose
|
|
73
73
|
// trigger sits somewhere that hook's plain window-relative measurement gets wrong. The motivating
|
|
74
74
|
// case is a FAKE-rotated frame (a View turned +-90deg while the OS stays portrait-locked):
|
|
75
75
|
// `measureInWindow` reports a rect in true screen space, `useAutoAlign` compares it against the
|
|
76
|
-
// raw portrait window, and so measures along the wrong axes
|
|
76
|
+
// raw portrait window, and so measures along the wrong axes - flipping the list upward off-screen
|
|
77
77
|
// or running it past the frame's bottom. The host runs its own rotation-aware placement hook
|
|
78
78
|
// (sized with `getProfilePickerContentSize`, passing `open` from its own `host.openId === ...`
|
|
79
79
|
// check) and hands the result in here; `align` above still wins over this result's own `align`.
|
|
@@ -81,7 +81,7 @@ interface ProfilePickerProps<P extends Profile> {
|
|
|
81
81
|
// produced it is measuring the right element. Omit for the default automatic placement.
|
|
82
82
|
alignOverride?: ProfilePickerAlignResult
|
|
83
83
|
// Rotation of an ancestor frame turned by a fake landscape (or equivalent), forwarded to the
|
|
84
|
-
// automatic `useAutoAlign` measurement
|
|
84
|
+
// automatic `useAutoAlign` measurement - the same seam @tastic/hud's InlineColorPicker/
|
|
85
85
|
// SectionedDropdown expose (needs @tastic/hud >= 0.8.0; older versions ignore it). Defaults to 0
|
|
86
86
|
// (measurement used as-is), which is exactly how this picker behaved before this prop existed.
|
|
87
87
|
// Deliberately NOT read from an ambient provider the way hud's own pickers do: this component
|
|
@@ -89,35 +89,35 @@ interface ProfilePickerProps<P extends Profile> {
|
|
|
89
89
|
// every existing caller already living under a rotation provider. Ignored when `alignOverride` is
|
|
90
90
|
// supplied (the override replaces the placement this feeds).
|
|
91
91
|
rotation?: PopoverRotation
|
|
92
|
-
// Trigger's own idle-state text (uppercased here regardless of casing passed in)
|
|
92
|
+
// Trigger's own idle-state text (uppercased here regardless of casing passed in) - shown until a
|
|
93
93
|
// profile is selected.
|
|
94
94
|
guestLabel: string
|
|
95
|
-
// The null-selection row's own label/icon
|
|
95
|
+
// The null-selection row's own label/icon - defaults to a generic anonymous-player row, but
|
|
96
96
|
// overridable for a caller where null means something else (e.g. an "All Profiles" combined view
|
|
97
97
|
// reusing this same component for its own picker).
|
|
98
98
|
nullLabel?: string
|
|
99
99
|
nullIcon?: string
|
|
100
100
|
onSelect: (profile: P | null) => void
|
|
101
|
-
// Renders a row for navigating to a dedicated create/rename/delete screen
|
|
101
|
+
// Renders a row for navigating to a dedicated create/rename/delete screen - omit to hide it
|
|
102
102
|
// entirely. This component never manages profiles itself (see ProfilesManager for that); this is
|
|
103
103
|
// selection-only.
|
|
104
104
|
onManage?: () => void
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
// Generic over P (an app's own Profile type, richer than this package's base Profile) so that a
|
|
108
|
-
// caller's own onSelect
|
|
108
|
+
// caller's own onSelect - typed to accept its own richer profile shape - type-checks against the
|
|
109
109
|
// objects this component actually hands back, which are always exactly whatever came in through
|
|
110
110
|
// `profiles` (see handleSelectExisting below). Without this, TypeScript would only ever let
|
|
111
111
|
// onSelect declare the base Profile type, since that's all a non-generic prop signature could
|
|
112
|
-
// promise it receives
|
|
112
|
+
// promise it receives - even though at runtime it always receives the caller's own richer objects.
|
|
113
113
|
// ProfileChip doesn't need this same treatment: it only ever takes a profile in, never hands one
|
|
114
114
|
// back out, so plain structural covariance already lets a richer object through.
|
|
115
115
|
//
|
|
116
116
|
// A name trigger + selection popover: pick an existing profile, fall back to a null/"guest"
|
|
117
117
|
// selection, or (if onManage is passed) navigate to manage the roster. Creating, renaming, and
|
|
118
|
-
// deleting profiles all live outside this component (see ProfilesManager)
|
|
118
|
+
// deleting profiles all live outside this component (see ProfilesManager) - this is selection-only,
|
|
119
119
|
// both because that keeps this component simple enough to drop into a HUD trigger row, and because
|
|
120
|
-
// it keeps this popover from needing its own destructive-delete confirmation. Fully prop-driven
|
|
120
|
+
// it keeps this popover from needing its own destructive-delete confirmation. Fully prop-driven -
|
|
121
121
|
// no internal profile-storage access of its own, so the host app stays the one place that reads
|
|
122
122
|
// its own persistence hook.
|
|
123
123
|
export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: forcedAlign, alignOverride, rotation = 0, guestLabel, nullLabel = 'Player', nullIcon = 'account-off-outline', onSelect, onManage }: ProfilePickerProps<P>) {
|
|
@@ -129,12 +129,12 @@ export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, sel
|
|
|
129
129
|
|
|
130
130
|
const open = host.openId === id
|
|
131
131
|
const selectedProfile = profiles.find((p) => p.id === selectedId) ?? null
|
|
132
|
-
// Alphabetical
|
|
132
|
+
// Alphabetical - this is the list that sees the most real use, so it's the one that benefits
|
|
133
133
|
// most from staying scannable once there are more than a handful of saved profiles.
|
|
134
134
|
const sortedProfiles = useMemo(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles])
|
|
135
135
|
// Null row + each saved profile + (if passed) the manage row.
|
|
136
136
|
const { width: contentWidth, height: contentHeight } = getProfilePickerContentSize(profiles.length, !!onManage)
|
|
137
|
-
// Always called, even when `alignOverride` is supplied and this result goes unused
|
|
137
|
+
// Always called, even when `alignOverride` is supplied and this result goes unused - hooks can't
|
|
138
138
|
// be conditional (see @tastic/hud's InlineColorPicker for the identical pattern). With an
|
|
139
139
|
// override its triggerRef is never attached, so this hook's own measurement is a no-op.
|
|
140
140
|
const auto = useAutoAlign(open, contentWidth, contentHeight, rotation)
|
|
@@ -169,7 +169,7 @@ export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, sel
|
|
|
169
169
|
</View>
|
|
170
170
|
</TouchableRipple>
|
|
171
171
|
|
|
172
|
-
{/* Gated on `measured`, not just `open`
|
|
172
|
+
{/* Gated on `measured`, not just `open` - see InlineColorPicker's identical fix. */}
|
|
173
173
|
<PopoverBody visible={open && measured} align={align} verticalAlign={verticalAlign} caretColor={menuBg} caretBorderColor={color} caretBorderWidth={2} triggerSize={TRIGGER_HEIGHT}>
|
|
174
174
|
<ScrollView
|
|
175
175
|
style={[
|
|
@@ -185,12 +185,12 @@ export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, sel
|
|
|
185
185
|
showsVerticalScrollIndicator={false}
|
|
186
186
|
>
|
|
187
187
|
{/* Selected row's own background fills entirely with its identity color (this seat's own
|
|
188
|
-
accent for the null row, the profile's own saved color otherwise)
|
|
188
|
+
accent for the null row, the profile's own saved color otherwise) - matching how a
|
|
189
189
|
selected row reads elsewhere (see @tastic/hud's SectionedDropdown) rather than a checkmark
|
|
190
190
|
bolted onto an otherwise-plain row. */}
|
|
191
191
|
<TouchableRipple onPress={handleSelectGuest} style={[styles.row, selectedId === null && { backgroundColor: color }]}>
|
|
192
192
|
<View style={styles.rowInner}>
|
|
193
|
-
{/* Unfilled, matching a selected profile row's own ProfileChip treatment
|
|
193
|
+
{/* Unfilled, matching a selected profile row's own ProfileChip treatment - no
|
|
194
194
|
background circle, but still the same fixed-size slot every row's icon/chip sits in
|
|
195
195
|
(see ProfileChip's own doc), so this row's icon stays lined up with every other row
|
|
196
196
|
regardless of selection. */}
|
|
@@ -208,7 +208,7 @@ export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, sel
|
|
|
208
208
|
return (
|
|
209
209
|
<TouchableRipple key={profile.id} disabled={taken} onPress={() => handleSelectExisting(profile)} style={[styles.row, selected && { backgroundColor: rowColor }]}>
|
|
210
210
|
<View style={[styles.rowInner, taken && styles.rowDisabled]}>
|
|
211
|
-
{/* Unfilled on a selected row
|
|
211
|
+
{/* Unfilled on a selected row - its own background already fills with the
|
|
212
212
|
profile's color, so a second circle behind the tag would just be a redundant badge
|
|
213
213
|
where every other selected row in the app has plain icon+text instead. */}
|
|
214
214
|
<ProfileChip profile={profile} filled={!selected} />
|
|
@@ -231,7 +231,7 @@ export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, sel
|
|
|
231
231
|
{onManage && (
|
|
232
232
|
<>
|
|
233
233
|
<View style={[styles.divider, { backgroundColor: color }]} />
|
|
234
|
-
{/* Tertiary
|
|
234
|
+
{/* Tertiary - a navigation action, not another selectable identity in the list above
|
|
235
235
|
it, so it reads more like @tastic/hud's SectionedDropdown All/Clear footer button
|
|
236
236
|
(centered, bold, accent-colored) than a normal row. */}
|
|
237
237
|
<TouchableRipple onPress={handleManage} style={styles.row}>
|
|
@@ -252,12 +252,12 @@ const styles = StyleSheet.create({
|
|
|
252
252
|
anchor: {
|
|
253
253
|
position: 'relative'
|
|
254
254
|
},
|
|
255
|
-
// See InlineColorPicker's identical comment
|
|
255
|
+
// See InlineColorPicker's identical comment - React Native Web gives every position:'relative'
|
|
256
256
|
// view its own stacking context, so the elevation has to live on the anchor itself.
|
|
257
257
|
anchorOpen: {
|
|
258
258
|
zIndex: 100
|
|
259
259
|
},
|
|
260
|
-
// Separates Manage (a navigation action) from the selectable rows above it
|
|
260
|
+
// Separates Manage (a navigation action) from the selectable rows above it - different kind of
|
|
261
261
|
// action, not just another row in the same list. Tinted with the seat's own color (passed in as
|
|
262
262
|
// backgroundColor) rather than a flat gray, matching the border/caret's own accent-tinted look.
|
|
263
263
|
divider: {
|
|
@@ -266,7 +266,7 @@ const styles = StyleSheet.create({
|
|
|
266
266
|
opacity: 0.25
|
|
267
267
|
},
|
|
268
268
|
// Matches ProfileChip's own default size/shape exactly (see this row's own render-site comment)
|
|
269
|
-
//
|
|
269
|
+
// - no backgroundColor, unlike ProfileChip's own default: this row has no identity color of its
|
|
270
270
|
// own to fill it with, and the render site now colors the icon directly instead.
|
|
271
271
|
guestChip: {
|
|
272
272
|
alignItems: 'center',
|
|
@@ -280,7 +280,7 @@ const styles = StyleSheet.create({
|
|
|
280
280
|
fontWeight: '700',
|
|
281
281
|
letterSpacing: 0.5
|
|
282
282
|
},
|
|
283
|
-
// Centered rather than left-aligned like a normal row
|
|
283
|
+
// Centered rather than left-aligned like a normal row - see this row's own render-site comment
|
|
284
284
|
// for why (a footer action, not another selectable identity).
|
|
285
285
|
manageRowInner: {
|
|
286
286
|
alignItems: 'center',
|
|
@@ -298,7 +298,7 @@ const styles = StyleSheet.create({
|
|
|
298
298
|
shadowOpacity: 0.3,
|
|
299
299
|
shadowRadius: 8
|
|
300
300
|
},
|
|
301
|
-
// Padded on every side (not just top/bottom)
|
|
301
|
+
// Padded on every side (not just top/bottom) - a selected row's own full-color background (see
|
|
302
302
|
// `row`'s borderRadius) needs real margin from the menu's own straight edges to read as a rounded
|
|
303
303
|
// pill instead of looking clipped against them.
|
|
304
304
|
menuContent: {
|