@tastic/hud 0.8.4 → 0.12.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 +301 -20
- package/dist/index.d.mts +107 -15
- package/dist/index.d.ts +107 -15
- package/dist/index.js +556 -315
- package/dist/index.mjs +483 -262
- package/package.json +16 -8
- package/src/AchievementCatalogSection.tsx +60 -0
- package/src/ActivityStatSection.tsx +29 -0
- package/src/ConfirmDialog.tsx +30 -11
- package/src/ControlSchemePicker.tsx +11 -2
- package/src/InlineColorPicker.tsx +38 -11
- package/src/LabeledDropdown.tsx +5 -17
- package/src/PlayerSetupPanel.tsx +196 -0
- package/src/SectionedDropdown.tsx +18 -7
- package/src/StaggeredWord.tsx +114 -0
- package/src/index.ts +8 -2
- package/src/useAutoAlign.ts +17 -1
- package/src/useQuitConfirmation.ts +61 -0
- package/src/useZoneClampedAlign.ts +135 -0
|
@@ -7,7 +7,7 @@ import { Icon, Text } from 'react-native-paper'
|
|
|
7
7
|
import { MONO_FONT } from './fonts'
|
|
8
8
|
import { PopoverBody } from './PopoverBody'
|
|
9
9
|
import TriggerGaugeHost from './TriggerGaugeHost'
|
|
10
|
-
import { PopoverRotation, useAutoAlign } from './useAutoAlign'
|
|
10
|
+
import { AlignResult, PopoverRotation, useAutoAlign } from './useAutoAlign'
|
|
11
11
|
import { PopoverHost } from './usePopoverHost'
|
|
12
12
|
|
|
13
13
|
const MENU_BORDER_WIDTH = 1
|
|
@@ -96,10 +96,17 @@ interface Props {
|
|
|
96
96
|
// background — pass a real design-system "on" color explicitly instead when you have one.
|
|
97
97
|
onAccentColor?: string
|
|
98
98
|
dark: boolean
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
99
|
+
// Forces a specific horizontal alignment instead of letting whichever placement result applies
|
|
100
|
+
// (see alignOverride below) decide. Only pass this to force a specific side regardless of where
|
|
101
|
+
// the trigger actually sits.
|
|
102
102
|
align?: 'left' | 'right' | 'center'
|
|
103
|
+
// Substitutes this package's own plain useAutoAlign-based placement wholesale (align,
|
|
104
|
+
// verticalAlign, maxHeight, measured, triggerRef) with a caller-supplied one — e.g.
|
|
105
|
+
// useZoneClampedAlign's result, for a dropdown living inside a @tastic/split-screen zone, where
|
|
106
|
+
// the plain window-relative decision can pick a direction that overflows into the shared row
|
|
107
|
+
// instead of the zone's own boundary. Same shape LabeledDropdown's own alignOverride takes; omit
|
|
108
|
+
// for the ordinary case (a popover with nothing but the screen edge to avoid).
|
|
109
|
+
alignOverride?: AlignResult
|
|
103
110
|
// Explicit override for the rotation a @tastic/split-screen-style FakeLandscapeView (or any other
|
|
104
111
|
// ancestor rotated the same way — a bare CSS `transform: rotate()`, not an actual OS-level
|
|
105
112
|
// orientation change) is currently applying. Defaults to a live ambient read via @tastic/core's
|
|
@@ -128,7 +135,7 @@ interface Props {
|
|
|
128
135
|
// powerups are enabled, say) is just this with one 'multi' section (optionally with allClear); a
|
|
129
136
|
// combined menu (e.g. spawn frequency + which types, in one menu) is just two sections in the same
|
|
130
137
|
// array.
|
|
131
|
-
export function SectionedDropdown({ id, host, icon, accessibilityLabel, sections, accentColor, mutedColor, onAccentColor, dark, align: alignOverride, rotation: rotationOverride, autoDismiss = true, labelFontFamily = MONO_FONT, allClearLabels = { all: 'All', clear: 'Clear' } }: Props) {
|
|
138
|
+
export function SectionedDropdown({ id, host, icon, accessibilityLabel, sections, accentColor, mutedColor, onAccentColor, dark, align: forcedAlign, alignOverride, rotation: rotationOverride, autoDismiss = true, labelFontFamily = MONO_FONT, allClearLabels = { all: 'All', clear: 'Clear' } }: Props) {
|
|
132
139
|
const ambientRotation = useRotation()
|
|
133
140
|
const rotation = rotationOverride ?? ambientRotation
|
|
134
141
|
const menuBg = dark ? '#000000' : '#FFFFFF'
|
|
@@ -156,8 +163,12 @@ export function SectionedDropdown({ id, host, icon, accessibilityLabel, sections
|
|
|
156
163
|
const divider = sectionIndex > 0 ? MENU_DIVIDER_HEIGHT : 0
|
|
157
164
|
return sum + rows + footer + divider
|
|
158
165
|
}, 0)
|
|
159
|
-
|
|
160
|
-
|
|
166
|
+
// Always called, even when alignOverride is supplied and this result goes unused — hooks can't be
|
|
167
|
+
// called conditionally. See LabeledDropdown's identical comment for the same tradeoff.
|
|
168
|
+
const auto = useAutoAlign(open, MENU_MAX_WIDTH, estimatedHeight, rotation)
|
|
169
|
+
const placement = alignOverride ?? auto
|
|
170
|
+
const { maxHeight, measured, triggerRef, verticalAlign } = placement
|
|
171
|
+
const align = forcedAlign ?? placement.align
|
|
161
172
|
|
|
162
173
|
// Trigger reflects the selected option's own icon only when there's exactly one section and it's
|
|
163
174
|
// single-select — any other shape (multi-select present, or more than one section) has no single
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
import { LayoutChangeEvent, StyleProp, StyleSheet, TextStyle, View, ViewStyle } from 'react-native'
|
|
3
|
+
import Animated, { Easing, useAnimatedStyle, useSharedValue, withDelay, withTiming } from 'react-native-reanimated'
|
|
4
|
+
|
|
5
|
+
const STAGGER_MS = 45
|
|
6
|
+
const LETTER_DURATION_MS = 320
|
|
7
|
+
|
|
8
|
+
// The one formula every consumer's own follow-on animation (a paddle rally, a bouncing puck, a
|
|
9
|
+
// looping trail) needs in order to time its own entrance off "once the letters have mostly
|
|
10
|
+
// landed" — five apps (AirHockey, BoxHockey, Pong, LightCycles, Snake) had each independently
|
|
11
|
+
// re-derived `(letterCount - 1) * STAGGER_MS + LETTER_DURATION_MS` locally as their own
|
|
12
|
+
// `TOTAL_STAGGER_MS` constant. Exported as a pure function, not a component return value, so a
|
|
13
|
+
// caller can compute it once, outside render, the same way every one of those apps already did.
|
|
14
|
+
// `letterCount` is the TOTAL number of letters actually animated, not necessarily a single
|
|
15
|
+
// StaggeredWord's own `word.length` — see `startIndex` below for a multi-word wordmark, where a
|
|
16
|
+
// second word's letters continue the same overall stagger sequence.
|
|
17
|
+
export function getStaggeredWordDuration(letterCount: number): number {
|
|
18
|
+
return (letterCount - 1) * STAGGER_MS + LETTER_DURATION_MS
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface AnimatedLetterProps {
|
|
22
|
+
char: string
|
|
23
|
+
index: number
|
|
24
|
+
color: string
|
|
25
|
+
reducedMotion: boolean
|
|
26
|
+
fontFamily: string
|
|
27
|
+
fontSize: number
|
|
28
|
+
lineHeight: number
|
|
29
|
+
fontWeight?: TextStyle['fontWeight']
|
|
30
|
+
letterSpacing?: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function AnimatedLetter({ char, index, color, reducedMotion, fontFamily, fontSize, lineHeight, fontWeight, letterSpacing }: AnimatedLetterProps) {
|
|
34
|
+
const progress = useSharedValue(reducedMotion ? 1 : 0)
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (reducedMotion) return
|
|
38
|
+
progress.value = withDelay(index * STAGGER_MS, withTiming(1, { duration: LETTER_DURATION_MS, easing: Easing.out(Easing.back(1.5)) }))
|
|
39
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
40
|
+
}, [reducedMotion])
|
|
41
|
+
|
|
42
|
+
const style = useAnimatedStyle(() => ({
|
|
43
|
+
opacity: progress.value,
|
|
44
|
+
transform: [{ translateY: (1 - progress.value) * 12 }, { scale: 0.85 + progress.value * 0.15 }]
|
|
45
|
+
}))
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<Animated.Text allowFontScaling={false} style={[{ color, fontFamily, fontSize, lineHeight, fontWeight, letterSpacing }, style]}>
|
|
49
|
+
{char}
|
|
50
|
+
</Animated.Text>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface StaggeredWordProps {
|
|
55
|
+
word: string
|
|
56
|
+
color: string
|
|
57
|
+
fontFamily: string
|
|
58
|
+
fontSize: number
|
|
59
|
+
lineHeight: number
|
|
60
|
+
// Whether to skip the stagger entirely and render every letter already fully settled — wire
|
|
61
|
+
// straight from react-native-reanimated's own useReducedMotion() at the call site rather than
|
|
62
|
+
// this component calling it internally, so a caller composing several StaggeredWords (see
|
|
63
|
+
// `startIndex` below) only needs the one hook call, not one per word.
|
|
64
|
+
reducedMotion: boolean
|
|
65
|
+
// Continues the stagger sequence from a larger overall wordmark this word is only one line/part
|
|
66
|
+
// of — this word's own first letter animates at index `startIndex`, its second at
|
|
67
|
+
// `startIndex + 1`, and so on. Defaults to 0 for a standalone single-word wordmark (Snake's
|
|
68
|
+
// "SNAKE", Pong's "PONG"); a caller stacking multiple words (AirHockey's "AIR"/"HOCKEY",
|
|
69
|
+
// BoxHockey's "BOX"/"HOCKEY", LightCycles' "Light"/"Cycles") passes the running total of letters
|
|
70
|
+
// already animated by earlier words, so the whole wordmark reads as one continuous cascade
|
|
71
|
+
// instead of each word restarting its own stagger from zero.
|
|
72
|
+
startIndex?: number
|
|
73
|
+
// Every consumer this component was extracted from hardcoded bold letters (baked into a fixed
|
|
74
|
+
// StyleSheet entry, not a prop) — kept as the default here rather than a fixed style, so the one
|
|
75
|
+
// consumer that doesn't want it (LightCycles, whose own registered display font is already
|
|
76
|
+
// sufficiently bold on its own) can opt out with `fontWeight='normal'` instead of this component
|
|
77
|
+
// forcing it unconditionally.
|
|
78
|
+
fontWeight?: TextStyle['fontWeight']
|
|
79
|
+
// Unset by default — only AirHockey/BoxHockey/Pong's shared MONO_FONT-based letter style actually
|
|
80
|
+
// wants extra tracking; Snake's/LightCycles' own theme fonts don't.
|
|
81
|
+
letterSpacing?: number
|
|
82
|
+
// The wrapping row's own style — e.g. LightCycles' own vertical offset for its second word
|
|
83
|
+
// beneath its first.
|
|
84
|
+
style?: StyleProp<ViewStyle>
|
|
85
|
+
// Lets a caller measure this word's own rendered box (e.g. to size/position a follow-on canvas
|
|
86
|
+
// overlay the way Snake's HeroSnakeTrail or LightCycles' HeroTitleTrails do) the same way it
|
|
87
|
+
// would measure any other plain View.
|
|
88
|
+
onLayout?: (e: LayoutChangeEvent) => void
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// One row of letters staggering in on mount (or rendered already fully settled under reduced
|
|
92
|
+
// motion) — the "AnimatedLetter" building block five apps' own hero-title screens
|
|
93
|
+
// (AirHockey/BoxHockey/Pong/LightCycles/Snake's own HeroTitle.tsx/AnimatedHeroTitle.tsx) had each
|
|
94
|
+
// hand-rolled independently, identical down to the same 45ms stagger, 320ms per-letter duration,
|
|
95
|
+
// and back-ease overshoot. A single word/line on its own, or one of several stacked/side-by-side
|
|
96
|
+
// words sharing one overall stagger sequence via `startIndex` — this component only ever renders
|
|
97
|
+
// one row of letters; a caller composing more than one word renders more than one StaggeredWord
|
|
98
|
+
// (see `startIndex`'s own doc), the same way every one of those five apps' own `Word` helper
|
|
99
|
+
// already did internally.
|
|
100
|
+
export function StaggeredWord({ word, color, fontFamily, fontSize, lineHeight, reducedMotion, startIndex = 0, fontWeight = 'bold', letterSpacing, style, onLayout }: StaggeredWordProps) {
|
|
101
|
+
return (
|
|
102
|
+
<View style={[styles.row, style]} onLayout={onLayout}>
|
|
103
|
+
{word.split('').map((char, i) => (
|
|
104
|
+
<AnimatedLetter key={i} char={char} index={startIndex + i} color={color} reducedMotion={reducedMotion} fontFamily={fontFamily} fontSize={fontSize} lineHeight={lineHeight} fontWeight={fontWeight} letterSpacing={letterSpacing} />
|
|
105
|
+
))}
|
|
106
|
+
</View>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const styles = StyleSheet.create({
|
|
111
|
+
row: {
|
|
112
|
+
flexDirection: 'row'
|
|
113
|
+
}
|
|
114
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
export { AchievementCatalogSection } from './AchievementCatalogSection'
|
|
1
2
|
export { AchievementRow, LOCKED_BADGE_COLOR } from './AchievementRow'
|
|
3
|
+
export { ActivityStatSection } from './ActivityStatSection'
|
|
2
4
|
export { BaseSettingsDialog, type BaseSettingsDialogProps } from './BaseSettingsDialog'
|
|
3
5
|
export { BaseStatsScreen } from './BaseStatsScreen'
|
|
4
6
|
export { ConfirmDialog, type ConfirmDialogProps } from './ConfirmDialog'
|
|
@@ -7,16 +9,18 @@ export { ControlSchemePicker, type ControlSchemePickerProps } from './ControlSch
|
|
|
7
9
|
export { CornerActionButtons } from './CornerActionButtons'
|
|
8
10
|
export { CornerStatusBadges, type CornerStatusBadgeSeat, type CornerStatusBadgesProps, type OrientationMode } from './CornerStatusBadges'
|
|
9
11
|
export { MONO_FONT } from './fonts'
|
|
10
|
-
export { InlineColorPicker } from './InlineColorPicker'
|
|
12
|
+
export { getInlineColorPickerContentSize, InlineColorPicker } from './InlineColorPicker'
|
|
11
13
|
export { getLabeledDropdownContentHeight, LABELED_DROPDOWN_POPOVER_WIDTH, LabeledDropdown, type LabeledDropdownOption } from './LabeledDropdown'
|
|
12
14
|
export { loadSkiaWeb } from './loadSkiaWeb'
|
|
13
15
|
export { getOverlayCardColors, type OverlayCardColors, overlayStyles } from './overlayCard'
|
|
16
|
+
export { getColorPopoverId, getCpuDifficultyPopoverId, PlayerSetupPanel, type PlayerSetupPanelProps } from './PlayerSetupPanel'
|
|
14
17
|
export { PopoverBody } from './PopoverBody'
|
|
15
18
|
export { PressAwayOverlay } from './PressAwayOverlay'
|
|
16
19
|
export { ReadyButton } from './ReadyButton'
|
|
17
20
|
export { type MenuOption, type MenuSection, type MultiSelectSection, SectionedDropdown, type SingleSelectSection } from './SectionedDropdown'
|
|
18
21
|
export { SharedActionBand } from './SharedActionBand'
|
|
19
22
|
export { SkiaGate, type SkiaGateProps } from './SkiaGate'
|
|
23
|
+
export { getStaggeredWordDuration, StaggeredWord, type StaggeredWordProps } from './StaggeredWord'
|
|
20
24
|
export { StatRow } from './StatRow'
|
|
21
25
|
export { StatSection } from './StatSection'
|
|
22
26
|
// TriggerGauge itself (the raw Skia-based component) is deliberately NOT exported here, even
|
|
@@ -29,5 +33,7 @@ export { StatSection } from './StatSection'
|
|
|
29
33
|
export type { TriggerGaugeProps } from './TriggerGauge'
|
|
30
34
|
export { default as TriggerGaugeHost } from './TriggerGaugeHost'
|
|
31
35
|
export { UpdateDialog, type UpdateDialogProps } from './UpdateDialog'
|
|
32
|
-
export { type PopoverAlign, type PopoverRotation, type PopoverVerticalAlign, useAutoAlign } from './useAutoAlign'
|
|
36
|
+
export { type AlignResult, type PopoverAlign, type PopoverRotation, type PopoverVerticalAlign, useAutoAlign } from './useAutoAlign'
|
|
33
37
|
export { type PopoverHost, usePopoverHost } from './usePopoverHost'
|
|
38
|
+
export { type QuitConfirmation, useQuitConfirmation } from './useQuitConfirmation'
|
|
39
|
+
export { useZoneClampedAlign } from './useZoneClampedAlign'
|
package/src/useAutoAlign.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ViewRotation } from '@tastic/core'
|
|
2
|
-
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { RefObject, useCallback, 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
|
|
@@ -13,6 +13,22 @@ export type PopoverVerticalAlign = 'below' | 'above'
|
|
|
13
13
|
// rotation" reads clearer at these call sites than the more general core name.
|
|
14
14
|
export type PopoverRotation = ViewRotation
|
|
15
15
|
|
|
16
|
+
// Same shape useAutoAlign itself returns — exported so a caller can substitute this package's own
|
|
17
|
+
// plain, screen-edge-aware placement wholesale via a component's own `alignOverride` prop (see
|
|
18
|
+
// LabeledDropdown's, SectionedDropdown's, and InlineColorPicker's own `alignOverride`), most often
|
|
19
|
+
// with useZoneClampedAlign's result for a popover living inside a @tastic/split-screen zone, where
|
|
20
|
+
// the plain window-relative decision can pick a direction that overflows into the shared row
|
|
21
|
+
// instead of the zone's own boundary. Hand-declared rather than `ReturnType<typeof useAutoAlign>`
|
|
22
|
+
// so this stays a stable, self-documented public type regardless of useAutoAlign's own internal
|
|
23
|
+
// implementation shape.
|
|
24
|
+
export interface AlignResult {
|
|
25
|
+
align: PopoverAlign
|
|
26
|
+
verticalAlign: PopoverVerticalAlign
|
|
27
|
+
maxHeight: number
|
|
28
|
+
measured: boolean
|
|
29
|
+
triggerRef: RefObject<View | null>
|
|
30
|
+
}
|
|
31
|
+
|
|
16
32
|
// Reflects a rect from the PRE-rotation layout frame measureInWindow actually reports (see
|
|
17
33
|
// useAutoAlign's own comment below for why that's what it reports, not the post-rotation visual
|
|
18
34
|
// position) into its true, post-rotation position — needed for a trigger sitting inside a
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface QuitConfirmation {
|
|
4
|
+
/** Wire directly to <ConfirmDialog visible={...}>. */
|
|
5
|
+
confirmVisible: boolean
|
|
6
|
+
/** Wire directly to the back button's onPress (GameActionChips/IconButton onBack/onPress). */
|
|
7
|
+
requestBack: () => void
|
|
8
|
+
/** Wire directly to <ConfirmDialog onCancel={...}>. */
|
|
9
|
+
cancelBack: () => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Captures the onBackPress + ConfirmDialog "Quit Match?" pattern every fleet game.tsx already
|
|
13
|
+
// hand-wires (Snake, AirHockey, BoxHockey, Pong, LightCycles): interrupt backing out of a match
|
|
14
|
+
// with a confirmation only when there's something worth losing, otherwise back out immediately.
|
|
15
|
+
//
|
|
16
|
+
// `hasProgress` is a lazy () => boolean, not a plain boolean value taken directly - deliberately
|
|
17
|
+
// corrected from this hook's own first version, which took a plain boolean and broke in practice
|
|
18
|
+
// for AirHockey/BoxHockey/Pong: `paused` (derived from this hook's own confirmVisible) has to be
|
|
19
|
+
// computed and handed to useGameState() BEFORE that call returns the very scores/lives/bricks state
|
|
20
|
+
// hasProgress needs to read, so a plain-boolean argument creates a real render-order circular
|
|
21
|
+
// dependency (confirmVisible -> paused -> useGameState -> state -> hasProgress -> confirmVisible).
|
|
22
|
+
// All three apps independently worked around this with their own bespoke shadow-state ("read last
|
|
23
|
+
// render's value, one commit behind"), converging on the same fix by three different names - a sign
|
|
24
|
+
// the hook's own contract was wrong, not that each call site needed its own patch. A lazy getter
|
|
25
|
+
// breaks the cycle by deferring the read to requestBack's own call time, exactly mirroring how the
|
|
26
|
+
// pre-extraction, hand-rolled version of this pattern in every app read scores/bricks/lives fresh
|
|
27
|
+
// from its own onBackPress closure rather than needing the value available earlier in the render.
|
|
28
|
+
//
|
|
29
|
+
// Mirrored into a ref via this fleet's standard effect-based idiom (useEffect with no dependency
|
|
30
|
+
// array, not a bare render-time assignment - see @rific/core's createSettingsContext.tsx for the
|
|
31
|
+
// same convention) rather than read directly, so a stale closure from an earlier render is never
|
|
32
|
+
// what requestBack ends up calling. This also gives requestBack/cancelBack a stable identity across
|
|
33
|
+
// every render (empty deps arrays), which the original plain-boolean version couldn't offer.
|
|
34
|
+
//
|
|
35
|
+
// Deliberately does NOT own or render a <ConfirmDialog> - title/message/icon/confirmLabel/
|
|
36
|
+
// cancelLabel/rotation are all still game-specific (a score pairing, a lives/bricks count, a
|
|
37
|
+
// round-history pip row) and stay authored at each call site; this only owns the open/closed
|
|
38
|
+
// state and the "should this even ask" gate. onConfirmedBack is a plain callback (every current
|
|
39
|
+
// caller passes its own app's safeBack from @/utils/navigation) rather than hardcoded, so this
|
|
40
|
+
// package takes on no expo-router dependency.
|
|
41
|
+
export function useQuitConfirmation(hasProgress: () => boolean, onConfirmedBack: () => void): QuitConfirmation {
|
|
42
|
+
const [confirmVisible, setConfirmVisible] = useState(false)
|
|
43
|
+
|
|
44
|
+
const hasProgressRef = useRef(hasProgress)
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
hasProgressRef.current = hasProgress
|
|
47
|
+
})
|
|
48
|
+
const onConfirmedBackRef = useRef(onConfirmedBack)
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
onConfirmedBackRef.current = onConfirmedBack
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const requestBack = useCallback(() => {
|
|
54
|
+
if (hasProgressRef.current()) setConfirmVisible(true)
|
|
55
|
+
else onConfirmedBackRef.current()
|
|
56
|
+
}, [])
|
|
57
|
+
|
|
58
|
+
const cancelBack = useCallback(() => setConfirmVisible(false), [])
|
|
59
|
+
|
|
60
|
+
return { confirmVisible, requestBack, cancelBack }
|
|
61
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { useZoneBounds } from '@tastic/split-screen'
|
|
2
|
+
import { useEffect, useState } from 'react'
|
|
3
|
+
import { useWindowDimensions } from 'react-native'
|
|
4
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
5
|
+
|
|
6
|
+
import { PopoverRotation, PopoverVerticalAlign, useAutoAlign } from './useAutoAlign'
|
|
7
|
+
|
|
8
|
+
// Matches useAutoAlign's own EDGE_MARGIN convention — not exported from that file, so re-declared
|
|
9
|
+
// here rather than reaching into its internals.
|
|
10
|
+
const EDGE_MARGIN = 12
|
|
11
|
+
|
|
12
|
+
interface OwnBounds {
|
|
13
|
+
verticalAlign: PopoverVerticalAlign
|
|
14
|
+
maxHeight: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Wraps useAutoAlign with a zone-aware vertical decision for popovers that live inside a
|
|
18
|
+
// @tastic/split-screen zone (see useZoneBounds' own doc) — a no-op outside one (e.g. a solo,
|
|
19
|
+
// non-split-screen screen), where useAutoAlign's own decision is returned untouched.
|
|
20
|
+
//
|
|
21
|
+
// useAutoAlign picks above-vs-below and its own maxHeight by comparing room against the *full
|
|
22
|
+
// device window* — for a trigger inside a zone, that's wrong in a way a simple clamp on top can't
|
|
23
|
+
// fix: capping its chosen direction after the fact still leaves it capable of *choosing* the
|
|
24
|
+
// direction that points into the shared row (or the opposite player's own zone), which, once
|
|
25
|
+
// correctly capped at that boundary, often has hardly any room at all — nearly useless rather than
|
|
26
|
+
// merely mis-sized. The fix has to re-derive the whole vertical decision using the zone's real
|
|
27
|
+
// boundary instead of the window edge, preferring whichever real-world direction is actually safe
|
|
28
|
+
// (away from the shared row — that direction's room really is bounded by the screen edge, same as
|
|
29
|
+
// useAutoAlign already assumes) and only reaching toward the shared row when the content doesn't
|
|
30
|
+
// fit the safe side and the shared side genuinely has more room — the same "only flips when
|
|
31
|
+
// genuinely better" bias useAutoAlign itself uses, just comparing against the zone's own boundary
|
|
32
|
+
// instead of the opposite screen edge.
|
|
33
|
+
//
|
|
34
|
+
// One more twist specific to p2's 180°-rotated zone: PopoverBody positions itself with plain
|
|
35
|
+
// relative CSS (`top:'100%'`/`bottom:'100%'`) against its trigger, resolved in *local*
|
|
36
|
+
// (pre-rotation) space and then rotated along with everything else in the zone — which inverts it,
|
|
37
|
+
// local 'below' the trigger paints as real-world *above* it once the whole zone flips 180°, and
|
|
38
|
+
// vice versa. This hook's result has to be flipped back to the *local* label PopoverBody expects
|
|
39
|
+
// whenever the zone itself is rotated (zone.rotated), or it ends up choosing the real-world
|
|
40
|
+
// direction it meant to avoid — which is exactly the bug an earlier version of this hook had for
|
|
41
|
+
// p2 specifically (it worked correctly for p1, which isn't rotated, and that's what made the bug
|
|
42
|
+
// easy to miss during testing).
|
|
43
|
+
//
|
|
44
|
+
// CORRECTION (2026-09-18): the paragraph above used to also claim "this hook's own direction math
|
|
45
|
+
// is all real-world (from measureInWindow, which — unlike onLayout — already resolves
|
|
46
|
+
// post-transform)" — that's the exact claim useAutoAlign.ts's own doc documents as having already
|
|
47
|
+
// shipped one real, unnoticed bug once (measureInWindow reports the PRE-transform frame, not
|
|
48
|
+
// post-transform; see that file's own comment). This hook still doesn't apply useAutoAlign's own
|
|
49
|
+
// rotateRect(...) correction to its raw measureInWindow read below (awayRoom/towardRoom are
|
|
50
|
+
// computed straight from the uncorrected y/height) — that's a real, currently-unfixed gap, left
|
|
51
|
+
// alone deliberately rather than guessed at: DualZoneLayout's own sharedEdgeY (see
|
|
52
|
+
// React-Native-Split-Screen's DualZoneLayout.tsx, measureShared) has the identical uncorrected-
|
|
53
|
+
// measureInWindow gap, since sharedRef sits under the same live-rotating outer FakeLandscapeView
|
|
54
|
+
// every real caller wraps this in — and zone.rotated alone isn't sufficient to fix just this
|
|
55
|
+
// hook's own half of it (it captures a trigger's own net rotation for p1/p2 specifically, which
|
|
56
|
+
// happens to fully account for the outer rotation there, but sharedEdgeY's own correction needs
|
|
57
|
+
// the outer rotation directly, which differs from zone.rotated for whichever seat zone.rotated is
|
|
58
|
+
// currently true for). Correcting one side without the other risks making the comparison *more*
|
|
59
|
+
// wrong, not less. No current caller in the fleet actually exercises this path today — every real
|
|
60
|
+
// cpuDifficultyAlignOverride call site across the fleet is a confirmed no-op, never actually
|
|
61
|
+
// rendering inside a live DualZoneLayout zone — so nothing currently miscalculates in practice, but
|
|
62
|
+
// this needs real rotated-zone test coverage (ideally live device testing, not just a unit test
|
|
63
|
+
// stubbing measureInWindow) before either hook's room-size/direction math under live device
|
|
64
|
+
// rotation is trustworthy.
|
|
65
|
+
//
|
|
66
|
+
// `rotation` IS threaded through below, though — that part's unambiguous and low-risk on its own:
|
|
67
|
+
// it only ever reaches useAutoAlign's own (rotation-aware) horizontal align decision, which is
|
|
68
|
+
// exactly what a caller gets back verbatim via the `if (!zone) return auto` fallback whenever this
|
|
69
|
+
// hook is used outside a zone at all (its own documented "safe to reach for even in a component
|
|
70
|
+
// that might or might not end up inside one" case) — matching every sibling rotation-aware
|
|
71
|
+
// component's own default of an ambient `useRotation()` read when the caller omits it.
|
|
72
|
+
//
|
|
73
|
+
// The "away" direction's own room also has to stop at the physical safe area, not the raw window
|
|
74
|
+
// edge — useAutoAlign's own windowHeight is the full device height, notch/status bar and home
|
|
75
|
+
// indicator included, so left alone this hook's fix for the shared-row boundary would just trade
|
|
76
|
+
// one overflow (into the other zone) for another (under the status bar, or behind the home
|
|
77
|
+
// indicator). The toward-shared direction doesn't need this: sharedEdgeY already sits well inside
|
|
78
|
+
// the safe content area on its own.
|
|
79
|
+
export function useZoneClampedAlign(open: boolean, contentWidth: number, contentHeight: number, rotation: PopoverRotation = 0): ReturnType<typeof useAutoAlign> {
|
|
80
|
+
const auto = useAutoAlign(open, contentWidth, contentHeight, rotation)
|
|
81
|
+
const zone = useZoneBounds()
|
|
82
|
+
const { height: windowHeight } = useWindowDimensions()
|
|
83
|
+
const insets = useSafeAreaInsets()
|
|
84
|
+
const shouldMeasure = open && auto.measured && zone !== null
|
|
85
|
+
|
|
86
|
+
// null until this popover's own zone-aware measurement lands (see the effect below) — separate
|
|
87
|
+
// from auto.measured, which only covers useAutoAlign's own (zone-blind) measurement.
|
|
88
|
+
const [ownBounds, setOwnBounds] = useState<OwnBounds | null>(null)
|
|
89
|
+
// Resets synchronously during render, not in the effect below, the instant shouldMeasure flips
|
|
90
|
+
// either direction — same "adjust state during render" pattern useAutoAlign itself uses for its
|
|
91
|
+
// own measured reset (see that hook's own comment), so a stale ownBounds from a previous
|
|
92
|
+
// open/zone never survives into a render it doesn't apply to, not even for the one frame before
|
|
93
|
+
// an effect would otherwise catch up.
|
|
94
|
+
const [prevShouldMeasure, setPrevShouldMeasure] = useState(shouldMeasure)
|
|
95
|
+
if (shouldMeasure !== prevShouldMeasure) {
|
|
96
|
+
setPrevShouldMeasure(shouldMeasure)
|
|
97
|
+
setOwnBounds(null)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Re-measures the same trigger node useAutoAlign itself measures, once *its* measurement has
|
|
101
|
+
// landed (auto.measured, folded into shouldMeasure) so the ref is guaranteed mounted and settled.
|
|
102
|
+
// A second measureInWindow call on the same node, not a shared one with useAutoAlign — that
|
|
103
|
+
// hook's own measurement is fully internal to it, with nothing exposed to piggyback on beyond the
|
|
104
|
+
// ref itself.
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (!shouldMeasure) return
|
|
107
|
+
auto.triggerRef.current?.measureInWindow((_x, y, _width, height) => {
|
|
108
|
+
const belowShared = zone!.zoneSide === 'belowShared'
|
|
109
|
+
// Same shape as useAutoAlign's own roomBelow/roomAbove — this is the direction with nothing
|
|
110
|
+
// else between the popover and the real screen edge — but stopping at the safe area inset
|
|
111
|
+
// instead of the raw window edge (insets.top/insets.bottom are always real physical-device
|
|
112
|
+
// edges, unaffected by any rotation applied further down the tree, so this is correct as-is
|
|
113
|
+
// for p2's rotated zone too, not just p1's).
|
|
114
|
+
const awayRoom = belowShared ? windowHeight - insets.bottom - (y + height) - EDGE_MARGIN : y - insets.top - EDGE_MARGIN
|
|
115
|
+
// The other direction runs into the shared row instead of the screen edge.
|
|
116
|
+
const towardRoom = belowShared ? y - zone!.sharedEdgeY - EDGE_MARGIN : zone!.sharedEdgeY - (y + height) - EDGE_MARGIN
|
|
117
|
+
const useToward = contentHeight > awayRoom && towardRoom > awayRoom
|
|
118
|
+
// Real-world direction, before any rotation compensation.
|
|
119
|
+
let awaySide: PopoverVerticalAlign = belowShared ? 'below' : 'above'
|
|
120
|
+
let towardSide: PopoverVerticalAlign = belowShared ? 'above' : 'below'
|
|
121
|
+
// See this hook's own doc — PopoverBody's local-then-rotate positioning inverts 'above'/
|
|
122
|
+
// 'below' for a rotated zone, so the real-world label chosen above has to flip back to the
|
|
123
|
+
// local one that actually produces it once painted.
|
|
124
|
+
if (zone!.rotated) {
|
|
125
|
+
awaySide = awaySide === 'above' ? 'below' : 'above'
|
|
126
|
+
towardSide = towardSide === 'above' ? 'below' : 'above'
|
|
127
|
+
}
|
|
128
|
+
setOwnBounds({ maxHeight: Math.max(useToward ? towardRoom : awayRoom, 0), verticalAlign: useToward ? towardSide : awaySide })
|
|
129
|
+
})
|
|
130
|
+
}, [shouldMeasure, zone, auto.triggerRef, contentHeight, windowHeight, insets.top, insets.bottom])
|
|
131
|
+
|
|
132
|
+
if (!zone) return auto
|
|
133
|
+
if (!ownBounds) return { ...auto, measured: false }
|
|
134
|
+
return { ...auto, maxHeight: ownBounds.maxHeight, measured: auto.measured, verticalAlign: ownBounds.verticalAlign }
|
|
135
|
+
}
|