@tastic/profile 0.12.0 → 0.14.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 +15 -11
- package/dist/index.d.mts +17 -8
- package/dist/index.d.ts +17 -8
- package/dist/index.js +211 -111
- package/dist/index.mjs +222 -122
- package/package.json +10 -10
- package/src/IdentityChip.tsx +58 -0
- package/src/ProfileChip.tsx +2 -37
- package/src/ProfilePicker.tsx +309 -77
- package/src/ProfilesManager.tsx +21 -67
- package/src/ProfilesScreen.tsx +3 -4
- package/src/index.ts +1 -1
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { getContrastColor } from '@rific/auto-paper'
|
|
2
|
+
import { StyleSheet, View } from 'react-native'
|
|
3
|
+
import { Icon, Text } from 'react-native-paper'
|
|
4
|
+
|
|
5
|
+
// Presence check, not isValidTag's own full-string emoji validation (profilesValidation.ts) - this
|
|
6
|
+
// only decides a font-size ratio, so it doesn't need that check's ZWJ/variation-selector precision,
|
|
7
|
+
// just "does this tag contain a pictograph at all."
|
|
8
|
+
const EMOJI_PATTERN = /\p{Extended_Pictographic}/u
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
// The color drawn, exactly as given (no ProfileColorProvider transform: the caller has already
|
|
12
|
+
// decided what is drawn).
|
|
13
|
+
color: string
|
|
14
|
+
// Shown when non-empty, else `icon`.
|
|
15
|
+
tag?: string
|
|
16
|
+
icon: string
|
|
17
|
+
size: number
|
|
18
|
+
// See ProfileChip's `filled`.
|
|
19
|
+
filled?: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// A filled circle with a tag (or an icon) in a contrast-safe color on top: the drawing behind
|
|
23
|
+
// ProfileChip (a profile's own colors) and ProfilePicker's chip trigger (the live color in effect,
|
|
24
|
+
// which may not be any profile's saved one).
|
|
25
|
+
export function IdentityChip({ color, tag, icon, size, filled = true }: Props) {
|
|
26
|
+
const contrastColor = getContrastColor(color)
|
|
27
|
+
// An emoji glyph reads visually smaller than a bold letter at the same fontSize (the system emoji
|
|
28
|
+
// font leaves more of its own em-box empty), so a plain-text ratio that looks right for "J" still
|
|
29
|
+
// looks small for "🎮" at the identical size - this bumps emoji up to the icon's own ratio instead,
|
|
30
|
+
// since a single emoji is closer in visual weight to an icon glyph than to a line of text.
|
|
31
|
+
//
|
|
32
|
+
// Three letters don't fit across the circle at 0.4 in a wide bold face (DAD in Solitaire's DejaVu
|
|
33
|
+
// Serif Bold is a whole chip wide), and the web build ignores adjustsFontSizeToFit, so it cut
|
|
34
|
+
// them off there ("D..."); 0.32 fits them, with adjustsFontSizeToFit still the backstop on native.
|
|
35
|
+
const tagFontSize = size * (tag && EMOJI_PATTERN.test(tag) ? 0.6 : tag && Array.from(tag).length >= 3 ? 0.32 : 0.4)
|
|
36
|
+
return (
|
|
37
|
+
<View style={[styles.chip, { borderRadius: size / 2, height: size, width: size }, filled && { backgroundColor: color }]}>
|
|
38
|
+
{tag ? (
|
|
39
|
+
<Text style={[styles.tag, { color: contrastColor, fontSize: tagFontSize }]} numberOfLines={1} adjustsFontSizeToFit>
|
|
40
|
+
{tag}
|
|
41
|
+
</Text>
|
|
42
|
+
) : (
|
|
43
|
+
<Icon source={icon} size={size * 0.6} color={contrastColor} />
|
|
44
|
+
)}
|
|
45
|
+
</View>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const styles = StyleSheet.create({
|
|
50
|
+
chip: {
|
|
51
|
+
alignItems: 'center',
|
|
52
|
+
justifyContent: 'center'
|
|
53
|
+
},
|
|
54
|
+
tag: {
|
|
55
|
+
fontWeight: '700',
|
|
56
|
+
paddingHorizontal: 2
|
|
57
|
+
}
|
|
58
|
+
})
|
package/src/ProfileChip.tsx
CHANGED
|
@@ -1,15 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { StyleSheet, View } from 'react-native'
|
|
3
|
-
import { Icon, Text } from 'react-native-paper'
|
|
4
|
-
|
|
1
|
+
import { IdentityChip } from './IdentityChip'
|
|
5
2
|
import { useProfileDisplayColor } from './ProfileColorContext'
|
|
6
3
|
import { Profile } from './types'
|
|
7
4
|
|
|
8
|
-
// Presence check, not isValidTag's own full-string emoji validation (profilesValidation.ts) - this
|
|
9
|
-
// only decides a font-size ratio, so it doesn't need that check's ZWJ/variation-selector precision,
|
|
10
|
-
// just "does this tag contain a pictograph at all."
|
|
11
|
-
const EMOJI_PATTERN = /\p{Extended_Pictographic}/u
|
|
12
|
-
|
|
13
5
|
interface Props {
|
|
14
6
|
profile: Profile
|
|
15
7
|
size?: number
|
|
@@ -32,32 +24,5 @@ interface Props {
|
|
|
32
24
|
export function ProfileChip({ profile, size = 22, filled = true }: Props) {
|
|
33
25
|
// The drawn color, not necessarily the saved one - see ProfileColorProvider.
|
|
34
26
|
const color = useProfileDisplayColor(profile.color)
|
|
35
|
-
|
|
36
|
-
// An emoji glyph reads visually smaller than a bold letter at the same fontSize (the system emoji
|
|
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 - this bumps emoji up to the icon's own ratio instead,
|
|
39
|
-
// since a single emoji is closer in visual weight to an icon glyph than to a line of text.
|
|
40
|
-
const tagFontSize = size * (profile.tag && EMOJI_PATTERN.test(profile.tag) ? 0.6 : 0.4)
|
|
41
|
-
return (
|
|
42
|
-
<View style={[styles.chip, { borderRadius: size / 2, height: size, width: size }, filled && { backgroundColor: color }]}>
|
|
43
|
-
{profile.tag ? (
|
|
44
|
-
<Text style={[styles.tag, { color: contrastColor, fontSize: tagFontSize }]} numberOfLines={1} adjustsFontSizeToFit>
|
|
45
|
-
{profile.tag}
|
|
46
|
-
</Text>
|
|
47
|
-
) : (
|
|
48
|
-
<Icon source='account' size={size * 0.6} color={contrastColor} />
|
|
49
|
-
)}
|
|
50
|
-
</View>
|
|
51
|
-
)
|
|
27
|
+
return <IdentityChip color={color} tag={profile.tag} icon='account' size={size} filled={filled} />
|
|
52
28
|
}
|
|
53
|
-
|
|
54
|
-
const styles = StyleSheet.create({
|
|
55
|
-
chip: {
|
|
56
|
-
alignItems: 'center',
|
|
57
|
-
justifyContent: 'center'
|
|
58
|
-
},
|
|
59
|
-
tag: {
|
|
60
|
-
fontWeight: '700',
|
|
61
|
-
paddingHorizontal: 2
|
|
62
|
-
}
|
|
63
|
-
})
|
package/src/ProfilePicker.tsx
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { getContrastColor } from '@rific/auto-paper'
|
|
1
|
+
import { defaultColors, FadingScrollView, getContrastColor, type SeedColor } from '@rific/auto-paper'
|
|
2
2
|
import { TouchableRipple } from '@rific/feedback-press'
|
|
3
3
|
import { PopoverBody, PopoverHost, PopoverRotation, useAutoAlign } from '@tastic/hud'
|
|
4
|
-
import { RefObject, useMemo } from 'react'
|
|
5
|
-
import {
|
|
4
|
+
import { RefObject, useMemo, useState } from 'react'
|
|
5
|
+
import { type LayoutChangeEvent, type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native'
|
|
6
6
|
import { Icon, Text } from 'react-native-paper'
|
|
7
7
|
|
|
8
|
+
import { IdentityChip } from './IdentityChip'
|
|
8
9
|
import { ProfileChip } from './ProfileChip'
|
|
9
10
|
import { useProfileColorPreview } from './ProfileColorContext'
|
|
10
11
|
import { Profile } from './types'
|
|
@@ -13,6 +14,58 @@ const TRIGGER_HEIGHT = 28
|
|
|
13
14
|
const POPOVER_WIDTH = 220
|
|
14
15
|
const ROW_HEIGHT = 36
|
|
15
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
|
+
}
|
|
16
69
|
|
|
17
70
|
// A complete placement decision for the popover: which way it opens, how tall it may grow, whether
|
|
18
71
|
// this open's measurement has landed yet, and the ref attached to the measured trigger.
|
|
@@ -35,16 +88,21 @@ export interface ProfilePickerContentSize {
|
|
|
35
88
|
|
|
36
89
|
// The popover's content footprint for a given roster size: always POPOVER_WIDTH wide, tall enough
|
|
37
90
|
// for the null/"guest" row + one row per profile + (when `hasManageRow`, i.e. when the picker is
|
|
38
|
-
// given an `onManage`) the trailing Manage row, plus the list's own padding.
|
|
91
|
+
// given an `onManage`) the trailing Manage row, plus the list's own padding. With a
|
|
92
|
+
// `colorSection` (the same object handed to the picker works), it's the taller of the popover's
|
|
93
|
+
// two pages, exactly: the list with the Color row and its divider on top, or the color page (a
|
|
94
|
+
// back row and the swatches), so one placement fits whichever page is showing. This is the size
|
|
39
95
|
// ProfilePicker hands to `useAutoAlign` - the ONE formula, used by the component itself (it leaves
|
|
40
96
|
// out the menu's 2pt border and the Manage divider, ~13pt in all, so treat it as a placement
|
|
41
97
|
// estimate, not a pixel-exact measurement) - exported
|
|
42
98
|
// so a host supplying its own placement through `alignOverride` can size that placement hook (its
|
|
43
99
|
// own useAutoAlign/useZoneClampedAlign call) to match the popover it is actually positioning,
|
|
44
100
|
// instead of copying these constants and drifting whenever they change here.
|
|
45
|
-
export function getProfilePickerContentSize(profileCount: number, hasManageRow = false): ProfilePickerContentSize {
|
|
101
|
+
export function getProfilePickerContentSize(profileCount: number, hasManageRow = false, colorSection?: Pick<ProfilePickerColorSection, 'swatches' | 'caption'>): ProfilePickerContentSize {
|
|
46
102
|
const rowCount = profileCount + 1 + (hasManageRow ? 1 : 0)
|
|
47
|
-
|
|
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 }
|
|
48
106
|
}
|
|
49
107
|
|
|
50
108
|
interface ProfilePickerProps<P extends Profile> {
|
|
@@ -102,6 +160,21 @@ interface ProfilePickerProps<P extends Profile> {
|
|
|
102
160
|
// entirely. This component never manages profiles itself (see ProfilesManager for that); this is
|
|
103
161
|
// selection-only.
|
|
104
162
|
onManage?: () => void
|
|
163
|
+
// Puts a Color row at the top of the popover, above the profile rows, and turns the trigger into
|
|
164
|
+
// one button that leads with a chip: a circle in `color` (the color in effect) with the selected
|
|
165
|
+
// profile's tag (`nullIcon` for the null selection), then the name and the chevron. For a host
|
|
166
|
+
// where the player's color and their profile are one control (Solitaire's Home), instead of a
|
|
167
|
+
// separate color trigger beside the name that read as a different thing. The Color row swaps the
|
|
168
|
+
// popover's contents for the color page (a back row, then the swatches); picking a swatch calls
|
|
169
|
+
// `onChange` and stays on that page, so the change can be seen behind it and another tried. A
|
|
170
|
+
// press away closes the whole popover, and the next open starts on the list again. The selected
|
|
171
|
+
// profile's own color, when it's one of the swatches, carries a small dot while another color is
|
|
172
|
+
// in effect, as the way back to it. Omit for the plain name trigger and a rows-only popover,
|
|
173
|
+
// exactly as before.
|
|
174
|
+
colorSection?: ProfilePickerColorSection
|
|
175
|
+
// Extra style for the trigger (its pressable box) - e.g. a background behind the chip trigger's
|
|
176
|
+
// pill. The chip trigger already pads itself and rounds into a pill.
|
|
177
|
+
triggerStyle?: StyleProp<ViewStyle>
|
|
105
178
|
}
|
|
106
179
|
|
|
107
180
|
// Generic over P (an app's own Profile type, richer than this package's base Profile) so that a
|
|
@@ -120,7 +193,7 @@ interface ProfilePickerProps<P extends Profile> {
|
|
|
120
193
|
// it keeps this popover from needing its own destructive-delete confirmation. Fully prop-driven -
|
|
121
194
|
// no internal profile-storage access of its own, so the host app stays the one place that reads
|
|
122
195
|
// its own persistence hook.
|
|
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>) {
|
|
196
|
+
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, colorSection, triggerStyle }: ProfilePickerProps<P>) {
|
|
124
197
|
const id = `${idPrefix}-profile`
|
|
125
198
|
// A selected row fills with the profile's drawn color, the same one its ProfileChip would show.
|
|
126
199
|
const colorPreview = useProfileColorPreview()
|
|
@@ -133,7 +206,7 @@ export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, sel
|
|
|
133
206
|
// most from staying scannable once there are more than a handful of saved profiles.
|
|
134
207
|
const sortedProfiles = useMemo(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles])
|
|
135
208
|
// Null row + each saved profile + (if passed) the manage row.
|
|
136
|
-
const { width: contentWidth, height: contentHeight } = getProfilePickerContentSize(profiles.length, !!onManage)
|
|
209
|
+
const { width: contentWidth, height: contentHeight } = getProfilePickerContentSize(profiles.length, !!onManage, colorSection)
|
|
137
210
|
// Always called, even when `alignOverride` is supplied and this result goes unused - hooks can't
|
|
138
211
|
// be conditional (see @tastic/hud's InlineColorPicker for the identical pattern). With an
|
|
139
212
|
// override its triggerRef is never attached, so this hook's own measurement is a no-op.
|
|
@@ -158,20 +231,42 @@ export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, sel
|
|
|
158
231
|
onManage?.()
|
|
159
232
|
}
|
|
160
233
|
|
|
234
|
+
// The chip trigger is a whole pill, so the caret points at its middle: PopoverBody places the
|
|
235
|
+
// caret half a `triggerSize` in from the side the popover is aligned to, so the pill's own width
|
|
236
|
+
// centers it whichever way the popover opens. The plain trigger keeps TRIGGER_HEIGHT, as before.
|
|
237
|
+
const chipTrigger = !!colorSection
|
|
238
|
+
const [triggerWidth, setTriggerWidth] = useState(0)
|
|
239
|
+
const handleAnchorLayout = chipTrigger ? (e: LayoutChangeEvent) => setTriggerWidth(e.nativeEvent.layout.width) : undefined
|
|
240
|
+
// Which page the popover shows. Reset on every press of the trigger, so an open always starts on
|
|
241
|
+
// the list (a press away closes the popover without coming back through here).
|
|
242
|
+
const [colorPage, setColorPage] = useState(false)
|
|
243
|
+
const handleTriggerPress = () => {
|
|
244
|
+
setColorPage(false)
|
|
245
|
+
host.toggle(id)
|
|
246
|
+
}
|
|
247
|
+
const triggerLabel = (selectedProfile?.name ?? guestLabel).toUpperCase()
|
|
248
|
+
|
|
161
249
|
return (
|
|
162
|
-
<View style={[styles.anchor, open && styles.anchorOpen]}>
|
|
163
|
-
<TouchableRipple onPress={
|
|
164
|
-
<View ref={triggerRef} collapsable={false} style={styles.triggerInner}>
|
|
165
|
-
<
|
|
166
|
-
|
|
250
|
+
<View style={[styles.anchor, open && styles.anchorOpen]} onLayout={handleAnchorLayout}>
|
|
251
|
+
<TouchableRipple onPress={handleTriggerPress} borderless={chipTrigger} style={[styles.trigger, chipTrigger && styles.chipTrigger, triggerStyle]} accessibilityRole='button' accessibilityLabel={triggerLabel}>
|
|
252
|
+
<View ref={triggerRef} collapsable={false} style={chipTrigger ? styles.chipTriggerInner : styles.triggerInner}>
|
|
253
|
+
{chipTrigger && <IdentityChip color={color} tag={selectedProfile?.tag} icon={selectedProfile ? 'account' : nullIcon} size={CHIP_SIZE} />}
|
|
254
|
+
{/* The chip trigger's name and chevron are plain fg: the chip carries the color, and a muted
|
|
255
|
+
color on the host's pill read dim. The plain trigger keeps the color, which is what tells a
|
|
256
|
+
multi-seat game's seats apart. */}
|
|
257
|
+
<Text style={[styles.triggerLabel, { color: chipTrigger ? fg : color }]} numberOfLines={1}>
|
|
258
|
+
{triggerLabel}
|
|
167
259
|
</Text>
|
|
168
|
-
<Icon source='menu-down' size={16} color={color} />
|
|
260
|
+
<Icon source='menu-down' size={16} color={chipTrigger ? fg : color} />
|
|
169
261
|
</View>
|
|
170
262
|
</TouchableRipple>
|
|
171
263
|
|
|
172
264
|
{/* Gated on `measured`, not just `open` - see InlineColorPicker's identical fix. */}
|
|
173
|
-
<PopoverBody visible={open && measured} align={align} verticalAlign={verticalAlign} caretColor={menuBg} caretBorderColor={color} caretBorderWidth={
|
|
174
|
-
|
|
265
|
+
<PopoverBody visible={open && measured} align={align} verticalAlign={verticalAlign} caretColor={menuBg} caretBorderColor={color} caretBorderWidth={MENU_BORDER_WIDTH} triggerSize={chipTrigger ? triggerWidth : TRIGGER_HEIGHT}>
|
|
266
|
+
{/* The box (background, border, max height) is a plain View around the scroller, so
|
|
267
|
+
FadingScrollView's edge fade - the same cue as a dialog's scrolling rows - masks only the
|
|
268
|
+
rows, never the menu's own border or background. */}
|
|
269
|
+
<View
|
|
175
270
|
style={[
|
|
176
271
|
styles.menu,
|
|
177
272
|
{
|
|
@@ -181,73 +276,149 @@ export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, sel
|
|
|
181
276
|
maxHeight
|
|
182
277
|
}
|
|
183
278
|
]}
|
|
184
|
-
contentContainerStyle={styles.menuContent}
|
|
185
|
-
showsVerticalScrollIndicator={false}
|
|
186
279
|
>
|
|
187
|
-
{/*
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
<
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
280
|
+
{/* Keyed by page, so swapping pages starts the new one scrolled to its top. */}
|
|
281
|
+
<FadingScrollView key={colorSection && colorPage ? 'color' : 'list'} contentContainerStyle={styles.menuContent} showsVerticalScrollIndicator={false}>
|
|
282
|
+
{colorSection && colorPage ? (
|
|
283
|
+
<ColorPage section={colorSection} profileColor={selectedProfile?.color} fg={fg} onBack={() => setColorPage(false)} />
|
|
284
|
+
) : (
|
|
285
|
+
<>
|
|
286
|
+
{colorSection && <ColorRow section={colorSection} fg={fg} dividerColor={color} onPress={() => setColorPage(true)} />}
|
|
287
|
+
{/* Selected row's own background fills entirely with its identity color (this seat's own
|
|
288
|
+
accent for the null row, the profile's own saved color otherwise) - matching how a
|
|
289
|
+
selected row reads elsewhere (see @tastic/hud's SectionedDropdown) rather than a checkmark
|
|
290
|
+
bolted onto an otherwise-plain row. */}
|
|
291
|
+
<TouchableRipple onPress={handleSelectGuest} style={[styles.row, selectedId === null && { backgroundColor: color }]}>
|
|
292
|
+
<View style={styles.rowInner}>
|
|
293
|
+
{/* Unfilled, matching a selected profile row's own ProfileChip treatment - no
|
|
294
|
+
background circle, but still the same fixed-size slot every row's icon/chip sits in
|
|
295
|
+
(see ProfileChip's own doc), so this row's icon stays lined up with every other row
|
|
296
|
+
regardless of selection. */}
|
|
297
|
+
<View style={styles.guestChip}>
|
|
298
|
+
<Icon source={nullIcon} size={13} color={selectedId === null ? getContrastColor(color) : fg} />
|
|
299
|
+
</View>
|
|
300
|
+
<Text style={[styles.rowLabel, { color: selectedId === null ? getContrastColor(color) : fg }]}>{nullLabel}</Text>
|
|
301
|
+
</View>
|
|
302
|
+
</TouchableRipple>
|
|
203
303
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
304
|
+
{sortedProfiles.map((profile) => {
|
|
305
|
+
const taken = profile.id === takenId
|
|
306
|
+
const selected = selectedId === profile.id
|
|
307
|
+
const rowColor = colorPreview ? colorPreview(profile.color) : profile.color
|
|
308
|
+
return (
|
|
309
|
+
<TouchableRipple key={profile.id} disabled={taken} onPress={() => handleSelectExisting(profile)} style={[styles.row, selected && { backgroundColor: rowColor }]}>
|
|
310
|
+
<View style={[styles.rowInner, taken && styles.rowDisabled]}>
|
|
311
|
+
{/* Unfilled on a selected row - its own background already fills with the
|
|
312
|
+
profile's color, so a second circle behind the tag would just be a redundant badge
|
|
313
|
+
where every other selected row in the app has plain icon+text instead. */}
|
|
314
|
+
<ProfileChip profile={profile} filled={!selected} />
|
|
315
|
+
<Text
|
|
316
|
+
style={[
|
|
317
|
+
styles.rowLabel,
|
|
318
|
+
{
|
|
319
|
+
color: selected ? getContrastColor(rowColor) : fg
|
|
320
|
+
}
|
|
321
|
+
]}
|
|
322
|
+
numberOfLines={1}
|
|
323
|
+
>
|
|
324
|
+
{profile.name}
|
|
325
|
+
</Text>
|
|
326
|
+
</View>
|
|
327
|
+
</TouchableRipple>
|
|
328
|
+
)
|
|
329
|
+
})}
|
|
230
330
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
331
|
+
{onManage && (
|
|
332
|
+
<>
|
|
333
|
+
<View style={[styles.divider, { backgroundColor: color }]} />
|
|
334
|
+
{/* Tertiary - a navigation action, not another selectable identity in the list above
|
|
335
|
+
it, so it reads more like @tastic/hud's SectionedDropdown All/Clear footer button
|
|
336
|
+
(centered, bold) than a normal row. The accent is on the icon only: a muted accent
|
|
337
|
+
label on the menu's black read dim, almost disabled (2026-10-16). */}
|
|
338
|
+
<TouchableRipple onPress={handleManage} style={styles.row}>
|
|
339
|
+
<View style={styles.manageRowInner}>
|
|
340
|
+
<Icon source='account-cog-outline' size={16} color={color} />
|
|
341
|
+
<Text style={[styles.manageLabel, { color: fg }]}>Manage</Text>
|
|
342
|
+
</View>
|
|
343
|
+
</TouchableRipple>
|
|
344
|
+
</>
|
|
345
|
+
)}
|
|
346
|
+
</>
|
|
347
|
+
)}
|
|
348
|
+
</FadingScrollView>
|
|
349
|
+
</View>
|
|
246
350
|
</PopoverBody>
|
|
247
351
|
</View>
|
|
248
352
|
)
|
|
249
353
|
}
|
|
250
354
|
|
|
355
|
+
// The color section's heading, caption and swatch grid, then a divider before the profile rows.
|
|
356
|
+
// The list page's top row: a swatch of the color in effect, the label (and caption), and a chevron
|
|
357
|
+
// to the color page. A divider sets it apart from the selectable rows under it.
|
|
358
|
+
function ColorRow({ section, fg, dividerColor, onPress }: { section: ProfilePickerColorSection; fg: string; dividerColor: string; onPress: () => void }) {
|
|
359
|
+
const { value, previewValue, label = 'Color', caption } = section
|
|
360
|
+
const drawn = previewValue ? previewValue(value) : value
|
|
361
|
+
return (
|
|
362
|
+
<>
|
|
363
|
+
<TouchableRipple onPress={onPress} style={[styles.row, { height: colorRowHeight(caption) }]} accessibilityRole='button' accessibilityLabel={caption ? `${label}, ${caption}` : label}>
|
|
364
|
+
<View style={styles.rowInner}>
|
|
365
|
+
<View style={[styles.colorRowSwatch, { backgroundColor: drawn }]} />
|
|
366
|
+
<View style={styles.colorRowText}>
|
|
367
|
+
<Text style={[styles.colorRowLabel, { color: fg }]} numberOfLines={1}>
|
|
368
|
+
{label}
|
|
369
|
+
</Text>
|
|
370
|
+
{caption ? (
|
|
371
|
+
<Text style={[styles.colorRowCaption, { color: fg }]} numberOfLines={1}>
|
|
372
|
+
{caption}
|
|
373
|
+
</Text>
|
|
374
|
+
) : null}
|
|
375
|
+
</View>
|
|
376
|
+
<Icon source='chevron-right' size={18} color={fg} />
|
|
377
|
+
</View>
|
|
378
|
+
</TouchableRipple>
|
|
379
|
+
<View style={[styles.divider, { backgroundColor: dividerColor }]} />
|
|
380
|
+
</>
|
|
381
|
+
)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// The color page: a back row to the list, then the swatches. `profileColor` is the selected
|
|
385
|
+
// profile's own color: its swatch carries a dot, so a player who has picked something else can find
|
|
386
|
+
// their way back to it (the profile's row shows that color, but matching it to one of two similar
|
|
387
|
+
// swatches by eye is guesswork). The check takes the dot's place on the swatch in effect, so the
|
|
388
|
+
// dot only shows while the color in effect is another one.
|
|
389
|
+
function ColorPage({ section, profileColor, fg, onBack }: { section: ProfilePickerColorSection; profileColor?: string; fg: string; onBack: () => void }) {
|
|
390
|
+
const { value, onChange, previewValue, swatches = defaultColors, label = 'Color' } = section
|
|
391
|
+
const displayFor = previewValue ?? ((hex: string) => hex)
|
|
392
|
+
return (
|
|
393
|
+
<>
|
|
394
|
+
<TouchableRipple onPress={onBack} style={styles.row} accessibilityRole='button' accessibilityLabel='Back'>
|
|
395
|
+
<View style={styles.rowInner}>
|
|
396
|
+
<View style={styles.guestChip}>
|
|
397
|
+
<Icon source='chevron-left' size={20} color={fg} />
|
|
398
|
+
</View>
|
|
399
|
+
<Text style={[styles.colorRowLabel, { color: fg }]} numberOfLines={1}>
|
|
400
|
+
{label}
|
|
401
|
+
</Text>
|
|
402
|
+
</View>
|
|
403
|
+
</TouchableRipple>
|
|
404
|
+
<View style={styles.swatches}>
|
|
405
|
+
{swatches.map((swatch) => {
|
|
406
|
+
const selected = swatch.value.toLowerCase() === value.toLowerCase()
|
|
407
|
+
const isProfileColor = !!profileColor && swatch.value.toLowerCase() === profileColor.toLowerCase()
|
|
408
|
+
const swatchDisplay = displayFor(swatch.value)
|
|
409
|
+
// The same swatch as @tastic/hud's InlineColorPicker: a filled circle, the selected one
|
|
410
|
+
// ringed and checked.
|
|
411
|
+
return (
|
|
412
|
+
<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 }}>
|
|
413
|
+
{selected ? <Icon source='check' size={16} color={getContrastColor(swatchDisplay)} /> : isProfileColor ? <View style={[styles.profileDot, { backgroundColor: getContrastColor(swatchDisplay) }]} /> : <View />}
|
|
414
|
+
</TouchableRipple>
|
|
415
|
+
)
|
|
416
|
+
})}
|
|
417
|
+
</View>
|
|
418
|
+
</>
|
|
419
|
+
)
|
|
420
|
+
}
|
|
421
|
+
|
|
251
422
|
const styles = StyleSheet.create({
|
|
252
423
|
anchor: {
|
|
253
424
|
position: 'relative'
|
|
@@ -257,6 +428,40 @@ const styles = StyleSheet.create({
|
|
|
257
428
|
anchorOpen: {
|
|
258
429
|
zIndex: 100
|
|
259
430
|
},
|
|
431
|
+
// The pill, rounded at both ends. The host adds its own background through `triggerStyle`.
|
|
432
|
+
chipTrigger: {
|
|
433
|
+
borderRadius: (CHIP_SIZE + 2 * CHIP_TRIGGER_PADDING) / 2
|
|
434
|
+
},
|
|
435
|
+
// The padding lives on the measured view, not the pressable around it, so the placement measures
|
|
436
|
+
// the pill's real edge: measured from inside the padding, a popover opening toward a screen edge
|
|
437
|
+
// was sized as if the pill were 6pt further from it, and ran 2pt past that edge.
|
|
438
|
+
chipTriggerInner: {
|
|
439
|
+
alignItems: 'center',
|
|
440
|
+
flexDirection: 'row',
|
|
441
|
+
gap: 8,
|
|
442
|
+
height: CHIP_SIZE + 2 * CHIP_TRIGGER_PADDING,
|
|
443
|
+
paddingLeft: CHIP_TRIGGER_PADDING,
|
|
444
|
+
paddingRight: CHIP_TRIGGER_PADDING + 8
|
|
445
|
+
},
|
|
446
|
+
colorRowCaption: {
|
|
447
|
+
fontSize: 12,
|
|
448
|
+
lineHeight: COLOR_CAPTION_LINE,
|
|
449
|
+
opacity: 0.6
|
|
450
|
+
},
|
|
451
|
+
// A row's label, with a line height so the captioned Color row's height is exact.
|
|
452
|
+
colorRowLabel: {
|
|
453
|
+
fontSize: 14,
|
|
454
|
+
lineHeight: COLOR_LABEL_LINE
|
|
455
|
+
},
|
|
456
|
+
// The color in effect, in the same 22pt slot as every row's chip.
|
|
457
|
+
colorRowSwatch: {
|
|
458
|
+
borderRadius: 11,
|
|
459
|
+
height: 22,
|
|
460
|
+
width: 22
|
|
461
|
+
},
|
|
462
|
+
colorRowText: {
|
|
463
|
+
flex: 1
|
|
464
|
+
},
|
|
260
465
|
// Separates Manage (a navigation action) from the selectable rows above it - different kind of
|
|
261
466
|
// action, not just another row in the same list. Tinted with the seat's own color (passed in as
|
|
262
467
|
// backgroundColor) rather than a flat gray, matching the border/caret's own accent-tinted look.
|
|
@@ -291,7 +496,7 @@ const styles = StyleSheet.create({
|
|
|
291
496
|
},
|
|
292
497
|
menu: {
|
|
293
498
|
borderRadius: 12,
|
|
294
|
-
borderWidth:
|
|
499
|
+
borderWidth: MENU_BORDER_WIDTH,
|
|
295
500
|
elevation: 8,
|
|
296
501
|
shadowColor: '#000000',
|
|
297
502
|
shadowOffset: { height: 2, width: 0 },
|
|
@@ -304,6 +509,12 @@ const styles = StyleSheet.create({
|
|
|
304
509
|
menuContent: {
|
|
305
510
|
padding: LIST_PADDING
|
|
306
511
|
},
|
|
512
|
+
// The selected profile's own color (see ColorSection).
|
|
513
|
+
profileDot: {
|
|
514
|
+
borderRadius: 4,
|
|
515
|
+
height: 8,
|
|
516
|
+
width: 8
|
|
517
|
+
},
|
|
307
518
|
row: {
|
|
308
519
|
borderRadius: 8,
|
|
309
520
|
height: ROW_HEIGHT
|
|
@@ -316,12 +527,33 @@ const styles = StyleSheet.create({
|
|
|
316
527
|
flexDirection: 'row',
|
|
317
528
|
flex: 1,
|
|
318
529
|
gap: 8,
|
|
319
|
-
paddingHorizontal:
|
|
530
|
+
paddingHorizontal: ROW_INSET
|
|
320
531
|
},
|
|
321
532
|
rowLabel: {
|
|
322
533
|
flex: 1,
|
|
323
534
|
fontSize: 14
|
|
324
535
|
},
|
|
536
|
+
swatch: {
|
|
537
|
+
alignItems: 'center',
|
|
538
|
+
borderRadius: SWATCH_SIZE / 2,
|
|
539
|
+
height: SWATCH_SIZE,
|
|
540
|
+
justifyContent: 'center',
|
|
541
|
+
width: SWATCH_SIZE
|
|
542
|
+
},
|
|
543
|
+
swatchSelected: {
|
|
544
|
+
borderColor: '#ffffff',
|
|
545
|
+
borderWidth: 2
|
|
546
|
+
},
|
|
547
|
+
swatches: {
|
|
548
|
+
columnGap: GRID_GAP,
|
|
549
|
+
flexDirection: 'row',
|
|
550
|
+
flexWrap: 'wrap',
|
|
551
|
+
marginBottom: GRID_BOTTOM,
|
|
552
|
+
marginHorizontal: ROW_INSET,
|
|
553
|
+
marginTop: GRID_TOP,
|
|
554
|
+
rowGap: GRID_GAP,
|
|
555
|
+
width: GRID_WIDTH
|
|
556
|
+
},
|
|
325
557
|
trigger: {
|
|
326
558
|
alignSelf: 'flex-start'
|
|
327
559
|
},
|