@tastic/hud 0.5.0 → 0.7.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.d.mts +122 -67
- package/dist/index.d.ts +122 -67
- package/dist/index.js +483 -337
- package/dist/index.mjs +455 -313
- package/package.json +4 -4
- package/src/BaseSettingsDialog.tsx +12 -12
- package/src/ConfirmDialog.tsx +104 -0
- package/src/ContentGutter.tsx +55 -0
- package/src/ControlSchemePicker.tsx +53 -0
- package/src/CornerActionButtons.tsx +10 -2
- package/src/CornerStatusBadges.tsx +87 -0
- package/src/InlineColorPicker.tsx +21 -11
- package/src/SectionedDropdown.tsx +14 -2
- package/src/index.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tastic/hud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Visual component kit for local-multiplayer React Native games: inline (non-modal) popovers, dropdowns, color pickers, gauges, ready buttons, and dialogs, built to stay scoped to one player's own zone on a shared screen",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react-native",
|
|
@@ -63,14 +63,14 @@
|
|
|
63
63
|
},
|
|
64
64
|
"prettier": "@infinitetoken/eslint-config/prettier",
|
|
65
65
|
"devDependencies": {
|
|
66
|
-
"@infinitetoken/eslint-config": "
|
|
66
|
+
"@infinitetoken/eslint-config": "0.2.0",
|
|
67
67
|
"@infinitetoken/jest-config": "^0.2.0",
|
|
68
|
-
"@infinitetoken/tsconfig": "^0.
|
|
68
|
+
"@infinitetoken/tsconfig": "^0.4.1",
|
|
69
69
|
"@rific/auto-paper": "^0.10.3",
|
|
70
70
|
"@rific/feedback-press": "^0.10.8",
|
|
71
71
|
"@rific/updater": "^0.4.2",
|
|
72
72
|
"@shopify/react-native-skia": "^2.6.2",
|
|
73
|
-
"@tastic/core": "^0.
|
|
73
|
+
"@tastic/core": "^0.2.0",
|
|
74
74
|
"@testing-library/dom": "^10.4.1",
|
|
75
75
|
"@testing-library/react": "^16.3.2",
|
|
76
76
|
"@types/jest": "^30.0.0",
|
|
@@ -334,25 +334,25 @@ const styles = StyleSheet.create({
|
|
|
334
334
|
sectionLabel: {
|
|
335
335
|
letterSpacing: 2
|
|
336
336
|
},
|
|
337
|
-
//
|
|
338
|
-
// HAPTICS below, while the ripple/hover highlight
|
|
339
|
-
//
|
|
340
|
-
//
|
|
337
|
+
// Used to carry marginHorizontal: -12 + paddingHorizontal: 12 (netting to the same inset as
|
|
338
|
+
// APPEARANCE/SOUND & HAPTICS below, while giving the ripple/hover highlight room to breathe
|
|
339
|
+
// into the dialog's own gutter beyond that). Removed: this dialog scrolls (Dialog.ScrollArea +
|
|
340
|
+
// ScrollView above), and on web a ScrollView's own content wrapper clips to its own bounds
|
|
341
|
+
// regardless of a child's negative margin - confirmed live, the icon's own rounded-square badge
|
|
342
|
+
// was getting its left edge sliced off by exactly that overhang. No horizontal inset of its own
|
|
343
|
+
// now - it relies purely on the same dialog padding APPEARANCE/every other row already does,
|
|
344
|
+
// which can never overflow because it's never negative.
|
|
341
345
|
toggleButton: {
|
|
342
346
|
borderRadius: 12,
|
|
343
|
-
marginHorizontal: -12,
|
|
344
347
|
overflow: 'hidden',
|
|
345
|
-
paddingHorizontal: 12,
|
|
346
348
|
paddingVertical: 10
|
|
347
349
|
},
|
|
348
|
-
// Same shape as toggleButton
|
|
349
|
-
//
|
|
350
|
-
// toggleRow carries that edge-alignment margin once, for the row as a whole, instead.
|
|
350
|
+
// Same shape as toggleButton, `flex: 1` instead of its own width - two of these side by side in
|
|
351
|
+
// toggleRow share the row's own edge alignment instead of each carrying their own.
|
|
351
352
|
toggleButtonInRow: {
|
|
352
353
|
borderRadius: 12,
|
|
353
354
|
flex: 1,
|
|
354
355
|
overflow: 'hidden',
|
|
355
|
-
paddingHorizontal: 12,
|
|
356
356
|
paddingVertical: 10
|
|
357
357
|
},
|
|
358
358
|
toggleContent: {
|
|
@@ -363,9 +363,9 @@ const styles = StyleSheet.create({
|
|
|
363
363
|
toggleGroup: {
|
|
364
364
|
gap: 4
|
|
365
365
|
},
|
|
366
|
+
// No horizontal margin of its own now either - see toggleButton's identical note.
|
|
366
367
|
toggleRow: {
|
|
367
368
|
flexDirection: 'row',
|
|
368
|
-
gap: 4
|
|
369
|
-
marginHorizontal: -12
|
|
369
|
+
gap: 4
|
|
370
370
|
}
|
|
371
371
|
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
|
+
import { Button } from '@rific/feedback-press'
|
|
3
|
+
import { StyleSheet, View } from 'react-native'
|
|
4
|
+
import { Icon, Portal, Text } from 'react-native-paper'
|
|
5
|
+
|
|
6
|
+
export interface ConfirmDialogProps {
|
|
7
|
+
visible: boolean
|
|
8
|
+
title: string
|
|
9
|
+
message: string
|
|
10
|
+
confirmLabel: string
|
|
11
|
+
cancelLabel: string
|
|
12
|
+
onConfirm: () => void
|
|
13
|
+
onCancel: () => void
|
|
14
|
+
// Live physical-hold rotation (see @tastic/split-screen's getViewRotation) - same convention as
|
|
15
|
+
// BaseSettingsDialog's own rotation prop; defaults to 0 for a caller with no per-player zone to
|
|
16
|
+
// match (a portrait-only or single-player game has nothing to stay consistent with).
|
|
17
|
+
rotation?: number
|
|
18
|
+
// Any react-native-paper Icon `source` name, shown above the title - defaults to a plain
|
|
19
|
+
// question mark, since this component has no way to know why a caller is asking.
|
|
20
|
+
icon?: string
|
|
21
|
+
// Whether the confirm button reads as destructive (the fleet's shared `danger` semantic role)
|
|
22
|
+
// instead of the default primary-colored affirmative action.
|
|
23
|
+
destructive?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// A themed replacement for the native Alert.alert/window.confirm two-button confirm pattern - the
|
|
27
|
+
// same "retro card overlay" look BaseSettingsDialog's own update-check info messages already use
|
|
28
|
+
// (see its onInfo/infoMessage), generalized here into its own reusable, exported component for any
|
|
29
|
+
// caller that needs a real yes/no decision instead of a single OK dismissal. A solid backdrop +
|
|
30
|
+
// centered card, not react-native-paper's own Dialog (which BaseSettingsDialog uses instead) -
|
|
31
|
+
// this renders identically on every platform, including web, where react-native-web's Alert.alert
|
|
32
|
+
// is a hard no-op (confirmed: its entire implementation is `static alert() {}`) and Dialog's own
|
|
33
|
+
// Portal-based positioning has its own web quirks BaseSettingsDialog already works around.
|
|
34
|
+
export function ConfirmDialog({ visible, title, message, confirmLabel, cancelLabel, onConfirm, onCancel, rotation = 0, icon = 'help-circle-outline', destructive = false }: ConfirmDialogProps) {
|
|
35
|
+
const { dark, colors } = useAutoPaperTheme()
|
|
36
|
+
if (!visible) return null
|
|
37
|
+
|
|
38
|
+
// High-contrast retro look: literal black/white by appearance, not auto-paper's own (slightly
|
|
39
|
+
// tinted) background role - same convention BaseSettingsDialog's own overlay card uses.
|
|
40
|
+
const fg = dark ? '#FFFFFF' : '#000000'
|
|
41
|
+
const cardBg = dark ? '#111111' : '#F2F2F2'
|
|
42
|
+
const cardBorder = dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'
|
|
43
|
+
const accent = destructive ? colors.danger : colors.primary
|
|
44
|
+
const onAccent = destructive ? colors.onDanger : colors.onPrimary
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<Portal>
|
|
48
|
+
<View style={styles.overlay}>
|
|
49
|
+
<View style={[styles.card, { backgroundColor: cardBg, borderColor: cardBorder }, rotation % 360 !== 0 && { transform: [{ rotate: `${rotation}deg` }] }]}>
|
|
50
|
+
<Icon source={icon} size={64} color={accent} />
|
|
51
|
+
<Text variant='headlineLarge' style={[styles.title, { color: accent }]}>
|
|
52
|
+
{title}
|
|
53
|
+
</Text>
|
|
54
|
+
<Text variant='bodyLarge' style={[styles.body, { color: fg }]}>
|
|
55
|
+
{message}
|
|
56
|
+
</Text>
|
|
57
|
+
<View style={styles.actions}>
|
|
58
|
+
<Button mode='outlined' onPress={onCancel} style={styles.actionButton}>
|
|
59
|
+
{cancelLabel}
|
|
60
|
+
</Button>
|
|
61
|
+
<Button mode='contained' onPress={onConfirm} style={styles.actionButton} buttonColor={accent} textColor={onAccent}>
|
|
62
|
+
{confirmLabel}
|
|
63
|
+
</Button>
|
|
64
|
+
</View>
|
|
65
|
+
</View>
|
|
66
|
+
</View>
|
|
67
|
+
</Portal>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const styles = StyleSheet.create({
|
|
72
|
+
actionButton: {
|
|
73
|
+
flex: 1
|
|
74
|
+
},
|
|
75
|
+
actions: {
|
|
76
|
+
flexDirection: 'row',
|
|
77
|
+
gap: 12,
|
|
78
|
+
width: '100%'
|
|
79
|
+
},
|
|
80
|
+
body: {
|
|
81
|
+
textAlign: 'center'
|
|
82
|
+
},
|
|
83
|
+
card: {
|
|
84
|
+
alignItems: 'center',
|
|
85
|
+
borderRadius: 20,
|
|
86
|
+
borderWidth: 1,
|
|
87
|
+
gap: 16,
|
|
88
|
+
maxWidth: 360,
|
|
89
|
+
padding: 32
|
|
90
|
+
},
|
|
91
|
+
overlay: {
|
|
92
|
+
alignItems: 'center',
|
|
93
|
+
backgroundColor: 'rgba(0,0,0,0.72)',
|
|
94
|
+
bottom: 0,
|
|
95
|
+
justifyContent: 'center',
|
|
96
|
+
left: 0,
|
|
97
|
+
position: 'absolute',
|
|
98
|
+
right: 0,
|
|
99
|
+
top: 0
|
|
100
|
+
},
|
|
101
|
+
title: {
|
|
102
|
+
fontWeight: 'bold'
|
|
103
|
+
}
|
|
104
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { computeContentBounds } from '@tastic/core'
|
|
2
|
+
import { ReactNode } from 'react'
|
|
3
|
+
import { StyleProp, StyleSheet, useWindowDimensions, View, ViewStyle } from 'react-native'
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
// Upper bound on the centered content region's width, in px. Omitted (the default) means no
|
|
7
|
+
// clamp at all - content fills the full window width and both gutters stay at 0 - the right
|
|
8
|
+
// default for a caller that wants this component's centered-content structure without ever
|
|
9
|
+
// actually capping width (e.g. a future full-bleed/"extend into safe area"-style mode: just
|
|
10
|
+
// don't pass this prop for that mode, rather than needing a separate disable flag).
|
|
11
|
+
maxContentWidth?: number
|
|
12
|
+
// Decoration for the left/right leftover space once maxContentWidth actually clamps something -
|
|
13
|
+
// e.g. a boundary wall. Each renders inside a View already sized to that side's own
|
|
14
|
+
// gutterWidth (0, and so with no room to show in, whenever nothing's clamped) - omit either/both
|
|
15
|
+
// for plain empty space, the default.
|
|
16
|
+
leftGutter?: ReactNode
|
|
17
|
+
rightGutter?: ReactNode
|
|
18
|
+
style?: StyleProp<ViewStyle>
|
|
19
|
+
children: ReactNode
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Caps a game's play area at maxContentWidth on a wide desktop/web window, splitting whatever's
|
|
23
|
+
// left over into two equal, empty-by-default gutters instead of letting content keep stretching
|
|
24
|
+
// (or drift off-center, left-anchored inside a now-much-wider parent) past the point where growing
|
|
25
|
+
// any further stops being useful. Below maxContentWidth - or with it omitted entirely - this is a
|
|
26
|
+
// no-op passthrough: children render at the full window width, no gutters.
|
|
27
|
+
//
|
|
28
|
+
// See @tastic/core's computeContentBounds for the actual clamp math this just wires up to a live
|
|
29
|
+
// useWindowDimensions() read - deliberately the only geometry source this component owns itself; a
|
|
30
|
+
// caller that also needs to fold in device safe-area insets or a split-screen zone boundary
|
|
31
|
+
// composes those together on its own side, the same way LightCycles' useZoneClampedAlign composes
|
|
32
|
+
// useAutoAlign with its own extra geometry (see that hook for the pattern this follows).
|
|
33
|
+
export function ContentGutter({ maxContentWidth, leftGutter, rightGutter, style, children }: Props) {
|
|
34
|
+
const { width: windowWidth } = useWindowDimensions()
|
|
35
|
+
const { contentWidth, gutterWidth } = computeContentBounds(windowWidth, maxContentWidth ?? Infinity)
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<View style={[styles.row, style]}>
|
|
39
|
+
<View style={[styles.gutter, { width: gutterWidth }]}>{leftGutter}</View>
|
|
40
|
+
<View style={{ width: contentWidth }}>{children}</View>
|
|
41
|
+
<View style={[styles.gutter, { width: gutterWidth }]}>{rightGutter}</View>
|
|
42
|
+
</View>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const styles = StyleSheet.create({
|
|
47
|
+
// overflow:'hidden' keeps an oversized leftGutter/rightGutter from bleeding into the content
|
|
48
|
+
// column instead of clipping cleanly at its own real width.
|
|
49
|
+
gutter: {
|
|
50
|
+
overflow: 'hidden'
|
|
51
|
+
},
|
|
52
|
+
row: {
|
|
53
|
+
flexDirection: 'row'
|
|
54
|
+
}
|
|
55
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useIsTouchPrimaryDevice } from '@tastic/core'
|
|
2
|
+
import { Platform } from 'react-native'
|
|
3
|
+
|
|
4
|
+
import { MenuOption, SectionedDropdown } from './SectionedDropdown'
|
|
5
|
+
import { PopoverHost } from './usePopoverHost'
|
|
6
|
+
|
|
7
|
+
export interface ControlSchemePickerProps<T extends string | number> {
|
|
8
|
+
id: string
|
|
9
|
+
host: PopoverHost
|
|
10
|
+
value: T
|
|
11
|
+
onChange: (value: T) => void
|
|
12
|
+
options: MenuOption<T>[]
|
|
13
|
+
// Another seat's currently-selected scheme, if any — passed straight through as the underlying
|
|
14
|
+
// section's own takenValue (see SectionedDropdown's own doc: stays in the list, shown disabled,
|
|
15
|
+
// rather than filtered out). Two local seats sharing one physical keyboard (and, depending on your
|
|
16
|
+
// own option set, one mouse) can't both be assigned the same scheme at once; this is what a caller
|
|
17
|
+
// wires up to prevent that.
|
|
18
|
+
takenValue?: T
|
|
19
|
+
accentColor: string
|
|
20
|
+
mutedColor: string
|
|
21
|
+
onAccentColor?: string
|
|
22
|
+
dark: boolean
|
|
23
|
+
align?: 'left' | 'right' | 'center'
|
|
24
|
+
labelFontFamily?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A single-select SectionedDropdown, opinionated for exactly one job: letting a local seat pick
|
|
28
|
+
// which physical input drives it in a couch-multiplayer game. Fixes the trigger icon and
|
|
29
|
+
// accessibility label so every game's control-scheme picker looks and reads the same, and — the
|
|
30
|
+
// actual reason this exists as its own component rather than a usage snippet in each app's own
|
|
31
|
+
// docs — bakes in the "does this even mean anything right now" gate every consumer was independently
|
|
32
|
+
// re-deriving: a physical keyboard scheme has no meaning on a touch-primary device (there's nothing
|
|
33
|
+
// to pick a *keyboard* layout for), and this repo's own components target React Native Web, where
|
|
34
|
+
// `Platform.OS !== 'web'` means native/touch already, so keying off `useIsTouchPrimaryDevice` alone
|
|
35
|
+
// isn't enough on its own — a native touch device still needs the `Platform.OS` check to be excluded
|
|
36
|
+
// the same way a desktop browser needs the touch check. Renders nothing at all when either
|
|
37
|
+
// condition fails, rather than a disabled/empty picker, so a caller can mount this unconditionally
|
|
38
|
+
// inside its own per-seat layout (a `View` row alongside a color picker, say) without its own
|
|
39
|
+
// `Platform`/`useIsTouchPrimaryDevice` branch — an always-rendered-but-sometimes-null child is a
|
|
40
|
+
// no-op in a flex layout, so the surrounding row collapses to just its other children automatically.
|
|
41
|
+
//
|
|
42
|
+
// Deliberately generic over `T` (matching SectionedDropdown's own `MenuOption<T>`) rather than
|
|
43
|
+
// hardcoding a fixed scheme union: this repo has no opinion on which schemes exist — the raw
|
|
44
|
+
// physical-key layouts (wasd/arrows/ijkl/numpad) live in `@tastic/input`, and any extra values with
|
|
45
|
+
// no keys of their own (a "keep using pointer input" option, say) are purely a per-app label — so a
|
|
46
|
+
// caller always supplies its own `options` (and typically its own `T`) rather than this component
|
|
47
|
+
// importing `@tastic/input` and choosing a set for every consumer.
|
|
48
|
+
export function ControlSchemePicker<T extends string | number>({ id, host, value, onChange, options, takenValue, accentColor, mutedColor, onAccentColor, dark, align, labelFontFamily }: ControlSchemePickerProps<T>) {
|
|
49
|
+
const isTouchPrimary = useIsTouchPrimaryDevice()
|
|
50
|
+
if (Platform.OS !== 'web' || isTouchPrimary) return null
|
|
51
|
+
|
|
52
|
+
return <SectionedDropdown id={id} host={host} icon='keyboard-outline' accessibilityLabel='Control scheme' sections={[{ kind: 'single', id: 'controls', options, value, onChange, takenValue }]} accentColor={accentColor} mutedColor={mutedColor} onAccentColor={onAccentColor} dark={dark} align={align} labelFontFamily={labelFontFamily} />
|
|
53
|
+
}
|
|
@@ -12,6 +12,14 @@ interface Props {
|
|
|
12
12
|
// @tastic/split-screen's rotateInsets) — this component just adds the fixed 8px margin on top,
|
|
13
13
|
// it doesn't know anything about physical device orientation itself.
|
|
14
14
|
insets: { top: number; left: number; right: number }
|
|
15
|
+
// Overrides the top-left button's icon (a react-native-paper/MaterialCommunityIcons name) and its
|
|
16
|
+
// accessibility label — default 'arrow-left'/'Back'. Not every screen's top-left control is
|
|
17
|
+
// actually "undo the last navigation": a flat menu/detail screen with nothing deeper to pop back
|
|
18
|
+
// through often wants a literal home/menu affordance driving an absolute navigation instead (see
|
|
19
|
+
// e.g. Solitaire's GameChrome, whose onBack navigates straight to its root route). Pass both
|
|
20
|
+
// together — an icon without a matching label (or vice versa) misleads screen readers/tooltips.
|
|
21
|
+
backIcon?: string
|
|
22
|
+
backLabel?: string
|
|
15
23
|
}
|
|
16
24
|
|
|
17
25
|
// Back (top-left) + Settings (top-right) icon buttons, fixed at the screen's rotated corners — the
|
|
@@ -20,10 +28,10 @@ interface Props {
|
|
|
20
28
|
// right answer for a screen where the top corners themselves fall *inside* one player's own rotated
|
|
21
29
|
// zone (two-player face-to-face) — see SharedActionBand for that case instead, which callers select
|
|
22
30
|
// between based on their own game-mode/orientation state (this component has no opinion on which).
|
|
23
|
-
export function CornerActionButtons({ onBack, onSettings, fg, insets }: Props) {
|
|
31
|
+
export function CornerActionButtons({ onBack, onSettings, fg, insets, backIcon = 'arrow-left', backLabel = 'Back' }: Props) {
|
|
24
32
|
return (
|
|
25
33
|
<>
|
|
26
|
-
<IconButton icon=
|
|
34
|
+
<IconButton icon={backIcon} iconColor={fg} size={24} style={[styles.back, { top: 8 + insets.top, left: 8 + insets.left }]} onPress={onBack} accessibilityLabel={backLabel} />
|
|
27
35
|
<IconButton icon='cog' iconColor={fg} size={24} style={[styles.topRight, { top: 8 + insets.top, right: 8 + insets.right }]} onPress={onSettings} accessibilityLabel='Settings' />
|
|
28
36
|
</>
|
|
29
37
|
)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { StyleSheet, View } from 'react-native'
|
|
2
|
+
import { Icon } from 'react-native-paper'
|
|
3
|
+
|
|
4
|
+
// Mirrors @tastic/split-screen's own type of the same name, duplicated here rather than imported —
|
|
5
|
+
// this package has never taken a dependency on @tastic/split-screen; every orientation/rotation-
|
|
6
|
+
// derived value it touches (ConfirmDialog's and BaseSettingsDialog's own `rotation` props, this
|
|
7
|
+
// component's orientationMode/p1OnRight below) is always a plain value the caller already resolved
|
|
8
|
+
// via that package's own hook, never something this package reads itself.
|
|
9
|
+
export type OrientationMode = 'faceToFace' | 'sideBySide'
|
|
10
|
+
|
|
11
|
+
export interface CornerStatusBadgeSeat {
|
|
12
|
+
// Any react-native-paper Icon `source` name. Omit to still render the badge itself, empty — a
|
|
13
|
+
// seat with nothing to show right now is different from no seat at all (see `p1`/`p2` on
|
|
14
|
+
// CornerStatusBadgesProps below), and the empty badge is itself the answer ("nothing"), not a
|
|
15
|
+
// reason to hide the corner.
|
|
16
|
+
icon?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CornerStatusBadgesProps {
|
|
20
|
+
orientationMode: OrientationMode
|
|
21
|
+
// Same "is this seat on the right" convention as @tastic/split-screen's own p1OnRight — only
|
|
22
|
+
// meaningful when orientationMode is 'sideBySide'; ignored in 'faceToFace', where seat 1 always
|
|
23
|
+
// owns the bottom half of a shared screen and seat 2 the top half, regardless of physical rotation.
|
|
24
|
+
p1OnRight: boolean
|
|
25
|
+
// Independently optional — omit a seat entirely to skip rendering its badge at all (e.g. a solo
|
|
26
|
+
// mode with no second seat to show anything for). Pass an object (even `{}`) to render that
|
|
27
|
+
// seat's badge with nothing in it.
|
|
28
|
+
p1?: CornerStatusBadgeSeat
|
|
29
|
+
p2?: CornerStatusBadgeSeat
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const BADGE_SIZE = 30
|
|
33
|
+
|
|
34
|
+
// Deliberately understated (small, low-opacity, no per-seat color) — a quiet corner landmark, not
|
|
35
|
+
// something that should compete for attention with whatever's on screen underneath it.
|
|
36
|
+
function StatusBadge({ icon }: { icon?: string }) {
|
|
37
|
+
return <View style={styles.badge}>{icon && <Icon source={icon} size={16} color='rgba(255,255,255,0.85)' />}</View>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Generic per-seat corner status badge: at most one caller-supplied icon per seat (or none), each
|
|
41
|
+
// pinned to that seat's own screen corner. Has zero opinion on what the icon means — originally
|
|
42
|
+
// extracted from LightCycles' PowerupHud.tsx and Snake's SnakePowerupHud.tsx, which both used this
|
|
43
|
+
// exact positioning and styling to reveal a held power-up, but this component only ever sees a
|
|
44
|
+
// resolved icon name (or undefined) per seat, never the domain value behind it, so it's equally
|
|
45
|
+
// suited to any other per-seat status a caller wants glanceable in a screen corner (connection
|
|
46
|
+
// state, a turn-order marker, anything that's a single icon or nothing). Corner picked per seat
|
|
47
|
+
// from orientationMode/p1OnRight — the same "is this seat on the right" convention
|
|
48
|
+
// @tastic/split-screen's own consumers already use for zone/rotation decisions — so each badge
|
|
49
|
+
// always sits in that seat's own zone rather than a side fixed regardless of which way the device
|
|
50
|
+
// is being held. In 'faceToFace', column doesn't matter (each seat's zone spans the full width):
|
|
51
|
+
// seat 1 stays bottom, seat 2 stays top, regardless of p1OnRight.
|
|
52
|
+
export function CornerStatusBadges({ orientationMode, p1OnRight, p1, p2 }: CornerStatusBadgesProps) {
|
|
53
|
+
const p1OnRightSide = orientationMode === 'sideBySide' && p1OnRight
|
|
54
|
+
const p2OnLeftSide = orientationMode === 'sideBySide' && !p1OnRight
|
|
55
|
+
return (
|
|
56
|
+
<>
|
|
57
|
+
{p1 && (
|
|
58
|
+
<View pointerEvents='none' style={p1OnRightSide ? styles.bottomRight : styles.bottomLeft}>
|
|
59
|
+
<StatusBadge icon={p1.icon} />
|
|
60
|
+
</View>
|
|
61
|
+
)}
|
|
62
|
+
{p2 && (
|
|
63
|
+
<View pointerEvents='none' style={p2OnLeftSide ? styles.topLeft : styles.topRight}>
|
|
64
|
+
<StatusBadge icon={p2.icon} />
|
|
65
|
+
</View>
|
|
66
|
+
)}
|
|
67
|
+
</>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const styles = StyleSheet.create({
|
|
72
|
+
badge: {
|
|
73
|
+
alignItems: 'center',
|
|
74
|
+
backgroundColor: 'rgba(0,0,0,0.28)',
|
|
75
|
+
borderColor: 'rgba(255,255,255,0.18)',
|
|
76
|
+
borderRadius: BADGE_SIZE / 2,
|
|
77
|
+
borderWidth: 1,
|
|
78
|
+
height: BADGE_SIZE,
|
|
79
|
+
justifyContent: 'center',
|
|
80
|
+
opacity: 0.7,
|
|
81
|
+
width: BADGE_SIZE
|
|
82
|
+
},
|
|
83
|
+
bottomLeft: { bottom: 12, left: 12, position: 'absolute' },
|
|
84
|
+
bottomRight: { bottom: 12, position: 'absolute', right: 12 },
|
|
85
|
+
topLeft: { left: 12, position: 'absolute', top: 12 },
|
|
86
|
+
topRight: { position: 'absolute', right: 12, top: 12 }
|
|
87
|
+
})
|
|
@@ -40,6 +40,13 @@ interface Props {
|
|
|
40
40
|
host: PopoverHost
|
|
41
41
|
value: string
|
|
42
42
|
onChange: (hex: string) => void
|
|
43
|
+
// Transforms only what's *rendered* — trigger fill, swatch fill, and the contrast/caret/border
|
|
44
|
+
// colors derived from them — leaving `value`/`swatch.value` themselves untouched for `selected`/
|
|
45
|
+
// `taken` matching and for what onChange actually commits. For a host whose own theme mutes or
|
|
46
|
+
// tints a raw seed color before it's actually used elsewhere (e.g. blending it toward a base
|
|
47
|
+
// palette for a card-back tint), so the picker can preview that real result per-swatch instead of
|
|
48
|
+
// the too-vibrant seed hue, without changing what gets stored.
|
|
49
|
+
previewValue?: (hex: string) => string
|
|
43
50
|
swatches?: SeedColor[]
|
|
44
51
|
// The other player's current color, if any — stays visible in the grid (so the full palette
|
|
45
52
|
// reads consistently for both players) but renders disabled with an X, rather than being
|
|
@@ -82,7 +89,7 @@ interface Props {
|
|
|
82
89
|
// Renders inline rather than as a full-screen modal, scoped to its own panel — a modal color
|
|
83
90
|
// picker would block the whole screen for one player while another can't touch their own panel at
|
|
84
91
|
// the same time, which defeats the point of a split-screen lobby.
|
|
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) {
|
|
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) {
|
|
86
93
|
const menuBg = dark ? '#000000' : '#FFFFFF'
|
|
87
94
|
const { width: windowWidth } = useWindowDimensions()
|
|
88
95
|
const autoColumns = clamp(Math.floor((windowWidth - 2 * SCREEN_MARGIN + SWATCHES_GAP) / (SWATCH_SIZE + SWATCHES_GAP)), MIN_COLUMNS, MAX_COLUMNS)
|
|
@@ -94,7 +101,9 @@ export function InlineColorPicker({ id, host, value, onChange, swatches = defaul
|
|
|
94
101
|
const open = host.openId === id
|
|
95
102
|
const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, swatchesWidth, swatchesHeight)
|
|
96
103
|
const align = alignOverride ?? autoAlign
|
|
97
|
-
const
|
|
104
|
+
const displayFor = previewValue ?? ((hex: string) => hex)
|
|
105
|
+
const displayValue = displayFor(value)
|
|
106
|
+
const contrastColor = getContrastColor(displayValue)
|
|
98
107
|
// An emoji glyph reads visually smaller than a bold letter at the same fontSize (the system emoji
|
|
99
108
|
// font leaves more of its own em-box empty), so a plain-text ratio that looks right for an initial
|
|
100
109
|
// still looks small for an emoji tag at the identical size — this bumps emoji up to the icon's own
|
|
@@ -103,7 +112,7 @@ export function InlineColorPicker({ id, host, value, onChange, swatches = defaul
|
|
|
103
112
|
|
|
104
113
|
return (
|
|
105
114
|
<View style={[styles.anchor, open && styles.anchorOpen]}>
|
|
106
|
-
<TouchableRipple onPress={() => host.toggle(id)} borderless style={[styles.trigger, { backgroundColor:
|
|
115
|
+
<TouchableRipple onPress={() => host.toggle(id)} borderless style={[styles.trigger, { backgroundColor: displayValue, borderRadius: size / 2, height: size, width: size }]}>
|
|
107
116
|
<View ref={triggerRef} collapsable={false} style={styles.triggerMeasure}>
|
|
108
117
|
{/* Sized as a fraction of the trigger's own diameter rather than a fixed pixel size, so
|
|
109
118
|
the glyph still reads as a deliberate part of the circle instead of shrinking toward its
|
|
@@ -121,18 +130,19 @@ export function InlineColorPicker({ id, host, value, onChange, swatches = defaul
|
|
|
121
130
|
{/* Gated on `measured`, not just `open` — see useAutoAlign's own comment and
|
|
122
131
|
SectionedDropdown's identical fix: without it, the popover mounts for one frame at a stale or
|
|
123
132
|
guessed alignment and visibly jumps once this open's own measurement lands. */}
|
|
124
|
-
<PopoverBody visible={open && measured} align={align} verticalAlign={verticalAlign} caretColor={menuBg} caretBorderColor={
|
|
133
|
+
<PopoverBody visible={open && measured} align={align} verticalAlign={verticalAlign} caretColor={menuBg} caretBorderColor={displayValue} caretBorderWidth={SWATCHES_BORDER_WIDTH} triggerSize={size}>
|
|
125
134
|
{/* Border matches this trigger's own current color (not a neutral gray) — two triggers can
|
|
126
135
|
sit close together, so the popover needs a clear visual tie back to which one opened it,
|
|
127
136
|
not just its screen position. maxHeight (see useAutoAlign) only actually clamps on a screen
|
|
128
137
|
short enough that the full grid can't fit above or below the trigger either way — a short
|
|
129
138
|
landscape screen with the trigger row near the top, same case SectionedDropdown's own
|
|
130
139
|
ScrollView exists for. */}
|
|
131
|
-
<ScrollView style={[styles.swatches, { backgroundColor: menuBg, borderColor:
|
|
140
|
+
<ScrollView style={[styles.swatches, { backgroundColor: menuBg, borderColor: displayValue, width: swatchesWidth, maxHeight }]} contentContainerStyle={styles.swatchesContent} showsVerticalScrollIndicator={false}>
|
|
132
141
|
{swatches.map((swatch) => {
|
|
133
142
|
const selected = swatch.value.toLowerCase() === value.toLowerCase()
|
|
134
143
|
const taken = !selected && !!takenValue && swatch.value.toLowerCase() === takenValue.toLowerCase()
|
|
135
144
|
const swappable = taken && allowSwapTaken
|
|
145
|
+
const swatchDisplay = displayFor(swatch.value)
|
|
136
146
|
return (
|
|
137
147
|
<TouchableRipple
|
|
138
148
|
key={swatch.value}
|
|
@@ -142,9 +152,9 @@ export function InlineColorPicker({ id, host, value, onChange, swatches = defaul
|
|
|
142
152
|
if (autoDismiss) host.close()
|
|
143
153
|
}}
|
|
144
154
|
borderless
|
|
145
|
-
style={[styles.swatch, { backgroundColor:
|
|
155
|
+
style={[styles.swatch, { backgroundColor: swatchDisplay }, selected && styles.swatchSelected, taken && !swappable && styles.swatchTaken]}
|
|
146
156
|
>
|
|
147
|
-
{selected ? <Icon source='check' size={16} color={getContrastColor(
|
|
157
|
+
{selected ? <Icon source='check' size={16} color={getContrastColor(swatchDisplay)} /> : swappable ? <Icon source='swap-horizontal' size={16} color={getContrastColor(swatchDisplay)} /> : taken ? <Icon source='close' size={16} color={getContrastColor(swatchDisplay)} /> : <View />}
|
|
148
158
|
</TouchableRipple>
|
|
149
159
|
)
|
|
150
160
|
})}
|
|
@@ -200,6 +210,10 @@ const styles = StyleSheet.create({
|
|
|
200
210
|
gap: SWATCHES_GAP,
|
|
201
211
|
padding: SWATCHES_PADDING
|
|
202
212
|
},
|
|
213
|
+
tagLabel: {
|
|
214
|
+
fontWeight: '700',
|
|
215
|
+
letterSpacing: 0.5
|
|
216
|
+
},
|
|
203
217
|
// borderRadius/height/width come from the `size` prop instead (see render-site override) — no
|
|
204
218
|
// fixed default here, since this style object is shared by every caller regardless of size.
|
|
205
219
|
trigger: {
|
|
@@ -218,9 +232,5 @@ const styles = StyleSheet.create({
|
|
|
218
232
|
justifyContent: 'center',
|
|
219
233
|
paddingHorizontal: 4,
|
|
220
234
|
width: '100%'
|
|
221
|
-
},
|
|
222
|
-
tagLabel: {
|
|
223
|
-
fontWeight: '700',
|
|
224
|
-
letterSpacing: 0.5
|
|
225
235
|
}
|
|
226
236
|
})
|
|
@@ -53,6 +53,12 @@ export interface SingleSelectSection<T extends string | number> {
|
|
|
53
53
|
options: MenuOption<T>[]
|
|
54
54
|
value: T
|
|
55
55
|
onChange: (value: T) => void
|
|
56
|
+
// Another seat's currently-selected value in this section, if any — stays in `options` (so the
|
|
57
|
+
// trigger gauge below keeps indexing into the one list every seat shares, instead of each seat's
|
|
58
|
+
// gauge landing wherever its own filtered-down list happens to put the remaining values) but
|
|
59
|
+
// renders disabled and dimmed, unselectable. Same "shown, not removed" treatment as
|
|
60
|
+
// InlineColorPicker's own takenValue, for the same reason.
|
|
61
|
+
takenValue?: T
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
// Any number of these can be true at once — checkbox-style. Tapping a row toggles just that one and
|
|
@@ -250,12 +256,13 @@ function SingleSection({
|
|
|
250
256
|
<>
|
|
251
257
|
{section.options.map((option) => {
|
|
252
258
|
const selected = option.value === section.value
|
|
259
|
+
const taken = !selected && option.value === section.takenValue
|
|
253
260
|
const rowColor = selected ? onAccentColor : mutedColor
|
|
254
|
-
const itemStyle = [styles.item, selected && { backgroundColor: accentColor }]
|
|
261
|
+
const itemStyle = [styles.item, selected && { backgroundColor: accentColor }, taken && styles.itemTaken]
|
|
255
262
|
const labelStyle: TextStyle = { fontFamily: labelFontFamily, color: rowColor, fontWeight: selected ? 'bold' : 'normal' }
|
|
256
263
|
const descriptionStyle: TextStyle = { fontFamily: labelFontFamily, color: rowColor }
|
|
257
264
|
return (
|
|
258
|
-
<TouchableRipple key={option.value} onPress={() => onSelect(option.value)} style={itemStyle}>
|
|
265
|
+
<TouchableRipple key={option.value} disabled={taken} onPress={() => onSelect(option.value)} style={itemStyle}>
|
|
259
266
|
<View style={styles.itemRow}>
|
|
260
267
|
{option.icon && <Icon source={option.icon} size={MENU_ITEM_ICON_SIZE} color={rowColor} />}
|
|
261
268
|
<View style={styles.itemLabelColumn}>
|
|
@@ -367,6 +374,11 @@ const styles = StyleSheet.create({
|
|
|
367
374
|
flexDirection: 'row',
|
|
368
375
|
gap: 8
|
|
369
376
|
},
|
|
377
|
+
// Same opacity as ProfilePicker's rowDisabled / InlineColorPicker's swatchTaken — the fleet's
|
|
378
|
+
// established "shown, not removed" treatment for another seat's own current pick.
|
|
379
|
+
itemTaken: {
|
|
380
|
+
opacity: 0.35
|
|
381
|
+
},
|
|
370
382
|
menu: {
|
|
371
383
|
borderRadius: 12,
|
|
372
384
|
borderWidth: MENU_BORDER_WIDTH,
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
export { AchievementRow, LOCKED_BADGE_COLOR } from './AchievementRow'
|
|
2
2
|
export { BaseSettingsDialog, type BaseSettingsDialogProps } from './BaseSettingsDialog'
|
|
3
3
|
export { BaseStatsScreen } from './BaseStatsScreen'
|
|
4
|
+
export { ConfirmDialog, type ConfirmDialogProps } from './ConfirmDialog'
|
|
5
|
+
export { ContentGutter } from './ContentGutter'
|
|
6
|
+
export { ControlSchemePicker, type ControlSchemePickerProps } from './ControlSchemePicker'
|
|
4
7
|
export { CornerActionButtons } from './CornerActionButtons'
|
|
8
|
+
export { CornerStatusBadges, type CornerStatusBadgeSeat, type CornerStatusBadgesProps, type OrientationMode } from './CornerStatusBadges'
|
|
5
9
|
export { MONO_FONT } from './fonts'
|
|
6
10
|
export { InlineColorPicker } from './InlineColorPicker'
|
|
7
11
|
export { getLabeledDropdownContentHeight, LABELED_DROPDOWN_POPOVER_WIDTH, LabeledDropdown, type LabeledDropdownOption } from './LabeledDropdown'
|