@tastic/hud 0.24.0 → 0.25.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/dist/index.mjs CHANGED
@@ -24,21 +24,22 @@ import {
24
24
  useCompactDialog,
25
25
  usePopoverHost,
26
26
  useWellColors
27
- } from "./chunk-BXG44LIA.mjs";
27
+ } from "./chunk-AJD4FMG5.mjs";
28
+ import {
29
+ loadSkiaWeb,
30
+ locateSkiaWebFile
31
+ } from "./chunk-3TCJHMJP.mjs";
28
32
  import {
29
33
  COMPACT_DIALOG_HEIGHT,
30
34
  DialogHeader,
31
35
  OverlayBackdrop,
32
36
  OverlayCard,
37
+ SecondaryButton,
33
38
  isCompactDialogHeight,
34
39
  overlayActionStyles,
35
40
  overlayStyles,
36
41
  useOverlayCardColors
37
- } from "./chunk-PUF6TABL.mjs";
38
- import {
39
- loadSkiaWeb,
40
- locateSkiaWebFile
41
- } from "./chunk-3TCJHMJP.mjs";
42
+ } from "./chunk-CBH7GQKI.mjs";
42
43
 
43
44
  // src/AchievementUnlockList.tsx
44
45
  import { getContrastColor, useAutoPaperTheme } from "@rific/auto-paper";
@@ -642,6 +643,7 @@ export {
642
643
  PressAwayOverlay,
643
644
  ReadyButton,
644
645
  RotatedFrame,
646
+ SecondaryButton,
645
647
  SectionedDropdown,
646
648
  SharedActionBand,
647
649
  StaggeredWord,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/hud",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Visual component kit for local-multiplayer React Native games: inline (non-modal) popovers, dropdowns, color pickers, gauges, ready buttons, and dialogs, built to stay scoped to one player's own zone on a shared screen",
5
5
  "keywords": [
6
6
  "react-native",
@@ -84,18 +84,18 @@
84
84
  },
85
85
  "prettier": "@infinitetoken/eslint-config/prettier",
86
86
  "devDependencies": {
87
- "@infinitetoken/eslint-config": "^0.2.0",
87
+ "@infinitetoken/eslint-config": "^0.2.1",
88
88
  "@infinitetoken/jest-config": "^0.5.2",
89
89
  "@infinitetoken/tsconfig": "^0.6.0",
90
90
  "@rific/auto-paper": "^0.13.3",
91
- "@rific/core": "^0.4.0",
92
- "@rific/feedback-press": "^0.13.0",
91
+ "@rific/core": "^0.5.0",
92
+ "@rific/feedback-press": "^0.13.1",
93
93
  "@rific/updater": "^0.7.1",
94
94
  "@shopify/react-native-skia": "^2.14.0",
95
95
  "@tastic/achievements": "^0.6.0",
96
96
  "@tastic/core": "^0.14.0",
97
- "@tastic/profile": "^0.15.0",
98
- "@tastic/split-screen": "^0.5.2",
97
+ "@tastic/profile": "^0.16.2",
98
+ "@tastic/split-screen": "^0.5.4",
99
99
  "@testing-library/dom": "^10.4.1",
100
100
  "@testing-library/react": "^16.3.2",
101
101
  "@types/jest": "^30.0.0",
@@ -157,9 +157,11 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
157
157
 
158
158
  const showLockOrientation = isTouchPrimary && lockOrientation !== undefined && onLockOrientationChange !== undefined
159
159
  const showEdgeGuard = Platform.OS === 'ios' && deferBottomEdgeGestures !== undefined && onDeferBottomEdgeGestures !== undefined
160
- // Only in an app that opted in by handing expo-keep-awake to @rific/core's KeepAwakeProvider:
161
- // without the module the setting would do nothing. On the same devices as Lock Orientation, since a
162
- // desktop/laptop browser stays awake on its own while someone plays with its keyboard or mouse.
160
+ // Only in an app that opted in by handing expo-keep-awake to @rific/core's KeepAwakeProvider, and
161
+ // only where it can work: core withholds the module from a browser without the Screen Wake Lock
162
+ // API (from @rific/core 0.4.1), so without it the setting would do nothing. On the same devices
163
+ // as Lock Orientation, since a desktop/laptop browser stays awake on its own while someone plays
164
+ // with its keyboard or mouse.
163
165
  const showKeepAwake = isTouchPrimary && expoKeepAwake !== undefined
164
166
  const showHaptics = Platform.OS !== 'web' && !hideHaptics
165
167
  const showSound = !hideSound
@@ -8,6 +8,7 @@ import { Portal, Text } from 'react-native-paper'
8
8
  import { DialogHeader } from './DialogHeader'
9
9
  import { overlayActionStyles } from './overlayCard'
10
10
  import { OverlayBackdrop, OverlayCard, useOverlayCardColors } from './OverlaySurface'
11
+ import { SecondaryButton } from './SecondaryButton'
11
12
  import { useCompactDialog } from './useCompactDialog'
12
13
 
13
14
  export interface ConfirmDialogProps {
@@ -81,11 +82,7 @@ export function ConfirmDialog({ visible, title, message, confirmLabel, cancelLab
81
82
  {message}
82
83
  </Text>
83
84
  <View style={overlayActionStyles.actions}>
84
- {showCancel && (
85
- <Button mode='outlined' onPress={onCancel} style={overlayActionStyles.actionButton}>
86
- {cancelLabel}
87
- </Button>
88
- )}
85
+ {showCancel && <SecondaryButton onPress={onCancel}>{cancelLabel}</SecondaryButton>}
89
86
  <Button mode='contained' onPress={onConfirm} style={showCancel ? overlayActionStyles.actionButton : overlayActionStyles.actionButtonSingle} buttonColor={accent} textColor={onAccent}>
90
87
  {confirmLabel}
91
88
  </Button>
@@ -1,12 +1,13 @@
1
1
  import { defaultColors, getContrastColor, SeedColor } from '@rific/auto-paper'
2
2
  import { TouchableRipple } from '@rific/feedback-press'
3
3
  import { clamp, useRotation } from '@tastic/core'
4
+ import { type ReactNode, type RefObject } from 'react'
4
5
  import { ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'
5
6
  import { Icon, Text } from 'react-native-paper'
6
7
 
7
8
  import { MONO_FONT } from './fonts'
8
9
  import { PopoverBody } from './PopoverBody'
9
- import { AlignResult, PopoverRotation, useAutoAlign } from './useAutoAlign'
10
+ import { AlignResult, PopoverRotation, useAutoAlign, type ViewInstance } from './useAutoAlign'
10
11
  import { PopoverHost } from './usePopoverHost'
11
12
 
12
13
  // Presence check, not a full-string "is this exactly one emoji" validation - this only decides a
@@ -16,6 +17,8 @@ const EMOJI_PATTERN = /\p{Extended_Pictographic}/u
16
17
 
17
18
  const DEFAULT_SIZE = 48
18
19
  const SWATCH_SIZE = 28
20
+ // The selected swatch's white ring, drawn inside its SWATCH_SIZE.
21
+ const SWATCH_RING_WIDTH = 2
19
22
  const SWATCHES_PADDING = 8
20
23
  const SWATCHES_GAP = 6
21
24
  const SWATCHES_BORDER_WIDTH = 2
@@ -55,6 +58,15 @@ export function getInlineColorPickerContentSize(swatchCount: number, windowWidth
55
58
  return { width, height }
56
59
  }
57
60
 
61
+ // Where a `renderSwatch` drawing goes: the trigger (the color in effect, with its tag or icon on
62
+ // it) or a swatch in the popover's grid.
63
+ export type InlineColorPickerSwatchPlace = 'trigger' | 'swatch'
64
+
65
+ // Draws one color's swatch in place of its flat fill: a circle `size` across. `color` is the drawn
66
+ // color (after `previewValue`) and stays the primary one: the tag, icon, check, X or swap icon on
67
+ // top takes its contrast from it. Returning null or undefined keeps that swatch's flat fill.
68
+ export type InlineColorPickerSwatchRenderer = (color: string, size: number, place: InlineColorPickerSwatchPlace) => ReactNode
69
+
58
70
  interface Props {
59
71
  id: string
60
72
  host: PopoverHost
@@ -115,12 +127,25 @@ interface Props {
115
127
  // with other fixed-size elements can line this trigger up with them instead of standing out at a
116
128
  // mismatched size.
117
129
  size?: number
130
+ // Draws the trigger and each grid swatch in place of the flat fill (a host's three-color pie, say),
131
+ // behind its tag, icon or mark and clipped to the circle; the selected swatch's drawing sits
132
+ // inside its ring. Omit for flat swatches, as before.
133
+ renderSwatch?: InlineColorPickerSwatchRenderer
134
+ // A View the popover can't leave (see useAutoAlign's `bounds`): the scroll view this picker sits
135
+ // in, which clips the popover at its edges. Ignored when `alignOverride` places the popover.
136
+ bounds?: RefObject<ViewInstance | null>
137
+ }
138
+
139
+ // A renderSwatch drawing: at the swatch's top left (inside any border), behind the mark drawn after
140
+ // it, and clipped to the circle so a square drawing still reads round.
141
+ function SwatchDrawing({ size, children }: { size: number; children: ReactNode }) {
142
+ return <View style={[styles.drawing, { borderRadius: size / 2, height: size, width: size }]}>{children}</View>
118
143
  }
119
144
 
120
145
  // Renders inline rather than as a full-screen modal, scoped to its own panel - a modal color
121
146
  // picker would block the whole screen for one player while another can't touch their own panel at
122
147
  // the same time, which defeats the point of a split-screen lobby.
123
- export function InlineColorPicker({ id, host, value, onChange, previewValue, swatches = defaultColors, takenValue, allowSwapTaken, dark, align: forcedAlign, alignOverride, rotation: rotationOverride, icon = 'palette', tag, labelFontFamily = MONO_FONT, autoDismiss = true, columns, size = DEFAULT_SIZE }: Props) {
148
+ export function InlineColorPicker({ id, host, value, onChange, previewValue, swatches = defaultColors, takenValue, allowSwapTaken, dark, align: forcedAlign, alignOverride, rotation: rotationOverride, icon = 'palette', tag, labelFontFamily = MONO_FONT, autoDismiss = true, columns, size = DEFAULT_SIZE, renderSwatch, bounds }: Props) {
124
149
  const ambientRotation = useRotation()
125
150
  const rotation = rotationOverride ?? ambientRotation
126
151
  const menuBg = dark ? '#000000' : '#FFFFFF'
@@ -130,7 +155,7 @@ export function InlineColorPicker({ id, host, value, onChange, previewValue, swa
130
155
  const open = host.openId === id
131
156
  // Always called, even when alignOverride is supplied and this result goes unused - hooks can't be
132
157
  // called conditionally. See LabeledDropdown's identical comment for the same tradeoff.
133
- const auto = useAutoAlign(open, swatchesWidth, swatchesHeight, rotation)
158
+ const auto = useAutoAlign(open, swatchesWidth, swatchesHeight, rotation, bounds)
134
159
  const placement = alignOverride ?? auto
135
160
  const { maxHeight, measured, triggerRef, verticalAlign } = placement
136
161
  const align = forcedAlign ?? placement.align
@@ -142,11 +167,13 @@ export function InlineColorPicker({ id, host, value, onChange, previewValue, swa
142
167
  // still looks small for an emoji tag at the identical size - this bumps emoji up to the icon's own
143
168
  // ratio instead, since a single emoji is closer in visual weight to an icon glyph than to text.
144
169
  const tagFontSize = size * (tag && EMOJI_PATTERN.test(tag) ? 0.6 : 0.4)
170
+ const triggerDrawing = renderSwatch?.(displayValue, size, 'trigger') ?? null
145
171
 
146
172
  return (
147
173
  <View style={[styles.anchor, open && styles.anchorOpen]}>
148
- <TouchableRipple onPress={() => host.toggle(id)} borderless style={[styles.trigger, { backgroundColor: displayValue, borderRadius: size / 2, height: size, width: size }]}>
174
+ <TouchableRipple onPress={() => host.toggle(id)} borderless style={[styles.trigger, triggerDrawing === null && { backgroundColor: displayValue }, { borderRadius: size / 2, height: size, width: size }]}>
149
175
  <View ref={triggerRef} collapsable={false} style={styles.triggerMeasure}>
176
+ {triggerDrawing !== null && <SwatchDrawing size={size}>{triggerDrawing}</SwatchDrawing>}
150
177
  {/* Sized as a fraction of the trigger's own diameter rather than a fixed pixel size, so
151
178
  the glyph still reads as a deliberate part of the circle instead of shrinking toward its
152
179
  center on a larger trigger. */}
@@ -176,6 +203,10 @@ export function InlineColorPicker({ id, host, value, onChange, previewValue, swa
176
203
  const taken = !selected && !!takenValue && swatch.value.toLowerCase() === takenValue.toLowerCase()
177
204
  const swappable = taken && allowSwapTaken
178
205
  const swatchDisplay = displayFor(swatch.value)
206
+ const contrast = getContrastColor(swatchDisplay)
207
+ const drawnSize = selected ? SWATCH_SIZE - 2 * SWATCH_RING_WIDTH : SWATCH_SIZE
208
+ const drawing = renderSwatch?.(swatchDisplay, drawnSize, 'swatch') ?? null
209
+ 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} /> : <View />
179
210
  return (
180
211
  <TouchableRipple
181
212
  key={swatch.value}
@@ -185,9 +216,18 @@ export function InlineColorPicker({ id, host, value, onChange, previewValue, swa
185
216
  if (autoDismiss) host.close()
186
217
  }}
187
218
  borderless
188
- style={[styles.swatch, { backgroundColor: swatchDisplay }, selected && styles.swatchSelected, taken && !swappable && styles.swatchTaken]}
219
+ style={[styles.swatch, drawing === null && { backgroundColor: swatchDisplay }, selected && styles.swatchSelected, taken && !swappable && styles.swatchTaken]}
189
220
  >
190
- {selected ? <Icon source='check' size={16} color={getContrastColor(swatchDisplay)} /> : swappable ? <Icon source='swap-horizontal' size={16} color={getContrastColor(swatchDisplay)} /> : taken ? <Icon source='close' size={16} color={getContrastColor(swatchDisplay)} /> : <View />}
221
+ {/* TouchableRipple takes one child, so a drawing and its mark share a face that fills
222
+ the circle inside the ring. */}
223
+ {drawing !== null ? (
224
+ <View style={styles.swatchFace}>
225
+ <SwatchDrawing size={drawnSize}>{drawing}</SwatchDrawing>
226
+ {mark}
227
+ </View>
228
+ ) : (
229
+ mark
230
+ )}
191
231
  </TouchableRipple>
192
232
  )
193
233
  })}
@@ -207,6 +247,14 @@ const styles = StyleSheet.create({
207
247
  anchorOpen: {
208
248
  zIndex: 100
209
249
  },
250
+ // A renderSwatch drawing (see SwatchDrawing).
251
+ drawing: {
252
+ left: 0,
253
+ overflow: 'hidden',
254
+ pointerEvents: 'none',
255
+ position: 'absolute',
256
+ top: 0
257
+ },
210
258
  swatch: {
211
259
  alignItems: 'center',
212
260
  borderRadius: SWATCH_SIZE / 2,
@@ -214,9 +262,19 @@ const styles = StyleSheet.create({
214
262
  justifyContent: 'center',
215
263
  width: SWATCH_SIZE
216
264
  },
265
+ // A drawn swatch (see SwatchDrawing) and its mark: the whole circle inside the selected one's ring.
266
+ swatchFace: {
267
+ alignItems: 'center',
268
+ bottom: 0,
269
+ justifyContent: 'center',
270
+ left: 0,
271
+ position: 'absolute',
272
+ right: 0,
273
+ top: 0
274
+ },
217
275
  swatchSelected: {
218
276
  borderColor: '#ffffff',
219
- borderWidth: 2
277
+ borderWidth: SWATCH_RING_WIDTH
220
278
  },
221
279
  swatchTaken: {
222
280
  opacity: 0.35
@@ -0,0 +1,17 @@
1
+ import { Button, type ButtonProps } from '@rific/feedback-press'
2
+
3
+ import { overlayActionStyles } from './overlayCard'
4
+ import { useOverlayCardColors } from './OverlaySurface'
5
+
6
+ export type SecondaryButtonProps = ButtonProps
7
+
8
+ // The secondary action on an overlay card's action row (Cancel, Skip, Back, a result card's Quit):
9
+ // outlined, labeled in the card's own text color (fg) rather than Paper's default (the theme's
10
+ // primary), and sized as one of the row's buttons. A secondary takes no theme or seat color, so a
11
+ // seat's card never shows another seat's color and the contained primary beside it is the only
12
+ // colored button (Jay, 2026-10-20). Every prop overrides those defaults (a `style` is laid over the
13
+ // row's button style).
14
+ export function SecondaryButton({ style, ...props }: SecondaryButtonProps) {
15
+ const { fg } = useOverlayCardColors()
16
+ return <Button mode='outlined' textColor={fg} {...props} style={[overlayActionStyles.actionButton, style]} />
17
+ }
@@ -18,17 +18,18 @@ export { ContentGutter } from '../ContentGutter'
18
18
  export { CornerActionButtons } from '../CornerActionButtons'
19
19
  export { COMPACT_DIALOG_HEIGHT, DialogHeader, type DialogHeaderProps, isCompactDialogHeight } from '../DialogHeader'
20
20
  export { MONO_FONT } from '../fonts'
21
- export { getInlineColorPickerContentSize, InlineColorPicker } from '../InlineColorPicker'
21
+ export { getInlineColorPickerContentSize, InlineColorPicker, type InlineColorPickerSwatchPlace, type InlineColorPickerSwatchRenderer } from '../InlineColorPicker'
22
22
  export { overlayActionStyles, overlayStyles } from '../overlayCard'
23
23
  export { OverlayBackdrop, type OverlayBackdropProps, OverlayCard, type OverlayCardProps, type OverlayCardThemeColors, useOverlayCardColors } from '../OverlaySurface'
24
24
  export { PopoverBody } from '../PopoverBody'
25
25
  export { PressAwayOverlay } from '../PressAwayOverlay'
26
26
  export { RotatedFrame, type RotatedFrameProps } from '../RotatedFrame'
27
+ export { SecondaryButton, type SecondaryButtonProps } from '../SecondaryButton'
27
28
  export { getStaggeredWordDuration, StaggeredWord, type StaggeredWordProps } from '../StaggeredWord'
28
29
  export { StatRow } from '../StatRow'
29
30
  export { StatSection } from '../StatSection'
30
31
  export { UpdateDialog, type UpdateDialogProps } from '../UpdateDialog'
31
- export { type AlignResult, type PopoverAlign, type PopoverRotation, type PopoverVerticalAlign, useAutoAlign } from '../useAutoAlign'
32
+ export { type AlignResult, type PopoverAlign, type PopoverRotation, type PopoverVerticalAlign, useAutoAlign, type ViewInstance } from '../useAutoAlign'
32
33
  export { useCompactDialog } from '../useCompactDialog'
33
34
  export { type PopoverHost, usePopoverHost } from '../usePopoverHost'
34
35
  export { useWellColors, WELL_SHADOW, type WellColors } from '../wellSurface'
@@ -1,6 +1,6 @@
1
1
  import { useAutoPaperTheme } from '@rific/auto-paper'
2
2
  import { Button } from '@rific/feedback-press'
3
- import { useEffect, useRef, useState } from 'react'
3
+ import { type ComponentRef, useEffect, useRef, useState } from 'react'
4
4
  import { BackHandler, type NativeScrollEvent, type NativeSyntheticEvent, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'
5
5
  import { Portal, Text } from 'react-native-paper'
6
6
  import { useReducedMotion } from 'react-native-reanimated'
@@ -8,6 +8,7 @@ import { useReducedMotion } from 'react-native-reanimated'
8
8
  import { DialogHeader, isCompactDialogHeight } from '../DialogHeader'
9
9
  import { overlayActionStyles } from '../overlayCard'
10
10
  import { OverlayBackdrop, OverlayCard, useOverlayCardColors } from '../OverlaySurface'
11
+ import { SecondaryButton } from '../SecondaryButton'
11
12
  import type { GuideRotation, GuideStep } from './types'
12
13
 
13
14
  // The overlay's own breathing room and the card's own inner padding. Together with the card's max
@@ -71,7 +72,7 @@ function HowToPlayCard({ steps, onFinish, onSkip, rotation = 0, title = 'How to
71
72
  const { width: windowWidth, height: windowHeight } = useWindowDimensions()
72
73
  const reducedMotion = useReducedMotion()
73
74
  const [index, setIndex] = useState(0)
74
- const scrollRef = useRef<ScrollView>(null)
75
+ const scrollRef = useRef<ComponentRef<typeof ScrollView>>(null)
75
76
 
76
77
  // Android's hardware back would otherwise fall through to the router underneath and pop a screen
77
78
  // (or exit the app) with the guide still up. Treated as Skip, the same as the on-screen button.
@@ -165,11 +166,7 @@ function HowToPlayCard({ steps, onFinish, onSkip, rotation = 0, title = 'How to
165
166
  offered on the first card; after that the way out is Next to the end (a short flow) or
166
167
  Android's hardware back, which skips from anywhere. */}
167
168
  <View style={overlayActionStyles.actions}>
168
- {!isOnly && (
169
- <Button mode='outlined' onPress={isFirst ? onSkip : () => goTo(index - 1)} style={overlayActionStyles.actionButton}>
170
- {isFirst ? 'Skip' : 'Back'}
171
- </Button>
172
- )}
169
+ {!isOnly && <SecondaryButton onPress={isFirst ? onSkip : () => goTo(index - 1)}>{isFirst ? 'Skip' : 'Back'}</SecondaryButton>}
173
170
  <Button mode='contained' onPress={isLast ? onFinish : () => goTo(index + 1)} style={isOnly ? overlayActionStyles.actionButtonSingle : overlayActionStyles.actionButton} buttonColor={colors.primary} textColor={colors.onPrimary}>
174
171
  {isLast ? 'Ready' : 'Next'}
175
172
  </Button>
package/src/index.ts CHANGED
@@ -11,7 +11,7 @@ export { CornerActionButtons } from './CornerActionButtons'
11
11
  export { CornerStatusBadges, type CornerStatusBadgeSeat, type CornerStatusBadgesProps, type OrientationMode } from './CornerStatusBadges'
12
12
  export { COMPACT_DIALOG_HEIGHT, DialogHeader, type DialogHeaderProps, isCompactDialogHeight } from './DialogHeader'
13
13
  export { MONO_FONT } from './fonts'
14
- export { getInlineColorPickerContentSize, InlineColorPicker } from './InlineColorPicker'
14
+ export { getInlineColorPickerContentSize, InlineColorPicker, type InlineColorPickerSwatchPlace, type InlineColorPickerSwatchRenderer } from './InlineColorPicker'
15
15
  export { getLabeledDropdownContentHeight, LABELED_DROPDOWN_POPOVER_WIDTH, LabeledDropdown, type LabeledDropdownOption } from './LabeledDropdown'
16
16
  export { loadSkiaWeb } from './loadSkiaWeb'
17
17
  export { locateSkiaWebFile } from './locateSkiaWebFile'
@@ -22,6 +22,7 @@ export { PopoverBody } from './PopoverBody'
22
22
  export { PressAwayOverlay } from './PressAwayOverlay'
23
23
  export { ReadyButton } from './ReadyButton'
24
24
  export { RotatedFrame, type RotatedFrameProps } from './RotatedFrame'
25
+ export { SecondaryButton, type SecondaryButtonProps } from './SecondaryButton'
25
26
  export { type MenuOption, type MenuSection, type MultiSelectSection, SectionedDropdown, type SingleSelectSection } from './SectionedDropdown'
26
27
  export { SharedActionBand } from './SharedActionBand'
27
28
  export { getStaggeredWordDuration, StaggeredWord, type StaggeredWordProps } from './StaggeredWord'
@@ -37,7 +38,7 @@ export { StatSection } from './StatSection'
37
38
  export type { TriggerGaugeProps } from './TriggerGauge'
38
39
  export { default as TriggerGaugeHost } from './TriggerGaugeHost'
39
40
  export { UpdateDialog, type UpdateDialogProps } from './UpdateDialog'
40
- export { type AlignResult, type PopoverAlign, type PopoverRotation, type PopoverVerticalAlign, useAutoAlign } from './useAutoAlign'
41
+ export { type AlignResult, type PopoverAlign, type PopoverRotation, type PopoverVerticalAlign, useAutoAlign, type ViewInstance } from './useAutoAlign'
41
42
  export { useCompactDialog } from './useCompactDialog'
42
43
  export { type PopoverHost, usePopoverHost } from './usePopoverHost'
43
44
  export { type QuitConfirmation, useQuitConfirmation } from './useQuitConfirmation'
@@ -1,11 +1,19 @@
1
1
  import { type ViewRotation } from '@tastic/core'
2
- import { RefObject, useCallback, useEffect, useRef, useState } from 'react'
2
+ import { type ComponentRef, RefObject, useEffect, useRef, useState } from 'react'
3
3
  import { useWindowDimensions, View } from 'react-native'
4
4
 
5
5
  // Minimum breathing room between a popover's outer edge and the screen edge - matches the general
6
6
  // "don't butt content right up against the bezel" margin used elsewhere in this app.
7
7
  const EDGE_MARGIN = 12
8
8
 
9
+ // The edges a popover has room up to: the window's, or a bounds view's (see useAutoAlign).
10
+ interface Limits {
11
+ left: number
12
+ top: number
13
+ right: number
14
+ bottom: number
15
+ }
16
+
9
17
  export type PopoverAlign = 'left' | 'right' | 'center'
10
18
  export type PopoverVerticalAlign = 'below' | 'above'
11
19
  // Same set of values as @tastic/core's own ViewRotation - re-exported under this package's existing
@@ -26,9 +34,14 @@ export interface AlignResult {
26
34
  verticalAlign: PopoverVerticalAlign
27
35
  maxHeight: number
28
36
  measured: boolean
29
- triggerRef: RefObject<View | null>
37
+ triggerRef: RefObject<ViewInstance | null>
30
38
  }
31
39
 
40
+ // What a ref on a View holds. React Native 0.88's strict types make View a function, so a bare
41
+ // `RefObject<View>` there names a ref to the function itself, with no measureInWindow; this is the
42
+ // instance in both its legacy and strict types.
43
+ export type ViewInstance = ComponentRef<typeof View>
44
+
32
45
  // Reflects a rect from the PRE-rotation layout frame measureInWindow actually reports (see
33
46
  // useAutoAlign's own comment below for why that's what it reports, not the post-rotation visual
34
47
  // position) into its true, post-rotation position - needed for a trigger sitting inside a
@@ -84,8 +97,15 @@ export function rotateRect(x: number, y: number, width: number, height: number,
84
97
  //
85
98
  // Re-measures on every open (not continuously) - the trigger's screen position can't change while
86
99
  // its own popover is showing anyway, so there's nothing to gain from tracking it beyond that.
87
- export function useAutoAlign(open: boolean, contentWidth: number, contentHeight: number, rotation: PopoverRotation = 0) {
88
- const triggerRef = useRef<View>(null)
100
+ //
101
+ // `bounds` is a View the popover can't leave, when that's narrower or shorter than the window: a
102
+ // scroll view the trigger sits in clips its popover at the scroll view's edges, so a popover
103
+ // centred against the window can be cut off by a column narrower than it (a roster capped to a
104
+ // column in a wide window). With it the room on every side is measured to the bounds' edges
105
+ // instead of the window's (measured and rotated the same way as the trigger). Omit it for the
106
+ // window, as before.
107
+ export function useAutoAlign(open: boolean, contentWidth: number, contentHeight: number, rotation: PopoverRotation = 0, bounds?: RefObject<ViewInstance | null>) {
108
+ const triggerRef = useRef<ViewInstance>(null)
89
109
  const { width: windowWidth, height: windowHeight } = useWindowDimensions()
90
110
  const [align, setAlign] = useState<PopoverAlign>('center')
91
111
  const [verticalAlign, setVerticalAlign] = useState<PopoverVerticalAlign>('below')
@@ -108,12 +128,20 @@ export function useAutoAlign(open: boolean, contentWidth: number, contentHeight:
108
128
  if (open) setMeasured(false)
109
129
  }
110
130
 
111
- const measure = useCallback(() => {
112
- triggerRef.current?.measureInWindow((rawX, rawY, rawWidth, rawHeight) => {
131
+ // Re-measures whenever `open` flips true, and also if the content size, the window, the rotation or
132
+ // the bounds change while already open - e.g. a live window resize, or the device being physically
133
+ // turned while the popover is showing - so it never goes stale while the popover is actually
134
+ // showing. `measured` only resets on a genuine (re)open, via the render-phase check above - not here
135
+ // on every re-measure, so an already-visible popover never flickers hidden again just because the
136
+ // window resized (or rotated) under it. (The measurement lives in the effect itself, not a
137
+ // useCallback, so the bounds view is read when it runs.)
138
+ useEffect(() => {
139
+ if (!open) return
140
+ const place = (limits: Limits) => (rawX: number, rawY: number, rawWidth: number, rawHeight: number) => {
113
141
  const { x, y, width: triggerWidth, height: triggerHeight } = rotateRect(rawX, rawY, rawWidth, rawHeight, rotation, windowWidth, windowHeight)
114
142
  const centerX = x + triggerWidth / 2
115
- const overflowsRight = centerX + contentWidth / 2 > windowWidth - EDGE_MARGIN
116
- const overflowsLeft = centerX - contentWidth / 2 < EDGE_MARGIN
143
+ const overflowsRight = centerX + contentWidth / 2 > limits.right - EDGE_MARGIN
144
+ const overflowsLeft = centerX - contentWidth / 2 < limits.left + EDGE_MARGIN
117
145
  // Both directions overflowing means the popover is simply wider than the screen has room for
118
146
  // either way - 'center' is the least-bad choice there (symmetric clipping beats asymmetric).
119
147
  if (overflowsRight && !overflowsLeft) setAlign('right')
@@ -123,24 +151,25 @@ export function useAutoAlign(open: boolean, contentWidth: number, contentHeight:
123
151
  // Below is the default/preferred direction - only flips to above when there's genuinely more
124
152
  // room that way, not merely whenever below is imperfect, so a popover close to fitting either
125
153
  // way doesn't flip-flop from a one-pixel margin difference.
126
- const roomBelow = windowHeight - (y + triggerHeight) - EDGE_MARGIN
127
- const roomAbove = y - EDGE_MARGIN
154
+ const roomBelow = limits.bottom - (y + triggerHeight) - EDGE_MARGIN
155
+ const roomAbove = y - limits.top - EDGE_MARGIN
128
156
  const above = contentHeight > roomBelow && roomAbove > roomBelow
129
157
  setVerticalAlign(above ? 'above' : 'below')
130
158
  setMaxHeight(Math.max(above ? roomAbove : roomBelow, 0))
131
159
  setMeasured(true)
160
+ }
161
+ const screen: Limits = { left: 0, top: 0, right: windowWidth, bottom: windowHeight }
162
+ const boundsView = bounds?.current
163
+ if (!boundsView) {
164
+ triggerRef.current?.measureInWindow(place(screen))
165
+ return
166
+ }
167
+ boundsView.measureInWindow((rawX, rawY, rawWidth, rawHeight) => {
168
+ const b = rotateRect(rawX, rawY, rawWidth, rawHeight, rotation, windowWidth, windowHeight)
169
+ // Kept inside the window: a bounds view running past it doesn't make room past the screen.
170
+ triggerRef.current?.measureInWindow(place({ left: Math.max(b.x, 0), top: Math.max(b.y, 0), right: Math.min(b.x + b.width, windowWidth), bottom: Math.min(b.y + b.height, windowHeight) }))
132
171
  })
133
- }, [contentWidth, contentHeight, windowWidth, windowHeight, rotation])
134
-
135
- // Re-measures whenever `open` flips true, and also if `measure` itself changes identity (i.e.
136
- // contentWidth/contentHeight/windowWidth/windowHeight/rotation changed) while already open - e.g.
137
- // a live window resize, or the device being physically turned while the popover is showing - so it
138
- // never goes stale while the popover is actually showing. `measured` only resets on a genuine
139
- // (re)open, via the render-phase check above - not here on every re-measure, so an already-visible
140
- // popover never flickers hidden again just because the window resized (or rotated) under it.
141
- useEffect(() => {
142
- if (open) measure()
143
- }, [open, measure])
172
+ }, [open, contentWidth, contentHeight, windowWidth, windowHeight, rotation, bounds])
144
173
 
145
174
  return { align, maxHeight, measured, triggerRef, verticalAlign }
146
175
  }