@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tastic/profile",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Player profile identity for local-multiplayer React Native games: a saved name/color/tag a player selects per seat, with a picker, an inline-editable roster screen, and a reusable identity badge, deliberately blind to any app-specific fields (control scheme, stats) an app layers on top",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react-native",
|
|
@@ -63,15 +63,15 @@
|
|
|
63
63
|
"prettier": "@infinitetoken/eslint-config/prettier",
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@infinitetoken/eslint-config": "0.2.0",
|
|
66
|
-
"@infinitetoken/jest-config": "^0.
|
|
67
|
-
"@infinitetoken/tsconfig": "^0.
|
|
68
|
-
"@rific/auto-paper": "^0.13.
|
|
66
|
+
"@infinitetoken/jest-config": "^0.5.2",
|
|
67
|
+
"@infinitetoken/tsconfig": "^0.6.0",
|
|
68
|
+
"@rific/auto-paper": "^0.13.3",
|
|
69
69
|
"@rific/core": "^0.3.1",
|
|
70
70
|
"@rific/feedback-press": "^0.13.0",
|
|
71
71
|
"@rific/focus-chain": "^0.4.8",
|
|
72
|
-
"@rific/toaster": "^0.8.
|
|
72
|
+
"@rific/toaster": "^0.8.2",
|
|
73
73
|
"@tastic/core": "^0.14.0",
|
|
74
|
-
"@tastic/hud": "^0.
|
|
74
|
+
"@tastic/hud": "^0.23.0",
|
|
75
75
|
"@testing-library/dom": "^10.4.1",
|
|
76
76
|
"@testing-library/react": "^16.3.2",
|
|
77
77
|
"@types/jest": "^30.0.0",
|
|
@@ -79,13 +79,13 @@
|
|
|
79
79
|
"eslint": "^9.39.4",
|
|
80
80
|
"eslint-plugin-react-hooks": "^7.1.1",
|
|
81
81
|
"eslint-plugin-react-native": "^5.0.0",
|
|
82
|
-
"expo-modules-core": "^
|
|
82
|
+
"expo-modules-core": "^58.0.22",
|
|
83
83
|
"jest": "^30.4.2",
|
|
84
84
|
"jest-environment-jsdom": "^30.4.1",
|
|
85
85
|
"prettier": "^3.8.3",
|
|
86
86
|
"react": "^19.0.0",
|
|
87
87
|
"react-dom": "^19.0.0",
|
|
88
|
-
"react-native": "^0.
|
|
88
|
+
"react-native": "^0.88.0",
|
|
89
89
|
"react-native-paper": "^5.15.2",
|
|
90
90
|
"react-native-safe-area-context": "^5.0.0",
|
|
91
91
|
"tsup": "^8.0.0",
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
"@rific/focus-chain": ">=0.4.0",
|
|
98
98
|
"@rific/toaster": ">=0.4.0",
|
|
99
99
|
"@tastic/core": ">=0.1.0",
|
|
100
|
-
"@tastic/hud": ">=0.
|
|
100
|
+
"@tastic/hud": ">=0.23.0",
|
|
101
101
|
"expo-modules-core": ">=1.0.0",
|
|
102
102
|
"react": ">=19.0.0",
|
|
103
103
|
"react-native": ">=0.76.0",
|
package/src/IdentityChip.tsx
CHANGED
|
@@ -2,6 +2,9 @@ import { getContrastColor } from '@rific/auto-paper'
|
|
|
2
2
|
import { StyleSheet, View } from 'react-native'
|
|
3
3
|
import { Icon, Text } from 'react-native-paper'
|
|
4
4
|
|
|
5
|
+
import { useProfileSwatchRenderer } from './ProfileColorContext'
|
|
6
|
+
import { SwatchLayer } from './SwatchLayer'
|
|
7
|
+
|
|
5
8
|
// Presence check, not isValidTag's own full-string emoji validation (profilesValidation.ts) - this
|
|
6
9
|
// only decides a font-size ratio, so it doesn't need that check's ZWJ/variation-selector precision,
|
|
7
10
|
// just "does this tag contain a pictograph at all."
|
|
@@ -21,8 +24,10 @@ interface Props {
|
|
|
21
24
|
|
|
22
25
|
// A filled circle with a tag (or an icon) in a contrast-safe color on top: the drawing behind
|
|
23
26
|
// 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).
|
|
27
|
+
// which may not be any profile's saved one). A ProfileColorProvider `renderSwatch` draws the fill in
|
|
28
|
+
// place of the flat color (a filled chip only); the tag's contrast still comes from `color`.
|
|
25
29
|
export function IdentityChip({ color, tag, icon, size, filled = true }: Props) {
|
|
30
|
+
const renderSwatch = useProfileSwatchRenderer()
|
|
26
31
|
const contrastColor = getContrastColor(color)
|
|
27
32
|
// An emoji glyph reads visually smaller than a bold letter at the same fontSize (the system emoji
|
|
28
33
|
// font leaves more of its own em-box empty), so a plain-text ratio that looks right for "J" still
|
|
@@ -34,7 +39,8 @@ export function IdentityChip({ color, tag, icon, size, filled = true }: Props) {
|
|
|
34
39
|
// them off there ("D..."); 0.32 fits them, with adjustsFontSizeToFit still the backstop on native.
|
|
35
40
|
const tagFontSize = size * (tag && EMOJI_PATTERN.test(tag) ? 0.6 : tag && Array.from(tag).length >= 3 ? 0.32 : 0.4)
|
|
36
41
|
return (
|
|
37
|
-
<View style={[styles.chip, { borderRadius: size / 2, height: size, width: size }, filled && { backgroundColor: color }]}>
|
|
42
|
+
<View style={[styles.chip, { borderRadius: size / 2, height: size, width: size }, filled && !renderSwatch && { backgroundColor: color }]}>
|
|
43
|
+
{filled && renderSwatch && <SwatchLayer render={renderSwatch} color={color} size={size} />}
|
|
38
44
|
{tag ? (
|
|
39
45
|
<Text style={[styles.tag, { color: contrastColor, fontSize: tagFontSize }]} numberOfLines={1} adjustsFontSizeToFit>
|
|
40
46
|
{tag}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { PopoverHost, PopoverRotation } from '@tastic/hud/chrome'
|
|
2
|
+
import { type StyleProp, type ViewStyle } from 'react-native'
|
|
3
|
+
|
|
4
|
+
import { IconRow, pickerContentSize, PickerMenu, type ProfilePickerAlignResult, type ProfilePickerColorSection, type ProfilePickerContentSize } from './PickerMenu'
|
|
5
|
+
|
|
6
|
+
export interface OptionPickerOption<T extends string> {
|
|
7
|
+
value: T
|
|
8
|
+
label: string
|
|
9
|
+
icon?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// The popover's content footprint: one row per option, plus (with a `colorSection`) the Color row,
|
|
13
|
+
// or the color page if that's taller - the same formula as getProfilePickerContentSize, for a host
|
|
14
|
+
// that places this picker itself through `alignOverride` (a @tastic/split-screen zone's
|
|
15
|
+
// useZoneClampedAlign, for one).
|
|
16
|
+
export function getOptionPickerContentSize(optionCount: number, colorSection?: Pick<ProfilePickerColorSection, 'swatches' | 'caption'>): ProfilePickerContentSize {
|
|
17
|
+
return pickerContentSize(optionCount, colorSection)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface OptionPickerProps<T extends string> {
|
|
21
|
+
// The popover id this trigger toggles on `host`.
|
|
22
|
+
id: string
|
|
23
|
+
host: PopoverHost
|
|
24
|
+
options: OptionPickerOption<T>[]
|
|
25
|
+
value: T
|
|
26
|
+
onChange: (value: T) => void
|
|
27
|
+
// The seat's live color: the chip, the menu's border and caret, and the selected row's fill.
|
|
28
|
+
color: string
|
|
29
|
+
dark: boolean
|
|
30
|
+
// The chip's glyph (e.g. a robot for a CPU seat).
|
|
31
|
+
icon: string
|
|
32
|
+
// An option without its own icon falls back to this one in its row. Defaults to `icon`.
|
|
33
|
+
rowIcon?: string
|
|
34
|
+
// The same Color row and page as ProfilePicker's `colorSection`.
|
|
35
|
+
colorSection?: ProfilePickerColorSection
|
|
36
|
+
// See ProfilePicker's own props of the same names.
|
|
37
|
+
align?: 'left' | 'right' | 'center'
|
|
38
|
+
alignOverride?: ProfilePickerAlignResult
|
|
39
|
+
rotation?: PopoverRotation
|
|
40
|
+
triggerStyle?: StyleProp<ViewStyle>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ProfilePicker's chip button and dropdown for a seat with no profile: a list of options (a CPU
|
|
44
|
+
// seat's difficulties) under the same Color row, so a CPU seat is the same one control as a
|
|
45
|
+
// player's. The button leads with a chip in `color` with `icon`, then the selected option's label
|
|
46
|
+
// and a chevron. Picking an option closes the dropdown; the Color row works exactly as it does in
|
|
47
|
+
// ProfilePicker (picking a swatch stays on the color page).
|
|
48
|
+
export function OptionPicker<T extends string>({ id, host, options, value, onChange, color, dark, icon, rowIcon = icon, colorSection, align, alignOverride, rotation = 0, triggerStyle }: OptionPickerProps<T>) {
|
|
49
|
+
const fg = dark ? '#FFFFFF' : '#000000'
|
|
50
|
+
const selected = options.find((option) => option.value === value)
|
|
51
|
+
|
|
52
|
+
const handleSelect = (option: OptionPickerOption<T>) => {
|
|
53
|
+
onChange(option.value)
|
|
54
|
+
host.close()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<PickerMenu id={id} host={host} color={color} dark={dark} label={selected?.label ?? ''} chip={{ icon }} colorSection={colorSection} contentSize={getOptionPickerContentSize(options.length, colorSection)} align={align} alignOverride={alignOverride} rotation={rotation} triggerStyle={triggerStyle}>
|
|
59
|
+
{options.map((option) => (
|
|
60
|
+
<IconRow key={option.value} icon={option.icon ?? rowIcon} label={option.label} selected={option.value === value} color={color} fg={fg} onPress={() => handleSelect(option)} />
|
|
61
|
+
))}
|
|
62
|
+
</PickerMenu>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
import { defaultColors, FadingScrollView, getContrastColor, type SeedColor } from '@rific/auto-paper'
|
|
2
|
+
import { TouchableRipple } from '@rific/feedback-press'
|
|
3
|
+
import type { PopoverHost, PopoverRotation } from '@tastic/hud/chrome'
|
|
4
|
+
import { PopoverBody, useAutoAlign } from '@tastic/hud/chrome'
|
|
5
|
+
import { ReactNode, RefObject, useState } from 'react'
|
|
6
|
+
import { type LayoutChangeEvent, type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native'
|
|
7
|
+
import { Icon, Text } from 'react-native-paper'
|
|
8
|
+
|
|
9
|
+
import { IdentityChip } from './IdentityChip'
|
|
10
|
+
import { useProfileSwatchRenderer } from './ProfileColorContext'
|
|
11
|
+
import { SwatchLayer } from './SwatchLayer'
|
|
12
|
+
|
|
13
|
+
// The trigger and popover ProfilePicker and OptionPicker share: a name trigger (or the chip pill),
|
|
14
|
+
// a bordered menu of rows, and with a `colorSection` a Color row that swaps the menu for a page of
|
|
15
|
+
// swatches. Not exported from index.ts: each picker owns its own rows.
|
|
16
|
+
|
|
17
|
+
const TRIGGER_HEIGHT = 28
|
|
18
|
+
const POPOVER_WIDTH = 220
|
|
19
|
+
const ROW_HEIGHT = 36
|
|
20
|
+
const LIST_PADDING = 8
|
|
21
|
+
const MENU_BORDER_WIDTH = 2
|
|
22
|
+
// A row's own inset from the list's padded edge (`rowInner`'s paddingHorizontal); the color page
|
|
23
|
+
// lines its swatches up with it.
|
|
24
|
+
const ROW_INSET = 12
|
|
25
|
+
// The chip trigger (see `colorSection`): a CHIP_SIZE circle inside a pill padded by CHIP_TRIGGER_PADDING.
|
|
26
|
+
const CHIP_SIZE = 32
|
|
27
|
+
const CHIP_TRIGGER_PADDING = 6
|
|
28
|
+
|
|
29
|
+
// The color row and page (see `colorSection`). Every height here is fixed (explicit line heights),
|
|
30
|
+
// so pickerContentSize can size them exactly. Swatches are the same 28pt circles as
|
|
31
|
+
// @tastic/hud's InlineColorPicker, spread to span exactly the rows' content width (5 across, so
|
|
32
|
+
// 20 colors fill 4 full rows), with the same spacing between rows.
|
|
33
|
+
const SWATCH_SIZE = 28
|
|
34
|
+
const SWATCH_MIN_GAP = 6
|
|
35
|
+
// The selected swatch's white ring, drawn inside its SWATCH_SIZE.
|
|
36
|
+
const SWATCH_RING_WIDTH = 2
|
|
37
|
+
// The Color row's swatch of the color in effect, in the same 22pt slot as every row's chip.
|
|
38
|
+
const COLOR_ROW_SWATCH_SIZE = 22
|
|
39
|
+
// The Color row: a plain row's height, or taller with a caption line under the label.
|
|
40
|
+
const COLOR_ROW_CAPTIONED_HEIGHT = 48
|
|
41
|
+
const COLOR_LABEL_LINE = 18
|
|
42
|
+
const COLOR_CAPTION_LINE = 16
|
|
43
|
+
// The divider under the Color row: 1pt plus 4pt above and below, the same as the Manage divider.
|
|
44
|
+
const COLOR_ROW_DIVIDER = 9
|
|
45
|
+
// The color page: a back row, then the grid with this much space above and below it.
|
|
46
|
+
const GRID_TOP = 4
|
|
47
|
+
const GRID_BOTTOM = 8
|
|
48
|
+
const GRID_WIDTH = POPOVER_WIDTH - 2 * MENU_BORDER_WIDTH - 2 * LIST_PADDING - 2 * ROW_INSET
|
|
49
|
+
const GRID_COLUMNS = Math.floor((GRID_WIDTH + SWATCH_MIN_GAP) / (SWATCH_SIZE + SWATCH_MIN_GAP))
|
|
50
|
+
const GRID_GAP = (GRID_WIDTH - GRID_COLUMNS * SWATCH_SIZE) / (GRID_COLUMNS - 1)
|
|
51
|
+
|
|
52
|
+
// A color choice behind a row at the top of the popover (see ProfilePicker's `colorSection`).
|
|
53
|
+
export interface ProfilePickerColorSection {
|
|
54
|
+
// The raw seed color in effect - matched against each swatch's own value for the checkmark.
|
|
55
|
+
value: string
|
|
56
|
+
onChange: (hex: string) => void
|
|
57
|
+
// Transforms only what's drawn (each swatch's fill), leaving `value` and what `onChange` commits
|
|
58
|
+
// untouched - the same contract as @tastic/hud's InlineColorPicker `previewValue`, for a host
|
|
59
|
+
// that mutes or tints the seed before using it.
|
|
60
|
+
previewValue?: (hex: string) => string
|
|
61
|
+
swatches?: SeedColor[]
|
|
62
|
+
// The row's label, and the color page's heading. Defaults to 'Color'.
|
|
63
|
+
label?: string
|
|
64
|
+
// One short line under the row's label, e.g. where the color applies.
|
|
65
|
+
caption?: string
|
|
66
|
+
// Another seat's color, if any: its swatch stays in the grid but is disabled and marked with an
|
|
67
|
+
// X, the same as @tastic/hud's InlineColorPicker `takenValue`. Unless `allowSwapTaken` is set.
|
|
68
|
+
takenValue?: string
|
|
69
|
+
// Lets a press on the taken swatch pick it anyway (marked with a swap icon instead), for a host
|
|
70
|
+
// that swaps the two colors - when the other seat is the CPU, not a second person. Same as
|
|
71
|
+
// InlineColorPicker's `allowSwapTaken`.
|
|
72
|
+
allowSwapTaken?: boolean
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// A complete placement decision for the popover: which way it opens, how tall it may grow, whether
|
|
76
|
+
// this open's measurement has landed yet, and the ref attached to the measured trigger.
|
|
77
|
+
// Structurally identical to @tastic/hud's own `AlignResult` (exported from hud >= 0.12.0, and the
|
|
78
|
+
// same shape `useAutoAlign` itself returns) - declared here rather than imported so this package's
|
|
79
|
+
// public types don't start requiring a newer @tastic/hud than its peer range already admits. A
|
|
80
|
+
// hud `AlignResult` (e.g. from `useZoneClampedAlign`) is assignable to this as-is.
|
|
81
|
+
export interface ProfilePickerAlignResult {
|
|
82
|
+
align: 'left' | 'right' | 'center'
|
|
83
|
+
verticalAlign: 'below' | 'above'
|
|
84
|
+
maxHeight: number
|
|
85
|
+
measured: boolean
|
|
86
|
+
triggerRef: RefObject<View | null>
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface ProfilePickerContentSize {
|
|
90
|
+
width: number
|
|
91
|
+
height: number
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function colorRowHeight(caption: string | undefined) {
|
|
95
|
+
return caption ? COLOR_ROW_CAPTIONED_HEIGHT : ROW_HEIGHT
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The color page's content: the back row and the grid.
|
|
99
|
+
function colorPageHeight(swatches: SeedColor[]) {
|
|
100
|
+
const rows = Math.ceil(swatches.length / GRID_COLUMNS)
|
|
101
|
+
return ROW_HEIGHT + GRID_TOP + rows * SWATCH_SIZE + (rows - 1) * GRID_GAP + GRID_BOTTOM
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The popover's content footprint for `rowCount` rows: always POPOVER_WIDTH wide, tall enough for
|
|
105
|
+
// the rows plus the list's own padding. With a `colorSection`, it's the taller of the popover's two
|
|
106
|
+
// pages, exactly: the list with the Color row and its divider on top, or the color page (a back row
|
|
107
|
+
// and the swatches), so one placement fits whichever page is showing. It leaves out the menu's 2pt
|
|
108
|
+
// border and the Manage divider, ~13pt in all, so treat it as a placement estimate, not a
|
|
109
|
+
// pixel-exact measurement. getProfilePickerContentSize and getOptionPickerContentSize count the
|
|
110
|
+
// rows for their own picker.
|
|
111
|
+
export function pickerContentSize(rowCount: number, colorSection?: Pick<ProfilePickerColorSection, 'swatches' | 'caption'>): ProfilePickerContentSize {
|
|
112
|
+
const list = rowCount * ROW_HEIGHT + (colorSection ? colorRowHeight(colorSection.caption) + COLOR_ROW_DIVIDER : 0)
|
|
113
|
+
const content = colorSection ? Math.max(list, colorPageHeight(colorSection.swatches ?? defaultColors)) : list
|
|
114
|
+
return { width: POPOVER_WIDTH, height: LIST_PADDING * 2 + content }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface PickerMenuProps {
|
|
118
|
+
// The popover id this trigger toggles on `host`.
|
|
119
|
+
id: string
|
|
120
|
+
host: PopoverHost
|
|
121
|
+
// The seat/context's live color: the menu's border and caret, the plain trigger's label, the chip.
|
|
122
|
+
color: string
|
|
123
|
+
dark: boolean
|
|
124
|
+
// The trigger's text, uppercased here.
|
|
125
|
+
label: string
|
|
126
|
+
// Turns the trigger into the chip pill: a circle in `color` with `tag` (or `icon`), then the label
|
|
127
|
+
// and the chevron. Omit for the plain name trigger.
|
|
128
|
+
chip?: { tag?: string; icon: string }
|
|
129
|
+
colorSection?: ProfilePickerColorSection
|
|
130
|
+
// The color whose swatch carries a dot on the color page (see ColorPage).
|
|
131
|
+
homeColor?: string
|
|
132
|
+
// The list page's size, from pickerContentSize, for the automatic placement.
|
|
133
|
+
contentSize: ProfilePickerContentSize
|
|
134
|
+
align?: 'left' | 'right' | 'center'
|
|
135
|
+
alignOverride?: ProfilePickerAlignResult
|
|
136
|
+
rotation: PopoverRotation
|
|
137
|
+
triggerStyle?: StyleProp<ViewStyle>
|
|
138
|
+
// Pinned under the list page's rows, behind a divider (ProfilePicker's Manage row).
|
|
139
|
+
footer?: ReactNode
|
|
140
|
+
// The list page's rows.
|
|
141
|
+
children: ReactNode
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function PickerMenu({ id, host, color, dark, label, chip, colorSection, homeColor, contentSize, align: forcedAlign, alignOverride, rotation, triggerStyle, footer, children }: PickerMenuProps) {
|
|
145
|
+
const menuBg = dark ? '#000000' : '#FFFFFF'
|
|
146
|
+
const fg = dark ? '#FFFFFF' : '#000000'
|
|
147
|
+
|
|
148
|
+
const open = host.openId === id
|
|
149
|
+
// Always called, even when `alignOverride` is supplied and this result goes unused - hooks can't
|
|
150
|
+
// be conditional (see @tastic/hud's InlineColorPicker for the identical pattern). With an
|
|
151
|
+
// override its triggerRef is never attached, so this hook's own measurement is a no-op.
|
|
152
|
+
const auto = useAutoAlign(open, contentSize.width, contentSize.height, rotation)
|
|
153
|
+
const placement = alignOverride ?? auto
|
|
154
|
+
const { maxHeight, measured, triggerRef, verticalAlign } = placement
|
|
155
|
+
const align = forcedAlign ?? placement.align
|
|
156
|
+
|
|
157
|
+
// The chip trigger is a whole pill, so the caret points at its middle: PopoverBody places the
|
|
158
|
+
// caret half a `triggerSize` in from the side the popover is aligned to, so the pill's own width
|
|
159
|
+
// centers it whichever way the popover opens. The plain trigger keeps TRIGGER_HEIGHT, as before.
|
|
160
|
+
const chipTrigger = !!chip
|
|
161
|
+
const [triggerWidth, setTriggerWidth] = useState(0)
|
|
162
|
+
const handleAnchorLayout = chipTrigger ? (e: LayoutChangeEvent) => setTriggerWidth(e.nativeEvent.layout.width) : undefined
|
|
163
|
+
// Which page the popover shows. Reset on every press of the trigger, so an open always starts on
|
|
164
|
+
// the list (a press away closes the popover without coming back through here).
|
|
165
|
+
const [colorPage, setColorPage] = useState(false)
|
|
166
|
+
const showColorPage = !!colorSection && colorPage
|
|
167
|
+
const handleTriggerPress = () => {
|
|
168
|
+
setColorPage(false)
|
|
169
|
+
host.toggle(id)
|
|
170
|
+
}
|
|
171
|
+
const triggerLabel = label.toUpperCase()
|
|
172
|
+
// Shown on the list page only (see the footer below).
|
|
173
|
+
const showFooter = !!footer && !showColorPage
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<View style={[styles.anchor, open && styles.anchorOpen]} onLayout={handleAnchorLayout}>
|
|
177
|
+
<TouchableRipple onPress={handleTriggerPress} borderless={chipTrigger} style={[styles.trigger, chipTrigger && styles.chipTrigger, triggerStyle]} accessibilityRole='button' accessibilityLabel={triggerLabel}>
|
|
178
|
+
<View ref={triggerRef} collapsable={false} style={chipTrigger ? styles.chipTriggerInner : styles.triggerInner}>
|
|
179
|
+
{chip && <IdentityChip color={color} tag={chip.tag} icon={chip.icon} size={CHIP_SIZE} />}
|
|
180
|
+
{/* The chip trigger's name and chevron are plain fg: the chip carries the color, and a muted
|
|
181
|
+
color on the host's pill read dim. The plain trigger keeps the color, which is what tells a
|
|
182
|
+
multi-seat game's seats apart. */}
|
|
183
|
+
<Text style={[styles.triggerLabel, { color: chipTrigger ? fg : color }]} numberOfLines={1}>
|
|
184
|
+
{triggerLabel}
|
|
185
|
+
</Text>
|
|
186
|
+
<Icon source='menu-down' size={16} color={chipTrigger ? fg : color} />
|
|
187
|
+
</View>
|
|
188
|
+
</TouchableRipple>
|
|
189
|
+
|
|
190
|
+
{/* Gated on `measured`, not just `open` - see InlineColorPicker's identical fix. */}
|
|
191
|
+
<PopoverBody visible={open && measured} align={align} verticalAlign={verticalAlign} caretColor={menuBg} caretBorderColor={color} caretBorderWidth={MENU_BORDER_WIDTH} triggerSize={chipTrigger ? triggerWidth : TRIGGER_HEIGHT}>
|
|
192
|
+
{/* The box (background, border, max height) is a plain View around the scroller, so
|
|
193
|
+
FadingScrollView's edge fade - the same cue as a dialog's scrolling rows - masks only the
|
|
194
|
+
rows, never the menu's own border or background. */}
|
|
195
|
+
<View
|
|
196
|
+
style={[
|
|
197
|
+
styles.menu,
|
|
198
|
+
{
|
|
199
|
+
backgroundColor: menuBg,
|
|
200
|
+
borderColor: color,
|
|
201
|
+
width: POPOVER_WIDTH,
|
|
202
|
+
maxHeight
|
|
203
|
+
}
|
|
204
|
+
]}
|
|
205
|
+
>
|
|
206
|
+
{/* Keyed by page, so swapping pages starts the new one scrolled to its top. */}
|
|
207
|
+
<FadingScrollView key={showColorPage ? 'color' : 'list'} contentContainerStyle={[styles.menuContent, showFooter && styles.menuContentAboveFooter]} showsVerticalScrollIndicator={false}>
|
|
208
|
+
{colorSection && colorPage ? (
|
|
209
|
+
<ColorPage section={colorSection} homeColor={homeColor} fg={fg} onBack={() => setColorPage(false)} />
|
|
210
|
+
) : (
|
|
211
|
+
<>
|
|
212
|
+
{colorSection && <ColorRow section={colorSection} fg={fg} dividerColor={color} onPress={() => setColorPage(true)} />}
|
|
213
|
+
{children}
|
|
214
|
+
</>
|
|
215
|
+
)}
|
|
216
|
+
</FadingScrollView>
|
|
217
|
+
{/* The footer is pinned below the scrolling rows, the way a dialog's Done sits below its
|
|
218
|
+
rows: always reachable, with the rows fading out above its divider while there are more
|
|
219
|
+
below. Not on the color page, which only has the back row and the swatches. */}
|
|
220
|
+
{showFooter && (
|
|
221
|
+
<View style={styles.menuFooter}>
|
|
222
|
+
<View style={[styles.divider, { backgroundColor: color }]} />
|
|
223
|
+
{footer}
|
|
224
|
+
</View>
|
|
225
|
+
)}
|
|
226
|
+
</View>
|
|
227
|
+
</PopoverBody>
|
|
228
|
+
</View>
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
interface IconRowProps {
|
|
233
|
+
icon: string
|
|
234
|
+
label: string
|
|
235
|
+
// Fills the row with `color` (its icon and label then contrast against it).
|
|
236
|
+
selected: boolean
|
|
237
|
+
color: string
|
|
238
|
+
fg: string
|
|
239
|
+
disabled?: boolean
|
|
240
|
+
onPress: () => void
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// A selectable row led by an icon: ProfilePicker's null/"guest" row, and every OptionPicker row.
|
|
244
|
+
// A selected row's own background fills entirely with its identity color - matching how a selected
|
|
245
|
+
// row reads elsewhere (see @tastic/hud's SectionedDropdown) rather than a checkmark bolted onto an
|
|
246
|
+
// otherwise-plain row.
|
|
247
|
+
export function IconRow({ icon, label, selected, color, fg, disabled = false, onPress }: IconRowProps) {
|
|
248
|
+
const textColor = selected ? getContrastColor(color) : fg
|
|
249
|
+
return (
|
|
250
|
+
<TouchableRipple disabled={disabled} onPress={onPress} style={[pickerMenuStyles.row, selected && { backgroundColor: color }]}>
|
|
251
|
+
<View style={[pickerMenuStyles.rowInner, disabled && pickerMenuStyles.rowDisabled]}>
|
|
252
|
+
{/* Unfilled, matching a selected profile row's own ProfileChip treatment - no background
|
|
253
|
+
circle, but still the same fixed-size slot every row's icon/chip sits in (see ProfileChip's
|
|
254
|
+
own doc), so this row's icon stays lined up with every other row regardless of selection. */}
|
|
255
|
+
<View style={styles.iconSlot}>
|
|
256
|
+
<Icon source={icon} size={13} color={textColor} />
|
|
257
|
+
</View>
|
|
258
|
+
<Text style={[pickerMenuStyles.rowLabel, { color: textColor }]} numberOfLines={1}>
|
|
259
|
+
{label}
|
|
260
|
+
</Text>
|
|
261
|
+
</View>
|
|
262
|
+
</TouchableRipple>
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// The list page's top row: a swatch of the color in effect, the label (and caption), and a chevron
|
|
267
|
+
// to the color page. A divider sets it apart from the selectable rows under it.
|
|
268
|
+
function ColorRow({ section, fg, dividerColor, onPress }: { section: ProfilePickerColorSection; fg: string; dividerColor: string; onPress: () => void }) {
|
|
269
|
+
const { value, previewValue, label = 'Color', caption } = section
|
|
270
|
+
const renderSwatch = useProfileSwatchRenderer()
|
|
271
|
+
const drawn = previewValue ? previewValue(value) : value
|
|
272
|
+
return (
|
|
273
|
+
<>
|
|
274
|
+
<TouchableRipple onPress={onPress} style={[pickerMenuStyles.row, { height: colorRowHeight(caption) }]} accessibilityRole='button' accessibilityLabel={caption ? `${label}, ${caption}` : label}>
|
|
275
|
+
<View style={pickerMenuStyles.rowInner}>
|
|
276
|
+
{/* A ProfileColorProvider renderSwatch draws it in place of the flat fill. */}
|
|
277
|
+
<View style={[styles.colorRowSwatch, !renderSwatch && { backgroundColor: drawn }]}>{renderSwatch && <SwatchLayer render={renderSwatch} color={drawn} size={COLOR_ROW_SWATCH_SIZE} />}</View>
|
|
278
|
+
<View style={styles.colorRowText}>
|
|
279
|
+
<Text style={[styles.colorRowLabel, { color: fg }]} numberOfLines={1}>
|
|
280
|
+
{label}
|
|
281
|
+
</Text>
|
|
282
|
+
{caption ? (
|
|
283
|
+
<Text style={[styles.colorRowCaption, { color: fg }]} numberOfLines={1}>
|
|
284
|
+
{caption}
|
|
285
|
+
</Text>
|
|
286
|
+
) : null}
|
|
287
|
+
</View>
|
|
288
|
+
<Icon source='chevron-right' size={18} color={fg} />
|
|
289
|
+
</View>
|
|
290
|
+
</TouchableRipple>
|
|
291
|
+
<View style={[styles.divider, { backgroundColor: dividerColor }]} />
|
|
292
|
+
</>
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// The color page: a back row to the list, then the swatches. `homeColor` is the selected profile's
|
|
297
|
+
// own color: its swatch carries a dot, so a player who has picked something else can find their way
|
|
298
|
+
// back to it (the profile's row shows that color, but matching it to one of two similar swatches by
|
|
299
|
+
// eye is guesswork). The check takes the dot's place on the swatch in effect, so the dot only shows
|
|
300
|
+
// while the color in effect is another one. Another seat's color (`takenValue`) is marked with an X
|
|
301
|
+
// and disabled, or a swap icon when `allowSwapTaken` lets it be picked. A ProfileColorProvider
|
|
302
|
+
// `renderSwatch` draws each swatch in place of its flat fill (inside the selected one's ring), with
|
|
303
|
+
// the mark's contrast still taken from the swatch's own color.
|
|
304
|
+
function ColorPage({ section, homeColor, fg, onBack }: { section: ProfilePickerColorSection; homeColor?: string; fg: string; onBack: () => void }) {
|
|
305
|
+
const { value, onChange, previewValue, swatches = defaultColors, label = 'Color', takenValue, allowSwapTaken } = section
|
|
306
|
+
const renderSwatch = useProfileSwatchRenderer()
|
|
307
|
+
const displayFor = previewValue ?? ((hex: string) => hex)
|
|
308
|
+
return (
|
|
309
|
+
<>
|
|
310
|
+
<TouchableRipple onPress={onBack} style={pickerMenuStyles.row} accessibilityRole='button' accessibilityLabel='Back'>
|
|
311
|
+
<View style={pickerMenuStyles.rowInner}>
|
|
312
|
+
<View style={styles.iconSlot}>
|
|
313
|
+
<Icon source='chevron-left' size={20} color={fg} />
|
|
314
|
+
</View>
|
|
315
|
+
<Text style={[styles.colorRowLabel, { color: fg }]} numberOfLines={1}>
|
|
316
|
+
{label}
|
|
317
|
+
</Text>
|
|
318
|
+
</View>
|
|
319
|
+
</TouchableRipple>
|
|
320
|
+
<View style={styles.swatches}>
|
|
321
|
+
{swatches.map((swatch) => {
|
|
322
|
+
const selected = swatch.value.toLowerCase() === value.toLowerCase()
|
|
323
|
+
const isHomeColor = !!homeColor && swatch.value.toLowerCase() === homeColor.toLowerCase()
|
|
324
|
+
const taken = !selected && !!takenValue && swatch.value.toLowerCase() === takenValue.toLowerCase()
|
|
325
|
+
const swappable = taken && !!allowSwapTaken
|
|
326
|
+
const swatchDisplay = displayFor(swatch.value)
|
|
327
|
+
const contrast = getContrastColor(swatchDisplay)
|
|
328
|
+
const accessibilityLabel = swappable ? `${swatch.label}, swap` : taken ? `${swatch.label}, taken` : isHomeColor ? `${swatch.label}, profile color` : swatch.label
|
|
329
|
+
const mark = selected ? <Icon source='check' size={16} color={contrast} /> : swappable ? <Icon source='swap-horizontal' size={16} color={contrast} /> : taken ? <Icon source='close' size={16} color={contrast} /> : isHomeColor ? <View style={[styles.homeDot, { backgroundColor: contrast }]} /> : <View />
|
|
330
|
+
// The same swatch as @tastic/hud's InlineColorPicker: a filled circle, the selected one
|
|
331
|
+
// ringed and checked, a taken one faded with an X. TouchableRipple takes one child, so a
|
|
332
|
+
// drawn swatch and its mark share a face that fills the circle inside the ring.
|
|
333
|
+
return (
|
|
334
|
+
<TouchableRipple key={swatch.value} disabled={taken && !swappable} onPress={() => onChange(swatch.value)} borderless style={[styles.swatch, !renderSwatch && { backgroundColor: swatchDisplay }, selected && styles.swatchSelected, taken && !swappable && styles.swatchTaken]} accessibilityRole='button' accessibilityLabel={accessibilityLabel} accessibilityState={{ selected, disabled: taken && !swappable }}>
|
|
335
|
+
{renderSwatch ? (
|
|
336
|
+
<View style={styles.swatchFace}>
|
|
337
|
+
<SwatchLayer render={renderSwatch} color={swatchDisplay} size={selected ? SWATCH_SIZE - 2 * SWATCH_RING_WIDTH : SWATCH_SIZE} />
|
|
338
|
+
{mark}
|
|
339
|
+
</View>
|
|
340
|
+
) : (
|
|
341
|
+
mark
|
|
342
|
+
)}
|
|
343
|
+
</TouchableRipple>
|
|
344
|
+
)
|
|
345
|
+
})}
|
|
346
|
+
</View>
|
|
347
|
+
</>
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// The rows' own styles, shared with ProfilePicker's profile rows.
|
|
352
|
+
export const pickerMenuStyles = StyleSheet.create({
|
|
353
|
+
row: {
|
|
354
|
+
borderRadius: 8,
|
|
355
|
+
height: ROW_HEIGHT
|
|
356
|
+
},
|
|
357
|
+
rowDisabled: {
|
|
358
|
+
opacity: 0.35
|
|
359
|
+
},
|
|
360
|
+
rowInner: {
|
|
361
|
+
alignItems: 'center',
|
|
362
|
+
flexDirection: 'row',
|
|
363
|
+
flex: 1,
|
|
364
|
+
gap: 8,
|
|
365
|
+
paddingHorizontal: ROW_INSET
|
|
366
|
+
},
|
|
367
|
+
rowLabel: {
|
|
368
|
+
flex: 1,
|
|
369
|
+
fontSize: 14
|
|
370
|
+
}
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
const styles = StyleSheet.create({
|
|
374
|
+
anchor: {
|
|
375
|
+
position: 'relative'
|
|
376
|
+
},
|
|
377
|
+
// See InlineColorPicker's identical comment - React Native Web gives every position:'relative'
|
|
378
|
+
// view its own stacking context, so the elevation has to live on the anchor itself.
|
|
379
|
+
anchorOpen: {
|
|
380
|
+
zIndex: 100
|
|
381
|
+
},
|
|
382
|
+
// The pill, rounded at both ends. The host adds its own background through `triggerStyle`.
|
|
383
|
+
chipTrigger: {
|
|
384
|
+
borderRadius: (CHIP_SIZE + 2 * CHIP_TRIGGER_PADDING) / 2
|
|
385
|
+
},
|
|
386
|
+
// The padding lives on the measured view, not the pressable around it, so the placement measures
|
|
387
|
+
// the pill's real edge: measured from inside the padding, a popover opening toward a screen edge
|
|
388
|
+
// was sized as if the pill were 6pt further from it, and ran 2pt past that edge.
|
|
389
|
+
chipTriggerInner: {
|
|
390
|
+
alignItems: 'center',
|
|
391
|
+
flexDirection: 'row',
|
|
392
|
+
gap: 8,
|
|
393
|
+
height: CHIP_SIZE + 2 * CHIP_TRIGGER_PADDING,
|
|
394
|
+
paddingLeft: CHIP_TRIGGER_PADDING,
|
|
395
|
+
paddingRight: CHIP_TRIGGER_PADDING + 8
|
|
396
|
+
},
|
|
397
|
+
colorRowCaption: {
|
|
398
|
+
fontSize: 12,
|
|
399
|
+
lineHeight: COLOR_CAPTION_LINE,
|
|
400
|
+
opacity: 0.6
|
|
401
|
+
},
|
|
402
|
+
// A row's label, with a line height so the captioned Color row's height is exact.
|
|
403
|
+
colorRowLabel: {
|
|
404
|
+
fontSize: 14,
|
|
405
|
+
lineHeight: COLOR_LABEL_LINE
|
|
406
|
+
},
|
|
407
|
+
// The color in effect, in the same 22pt slot as every row's chip.
|
|
408
|
+
colorRowSwatch: {
|
|
409
|
+
borderRadius: COLOR_ROW_SWATCH_SIZE / 2,
|
|
410
|
+
height: COLOR_ROW_SWATCH_SIZE,
|
|
411
|
+
width: COLOR_ROW_SWATCH_SIZE
|
|
412
|
+
},
|
|
413
|
+
colorRowText: {
|
|
414
|
+
flex: 1
|
|
415
|
+
},
|
|
416
|
+
// Separates the footer (a navigation action) and the Color row from the selectable rows - a
|
|
417
|
+
// different kind of action, not just another row in the same list. Tinted with the seat's own color
|
|
418
|
+
// (passed in as backgroundColor) rather than a flat gray, matching the border/caret's own
|
|
419
|
+
// accent-tinted look.
|
|
420
|
+
divider: {
|
|
421
|
+
height: 1,
|
|
422
|
+
marginVertical: 4,
|
|
423
|
+
opacity: 0.25
|
|
424
|
+
},
|
|
425
|
+
// The selected profile's own color (see ColorPage).
|
|
426
|
+
homeDot: {
|
|
427
|
+
borderRadius: 4,
|
|
428
|
+
height: 8,
|
|
429
|
+
width: 8
|
|
430
|
+
},
|
|
431
|
+
// Matches ProfileChip's own default size/shape exactly (see IconRow's own render-site comment) -
|
|
432
|
+
// no backgroundColor, unlike ProfileChip's own default: an icon row has no identity color of its
|
|
433
|
+
// own to fill it with, and the render site colors the icon directly instead.
|
|
434
|
+
iconSlot: {
|
|
435
|
+
alignItems: 'center',
|
|
436
|
+
borderRadius: 11,
|
|
437
|
+
height: 22,
|
|
438
|
+
justifyContent: 'center',
|
|
439
|
+
width: 22
|
|
440
|
+
},
|
|
441
|
+
menu: {
|
|
442
|
+
borderRadius: 12,
|
|
443
|
+
borderWidth: MENU_BORDER_WIDTH,
|
|
444
|
+
elevation: 8,
|
|
445
|
+
shadowColor: '#000000',
|
|
446
|
+
shadowOffset: { height: 2, width: 0 },
|
|
447
|
+
shadowOpacity: 0.3,
|
|
448
|
+
shadowRadius: 8
|
|
449
|
+
},
|
|
450
|
+
// Padded on every side (not just top/bottom) - a selected row's own full-color background (see
|
|
451
|
+
// `row`'s borderRadius) needs real margin from the menu's own straight edges to read as a rounded
|
|
452
|
+
// pill instead of looking clipped against them.
|
|
453
|
+
menuContent: {
|
|
454
|
+
padding: LIST_PADDING
|
|
455
|
+
},
|
|
456
|
+
// With a footer pinned below, its own padding takes the bottom edge (same total height as before).
|
|
457
|
+
menuContentAboveFooter: {
|
|
458
|
+
paddingBottom: 0
|
|
459
|
+
},
|
|
460
|
+
menuFooter: {
|
|
461
|
+
paddingBottom: LIST_PADDING,
|
|
462
|
+
paddingHorizontal: LIST_PADDING
|
|
463
|
+
},
|
|
464
|
+
swatch: {
|
|
465
|
+
alignItems: 'center',
|
|
466
|
+
borderRadius: SWATCH_SIZE / 2,
|
|
467
|
+
height: SWATCH_SIZE,
|
|
468
|
+
justifyContent: 'center',
|
|
469
|
+
width: SWATCH_SIZE
|
|
470
|
+
},
|
|
471
|
+
// A drawn swatch (see ColorPage) and its mark: the whole circle inside the selected one's ring.
|
|
472
|
+
swatchFace: {
|
|
473
|
+
alignItems: 'center',
|
|
474
|
+
bottom: 0,
|
|
475
|
+
justifyContent: 'center',
|
|
476
|
+
left: 0,
|
|
477
|
+
position: 'absolute',
|
|
478
|
+
right: 0,
|
|
479
|
+
top: 0
|
|
480
|
+
},
|
|
481
|
+
swatchSelected: {
|
|
482
|
+
borderColor: '#ffffff',
|
|
483
|
+
borderWidth: SWATCH_RING_WIDTH
|
|
484
|
+
},
|
|
485
|
+
swatchTaken: {
|
|
486
|
+
opacity: 0.35
|
|
487
|
+
},
|
|
488
|
+
swatches: {
|
|
489
|
+
columnGap: GRID_GAP,
|
|
490
|
+
flexDirection: 'row',
|
|
491
|
+
flexWrap: 'wrap',
|
|
492
|
+
marginBottom: GRID_BOTTOM,
|
|
493
|
+
marginHorizontal: ROW_INSET,
|
|
494
|
+
marginTop: GRID_TOP,
|
|
495
|
+
rowGap: GRID_GAP,
|
|
496
|
+
width: GRID_WIDTH
|
|
497
|
+
},
|
|
498
|
+
trigger: {
|
|
499
|
+
alignSelf: 'flex-start'
|
|
500
|
+
},
|
|
501
|
+
triggerInner: {
|
|
502
|
+
alignItems: 'center',
|
|
503
|
+
flexDirection: 'row',
|
|
504
|
+
gap: 2,
|
|
505
|
+
height: TRIGGER_HEIGHT
|
|
506
|
+
},
|
|
507
|
+
triggerLabel: {
|
|
508
|
+
fontSize: 13,
|
|
509
|
+
fontWeight: '700',
|
|
510
|
+
letterSpacing: 1
|
|
511
|
+
}
|
|
512
|
+
})
|