@tastic/profile 0.14.1 → 0.16.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 +46 -11
- package/dist/index.d.mts +67 -30
- package/dist/index.d.ts +67 -30
- package/dist/index.js +429 -237
- package/dist/index.mjs +393 -206
- package/package.json +9 -9
- package/src/IdentityChip.tsx +8 -2
- package/src/OptionPicker.tsx +64 -0
- package/src/PickerMenu.tsx +512 -0
- package/src/ProfileColorContext.tsx +30 -8
- package/src/ProfilePicker.tsx +61 -426
- package/src/ProfilesManager.tsx +9 -6
- package/src/SwatchLayer.tsx +28 -0
- package/src/index.ts +4 -3
- package/src/redux/resolveInitialProfiles.ts +3 -2
- package/src/redux/seatColorsSlice.ts +79 -29
- package/src/redux/useSharedProfilesSync.ts +12 -6
- package/src/sharedProfileStore.ts +87 -11
|
@@ -1,27 +1,49 @@
|
|
|
1
|
-
import { createContext, type ReactNode, useContext } from 'react'
|
|
1
|
+
import { createContext, type ReactNode, useContext, useMemo } from 'react'
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// Draws one color's swatch fill: a circle `size` across, standing in for the flat fill. `color` is the
|
|
4
|
+
// drawn color (after `preview`), and it stays the primary one: the tag, icon, check or dot on top of
|
|
5
|
+
// the swatch takes its contrast from it.
|
|
6
|
+
export type ProfileSwatchRenderer = (color: string, size: number) => ReactNode
|
|
7
|
+
|
|
8
|
+
interface ProfileColorContextValue {
|
|
9
|
+
preview?: (hex: string) => string
|
|
10
|
+
renderSwatch?: ProfileSwatchRenderer
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const ProfileColorContext = createContext<ProfileColorContextValue>({})
|
|
4
14
|
|
|
5
15
|
interface ProfileColorProviderProps {
|
|
6
16
|
// Maps a profile's saved color to the color actually drawn for it. Solitaire blends profile colors
|
|
7
17
|
// toward its card-back palette so they read muted, not as the raw swatch; a context rather than a
|
|
8
18
|
// module-level setting because that blend depends on light/dark mode.
|
|
9
|
-
preview
|
|
19
|
+
preview?: (hex: string) => string
|
|
20
|
+
// Replaces the flat fill of every round swatch this package draws: IdentityChip (ProfileChip and the
|
|
21
|
+
// pickers' chip trigger), the pickers' Color row swatch and their color page swatches. Hangman draws
|
|
22
|
+
// a pie of each color's triad. The renderer is drawn behind the swatch's mark, clipped to the circle.
|
|
23
|
+
// Rows that fill with a color (a selected picker row) stay flat, and so does hud's InlineColorPicker
|
|
24
|
+
// (ProfilesManager's color trigger and swatches), which doesn't read this context.
|
|
25
|
+
renderSwatch?: ProfileSwatchRenderer
|
|
10
26
|
children: ReactNode
|
|
11
27
|
}
|
|
12
28
|
|
|
13
29
|
// App-wide display transform for profile colors. Every ProfileChip, ProfilePicker's selected-row
|
|
14
30
|
// fill and ProfilesManager's color picker previews read it, and so does anything outside this
|
|
15
|
-
// package that draws a ProfileChip (@tastic/hud's AchievementUnlockList). Without a provider
|
|
16
|
-
// profile colors draw as saved.
|
|
17
|
-
export function ProfileColorProvider({ preview, children }: ProfileColorProviderProps) {
|
|
18
|
-
|
|
31
|
+
// package that draws a ProfileChip (@tastic/hud's AchievementUnlockList). Without a provider (or
|
|
32
|
+
// with neither prop), profile colors draw as saved, in flat swatches.
|
|
33
|
+
export function ProfileColorProvider({ preview, renderSwatch, children }: ProfileColorProviderProps) {
|
|
34
|
+
const value = useMemo(() => ({ preview, renderSwatch }), [preview, renderSwatch])
|
|
35
|
+
return <ProfileColorContext.Provider value={value}>{children}</ProfileColorContext.Provider>
|
|
19
36
|
}
|
|
20
37
|
|
|
21
38
|
// The provider's preview, or undefined when there's none (so a caller can tell "no transform"
|
|
22
39
|
// apart from an identity one, e.g. to fall back to its own colorPreview prop).
|
|
23
40
|
export function useProfileColorPreview(): ((hex: string) => string) | undefined {
|
|
24
|
-
return useContext(ProfileColorContext)
|
|
41
|
+
return useContext(ProfileColorContext).preview
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The provider's swatch renderer, or undefined for the flat fill.
|
|
45
|
+
export function useProfileSwatchRenderer(): ProfileSwatchRenderer | undefined {
|
|
46
|
+
return useContext(ProfileColorContext).renderSwatch
|
|
25
47
|
}
|
|
26
48
|
|
|
27
49
|
// Resolves one saved color to its drawn color.
|
package/src/ProfilePicker.tsx
CHANGED
|
@@ -1,90 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getContrastColor } from '@rific/auto-paper'
|
|
2
2
|
import { TouchableRipple } from '@rific/feedback-press'
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { type
|
|
3
|
+
import type { PopoverHost, PopoverRotation } from '@tastic/hud/chrome'
|
|
4
|
+
import { useMemo } from 'react'
|
|
5
|
+
import { type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native'
|
|
6
6
|
import { Icon, Text } from 'react-native-paper'
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { IconRow, pickerContentSize, PickerMenu, pickerMenuStyles, type ProfilePickerAlignResult, type ProfilePickerColorSection, type ProfilePickerContentSize } from './PickerMenu'
|
|
9
9
|
import { ProfileChip } from './ProfileChip'
|
|
10
10
|
import { useProfileColorPreview } from './ProfileColorContext'
|
|
11
11
|
import { Profile } from './types'
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
const POPOVER_WIDTH = 220
|
|
15
|
-
const ROW_HEIGHT = 36
|
|
16
|
-
const LIST_PADDING = 8
|
|
17
|
-
const MENU_BORDER_WIDTH = 2
|
|
18
|
-
// A row's own inset from the list's padded edge (`rowInner`'s paddingHorizontal); the color page
|
|
19
|
-
// lines its swatches up with it.
|
|
20
|
-
const ROW_INSET = 12
|
|
21
|
-
// The chip trigger (see `colorSection`): a CHIP_SIZE circle inside a pill padded by CHIP_TRIGGER_PADDING.
|
|
22
|
-
const CHIP_SIZE = 32
|
|
23
|
-
const CHIP_TRIGGER_PADDING = 6
|
|
24
|
-
|
|
25
|
-
// The color row and page (see `colorSection`). Every height here is fixed (explicit line heights),
|
|
26
|
-
// so getProfilePickerContentSize can size them exactly. Swatches are the same 28pt circles as
|
|
27
|
-
// @tastic/hud's InlineColorPicker, spread to span exactly the rows' content width (5 across, so
|
|
28
|
-
// 20 colors fill 4 full rows), with the same spacing between rows.
|
|
29
|
-
const SWATCH_SIZE = 28
|
|
30
|
-
const SWATCH_MIN_GAP = 6
|
|
31
|
-
// The Color row: a plain row's height, or taller with a caption line under the label.
|
|
32
|
-
const COLOR_ROW_CAPTIONED_HEIGHT = 48
|
|
33
|
-
const COLOR_LABEL_LINE = 18
|
|
34
|
-
const COLOR_CAPTION_LINE = 16
|
|
35
|
-
// The divider under the Color row: 1pt plus 4pt above and below, the same as the Manage divider.
|
|
36
|
-
const COLOR_ROW_DIVIDER = 9
|
|
37
|
-
// The color page: a back row, then the grid with this much space above and below it.
|
|
38
|
-
const GRID_TOP = 4
|
|
39
|
-
const GRID_BOTTOM = 8
|
|
40
|
-
const GRID_WIDTH = POPOVER_WIDTH - 2 * MENU_BORDER_WIDTH - 2 * LIST_PADDING - 2 * ROW_INSET
|
|
41
|
-
const GRID_COLUMNS = Math.floor((GRID_WIDTH + SWATCH_MIN_GAP) / (SWATCH_SIZE + SWATCH_MIN_GAP))
|
|
42
|
-
const GRID_GAP = (GRID_WIDTH - GRID_COLUMNS * SWATCH_SIZE) / (GRID_COLUMNS - 1)
|
|
43
|
-
|
|
44
|
-
// A color choice behind a row at the top of the popover (see ProfilePicker's `colorSection`).
|
|
45
|
-
export interface ProfilePickerColorSection {
|
|
46
|
-
// The raw seed color in effect - matched against each swatch's own value for the checkmark.
|
|
47
|
-
value: string
|
|
48
|
-
onChange: (hex: string) => void
|
|
49
|
-
// Transforms only what's drawn (each swatch's fill), leaving `value` and what `onChange` commits
|
|
50
|
-
// untouched - the same contract as @tastic/hud's InlineColorPicker `previewValue`, for a host
|
|
51
|
-
// that mutes or tints the seed before using it.
|
|
52
|
-
previewValue?: (hex: string) => string
|
|
53
|
-
swatches?: SeedColor[]
|
|
54
|
-
// The row's label, and the color page's heading. Defaults to 'Color'.
|
|
55
|
-
label?: string
|
|
56
|
-
// One short line under the row's label, e.g. where the color applies.
|
|
57
|
-
caption?: string
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function colorRowHeight(caption: string | undefined) {
|
|
61
|
-
return caption ? COLOR_ROW_CAPTIONED_HEIGHT : ROW_HEIGHT
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// The color page's content: the back row and the grid.
|
|
65
|
-
function colorPageHeight(swatches: SeedColor[]) {
|
|
66
|
-
const rows = Math.ceil(swatches.length / GRID_COLUMNS)
|
|
67
|
-
return ROW_HEIGHT + GRID_TOP + rows * SWATCH_SIZE + (rows - 1) * GRID_GAP + GRID_BOTTOM
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// A complete placement decision for the popover: which way it opens, how tall it may grow, whether
|
|
71
|
-
// this open's measurement has landed yet, and the ref attached to the measured trigger.
|
|
72
|
-
// Structurally identical to @tastic/hud's own `AlignResult` (exported from hud >= 0.12.0, and the
|
|
73
|
-
// same shape `useAutoAlign` itself returns) - declared here rather than imported so this package's
|
|
74
|
-
// public types don't start requiring a newer @tastic/hud than its peer range already admits. A
|
|
75
|
-
// hud `AlignResult` (e.g. from `useZoneClampedAlign`) is assignable to this as-is.
|
|
76
|
-
export interface ProfilePickerAlignResult {
|
|
77
|
-
align: 'left' | 'right' | 'center'
|
|
78
|
-
verticalAlign: 'below' | 'above'
|
|
79
|
-
maxHeight: number
|
|
80
|
-
measured: boolean
|
|
81
|
-
triggerRef: RefObject<View | null>
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export interface ProfilePickerContentSize {
|
|
85
|
-
width: number
|
|
86
|
-
height: number
|
|
87
|
-
}
|
|
13
|
+
export type { ProfilePickerAlignResult, ProfilePickerColorSection, ProfilePickerContentSize } from './PickerMenu'
|
|
88
14
|
|
|
89
15
|
// The popover's content footprint for a given roster size: always POPOVER_WIDTH wide, tall enough
|
|
90
16
|
// for the null/"guest" row + one row per profile + (when `hasManageRow`, i.e. when the picker is
|
|
@@ -99,10 +25,7 @@ export interface ProfilePickerContentSize {
|
|
|
99
25
|
// own useAutoAlign/useZoneClampedAlign call) to match the popover it is actually positioning,
|
|
100
26
|
// instead of copying these constants and drifting whenever they change here.
|
|
101
27
|
export function getProfilePickerContentSize(profileCount: number, hasManageRow = false, colorSection?: Pick<ProfilePickerColorSection, 'swatches' | 'caption'>): ProfilePickerContentSize {
|
|
102
|
-
|
|
103
|
-
const list = rowCount * ROW_HEIGHT + (colorSection ? colorRowHeight(colorSection.caption) + COLOR_ROW_DIVIDER : 0)
|
|
104
|
-
const content = colorSection ? Math.max(list, colorPageHeight(colorSection.swatches ?? defaultColors)) : list
|
|
105
|
-
return { width: POPOVER_WIDTH, height: LIST_PADDING * 2 + content }
|
|
28
|
+
return pickerContentSize(profileCount + 1 + (hasManageRow ? 1 : 0), colorSection)
|
|
106
29
|
}
|
|
107
30
|
|
|
108
31
|
interface ProfilePickerProps<P extends Profile> {
|
|
@@ -119,6 +42,10 @@ interface ProfilePickerProps<P extends Profile> {
|
|
|
119
42
|
// game, where double-selecting one profile would double-count its stats as both winner and
|
|
120
43
|
// loser) - omit if that doesn't apply.
|
|
121
44
|
takenId?: string | null
|
|
45
|
+
// Every other seat's profile id, for a lobby with more than two seats: each is disabled in this
|
|
46
|
+
// list the same way as `takenId` (both may be given). This picker's own selection is never taken,
|
|
47
|
+
// so a host can pass every seated id, this seat's included.
|
|
48
|
+
takenIds?: readonly string[]
|
|
122
49
|
// Tint for the trigger's own label/icon - pass the seat/context's current live color so the
|
|
123
50
|
// trigger reads as one identity with whatever it's paired with.
|
|
124
51
|
color: string
|
|
@@ -193,27 +120,20 @@ interface ProfilePickerProps<P extends Profile> {
|
|
|
193
120
|
// it keeps this popover from needing its own destructive-delete confirmation. Fully prop-driven -
|
|
194
121
|
// no internal profile-storage access of its own, so the host app stays the one place that reads
|
|
195
122
|
// its own persistence hook.
|
|
196
|
-
export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, selectedId, takenId, color, dark, align
|
|
123
|
+
export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, selectedId, takenId, takenIds, color, dark, align, alignOverride, rotation = 0, guestLabel, nullLabel = 'Player', nullIcon = 'account-off-outline', onSelect, onManage, colorSection, triggerStyle }: ProfilePickerProps<P>) {
|
|
197
124
|
const id = `${idPrefix}-profile`
|
|
198
125
|
// A selected row fills with the profile's drawn color, the same one its ProfileChip would show.
|
|
199
126
|
const colorPreview = useProfileColorPreview()
|
|
200
|
-
const menuBg = dark ? '#000000' : '#FFFFFF'
|
|
201
127
|
const fg = dark ? '#FFFFFF' : '#000000'
|
|
202
128
|
|
|
203
|
-
const open = host.openId === id
|
|
204
129
|
const selectedProfile = profiles.find((p) => p.id === selectedId) ?? null
|
|
205
130
|
// Alphabetical - this is the list that sees the most real use, so it's the one that benefits
|
|
206
131
|
// most from staying scannable once there are more than a handful of saved profiles.
|
|
207
132
|
const sortedProfiles = useMemo(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles])
|
|
208
133
|
// Null row + each saved profile + (if passed) the manage row.
|
|
209
|
-
const
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
// override its triggerRef is never attached, so this hook's own measurement is a no-op.
|
|
213
|
-
const auto = useAutoAlign(open, contentWidth, contentHeight, rotation)
|
|
214
|
-
const placement = alignOverride ?? auto
|
|
215
|
-
const { maxHeight, measured, triggerRef, verticalAlign } = placement
|
|
216
|
-
const align = forcedAlign ?? placement.align
|
|
134
|
+
const contentSize = getProfilePickerContentSize(profiles.length, !!onManage, colorSection)
|
|
135
|
+
// `takenId` exactly as before; a `takenIds` entry skips this picker's own selection.
|
|
136
|
+
const isTaken = (profileId: string) => profileId === takenId || (profileId !== selectedId && !!takenIds?.includes(profileId))
|
|
217
137
|
|
|
218
138
|
const handleSelectGuest = () => {
|
|
219
139
|
onSelect(null)
|
|
@@ -221,7 +141,7 @@ export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, sel
|
|
|
221
141
|
}
|
|
222
142
|
|
|
223
143
|
const handleSelectExisting = (profile: P) => {
|
|
224
|
-
if (profile.id
|
|
144
|
+
if (isTaken(profile.id)) return
|
|
225
145
|
onSelect(profile)
|
|
226
146
|
host.close()
|
|
227
147
|
}
|
|
@@ -231,353 +151,68 @@ export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, sel
|
|
|
231
151
|
onManage?.()
|
|
232
152
|
}
|
|
233
153
|
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
// Shown on the list page only (see the footer below).
|
|
249
|
-
const manageFooter = !!onManage && !(colorSection && colorPage)
|
|
250
|
-
|
|
251
|
-
return (
|
|
252
|
-
<View style={[styles.anchor, open && styles.anchorOpen]} onLayout={handleAnchorLayout}>
|
|
253
|
-
<TouchableRipple onPress={handleTriggerPress} borderless={chipTrigger} style={[styles.trigger, chipTrigger && styles.chipTrigger, triggerStyle]} accessibilityRole='button' accessibilityLabel={triggerLabel}>
|
|
254
|
-
<View ref={triggerRef} collapsable={false} style={chipTrigger ? styles.chipTriggerInner : styles.triggerInner}>
|
|
255
|
-
{chipTrigger && <IdentityChip color={color} tag={selectedProfile?.tag} icon={selectedProfile ? 'account' : nullIcon} size={CHIP_SIZE} />}
|
|
256
|
-
{/* The chip trigger's name and chevron are plain fg: the chip carries the color, and a muted
|
|
257
|
-
color on the host's pill read dim. The plain trigger keeps the color, which is what tells a
|
|
258
|
-
multi-seat game's seats apart. */}
|
|
259
|
-
<Text style={[styles.triggerLabel, { color: chipTrigger ? fg : color }]} numberOfLines={1}>
|
|
260
|
-
{triggerLabel}
|
|
261
|
-
</Text>
|
|
262
|
-
<Icon source='menu-down' size={16} color={chipTrigger ? fg : color} />
|
|
263
|
-
</View>
|
|
264
|
-
</TouchableRipple>
|
|
265
|
-
|
|
266
|
-
{/* Gated on `measured`, not just `open` - see InlineColorPicker's identical fix. */}
|
|
267
|
-
<PopoverBody visible={open && measured} align={align} verticalAlign={verticalAlign} caretColor={menuBg} caretBorderColor={color} caretBorderWidth={MENU_BORDER_WIDTH} triggerSize={chipTrigger ? triggerWidth : TRIGGER_HEIGHT}>
|
|
268
|
-
{/* The box (background, border, max height) is a plain View around the scroller, so
|
|
269
|
-
FadingScrollView's edge fade - the same cue as a dialog's scrolling rows - masks only the
|
|
270
|
-
rows, never the menu's own border or background. */}
|
|
271
|
-
<View
|
|
272
|
-
style={[
|
|
273
|
-
styles.menu,
|
|
274
|
-
{
|
|
275
|
-
backgroundColor: menuBg,
|
|
276
|
-
borderColor: color,
|
|
277
|
-
width: POPOVER_WIDTH,
|
|
278
|
-
maxHeight
|
|
279
|
-
}
|
|
280
|
-
]}
|
|
281
|
-
>
|
|
282
|
-
{/* Keyed by page, so swapping pages starts the new one scrolled to its top. */}
|
|
283
|
-
<FadingScrollView key={colorSection && colorPage ? 'color' : 'list'} contentContainerStyle={[styles.menuContent, manageFooter && styles.menuContentAboveFooter]} showsVerticalScrollIndicator={false}>
|
|
284
|
-
{colorSection && colorPage ? (
|
|
285
|
-
<ColorPage section={colorSection} profileColor={selectedProfile?.color} fg={fg} onBack={() => setColorPage(false)} />
|
|
286
|
-
) : (
|
|
287
|
-
<>
|
|
288
|
-
{colorSection && <ColorRow section={colorSection} fg={fg} dividerColor={color} onPress={() => setColorPage(true)} />}
|
|
289
|
-
{/* Selected row's own background fills entirely with its identity color (this seat's own
|
|
290
|
-
accent for the null row, the profile's own saved color otherwise) - matching how a
|
|
291
|
-
selected row reads elsewhere (see @tastic/hud's SectionedDropdown) rather than a checkmark
|
|
292
|
-
bolted onto an otherwise-plain row. */}
|
|
293
|
-
<TouchableRipple onPress={handleSelectGuest} style={[styles.row, selectedId === null && { backgroundColor: color }]}>
|
|
294
|
-
<View style={styles.rowInner}>
|
|
295
|
-
{/* Unfilled, matching a selected profile row's own ProfileChip treatment - no
|
|
296
|
-
background circle, but still the same fixed-size slot every row's icon/chip sits in
|
|
297
|
-
(see ProfileChip's own doc), so this row's icon stays lined up with every other row
|
|
298
|
-
regardless of selection. */}
|
|
299
|
-
<View style={styles.guestChip}>
|
|
300
|
-
<Icon source={nullIcon} size={13} color={selectedId === null ? getContrastColor(color) : fg} />
|
|
301
|
-
</View>
|
|
302
|
-
<Text style={[styles.rowLabel, { color: selectedId === null ? getContrastColor(color) : fg }]}>{nullLabel}</Text>
|
|
303
|
-
</View>
|
|
304
|
-
</TouchableRipple>
|
|
305
|
-
|
|
306
|
-
{sortedProfiles.map((profile) => {
|
|
307
|
-
const taken = profile.id === takenId
|
|
308
|
-
const selected = selectedId === profile.id
|
|
309
|
-
const rowColor = colorPreview ? colorPreview(profile.color) : profile.color
|
|
310
|
-
return (
|
|
311
|
-
<TouchableRipple key={profile.id} disabled={taken} onPress={() => handleSelectExisting(profile)} style={[styles.row, selected && { backgroundColor: rowColor }]}>
|
|
312
|
-
<View style={[styles.rowInner, taken && styles.rowDisabled]}>
|
|
313
|
-
{/* Unfilled on a selected row - its own background already fills with the
|
|
314
|
-
profile's color, so a second circle behind the tag would just be a redundant badge
|
|
315
|
-
where every other selected row in the app has plain icon+text instead. */}
|
|
316
|
-
<ProfileChip profile={profile} filled={!selected} />
|
|
317
|
-
<Text
|
|
318
|
-
style={[
|
|
319
|
-
styles.rowLabel,
|
|
320
|
-
{
|
|
321
|
-
color: selected ? getContrastColor(rowColor) : fg
|
|
322
|
-
}
|
|
323
|
-
]}
|
|
324
|
-
numberOfLines={1}
|
|
325
|
-
>
|
|
326
|
-
{profile.name}
|
|
327
|
-
</Text>
|
|
328
|
-
</View>
|
|
329
|
-
</TouchableRipple>
|
|
330
|
-
)
|
|
331
|
-
})}
|
|
332
|
-
</>
|
|
333
|
-
)}
|
|
334
|
-
</FadingScrollView>
|
|
335
|
-
{/* Manage is pinned below the scrolling rows, the way a dialog's Done sits below its rows:
|
|
336
|
-
always reachable, with the rows fading out above its divider while there are more below
|
|
337
|
-
(2026-10-16; it used to be the list's last row and scrolled away with it). Tertiary - a
|
|
338
|
-
navigation action, not another selectable identity in the list above it, so it reads more
|
|
339
|
-
like @tastic/hud's SectionedDropdown All/Clear footer button (centered, bold) than a normal
|
|
340
|
-
row. The accent is on the icon only: a muted accent label on the menu's black read dim,
|
|
341
|
-
almost disabled. Not on the color page, which only has the back row and the swatches. */}
|
|
342
|
-
{manageFooter && (
|
|
343
|
-
<View style={styles.menuFooter}>
|
|
344
|
-
<View style={[styles.divider, { backgroundColor: color }]} />
|
|
345
|
-
<TouchableRipple onPress={handleManage} style={styles.row}>
|
|
346
|
-
<View style={styles.manageRowInner}>
|
|
347
|
-
<Icon source='account-cog-outline' size={16} color={color} />
|
|
348
|
-
<Text style={[styles.manageLabel, { color: fg }]}>Manage</Text>
|
|
349
|
-
</View>
|
|
350
|
-
</TouchableRipple>
|
|
351
|
-
</View>
|
|
352
|
-
)}
|
|
353
|
-
</View>
|
|
354
|
-
</PopoverBody>
|
|
355
|
-
</View>
|
|
356
|
-
)
|
|
357
|
-
}
|
|
154
|
+
// Manage is pinned below the scrolling rows, the way a dialog's Done sits below its rows: always
|
|
155
|
+
// reachable, with the rows fading out above its divider while there are more below (2026-10-16;
|
|
156
|
+
// it used to be the list's last row and scrolled away with it). Tertiary - a navigation action,
|
|
157
|
+
// not another selectable identity in the list above it, so it reads more like @tastic/hud's
|
|
158
|
+
// SectionedDropdown All/Clear footer button (centered, bold) than a normal row. The accent is on
|
|
159
|
+
// the icon only: a muted accent label on the menu's black read dim, almost disabled.
|
|
160
|
+
const manageFooter = onManage ? (
|
|
161
|
+
<TouchableRipple onPress={handleManage} style={pickerMenuStyles.row}>
|
|
162
|
+
<View style={styles.manageRowInner}>
|
|
163
|
+
<Icon source='account-cog-outline' size={16} color={color} />
|
|
164
|
+
<Text style={[styles.manageLabel, { color: fg }]}>Manage</Text>
|
|
165
|
+
</View>
|
|
166
|
+
</TouchableRipple>
|
|
167
|
+
) : undefined
|
|
358
168
|
|
|
359
|
-
// The color section's heading, caption and swatch grid, then a divider before the profile rows.
|
|
360
|
-
// The list page's top row: a swatch of the color in effect, the label (and caption), and a chevron
|
|
361
|
-
// to the color page. A divider sets it apart from the selectable rows under it.
|
|
362
|
-
function ColorRow({ section, fg, dividerColor, onPress }: { section: ProfilePickerColorSection; fg: string; dividerColor: string; onPress: () => void }) {
|
|
363
|
-
const { value, previewValue, label = 'Color', caption } = section
|
|
364
|
-
const drawn = previewValue ? previewValue(value) : value
|
|
365
169
|
return (
|
|
366
|
-
|
|
367
|
-
<
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
170
|
+
<PickerMenu id={id} host={host} color={color} dark={dark} label={selectedProfile?.name ?? guestLabel} chip={colorSection ? { tag: selectedProfile?.tag, icon: selectedProfile ? 'account' : nullIcon } : undefined} colorSection={colorSection} homeColor={selectedProfile?.color} contentSize={contentSize} align={align} alignOverride={alignOverride} rotation={rotation} triggerStyle={triggerStyle} footer={manageFooter}>
|
|
171
|
+
<IconRow icon={nullIcon} label={nullLabel} selected={selectedId === null} color={color} fg={fg} onPress={handleSelectGuest} />
|
|
172
|
+
|
|
173
|
+
{sortedProfiles.map((profile) => {
|
|
174
|
+
const taken = isTaken(profile.id)
|
|
175
|
+
const selected = selectedId === profile.id
|
|
176
|
+
const rowColor = colorPreview ? colorPreview(profile.color) : profile.color
|
|
177
|
+
return (
|
|
178
|
+
<TouchableRipple key={profile.id} disabled={taken} onPress={() => handleSelectExisting(profile)} style={[pickerMenuStyles.row, selected && { backgroundColor: rowColor }]}>
|
|
179
|
+
<View style={[pickerMenuStyles.rowInner, taken && pickerMenuStyles.rowDisabled]}>
|
|
180
|
+
{/* Unfilled on a selected row - its own background already fills with the
|
|
181
|
+
profile's color, so a second circle behind the tag would just be a redundant badge
|
|
182
|
+
where every other selected row in the app has plain icon+text instead. */}
|
|
183
|
+
<ProfileChip profile={profile} filled={!selected} />
|
|
184
|
+
<Text
|
|
185
|
+
style={[
|
|
186
|
+
pickerMenuStyles.rowLabel,
|
|
187
|
+
{
|
|
188
|
+
color: selected ? getContrastColor(rowColor) : fg
|
|
189
|
+
}
|
|
190
|
+
]}
|
|
191
|
+
numberOfLines={1}
|
|
192
|
+
>
|
|
193
|
+
{profile.name}
|
|
377
194
|
</Text>
|
|
378
|
-
|
|
379
|
-
</
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
<View style={[styles.divider, { backgroundColor: dividerColor }]} />
|
|
384
|
-
</>
|
|
385
|
-
)
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
// The color page: a back row to the list, then the swatches. `profileColor` is the selected
|
|
389
|
-
// profile's own color: its swatch carries a dot, so a player who has picked something else can find
|
|
390
|
-
// their way back to it (the profile's row shows that color, but matching it to one of two similar
|
|
391
|
-
// swatches by eye is guesswork). The check takes the dot's place on the swatch in effect, so the
|
|
392
|
-
// dot only shows while the color in effect is another one.
|
|
393
|
-
function ColorPage({ section, profileColor, fg, onBack }: { section: ProfilePickerColorSection; profileColor?: string; fg: string; onBack: () => void }) {
|
|
394
|
-
const { value, onChange, previewValue, swatches = defaultColors, label = 'Color' } = section
|
|
395
|
-
const displayFor = previewValue ?? ((hex: string) => hex)
|
|
396
|
-
return (
|
|
397
|
-
<>
|
|
398
|
-
<TouchableRipple onPress={onBack} style={styles.row} accessibilityRole='button' accessibilityLabel='Back'>
|
|
399
|
-
<View style={styles.rowInner}>
|
|
400
|
-
<View style={styles.guestChip}>
|
|
401
|
-
<Icon source='chevron-left' size={20} color={fg} />
|
|
402
|
-
</View>
|
|
403
|
-
<Text style={[styles.colorRowLabel, { color: fg }]} numberOfLines={1}>
|
|
404
|
-
{label}
|
|
405
|
-
</Text>
|
|
406
|
-
</View>
|
|
407
|
-
</TouchableRipple>
|
|
408
|
-
<View style={styles.swatches}>
|
|
409
|
-
{swatches.map((swatch) => {
|
|
410
|
-
const selected = swatch.value.toLowerCase() === value.toLowerCase()
|
|
411
|
-
const isProfileColor = !!profileColor && swatch.value.toLowerCase() === profileColor.toLowerCase()
|
|
412
|
-
const swatchDisplay = displayFor(swatch.value)
|
|
413
|
-
// The same swatch as @tastic/hud's InlineColorPicker: a filled circle, the selected one
|
|
414
|
-
// ringed and checked.
|
|
415
|
-
return (
|
|
416
|
-
<TouchableRipple key={swatch.value} onPress={() => onChange(swatch.value)} borderless style={[styles.swatch, { backgroundColor: swatchDisplay }, selected && styles.swatchSelected]} accessibilityRole='button' accessibilityLabel={isProfileColor ? `${swatch.label}, profile color` : swatch.label} accessibilityState={{ selected }}>
|
|
417
|
-
{selected ? <Icon source='check' size={16} color={getContrastColor(swatchDisplay)} /> : isProfileColor ? <View style={[styles.profileDot, { backgroundColor: getContrastColor(swatchDisplay) }]} /> : <View />}
|
|
418
|
-
</TouchableRipple>
|
|
419
|
-
)
|
|
420
|
-
})}
|
|
421
|
-
</View>
|
|
422
|
-
</>
|
|
195
|
+
</View>
|
|
196
|
+
</TouchableRipple>
|
|
197
|
+
)
|
|
198
|
+
})}
|
|
199
|
+
</PickerMenu>
|
|
423
200
|
)
|
|
424
201
|
}
|
|
425
202
|
|
|
426
203
|
const styles = StyleSheet.create({
|
|
427
|
-
anchor: {
|
|
428
|
-
position: 'relative'
|
|
429
|
-
},
|
|
430
|
-
// See InlineColorPicker's identical comment - React Native Web gives every position:'relative'
|
|
431
|
-
// view its own stacking context, so the elevation has to live on the anchor itself.
|
|
432
|
-
anchorOpen: {
|
|
433
|
-
zIndex: 100
|
|
434
|
-
},
|
|
435
|
-
// The pill, rounded at both ends. The host adds its own background through `triggerStyle`.
|
|
436
|
-
chipTrigger: {
|
|
437
|
-
borderRadius: (CHIP_SIZE + 2 * CHIP_TRIGGER_PADDING) / 2
|
|
438
|
-
},
|
|
439
|
-
// The padding lives on the measured view, not the pressable around it, so the placement measures
|
|
440
|
-
// the pill's real edge: measured from inside the padding, a popover opening toward a screen edge
|
|
441
|
-
// was sized as if the pill were 6pt further from it, and ran 2pt past that edge.
|
|
442
|
-
chipTriggerInner: {
|
|
443
|
-
alignItems: 'center',
|
|
444
|
-
flexDirection: 'row',
|
|
445
|
-
gap: 8,
|
|
446
|
-
height: CHIP_SIZE + 2 * CHIP_TRIGGER_PADDING,
|
|
447
|
-
paddingLeft: CHIP_TRIGGER_PADDING,
|
|
448
|
-
paddingRight: CHIP_TRIGGER_PADDING + 8
|
|
449
|
-
},
|
|
450
|
-
colorRowCaption: {
|
|
451
|
-
fontSize: 12,
|
|
452
|
-
lineHeight: COLOR_CAPTION_LINE,
|
|
453
|
-
opacity: 0.6
|
|
454
|
-
},
|
|
455
|
-
// A row's label, with a line height so the captioned Color row's height is exact.
|
|
456
|
-
colorRowLabel: {
|
|
457
|
-
fontSize: 14,
|
|
458
|
-
lineHeight: COLOR_LABEL_LINE
|
|
459
|
-
},
|
|
460
|
-
// The color in effect, in the same 22pt slot as every row's chip.
|
|
461
|
-
colorRowSwatch: {
|
|
462
|
-
borderRadius: 11,
|
|
463
|
-
height: 22,
|
|
464
|
-
width: 22
|
|
465
|
-
},
|
|
466
|
-
colorRowText: {
|
|
467
|
-
flex: 1
|
|
468
|
-
},
|
|
469
|
-
// Separates Manage (a navigation action) from the selectable rows above it - different kind of
|
|
470
|
-
// action, not just another row in the same list. Tinted with the seat's own color (passed in as
|
|
471
|
-
// backgroundColor) rather than a flat gray, matching the border/caret's own accent-tinted look.
|
|
472
|
-
divider: {
|
|
473
|
-
height: 1,
|
|
474
|
-
marginVertical: 4,
|
|
475
|
-
opacity: 0.25
|
|
476
|
-
},
|
|
477
|
-
// Matches ProfileChip's own default size/shape exactly (see this row's own render-site comment)
|
|
478
|
-
// - no backgroundColor, unlike ProfileChip's own default: this row has no identity color of its
|
|
479
|
-
// own to fill it with, and the render site now colors the icon directly instead.
|
|
480
|
-
guestChip: {
|
|
481
|
-
alignItems: 'center',
|
|
482
|
-
borderRadius: 11,
|
|
483
|
-
height: 22,
|
|
484
|
-
justifyContent: 'center',
|
|
485
|
-
width: 22
|
|
486
|
-
},
|
|
487
204
|
manageLabel: {
|
|
488
205
|
fontSize: 13,
|
|
489
206
|
fontWeight: '700',
|
|
490
207
|
letterSpacing: 0.5
|
|
491
208
|
},
|
|
492
|
-
// Centered rather than left-aligned like a normal row - see
|
|
493
|
-
//
|
|
209
|
+
// Centered rather than left-aligned like a normal row - see the footer's own comment for why (a
|
|
210
|
+
// footer action, not another selectable identity).
|
|
494
211
|
manageRowInner: {
|
|
495
212
|
alignItems: 'center',
|
|
496
213
|
flexDirection: 'row',
|
|
497
214
|
gap: 6,
|
|
498
215
|
height: '100%',
|
|
499
216
|
justifyContent: 'center'
|
|
500
|
-
},
|
|
501
|
-
menu: {
|
|
502
|
-
borderRadius: 12,
|
|
503
|
-
borderWidth: MENU_BORDER_WIDTH,
|
|
504
|
-
elevation: 8,
|
|
505
|
-
shadowColor: '#000000',
|
|
506
|
-
shadowOffset: { height: 2, width: 0 },
|
|
507
|
-
shadowOpacity: 0.3,
|
|
508
|
-
shadowRadius: 8
|
|
509
|
-
},
|
|
510
|
-
// Padded on every side (not just top/bottom) - a selected row's own full-color background (see
|
|
511
|
-
// `row`'s borderRadius) needs real margin from the menu's own straight edges to read as a rounded
|
|
512
|
-
// pill instead of looking clipped against them.
|
|
513
|
-
menuContent: {
|
|
514
|
-
padding: LIST_PADDING
|
|
515
|
-
},
|
|
516
|
-
// With Manage pinned below, its own padding takes the bottom edge (same total height as before).
|
|
517
|
-
menuContentAboveFooter: {
|
|
518
|
-
paddingBottom: 0
|
|
519
|
-
},
|
|
520
|
-
menuFooter: {
|
|
521
|
-
paddingBottom: LIST_PADDING,
|
|
522
|
-
paddingHorizontal: LIST_PADDING
|
|
523
|
-
},
|
|
524
|
-
// The selected profile's own color (see ColorSection).
|
|
525
|
-
profileDot: {
|
|
526
|
-
borderRadius: 4,
|
|
527
|
-
height: 8,
|
|
528
|
-
width: 8
|
|
529
|
-
},
|
|
530
|
-
row: {
|
|
531
|
-
borderRadius: 8,
|
|
532
|
-
height: ROW_HEIGHT
|
|
533
|
-
},
|
|
534
|
-
rowDisabled: {
|
|
535
|
-
opacity: 0.35
|
|
536
|
-
},
|
|
537
|
-
rowInner: {
|
|
538
|
-
alignItems: 'center',
|
|
539
|
-
flexDirection: 'row',
|
|
540
|
-
flex: 1,
|
|
541
|
-
gap: 8,
|
|
542
|
-
paddingHorizontal: ROW_INSET
|
|
543
|
-
},
|
|
544
|
-
rowLabel: {
|
|
545
|
-
flex: 1,
|
|
546
|
-
fontSize: 14
|
|
547
|
-
},
|
|
548
|
-
swatch: {
|
|
549
|
-
alignItems: 'center',
|
|
550
|
-
borderRadius: SWATCH_SIZE / 2,
|
|
551
|
-
height: SWATCH_SIZE,
|
|
552
|
-
justifyContent: 'center',
|
|
553
|
-
width: SWATCH_SIZE
|
|
554
|
-
},
|
|
555
|
-
swatchSelected: {
|
|
556
|
-
borderColor: '#ffffff',
|
|
557
|
-
borderWidth: 2
|
|
558
|
-
},
|
|
559
|
-
swatches: {
|
|
560
|
-
columnGap: GRID_GAP,
|
|
561
|
-
flexDirection: 'row',
|
|
562
|
-
flexWrap: 'wrap',
|
|
563
|
-
marginBottom: GRID_BOTTOM,
|
|
564
|
-
marginHorizontal: ROW_INSET,
|
|
565
|
-
marginTop: GRID_TOP,
|
|
566
|
-
rowGap: GRID_GAP,
|
|
567
|
-
width: GRID_WIDTH
|
|
568
|
-
},
|
|
569
|
-
trigger: {
|
|
570
|
-
alignSelf: 'flex-start'
|
|
571
|
-
},
|
|
572
|
-
triggerInner: {
|
|
573
|
-
alignItems: 'center',
|
|
574
|
-
flexDirection: 'row',
|
|
575
|
-
gap: 2,
|
|
576
|
-
height: TRIGGER_HEIGHT
|
|
577
|
-
},
|
|
578
|
-
triggerLabel: {
|
|
579
|
-
fontSize: 13,
|
|
580
|
-
fontWeight: '700',
|
|
581
|
-
letterSpacing: 1
|
|
582
217
|
}
|
|
583
218
|
})
|