@tastic/hud 0.7.0 → 0.8.2
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 +48 -8
- package/dist/SkiaGate.d.mts +13 -0
- package/dist/SkiaGate.d.ts +13 -0
- package/dist/SkiaGate.js +45 -0
- package/dist/SkiaGate.mjs +6 -0
- package/dist/chunk-XEOFR236.mjs +21 -0
- package/dist/index.d.mts +20 -17
- package/dist/index.d.ts +20 -17
- package/dist/index.js +250 -214
- package/dist/index.mjs +166 -145
- package/package.json +13 -6
- package/src/BaseSettingsDialog.tsx +9 -6
- package/src/BaseStatsScreen.tsx +54 -93
- package/src/ConfirmDialog.tsx +29 -3
- package/src/InlineColorPicker.tsx +10 -4
- package/src/SectionedDropdown.tsx +17 -3
- package/src/SkiaGate.tsx +45 -0
- package/src/SkiaGate.web.tsx +9 -0
- package/src/TriggerGaugeHost.tsx +12 -20
- package/src/index.ts +8 -7
- package/src/useAutoAlign.ts +58 -16
package/src/BaseStatsScreen.tsx
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
2
|
import { Button, IconButton } from '@rific/feedback-press'
|
|
3
|
+
import { useRotation } from '@tastic/core'
|
|
3
4
|
import { ReactNode, useState } from 'react'
|
|
4
5
|
import { ScrollView, StyleSheet, View } from 'react-native'
|
|
5
|
-
import {
|
|
6
|
+
import { Text } from 'react-native-paper'
|
|
7
|
+
|
|
8
|
+
import { ConfirmDialog } from './ConfirmDialog'
|
|
6
9
|
|
|
7
10
|
// react-native-paper's own MD3 Text variant names — mirrored locally rather than importing its
|
|
8
11
|
// internal VariantProp/MD3TypescaleKey (not exported from the package's public entry point) just
|
|
@@ -10,12 +13,13 @@ import { Icon, Portal, Text } from 'react-native-paper'
|
|
|
10
13
|
type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall'
|
|
11
14
|
|
|
12
15
|
interface Props {
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
16
|
+
// Explicit override for the live physical-hold rotation (see @tastic/core's useRotation) — only
|
|
17
|
+
// affects this component's own Portal-rendered reset-confirm dialog (via ConfirmDialog's own
|
|
18
|
+
// rotation prop), which (like any Portal content) is unaffected by whichever screen's own
|
|
19
|
+
// FakeLandscapeView wraps the trigger and so has to rotate itself. Defaults to a live ambient
|
|
20
|
+
// read via useRotation() when omitted — note this does NOT make the header/scrollable content
|
|
21
|
+
// below rotate; the caller's own screen still needs its own FakeLandscapeView wrap for that
|
|
22
|
+
// (this component has no transform on its own main body).
|
|
19
23
|
rotation?: number
|
|
20
24
|
title?: string
|
|
21
25
|
// 'headlineSmall' (this screen's own default, below) — was originally 'displaySmall' (the full
|
|
@@ -35,54 +39,50 @@ interface Props {
|
|
|
35
39
|
resetLabel?: string
|
|
36
40
|
resetConfirmTitle?: string
|
|
37
41
|
resetConfirmBody?: string
|
|
38
|
-
// Independently optional overrides for this screen's own background
|
|
39
|
-
// to the literal-black/white-by-appearance formula described below when omitted, so
|
|
40
|
-
// existing caller (none of which pass these) renders identically to before. A caller with
|
|
41
|
-
// app-wide chrome palette (e.g. a tinted background rather than neutral black/white)
|
|
42
|
-
// whichever of the
|
|
43
|
-
//
|
|
42
|
+
// Independently optional overrides for this screen's own header/background colors — each
|
|
43
|
+
// defaults to the literal-black/white-by-appearance formula described below when omitted, so
|
|
44
|
+
// every existing caller (none of which pass these) renders identically to before. A caller with
|
|
45
|
+
// its own app-wide chrome palette (e.g. a tinted background rather than neutral black/white)
|
|
46
|
+
// passes whichever of the two it needs.
|
|
47
|
+
//
|
|
48
|
+
// There used to be a third override here, cardBg, plus a separate accentColor override for the
|
|
49
|
+
// reset-confirm dialog's own icon/title/card. Both are gone: the reset-confirm step used to be a
|
|
50
|
+
// hand-rolled lookalike of ConfirmDialog with its own copy of the overlay/card/button styling,
|
|
51
|
+
// which is exactly how it drifted from ConfirmDialog's real look in the first place (different
|
|
52
|
+
// button layout, different Cancel styling, and — via cardBg — a different, caller-tintable card
|
|
53
|
+
// background no other ConfirmDialog in the fleet has). It now renders a real ConfirmDialog
|
|
54
|
+
// instance below instead of its own copy, which by construction can never drift from every other
|
|
55
|
+
// confirm prompt in the app again — at the cost of losing that per-caller tinting, which is the
|
|
56
|
+
// right trade: a "you're about to erase everything" prompt should look and read identically
|
|
57
|
+
// everywhere, not take on whichever screen happened to trigger it.
|
|
44
58
|
fg?: string
|
|
45
59
|
bg?: string
|
|
46
|
-
cardBg?: string
|
|
47
|
-
// Overrides the confirm-overlay's alert icon/title, which otherwise default to auto-paper's
|
|
48
|
-
// colors.secondary. That default is tuned to read against auto-paper's OWN background role, not
|
|
49
|
-
// necessarily against a cardBg override above — a caller passing cardBg should generally pass a
|
|
50
|
-
// matching accentColor too, rather than risk colors.secondary landing close in hue/lightness to
|
|
51
|
-
// its own custom card background (this is what actually happened with a tinted felt cardBg in the
|
|
52
|
-
// app this was first overridden for). Every button on this screen (the reset trigger included)
|
|
53
|
-
// fills with colors.secondary/onSecondary as a self-contained, always-legible pair instead of
|
|
54
|
-
// relying on a text-only color read against the surrounding chrome, so none of them need this —
|
|
55
|
-
// only the bare icon/title do.
|
|
56
|
-
accentColor?: string
|
|
57
60
|
children: ReactNode
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
// The stats/achievements screen shell every @tastic game shares — back button + title header,
|
|
61
64
|
// scrollable content area, and an optional reset-everything action with its own confirm-before-
|
|
62
|
-
// destroying
|
|
65
|
+
// destroying dialog — extracted after the same header/scroll/reset-confirm shape was independently
|
|
63
66
|
// built into LightCycles' own achievements.tsx. Colors default to the same literal-black/white-by-
|
|
64
67
|
// appearance formula BaseSettingsDialog hardcodes internally (same as this package's own
|
|
65
|
-
// BaseSettingsDialog) — zero-config for every existing caller — but fg/bg
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
export function BaseStatsScreen({ rotation
|
|
68
|
+
// BaseSettingsDialog) — zero-config for every existing caller — but fg/bg are each independently
|
|
69
|
+
// overridable for a caller whose own app-wide chrome palette isn't that literal black/white
|
|
70
|
+
// convention. `children` is the seam for whatever isn't shared: this component has no opinion on
|
|
71
|
+
// what a "stat" is or how achievements are catalogued — see StatRow/StatSection/AchievementRow for
|
|
72
|
+
// the smaller presentational pieces built to go inside it.
|
|
73
|
+
export function BaseStatsScreen({ rotation: rotationOverride, title = 'Achievements', titleVariant = 'headlineSmall', onBack, insets, onReset, resetLabel = 'Reset All Stats', resetConfirmTitle = 'Reset Everything?', resetConfirmBody = 'This permanently erases all stats and achievements. This cannot be undone.', fg: fgOverride, bg: bgOverride, children }: Props) {
|
|
71
74
|
const { dark, colors } = useAutoPaperTheme()
|
|
75
|
+
const ambientRotation = useRotation()
|
|
76
|
+
const rotation = rotationOverride ?? ambientRotation
|
|
72
77
|
const [confirmResetVisible, setConfirmResetVisible] = useState(false)
|
|
73
78
|
const showReset = !!onReset
|
|
74
79
|
|
|
75
80
|
// High-contrast retro look by default: literal black/white by appearance, matching every other
|
|
76
81
|
// screen in this ecosystem (title/loadout/settings all use this identical formula) rather than
|
|
77
|
-
// auto-paper's own (slightly tinted) background role
|
|
78
|
-
//
|
|
79
|
-
// whose own chrome isn't that literal black/white convention; cardBorder has no override (see the
|
|
80
|
-
// Props doc above for why).
|
|
82
|
+
// auto-paper's own (slightly tinted) background role. Overridable per-instance via the props
|
|
83
|
+
// above for a caller whose own chrome isn't that literal black/white convention.
|
|
81
84
|
const fg = fgOverride ?? (dark ? '#FFFFFF' : '#000000')
|
|
82
85
|
const bg = bgOverride ?? (dark ? '#000000' : '#FFFFFF')
|
|
83
|
-
const cardBg = cardBgOverride ?? (dark ? '#111111' : '#F2F2F2')
|
|
84
|
-
const cardBorder = dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'
|
|
85
|
-
const accentColor = accentColorOverride ?? colors.secondary
|
|
86
86
|
|
|
87
87
|
return (
|
|
88
88
|
<View style={[styles.container, { backgroundColor: bg }]}>
|
|
@@ -97,42 +97,27 @@ export function BaseStatsScreen({ rotation = 0, title = 'Achievements', titleVar
|
|
|
97
97
|
{children}
|
|
98
98
|
|
|
99
99
|
{showReset && (
|
|
100
|
-
<Button mode='contained' onPress={() => setConfirmResetVisible(true)} buttonColor={colors.
|
|
100
|
+
<Button mode='contained' onPress={() => setConfirmResetVisible(true)} buttonColor={colors.danger} textColor={colors.onDanger} style={styles.resetButton}>
|
|
101
101
|
{resetLabel}
|
|
102
102
|
</Button>
|
|
103
103
|
)}
|
|
104
104
|
</ScrollView>
|
|
105
105
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
mode='contained'
|
|
122
|
-
onPress={() => {
|
|
123
|
-
onReset?.()
|
|
124
|
-
setConfirmResetVisible(false)
|
|
125
|
-
}}
|
|
126
|
-
style={styles.overlayButton}
|
|
127
|
-
buttonColor={colors.secondary}
|
|
128
|
-
textColor={colors.onSecondary}
|
|
129
|
-
>
|
|
130
|
-
Reset
|
|
131
|
-
</Button>
|
|
132
|
-
</View>
|
|
133
|
-
</View>
|
|
134
|
-
</Portal>
|
|
135
|
-
)}
|
|
106
|
+
<ConfirmDialog
|
|
107
|
+
visible={confirmResetVisible}
|
|
108
|
+
title={resetConfirmTitle}
|
|
109
|
+
message={resetConfirmBody}
|
|
110
|
+
confirmLabel='Reset'
|
|
111
|
+
cancelLabel='Cancel'
|
|
112
|
+
icon='alert-outline'
|
|
113
|
+
destructive
|
|
114
|
+
rotation={rotation}
|
|
115
|
+
onCancel={() => setConfirmResetVisible(false)}
|
|
116
|
+
onConfirm={() => {
|
|
117
|
+
onReset?.()
|
|
118
|
+
setConfirmResetVisible(false)
|
|
119
|
+
}}
|
|
120
|
+
/>
|
|
136
121
|
</View>
|
|
137
122
|
)
|
|
138
123
|
}
|
|
@@ -151,30 +136,6 @@ const styles = StyleSheet.create({
|
|
|
151
136
|
gap: 4,
|
|
152
137
|
paddingBottom: 8
|
|
153
138
|
},
|
|
154
|
-
// Same overlay shape BaseSettingsDialog's own info overlay uses (backdrop + centered card),
|
|
155
|
-
// rotated in place the same way rather than via Portal-root transform for the same reason — see
|
|
156
|
-
// that component's own identical styles for the full rationale.
|
|
157
|
-
overlay: {
|
|
158
|
-
alignItems: 'center',
|
|
159
|
-
backgroundColor: 'rgba(0,0,0,0.72)',
|
|
160
|
-
bottom: 0,
|
|
161
|
-
justifyContent: 'center',
|
|
162
|
-
left: 0,
|
|
163
|
-
position: 'absolute',
|
|
164
|
-
right: 0,
|
|
165
|
-
top: 0
|
|
166
|
-
},
|
|
167
|
-
overlayBody: { textAlign: 'center' },
|
|
168
|
-
overlayButton: { width: 160 },
|
|
169
|
-
overlayCard: {
|
|
170
|
-
alignItems: 'center',
|
|
171
|
-
borderRadius: 20,
|
|
172
|
-
borderWidth: 1,
|
|
173
|
-
gap: 16,
|
|
174
|
-
maxWidth: 360,
|
|
175
|
-
padding: 32
|
|
176
|
-
},
|
|
177
|
-
overlayTitle: { fontWeight: 'bold', marginBottom: -8 },
|
|
178
139
|
resetButton: {
|
|
179
140
|
marginTop: 16
|
|
180
141
|
},
|
package/src/ConfirmDialog.tsx
CHANGED
|
@@ -69,11 +69,24 @@ export function ConfirmDialog({ visible, title, message, confirmLabel, cancelLab
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
const styles = StyleSheet.create({
|
|
72
|
+
// flexBasis (not flex: 1's own 0%) is each button's real floor: whenever both fit side by side,
|
|
73
|
+
// flexGrow still splits any leftover width evenly beyond that floor, same visual result as the
|
|
74
|
+
// old flex: 1 on a roomy screen - but a button can never be squeezed narrower than 128, only
|
|
75
|
+
// wrap to its own row instead (see actions' flexWrap below). react-native-paper's Button
|
|
76
|
+
// hardcodes numberOfLines={1} on its label with no override, so a button squeezed under its
|
|
77
|
+
// label's natural width silently ellipsizes ("Cancel" -> "Can...") instead of wrapping - assumed
|
|
78
|
+
// fixable by just giving the row more width, until confirmed live that these two-word labels
|
|
79
|
+
// (Cancel/Reset/New Game) can still lose the truncation fight even with the extra room card's own
|
|
80
|
+
// width fix below provides, on a narrow enough screen. flexBasis is the real fix: past a certain
|
|
81
|
+
// width there's no side-by-side arrangement that fits both buttons at their natural label size,
|
|
82
|
+
// so the layout has to give (stack) rather than the label (truncate).
|
|
72
83
|
actionButton: {
|
|
73
|
-
|
|
84
|
+
flexBasis: 128,
|
|
85
|
+
flexGrow: 1
|
|
74
86
|
},
|
|
75
87
|
actions: {
|
|
76
88
|
flexDirection: 'row',
|
|
89
|
+
flexWrap: 'wrap',
|
|
77
90
|
gap: 12,
|
|
78
91
|
width: '100%'
|
|
79
92
|
},
|
|
@@ -86,7 +99,12 @@ const styles = StyleSheet.create({
|
|
|
86
99
|
borderWidth: 1,
|
|
87
100
|
gap: 16,
|
|
88
101
|
maxWidth: 360,
|
|
89
|
-
padding: 32
|
|
102
|
+
padding: 32,
|
|
103
|
+
// Claims its full allowance instead of just maxWidth alone, which only caps how wide the card
|
|
104
|
+
// is allowed to get - without an explicit width, a flex-centered child sizes to its own content
|
|
105
|
+
// instead of stretching, so the card (and therefore actions below) can end up narrower than the
|
|
106
|
+
// screen actually has room for, needlessly forcing the action row to wrap sooner than it has to.
|
|
107
|
+
width: '100%'
|
|
90
108
|
},
|
|
91
109
|
overlay: {
|
|
92
110
|
alignItems: 'center',
|
|
@@ -94,11 +112,19 @@ const styles = StyleSheet.create({
|
|
|
94
112
|
bottom: 0,
|
|
95
113
|
justifyContent: 'center',
|
|
96
114
|
left: 0,
|
|
115
|
+
// Gives the card room to breathe from the screen edges now that it reliably stretches to fill
|
|
116
|
+
// its full width allowance (see card's own width comment) - without this, width: '100%' would
|
|
117
|
+
// run the card edge-to-edge on a screen narrower than maxWidth's own 360.
|
|
118
|
+
paddingHorizontal: 24,
|
|
97
119
|
position: 'absolute',
|
|
98
120
|
right: 0,
|
|
99
121
|
top: 0
|
|
100
122
|
},
|
|
123
|
+
// textAlign is load-bearing, not decorative, once `title` wraps to more than one line - centered
|
|
124
|
+
// within card's own alignItems: 'center' only centers the Text's block as a whole; each wrapped
|
|
125
|
+
// line inside that block still defaults to left-aligned without this.
|
|
101
126
|
title: {
|
|
102
|
-
fontWeight: 'bold'
|
|
127
|
+
fontWeight: 'bold',
|
|
128
|
+
textAlign: 'center'
|
|
103
129
|
}
|
|
104
130
|
})
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { defaultColors, getContrastColor, SeedColor } from '@rific/auto-paper'
|
|
2
2
|
import { TouchableRipple } from '@rific/feedback-press'
|
|
3
|
-
import { clamp } from '@tastic/core'
|
|
3
|
+
import { clamp, useRotation } from '@tastic/core'
|
|
4
4
|
import { ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'
|
|
5
5
|
import { Icon, Text } from 'react-native-paper'
|
|
6
6
|
|
|
7
7
|
import { MONO_FONT } from './fonts'
|
|
8
8
|
import { PopoverBody } from './PopoverBody'
|
|
9
|
-
import { useAutoAlign } from './useAutoAlign'
|
|
9
|
+
import { PopoverRotation, useAutoAlign } from './useAutoAlign'
|
|
10
10
|
import { PopoverHost } from './usePopoverHost'
|
|
11
11
|
|
|
12
12
|
// Presence check, not a full-string "is this exactly one emoji" validation — this only decides a
|
|
@@ -62,6 +62,10 @@ interface Props {
|
|
|
62
62
|
// Manual override — omit to let the popover measure its own trigger and pick whichever alignment
|
|
63
63
|
// keeps it from overflowing the screen edge (see useAutoAlign).
|
|
64
64
|
align?: 'left' | 'right' | 'center'
|
|
65
|
+
// Explicit override for the rotation a @tastic/split-screen-style FakeLandscapeView (or
|
|
66
|
+
// equivalent) ancestor is currently applying — see SectionedDropdown's identical prop for the
|
|
67
|
+
// full reasoning. Defaults to a live ambient read via @tastic/core's useRotation() when omitted.
|
|
68
|
+
rotation?: PopoverRotation
|
|
65
69
|
// Trigger glyph — defaults to a plain palette. Pass a distinct icon per slot (e.g. a face for a
|
|
66
70
|
// human, a robot for CPU) to convey identity through the icon itself. Overridden by `tag` below
|
|
67
71
|
// when present.
|
|
@@ -89,7 +93,9 @@ interface Props {
|
|
|
89
93
|
// Renders inline rather than as a full-screen modal, scoped to its own panel — a modal color
|
|
90
94
|
// picker would block the whole screen for one player while another can't touch their own panel at
|
|
91
95
|
// the same time, which defeats the point of a split-screen lobby.
|
|
92
|
-
export function InlineColorPicker({ id, host, value, onChange, previewValue, swatches = defaultColors, takenValue, allowSwapTaken, dark, align: alignOverride, icon = 'palette', tag, labelFontFamily = MONO_FONT, autoDismiss = true, columns, size = DEFAULT_SIZE }: Props) {
|
|
96
|
+
export function InlineColorPicker({ id, host, value, onChange, previewValue, swatches = defaultColors, takenValue, allowSwapTaken, dark, align: alignOverride, rotation: rotationOverride, icon = 'palette', tag, labelFontFamily = MONO_FONT, autoDismiss = true, columns, size = DEFAULT_SIZE }: Props) {
|
|
97
|
+
const ambientRotation = useRotation()
|
|
98
|
+
const rotation = rotationOverride ?? ambientRotation
|
|
93
99
|
const menuBg = dark ? '#000000' : '#FFFFFF'
|
|
94
100
|
const { width: windowWidth } = useWindowDimensions()
|
|
95
101
|
const autoColumns = clamp(Math.floor((windowWidth - 2 * SCREEN_MARGIN + SWATCHES_GAP) / (SWATCH_SIZE + SWATCHES_GAP)), MIN_COLUMNS, MAX_COLUMNS)
|
|
@@ -99,7 +105,7 @@ export function InlineColorPicker({ id, host, value, onChange, previewValue, swa
|
|
|
99
105
|
const swatchesHeight = SWATCHES_BORDER_WIDTH * 2 + SWATCHES_PADDING * 2 + SWATCH_SIZE * swatchRows + SWATCHES_GAP * (swatchRows - 1)
|
|
100
106
|
|
|
101
107
|
const open = host.openId === id
|
|
102
|
-
const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, swatchesWidth, swatchesHeight)
|
|
108
|
+
const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, swatchesWidth, swatchesHeight, rotation)
|
|
103
109
|
const align = alignOverride ?? autoAlign
|
|
104
110
|
const displayFor = previewValue ?? ((hex: string) => hex)
|
|
105
111
|
const displayValue = displayFor(value)
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { getBlendedColor, getColorRoles } from '@rific/auto-paper'
|
|
2
2
|
import { IconButton, TouchableRipple } from '@rific/feedback-press'
|
|
3
|
+
import { useRotation } from '@tastic/core'
|
|
3
4
|
import { ScrollView, StyleSheet, TextStyle, View } from 'react-native'
|
|
4
5
|
import { Icon, Text } from 'react-native-paper'
|
|
5
6
|
|
|
6
7
|
import { MONO_FONT } from './fonts'
|
|
7
8
|
import { PopoverBody } from './PopoverBody'
|
|
8
9
|
import TriggerGaugeHost from './TriggerGaugeHost'
|
|
9
|
-
import { useAutoAlign } from './useAutoAlign'
|
|
10
|
+
import { PopoverRotation, useAutoAlign } from './useAutoAlign'
|
|
10
11
|
import { PopoverHost } from './usePopoverHost'
|
|
11
12
|
|
|
12
13
|
const MENU_BORDER_WIDTH = 1
|
|
@@ -99,6 +100,17 @@ interface Props {
|
|
|
99
100
|
// keeps it from overflowing the screen edge (see useAutoAlign). Only pass this to force a specific
|
|
100
101
|
// side regardless of where the trigger actually sits.
|
|
101
102
|
align?: 'left' | 'right' | 'center'
|
|
103
|
+
// Explicit override for the rotation a @tastic/split-screen-style FakeLandscapeView (or any other
|
|
104
|
+
// ancestor rotated the same way — a bare CSS `transform: rotate()`, not an actual OS-level
|
|
105
|
+
// orientation change) is currently applying. Defaults to a live ambient read via @tastic/core's
|
|
106
|
+
// useRotation() when omitted, so most callers never need to pass this at all. Pass it explicitly
|
|
107
|
+
// only when a caller's own reading needs to differ from the ambient one (e.g. a fading dual-zone
|
|
108
|
+
// layout whose panel content deliberately lags the live reading behind a transition). Correctness
|
|
109
|
+
// matters here regardless of source: measureInWindow reports the trigger's PRE-rotation layout
|
|
110
|
+
// position (see useAutoAlign's own doc comment for why, and why it can't recover this any other
|
|
111
|
+
// way), so a wrong rotation value picks an alignment for where the trigger *used to be* before
|
|
112
|
+
// rotating, clipping off the edge it's actually now near instead of the one it was near.
|
|
113
|
+
rotation?: PopoverRotation
|
|
102
114
|
// Whether picking a value in a *single-select* section closes the popover. Defaults to true
|
|
103
115
|
// (the old, pre-press-away default: pick one thing, you're done). Multi-select rows never
|
|
104
116
|
// auto-close regardless of this — see MultiSelectSection's own comment. Worth setting false for a
|
|
@@ -116,7 +128,9 @@ interface Props {
|
|
|
116
128
|
// powerups are enabled, say) is just this with one 'multi' section (optionally with allClear); a
|
|
117
129
|
// combined menu (e.g. spawn frequency + which types, in one menu) is just two sections in the same
|
|
118
130
|
// array.
|
|
119
|
-
export function SectionedDropdown({ id, host, icon, accessibilityLabel, sections, accentColor, mutedColor, onAccentColor, dark, align: alignOverride, autoDismiss = true, labelFontFamily = MONO_FONT, allClearLabels = { all: 'All', clear: 'Clear' } }: Props) {
|
|
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) {
|
|
132
|
+
const ambientRotation = useRotation()
|
|
133
|
+
const rotation = rotationOverride ?? ambientRotation
|
|
120
134
|
const menuBg = dark ? '#000000' : '#FFFFFF'
|
|
121
135
|
// mutedColor is translucent (fine for the icon/text tints below, each a single paint), but the
|
|
122
136
|
// caret is deliberately drawn overlapping the box's own top border (PopoverBody's CARET_DIP,
|
|
@@ -142,7 +156,7 @@ export function SectionedDropdown({ id, host, icon, accessibilityLabel, sections
|
|
|
142
156
|
const divider = sectionIndex > 0 ? MENU_DIVIDER_HEIGHT : 0
|
|
143
157
|
return sum + rows + footer + divider
|
|
144
158
|
}, 0)
|
|
145
|
-
const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, MENU_MAX_WIDTH, estimatedHeight)
|
|
159
|
+
const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, MENU_MAX_WIDTH, estimatedHeight, rotation)
|
|
146
160
|
const align = alignOverride ?? autoAlign
|
|
147
161
|
|
|
148
162
|
// Trigger reflects the selected option's own icon only when there's exactly one section and it's
|
package/src/SkiaGate.tsx
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from 'react'
|
|
2
|
+
import { useEffect, useState } from 'react'
|
|
3
|
+
|
|
4
|
+
export interface SkiaGateProps<P extends object> {
|
|
5
|
+
// A dynamic import, e.g. `() => import('./Board')` — hoist to module scope at the call site
|
|
6
|
+
// (never an inline closure written directly in JSX) so its identity stays stable across
|
|
7
|
+
// renders; it's this component's own useEffect dependency below.
|
|
8
|
+
getComponent: () => Promise<{ default: ComponentType<P> }>
|
|
9
|
+
componentProps: P
|
|
10
|
+
// Rendered while getComponent()'s promise is still pending. Defaults to null.
|
|
11
|
+
fallback?: ReactNode
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Native Skia renders through JSI — no WASM/CanvasKit fetch to wait on — so this file just
|
|
15
|
+
// lazily mounts whatever getComponent() resolves to, once it resolves. The .web.tsx sibling
|
|
16
|
+
// (picked by Metro/webpack's own platform-extension resolution for web bundles) is a different
|
|
17
|
+
// implementation entirely: a direct re-export of @shopify/react-native-skia's own WithSkiaWeb,
|
|
18
|
+
// which gates the same mount behind LoadSkiaWeb() first — CanvasKit's WASM build has to actually
|
|
19
|
+
// finish loading before anything that imports '@shopify/react-native-skia' can evaluate its own
|
|
20
|
+
// top-level `Skia` binding without crashing (see loadSkiaWeb.web.ts's own comment).
|
|
21
|
+
//
|
|
22
|
+
// No React.lazy()/Suspense here, deliberately: lazy() can't be called inline during render
|
|
23
|
+
// (react-hooks/static-components — a getComponent prop that isn't referentially stable would
|
|
24
|
+
// give a new component identity, and therefore an unwanted remount, on every render that passes
|
|
25
|
+
// a fresh one) and can't be cached in a ref either (react-hooks/refs forbids reading or writing
|
|
26
|
+
// ref.current during render, only inside an effect). Awaiting the import directly and setting
|
|
27
|
+
// state from the resolved module sidesteps both — setState only ever runs from the promise
|
|
28
|
+
// callback, never synchronously in render or in the effect body itself.
|
|
29
|
+
export function SkiaGate<P extends object>({ getComponent, componentProps, fallback = null }: SkiaGateProps<P>) {
|
|
30
|
+
const [Component, setComponent] = useState<ComponentType<P> | null>(null)
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
let cancelled = false
|
|
34
|
+
getComponent().then((mod) => {
|
|
35
|
+
if (!cancelled) setComponent(() => mod.default)
|
|
36
|
+
})
|
|
37
|
+
return () => {
|
|
38
|
+
cancelled = true
|
|
39
|
+
}
|
|
40
|
+
}, [getComponent])
|
|
41
|
+
|
|
42
|
+
if (!Component) return fallback
|
|
43
|
+
|
|
44
|
+
return <Component {...componentProps} />
|
|
45
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Web needs CanvasKit's WASM loaded before any Skia <Canvas> evaluates — see loadSkiaWeb.web.ts's
|
|
2
|
+
// own comment for why a plain useEffect-based loader can't fix this (it's a module-evaluation-
|
|
3
|
+
// order problem, not a component-lifecycle one). Only this file (picked by Metro/webpack's
|
|
4
|
+
// platform-extension resolution for web bundles) may import from
|
|
5
|
+
// '@shopify/react-native-skia/src/web' — a static import of that subpath from a native-reachable
|
|
6
|
+
// file pulls canvaskit-wasm's `require('fs')` into the native bundle and breaks iOS/Android
|
|
7
|
+
// builds; this file vs. SkiaGate.tsx is what actually keeps it out, the same split
|
|
8
|
+
// loadSkiaWeb.web.ts/loadSkiaWeb.ts already use.
|
|
9
|
+
export { WithSkiaWeb as SkiaGate } from '@shopify/react-native-skia/src/web'
|
package/src/TriggerGaugeHost.tsx
CHANGED
|
@@ -1,26 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
import { loadSkiaWeb } from './loadSkiaWeb'
|
|
1
|
+
import { SkiaGate } from './SkiaGate'
|
|
4
2
|
import { TriggerGaugeProps } from './TriggerGauge'
|
|
5
3
|
|
|
6
4
|
// TriggerGauge.tsx's `Skia` import binds to global.CanvasKit at module-evaluation time, which on web
|
|
7
|
-
// only exists once
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
// consumer's trigger icon still shows immediately and the gauge ring just pops in a beat later.
|
|
18
|
-
const LazyTriggerGauge = lazy(() => loadSkiaWeb().then(() => import('./TriggerGauge').then((m) => ({ default: m.TriggerGauge }))))
|
|
5
|
+
// only exists once CanvasKit's WASM fetch resolves — a plain `import { TriggerGauge } from
|
|
6
|
+
// './TriggerGauge'` at the top of this file would already have evaluated that binding, against a
|
|
7
|
+
// not-yet-loaded, undefined CanvasKit, before anything in a consuming app renders at all, since
|
|
8
|
+
// bundlers evaluate the whole static import graph up front. This file therefore never imports
|
|
9
|
+
// TriggerGauge itself except as a type (erased at compile time, carrying no runtime module
|
|
10
|
+
// reference) — getTriggerGauge below, hoisted to module scope so its identity stays stable across
|
|
11
|
+
// renders (see SkiaGate's own comment on why that matters), is what actually defers the real import
|
|
12
|
+
// to runtime. No explicit loadSkiaWeb() call here on web: SkiaGate.web.tsx (WithSkiaWeb) already
|
|
13
|
+
// gates its own mount behind CanvasKit being ready, and on native there's nothing to wait on at all.
|
|
14
|
+
const getTriggerGauge = () => import('./TriggerGauge').then((mod) => ({ default: mod.TriggerGauge }))
|
|
19
15
|
|
|
20
16
|
export default function TriggerGaugeHost(props: TriggerGaugeProps) {
|
|
21
|
-
return
|
|
22
|
-
<Suspense fallback={null}>
|
|
23
|
-
<LazyTriggerGauge {...props} />
|
|
24
|
-
</Suspense>
|
|
25
|
-
)
|
|
17
|
+
return <SkiaGate getComponent={getTriggerGauge} componentProps={props} />
|
|
26
18
|
}
|
package/src/index.ts
CHANGED
|
@@ -15,16 +15,17 @@ export { PressAwayOverlay } from './PressAwayOverlay'
|
|
|
15
15
|
export { ReadyButton } from './ReadyButton'
|
|
16
16
|
export { type MenuOption, type MenuSection, type MultiSelectSection, SectionedDropdown, type SingleSelectSection } from './SectionedDropdown'
|
|
17
17
|
export { SharedActionBand } from './SharedActionBand'
|
|
18
|
+
export { SkiaGate, type SkiaGateProps } from './SkiaGate'
|
|
18
19
|
export { StatRow } from './StatRow'
|
|
19
20
|
export { StatSection } from './StatSection'
|
|
20
21
|
// TriggerGauge itself (the raw Skia-based component) is deliberately NOT exported here, even
|
|
21
|
-
// though a native-only consumer could safely skip TriggerGaugeHost's
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
22
|
+
// though a native-only consumer could safely skip TriggerGaugeHost's SkiaGate indirection. A
|
|
23
|
+
// value re-export forces the bundler to fold TriggerGauge.tsx's own `Skia` import into a chunk
|
|
24
|
+
// that this entry point imports eagerly at the top level — since that chunk is then shared with
|
|
25
|
+
// TriggerGaugeHost's own dynamic import too, it defeats TriggerGaugeHost's deferred loading for
|
|
26
|
+
// every consumer, on every platform, not just the ones reaching for the raw component.
|
|
27
|
+
// TriggerGaugeHost is the only supported way to render this.
|
|
27
28
|
export type { TriggerGaugeProps } from './TriggerGauge'
|
|
28
29
|
export { default as TriggerGaugeHost } from './TriggerGaugeHost'
|
|
29
|
-
export { type PopoverAlign, type PopoverVerticalAlign, useAutoAlign } from './useAutoAlign'
|
|
30
|
+
export { type PopoverAlign, type PopoverRotation, type PopoverVerticalAlign, useAutoAlign } from './useAutoAlign'
|
|
30
31
|
export { type PopoverHost, usePopoverHost } from './usePopoverHost'
|
package/src/useAutoAlign.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ViewRotation } from '@tastic/core'
|
|
1
2
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
3
|
import { useWindowDimensions, View } from 'react-native'
|
|
3
4
|
|
|
@@ -7,15 +8,55 @@ const EDGE_MARGIN = 12
|
|
|
7
8
|
|
|
8
9
|
export type PopoverAlign = 'left' | 'right' | 'center'
|
|
9
10
|
export type PopoverVerticalAlign = 'below' | 'above'
|
|
11
|
+
// Same set of values as @tastic/core's own ViewRotation — re-exported under this package's existing
|
|
12
|
+
// name rather than switching every call site to import ViewRotation directly, since "popover
|
|
13
|
+
// rotation" reads clearer at these call sites than the more general core name.
|
|
14
|
+
export type PopoverRotation = ViewRotation
|
|
10
15
|
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
16
|
+
// Reflects a rect from the PRE-rotation layout frame measureInWindow actually reports (see
|
|
17
|
+
// useAutoAlign's own comment below for why that's what it reports, not the post-rotation visual
|
|
18
|
+
// position) into its true, post-rotation position — needed for a trigger sitting inside a
|
|
19
|
+
// @tastic/split-screen-style FakeLandscapeView, or any other ancestor rotated the same way (a bare
|
|
20
|
+
// CSS `transform: rotate()`, not an actual OS-level orientation change).
|
|
21
|
+
//
|
|
22
|
+
// The pivot is always the window's own center, for every rotation amount: FakeLandscapeView (and
|
|
23
|
+
// this package's own doc-recommended equivalent for a caller that rolls its own) always centers its
|
|
24
|
+
// rotated container — width/height swapped first for ±90°, unchanged for 180° — within the original,
|
|
25
|
+
// un-rotated screen bounds before applying the CSS rotation, so the transform's own effective pivot
|
|
26
|
+
// lands at that shared center regardless of which of the three non-zero rotations it is.
|
|
27
|
+
export function rotateRect(x: number, y: number, width: number, height: number, rotation: PopoverRotation, windowWidth: number, windowHeight: number): { x: number; y: number; width: number; height: number } {
|
|
28
|
+
if (rotation === 0) return { x, y, width, height }
|
|
29
|
+
const cx = windowWidth / 2
|
|
30
|
+
const cy = windowHeight / 2
|
|
31
|
+
const dx = x + width / 2 - cx
|
|
32
|
+
const dy = y + height / 2 - cy
|
|
33
|
+
// Clockwise-positive about (cx, cy) — matches CSS's own rotate(deg) sign convention, which is
|
|
34
|
+
// where every rotation value this ever receives ultimately comes from (getViewRotation and
|
|
35
|
+
// FakeLandscapeView both already commit to that same convention).
|
|
36
|
+
const [newDx, newDy, newWidth, newHeight] = rotation === 180 ? [-dx, -dy, width, height] : rotation === 90 ? [-dy, dx, height, width] : [dy, -dx, height, width]
|
|
37
|
+
return { x: cx + newDx - newWidth / 2, y: cy + newDy - newHeight / 2, width: newWidth, height: newHeight }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Measures a trigger's on-screen position (via measureInWindow) and picks whichever alignment keeps
|
|
41
|
+
// a popover of `contentWidth`×`contentHeight` from overflowing a screen edge — horizontally
|
|
42
|
+
// (left/right/center, preferring center) and vertically (below/above, preferring below — the usual
|
|
43
|
+
// case — but flipping to above when the trigger doesn't have enough room underneath it, which is
|
|
44
|
+
// what a short landscape screen with the trigger row near the top actually runs into).
|
|
45
|
+
//
|
|
46
|
+
// measureInWindow resolves the trigger's PRE-transform layout frame — confirmed against a real
|
|
47
|
+
// rotated screen, not just reasoned about: an earlier version of this comment claimed the opposite
|
|
48
|
+
// (that measureInWindow "resolves post-transform"), which is what let a real bug ship unnoticed,
|
|
49
|
+
// since nothing here ever actually exercised a rotated case to catch that claim being wrong (see
|
|
50
|
+
// this file's own test suite, which now does). A plain `onLayout` reports the same pre-transform
|
|
51
|
+
// frame — there is no plain RN layout API that reports a transformed descendant's true visual
|
|
52
|
+
// position, which is exactly why `rotation` below exists: it's the one piece of information this
|
|
53
|
+
// hook cannot recover from measurement alone, since Yoga's layout pass and a CSS `transform` are
|
|
54
|
+
// deliberately independent stages, so the caller has to supply it.
|
|
55
|
+
//
|
|
56
|
+
// `rotation` defaults to 0 (measureInWindow's raw result used as-is) — every existing caller that
|
|
57
|
+
// never sits inside a rotated ancestor keeps behaving identically; only a caller that both (a) can
|
|
58
|
+
// end up inside a @tastic/split-screen-style rotated zone and (b) knows which rotation is currently
|
|
59
|
+
// live needs to pass it (see SectionedDropdown's own `rotation` prop for the wiring this expects).
|
|
19
60
|
//
|
|
20
61
|
// Also returns `maxHeight`: the actual room available in whichever vertical direction got picked.
|
|
21
62
|
// Flipping above-vs-below only ever picks the *better* side — on a screen short enough that neither
|
|
@@ -27,7 +68,7 @@ export type PopoverVerticalAlign = 'below' | 'above'
|
|
|
27
68
|
//
|
|
28
69
|
// Re-measures on every open (not continuously) — the trigger's screen position can't change while
|
|
29
70
|
// its own popover is showing anyway, so there's nothing to gain from tracking it beyond that.
|
|
30
|
-
export function useAutoAlign(open: boolean, contentWidth: number, contentHeight: number) {
|
|
71
|
+
export function useAutoAlign(open: boolean, contentWidth: number, contentHeight: number, rotation: PopoverRotation = 0) {
|
|
31
72
|
const triggerRef = useRef<View>(null)
|
|
32
73
|
const { width: windowWidth, height: windowHeight } = useWindowDimensions()
|
|
33
74
|
const [align, setAlign] = useState<PopoverAlign>('center')
|
|
@@ -52,7 +93,8 @@ export function useAutoAlign(open: boolean, contentWidth: number, contentHeight:
|
|
|
52
93
|
}
|
|
53
94
|
|
|
54
95
|
const measure = useCallback(() => {
|
|
55
|
-
triggerRef.current?.measureInWindow((
|
|
96
|
+
triggerRef.current?.measureInWindow((rawX, rawY, rawWidth, rawHeight) => {
|
|
97
|
+
const { x, y, width: triggerWidth, height: triggerHeight } = rotateRect(rawX, rawY, rawWidth, rawHeight, rotation, windowWidth, windowHeight)
|
|
56
98
|
const centerX = x + triggerWidth / 2
|
|
57
99
|
const overflowsRight = centerX + contentWidth / 2 > windowWidth - EDGE_MARGIN
|
|
58
100
|
const overflowsLeft = centerX - contentWidth / 2 < EDGE_MARGIN
|
|
@@ -72,14 +114,14 @@ export function useAutoAlign(open: boolean, contentWidth: number, contentHeight:
|
|
|
72
114
|
setMaxHeight(Math.max(above ? roomAbove : roomBelow, 0))
|
|
73
115
|
setMeasured(true)
|
|
74
116
|
})
|
|
75
|
-
}, [contentWidth, contentHeight, windowWidth, windowHeight])
|
|
117
|
+
}, [contentWidth, contentHeight, windowWidth, windowHeight, rotation])
|
|
76
118
|
|
|
77
119
|
// Re-measures whenever `open` flips true, and also if `measure` itself changes identity (i.e.
|
|
78
|
-
// contentWidth/contentHeight/windowWidth/windowHeight changed) while already open — e.g.
|
|
79
|
-
// window resize
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
// under it.
|
|
120
|
+
// contentWidth/contentHeight/windowWidth/windowHeight/rotation changed) while already open — e.g.
|
|
121
|
+
// a live window resize, or the device being physically turned while the popover is showing — so it
|
|
122
|
+
// never goes stale while the popover is actually showing. `measured` only resets on a genuine
|
|
123
|
+
// (re)open, via the render-phase check above — not here on every re-measure, so an already-visible
|
|
124
|
+
// popover never flickers hidden again just because the window resized (or rotated) under it.
|
|
83
125
|
useEffect(() => {
|
|
84
126
|
if (open) measure()
|
|
85
127
|
}, [open, measure])
|