@tastic/hud 0.1.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/dist/index.mjs ADDED
@@ -0,0 +1,475 @@
1
+ // src/fonts.ts
2
+ import { Platform } from "react-native";
3
+ var MONO_FONT = Platform.select({ ios: "Menlo", android: "monospace", default: "monospace" });
4
+
5
+ // src/InlineColorPicker.tsx
6
+ import { defaultColors, getContrastColor } from "@rific/auto-paper";
7
+ import { TouchableRipple } from "@rific/feedback-press";
8
+ import { ScrollView, StyleSheet as StyleSheet2, useWindowDimensions as useWindowDimensions2, View as View3 } from "react-native";
9
+ import { Icon } from "react-native-paper";
10
+
11
+ // src/PopoverBody.tsx
12
+ import { StyleSheet, View } from "react-native";
13
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
14
+ var CARET_SIZE = 7;
15
+ var CARET_RING_OFFSET = 2;
16
+ var CARET_DIP = 1;
17
+ function PopoverBody({ visible, children, align = "center", verticalAlign = "below", caretColor, caretBorderColor, caretBorderWidth = 1, triggerSize = 0 }) {
18
+ if (!visible) return null;
19
+ const above = verticalAlign === "above";
20
+ const alignStyle = align === "left" ? styles.contentLeft : align === "right" ? styles.contentRight : styles.contentCenter;
21
+ const verticalStyle = above ? styles.contentAbove : styles.contentBelow;
22
+ const ringSize = CARET_SIZE + caretBorderWidth;
23
+ const caretOffset = (halfFootprint) => align === "right" ? { right: triggerSize / 2 - halfFootprint } : { left: triggerSize / 2 - halfFootprint };
24
+ const caretFill = above ? "borderTopColor" : "borderBottomColor";
25
+ const caretWidthProp = above ? "borderTopWidth" : "borderBottomWidth";
26
+ const caretEdge = (size) => above ? { bottom: -size + CARET_DIP } : { top: -size + CARET_DIP };
27
+ return /* @__PURE__ */ jsxs(View, { style: [styles.content, alignStyle, verticalStyle], children: [
28
+ children,
29
+ caretColor && /* @__PURE__ */ jsxs(Fragment, { children: [
30
+ /* @__PURE__ */ jsx(View, { style: [styles.caret, caretOffset(ringSize), caretEdge(ringSize), { [caretFill]: caretBorderColor ?? caretColor, [caretWidthProp]: ringSize, borderLeftWidth: ringSize, borderRightWidth: ringSize }] }),
31
+ /* @__PURE__ */ jsx(View, { style: [styles.caret, caretOffset(CARET_SIZE), caretEdge(CARET_SIZE - CARET_RING_OFFSET), { [caretFill]: caretColor, [caretWidthProp]: CARET_SIZE, borderLeftWidth: CARET_SIZE, borderRightWidth: CARET_SIZE }] })
32
+ ] })
33
+ ] });
34
+ }
35
+ var styles = StyleSheet.create({
36
+ caret: {
37
+ borderLeftColor: "transparent",
38
+ borderRightColor: "transparent",
39
+ height: 0,
40
+ position: "absolute",
41
+ width: 0,
42
+ // Explicit, not left to DOM order — the box it dips into is itself a position:'relative' view,
43
+ // which React Native Web always gives an implicit zIndex:0 (see PopoverBody's stacking-context
44
+ // notes elsewhere in this codebase), so painting on top needs a real zIndex to beat that
45
+ // reliably rather than relying on being the later sibling.
46
+ zIndex: 1
47
+ },
48
+ content: {
49
+ position: "absolute",
50
+ zIndex: 50
51
+ },
52
+ contentAbove: {
53
+ bottom: "100%",
54
+ marginBottom: 8
55
+ },
56
+ contentBelow: {
57
+ marginTop: 8,
58
+ top: "100%"
59
+ },
60
+ // Stretched to the anchor's own (trigger) width, with alignItems centering the actual (usually
61
+ // much wider) popover box inside via plain flexbox — not `left:'50%'` plus a percentage
62
+ // `transform`, which depends on native resolving the box's own auto width before applying the
63
+ // transform and has proven unreliable there, visibly shifting the box off-center.
64
+ contentCenter: {
65
+ alignItems: "center",
66
+ left: 0,
67
+ right: 0
68
+ },
69
+ contentLeft: {
70
+ left: 0
71
+ },
72
+ contentRight: {
73
+ right: 0
74
+ }
75
+ });
76
+
77
+ // src/useAutoAlign.ts
78
+ import { useCallback, useEffect, useRef, useState } from "react";
79
+ import { useWindowDimensions } from "react-native";
80
+ var EDGE_MARGIN = 12;
81
+ function useAutoAlign(open, contentWidth, contentHeight) {
82
+ const triggerRef = useRef(null);
83
+ const { width: windowWidth, height: windowHeight } = useWindowDimensions();
84
+ const [align, setAlign] = useState("center");
85
+ const [verticalAlign, setVerticalAlign] = useState("below");
86
+ const [maxHeight, setMaxHeight] = useState(contentHeight);
87
+ const [measured, setMeasured] = useState(false);
88
+ const [prevOpen, setPrevOpen] = useState(open);
89
+ if (open !== prevOpen) {
90
+ setPrevOpen(open);
91
+ if (open) setMeasured(false);
92
+ }
93
+ const measure = useCallback(() => {
94
+ triggerRef.current?.measureInWindow((x, y, triggerWidth, triggerHeight) => {
95
+ const centerX = x + triggerWidth / 2;
96
+ const overflowsRight = centerX + contentWidth / 2 > windowWidth - EDGE_MARGIN;
97
+ const overflowsLeft = centerX - contentWidth / 2 < EDGE_MARGIN;
98
+ if (overflowsRight && !overflowsLeft) setAlign("right");
99
+ else if (overflowsLeft && !overflowsRight) setAlign("left");
100
+ else setAlign("center");
101
+ const roomBelow = windowHeight - (y + triggerHeight) - EDGE_MARGIN;
102
+ const roomAbove = y - EDGE_MARGIN;
103
+ const above = contentHeight > roomBelow && roomAbove > roomBelow;
104
+ setVerticalAlign(above ? "above" : "below");
105
+ setMaxHeight(Math.max(above ? roomAbove : roomBelow, 0));
106
+ setMeasured(true);
107
+ });
108
+ }, [contentWidth, contentHeight, windowWidth, windowHeight]);
109
+ useEffect(() => {
110
+ if (open) measure();
111
+ }, [open, measure]);
112
+ return { align, maxHeight, measured, triggerRef, verticalAlign };
113
+ }
114
+
115
+ // src/InlineColorPicker.tsx
116
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
117
+ var SIZE = 48;
118
+ var SWATCH_SIZE = 28;
119
+ var SWATCHES_PADDING = 8;
120
+ var SWATCHES_GAP = 6;
121
+ var SWATCHES_BORDER_WIDTH = 2;
122
+ var MIN_COLUMNS = 3;
123
+ var MAX_COLUMNS = 6;
124
+ var SCREEN_MARGIN = 40;
125
+ function widthForColumns(columns) {
126
+ return SWATCHES_BORDER_WIDTH * 2 + SWATCHES_PADDING * 2 + SWATCH_SIZE * columns + SWATCHES_GAP * (columns - 1);
127
+ }
128
+ function InlineColorPicker({ id, host, value, onChange, swatches = defaultColors, takenValue, allowSwapTaken, dark, align: alignOverride, icon = "palette", autoDismiss = true, columns }) {
129
+ const menuBg = dark ? "#000000" : "#FFFFFF";
130
+ const { width: windowWidth } = useWindowDimensions2();
131
+ const autoColumns = Math.min(MAX_COLUMNS, Math.max(MIN_COLUMNS, Math.floor((windowWidth - 2 * SCREEN_MARGIN + SWATCHES_GAP) / (SWATCH_SIZE + SWATCHES_GAP))));
132
+ const resolvedColumns = columns ?? autoColumns;
133
+ const swatchesWidth = widthForColumns(resolvedColumns);
134
+ const swatchRows = Math.ceil(swatches.length / resolvedColumns);
135
+ const swatchesHeight = SWATCHES_BORDER_WIDTH * 2 + SWATCHES_PADDING * 2 + SWATCH_SIZE * swatchRows + SWATCHES_GAP * (swatchRows - 1);
136
+ const open = host.openId === id;
137
+ const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, swatchesWidth, swatchesHeight);
138
+ const align = alignOverride ?? autoAlign;
139
+ 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: 20, color: getContrastColor(value) }) }) }),
141
+ /* @__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
+ const selected = swatch.value.toLowerCase() === value.toLowerCase();
143
+ const taken = !selected && !!takenValue && swatch.value.toLowerCase() === takenValue.toLowerCase();
144
+ const swappable = taken && allowSwapTaken;
145
+ return /* @__PURE__ */ jsx2(
146
+ TouchableRipple,
147
+ {
148
+ disabled: taken && !swappable,
149
+ onPress: () => {
150
+ onChange(swatch.value);
151
+ if (autoDismiss) host.close();
152
+ },
153
+ borderless: true,
154
+ style: [styles2.swatch, { backgroundColor: swatch.value }, selected && styles2.swatchSelected, taken && !swappable && styles2.swatchTaken],
155
+ children: selected ? /* @__PURE__ */ jsx2(Icon, { source: "check", size: 16, color: getContrastColor(swatch.value) }) : swappable ? /* @__PURE__ */ jsx2(Icon, { source: "swap-horizontal", size: 16, color: getContrastColor(swatch.value) }) : taken ? /* @__PURE__ */ jsx2(Icon, { source: "close", size: 16, color: getContrastColor(swatch.value) }) : /* @__PURE__ */ jsx2(View3, {})
156
+ },
157
+ swatch.value
158
+ );
159
+ }) }) })
160
+ ] });
161
+ }
162
+ var styles2 = StyleSheet2.create({
163
+ anchor: {
164
+ position: "relative"
165
+ },
166
+ // See SectionedDropdown's identical comment — React Native Web gives every position:'relative' view
167
+ // its own stacking context, so the elevation has to live on the anchor itself, not just the
168
+ // popover content nested inside it, to correctly paint above this anchor's own later siblings.
169
+ anchorOpen: {
170
+ zIndex: 100
171
+ },
172
+ swatch: {
173
+ alignItems: "center",
174
+ borderRadius: SWATCH_SIZE / 2,
175
+ height: SWATCH_SIZE,
176
+ justifyContent: "center",
177
+ width: SWATCH_SIZE
178
+ },
179
+ swatchSelected: {
180
+ borderColor: "#ffffff",
181
+ borderWidth: 2
182
+ },
183
+ swatchTaken: {
184
+ opacity: 0.35
185
+ },
186
+ swatches: {
187
+ borderRadius: 12,
188
+ borderWidth: SWATCHES_BORDER_WIDTH,
189
+ elevation: 8,
190
+ shadowColor: "#000000",
191
+ shadowOffset: { height: 2, width: 0 },
192
+ shadowOpacity: 0.3,
193
+ shadowRadius: 8
194
+ // width is set inline above — border-box sizing means it has to include the border too
195
+ // (2*SWATCHES_BORDER_WIDTH), not just padding+content, or exactly enough room goes missing that
196
+ // the last column silently wraps to a new row. Anything wider than the exact sum just shows as
197
+ // dead space on the right edge of each row instead.
198
+ },
199
+ // Separate from `swatches` itself — see SectionedDropdown's identical menu/menuContent split.
200
+ // The scroll frame's own bounds (including maxHeight) live on the ScrollView; the actual grid
201
+ // wrapping belongs to its contentContainerStyle, which sizes to the unclamped content instead.
202
+ swatchesContent: {
203
+ flexDirection: "row",
204
+ flexWrap: "wrap",
205
+ gap: SWATCHES_GAP,
206
+ padding: SWATCHES_PADDING
207
+ },
208
+ trigger: {
209
+ alignItems: "center",
210
+ alignSelf: "flex-start",
211
+ borderRadius: SIZE / 2,
212
+ height: SIZE,
213
+ justifyContent: "center",
214
+ width: SIZE
215
+ },
216
+ // Wraps just the icon, inside the TouchableRipple, purely so useAutoAlign has a plain View to
217
+ // attach its measurement ref to — TouchableRipple itself doesn't forward a ref to a measurable
218
+ // native node.
219
+ triggerMeasure: {
220
+ alignItems: "center",
221
+ height: "100%",
222
+ justifyContent: "center",
223
+ width: "100%"
224
+ }
225
+ });
226
+
227
+ // src/loadSkiaWeb.ts
228
+ import { Platform as Platform2 } from "react-native";
229
+ function loadSkiaWeb() {
230
+ if (Platform2.OS !== "web") return Promise.resolve();
231
+ return import("@shopify/react-native-skia/src/web").then((m) => m.LoadSkiaWeb());
232
+ }
233
+
234
+ // src/PressAwayOverlay.tsx
235
+ import { Pressable, StyleSheet as StyleSheet3 } from "react-native";
236
+ import { jsx as jsx3 } from "react/jsx-runtime";
237
+ function PressAwayOverlay({ active, onPress, style }) {
238
+ if (!active) return null;
239
+ return /* @__PURE__ */ jsx3(Pressable, { accessibilityLabel: "Dismiss", accessibilityRole: "button", onPress, style: [StyleSheet3.absoluteFill, style] });
240
+ }
241
+
242
+ // src/ReadyButton.tsx
243
+ import { TouchableRipple as TouchableRipple2 } from "@rific/feedback-press";
244
+ import { StyleSheet as StyleSheet4 } from "react-native";
245
+ import { Text } from "react-native-paper";
246
+ import { jsx as jsx4 } from "react/jsx-runtime";
247
+ function ReadyButton({ color, ready, onToggleReady, style, labelFontFamily = MONO_FONT }) {
248
+ return /* @__PURE__ */ jsx4(TouchableRipple2, { onPress: onToggleReady, borderless: true, style: [styles3.readyButton, { borderColor: color }, ready && { backgroundColor: color }, style], children: /* @__PURE__ */ jsx4(Text, { variant: "labelLarge", style: [{ fontFamily: labelFontFamily, fontWeight: "bold" }, { color: ready ? "#000000" : color }], children: ready ? "READY \u2713" : "READY?" }) });
249
+ }
250
+ var styles3 = StyleSheet4.create({
251
+ readyButton: {
252
+ alignItems: "center",
253
+ borderRadius: 12,
254
+ borderWidth: 1,
255
+ justifyContent: "center",
256
+ minWidth: 120,
257
+ paddingHorizontal: 16,
258
+ paddingVertical: 10
259
+ }
260
+ });
261
+
262
+ // src/SectionedDropdown.tsx
263
+ import { getBlendedColor, getColorRoles } from "@rific/auto-paper";
264
+ import { IconButton, TouchableRipple as TouchableRipple3 } from "@rific/feedback-press";
265
+ import { ScrollView as ScrollView2, StyleSheet as StyleSheet5, View as View4 } from "react-native";
266
+ import { Icon as Icon2, Text as Text2 } from "react-native-paper";
267
+
268
+ // src/TriggerGaugeHost.tsx
269
+ import { lazy, Suspense } from "react";
270
+ import { jsx as jsx5 } from "react/jsx-runtime";
271
+ var LazyTriggerGauge = lazy(() => loadSkiaWeb().then(() => import("./TriggerGauge-HIPRGEWJ.mjs").then((m) => ({ default: m.TriggerGauge }))));
272
+ function TriggerGaugeHost(props) {
273
+ return /* @__PURE__ */ jsx5(Suspense, { fallback: null, children: /* @__PURE__ */ jsx5(LazyTriggerGauge, { ...props }) });
274
+ }
275
+
276
+ // src/SectionedDropdown.tsx
277
+ import { Fragment as Fragment2, jsx as jsx6, jsxs as jsxs3 } from "react/jsx-runtime";
278
+ var MENU_BORDER_WIDTH = 1;
279
+ var TRIGGER_SIZE = 44;
280
+ var MENU_MIN_WIDTH = 200;
281
+ var MENU_MAX_WIDTH = 220;
282
+ var MENU_ITEM_ICON_SIZE = 18;
283
+ var MENU_ITEM_LABEL_MAX_WIDTH = 150;
284
+ var MENU_ITEM_HEIGHT = 40;
285
+ var MENU_ITEM_HEIGHT_WITH_DESCRIPTION = 58;
286
+ var MENU_DIVIDER_HEIGHT = 9;
287
+ var MENU_VERTICAL_PADDING = 12;
288
+ function SectionedDropdown({ id, host, icon, accessibilityLabel, sections, accentColor, mutedColor, onAccentColor, dark, align: alignOverride, autoDismiss = true, labelFontFamily = MONO_FONT, allClearLabels = { all: "All", clear: "Clear" } }) {
289
+ const menuBg = dark ? "#000000" : "#FFFFFF";
290
+ const menuBorderColor = getBlendedColor(mutedColor, menuBg, 0.5);
291
+ const resolvedOnAccentColor = onAccentColor ?? getColorRoles(accentColor, menuBg).onColor;
292
+ const fg = dark ? "#FFFFFF" : "#000000";
293
+ const open = host.openId === id;
294
+ const estimatedHeight = MENU_VERTICAL_PADDING + sections.reduce((sum, section, sectionIndex) => {
295
+ const rows = section.options.reduce((rowSum, option) => rowSum + (option.description ? MENU_ITEM_HEIGHT_WITH_DESCRIPTION : MENU_ITEM_HEIGHT), 0);
296
+ const footer = section.kind === "multi" && section.allClear ? MENU_ITEM_HEIGHT : 0;
297
+ const divider = sectionIndex > 0 ? MENU_DIVIDER_HEIGHT : 0;
298
+ return sum + rows + footer + divider;
299
+ }, 0);
300
+ const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, MENU_MAX_WIDTH, estimatedHeight);
301
+ const align = alignOverride ?? autoAlign;
302
+ const onlySection = sections.length === 1 ? sections[0] : null;
303
+ const selectedOption = onlySection?.kind === "single" ? onlySection.options.find((o) => o.value === onlySection.value) : void 0;
304
+ const triggerIcon = selectedOption?.icon ?? icon;
305
+ const triggerIconSize = selectedOption?.iconSize ?? 22;
306
+ const hasMultiSection = sections.some((s) => s.kind === "multi");
307
+ const anyMultiSelected = sections.some((s) => s.kind === "multi" && s.value.length > 0);
308
+ const triggerIconColor = hasMultiSection ? anyMultiSelected ? accentColor : fg : fg;
309
+ const gaugeSegments = sections.reduce((sum, section) => sum + section.options.length, 0);
310
+ const gaugeLitIndices = [];
311
+ let gaugeOffset = 0;
312
+ for (const section of sections) {
313
+ if (section.kind === "single") {
314
+ const index = section.options.findIndex((o) => o.value === section.value);
315
+ if (index >= 0) gaugeLitIndices.push(gaugeOffset + index);
316
+ } else {
317
+ section.options.forEach((o, i) => {
318
+ if (section.value.includes(o.value)) gaugeLitIndices.push(gaugeOffset + i);
319
+ });
320
+ }
321
+ gaugeOffset += section.options.length;
322
+ }
323
+ return /* @__PURE__ */ jsxs3(View4, { style: [styles4.anchor, open && styles4.anchorOpen], children: [
324
+ /* @__PURE__ */ jsxs3(View4, { ref: triggerRef, collapsable: false, style: styles4.triggerBox, children: [
325
+ /* @__PURE__ */ jsx6(TriggerGaugeHost, { segments: gaugeSegments, litIndices: gaugeLitIndices, size: TRIGGER_SIZE, accentColor, mutedColor }),
326
+ /* @__PURE__ */ jsx6(IconButton, { icon: triggerIcon, iconColor: triggerIconColor, size: triggerIconSize, accessibilityLabel, onPress: () => host.toggle(id) })
327
+ ] }),
328
+ /* @__PURE__ */ jsx6(PopoverBody, { visible: open && measured, align, verticalAlign, caretColor: menuBg, caretBorderColor: menuBorderColor, caretBorderWidth: MENU_BORDER_WIDTH, triggerSize: TRIGGER_SIZE, children: /* @__PURE__ */ jsx6(ScrollView2, { style: [styles4.menu, { backgroundColor: menuBg, borderColor: menuBorderColor, maxHeight }], contentContainerStyle: styles4.menuContent, showsVerticalScrollIndicator: false, children: sections.map((section, sectionIndex) => /* @__PURE__ */ jsxs3(View4, { children: [
329
+ sectionIndex > 0 && /* @__PURE__ */ jsx6(View4, { style: [styles4.divider, { backgroundColor: menuBorderColor }] }),
330
+ section.kind === "single" ? /* @__PURE__ */ jsx6(
331
+ SingleSection,
332
+ {
333
+ section,
334
+ accentColor,
335
+ onAccentColor: resolvedOnAccentColor,
336
+ mutedColor,
337
+ labelFontFamily,
338
+ onSelect: (value) => {
339
+ section.onChange(value);
340
+ if (autoDismiss) host.close();
341
+ }
342
+ }
343
+ ) : /* @__PURE__ */ jsx6(MultiSection, { section, accentColor, onAccentColor: resolvedOnAccentColor, mutedColor, labelFontFamily, allClearLabels })
344
+ ] }, section.id)) }) })
345
+ ] });
346
+ }
347
+ function SingleSection({
348
+ section,
349
+ accentColor,
350
+ onAccentColor,
351
+ mutedColor,
352
+ labelFontFamily,
353
+ onSelect
354
+ }) {
355
+ return /* @__PURE__ */ jsx6(Fragment2, { children: section.options.map((option) => {
356
+ const selected = option.value === section.value;
357
+ const rowColor = selected ? onAccentColor : mutedColor;
358
+ return /* @__PURE__ */ jsx6(TouchableRipple3, { onPress: () => onSelect(option.value), style: [styles4.item, selected && { backgroundColor: accentColor }], children: /* @__PURE__ */ jsxs3(View4, { style: styles4.itemRow, children: [
359
+ option.icon && /* @__PURE__ */ jsx6(Icon2, { source: option.icon, size: MENU_ITEM_ICON_SIZE, color: rowColor }),
360
+ /* @__PURE__ */ jsxs3(View4, { style: styles4.itemLabelColumn, children: [
361
+ /* @__PURE__ */ jsx6(Text2, { variant: "labelLarge", style: [{ fontFamily: labelFontFamily }, { color: rowColor, fontWeight: selected ? "bold" : "normal" }], children: option.label }),
362
+ option.description && /* @__PURE__ */ jsx6(Text2, { variant: "labelSmall", style: [{ fontFamily: labelFontFamily }, { color: rowColor }], children: option.description })
363
+ ] })
364
+ ] }) }, option.value);
365
+ }) });
366
+ }
367
+ function MultiSection({
368
+ section,
369
+ accentColor,
370
+ onAccentColor,
371
+ mutedColor,
372
+ labelFontFamily,
373
+ allClearLabels
374
+ }) {
375
+ const allSelected = section.value.length === section.options.length;
376
+ return /* @__PURE__ */ jsxs3(Fragment2, { children: [
377
+ section.options.map((option, i) => {
378
+ const selected = section.value.includes(option.value);
379
+ const rowColor = selected ? onAccentColor : mutedColor;
380
+ const prevSelected = selected && i > 0 && section.value.includes(section.options[i - 1].value);
381
+ const nextSelected = selected && i < section.options.length - 1 && section.value.includes(section.options[i + 1].value);
382
+ return /* @__PURE__ */ jsx6(TouchableRipple3, { onPress: () => section.onChange(selected ? section.value.filter((v) => v !== option.value) : [...section.value, option.value]), style: [styles4.item, selected && { backgroundColor: accentColor }, prevSelected && { borderTopLeftRadius: 0, borderTopRightRadius: 0 }, nextSelected && { borderBottomLeftRadius: 0, borderBottomRightRadius: 0 }], children: /* @__PURE__ */ jsxs3(View4, { style: styles4.itemRow, children: [
383
+ option.icon && /* @__PURE__ */ jsx6(Icon2, { source: option.icon, size: MENU_ITEM_ICON_SIZE, color: rowColor }),
384
+ /* @__PURE__ */ jsx6(Text2, { variant: "labelLarge", style: [styles4.itemLabelFlex, { fontFamily: labelFontFamily }, { color: rowColor, fontWeight: selected ? "bold" : "normal" }], children: option.label })
385
+ ] }) }, option.value);
386
+ }),
387
+ section.allClear && /* @__PURE__ */ jsx6(TouchableRipple3, { onPress: () => section.onChange(allSelected ? [] : section.options.map((o) => o.value)), style: styles4.item, children: /* @__PURE__ */ jsx6(Text2, { variant: "labelLarge", style: [styles4.allClearLabel, { fontFamily: labelFontFamily, color: accentColor }], children: allSelected ? allClearLabels.clear : allClearLabels.all }) })
388
+ ] });
389
+ }
390
+ var styles4 = StyleSheet5.create({
391
+ allClearLabel: {
392
+ fontWeight: "bold",
393
+ textAlign: "center"
394
+ },
395
+ anchor: {
396
+ position: "relative"
397
+ },
398
+ // React Native Web gives every position:'relative' view an explicit zIndex (0, not 'auto'),
399
+ // which makes each one its own stacking context — so a zIndex set only on the popover content
400
+ // below only wins comparisons within this anchor's own box, not against this anchor's own later
401
+ // siblings (e.g. a Ready button), which are otherwise painted on top by DOM-order tiebreak.
402
+ // Elevating the anchor itself, only while its popover is open, fixes that at the right level.
403
+ anchorOpen: {
404
+ zIndex: 100
405
+ },
406
+ divider: {
407
+ height: StyleSheet5.hairlineWidth,
408
+ marginVertical: 4
409
+ },
410
+ item: {
411
+ borderRadius: 8,
412
+ paddingHorizontal: 12,
413
+ paddingVertical: 8
414
+ },
415
+ itemLabelColumn: {
416
+ maxWidth: MENU_ITEM_LABEL_MAX_WIDTH
417
+ },
418
+ itemLabelFlex: {
419
+ flex: 1
420
+ },
421
+ itemRow: {
422
+ alignItems: "center",
423
+ flexDirection: "row",
424
+ gap: 8
425
+ },
426
+ menu: {
427
+ borderRadius: 12,
428
+ borderWidth: MENU_BORDER_WIDTH,
429
+ elevation: 8,
430
+ maxWidth: MENU_MAX_WIDTH,
431
+ minWidth: MENU_MIN_WIDTH,
432
+ shadowColor: "#000000",
433
+ shadowOffset: { height: 2, width: 0 },
434
+ shadowOpacity: 0.3,
435
+ shadowRadius: 8
436
+ },
437
+ // Separate from `menu` itself — a ScrollView's own style sets the scrolling frame's bounds
438
+ // (including the maxHeight that actually caps it), while padding/gap belong to its
439
+ // contentContainerStyle, which sizes to the *unclamped* content instead of the frame.
440
+ menuContent: {
441
+ gap: 4,
442
+ padding: 6
443
+ },
444
+ // Fixed hit-area regardless of the current option's iconSize, so a small-vs-large trigger icon
445
+ // doesn't shift this picker's position within its row.
446
+ triggerBox: {
447
+ alignItems: "center",
448
+ height: TRIGGER_SIZE,
449
+ justifyContent: "center",
450
+ width: TRIGGER_SIZE
451
+ }
452
+ });
453
+
454
+ // src/usePopoverHost.ts
455
+ import { useCallback as useCallback2, useState as useState2 } from "react";
456
+ function usePopoverHost() {
457
+ const [openId, setOpenId] = useState2(null);
458
+ const toggle = useCallback2((id) => {
459
+ setOpenId((prev) => prev === id ? null : id);
460
+ }, []);
461
+ const close = useCallback2(() => setOpenId(null), []);
462
+ return { openId, toggle, close };
463
+ }
464
+ export {
465
+ InlineColorPicker,
466
+ MONO_FONT,
467
+ PopoverBody,
468
+ PressAwayOverlay,
469
+ ReadyButton,
470
+ SectionedDropdown,
471
+ TriggerGaugeHost,
472
+ loadSkiaWeb,
473
+ useAutoAlign,
474
+ usePopoverHost
475
+ };
package/package.json ADDED
@@ -0,0 +1,103 @@
1
+ {
2
+ "name": "@tastic/hud",
3
+ "version": "0.1.0",
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
+ "keywords": [
6
+ "react-native",
7
+ "expo",
8
+ "local-multiplayer",
9
+ "couch-multiplayer",
10
+ "hud",
11
+ "popover",
12
+ "dropdown",
13
+ "color-picker",
14
+ "press-away",
15
+ "dialog"
16
+ ],
17
+ "homepage": "https://github.com/jayrdeaton/react-native-hud#readme",
18
+ "bugs": {
19
+ "url": "https://github.com/jayrdeaton/react-native-hud/issues"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/jayrdeaton/react-native-hud.git"
24
+ },
25
+ "license": "MIT",
26
+ "author": "Jay Deaton",
27
+ "sideEffects": false,
28
+ "type": "commonjs",
29
+ "exports": {
30
+ ".": {
31
+ "react-native": "./src/index.ts",
32
+ "types": "./dist/index.d.ts",
33
+ "import": "./dist/index.mjs",
34
+ "require": "./dist/index.js"
35
+ }
36
+ },
37
+ "main": "dist/index.js",
38
+ "module": "dist/index.mjs",
39
+ "react-native": "src/index.ts",
40
+ "types": "dist/index.d.ts",
41
+ "files": [
42
+ "dist",
43
+ "src",
44
+ "!src/__tests__",
45
+ "!src/__mocks__"
46
+ ],
47
+ "scripts": {
48
+ "build": "tsup src/index.ts --format cjs,esm --dts --clean",
49
+ "build:watch": "tsup src/index.ts --format cjs,esm --dts --watch",
50
+ "fix": "eslint --fix",
51
+ "lint": "eslint",
52
+ "prepublishOnly": "npm run build",
53
+ "release": "git push --follow-tags",
54
+ "release:major": "npm version major && git push --follow-tags",
55
+ "release:minor": "npm version minor && git push --follow-tags",
56
+ "release:patch": "npm version patch && git push --follow-tags",
57
+ "test": "jest",
58
+ "test:watch": "jest --watchAll",
59
+ "typecheck": "tsc --noEmit",
60
+ "preversion": "npm run lint && npm test && npm run build"
61
+ },
62
+ "devDependencies": {
63
+ "@rific/auto-paper": "^0.9.4",
64
+ "@rific/feedback-press": "^0.10.4",
65
+ "@shopify/react-native-skia": "^2.6.2",
66
+ "@testing-library/dom": "^10.4.1",
67
+ "@testing-library/react": "^16.3.2",
68
+ "@types/jest": "^30.0.0",
69
+ "@types/react": "^19.0.0",
70
+ "@typescript-eslint/parser": "^8.59.3",
71
+ "eslint": "^9.39.4",
72
+ "eslint-config-prettier": "^10.1.8",
73
+ "eslint-plugin-package-json": "^1.0.0",
74
+ "eslint-plugin-prettier": "^5.5.5",
75
+ "eslint-plugin-react-hooks": "^7.1.1",
76
+ "eslint-plugin-react-native": "^5.0.0",
77
+ "eslint-plugin-simple-import-sort": "^13.0.0",
78
+ "jest": "^30.4.2",
79
+ "jest-environment-jsdom": "^30.4.1",
80
+ "prettier": "^3.8.3",
81
+ "react": "^19.0.0",
82
+ "react-dom": "^19.0.0",
83
+ "react-native": "^0.85.3",
84
+ "react-native-paper": "^5.15.2",
85
+ "react-native-reanimated": "^4.5.1",
86
+ "ts-jest": "^29.4.9",
87
+ "tsup": "^8.0.0",
88
+ "typescript": "^6.0.3",
89
+ "typescript-eslint": "^8.59.3"
90
+ },
91
+ "peerDependencies": {
92
+ "@rific/auto-paper": ">=0.9.0",
93
+ "@rific/feedback-press": ">=0.10.0",
94
+ "@shopify/react-native-skia": ">=1.5.0",
95
+ "react": ">=19.0.0",
96
+ "react-native": ">=0.76.0",
97
+ "react-native-paper": ">=5.0.0",
98
+ "react-native-reanimated": ">=3.0.0"
99
+ },
100
+ "publishConfig": {
101
+ "access": "public"
102
+ }
103
+ }