@tastic/hud 0.1.2 → 0.1.4
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 +6 -2
- package/dist/{TriggerGauge-XBKZ3KER.mjs → TriggerGauge-2OJOOF6I.mjs} +3 -2
- package/dist/index.d.mts +4 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +28 -17
- package/dist/index.mjs +33 -23
- package/package.json +4 -1
- package/src/InlineColorPicker.tsx +63 -20
- package/src/TriggerGauge.tsx +3 -2
package/README.md
CHANGED
|
@@ -28,8 +28,12 @@ corner of the screen.
|
|
|
28
28
|
`maxHeight` for the caller to cap content and scroll when neither direction has enough room.
|
|
29
29
|
- **`SectionedDropdown`** — a popover holding any mix of single-select ("pick one", radio-style) and
|
|
30
30
|
multi-select ("pick some", checkbox-style, optional all/clear footer) sections, divided by rules.
|
|
31
|
-
- **`InlineColorPicker`** — swatch-grid color popover, auto-
|
|
32
|
-
|
|
31
|
+
- **`InlineColorPicker`** — swatch-grid color popover, columns auto-clamped to 4 or 5 (the default
|
|
32
|
+
`swatches` list is exactly 20 colors, so that's the only range that fills every row completely),
|
|
33
|
+
with an optional "taken" color (shown disabled, or swappable). The trigger itself is a `size`-
|
|
34
|
+
configurable circle showing either an `icon` or a short `tag` (e.g. a saved player identity's own
|
|
35
|
+
initial or emoji) — `tag` renders at a larger ratio automatically when it's an emoji, since an
|
|
36
|
+
emoji glyph reads visually smaller than a bold letter at the same font size.
|
|
33
37
|
- **`TriggerGauge`** — decorative ring of tick marks around a trigger, showing which option(s) are
|
|
34
38
|
active without opening the popover.
|
|
35
39
|
- **`ReadyButton`** — a per-player or standalone ready toggle.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// src/TriggerGauge.tsx
|
|
2
2
|
import { Canvas, Path, Skia } from "@shopify/react-native-skia";
|
|
3
|
+
import { clamp } from "@tastic/core";
|
|
3
4
|
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
|
4
5
|
import { StyleSheet, View } from "react-native";
|
|
5
6
|
import { Easing, useDerivedValue, useSharedValue, withTiming } from "react-native-reanimated";
|
|
@@ -60,7 +61,7 @@ function TriggerGauge({ segments, litIndices, size, accentColor, mutedColor, das
|
|
|
60
61
|
if (mountProgress.value < 1) {
|
|
61
62
|
const startFrac = i * MOUNT_STAGGER_MS / totalDurationMs;
|
|
62
63
|
const endFrac = startFrac + MOUNT_DURATION_MS / totalDurationMs;
|
|
63
|
-
local =
|
|
64
|
+
local = clamp((mountProgress.value - startFrac) / (endFrac - startFrac), 0, 1);
|
|
64
65
|
}
|
|
65
66
|
const sweep = dashAngleDeg * local;
|
|
66
67
|
if (sweep <= 0) continue;
|
|
@@ -84,7 +85,7 @@ function TriggerGauge({ segments, litIndices, size, accentColor, mutedColor, das
|
|
|
84
85
|
if (!isLitNow) continue;
|
|
85
86
|
const startFrac = i * MOUNT_STAGGER_MS / totalDurationMs;
|
|
86
87
|
const endFrac = startFrac + MOUNT_DURATION_MS / totalDurationMs;
|
|
87
|
-
local =
|
|
88
|
+
local = clamp((mountProgress.value - startFrac) / (endFrac - startFrac), 0, 1);
|
|
88
89
|
} else if (isLitNow === wasLit) {
|
|
89
90
|
local = 1;
|
|
90
91
|
} else if (isLitNow) {
|
package/dist/index.d.mts
CHANGED
|
@@ -23,10 +23,13 @@ interface Props$4 {
|
|
|
23
23
|
dark: boolean;
|
|
24
24
|
align?: 'left' | 'right' | 'center';
|
|
25
25
|
icon?: string;
|
|
26
|
+
tag?: string;
|
|
27
|
+
labelFontFamily?: string;
|
|
26
28
|
autoDismiss?: boolean;
|
|
27
29
|
columns?: number;
|
|
30
|
+
size?: number;
|
|
28
31
|
}
|
|
29
|
-
declare function InlineColorPicker({ id, host, value, onChange, swatches, takenValue, allowSwapTaken, dark, align: alignOverride, icon, autoDismiss, columns }: Props$4): react.JSX.Element;
|
|
32
|
+
declare function InlineColorPicker({ id, host, value, onChange, swatches, takenValue, allowSwapTaken, dark, align: alignOverride, icon, tag, labelFontFamily, autoDismiss, columns, size }: Props$4): react.JSX.Element;
|
|
30
33
|
|
|
31
34
|
declare function loadSkiaWeb(): Promise<void>;
|
|
32
35
|
|
package/dist/index.d.ts
CHANGED
|
@@ -23,10 +23,13 @@ interface Props$4 {
|
|
|
23
23
|
dark: boolean;
|
|
24
24
|
align?: 'left' | 'right' | 'center';
|
|
25
25
|
icon?: string;
|
|
26
|
+
tag?: string;
|
|
27
|
+
labelFontFamily?: string;
|
|
26
28
|
autoDismiss?: boolean;
|
|
27
29
|
columns?: number;
|
|
30
|
+
size?: number;
|
|
28
31
|
}
|
|
29
|
-
declare function InlineColorPicker({ id, host, value, onChange, swatches, takenValue, allowSwapTaken, dark, align: alignOverride, icon, autoDismiss, columns }: Props$4): react.JSX.Element;
|
|
32
|
+
declare function InlineColorPicker({ id, host, value, onChange, swatches, takenValue, allowSwapTaken, dark, align: alignOverride, icon, tag, labelFontFamily, autoDismiss, columns, size }: Props$4): react.JSX.Element;
|
|
30
33
|
|
|
31
34
|
declare function loadSkiaWeb(): Promise<void>;
|
|
32
35
|
|
package/dist/index.js
CHANGED
|
@@ -72,7 +72,7 @@ function TriggerGauge({ segments, litIndices, size, accentColor, mutedColor, das
|
|
|
72
72
|
if (mountProgress.value < 1) {
|
|
73
73
|
const startFrac = i * MOUNT_STAGGER_MS / totalDurationMs;
|
|
74
74
|
const endFrac = startFrac + MOUNT_DURATION_MS / totalDurationMs;
|
|
75
|
-
local =
|
|
75
|
+
local = (0, import_core2.clamp)((mountProgress.value - startFrac) / (endFrac - startFrac), 0, 1);
|
|
76
76
|
}
|
|
77
77
|
const sweep = dashAngleDeg * local;
|
|
78
78
|
if (sweep <= 0) continue;
|
|
@@ -96,7 +96,7 @@ function TriggerGauge({ segments, litIndices, size, accentColor, mutedColor, das
|
|
|
96
96
|
if (!isLitNow) continue;
|
|
97
97
|
const startFrac = i * MOUNT_STAGGER_MS / totalDurationMs;
|
|
98
98
|
const endFrac = startFrac + MOUNT_DURATION_MS / totalDurationMs;
|
|
99
|
-
local =
|
|
99
|
+
local = (0, import_core2.clamp)((mountProgress.value - startFrac) / (endFrac - startFrac), 0, 1);
|
|
100
100
|
} else if (isLitNow === wasLit) {
|
|
101
101
|
local = 1;
|
|
102
102
|
} else if (isLitNow) {
|
|
@@ -125,11 +125,12 @@ function TriggerGauge({ segments, litIndices, size, accentColor, mutedColor, das
|
|
|
125
125
|
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)(import_react_native_skia.Path, { path: litPath, style: "stroke", strokeWidth: LIT_THICKNESS, strokeCap: "round", color: accentColor, opacity: 1 })
|
|
126
126
|
] }) });
|
|
127
127
|
}
|
|
128
|
-
var import_react_native_skia, import_react2, import_react_native7, import_react_native_reanimated, import_jsx_runtime5, UNLIT_THICKNESS, LIT_THICKNESS, DASH_GAP_PX, ARC_GAP, DEFAULT_START_DEG, MIN_DASH_ANGLE_DEG, MOUNT_DURATION_MS, MOUNT_STAGGER_MS, SELECTION_TRANSITION_DURATION_MS, styles4;
|
|
128
|
+
var import_react_native_skia, import_core2, import_react2, import_react_native7, import_react_native_reanimated, import_jsx_runtime5, UNLIT_THICKNESS, LIT_THICKNESS, DASH_GAP_PX, ARC_GAP, DEFAULT_START_DEG, MIN_DASH_ANGLE_DEG, MOUNT_DURATION_MS, MOUNT_STAGGER_MS, SELECTION_TRANSITION_DURATION_MS, styles4;
|
|
129
129
|
var init_TriggerGauge = __esm({
|
|
130
130
|
"src/TriggerGauge.tsx"() {
|
|
131
131
|
"use strict";
|
|
132
132
|
import_react_native_skia = require("@shopify/react-native-skia");
|
|
133
|
+
import_core2 = require("@tastic/core");
|
|
133
134
|
import_react2 = require("react");
|
|
134
135
|
import_react_native7 = require("react-native");
|
|
135
136
|
import_react_native_reanimated = require("react-native-reanimated");
|
|
@@ -176,6 +177,7 @@ var MONO_FONT = import_react_native.Platform.select({ ios: "Menlo", android: "mo
|
|
|
176
177
|
// src/InlineColorPicker.tsx
|
|
177
178
|
var import_auto_paper = require("@rific/auto-paper");
|
|
178
179
|
var import_feedback_press = require("@rific/feedback-press");
|
|
180
|
+
var import_core = require("@tastic/core");
|
|
179
181
|
var import_react_native4 = require("react-native");
|
|
180
182
|
var import_react_native_paper = require("react-native-paper");
|
|
181
183
|
|
|
@@ -285,21 +287,22 @@ function useAutoAlign(open, contentWidth, contentHeight) {
|
|
|
285
287
|
|
|
286
288
|
// src/InlineColorPicker.tsx
|
|
287
289
|
var import_jsx_runtime2 = require("react/jsx-runtime");
|
|
288
|
-
var
|
|
290
|
+
var EMOJI_PATTERN = /\p{Extended_Pictographic}/u;
|
|
291
|
+
var DEFAULT_SIZE = 48;
|
|
289
292
|
var SWATCH_SIZE = 28;
|
|
290
293
|
var SWATCHES_PADDING = 8;
|
|
291
294
|
var SWATCHES_GAP = 6;
|
|
292
295
|
var SWATCHES_BORDER_WIDTH = 2;
|
|
293
|
-
var MIN_COLUMNS =
|
|
294
|
-
var MAX_COLUMNS =
|
|
296
|
+
var MIN_COLUMNS = 4;
|
|
297
|
+
var MAX_COLUMNS = 5;
|
|
295
298
|
var SCREEN_MARGIN = 40;
|
|
296
299
|
function widthForColumns(columns) {
|
|
297
300
|
return SWATCHES_BORDER_WIDTH * 2 + SWATCHES_PADDING * 2 + SWATCH_SIZE * columns + SWATCHES_GAP * (columns - 1);
|
|
298
301
|
}
|
|
299
|
-
function InlineColorPicker({ id, host, value, onChange, swatches = import_auto_paper.defaultColors, takenValue, allowSwapTaken, dark, align: alignOverride, icon = "palette", autoDismiss = true, columns }) {
|
|
302
|
+
function InlineColorPicker({ id, host, value, onChange, swatches = import_auto_paper.defaultColors, takenValue, allowSwapTaken, dark, align: alignOverride, icon = "palette", tag, labelFontFamily = MONO_FONT, autoDismiss = true, columns, size = DEFAULT_SIZE }) {
|
|
300
303
|
const menuBg = dark ? "#000000" : "#FFFFFF";
|
|
301
304
|
const { width: windowWidth } = (0, import_react_native4.useWindowDimensions)();
|
|
302
|
-
const autoColumns =
|
|
305
|
+
const autoColumns = (0, import_core.clamp)(Math.floor((windowWidth - 2 * SCREEN_MARGIN + SWATCHES_GAP) / (SWATCH_SIZE + SWATCHES_GAP)), MIN_COLUMNS, MAX_COLUMNS);
|
|
303
306
|
const resolvedColumns = columns ?? autoColumns;
|
|
304
307
|
const swatchesWidth = widthForColumns(resolvedColumns);
|
|
305
308
|
const swatchRows = Math.ceil(swatches.length / resolvedColumns);
|
|
@@ -307,9 +310,11 @@ function InlineColorPicker({ id, host, value, onChange, swatches = import_auto_p
|
|
|
307
310
|
const open = host.openId === id;
|
|
308
311
|
const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, swatchesWidth, swatchesHeight);
|
|
309
312
|
const align = alignOverride ?? autoAlign;
|
|
313
|
+
const contrastColor = (0, import_auto_paper.getContrastColor)(value);
|
|
314
|
+
const tagFontSize = size * (tag && EMOJI_PATTERN.test(tag) ? 0.6 : 0.4);
|
|
310
315
|
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_react_native4.View, { style: [styles2.anchor, open && styles2.anchorOpen], children: [
|
|
311
|
-
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_feedback_press.TouchableRipple, { onPress: () => host.toggle(id), borderless: true, style: [styles2.trigger, { backgroundColor: value }], children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native4.View, { ref: triggerRef, collapsable: false, style: styles2.triggerMeasure, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native_paper.
|
|
312
|
-
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(PopoverBody, { visible: open && measured, align, verticalAlign, caretColor: menuBg, caretBorderColor: value, caretBorderWidth: SWATCHES_BORDER_WIDTH, triggerSize:
|
|
316
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_feedback_press.TouchableRipple, { onPress: () => host.toggle(id), borderless: true, style: [styles2.trigger, { backgroundColor: value, borderRadius: size / 2, height: size, width: size }], children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native4.View, { ref: triggerRef, collapsable: false, style: styles2.triggerMeasure, children: tag ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native_paper.Text, { style: [styles2.tagLabel, { color: contrastColor, fontFamily: labelFontFamily, fontSize: tagFontSize }], numberOfLines: 1, adjustsFontSizeToFit: true, children: tag }) : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native_paper.Icon, { source: icon, size: size * 0.6, color: contrastColor }) }) }),
|
|
317
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(PopoverBody, { visible: open && measured, align, verticalAlign, caretColor: menuBg, caretBorderColor: value, caretBorderWidth: SWATCHES_BORDER_WIDTH, triggerSize: size, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native4.ScrollView, { style: [styles2.swatches, { backgroundColor: menuBg, borderColor: value, width: swatchesWidth, maxHeight }], contentContainerStyle: styles2.swatchesContent, showsVerticalScrollIndicator: false, children: swatches.map((swatch) => {
|
|
313
318
|
const selected = swatch.value.toLowerCase() === value.toLowerCase();
|
|
314
319
|
const taken = !selected && !!takenValue && swatch.value.toLowerCase() === takenValue.toLowerCase();
|
|
315
320
|
const swappable = taken && allowSwapTaken;
|
|
@@ -376,22 +381,28 @@ var styles2 = import_react_native4.StyleSheet.create({
|
|
|
376
381
|
gap: SWATCHES_GAP,
|
|
377
382
|
padding: SWATCHES_PADDING
|
|
378
383
|
},
|
|
384
|
+
// borderRadius/height/width come from the `size` prop instead (see render-site override) — no
|
|
385
|
+
// fixed default here, since this style object is shared by every caller regardless of size.
|
|
379
386
|
trigger: {
|
|
380
387
|
alignItems: "center",
|
|
381
388
|
alignSelf: "flex-start",
|
|
382
|
-
|
|
383
|
-
height: SIZE,
|
|
384
|
-
justifyContent: "center",
|
|
385
|
-
width: SIZE
|
|
389
|
+
justifyContent: "center"
|
|
386
390
|
},
|
|
387
|
-
// Wraps just the icon, inside the TouchableRipple, purely so useAutoAlign has a plain View
|
|
388
|
-
// attach its measurement ref to — TouchableRipple itself doesn't forward a ref to a
|
|
389
|
-
// native node.
|
|
391
|
+
// Wraps just the icon/tag, inside the TouchableRipple, purely so useAutoAlign has a plain View
|
|
392
|
+
// to attach its measurement ref to — TouchableRipple itself doesn't forward a ref to a
|
|
393
|
+
// measurable native node. Padded horizontally so a multi-character tag doesn't butt right up
|
|
394
|
+
// against the trigger's own circular edge (an icon alone doesn't need it, but this style is
|
|
395
|
+
// shared by both).
|
|
390
396
|
triggerMeasure: {
|
|
391
397
|
alignItems: "center",
|
|
392
398
|
height: "100%",
|
|
393
399
|
justifyContent: "center",
|
|
400
|
+
paddingHorizontal: 4,
|
|
394
401
|
width: "100%"
|
|
402
|
+
},
|
|
403
|
+
tagLabel: {
|
|
404
|
+
fontWeight: "700",
|
|
405
|
+
letterSpacing: 0.5
|
|
395
406
|
}
|
|
396
407
|
});
|
|
397
408
|
|
package/dist/index.mjs
CHANGED
|
@@ -5,8 +5,9 @@ var MONO_FONT = Platform.select({ ios: "Menlo", android: "monospace", default: "
|
|
|
5
5
|
// src/InlineColorPicker.tsx
|
|
6
6
|
import { defaultColors, getContrastColor } from "@rific/auto-paper";
|
|
7
7
|
import { TouchableRipple } from "@rific/feedback-press";
|
|
8
|
+
import { clamp } from "@tastic/core";
|
|
8
9
|
import { ScrollView, StyleSheet as StyleSheet2, useWindowDimensions as useWindowDimensions2, View as View3 } from "react-native";
|
|
9
|
-
import { Icon } from "react-native-paper";
|
|
10
|
+
import { Icon, Text } from "react-native-paper";
|
|
10
11
|
|
|
11
12
|
// src/PopoverBody.tsx
|
|
12
13
|
import { StyleSheet, View } from "react-native";
|
|
@@ -114,21 +115,22 @@ function useAutoAlign(open, contentWidth, contentHeight) {
|
|
|
114
115
|
|
|
115
116
|
// src/InlineColorPicker.tsx
|
|
116
117
|
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
117
|
-
var
|
|
118
|
+
var EMOJI_PATTERN = /\p{Extended_Pictographic}/u;
|
|
119
|
+
var DEFAULT_SIZE = 48;
|
|
118
120
|
var SWATCH_SIZE = 28;
|
|
119
121
|
var SWATCHES_PADDING = 8;
|
|
120
122
|
var SWATCHES_GAP = 6;
|
|
121
123
|
var SWATCHES_BORDER_WIDTH = 2;
|
|
122
|
-
var MIN_COLUMNS =
|
|
123
|
-
var MAX_COLUMNS =
|
|
124
|
+
var MIN_COLUMNS = 4;
|
|
125
|
+
var MAX_COLUMNS = 5;
|
|
124
126
|
var SCREEN_MARGIN = 40;
|
|
125
127
|
function widthForColumns(columns) {
|
|
126
128
|
return SWATCHES_BORDER_WIDTH * 2 + SWATCHES_PADDING * 2 + SWATCH_SIZE * columns + SWATCHES_GAP * (columns - 1);
|
|
127
129
|
}
|
|
128
|
-
function InlineColorPicker({ id, host, value, onChange, swatches = defaultColors, takenValue, allowSwapTaken, dark, align: alignOverride, icon = "palette", autoDismiss = true, columns }) {
|
|
130
|
+
function InlineColorPicker({ id, host, value, onChange, swatches = defaultColors, takenValue, allowSwapTaken, dark, align: alignOverride, icon = "palette", tag, labelFontFamily = MONO_FONT, autoDismiss = true, columns, size = DEFAULT_SIZE }) {
|
|
129
131
|
const menuBg = dark ? "#000000" : "#FFFFFF";
|
|
130
132
|
const { width: windowWidth } = useWindowDimensions2();
|
|
131
|
-
const autoColumns =
|
|
133
|
+
const autoColumns = clamp(Math.floor((windowWidth - 2 * SCREEN_MARGIN + SWATCHES_GAP) / (SWATCH_SIZE + SWATCHES_GAP)), MIN_COLUMNS, MAX_COLUMNS);
|
|
132
134
|
const resolvedColumns = columns ?? autoColumns;
|
|
133
135
|
const swatchesWidth = widthForColumns(resolvedColumns);
|
|
134
136
|
const swatchRows = Math.ceil(swatches.length / resolvedColumns);
|
|
@@ -136,9 +138,11 @@ function InlineColorPicker({ id, host, value, onChange, swatches = defaultColors
|
|
|
136
138
|
const open = host.openId === id;
|
|
137
139
|
const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, swatchesWidth, swatchesHeight);
|
|
138
140
|
const align = alignOverride ?? autoAlign;
|
|
141
|
+
const contrastColor = getContrastColor(value);
|
|
142
|
+
const tagFontSize = size * (tag && EMOJI_PATTERN.test(tag) ? 0.6 : 0.4);
|
|
139
143
|
return /* @__PURE__ */ jsxs2(View3, { style: [styles2.anchor, open && styles2.anchorOpen], children: [
|
|
140
|
-
/* @__PURE__ */ jsx2(TouchableRipple, { onPress: () => host.toggle(id), borderless: true, style: [styles2.trigger, { backgroundColor: value }], children: /* @__PURE__ */ jsx2(View3, { ref: triggerRef, collapsable: false, style: styles2.triggerMeasure, children: /* @__PURE__ */ jsx2(Icon, { source: icon, size:
|
|
141
|
-
/* @__PURE__ */ jsx2(PopoverBody, { visible: open && measured, align, verticalAlign, caretColor: menuBg, caretBorderColor: value, caretBorderWidth: SWATCHES_BORDER_WIDTH, triggerSize:
|
|
144
|
+
/* @__PURE__ */ jsx2(TouchableRipple, { onPress: () => host.toggle(id), borderless: true, style: [styles2.trigger, { backgroundColor: value, borderRadius: size / 2, height: size, width: size }], children: /* @__PURE__ */ jsx2(View3, { ref: triggerRef, collapsable: false, style: styles2.triggerMeasure, children: tag ? /* @__PURE__ */ jsx2(Text, { style: [styles2.tagLabel, { color: contrastColor, fontFamily: labelFontFamily, fontSize: tagFontSize }], numberOfLines: 1, adjustsFontSizeToFit: true, children: tag }) : /* @__PURE__ */ jsx2(Icon, { source: icon, size: size * 0.6, color: contrastColor }) }) }),
|
|
145
|
+
/* @__PURE__ */ jsx2(PopoverBody, { visible: open && measured, align, verticalAlign, caretColor: menuBg, caretBorderColor: value, caretBorderWidth: SWATCHES_BORDER_WIDTH, triggerSize: size, children: /* @__PURE__ */ jsx2(ScrollView, { style: [styles2.swatches, { backgroundColor: menuBg, borderColor: value, width: swatchesWidth, maxHeight }], contentContainerStyle: styles2.swatchesContent, showsVerticalScrollIndicator: false, children: swatches.map((swatch) => {
|
|
142
146
|
const selected = swatch.value.toLowerCase() === value.toLowerCase();
|
|
143
147
|
const taken = !selected && !!takenValue && swatch.value.toLowerCase() === takenValue.toLowerCase();
|
|
144
148
|
const swappable = taken && allowSwapTaken;
|
|
@@ -205,22 +209,28 @@ var styles2 = StyleSheet2.create({
|
|
|
205
209
|
gap: SWATCHES_GAP,
|
|
206
210
|
padding: SWATCHES_PADDING
|
|
207
211
|
},
|
|
212
|
+
// borderRadius/height/width come from the `size` prop instead (see render-site override) — no
|
|
213
|
+
// fixed default here, since this style object is shared by every caller regardless of size.
|
|
208
214
|
trigger: {
|
|
209
215
|
alignItems: "center",
|
|
210
216
|
alignSelf: "flex-start",
|
|
211
|
-
|
|
212
|
-
height: SIZE,
|
|
213
|
-
justifyContent: "center",
|
|
214
|
-
width: SIZE
|
|
217
|
+
justifyContent: "center"
|
|
215
218
|
},
|
|
216
|
-
// Wraps just the icon, inside the TouchableRipple, purely so useAutoAlign has a plain View
|
|
217
|
-
// attach its measurement ref to — TouchableRipple itself doesn't forward a ref to a
|
|
218
|
-
// native node.
|
|
219
|
+
// Wraps just the icon/tag, inside the TouchableRipple, purely so useAutoAlign has a plain View
|
|
220
|
+
// to attach its measurement ref to — TouchableRipple itself doesn't forward a ref to a
|
|
221
|
+
// measurable native node. Padded horizontally so a multi-character tag doesn't butt right up
|
|
222
|
+
// against the trigger's own circular edge (an icon alone doesn't need it, but this style is
|
|
223
|
+
// shared by both).
|
|
219
224
|
triggerMeasure: {
|
|
220
225
|
alignItems: "center",
|
|
221
226
|
height: "100%",
|
|
222
227
|
justifyContent: "center",
|
|
228
|
+
paddingHorizontal: 4,
|
|
223
229
|
width: "100%"
|
|
230
|
+
},
|
|
231
|
+
tagLabel: {
|
|
232
|
+
fontWeight: "700",
|
|
233
|
+
letterSpacing: 0.5
|
|
224
234
|
}
|
|
225
235
|
});
|
|
226
236
|
|
|
@@ -240,13 +250,13 @@ function PressAwayOverlay({ active, onPress, style }) {
|
|
|
240
250
|
// src/ReadyButton.tsx
|
|
241
251
|
import { TouchableRipple as TouchableRipple2 } from "@rific/feedback-press";
|
|
242
252
|
import { StyleSheet as StyleSheet4 } from "react-native";
|
|
243
|
-
import { Text } from "react-native-paper";
|
|
253
|
+
import { Text as Text2 } from "react-native-paper";
|
|
244
254
|
import { jsx as jsx4 } from "react/jsx-runtime";
|
|
245
255
|
function ReadyButton({ color, ready, onToggleReady, style, labelFontFamily = MONO_FONT }) {
|
|
246
256
|
const readyButtonStyle = [styles3.readyButton, { borderColor: color }, ready && { backgroundColor: color }, style];
|
|
247
257
|
const labelTextStyle = { fontFamily: labelFontFamily, fontWeight: "bold" };
|
|
248
258
|
const labelColorStyle = { color: ready ? "#000000" : color };
|
|
249
|
-
return /* @__PURE__ */ jsx4(TouchableRipple2, { onPress: onToggleReady, borderless: true, style: readyButtonStyle, children: /* @__PURE__ */ jsx4(
|
|
259
|
+
return /* @__PURE__ */ jsx4(TouchableRipple2, { onPress: onToggleReady, borderless: true, style: readyButtonStyle, children: /* @__PURE__ */ jsx4(Text2, { variant: "labelLarge", style: [labelTextStyle, labelColorStyle], children: ready ? "READY \u2713" : "READY?" }) });
|
|
250
260
|
}
|
|
251
261
|
var styles3 = StyleSheet4.create({
|
|
252
262
|
readyButton: {
|
|
@@ -264,12 +274,12 @@ var styles3 = StyleSheet4.create({
|
|
|
264
274
|
import { getBlendedColor, getColorRoles } from "@rific/auto-paper";
|
|
265
275
|
import { IconButton, TouchableRipple as TouchableRipple3 } from "@rific/feedback-press";
|
|
266
276
|
import { ScrollView as ScrollView2, StyleSheet as StyleSheet5, View as View4 } from "react-native";
|
|
267
|
-
import { Icon as Icon2, Text as
|
|
277
|
+
import { Icon as Icon2, Text as Text3 } from "react-native-paper";
|
|
268
278
|
|
|
269
279
|
// src/TriggerGaugeHost.tsx
|
|
270
280
|
import { lazy, Suspense } from "react";
|
|
271
281
|
import { jsx as jsx5 } from "react/jsx-runtime";
|
|
272
|
-
var LazyTriggerGauge = lazy(() => loadSkiaWeb().then(() => import("./TriggerGauge-
|
|
282
|
+
var LazyTriggerGauge = lazy(() => loadSkiaWeb().then(() => import("./TriggerGauge-2OJOOF6I.mjs").then((m) => ({ default: m.TriggerGauge }))));
|
|
273
283
|
function TriggerGaugeHost(props) {
|
|
274
284
|
return /* @__PURE__ */ jsx5(Suspense, { fallback: null, children: /* @__PURE__ */ jsx5(LazyTriggerGauge, { ...props }) });
|
|
275
285
|
}
|
|
@@ -365,8 +375,8 @@ function SingleSection({
|
|
|
365
375
|
return /* @__PURE__ */ jsx6(TouchableRipple3, { onPress: () => onSelect(option.value), style: itemStyle, children: /* @__PURE__ */ jsxs3(View4, { style: styles4.itemRow, children: [
|
|
366
376
|
option.icon && /* @__PURE__ */ jsx6(Icon2, { source: option.icon, size: MENU_ITEM_ICON_SIZE, color: rowColor }),
|
|
367
377
|
/* @__PURE__ */ jsxs3(View4, { style: styles4.itemLabelColumn, children: [
|
|
368
|
-
/* @__PURE__ */ jsx6(
|
|
369
|
-
option.description && /* @__PURE__ */ jsx6(
|
|
378
|
+
/* @__PURE__ */ jsx6(Text3, { variant: "labelLarge", style: labelStyle, children: option.label }),
|
|
379
|
+
option.description && /* @__PURE__ */ jsx6(Text3, { variant: "labelSmall", style: descriptionStyle, children: option.description })
|
|
370
380
|
] })
|
|
371
381
|
] }) }, option.value);
|
|
372
382
|
}) });
|
|
@@ -390,10 +400,10 @@ function MultiSection({
|
|
|
390
400
|
const labelStyle = { fontFamily: labelFontFamily, color: rowColor, fontWeight: selected ? "bold" : "normal" };
|
|
391
401
|
return /* @__PURE__ */ jsx6(TouchableRipple3, { onPress: () => section.onChange(selected ? section.value.filter((v) => v !== option.value) : [...section.value, option.value]), style: itemStyle, children: /* @__PURE__ */ jsxs3(View4, { style: styles4.itemRow, children: [
|
|
392
402
|
option.icon && /* @__PURE__ */ jsx6(Icon2, { source: option.icon, size: MENU_ITEM_ICON_SIZE, color: rowColor }),
|
|
393
|
-
/* @__PURE__ */ jsx6(
|
|
403
|
+
/* @__PURE__ */ jsx6(Text3, { variant: "labelLarge", style: [styles4.itemLabelFlex, labelStyle], children: option.label })
|
|
394
404
|
] }) }, option.value);
|
|
395
405
|
}),
|
|
396
|
-
section.allClear && /* @__PURE__ */ jsx6(TouchableRipple3, { onPress: () => section.onChange(allSelected ? [] : section.options.map((o) => o.value)), style: styles4.item, children: /* @__PURE__ */ jsx6(
|
|
406
|
+
section.allClear && /* @__PURE__ */ jsx6(TouchableRipple3, { onPress: () => section.onChange(allSelected ? [] : section.options.map((o) => o.value)), style: styles4.item, children: /* @__PURE__ */ jsx6(Text3, { variant: "labelLarge", style: [styles4.allClearLabel, { fontFamily: labelFontFamily, color: accentColor }], children: allSelected ? allClearLabels.clear : allClearLabels.all }) })
|
|
397
407
|
] });
|
|
398
408
|
}
|
|
399
409
|
var styles4 = StyleSheet5.create({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tastic/hud",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"exports": {
|
|
30
30
|
".": {
|
|
31
31
|
"react-native": "./src/index.ts",
|
|
32
|
+
"browser": "./src/index.ts",
|
|
32
33
|
"types": "./dist/index.d.ts",
|
|
33
34
|
"import": "./dist/index.mjs",
|
|
34
35
|
"require": "./dist/index.js"
|
|
@@ -63,6 +64,7 @@
|
|
|
63
64
|
"@rific/auto-paper": "^0.9.4",
|
|
64
65
|
"@rific/feedback-press": "^0.10.4",
|
|
65
66
|
"@shopify/react-native-skia": "^2.6.2",
|
|
67
|
+
"@tastic/core": "^0.1.2",
|
|
66
68
|
"@testing-library/dom": "^10.4.1",
|
|
67
69
|
"@testing-library/react": "^16.3.2",
|
|
68
70
|
"@types/jest": "^30.0.0",
|
|
@@ -92,6 +94,7 @@
|
|
|
92
94
|
"@rific/auto-paper": ">=0.9.0",
|
|
93
95
|
"@rific/feedback-press": ">=0.10.0",
|
|
94
96
|
"@shopify/react-native-skia": ">=1.5.0",
|
|
97
|
+
"@tastic/core": ">=0.1.0",
|
|
95
98
|
"react": ">=19.0.0",
|
|
96
99
|
"react-native": ">=0.76.0",
|
|
97
100
|
"react-native-paper": ">=5.0.0",
|
|
@@ -1,22 +1,31 @@
|
|
|
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
4
|
import { ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'
|
|
4
|
-
import { Icon } from 'react-native-paper'
|
|
5
|
+
import { Icon, Text } from 'react-native-paper'
|
|
5
6
|
|
|
7
|
+
import { MONO_FONT } from './fonts'
|
|
6
8
|
import { PopoverBody } from './PopoverBody'
|
|
7
9
|
import { useAutoAlign } from './useAutoAlign'
|
|
8
10
|
import { PopoverHost } from './usePopoverHost'
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
// Presence check, not a full-string "is this exactly one emoji" validation — this only decides a
|
|
13
|
+
// font-size ratio for the trigger's own `tag`, so it doesn't need that precision, just "does this
|
|
14
|
+
// tag contain a pictograph at all."
|
|
15
|
+
const EMOJI_PATTERN = /\p{Extended_Pictographic}/u
|
|
16
|
+
|
|
17
|
+
const DEFAULT_SIZE = 48
|
|
11
18
|
const SWATCH_SIZE = 28
|
|
12
19
|
const SWATCHES_PADDING = 8
|
|
13
20
|
const SWATCHES_GAP = 6
|
|
14
21
|
const SWATCHES_BORDER_WIDTH = 2
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
// Clamped to exactly 4 or 5, not a wider 3-6 range — `defaultColors` (this component's own default
|
|
23
|
+
// `swatches`, and what every consumer actually ends up passing) is exactly 20 colors, so 4 or 5
|
|
24
|
+
// columns are the only counts that fill every row completely (4x5 or 5x4) instead of leaving a
|
|
25
|
+
// ragged, off-center last row. A caller passing a custom, non-20-length `swatches` list still gets
|
|
26
|
+
// a reasonable grid either way — just not guaranteed to be a full rectangle.
|
|
27
|
+
const MIN_COLUMNS = 4
|
|
28
|
+
const MAX_COLUMNS = 5
|
|
20
29
|
// How much screen width the grid leaves alone on either side when computing how many columns fit —
|
|
21
30
|
// this trigger can sit anywhere (including flush against a player's own screen edge in split-screen
|
|
22
31
|
// mode), so the budget is against the whole window, not this trigger's own local space.
|
|
@@ -47,23 +56,36 @@ interface Props {
|
|
|
47
56
|
// keeps it from overflowing the screen edge (see useAutoAlign).
|
|
48
57
|
align?: 'left' | 'right' | 'center'
|
|
49
58
|
// Trigger glyph — defaults to a plain palette. Pass a distinct icon per slot (e.g. a face for a
|
|
50
|
-
// human, a robot for CPU) to convey identity through the icon itself.
|
|
59
|
+
// human, a robot for CPU) to convey identity through the icon itself. Overridden by `tag` below
|
|
60
|
+
// when present.
|
|
51
61
|
icon?: string
|
|
62
|
+
// A short identity mark (an initial, an emoji, a couple of characters) shown as text on the
|
|
63
|
+
// trigger instead of `icon` — e.g. a saved player profile's own tag. Falls back to `icon` when
|
|
64
|
+
// empty/omitted, same as a seat with no such identity selected.
|
|
65
|
+
tag?: string
|
|
66
|
+
// Same convention as SectionedDropdown/ReadyButton's identical prop — defaults to this package's
|
|
67
|
+
// own MONO_FONT rather than inheriting whatever font the host app's theme happens to be using,
|
|
68
|
+
// so this trigger's tag reads consistently with the rest of this package's own chrome text.
|
|
69
|
+
labelFontFamily?: string
|
|
52
70
|
// Whether picking a swatch closes the popover. Defaults to true — a color pick is a single,
|
|
53
71
|
// complete choice the same way a single-select dropdown row is (see SectionedDropdown's identical
|
|
54
72
|
// prop), so press-away isn't the only way out, but doesn't have to be either.
|
|
55
73
|
autoDismiss?: boolean
|
|
56
74
|
// Overrides the auto-computed column count (see MIN_COLUMNS/MAX_COLUMNS/SCREEN_MARGIN above).
|
|
57
75
|
columns?: number
|
|
76
|
+
// Trigger circle's diameter — defaults to DEFAULT_SIZE. Overridable so a caller sharing a row
|
|
77
|
+
// with other fixed-size elements can line this trigger up with them instead of standing out at a
|
|
78
|
+
// mismatched size.
|
|
79
|
+
size?: number
|
|
58
80
|
}
|
|
59
81
|
|
|
60
82
|
// Renders inline rather than as a full-screen modal, scoped to its own panel — a modal color
|
|
61
83
|
// picker would block the whole screen for one player while another can't touch their own panel at
|
|
62
84
|
// the same time, which defeats the point of a split-screen lobby.
|
|
63
|
-
export function InlineColorPicker({ id, host, value, onChange, swatches = defaultColors, takenValue, allowSwapTaken, dark, align: alignOverride, icon = 'palette', autoDismiss = true, columns }: Props) {
|
|
85
|
+
export function InlineColorPicker({ id, host, value, onChange, swatches = defaultColors, takenValue, allowSwapTaken, dark, align: alignOverride, icon = 'palette', tag, labelFontFamily = MONO_FONT, autoDismiss = true, columns, size = DEFAULT_SIZE }: Props) {
|
|
64
86
|
const menuBg = dark ? '#000000' : '#FFFFFF'
|
|
65
87
|
const { width: windowWidth } = useWindowDimensions()
|
|
66
|
-
const autoColumns =
|
|
88
|
+
const autoColumns = clamp(Math.floor((windowWidth - 2 * SCREEN_MARGIN + SWATCHES_GAP) / (SWATCH_SIZE + SWATCHES_GAP)), MIN_COLUMNS, MAX_COLUMNS)
|
|
67
89
|
const resolvedColumns = columns ?? autoColumns
|
|
68
90
|
const swatchesWidth = widthForColumns(resolvedColumns)
|
|
69
91
|
const swatchRows = Math.ceil(swatches.length / resolvedColumns)
|
|
@@ -72,19 +94,34 @@ export function InlineColorPicker({ id, host, value, onChange, swatches = defaul
|
|
|
72
94
|
const open = host.openId === id
|
|
73
95
|
const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, swatchesWidth, swatchesHeight)
|
|
74
96
|
const align = alignOverride ?? autoAlign
|
|
97
|
+
const contrastColor = getContrastColor(value)
|
|
98
|
+
// An emoji glyph reads visually smaller than a bold letter at the same fontSize (the system emoji
|
|
99
|
+
// font leaves more of its own em-box empty), so a plain-text ratio that looks right for an initial
|
|
100
|
+
// still looks small for an emoji tag at the identical size — this bumps emoji up to the icon's own
|
|
101
|
+
// ratio instead, since a single emoji is closer in visual weight to an icon glyph than to text.
|
|
102
|
+
const tagFontSize = size * (tag && EMOJI_PATTERN.test(tag) ? 0.6 : 0.4)
|
|
75
103
|
|
|
76
104
|
return (
|
|
77
105
|
<View style={[styles.anchor, open && styles.anchorOpen]}>
|
|
78
|
-
<TouchableRipple onPress={() => host.toggle(id)} borderless style={[styles.trigger, { backgroundColor: value }]}>
|
|
106
|
+
<TouchableRipple onPress={() => host.toggle(id)} borderless style={[styles.trigger, { backgroundColor: value, borderRadius: size / 2, height: size, width: size }]}>
|
|
79
107
|
<View ref={triggerRef} collapsable={false} style={styles.triggerMeasure}>
|
|
80
|
-
|
|
108
|
+
{/* Sized as a fraction of the trigger's own diameter rather than a fixed pixel size, so
|
|
109
|
+
the glyph still reads as a deliberate part of the circle instead of shrinking toward its
|
|
110
|
+
center on a larger trigger. */}
|
|
111
|
+
{tag ? (
|
|
112
|
+
<Text style={[styles.tagLabel, { color: contrastColor, fontFamily: labelFontFamily, fontSize: tagFontSize }]} numberOfLines={1} adjustsFontSizeToFit>
|
|
113
|
+
{tag}
|
|
114
|
+
</Text>
|
|
115
|
+
) : (
|
|
116
|
+
<Icon source={icon} size={size * 0.6} color={contrastColor} />
|
|
117
|
+
)}
|
|
81
118
|
</View>
|
|
82
119
|
</TouchableRipple>
|
|
83
120
|
|
|
84
121
|
{/* Gated on `measured`, not just `open` — see useAutoAlign's own comment and
|
|
85
122
|
SectionedDropdown's identical fix: without it, the popover mounts for one frame at a stale or
|
|
86
123
|
guessed alignment and visibly jumps once this open's own measurement lands. */}
|
|
87
|
-
<PopoverBody visible={open && measured} align={align} verticalAlign={verticalAlign} caretColor={menuBg} caretBorderColor={value} caretBorderWidth={SWATCHES_BORDER_WIDTH} triggerSize={
|
|
124
|
+
<PopoverBody visible={open && measured} align={align} verticalAlign={verticalAlign} caretColor={menuBg} caretBorderColor={value} caretBorderWidth={SWATCHES_BORDER_WIDTH} triggerSize={size}>
|
|
88
125
|
{/* Border matches this trigger's own current color (not a neutral gray) — two triggers can
|
|
89
126
|
sit close together, so the popover needs a clear visual tie back to which one opened it,
|
|
90
127
|
not just its screen position. maxHeight (see useAutoAlign) only actually clamps on a screen
|
|
@@ -163,21 +200,27 @@ const styles = StyleSheet.create({
|
|
|
163
200
|
gap: SWATCHES_GAP,
|
|
164
201
|
padding: SWATCHES_PADDING
|
|
165
202
|
},
|
|
203
|
+
// borderRadius/height/width come from the `size` prop instead (see render-site override) — no
|
|
204
|
+
// fixed default here, since this style object is shared by every caller regardless of size.
|
|
166
205
|
trigger: {
|
|
167
206
|
alignItems: 'center',
|
|
168
207
|
alignSelf: 'flex-start',
|
|
169
|
-
|
|
170
|
-
height: SIZE,
|
|
171
|
-
justifyContent: 'center',
|
|
172
|
-
width: SIZE
|
|
208
|
+
justifyContent: 'center'
|
|
173
209
|
},
|
|
174
|
-
// Wraps just the icon, inside the TouchableRipple, purely so useAutoAlign has a plain View
|
|
175
|
-
// attach its measurement ref to — TouchableRipple itself doesn't forward a ref to a
|
|
176
|
-
// native node.
|
|
210
|
+
// Wraps just the icon/tag, inside the TouchableRipple, purely so useAutoAlign has a plain View
|
|
211
|
+
// to attach its measurement ref to — TouchableRipple itself doesn't forward a ref to a
|
|
212
|
+
// measurable native node. Padded horizontally so a multi-character tag doesn't butt right up
|
|
213
|
+
// against the trigger's own circular edge (an icon alone doesn't need it, but this style is
|
|
214
|
+
// shared by both).
|
|
177
215
|
triggerMeasure: {
|
|
178
216
|
alignItems: 'center',
|
|
179
217
|
height: '100%',
|
|
180
218
|
justifyContent: 'center',
|
|
219
|
+
paddingHorizontal: 4,
|
|
181
220
|
width: '100%'
|
|
221
|
+
},
|
|
222
|
+
tagLabel: {
|
|
223
|
+
fontWeight: '700',
|
|
224
|
+
letterSpacing: 0.5
|
|
182
225
|
}
|
|
183
226
|
})
|
package/src/TriggerGauge.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Canvas, Path, Skia } from '@shopify/react-native-skia'
|
|
2
|
+
import { clamp } from '@tastic/core'
|
|
2
3
|
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
|
|
3
4
|
import { StyleSheet, View } from 'react-native'
|
|
4
5
|
import { Easing, useDerivedValue, useSharedValue, withTiming } from 'react-native-reanimated'
|
|
@@ -207,7 +208,7 @@ export function TriggerGauge({ segments, litIndices, size, accentColor, mutedCol
|
|
|
207
208
|
if (mountProgress.value < 1) {
|
|
208
209
|
const startFrac = (i * MOUNT_STAGGER_MS) / totalDurationMs
|
|
209
210
|
const endFrac = startFrac + MOUNT_DURATION_MS / totalDurationMs
|
|
210
|
-
local =
|
|
211
|
+
local = clamp((mountProgress.value - startFrac) / (endFrac - startFrac), 0, 1)
|
|
211
212
|
}
|
|
212
213
|
const sweep = dashAngleDeg * local
|
|
213
214
|
if (sweep <= 0) continue
|
|
@@ -245,7 +246,7 @@ export function TriggerGauge({ segments, litIndices, size, accentColor, mutedCol
|
|
|
245
246
|
if (!isLitNow) continue
|
|
246
247
|
const startFrac = (i * MOUNT_STAGGER_MS) / totalDurationMs
|
|
247
248
|
const endFrac = startFrac + MOUNT_DURATION_MS / totalDurationMs
|
|
248
|
-
local =
|
|
249
|
+
local = clamp((mountProgress.value - startFrac) / (endFrac - startFrac), 0, 1)
|
|
249
250
|
} else if (isLitNow === wasLit) {
|
|
250
251
|
local = 1 // steady — lit before and after (or the mount-only branch above already handled unlit)
|
|
251
252
|
} else if (isLitNow) {
|