@tastic/profile 0.14.0 → 0.15.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.
@@ -1,90 +1,16 @@
1
- import { defaultColors, FadingScrollView, getContrastColor, type SeedColor } from '@rific/auto-paper'
1
+ import { getContrastColor } from '@rific/auto-paper'
2
2
  import { TouchableRipple } from '@rific/feedback-press'
3
- import { PopoverBody, PopoverHost, PopoverRotation, useAutoAlign } from '@tastic/hud'
4
- import { RefObject, useMemo, useState } from 'react'
5
- import { type LayoutChangeEvent, type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native'
3
+ import { PopoverHost, PopoverRotation } from '@tastic/hud'
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 { IdentityChip } from './IdentityChip'
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
- const TRIGGER_HEIGHT = 28
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
- const rowCount = profileCount + 1 + (hasManageRow ? 1 : 0)
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> {
@@ -193,27 +116,18 @@ interface ProfilePickerProps<P extends Profile> {
193
116
  // it keeps this popover from needing its own destructive-delete confirmation. Fully prop-driven -
194
117
  // no internal profile-storage access of its own, so the host app stays the one place that reads
195
118
  // its own persistence hook.
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>) {
119
+ export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, selectedId, takenId, color, dark, align, alignOverride, rotation = 0, guestLabel, nullLabel = 'Player', nullIcon = 'account-off-outline', onSelect, onManage, colorSection, triggerStyle }: ProfilePickerProps<P>) {
197
120
  const id = `${idPrefix}-profile`
198
121
  // A selected row fills with the profile's drawn color, the same one its ProfileChip would show.
199
122
  const colorPreview = useProfileColorPreview()
200
- const menuBg = dark ? '#000000' : '#FFFFFF'
201
123
  const fg = dark ? '#FFFFFF' : '#000000'
202
124
 
203
- const open = host.openId === id
204
125
  const selectedProfile = profiles.find((p) => p.id === selectedId) ?? null
205
126
  // Alphabetical - this is the list that sees the most real use, so it's the one that benefits
206
127
  // most from staying scannable once there are more than a handful of saved profiles.
207
128
  const sortedProfiles = useMemo(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles])
208
129
  // Null row + each saved profile + (if passed) the manage row.
209
- const { width: contentWidth, height: contentHeight } = getProfilePickerContentSize(profiles.length, !!onManage, colorSection)
210
- // Always called, even when `alignOverride` is supplied and this result goes unused - hooks can't
211
- // be conditional (see @tastic/hud's InlineColorPicker for the identical pattern). With an
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
130
+ const contentSize = getProfilePickerContentSize(profiles.length, !!onManage, colorSection)
217
131
 
218
132
  const handleSelectGuest = () => {
219
133
  onSelect(null)
@@ -231,341 +145,68 @@ export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, sel
231
145
  onManage?.()
232
146
  }
233
147
 
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
-
249
- return (
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}
259
- </Text>
260
- <Icon source='menu-down' size={16} color={chipTrigger ? fg : color} />
261
- </View>
262
- </TouchableRipple>
263
-
264
- {/* Gated on `measured`, not just `open` - see InlineColorPicker's identical fix. */}
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
270
- style={[
271
- styles.menu,
272
- {
273
- backgroundColor: menuBg,
274
- borderColor: color,
275
- width: POPOVER_WIDTH,
276
- maxHeight
277
- }
278
- ]}
279
- >
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>
303
-
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
- })}
330
-
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>
350
- </PopoverBody>
351
- </View>
352
- )
353
- }
148
+ // Manage is pinned below the scrolling rows, the way a dialog's Done sits below its rows: always
149
+ // reachable, with the rows fading out above its divider while there are more below (2026-10-16;
150
+ // it used to be the list's last row and scrolled away with it). Tertiary - a navigation action,
151
+ // not another selectable identity in the list above it, so it reads more like @tastic/hud's
152
+ // SectionedDropdown All/Clear footer button (centered, bold) than a normal row. The accent is on
153
+ // the icon only: a muted accent label on the menu's black read dim, almost disabled.
154
+ const manageFooter = onManage ? (
155
+ <TouchableRipple onPress={handleManage} style={pickerMenuStyles.row}>
156
+ <View style={styles.manageRowInner}>
157
+ <Icon source='account-cog-outline' size={16} color={color} />
158
+ <Text style={[styles.manageLabel, { color: fg }]}>Manage</Text>
159
+ </View>
160
+ </TouchableRipple>
161
+ ) : undefined
354
162
 
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
163
  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}
164
+ <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}>
165
+ <IconRow icon={nullIcon} label={nullLabel} selected={selectedId === null} color={color} fg={fg} onPress={handleSelectGuest} />
166
+
167
+ {sortedProfiles.map((profile) => {
168
+ const taken = profile.id === takenId
169
+ const selected = selectedId === profile.id
170
+ const rowColor = colorPreview ? colorPreview(profile.color) : profile.color
171
+ return (
172
+ <TouchableRipple key={profile.id} disabled={taken} onPress={() => handleSelectExisting(profile)} style={[pickerMenuStyles.row, selected && { backgroundColor: rowColor }]}>
173
+ <View style={[pickerMenuStyles.rowInner, taken && pickerMenuStyles.rowDisabled]}>
174
+ {/* Unfilled on a selected row - its own background already fills with the
175
+ profile's color, so a second circle behind the tag would just be a redundant badge
176
+ where every other selected row in the app has plain icon+text instead. */}
177
+ <ProfileChip profile={profile} filled={!selected} />
178
+ <Text
179
+ style={[
180
+ pickerMenuStyles.rowLabel,
181
+ {
182
+ color: selected ? getContrastColor(rowColor) : fg
183
+ }
184
+ ]}
185
+ numberOfLines={1}
186
+ >
187
+ {profile.name}
373
188
  </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
- </>
189
+ </View>
190
+ </TouchableRipple>
191
+ )
192
+ })}
193
+ </PickerMenu>
419
194
  )
420
195
  }
421
196
 
422
197
  const styles = StyleSheet.create({
423
- anchor: {
424
- position: 'relative'
425
- },
426
- // See InlineColorPicker's identical comment - React Native Web gives every position:'relative'
427
- // view its own stacking context, so the elevation has to live on the anchor itself.
428
- anchorOpen: {
429
- zIndex: 100
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
- },
465
- // Separates Manage (a navigation action) from the selectable rows above it - different kind of
466
- // action, not just another row in the same list. Tinted with the seat's own color (passed in as
467
- // backgroundColor) rather than a flat gray, matching the border/caret's own accent-tinted look.
468
- divider: {
469
- height: 1,
470
- marginVertical: 4,
471
- opacity: 0.25
472
- },
473
- // Matches ProfileChip's own default size/shape exactly (see this row's own render-site comment)
474
- // - no backgroundColor, unlike ProfileChip's own default: this row has no identity color of its
475
- // own to fill it with, and the render site now colors the icon directly instead.
476
- guestChip: {
477
- alignItems: 'center',
478
- borderRadius: 11,
479
- height: 22,
480
- justifyContent: 'center',
481
- width: 22
482
- },
483
198
  manageLabel: {
484
199
  fontSize: 13,
485
200
  fontWeight: '700',
486
201
  letterSpacing: 0.5
487
202
  },
488
- // Centered rather than left-aligned like a normal row - see this row's own render-site comment
489
- // for why (a footer action, not another selectable identity).
203
+ // Centered rather than left-aligned like a normal row - see the footer's own comment for why (a
204
+ // footer action, not another selectable identity).
490
205
  manageRowInner: {
491
206
  alignItems: 'center',
492
207
  flexDirection: 'row',
493
208
  gap: 6,
494
209
  height: '100%',
495
210
  justifyContent: 'center'
496
- },
497
- menu: {
498
- borderRadius: 12,
499
- borderWidth: MENU_BORDER_WIDTH,
500
- elevation: 8,
501
- shadowColor: '#000000',
502
- shadowOffset: { height: 2, width: 0 },
503
- shadowOpacity: 0.3,
504
- shadowRadius: 8
505
- },
506
- // Padded on every side (not just top/bottom) - a selected row's own full-color background (see
507
- // `row`'s borderRadius) needs real margin from the menu's own straight edges to read as a rounded
508
- // pill instead of looking clipped against them.
509
- menuContent: {
510
- padding: LIST_PADDING
511
- },
512
- // The selected profile's own color (see ColorSection).
513
- profileDot: {
514
- borderRadius: 4,
515
- height: 8,
516
- width: 8
517
- },
518
- row: {
519
- borderRadius: 8,
520
- height: ROW_HEIGHT
521
- },
522
- rowDisabled: {
523
- opacity: 0.35
524
- },
525
- rowInner: {
526
- alignItems: 'center',
527
- flexDirection: 'row',
528
- flex: 1,
529
- gap: 8,
530
- paddingHorizontal: ROW_INSET
531
- },
532
- rowLabel: {
533
- flex: 1,
534
- fontSize: 14
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
- },
557
- trigger: {
558
- alignSelf: 'flex-start'
559
- },
560
- triggerInner: {
561
- alignItems: 'center',
562
- flexDirection: 'row',
563
- gap: 2,
564
- height: TRIGGER_HEIGHT
565
- },
566
- triggerLabel: {
567
- fontSize: 13,
568
- fontWeight: '700',
569
- letterSpacing: 1
570
211
  }
571
212
  })
@@ -1,10 +1,10 @@
1
- import { defaultColors, useAutoPaperTheme } from '@rific/auto-paper'
1
+ import { defaultColors, FadingScrollView, useAutoPaperTheme } from '@rific/auto-paper'
2
2
  import { TouchableRipple } from '@rific/feedback-press'
3
3
  import { useFocusChain } from '@rific/focus-chain'
4
4
  import { useToast } from '@rific/toaster'
5
5
  import { ConfirmDialog, getInlineColorPickerContentSize, InlineColorPicker, PopoverHost, usePopoverHost } from '@tastic/hud'
6
6
  import { ComponentProps, ReactNode, Ref, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
7
- import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'
7
+ import { KeyboardAvoidingView, Platform, StyleSheet, useWindowDimensions, View } from 'react-native'
8
8
  import { Icon, Text, TextInput } from 'react-native-paper'
9
9
  import { useSafeAreaInsets } from 'react-native-safe-area-context'
10
10
 
@@ -242,9 +242,11 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
242
242
  actually keeps a focused row's own fields scrollable into view above the keyboard instead of
243
243
  stuck behind it. keyboardShouldPersistTaps='handled' on the ScrollView below is what lets
244
244
  tapping a *different* row (e.g. a color swatch, or another profile entirely) register on the
245
- first tap while a field elsewhere is still focused, instead of just dismissing the keyboard. */}
245
+ first tap while a field elsewhere is still focused, instead of just dismissing the keyboard. The rows
246
+ fade out at an edge while there is more past it, the same cue as the Settings sheet and the
247
+ Achievements screen (Jay, 2026-10-16: every scrolling screen alike). */}
246
248
  <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.keyboardAvoiding}>
247
- <ScrollView style={styles.scrollView} contentContainerStyle={[styles.content, { paddingBottom: 32 + insets.bottom }]} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps='handled'>
249
+ <FadingScrollView style={styles.scrollView} contentContainerStyle={[styles.content, { paddingBottom: 32 + insets.bottom }]} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps='handled'>
248
250
  {rows.map((row) => {
249
251
  if (row.kind === 'new') {
250
252
  if (editingId !== 'new') {
@@ -283,7 +285,7 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
283
285
  </TouchableRipple>
284
286
  )
285
287
  })}
286
- </ScrollView>
288
+ </FadingScrollView>
287
289
  </KeyboardAvoidingView>
288
290
 
289
291
  {/* hud's ConfirmDialog, the one confirm card every app in the fleet uses, so this reads like the
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { getOptionPickerContentSize, OptionPicker, type OptionPickerOption } from './OptionPicker'
1
2
  export { ProfileChip } from './ProfileChip'
2
3
  export { ProfileColorProvider, useProfileColorPreview, useProfileDisplayColor } from './ProfileColorContext'
3
4
  export { getProfilePickerContentSize, ProfilePicker, type ProfilePickerAlignResult, type ProfilePickerColorSection, type ProfilePickerContentSize } from './ProfilePicker'
@@ -8,7 +9,7 @@ export { createProfileExtensionSlice, type ProfileExtensionActions, type Profile
8
9
  export { createProfileSelectionSlice, type ProfileSelectionActions, type ProfileSelectionSlice, type ProfileSelectionState, type SelectProfilePayload } from './redux/profileSelectionSlice'
9
10
  export { type CreateProfileInput, createProfileRecord, profilesActions, profilesReducer, type UpdateProfilePayload } from './redux/profilesSlice'
10
11
  export { resolveInitialProfiles } from './redux/resolveInitialProfiles'
11
- export { createSeatColorsSlice, type SeatColorsActions, type SeatColorsSlice, type SeatColorsState, type SetLastGuestColorPayload, type SetProfileOverridePayload } from './redux/seatColorsSlice'
12
+ export { createSeatColorsSlice, type ProfileColorPick, profileSeatColor, type SeatColorsActions, type SeatColorsSlice, type SeatColorsState, type SetLastGuestColorPayload, type SetProfileColorPayload } from './redux/seatColorsSlice'
12
13
  export { useSharedProfilesSync, type UseSharedProfilesSyncOptions, type UseSharedProfilesSyncResult } from './redux/useSharedProfilesSync'
13
14
  export { avoidSeatColorClash } from './seatColorClash'
14
15
  export { isSharedProfileStoreAvailable, loadSharedProfiles, readSharedProfiles, saveSharedProfiles } from './sharedProfileStore'