@tastic/hud 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/hud",
3
- "version": "0.13.0",
3
+ "version": "0.14.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",
@@ -80,13 +80,13 @@
80
80
  "@infinitetoken/eslint-config": "^0.2.0",
81
81
  "@infinitetoken/jest-config": "^0.4.0",
82
82
  "@infinitetoken/tsconfig": "^0.5.0",
83
- "@rific/auto-paper": "^0.12.0",
83
+ "@rific/auto-paper": "^0.12.3",
84
84
  "@rific/core": "^0.2.0",
85
85
  "@rific/feedback-press": "^0.12.0",
86
86
  "@rific/updater": "^0.6.0",
87
87
  "@shopify/react-native-skia": "^2.6.2",
88
88
  "@tastic/achievements": "^0.2.0",
89
- "@tastic/core": "^0.8.0",
89
+ "@tastic/core": "^0.12.0",
90
90
  "@tastic/profile": "^0.7.0",
91
91
  "@tastic/split-screen": "^0.5.0",
92
92
  "@testing-library/dom": "^10.4.1",
@@ -109,12 +109,12 @@
109
109
  "typescript": "^6.0.3"
110
110
  },
111
111
  "peerDependencies": {
112
- "@rific/auto-paper": ">=0.9.4",
112
+ "@rific/auto-paper": ">=0.12.2",
113
113
  "@rific/feedback-press": ">=0.10.2",
114
114
  "@rific/updater": ">=0.4.0",
115
115
  "@shopify/react-native-skia": ">=1.5.0",
116
116
  "@tastic/achievements": ">=0.2.0",
117
- "@tastic/core": ">=0.5.0",
117
+ "@tastic/core": ">=0.12.0",
118
118
  "@tastic/profile": ">=0.4.0",
119
119
  "@tastic/split-screen": ">=0.5.0",
120
120
  "react": ">=19.0.0",
@@ -1,12 +1,13 @@
1
1
  import { AutoAppearancePicker, Dialog, useAutoPaperTheme } from '@rific/auto-paper'
2
2
  import { Button, SoundContext, TouchableRipple, useHapticSettings, useSoundSettings, useVibration } from '@rific/feedback-press'
3
3
  import { useUpdater } from '@rific/updater'
4
- import { toRotationStyle, useIsTouchPrimaryDevice, useRotation, type ViewRotation } from '@tastic/core'
4
+ import { useIsTouchPrimaryDevice, useRotation, type ViewRotation } from '@tastic/core'
5
5
  import { ReactNode, useContext, useState } from 'react'
6
6
  import { Platform, ScrollView, StyleSheet, View } from 'react-native'
7
- import { Icon, Text } from 'react-native-paper'
7
+ import { Icon, Portal, Text } from 'react-native-paper'
8
8
 
9
9
  import { ConfirmDialog } from './ConfirmDialog'
10
+ import { RotatedFrame } from './RotatedFrame'
10
11
  import { useUpdateConfirmBridge } from './useUpdateConfirmBridge'
11
12
 
12
13
  interface SettingIconProps {
@@ -29,11 +30,11 @@ function SettingIcon({ source, color, containerColor }: SettingIconProps) {
29
30
  export interface BaseSettingsDialogProps {
30
31
  visible: boolean
31
32
  onDismiss: () => void
32
- // Explicit override for the live physical-hold rotation (see @tastic/core's useRotation) — this is
33
+ // Explicit override for the live physical-hold rotation (see @tastic/core's useRotation). This is
33
34
  // a centered, app-wide modal with no per-player zone to match (unlike an in-game round-over
34
- // dialog), so it just rotates its own content in place. Defaults to a live ambient read via
35
- // useRotation() when omitted; pass this only when a caller's own reading needs to differ from the
36
- // ambient one (rare — most callers should simply omit it).
35
+ // dialog), so it lays itself out in its own frame turned by this angle (see the return below).
36
+ // Defaults to a live ambient read via useRotation() when omitted. Pass 0 when the dialog already
37
+ // renders under a Portal.Host inside a turned frame of the caller's own (Solitaire's screens).
37
38
  rotation?: ViewRotation
38
39
  // Each app's own OTA version (e.g. release.otaVersion) — not something this package can read
39
40
  // itself, since every consuming app tracks its own release.ts independently. Accepts a number too
@@ -130,175 +131,187 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
130
131
  const showAppearance = !hideAppearance
131
132
  const showUpdateCheck = Platform.OS !== 'web' && !hideUpdateCheck
132
133
 
134
+ // Laid out in its own turned frame rather than rotated in place. Paper's Portal mounts at the app
135
+ // root, full screen and portrait-shaped, so a sheet that only rotated its card kept resolving its
136
+ // maxHeight against the PORTRAIT height: turned 90deg it ran past the ~402pt short edge, and the top
137
+ // row (Lock Orientation, the one you need to get back out of landscape) was clipped with no way to
138
+ // scroll to it. So the whole output goes through one Portal to the root, into a RotatedFrame sized
139
+ // to the turned footprint, with rotated safe-area insets and its own Portal.Host. The Dialog and the
140
+ // two ConfirmDialogs below portal into THAT host, so they lay out against the turned window with no
141
+ // transform of their own (hence rotation={0} on the ConfirmDialogs). The frame is pass-through at
142
+ // every level and stays mounted with this component, which keeps the Dialog's close fade; at
143
+ // rotation 0 it is an identity frame.
133
144
  return (
134
- <>
135
- <Dialog visible={visible} onDismiss={onDismiss} style={[styles.dialog, toRotationStyle(rotation)]}>
136
- <Dialog.Title>Settings</Dialog.Title>
137
- {/* react-native-paper's Dialog.ScrollArea has a fixed 24px marginBottom baked in (meant to
138
- reserve room for a Dialog.Actions row below it) — overridden to 0 since this dialog has
139
- no actions row, and that gap otherwise reads as unexplained empty footer space. */}
140
- <Dialog.ScrollArea style={styles.scrollArea}>
141
- <ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
142
- {/* Grouped tightly together (styles.toggleGroup's small internal gap, not the 24px gap
143
- between sections below) — same bare-row shape for all of them, no segmented control
144
- and no section heading (each row's own label already says what it is). Haptics only
145
- joins on native — there's nothing for it to control on web. */}
146
- <View style={styles.toggleGroup}>
147
- {/* Omitted entirely for a portrait-only game (no lockOrientation/onLockOrientationChange
148
- passed at all — nothing to lock), and hidden (not just disabled) on a desktop/laptop
149
- browser even when they are — see isTouchPrimary's own doc above. Still shown on
150
- native and on a touch-capable mobile browser, where a rotated window really does
151
- reflect a rotated device. */}
152
- {showLockOrientation && (
153
- <TouchableRipple onPress={() => onLockOrientationChange!(!lockOrientation)} style={styles.toggleButton} accessibilityLabel={`Lock orientation ${lockOrientation ? 'on' : 'off'}`}>
154
- <View style={styles.toggleContent}>
155
- <SettingIcon source={lockOrientation ? 'lock' : 'lock-open-variant-outline'} color={lockOrientation ? colors.secondary : colors.onSurfaceVariant} containerColor={lockOrientation ? colors.secondaryContainer : colors.surfaceVariant} />
156
- <View style={styles.flexShrink}>
157
- <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
158
- Lock Orientation
159
- </Text>
160
- <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
161
- Pins the current layout
162
- </Text>
163
- </View>
164
- </View>
165
- </TouchableRipple>
166
- )}
167
-
168
- {/* iOS only — mirrors into UserDefaults for @tastic/edge-guard's own config plugin
169
- swizzle to read. Guards both edges — the top (Notification Center/Control Center)
170
- and the bottom (home-indicator swipe, incidentally Reachability too), per that
171
- package's own doc. Off by default: a deliberate opt-in for whoever's actually hit an
172
- accidental-swipe-near-an-edge problem mid-game and come looking for a fix, not a
173
- surprise every player gets by default. */}
174
- {showEdgeGuard && (
175
- <TouchableRipple onPress={() => onDeferBottomEdgeGestures!(!deferBottomEdgeGestures)} style={styles.toggleButton} accessibilityLabel={`Edge guard ${deferBottomEdgeGestures ? 'on' : 'off'}`}>
176
- <View style={styles.toggleContent}>
177
- <SettingIcon source={deferBottomEdgeGestures ? 'shield-check-outline' : 'shield-off-outline'} color={deferBottomEdgeGestures ? colors.secondary : colors.onSurfaceVariant} containerColor={deferBottomEdgeGestures ? colors.secondaryContainer : colors.surfaceVariant} />
178
- <View style={styles.flexShrink}>
179
- <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
180
- Edge Guard
181
- </Text>
182
- <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
183
- Requires a second swipe
184
- </Text>
185
- </View>
186
- </View>
187
- </TouchableRipple>
188
- )}
189
-
190
- {/* Side by side, not stacked — neither needs a full row's width (single-line label,
191
- no description under it the way Lock Orientation has), so sharing one row reads
192
- just as clearly and takes half the vertical space. toggleRow carries the same
193
- edge-alignment negative margin toggleButton normally carries itself; the buttons
194
- inside it use toggleButtonInRow (flex: 1) instead so the two don't double up on it.
195
- The row itself only renders when at least one of the two will — an empty flex row
196
- still eats its own gap/margin otherwise. */}
197
- {(showSound || showHaptics) && (
198
- <View style={styles.toggleRow}>
199
- {showSound && (
200
- <TouchableRipple
201
- soundDisabled
202
- onPress={() => {
203
- const enabled = !soundSettings.enabled
204
- setSoundSettings({ enabled })
205
- if (enabled) sound.selection?.()
206
- }}
207
- style={styles.toggleButtonInRow}
208
- accessibilityLabel={`Sound ${soundSettings.enabled ? 'on' : 'off'}`}
209
- >
210
- <View style={styles.toggleContent}>
211
- <SettingIcon source={soundSettings.enabled ? 'volume-high' : 'volume-off'} color={soundSettings.enabled ? colors.tertiary : colors.onSurfaceVariant} containerColor={soundSettings.enabled ? colors.tertiaryContainer : colors.surfaceVariant} />
145
+ <Portal>
146
+ <RotatedFrame rotation={rotation} rotatedSafeArea portalHost passThrough>
147
+ <Dialog visible={visible} onDismiss={onDismiss} style={styles.dialog}>
148
+ <Dialog.Title>Settings</Dialog.Title>
149
+ {/* react-native-paper's Dialog.ScrollArea has a fixed 24px marginBottom baked in (meant to
150
+ reserve room for a Dialog.Actions row below it) — overridden to 0 since this dialog has
151
+ no actions row, and that gap otherwise reads as unexplained empty footer space. */}
152
+ <Dialog.ScrollArea style={styles.scrollArea}>
153
+ <ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
154
+ {/* Grouped tightly together (styles.toggleGroup's small internal gap, not the 24px gap
155
+ between sections below) — same bare-row shape for all of them, no segmented control
156
+ and no section heading (each row's own label already says what it is). Haptics only
157
+ joins on native — there's nothing for it to control on web. */}
158
+ <View style={styles.toggleGroup}>
159
+ {/* Omitted entirely for a portrait-only game (no lockOrientation/onLockOrientationChange
160
+ passed at all — nothing to lock), and hidden (not just disabled) on a desktop/laptop
161
+ browser even when they are — see isTouchPrimary's own doc above. Still shown on
162
+ native and on a touch-capable mobile browser, where a rotated window really does
163
+ reflect a rotated device. */}
164
+ {showLockOrientation && (
165
+ <TouchableRipple onPress={() => onLockOrientationChange!(!lockOrientation)} style={styles.toggleButton} accessibilityLabel={`Lock orientation ${lockOrientation ? 'on' : 'off'}`}>
166
+ <View style={styles.toggleContent}>
167
+ <SettingIcon source={lockOrientation ? 'lock' : 'lock-open-variant-outline'} color={lockOrientation ? colors.secondary : colors.onSurfaceVariant} containerColor={lockOrientation ? colors.secondaryContainer : colors.surfaceVariant} />
168
+ <View style={styles.flexShrink}>
212
169
  <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
213
- Sound
170
+ Lock Orientation
171
+ </Text>
172
+ <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
173
+ Pins the current layout
214
174
  </Text>
215
175
  </View>
216
- </TouchableRipple>
217
- )}
176
+ </View>
177
+ </TouchableRipple>
178
+ )}
218
179
 
219
- {/* Already native-only (see showHaptics's own definition) — hideHaptics can hide
220
- it there too, for a game that's deliberately silent on vibration. */}
221
- {showHaptics && (
222
- <TouchableRipple
223
- hapticDisabled
224
- onPress={() => {
225
- const vibrate = !hapticSettings.vibrate
226
- setHapticSettings({ vibrate })
227
- if (vibrate) forceHapticFeedback()
228
- }}
229
- style={styles.toggleButtonInRow}
230
- accessibilityLabel={`Haptics ${hapticSettings.vibrate ? 'on' : 'off'}`}
231
- >
232
- <View style={styles.toggleContent}>
233
- <SettingIcon source={hapticSettings.vibrate ? 'vibrate' : 'vibrate-off'} color={hapticSettings.vibrate ? colors.tertiary : colors.onSurfaceVariant} containerColor={hapticSettings.vibrate ? colors.tertiaryContainer : colors.surfaceVariant} />
180
+ {/* iOS only — mirrors into UserDefaults for @tastic/edge-guard's own config plugin
181
+ swizzle to read. Guards both edges — the top (Notification Center/Control Center)
182
+ and the bottom (home-indicator swipe, incidentally Reachability too), per that
183
+ package's own doc. Off by default: a deliberate opt-in for whoever's actually hit an
184
+ accidental-swipe-near-an-edge problem mid-game and come looking for a fix, not a
185
+ surprise every player gets by default. */}
186
+ {showEdgeGuard && (
187
+ <TouchableRipple onPress={() => onDeferBottomEdgeGestures!(!deferBottomEdgeGestures)} style={styles.toggleButton} accessibilityLabel={`Edge guard ${deferBottomEdgeGestures ? 'on' : 'off'}`}>
188
+ <View style={styles.toggleContent}>
189
+ <SettingIcon source={deferBottomEdgeGestures ? 'shield-check-outline' : 'shield-off-outline'} color={deferBottomEdgeGestures ? colors.secondary : colors.onSurfaceVariant} containerColor={deferBottomEdgeGestures ? colors.secondaryContainer : colors.surfaceVariant} />
190
+ <View style={styles.flexShrink}>
234
191
  <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
235
- Haptics
192
+ Edge Guard
193
+ </Text>
194
+ <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
195
+ Requires a second swipe
236
196
  </Text>
237
197
  </View>
238
- </TouchableRipple>
239
- )}
240
- </View>
241
- )}
242
-
243
- {/* Not a toggle, but it reads like one of the rows above and belongs with them rather than
244
- down in `children` (which sits below Appearance) — this is the "replay the intro"
245
- entry point every game's own how-to-play flow shares. Dismisses this dialog first so
246
- the guide's own overlay never opens on top of a still-visible Settings card. */}
247
- {onShowHowToPlay && (
248
- <TouchableRipple
249
- onPress={() => {
250
- onDismiss()
251
- onShowHowToPlay()
252
- }}
253
- style={styles.toggleButton}
254
- accessibilityLabel='How to Play'
255
- >
256
- <View style={styles.toggleContent}>
257
- <SettingIcon source='help-circle-outline' color={colors.secondary} containerColor={colors.secondaryContainer} />
258
- <View style={styles.flexShrink}>
259
- <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
260
- How to Play
261
- </Text>
262
- <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
263
- Replay the quick intro
264
- </Text>
265
198
  </View>
199
+ </TouchableRipple>
200
+ )}
201
+
202
+ {/* Side by side, not stacked — neither needs a full row's width (single-line label,
203
+ no description under it the way Lock Orientation has), so sharing one row reads
204
+ just as clearly and takes half the vertical space. toggleRow carries the same
205
+ edge-alignment negative margin toggleButton normally carries itself; the buttons
206
+ inside it use toggleButtonInRow (flex: 1) instead so the two don't double up on it.
207
+ The row itself only renders when at least one of the two will — an empty flex row
208
+ still eats its own gap/margin otherwise. */}
209
+ {(showSound || showHaptics) && (
210
+ <View style={styles.toggleRow}>
211
+ {showSound && (
212
+ <TouchableRipple
213
+ soundDisabled
214
+ onPress={() => {
215
+ const enabled = !soundSettings.enabled
216
+ setSoundSettings({ enabled })
217
+ if (enabled) sound.selection?.()
218
+ }}
219
+ style={styles.toggleButtonInRow}
220
+ accessibilityLabel={`Sound ${soundSettings.enabled ? 'on' : 'off'}`}
221
+ >
222
+ <View style={styles.toggleContent}>
223
+ <SettingIcon source={soundSettings.enabled ? 'volume-high' : 'volume-off'} color={soundSettings.enabled ? colors.tertiary : colors.onSurfaceVariant} containerColor={soundSettings.enabled ? colors.tertiaryContainer : colors.surfaceVariant} />
224
+ <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
225
+ Sound
226
+ </Text>
227
+ </View>
228
+ </TouchableRipple>
229
+ )}
230
+
231
+ {/* Already native-only (see showHaptics's own definition) — hideHaptics can hide
232
+ it there too, for a game that's deliberately silent on vibration. */}
233
+ {showHaptics && (
234
+ <TouchableRipple
235
+ hapticDisabled
236
+ onPress={() => {
237
+ const vibrate = !hapticSettings.vibrate
238
+ setHapticSettings({ vibrate })
239
+ if (vibrate) forceHapticFeedback()
240
+ }}
241
+ style={styles.toggleButtonInRow}
242
+ accessibilityLabel={`Haptics ${hapticSettings.vibrate ? 'on' : 'off'}`}
243
+ >
244
+ <View style={styles.toggleContent}>
245
+ <SettingIcon source={hapticSettings.vibrate ? 'vibrate' : 'vibrate-off'} color={hapticSettings.vibrate ? colors.tertiary : colors.onSurfaceVariant} containerColor={hapticSettings.vibrate ? colors.tertiaryContainer : colors.surfaceVariant} />
246
+ <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
247
+ Haptics
248
+ </Text>
249
+ </View>
250
+ </TouchableRipple>
251
+ )}
266
252
  </View>
267
- </TouchableRipple>
268
- )}
269
- </View>
253
+ )}
270
254
 
271
- {showAppearance && (
272
- <View style={styles.section}>
273
- <Text variant='labelMedium' style={[styles.sectionLabel, { color: colors.onSurfaceVariant }]}>
274
- APPEARANCE
275
- </Text>
276
- <AutoAppearancePicker showLabels={false} />
255
+ {/* Not a toggle, but it reads like one of the rows above and belongs with them rather than
256
+ down in `children` (which sits below Appearance) — this is the "replay the intro"
257
+ entry point every game's own how-to-play flow shares. Dismisses this dialog first so
258
+ the guide's own overlay never opens on top of a still-visible Settings card. */}
259
+ {onShowHowToPlay && (
260
+ <TouchableRipple
261
+ onPress={() => {
262
+ onDismiss()
263
+ onShowHowToPlay()
264
+ }}
265
+ style={styles.toggleButton}
266
+ accessibilityLabel='How to Play'
267
+ >
268
+ <View style={styles.toggleContent}>
269
+ <SettingIcon source='help-circle-outline' color={colors.secondary} containerColor={colors.secondaryContainer} />
270
+ <View style={styles.flexShrink}>
271
+ <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
272
+ How to Play
273
+ </Text>
274
+ <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
275
+ Replay the quick intro
276
+ </Text>
277
+ </View>
278
+ </View>
279
+ </TouchableRipple>
280
+ )}
277
281
  </View>
278
- )}
279
282
 
280
- {children}
283
+ {showAppearance && (
284
+ <View style={styles.section}>
285
+ <Text variant='labelMedium' style={[styles.sectionLabel, { color: colors.onSurfaceVariant }]}>
286
+ APPEARANCE
287
+ </Text>
288
+ <AutoAppearancePicker showLabels={false} />
289
+ </View>
290
+ )}
281
291
 
282
- {/* Native only (a web build has no OTA update concept at all, desktop or mobile browser
283
- alike) — hideUpdateCheck can hide it there too, for a native app that isn't wired up
284
- for EAS/OTA updates at all. */}
285
- {showUpdateCheck && (
286
- <View style={styles.section}>
287
- <Text variant='labelSmall' style={[styles.sectionLabel, { color: colors.onSurfaceVariant }]}>
288
- VERSION {version}
289
- {updateReady ? ' · UPDATE READY' : ''}
290
- </Text>
291
- <Button mode='outlined' onPress={check} loading={checking} disabled={checking}>
292
- Check for Updates
293
- </Button>
294
- </View>
295
- )}
296
- </ScrollView>
297
- </Dialog.ScrollArea>
298
- </Dialog>
299
- <ConfirmDialog visible={!!updateManifest} title='Update Available' message={updateManifestDate ? `Released ${updateManifestDate.toLocaleDateString()} at ${updateManifestDate.toLocaleTimeString()}` : ''} confirmLabel='Restart' cancelLabel='Later' icon='update' rotation={rotation} onConfirm={() => respondToUpdateConfirm(true)} onCancel={() => respondToUpdateConfirm(false)} />
300
- <ConfirmDialog visible={!!infoMessage} title={infoMessage?.title ?? ''} message={infoMessage?.message ?? ''} confirmLabel='OK' icon='information-outline' rotation={rotation} onConfirm={() => setInfoMessage(null)} />
301
- </>
292
+ {children}
293
+
294
+ {/* Native only (a web build has no OTA update concept at all, desktop or mobile browser
295
+ alike) — hideUpdateCheck can hide it there too, for a native app that isn't wired up
296
+ for EAS/OTA updates at all. */}
297
+ {showUpdateCheck && (
298
+ <View style={styles.section}>
299
+ <Text variant='labelSmall' style={[styles.sectionLabel, { color: colors.onSurfaceVariant }]}>
300
+ VERSION {version}
301
+ {updateReady ? ' · UPDATE READY' : ''}
302
+ </Text>
303
+ <Button mode='outlined' onPress={check} loading={checking} disabled={checking}>
304
+ Check for Updates
305
+ </Button>
306
+ </View>
307
+ )}
308
+ </ScrollView>
309
+ </Dialog.ScrollArea>
310
+ </Dialog>
311
+ <ConfirmDialog visible={!!updateManifest} title='Update Available' message={updateManifestDate ? `Released ${updateManifestDate.toLocaleDateString()} at ${updateManifestDate.toLocaleTimeString()}` : ''} confirmLabel='Restart' cancelLabel='Later' icon='update' rotation={0} onConfirm={() => respondToUpdateConfirm(true)} onCancel={() => respondToUpdateConfirm(false)} />
312
+ <ConfirmDialog visible={!!infoMessage} title={infoMessage?.title ?? ''} message={infoMessage?.message ?? ''} confirmLabel='OK' icon='information-outline' rotation={0} onConfirm={() => setInfoMessage(null)} />
313
+ </RotatedFrame>
314
+ </Portal>
302
315
  )
303
316
  }
304
317
 
@@ -0,0 +1,71 @@
1
+ import { FakeLandscapeView, rotateDimensions, rotateInsets, useRotation, type ViewRotation } from '@tastic/core'
2
+ import { type ReactNode, useMemo } from 'react'
3
+ import { StyleSheet, useWindowDimensions, View } from 'react-native'
4
+ import { Portal } from 'react-native-paper'
5
+ import { SafeAreaInsetsContext, useSafeAreaInsets } from 'react-native-safe-area-context'
6
+
7
+ export interface RotatedFrameProps {
8
+ children: ReactNode
9
+ /** The angle to turn the frame by. Defaults to the live physical hold (@tastic/core's useRotation); pass it
10
+ * when the caller already has the exact angle it wants, e.g. a dialog handed its caller's rotation, or 0 for
11
+ * one that already sits inside a turned frame. */
12
+ rotation?: ViewRotation
13
+ /** Give everything inside the frame the safe-area insets already remapped onto the frame's own edges (see
14
+ * @tastic/core's rotateInsets), so a component that reads `useSafeAreaInsets()` itself (a library screen,
15
+ * Paper's Modal, a toaster) lands on the right edge when the frame is turned. Leave it off for content that
16
+ * rotates the insets itself, which would otherwise rotate them twice. */
17
+ rotatedSafeArea?: boolean
18
+ /** Mount a Paper Portal.Host INSIDE the frame, so a dialog (Paper's Portal otherwise mounts at the app root,
19
+ * outside the frame) is laid out against the frame's footprint and turned with it. The dialog must then be
20
+ * told `rotation={0}`. */
21
+ portalHost?: boolean
22
+ /** Let touches fall through the frame to whatever is beneath it, so only its children claim them. For a frame
23
+ * laid OVER other content (a dialog layer, a root-level overlay); a screen that owns the window leaves it off. */
24
+ passThrough?: boolean
25
+ /** Cap on the content column's width in the WIDE layout only (the rotated footprint is landscape), centred: a
26
+ * stats list or a roster stretched across an 874pt-wide landscape screen reads badly. Portrait is never capped,
27
+ * so a portrait iPad or desktop window keeps its full width. Omit for no cap. */
28
+ maxContentWidth?: number
29
+ }
30
+
31
+ /** A frame turned to follow the phone while the OS stays portrait-locked (see @tastic/core's FakeLandscapeView),
32
+ * sized to the turned footprint so its content lays out against the short edge at +/-90. The tree is IDENTICAL
33
+ * at every angle, so a rotation never remounts what is inside: `FakeLandscapeView > SafeArea provider >
34
+ * [Portal.Host] > column`. `rotation`, `rotatedSafeArea`, `passThrough` and `maxContentWidth` only change styles
35
+ * and values, but `portalHost` adds or removes the Portal.Host element, so it must be STATIC per call site
36
+ * (flipping it at runtime would remount everything inside).
37
+ *
38
+ * Safe for tap-driven content only: react-native-gesture-handler's translation deltas are untransformed, so
39
+ * never put a gesture-tracked game board inside it. */
40
+ export function RotatedFrame({ children, rotation: rotationOverride, rotatedSafeArea = false, portalHost = false, passThrough = false, maxContentWidth }: RotatedFrameProps) {
41
+ const ambientRotation = useRotation()
42
+ const rotation = rotationOverride ?? ambientRotation
43
+ const window = useWindowDimensions()
44
+ const { width, height } = rotateDimensions(window.width, window.height, rotation)
45
+ const rawInsets = useSafeAreaInsets()
46
+ const insets = useMemo(() => (rotatedSafeArea ? rotateInsets(rawInsets, rotation) : rawInsets), [rotatedSafeArea, rawInsets, rotation])
47
+ const cappedWidth = maxContentWidth !== undefined && width > height ? maxContentWidth : undefined
48
+ const column = (
49
+ <View style={[styles.column, cappedWidth !== undefined && { maxWidth: cappedWidth }]} pointerEvents={passThrough ? 'box-none' : undefined}>
50
+ {children}
51
+ </View>
52
+ )
53
+
54
+ return (
55
+ <FakeLandscapeView rotation={rotation} passThrough={passThrough} style={styles.root}>
56
+ <SafeAreaInsetsContext.Provider value={insets}>{portalHost ? <Portal.Host>{column}</Portal.Host> : column}</SafeAreaInsetsContext.Provider>
57
+ </FakeLandscapeView>
58
+ )
59
+ }
60
+
61
+ const styles = StyleSheet.create({
62
+ // A centred column: full width up to `maxWidth`, so a capped frame sits in the middle of a wider window.
63
+ column: {
64
+ alignSelf: 'center',
65
+ flex: 1,
66
+ width: '100%'
67
+ },
68
+ root: {
69
+ flex: 1
70
+ }
71
+ })
package/src/index.ts CHANGED
@@ -18,6 +18,7 @@ export { getColorPopoverId, getCpuDifficultyPopoverId, PlayerSetupPanel, type Pl
18
18
  export { PopoverBody } from './PopoverBody'
19
19
  export { PressAwayOverlay } from './PressAwayOverlay'
20
20
  export { ReadyButton } from './ReadyButton'
21
+ export { RotatedFrame, type RotatedFrameProps } from './RotatedFrame'
21
22
  export { type MenuOption, type MenuSection, type MultiSelectSection, SectionedDropdown, type SingleSelectSection } from './SectionedDropdown'
22
23
  export { SharedActionBand } from './SharedActionBand'
23
24
  export { SkiaGate, type SkiaGateProps } from './SkiaGate'