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