@tastic/profile 0.11.0 → 0.13.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 +91 -70
- package/dist/index.d.mts +6 -5
- package/dist/index.d.ts +6 -5
- package/dist/index.js +55 -55
- package/dist/index.mjs +55 -57
- package/ios/TasticProfileModule.swift +2 -2
- package/package.json +10 -10
- package/src/ProfileChip.tsx +5 -5
- package/src/ProfilePicker.tsx +31 -31
- package/src/ProfilesManager.tsx +55 -107
- package/src/ProfilesScreen.tsx +11 -12
- 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/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: {
|
package/src/ProfilesManager.tsx
CHANGED
|
@@ -2,10 +2,10 @@ import { defaultColors, useAutoPaperTheme } from '@rific/auto-paper'
|
|
|
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 { getInlineColorPickerContentSize, InlineColorPicker, PopoverHost, usePopoverHost } from '@tastic/hud'
|
|
5
|
+
import { ConfirmDialog, getInlineColorPickerContentSize, InlineColorPicker, PopoverHost, usePopoverHost } from '@tastic/hud'
|
|
6
6
|
import { ComponentProps, ReactNode, Ref, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
|
7
7
|
import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'
|
|
8
|
-
import {
|
|
8
|
+
import { Icon, Text, TextInput } from 'react-native-paper'
|
|
9
9
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
10
10
|
|
|
11
11
|
import { ProfileChip } from './ProfileChip'
|
|
@@ -14,17 +14,17 @@ import { type ProfilePickerAlignResult } from './ProfilePicker'
|
|
|
14
14
|
import { clampTag, isValidTag, MAX_PROFILE_NAME_LENGTH } from './profilesValidation'
|
|
15
15
|
import { Profile } from './types'
|
|
16
16
|
|
|
17
|
-
// Shared by the New Profile row's own dashed circle and every profile row's own ProfileChip
|
|
17
|
+
// Shared by the New Profile row's own dashed circle and every profile row's own ProfileChip - one
|
|
18
18
|
// size for every circle on this screen, rather than each picking its own.
|
|
19
19
|
const CHIP_SIZE = 40
|
|
20
20
|
|
|
21
|
-
// react-native-paper's own MD3 Text variant names
|
|
21
|
+
// react-native-paper's own MD3 Text variant names - mirrored locally rather than importing its
|
|
22
22
|
// internal VariantProp/MD3TypescaleKey (not exported from the package's public entry point), same
|
|
23
23
|
// as @tastic/hud's BaseStatsScreen does for its own identical titleVariant prop.
|
|
24
24
|
type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall'
|
|
25
25
|
|
|
26
|
-
// Exported (not just used internally) so a host composing a screen around this component
|
|
27
|
-
// @tastic/profile's own ProfilesScreen
|
|
26
|
+
// Exported (not just used internally) so a host composing a screen around this component - e.g.
|
|
27
|
+
// @tastic/profile's own ProfilesScreen - can reference this exact shape in its own onCreate/onSave
|
|
28
28
|
// prop types without redeclaring it.
|
|
29
29
|
export interface ProfileEditPatch {
|
|
30
30
|
name: string
|
|
@@ -33,51 +33,47 @@ export interface ProfileEditPatch {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
export interface ProfilesManagerHandle {
|
|
36
|
-
// Flushes whatever row is currently being edited
|
|
36
|
+
// Flushes whatever row is currently being edited - the same thing this component's own unmount
|
|
37
37
|
// effect does, but callable explicitly. Exists because "unmount" isn't a reliable "the user is
|
|
38
38
|
// navigating away" signal on every host: React Navigation's web renderer keeps a popped screen
|
|
39
39
|
// mounted-but-hidden instead of unmounting it, so a host relying only on the unmount fallback
|
|
40
40
|
// silently loses an in-progress edit on `router.back()` there. A host whose router doesn't
|
|
41
41
|
// genuinely unmount this component on navigation should hold a ref and call this right before
|
|
42
42
|
// navigating away (e.g. in its own back-button handler). Safe to call any time, including when
|
|
43
|
-
// nothing is being edited
|
|
43
|
+
// nothing is being edited - a no-op then, same as the unmount effect already is.
|
|
44
44
|
commitPendingEdit: () => void
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
export interface ProfilesManagerProps {
|
|
48
48
|
profiles: Profile[]
|
|
49
|
-
// The color a brand-new profile's draft starts on
|
|
49
|
+
// The color a brand-new profile's draft starts on - this component doesn't have (and shouldn't
|
|
50
50
|
// invent) an opinion about the host app's own palette, so this is required rather than defaulted.
|
|
51
51
|
defaultColor: string
|
|
52
52
|
onCreate: (input: ProfileEditPatch) => void
|
|
53
53
|
onSave: (id: string, patch: ProfileEditPatch) => void
|
|
54
|
-
// Irreversible
|
|
54
|
+
// Irreversible - this only fires after this component's own confirmation card, not on the first
|
|
55
55
|
// tap. If the host app tracks anything else keyed by profile id (stats, history), clear that on
|
|
56
56
|
// its own side when this fires.
|
|
57
57
|
onDelete: (id: string) => void
|
|
58
|
-
// Rendered inline, before the "Profiles" title, in this component's own header row
|
|
58
|
+
// Rendered inline, before the "Profiles" title, in this component's own header row - a plain
|
|
59
59
|
// rendering slot, not an onBack-style callback: this component doesn't know or care that it's a
|
|
60
60
|
// back button, only that the host wants something shown there. Omit to hide the slot.
|
|
61
61
|
headerLeft?: ReactNode
|
|
62
|
-
// React 19 accepts `ref` as a plain prop on a function component (no forwardRef needed)
|
|
62
|
+
// React 19 accepts `ref` as a plain prop on a function component (no forwardRef needed) - see
|
|
63
63
|
// ProfilesManagerHandle's own doc for why a host may need this.
|
|
64
64
|
ref?: Ref<ProfilesManagerHandle>
|
|
65
|
-
// Independently optional overrides for this screen's text
|
|
66
|
-
// literal-black/white-by-appearance formula as before when omitted
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
// BaseStatsScreen's identical reasoning for cardBorder: their low-alpha/neutral values already
|
|
71
|
-
// read fine against any cardBg, and (for Cancel specifically) are deliberately neutral rather than
|
|
72
|
-
// theme-derived so a host's own live gameplay/seat color can't land there by coincidence.
|
|
65
|
+
// Independently optional overrides for this screen's text colors - each defaults to the same
|
|
66
|
+
// literal-black/white-by-appearance formula as before when omitted. Same convention as @tastic/hud's
|
|
67
|
+
// BaseStatsScreen fg override, for a caller with its own app-wide chrome palette instead of that
|
|
68
|
+
// literal black/white convention. (cardBg, which only colored the delete confirmation, went with it
|
|
69
|
+
// when that became hud's ConfirmDialog, 0.13.0.)
|
|
73
70
|
fg?: string
|
|
74
71
|
fgMuted?: string
|
|
75
|
-
cardBg?: string
|
|
76
72
|
// 'headlineSmall' (this screen's own default, below), matching BaseStatsScreen's own settled
|
|
77
|
-
// default and the reasoning behind it
|
|
73
|
+
// default and the reasoning behind it - this title sits next to a small headerLeft back-button
|
|
78
74
|
// glyph the same way, and the previous hardcoded 'displaySmall' read as oversized there too.
|
|
79
75
|
titleVariant?: MD3TextVariant
|
|
80
|
-
// Transforms the color swatch grid's own preview per-swatch
|
|
76
|
+
// Transforms the color swatch grid's own preview per-swatch - forwarded straight to the internal
|
|
81
77
|
// InlineColorPicker's identical prop (see its own doc). Leaves draftColor/onCreate/onSave's actual
|
|
82
78
|
// color value untouched; only what's rendered changes. Defaults to ProfileColorProvider's preview.
|
|
83
79
|
colorPreview?: (hex: string) => string
|
|
@@ -97,7 +93,7 @@ export interface ProfilesManagerProps {
|
|
|
97
93
|
|
|
98
94
|
type Row = { kind: 'profile'; profile: Profile } | { kind: 'new' }
|
|
99
95
|
|
|
100
|
-
// First letter of the name, uppercased
|
|
96
|
+
// First letter of the name, uppercased - the tag's own default for as long as it hasn't been typed
|
|
101
97
|
// into directly (see the tagManuallySet state below). Empty name -> empty tag, same as typing
|
|
102
98
|
// nothing into the tag field yourself would leave it.
|
|
103
99
|
function deriveTag(name: string): string {
|
|
@@ -105,22 +101,22 @@ function deriveTag(name: string): string {
|
|
|
105
101
|
return trimmed ? trimmed[0].toUpperCase() : ''
|
|
106
102
|
}
|
|
107
103
|
|
|
108
|
-
// The profile roster itself
|
|
109
|
-
// you tapped
|
|
104
|
+
// The profile roster itself - create/rename/recolor/retag/delete, editing inline right in the row
|
|
105
|
+
// you tapped - with no navigation of its own: just data in (`profiles`) and callbacks out, plus
|
|
110
106
|
// this file's own internal edit-draft state. Deliberately router-agnostic (an `onBack`-style prop
|
|
111
107
|
// would still assume *some* host owns navigation; this component doesn't even go that far), so any
|
|
112
108
|
// consuming screen just renders it as its body inside its own routed wrapper (back button, native
|
|
113
109
|
// push/pop transition, whatever persistence hook backs `profiles`/`onCreate`/`onSave`/`onDelete`).
|
|
114
110
|
// Staying router-agnostic means this component can't know for itself whether "navigating away"
|
|
115
|
-
// really unmounts it on a given host's router
|
|
111
|
+
// really unmounts it on a given host's router - see ProfilesManagerHandle's own doc for the
|
|
116
112
|
// react-navigation-web case where it doesn't, and why a host there needs the `ref` escape hatch
|
|
117
113
|
// instead of relying on the unmount fallback alone.
|
|
118
|
-
export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride,
|
|
114
|
+
export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, titleVariant = 'headlineSmall', colorPreview: colorPreviewProp, useColorPickerAlign }: ProfilesManagerProps) {
|
|
119
115
|
// An explicit prop wins; otherwise the app-wide ProfileColorProvider's, so the swatch previews
|
|
120
116
|
// match the ProfileChips drawn in the list.
|
|
121
117
|
const contextColorPreview = useProfileColorPreview()
|
|
122
118
|
const colorPreview = colorPreviewProp ?? contextColorPreview
|
|
123
|
-
const { dark
|
|
119
|
+
const { dark } = useAutoPaperTheme()
|
|
124
120
|
const insets = useSafeAreaInsets()
|
|
125
121
|
const { error: showErrorToast } = useToast()
|
|
126
122
|
// Only one row is ever in edit mode at a time (see commitEdit, called before any new row opens),
|
|
@@ -128,37 +124,31 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
|
|
|
128
124
|
const host = usePopoverHost()
|
|
129
125
|
const fg = fgOverride ?? (dark ? '#FFFFFF' : '#000000')
|
|
130
126
|
const fgMuted = fgMutedOverride ?? (dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)')
|
|
131
|
-
const cardBg = cardBgOverride ?? (dark ? '#111111' : '#F2F2F2')
|
|
132
|
-
const cardBorder = dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'
|
|
133
|
-
// A contained neutral, not an outlined one — reads as "the other button" next to Delete's solid
|
|
134
|
-
// danger fill, rather than looking unfinished/secondary beside it.
|
|
135
|
-
const cancelBg = dark ? '#2A2A2A' : '#E0E0E0'
|
|
136
|
-
const cancelFg = dark ? '#FFFFFF' : '#000000'
|
|
137
127
|
|
|
138
|
-
// 'new' clears back to null once a commit resolves
|
|
128
|
+
// 'new' clears back to null once a commit resolves - there's only ever one row open at a time.
|
|
139
129
|
const [editingId, setEditingId] = useState<string | 'new' | null>(null)
|
|
140
130
|
const [draftName, setDraftName] = useState('')
|
|
141
131
|
const [draftColor, setDraftColor] = useState(defaultColor)
|
|
142
132
|
const [draftTag, setDraftTag] = useState('')
|
|
143
|
-
// Whether the tag has been typed into directly this session
|
|
133
|
+
// Whether the tag has been typed into directly this session - once true, editing the name stops
|
|
144
134
|
// overwriting it (see deriveTag's own doc). Starts true for an existing profile that already has
|
|
145
|
-
// a tag (don't clobber a deliberate choice just because the name changed), false otherwise
|
|
135
|
+
// a tag (don't clobber a deliberate choice just because the name changed), false otherwise - a
|
|
146
136
|
// brand new profile, or one that's never had a tag set at all.
|
|
147
137
|
const [tagManuallySet, setTagManuallySet] = useState(false)
|
|
148
138
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
|
|
149
139
|
|
|
150
|
-
// Commits whichever row is currently open, using its own latest draft state, then closes it
|
|
140
|
+
// Commits whichever row is currently open, using its own latest draft state, then closes it -
|
|
151
141
|
// always closes, even when there's nothing valid to save (an empty name just discards the draft
|
|
152
142
|
// rather than leaving the row stuck open). This is the only way a row ever closes: submitting the
|
|
153
|
-
// name field (see EditRow's own focus-chain wiring
|
|
143
|
+
// name field (see EditRow's own focus-chain wiring - tag submits to name, name submits this),
|
|
154
144
|
// switching to a different row (see startEdit, which calls this before opening a new one), or
|
|
155
|
-
// leaving the screen entirely (see the unmount effect below)
|
|
145
|
+
// leaving the screen entirely (see the unmount effect below) - no explicit save/cancel buttons,
|
|
156
146
|
// just scoped to the whole row instead of one field, since there's more than one field to touch.
|
|
157
147
|
const commitEdit = useCallback(() => {
|
|
158
148
|
const name = draftName.trim()
|
|
159
149
|
if (editingId && name) {
|
|
160
150
|
const tagValid = isValidTag(draftTag)
|
|
161
|
-
// Surfaced here, not just the field's own live `error` outline
|
|
151
|
+
// Surfaced here, not just the field's own live `error` outline - that outline disappears the
|
|
162
152
|
// moment this row closes, so without this a still-invalid tag would just silently vanish
|
|
163
153
|
// (saved as empty) with no explanation of why it didn't stick.
|
|
164
154
|
if (draftTag && !tagValid) showErrorToast('Invalid tag', 'Use up to 3 letters or a single emoji')
|
|
@@ -169,11 +159,11 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
|
|
|
169
159
|
setEditingId(null)
|
|
170
160
|
}, [editingId, draftName, draftColor, draftTag, onCreate, onSave, showErrorToast])
|
|
171
161
|
|
|
172
|
-
// Flushes whatever's currently being edited if this whole screen goes away mid-edit
|
|
162
|
+
// Flushes whatever's currently being edited if this whole screen goes away mid-edit - a hardware
|
|
173
163
|
// back gesture or swipe-back can bypass a host screen's own back button (which should already
|
|
174
164
|
// call commitEdit before navigating away) but still unmounts this component the same as any other
|
|
175
165
|
// exit does. Reads the always-current commitEdit through a ref rather than depending on it
|
|
176
|
-
// directly in the effect
|
|
166
|
+
// directly in the effect - one that re-ran (and re-cleaned-up) on every keystroke would commit on
|
|
177
167
|
// every keystroke too, not just on a genuine unmount.
|
|
178
168
|
const commitEditRef = useRef(commitEdit)
|
|
179
169
|
useEffect(() => {
|
|
@@ -181,7 +171,7 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
|
|
|
181
171
|
})
|
|
182
172
|
useEffect(() => () => commitEditRef.current(), [])
|
|
183
173
|
// The explicit escape hatch for hosts whose "navigate away" doesn't genuinely unmount this
|
|
184
|
-
// component
|
|
174
|
+
// component - see ProfilesManagerHandle's own doc. `[]` deps: the handle object itself never
|
|
185
175
|
// needs to change identity, since it always calls through the ref to whatever commitEdit is
|
|
186
176
|
// current at call time, the same indirection the unmount effect above already relies on.
|
|
187
177
|
useImperativeHandle(ref, () => ({ commitPendingEdit: () => commitEditRef.current() }), [])
|
|
@@ -213,8 +203,8 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
|
|
|
213
203
|
[tagManuallySet]
|
|
214
204
|
)
|
|
215
205
|
|
|
216
|
-
// The functional setDraftTag form
|
|
217
|
-
// rather than closing over the `draftTag` variable
|
|
206
|
+
// The functional setDraftTag form - reading `previous` from React's own latest-applied state
|
|
207
|
+
// rather than closing over the `draftTag` variable - matters here, not just style: fast/rapid
|
|
218
208
|
// keystrokes can fire their onChangeText calls before a re-render lands, batched into the same
|
|
219
209
|
// update, and clamping every one of them against the same stale `draftTag` (captured once at
|
|
220
210
|
// render time) would let the later ones silently clobber what the earlier ones already clamped
|
|
@@ -233,7 +223,7 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
|
|
|
233
223
|
setConfirmDeleteId(null)
|
|
234
224
|
}, [confirmDeleteId, editingId, onDelete])
|
|
235
225
|
|
|
236
|
-
// Alphabetical
|
|
226
|
+
// Alphabetical - this list has no other natural order (creation order isn't meaningful to look
|
|
237
227
|
// something up by), and it's the one that actually stays usable once there are more than a
|
|
238
228
|
// handful of profiles on one shared device.
|
|
239
229
|
const sortedProfiles = useMemo(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles])
|
|
@@ -279,7 +269,7 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
|
|
|
279
269
|
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} />
|
|
280
270
|
}
|
|
281
271
|
|
|
282
|
-
// No delete affordance at rest
|
|
272
|
+
// No delete affordance at rest - tap the row to start editing, which is the one place
|
|
283
273
|
// delete shows (see EditRow): a deliberate extra step before a destructive action is
|
|
284
274
|
// even reachable, not just before it's confirmed.
|
|
285
275
|
return (
|
|
@@ -296,32 +286,11 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
|
|
|
296
286
|
</ScrollView>
|
|
297
287
|
</KeyboardAvoidingView>
|
|
298
288
|
|
|
299
|
-
{
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
<Text variant='headlineLarge' style={[styles.overlayTitle, { color: colors.danger }]}>
|
|
305
|
-
Delete Profile?
|
|
306
|
-
</Text>
|
|
307
|
-
<Text variant='bodyLarge' style={[styles.overlayBody, { color: fg }]}>
|
|
308
|
-
This permanently deletes {confirmDeleteProfile.name}’s profile. This cannot be undone.
|
|
309
|
-
</Text>
|
|
310
|
-
{/* Contained neutral, not buttonColor={colors.primary} — colors.primary is often a
|
|
311
|
-
live gameplay/seat color in host apps, so a contained Cancel in that color would just
|
|
312
|
-
be whatever's currently painted there, with no relation to "safe to tap". */}
|
|
313
|
-
<Button mode='contained' onPress={() => setConfirmDeleteId(null)} style={styles.overlayButton} buttonColor={cancelBg} textColor={cancelFg}>
|
|
314
|
-
Cancel
|
|
315
|
-
</Button>
|
|
316
|
-
{/* colors.danger/onDanger — auto-paper's actual semantic role for this, not a seat's
|
|
317
|
-
own live color by luck of the default palette. */}
|
|
318
|
-
<Button mode='contained' onPress={handleConfirmDelete} style={styles.overlayButton} buttonColor={colors.danger} textColor={colors.onDanger}>
|
|
319
|
-
Delete
|
|
320
|
-
</Button>
|
|
321
|
-
</View>
|
|
322
|
-
</View>
|
|
323
|
-
</Portal>
|
|
324
|
-
)}
|
|
289
|
+
{/* hud's ConfirmDialog, the one confirm card every app in the fleet uses, so this reads like the
|
|
290
|
+
Reset Everything?/New Game confirms beside it (Jay, 2026-10-15: one look for every dialog). It
|
|
291
|
+
used to be its own card here, in this screen's literal black/white (or the host's cardBg), with
|
|
292
|
+
two stacked contained buttons. */}
|
|
293
|
+
<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 onConfirm={handleConfirmDelete} onCancel={() => setConfirmDeleteId(null)} />
|
|
325
294
|
</>
|
|
326
295
|
)
|
|
327
296
|
}
|
|
@@ -334,7 +303,7 @@ interface EditRowProps {
|
|
|
334
303
|
draftTag: string
|
|
335
304
|
onTagChange: (tag: string) => void
|
|
336
305
|
onSubmit: () => void
|
|
337
|
-
// Omitted for the 'new' row
|
|
306
|
+
// Omitted for the 'new' row - nothing saved yet to delete.
|
|
338
307
|
onDelete?: () => void
|
|
339
308
|
autoFocus?: boolean
|
|
340
309
|
fgMuted: string
|
|
@@ -356,29 +325,29 @@ function AlignedColorPicker({ useAlign, ...props }: ComponentProps<typeof Inline
|
|
|
356
325
|
return <InlineColorPicker {...props} alignOverride={alignOverride} />
|
|
357
326
|
}
|
|
358
327
|
|
|
359
|
-
// One row, expanded: the color trigger (@tastic/hud's InlineColorPicker)
|
|
360
|
-
// own display via its `tag` prop below, since there's no separate visible tag field anymore
|
|
328
|
+
// One row, expanded: the color trigger (@tastic/hud's InlineColorPicker) - doubling as the tag's
|
|
329
|
+
// own display via its `tag` prop below, since there's no separate visible tag field anymore - an
|
|
361
330
|
// invisible TextInput that exists purely to actually capture what's typed (RN has no way to bring
|
|
362
331
|
// up a keyboard without a real focusable input; this one is just never meant to be *seen*), the
|
|
363
|
-
// name taking up the row's remaining space, and
|
|
332
|
+
// name taking up the row's remaining space, and - only for an existing profile - a delete icon at
|
|
364
333
|
// the very end. The two effects below are what wires tapping the swatch to popping the keyboard on
|
|
365
334
|
// the hidden tag field too, so typing a tag and picking a color read as one combined action instead
|
|
366
335
|
// of two separate taps, with the swatch itself as the only place the tag ever visibly shows.
|
|
367
336
|
// Return-key chains tag -> name via @rific/focus-chain rather than each field submitting on its
|
|
368
|
-
// own
|
|
369
|
-
// onSubmitEditing (a no-op
|
|
337
|
+
// own - submitting the *name* field is what actually commits the row, so its registration's own
|
|
338
|
+
// onSubmitEditing (a no-op - it's last in the chain) is overridden with onSubmit below, matching
|
|
370
339
|
// how a normal multi-field form reads: fill fields in order, the last one finishes it.
|
|
371
340
|
function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview, useColorPickerAlign }: EditRowProps) {
|
|
372
341
|
const register = useFocusChain()
|
|
373
342
|
const tag = register()
|
|
374
343
|
const name = register()
|
|
375
344
|
|
|
376
|
-
// tag.props.focus (from @rific/focus-chain's own register(), not tag.ref
|
|
345
|
+
// tag.props.focus (from @rific/focus-chain's own register(), not tag.ref - that's a callback
|
|
377
346
|
// ref, not a ref object, so it has no .current to read) is the hook's own imperative "focus this
|
|
378
347
|
// exact registered field" call. Mirrored into a ref every render, same as commitEditRef above,
|
|
379
348
|
// rather than read directly in the effect below: register() returns a brand-new props object on
|
|
380
349
|
// every render (it isn't memoized), so depending on tag.props.focus directly would rerun that
|
|
381
|
-
// effect
|
|
350
|
+
// effect - and refocus an already-focused field - on every keystroke into the tag field itself,
|
|
382
351
|
// re-triggering its own selectTextOnFocus mid-type and wiping out what's being typed.
|
|
383
352
|
const tagFocusRef = useRef(tag.props.focus)
|
|
384
353
|
useEffect(() => {
|
|
@@ -397,9 +366,9 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
|
|
|
397
366
|
<View style={styles.editRow}>
|
|
398
367
|
{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} />}
|
|
399
368
|
{/* Invisible on purpose (styles.hiddenTagInput: zero footprint, position: 'absolute' so it
|
|
400
|
-
doesn't reserve space in the row's own flex layout)
|
|
369
|
+
doesn't reserve space in the row's own flex layout) - the swatch above is the only place this
|
|
401
370
|
value is ever meant to be seen. pointerEvents='none' is what actually keeps it out of the
|
|
402
|
-
way: without it, this field
|
|
371
|
+
way: without it, this field - even at 1x1 and fully transparent - still sits in front of the
|
|
403
372
|
swatch for hit-testing purposes and swallows the tap meant to open the color popover, since
|
|
404
373
|
focusing this field is never done by tapping it directly anyway (see the effect above, which
|
|
405
374
|
focuses it programmatically). accessibilityLabel stands in for the placeholder a visible field
|
|
@@ -439,7 +408,7 @@ const styles = StyleSheet.create({
|
|
|
439
408
|
gap: 4,
|
|
440
409
|
paddingBottom: 8
|
|
441
410
|
},
|
|
442
|
-
// 1x1 and fully transparent
|
|
411
|
+
// 1x1 and fully transparent - there's nothing to see here on purpose (see EditRow's own comment
|
|
443
412
|
// above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
|
|
444
413
|
// entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
|
|
445
414
|
hiddenTagInput: {
|
|
@@ -466,27 +435,6 @@ const styles = StyleSheet.create({
|
|
|
466
435
|
borderStyle: 'dashed',
|
|
467
436
|
borderWidth: 2
|
|
468
437
|
},
|
|
469
|
-
overlay: {
|
|
470
|
-
alignItems: 'center',
|
|
471
|
-
backgroundColor: 'rgba(0,0,0,0.72)',
|
|
472
|
-
bottom: 0,
|
|
473
|
-
justifyContent: 'center',
|
|
474
|
-
left: 0,
|
|
475
|
-
position: 'absolute',
|
|
476
|
-
right: 0,
|
|
477
|
-
top: 0
|
|
478
|
-
},
|
|
479
|
-
overlayBody: { textAlign: 'center' },
|
|
480
|
-
overlayButton: { width: 160 },
|
|
481
|
-
overlayCard: {
|
|
482
|
-
alignItems: 'center',
|
|
483
|
-
borderRadius: 20,
|
|
484
|
-
borderWidth: 1,
|
|
485
|
-
gap: 16,
|
|
486
|
-
maxWidth: 360,
|
|
487
|
-
padding: 32
|
|
488
|
-
},
|
|
489
|
-
overlayTitle: { fontWeight: 'bold', marginBottom: -8 },
|
|
490
438
|
row: {
|
|
491
439
|
borderRadius: 8,
|
|
492
440
|
paddingVertical: 8
|