@tastic/hud 0.7.0 → 0.8.1
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 -15
- package/dist/index.d.ts +20 -15
- package/dist/index.js +140 -103
- package/dist/index.mjs +43 -21
- package/package.json +13 -6
- package/src/BaseSettingsDialog.tsx +9 -6
- package/src/BaseStatsScreen.tsx +10 -7
- 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/dist/index.mjs
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SkiaGate
|
|
3
|
+
} from "./chunk-XEOFR236.mjs";
|
|
4
|
+
|
|
1
5
|
// src/AchievementRow.tsx
|
|
2
6
|
import { useAutoPaperTheme } from "@rific/auto-paper";
|
|
3
7
|
import { StyleSheet, View } from "react-native";
|
|
@@ -70,7 +74,7 @@ var styles = StyleSheet.create({
|
|
|
70
74
|
import { AutoAppearancePicker, Dialog, useAutoPaperTheme as useAutoPaperTheme2 } from "@rific/auto-paper";
|
|
71
75
|
import { Button, SoundContext, TouchableRipple, useHapticSettings, useSoundSettings, useVibration } from "@rific/feedback-press";
|
|
72
76
|
import { useUpdater } from "@rific/updater";
|
|
73
|
-
import { useIsTouchPrimaryDevice } from "@tastic/core";
|
|
77
|
+
import { useIsTouchPrimaryDevice, useRotation } from "@tastic/core";
|
|
74
78
|
import { useContext, useState } from "react";
|
|
75
79
|
import { Platform, ScrollView, StyleSheet as StyleSheet2, View as View2 } from "react-native";
|
|
76
80
|
import { Icon as Icon2, Portal, Text as Text2 } from "react-native-paper";
|
|
@@ -78,8 +82,10 @@ import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
|
78
82
|
function SettingIcon({ source, color, containerColor }) {
|
|
79
83
|
return /* @__PURE__ */ jsx2(View2, { style: [styles2.iconBadge, { backgroundColor: containerColor }], children: /* @__PURE__ */ jsx2(Icon2, { source, size: 18, color }) });
|
|
80
84
|
}
|
|
81
|
-
function BaseSettingsDialog({ visible, onDismiss, rotation
|
|
85
|
+
function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, version, lockOrientation, onLockOrientationChange, deferBottomEdgeGestures, onDeferBottomEdgeGestures, hideSound = false, hideHaptics = false, hideAppearance = false, hideUpdateCheck = false, onUpdateError, children }) {
|
|
82
86
|
const { dark, colors } = useAutoPaperTheme2();
|
|
87
|
+
const ambientRotation = useRotation();
|
|
88
|
+
const rotation = rotationOverride ?? ambientRotation;
|
|
83
89
|
const isTouchPrimary = useIsTouchPrimaryDevice();
|
|
84
90
|
const { settings: hapticSettings, set: setHapticSettings } = useHapticSettings();
|
|
85
91
|
const { settings: soundSettings, set: setSoundSettings } = useSoundSettings();
|
|
@@ -271,12 +277,15 @@ var styles2 = StyleSheet2.create({
|
|
|
271
277
|
// src/BaseStatsScreen.tsx
|
|
272
278
|
import { useAutoPaperTheme as useAutoPaperTheme3 } from "@rific/auto-paper";
|
|
273
279
|
import { Button as Button2, IconButton } from "@rific/feedback-press";
|
|
280
|
+
import { useRotation as useRotation2 } from "@tastic/core";
|
|
274
281
|
import { useState as useState2 } from "react";
|
|
275
282
|
import { ScrollView as ScrollView2, StyleSheet as StyleSheet3, View as View3 } from "react-native";
|
|
276
283
|
import { Icon as Icon3, Portal as Portal2, Text as Text3 } from "react-native-paper";
|
|
277
284
|
import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
278
|
-
function BaseStatsScreen({ rotation
|
|
285
|
+
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, cardBg: cardBgOverride, accentColor: accentColorOverride, children }) {
|
|
279
286
|
const { dark, colors } = useAutoPaperTheme3();
|
|
287
|
+
const ambientRotation = useRotation2();
|
|
288
|
+
const rotation = rotationOverride ?? ambientRotation;
|
|
280
289
|
const [confirmResetVisible, setConfirmResetVisible] = useState2(false);
|
|
281
290
|
const showReset = !!onReset;
|
|
282
291
|
const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
|
|
@@ -459,6 +468,7 @@ import { Platform as Platform3 } from "react-native";
|
|
|
459
468
|
// src/SectionedDropdown.tsx
|
|
460
469
|
import { getBlendedColor, getColorRoles } from "@rific/auto-paper";
|
|
461
470
|
import { IconButton as IconButton2, TouchableRipple as TouchableRipple2 } from "@rific/feedback-press";
|
|
471
|
+
import { useRotation as useRotation3 } from "@tastic/core";
|
|
462
472
|
import { ScrollView as ScrollView3, StyleSheet as StyleSheet7, View as View8 } from "react-native";
|
|
463
473
|
import { Icon as Icon5, Text as Text5 } from "react-native-paper";
|
|
464
474
|
|
|
@@ -532,26 +542,27 @@ var styles6 = StyleSheet6.create({
|
|
|
532
542
|
}
|
|
533
543
|
});
|
|
534
544
|
|
|
535
|
-
// src/TriggerGaugeHost.tsx
|
|
536
|
-
import { lazy, Suspense } from "react";
|
|
537
|
-
|
|
538
|
-
// src/loadSkiaWeb.ts
|
|
539
|
-
function loadSkiaWeb() {
|
|
540
|
-
return Promise.resolve();
|
|
541
|
-
}
|
|
542
|
-
|
|
543
545
|
// src/TriggerGaugeHost.tsx
|
|
544
546
|
import { jsx as jsx7 } from "react/jsx-runtime";
|
|
545
|
-
var
|
|
547
|
+
var getTriggerGauge = () => import("./TriggerGauge-2OJOOF6I.mjs").then((mod) => ({ default: mod.TriggerGauge }));
|
|
546
548
|
function TriggerGaugeHost(props) {
|
|
547
|
-
return /* @__PURE__ */ jsx7(
|
|
549
|
+
return /* @__PURE__ */ jsx7(SkiaGate, { getComponent: getTriggerGauge, componentProps: props });
|
|
548
550
|
}
|
|
549
551
|
|
|
550
552
|
// src/useAutoAlign.ts
|
|
551
553
|
import { useCallback, useEffect, useRef, useState as useState3 } from "react";
|
|
552
554
|
import { useWindowDimensions as useWindowDimensions2 } from "react-native";
|
|
553
555
|
var EDGE_MARGIN = 12;
|
|
554
|
-
function
|
|
556
|
+
function rotateRect(x, y, width, height, rotation, windowWidth, windowHeight) {
|
|
557
|
+
if (rotation === 0) return { x, y, width, height };
|
|
558
|
+
const cx = windowWidth / 2;
|
|
559
|
+
const cy = windowHeight / 2;
|
|
560
|
+
const dx = x + width / 2 - cx;
|
|
561
|
+
const dy = y + height / 2 - cy;
|
|
562
|
+
const [newDx, newDy, newWidth, newHeight] = rotation === 180 ? [-dx, -dy, width, height] : rotation === 90 ? [-dy, dx, height, width] : [dy, -dx, height, width];
|
|
563
|
+
return { x: cx + newDx - newWidth / 2, y: cy + newDy - newHeight / 2, width: newWidth, height: newHeight };
|
|
564
|
+
}
|
|
565
|
+
function useAutoAlign(open, contentWidth, contentHeight, rotation = 0) {
|
|
555
566
|
const triggerRef = useRef(null);
|
|
556
567
|
const { width: windowWidth, height: windowHeight } = useWindowDimensions2();
|
|
557
568
|
const [align, setAlign] = useState3("center");
|
|
@@ -564,7 +575,8 @@ function useAutoAlign(open, contentWidth, contentHeight) {
|
|
|
564
575
|
if (open) setMeasured(false);
|
|
565
576
|
}
|
|
566
577
|
const measure = useCallback(() => {
|
|
567
|
-
triggerRef.current?.measureInWindow((
|
|
578
|
+
triggerRef.current?.measureInWindow((rawX, rawY, rawWidth, rawHeight) => {
|
|
579
|
+
const { x, y, width: triggerWidth, height: triggerHeight } = rotateRect(rawX, rawY, rawWidth, rawHeight, rotation, windowWidth, windowHeight);
|
|
568
580
|
const centerX = x + triggerWidth / 2;
|
|
569
581
|
const overflowsRight = centerX + contentWidth / 2 > windowWidth - EDGE_MARGIN;
|
|
570
582
|
const overflowsLeft = centerX - contentWidth / 2 < EDGE_MARGIN;
|
|
@@ -578,7 +590,7 @@ function useAutoAlign(open, contentWidth, contentHeight) {
|
|
|
578
590
|
setMaxHeight(Math.max(above ? roomAbove : roomBelow, 0));
|
|
579
591
|
setMeasured(true);
|
|
580
592
|
});
|
|
581
|
-
}, [contentWidth, contentHeight, windowWidth, windowHeight]);
|
|
593
|
+
}, [contentWidth, contentHeight, windowWidth, windowHeight, rotation]);
|
|
582
594
|
useEffect(() => {
|
|
583
595
|
if (open) measure();
|
|
584
596
|
}, [open, measure]);
|
|
@@ -597,7 +609,9 @@ var MENU_ITEM_HEIGHT = 40;
|
|
|
597
609
|
var MENU_ITEM_HEIGHT_WITH_DESCRIPTION = 58;
|
|
598
610
|
var MENU_DIVIDER_HEIGHT = 9;
|
|
599
611
|
var MENU_VERTICAL_PADDING = 12;
|
|
600
|
-
function SectionedDropdown({ id, host, icon, accessibilityLabel, sections, accentColor, mutedColor, onAccentColor, dark, align: alignOverride, autoDismiss = true, labelFontFamily = MONO_FONT, allClearLabels = { all: "All", clear: "Clear" } }) {
|
|
612
|
+
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" } }) {
|
|
613
|
+
const ambientRotation = useRotation3();
|
|
614
|
+
const rotation = rotationOverride ?? ambientRotation;
|
|
601
615
|
const menuBg = dark ? "#000000" : "#FFFFFF";
|
|
602
616
|
const menuBorderColor = getBlendedColor(mutedColor, menuBg, 0.5);
|
|
603
617
|
const resolvedOnAccentColor = onAccentColor ?? getColorRoles(accentColor, menuBg).onColor;
|
|
@@ -609,7 +623,7 @@ function SectionedDropdown({ id, host, icon, accessibilityLabel, sections, accen
|
|
|
609
623
|
const divider = sectionIndex > 0 ? MENU_DIVIDER_HEIGHT : 0;
|
|
610
624
|
return sum + rows + footer + divider;
|
|
611
625
|
}, 0);
|
|
612
|
-
const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, MENU_MAX_WIDTH, estimatedHeight);
|
|
626
|
+
const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, MENU_MAX_WIDTH, estimatedHeight, rotation);
|
|
613
627
|
const align = alignOverride ?? autoAlign;
|
|
614
628
|
const onlySection = sections.length === 1 ? sections[0] : null;
|
|
615
629
|
const selectedOption = onlySection?.kind === "single" ? onlySection.options.find((o) => o.value === onlySection.value) : void 0;
|
|
@@ -841,7 +855,7 @@ var styles9 = StyleSheet9.create({
|
|
|
841
855
|
// src/InlineColorPicker.tsx
|
|
842
856
|
import { defaultColors, getContrastColor } from "@rific/auto-paper";
|
|
843
857
|
import { TouchableRipple as TouchableRipple3 } from "@rific/feedback-press";
|
|
844
|
-
import { clamp } from "@tastic/core";
|
|
858
|
+
import { clamp, useRotation as useRotation4 } from "@tastic/core";
|
|
845
859
|
import { ScrollView as ScrollView4, StyleSheet as StyleSheet10, useWindowDimensions as useWindowDimensions3, View as View10 } from "react-native";
|
|
846
860
|
import { Icon as Icon7, Text as Text6 } from "react-native-paper";
|
|
847
861
|
import { jsx as jsx12, jsxs as jsxs10 } from "react/jsx-runtime";
|
|
@@ -857,7 +871,9 @@ var SCREEN_MARGIN = 40;
|
|
|
857
871
|
function widthForColumns(columns) {
|
|
858
872
|
return SWATCHES_BORDER_WIDTH * 2 + SWATCHES_PADDING * 2 + SWATCH_SIZE * columns + SWATCHES_GAP * (columns - 1);
|
|
859
873
|
}
|
|
860
|
-
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 }) {
|
|
874
|
+
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 }) {
|
|
875
|
+
const ambientRotation = useRotation4();
|
|
876
|
+
const rotation = rotationOverride ?? ambientRotation;
|
|
861
877
|
const menuBg = dark ? "#000000" : "#FFFFFF";
|
|
862
878
|
const { width: windowWidth } = useWindowDimensions3();
|
|
863
879
|
const autoColumns = clamp(Math.floor((windowWidth - 2 * SCREEN_MARGIN + SWATCHES_GAP) / (SWATCH_SIZE + SWATCHES_GAP)), MIN_COLUMNS, MAX_COLUMNS);
|
|
@@ -866,7 +882,7 @@ function InlineColorPicker({ id, host, value, onChange, previewValue, swatches =
|
|
|
866
882
|
const swatchRows = Math.ceil(swatches.length / resolvedColumns);
|
|
867
883
|
const swatchesHeight = SWATCHES_BORDER_WIDTH * 2 + SWATCHES_PADDING * 2 + SWATCH_SIZE * swatchRows + SWATCHES_GAP * (swatchRows - 1);
|
|
868
884
|
const open = host.openId === id;
|
|
869
|
-
const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, swatchesWidth, swatchesHeight);
|
|
885
|
+
const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, swatchesWidth, swatchesHeight, rotation);
|
|
870
886
|
const align = alignOverride ?? autoAlign;
|
|
871
887
|
const displayFor = previewValue ?? ((hex) => hex);
|
|
872
888
|
const displayValue = displayFor(value);
|
|
@@ -1062,6 +1078,11 @@ var styles11 = StyleSheet11.create({
|
|
|
1062
1078
|
}
|
|
1063
1079
|
});
|
|
1064
1080
|
|
|
1081
|
+
// src/loadSkiaWeb.ts
|
|
1082
|
+
function loadSkiaWeb() {
|
|
1083
|
+
return Promise.resolve();
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1065
1086
|
// src/PressAwayOverlay.tsx
|
|
1066
1087
|
import { Pressable, StyleSheet as StyleSheet12 } from "react-native";
|
|
1067
1088
|
import { jsx as jsx14 } from "react/jsx-runtime";
|
|
@@ -1207,6 +1228,7 @@ export {
|
|
|
1207
1228
|
ReadyButton,
|
|
1208
1229
|
SectionedDropdown,
|
|
1209
1230
|
SharedActionBand,
|
|
1231
|
+
SkiaGate,
|
|
1210
1232
|
StatRow,
|
|
1211
1233
|
StatSection,
|
|
1212
1234
|
TriggerGaugeHost,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tastic/hud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
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",
|
|
@@ -33,6 +33,13 @@
|
|
|
33
33
|
"types": "./dist/index.d.ts",
|
|
34
34
|
"import": "./dist/index.mjs",
|
|
35
35
|
"require": "./dist/index.js"
|
|
36
|
+
},
|
|
37
|
+
"./skia-gate": {
|
|
38
|
+
"react-native": "./src/SkiaGate.tsx",
|
|
39
|
+
"browser": "./src/SkiaGate.web.tsx",
|
|
40
|
+
"types": "./dist/SkiaGate.d.ts",
|
|
41
|
+
"import": "./dist/SkiaGate.mjs",
|
|
42
|
+
"require": "./dist/SkiaGate.js"
|
|
36
43
|
}
|
|
37
44
|
},
|
|
38
45
|
"main": "dist/index.js",
|
|
@@ -63,14 +70,14 @@
|
|
|
63
70
|
},
|
|
64
71
|
"prettier": "@infinitetoken/eslint-config/prettier",
|
|
65
72
|
"devDependencies": {
|
|
66
|
-
"@infinitetoken/eslint-config": "0.2.0",
|
|
67
|
-
"@infinitetoken/jest-config": "^0.
|
|
73
|
+
"@infinitetoken/eslint-config": "^0.2.0",
|
|
74
|
+
"@infinitetoken/jest-config": "^0.3.0",
|
|
68
75
|
"@infinitetoken/tsconfig": "^0.4.1",
|
|
69
76
|
"@rific/auto-paper": "^0.10.3",
|
|
70
77
|
"@rific/feedback-press": "^0.10.8",
|
|
71
|
-
"@rific/updater": "^0.
|
|
78
|
+
"@rific/updater": "^0.6.0",
|
|
72
79
|
"@shopify/react-native-skia": "^2.6.2",
|
|
73
|
-
"@tastic/core": "^0.
|
|
80
|
+
"@tastic/core": "^0.3.0",
|
|
74
81
|
"@testing-library/dom": "^10.4.1",
|
|
75
82
|
"@testing-library/react": "^16.3.2",
|
|
76
83
|
"@types/jest": "^30.0.0",
|
|
@@ -94,7 +101,7 @@
|
|
|
94
101
|
"@rific/feedback-press": ">=0.10.0",
|
|
95
102
|
"@rific/updater": ">=0.4.0",
|
|
96
103
|
"@shopify/react-native-skia": ">=1.5.0",
|
|
97
|
-
"@tastic/core": ">=0.
|
|
104
|
+
"@tastic/core": ">=0.3.0",
|
|
98
105
|
"react": ">=19.0.0",
|
|
99
106
|
"react-native": ">=0.76.0",
|
|
100
107
|
"react-native-paper": ">=5.0.0",
|
|
@@ -1,7 +1,7 @@
|
|
|
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 { useIsTouchPrimaryDevice } from '@tastic/core'
|
|
4
|
+
import { useIsTouchPrimaryDevice, useRotation } from '@tastic/core'
|
|
5
5
|
import { ReactNode, useContext, useState } from 'react'
|
|
6
6
|
import { Platform, ScrollView, StyleSheet, View } from 'react-native'
|
|
7
7
|
import { Icon, Portal, Text } from 'react-native-paper'
|
|
@@ -26,10 +26,11 @@ function SettingIcon({ source, color, containerColor }: SettingIconProps) {
|
|
|
26
26
|
export interface BaseSettingsDialogProps {
|
|
27
27
|
visible: boolean
|
|
28
28
|
onDismiss: () => void
|
|
29
|
-
//
|
|
30
|
-
// app-wide modal with no per-player zone to match (unlike an in-game round-over
|
|
31
|
-
// just rotates its own content in place
|
|
32
|
-
//
|
|
29
|
+
// Explicit override for the live physical-hold rotation (see @tastic/core's useRotation) — this is
|
|
30
|
+
// a centered, app-wide modal with no per-player zone to match (unlike an in-game round-over
|
|
31
|
+
// dialog), so it just rotates its own content in place. Defaults to a live ambient read via
|
|
32
|
+
// useRotation() when omitted; pass this only when a caller's own reading needs to differ from the
|
|
33
|
+
// ambient one (rare — most callers should simply omit it).
|
|
33
34
|
rotation?: number
|
|
34
35
|
// Each app's own OTA version (e.g. release.otaVersion) — not something this package can read
|
|
35
36
|
// itself, since every consuming app tracks its own release.ts independently. Accepts a number too
|
|
@@ -76,8 +77,10 @@ export interface BaseSettingsDialogProps {
|
|
|
76
77
|
// is the seam for whatever isn't shared: a game with its own board/difficulty settings (or a stats
|
|
77
78
|
// backup flow, or anything else genuinely specific to it) renders those below Appearance, not by
|
|
78
79
|
// forking this component.
|
|
79
|
-
export function BaseSettingsDialog({ visible, onDismiss, rotation
|
|
80
|
+
export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, version, lockOrientation, onLockOrientationChange, deferBottomEdgeGestures, onDeferBottomEdgeGestures, hideSound = false, hideHaptics = false, hideAppearance = false, hideUpdateCheck = false, onUpdateError, children }: BaseSettingsDialogProps) {
|
|
80
81
|
const { dark, colors } = useAutoPaperTheme()
|
|
82
|
+
const ambientRotation = useRotation()
|
|
83
|
+
const rotation = rotationOverride ?? ambientRotation
|
|
81
84
|
// Also gates the Lock Orientation row below (alongside showLockOrientation itself) — true
|
|
82
85
|
// unconditionally on native (see the hook's own doc), so this only actually excludes a
|
|
83
86
|
// desktop/laptop browser, where a mouse-driven window has no physical orientation to lock
|
package/src/BaseStatsScreen.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
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
6
|
import { Icon, Portal, Text } from 'react-native-paper'
|
|
@@ -10,12 +11,12 @@ import { Icon, Portal, Text } from 'react-native-paper'
|
|
|
10
11
|
type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall'
|
|
11
12
|
|
|
12
13
|
interface Props {
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
14
|
+
// Explicit override for the live physical-hold rotation (see @tastic/core's useRotation) — only
|
|
15
|
+
// affects this component's own Portal-rendered reset-confirm overlay, which (like any Portal
|
|
16
|
+
// content) is unaffected by whichever screen's own FakeLandscapeView wraps the trigger and so has
|
|
17
|
+
// to rotate itself. Defaults to a live ambient read via useRotation() when omitted — note this
|
|
18
|
+
// does NOT make the header/scrollable content below rotate; the caller's own screen still needs
|
|
19
|
+
// its own FakeLandscapeView wrap for that (this component has no transform on its own main body).
|
|
19
20
|
rotation?: number
|
|
20
21
|
title?: string
|
|
21
22
|
// 'headlineSmall' (this screen's own default, below) — was originally 'displaySmall' (the full
|
|
@@ -67,8 +68,10 @@ interface Props {
|
|
|
67
68
|
// black/white convention. `children` is the seam for whatever isn't shared: this component has no
|
|
68
69
|
// opinion on what a "stat" is or how achievements are catalogued — see StatRow/StatSection/
|
|
69
70
|
// AchievementRow for the smaller presentational pieces built to go inside it.
|
|
70
|
-
export function BaseStatsScreen({ rotation
|
|
71
|
+
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, cardBg: cardBgOverride, accentColor: accentColorOverride, children }: Props) {
|
|
71
72
|
const { dark, colors } = useAutoPaperTheme()
|
|
73
|
+
const ambientRotation = useRotation()
|
|
74
|
+
const rotation = rotationOverride ?? ambientRotation
|
|
72
75
|
const [confirmResetVisible, setConfirmResetVisible] = useState(false)
|
|
73
76
|
const showReset = !!onReset
|
|
74
77
|
|
|
@@ -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'
|