@tastic/hud 0.1.5 → 0.2.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 +19 -1
- package/dist/index.d.ts +19 -1
- package/dist/index.js +309 -105
- package/dist/index.mjs +274 -71
- package/package.json +13 -11
- package/src/BaseSettingsDialog.tsx +371 -0
- package/src/index.ts +1 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { AutoAppearancePicker, Dialog, useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
|
+
import { Button, SoundContext, TouchableRipple, useHapticSettings, useSoundSettings, useVibration } from '@rific/feedback-press'
|
|
3
|
+
import { useUpdater } from '@rific/updater'
|
|
4
|
+
import { useIsTouchPrimaryDevice } from '@tastic/core'
|
|
5
|
+
import { ReactNode, useContext, useState } from 'react'
|
|
6
|
+
import { Platform, ScrollView, StyleSheet, View } from 'react-native'
|
|
7
|
+
import { Icon, Portal, Text } from 'react-native-paper'
|
|
8
|
+
|
|
9
|
+
interface SettingIconProps {
|
|
10
|
+
source: string
|
|
11
|
+
color: string
|
|
12
|
+
containerColor: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Small colored badge per row (icon tinted on its own MD3 container color) — echoes the
|
|
16
|
+
// primary/secondary/tertiary triad that's already each consuming app's own identity, so the
|
|
17
|
+
// settings list picks up that same palette instead of introducing new colors of its own.
|
|
18
|
+
function SettingIcon({ source, color, containerColor }: SettingIconProps) {
|
|
19
|
+
return (
|
|
20
|
+
<View style={[styles.iconBadge, { backgroundColor: containerColor }]}>
|
|
21
|
+
<Icon source={source} size={18} color={color} />
|
|
22
|
+
</View>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface BaseSettingsDialogProps {
|
|
27
|
+
visible: boolean
|
|
28
|
+
onDismiss: () => void
|
|
29
|
+
// Live physical-hold rotation (see @tastic/split-screen's getViewRotation) — this is a centered,
|
|
30
|
+
// app-wide modal with no per-player zone to match (unlike an in-game round-over dialog), so it
|
|
31
|
+
// just rotates its own content in place; defaults to 0 for a caller that doesn't track one (a
|
|
32
|
+
// game with no face-to-face two-player mode has nothing to stay consistent with anyway).
|
|
33
|
+
rotation?: number
|
|
34
|
+
// Each app's own OTA version (e.g. release.otaVersion) — not something this package can read
|
|
35
|
+
// itself, since every consuming app tracks its own release.ts independently. Accepts a number too
|
|
36
|
+
// since that's what release.otaVersion actually is in most apps (a bare incrementing counter, not
|
|
37
|
+
// a formatted string) — this just interpolates it into the VERSION label same as the original
|
|
38
|
+
// inline JSX did.
|
|
39
|
+
version: string | number
|
|
40
|
+
// Optional, and always passed as a pair — omit both entirely for a game that's portrait-only (or
|
|
41
|
+
// otherwise has no orientation-based reflow at all), where there's nothing to lock against.
|
|
42
|
+
lockOrientation?: boolean
|
|
43
|
+
onLockOrientationChange?: (value: boolean) => void
|
|
44
|
+
// Optional, and always passed as a pair — omit both entirely for a game with no full-screen edge-
|
|
45
|
+
// anchored swipe controls (nothing to guard). iOS-only regardless of whether the pair is passed:
|
|
46
|
+
// @tastic/edge-guard's own swizzle is a no-op on every other platform.
|
|
47
|
+
deferBottomEdgeGestures?: boolean
|
|
48
|
+
onDeferBottomEdgeGestures?: (value: boolean) => void
|
|
49
|
+
// Explicit opt-outs for the rows that pull their own state straight from @rific/feedback-press,
|
|
50
|
+
// @rific/auto-paper, and @rific/updater's own shared contexts/hooks rather than from anything the
|
|
51
|
+
// caller passes in — unlike Lock Orientation/Edge Guard above, there's no "value" to omit for
|
|
52
|
+
// these, so hiding one takes its own explicit flag instead. All default to shown, matching every
|
|
53
|
+
// app's existing behavior before this prop existed.
|
|
54
|
+
hideSound?: boolean
|
|
55
|
+
// Haptics is already native-only (see the row's own Platform.OS check) — this hides it even
|
|
56
|
+
// there, for a game that's deliberately silent on vibration.
|
|
57
|
+
hideHaptics?: boolean
|
|
58
|
+
hideAppearance?: boolean
|
|
59
|
+
// Check for Updates is already native-only (a web build has no OTA update concept at all) — this
|
|
60
|
+
// hides it even there, for a native app that isn't wired up for EAS/OTA updates at all.
|
|
61
|
+
hideUpdateCheck?: boolean
|
|
62
|
+
// Surfaces a failed update check (e.g. via @rific/toaster's error()) — omit for an app with no
|
|
63
|
+
// toast system wired up; the check itself still runs and still reports success/no-update through
|
|
64
|
+
// the ordinary info overlay either way. Meaningless (never called) when hideUpdateCheck is set.
|
|
65
|
+
onUpdateError?: (message: string) => void
|
|
66
|
+
// App-specific extra settings sections (board options, CPU difficulty, stats backup, ...),
|
|
67
|
+
// rendered between Appearance and Check for Updates — this component only owns the settings
|
|
68
|
+
// every game shares, never anything about how a particular game plays.
|
|
69
|
+
children?: ReactNode
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The settings shell every @tastic game shares — orientation lock, the optional edge-guard toggle,
|
|
73
|
+
// sound/haptics, appearance, and update checking — extracted after the same six rows were
|
|
74
|
+
// independently copied into LightCycles, AirHockey, BoxHockey, Pong, and Snake and then needed
|
|
75
|
+
// hand-editing in every one of them, twice, for what were meant to be identical fixes. `children`
|
|
76
|
+
// is the seam for whatever isn't shared: a game with its own board/difficulty settings (or a stats
|
|
77
|
+
// backup flow, or anything else genuinely specific to it) renders those below Appearance, not by
|
|
78
|
+
// forking this component.
|
|
79
|
+
export function BaseSettingsDialog({ visible, onDismiss, rotation = 0, version, lockOrientation, onLockOrientationChange, deferBottomEdgeGestures, onDeferBottomEdgeGestures, hideSound = false, hideHaptics = false, hideAppearance = false, hideUpdateCheck = false, onUpdateError, children }: BaseSettingsDialogProps) {
|
|
80
|
+
const { dark, colors } = useAutoPaperTheme()
|
|
81
|
+
// Also gates the Lock Orientation row below (alongside showLockOrientation itself) — true
|
|
82
|
+
// unconditionally on native (see the hook's own doc), so this only actually excludes a
|
|
83
|
+
// desktop/laptop browser, where a mouse-driven window has no physical orientation to lock
|
|
84
|
+
// against; a resize there would just make the setting silently stop reflowing the layout instead
|
|
85
|
+
// of actually locking anything.
|
|
86
|
+
const isTouchPrimary = useIsTouchPrimaryDevice()
|
|
87
|
+
const { settings: hapticSettings, set: setHapticSettings } = useHapticSettings()
|
|
88
|
+
const { settings: soundSettings, set: setSoundSettings } = useSoundSettings()
|
|
89
|
+
// Sound/Haptics toggle themselves: the ripple's automatic press feedback fires on onPressIn,
|
|
90
|
+
// before onPress applies the toggle, so it reflects the OLD enabled value — backwards from what
|
|
91
|
+
// a settings toggle should confirm (turning off would click/buzz, turning on would go silent).
|
|
92
|
+
// Both rows disable their own automatic channel below (soundDisabled/hapticDisabled) and fire it
|
|
93
|
+
// manually here instead, gated on the NEW value so it only plays when switching that channel on.
|
|
94
|
+
const sound = useContext(SoundContext)
|
|
95
|
+
const { forceShort: forceHapticFeedback } = useVibration()
|
|
96
|
+
// Covers check()'s three purely-informational cases (dev-mode disabled, web unsupported, no
|
|
97
|
+
// update found) via onInfo below — a retro card overlay instead of the native Alert.alert those
|
|
98
|
+
// cases fall back to by default.
|
|
99
|
+
const [infoMessage, setInfoMessage] = useState<{ title: string; message: string } | null>(null)
|
|
100
|
+
// autoCheck: false — every consuming app's own root layout already runs the background check via
|
|
101
|
+
// its own useUpdater() instance; a second instance with autoCheck's default (true) would set up a
|
|
102
|
+
// second AppState listener and double every foreground-resume update check. This instance only
|
|
103
|
+
// ever checks on an explicit tap of the button below.
|
|
104
|
+
const { check, checking, updateReady } = useUpdater({
|
|
105
|
+
autoCheck: false,
|
|
106
|
+
autoPrompt: false,
|
|
107
|
+
onError: onUpdateError,
|
|
108
|
+
onInfo: (title, message) => setInfoMessage({ title, message })
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const showLockOrientation = isTouchPrimary && lockOrientation !== undefined && onLockOrientationChange !== undefined
|
|
112
|
+
const showEdgeGuard = Platform.OS === 'ios' && deferBottomEdgeGestures !== undefined && onDeferBottomEdgeGestures !== undefined
|
|
113
|
+
const showHaptics = Platform.OS !== 'web' && !hideHaptics
|
|
114
|
+
const showSound = !hideSound
|
|
115
|
+
const showAppearance = !hideAppearance
|
|
116
|
+
const showUpdateCheck = Platform.OS !== 'web' && !hideUpdateCheck
|
|
117
|
+
|
|
118
|
+
// High-contrast retro look: literal black/white by appearance, not auto-paper's own (slightly
|
|
119
|
+
// tinted) background role.
|
|
120
|
+
const fg = dark ? '#FFFFFF' : '#000000'
|
|
121
|
+
const cardBg = dark ? '#111111' : '#F2F2F2'
|
|
122
|
+
const cardBorder = dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<>
|
|
126
|
+
<Dialog visible={visible} onDismiss={onDismiss} style={[styles.dialog, rotation % 360 !== 0 && { transform: [{ rotate: `${rotation}deg` }] }]}>
|
|
127
|
+
<Dialog.Title>Settings</Dialog.Title>
|
|
128
|
+
{/* react-native-paper's Dialog.ScrollArea has a fixed 24px marginBottom baked in (meant to
|
|
129
|
+
reserve room for a Dialog.Actions row below it) — overridden to 0 since this dialog has
|
|
130
|
+
no actions row, and that gap otherwise reads as unexplained empty footer space. */}
|
|
131
|
+
<Dialog.ScrollArea style={styles.scrollArea}>
|
|
132
|
+
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
|
133
|
+
{/* Grouped tightly together (styles.toggleGroup's small internal gap, not the 24px gap
|
|
134
|
+
between sections below) — same bare-row shape for all of them, no segmented control
|
|
135
|
+
and no section heading (each row's own label already says what it is). Haptics only
|
|
136
|
+
joins on native — there's nothing for it to control on web. */}
|
|
137
|
+
<View style={styles.toggleGroup}>
|
|
138
|
+
{/* Omitted entirely for a portrait-only game (no lockOrientation/onLockOrientationChange
|
|
139
|
+
passed at all — nothing to lock), and hidden (not just disabled) on a desktop/laptop
|
|
140
|
+
browser even when they are — see isTouchPrimary's own doc above. Still shown on
|
|
141
|
+
native and on a touch-capable mobile browser, where a rotated window really does
|
|
142
|
+
reflect a rotated device. */}
|
|
143
|
+
{showLockOrientation && (
|
|
144
|
+
<TouchableRipple onPress={() => onLockOrientationChange!(!lockOrientation)} style={styles.toggleButton} accessibilityLabel={`Lock orientation ${lockOrientation ? 'on' : 'off'}`}>
|
|
145
|
+
<View style={styles.toggleContent}>
|
|
146
|
+
<SettingIcon source={lockOrientation ? 'lock' : 'lock-open-variant-outline'} color={lockOrientation ? colors.secondary : colors.onSurfaceVariant} containerColor={lockOrientation ? colors.secondaryContainer : colors.surfaceVariant} />
|
|
147
|
+
<View style={styles.flexShrink}>
|
|
148
|
+
<Text variant='bodyLarge' style={{ color: colors.onSurface }}>
|
|
149
|
+
Lock Orientation
|
|
150
|
+
</Text>
|
|
151
|
+
<Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
|
|
152
|
+
Pins the current layout
|
|
153
|
+
</Text>
|
|
154
|
+
</View>
|
|
155
|
+
</View>
|
|
156
|
+
</TouchableRipple>
|
|
157
|
+
)}
|
|
158
|
+
|
|
159
|
+
{/* iOS only — mirrors into UserDefaults for @tastic/edge-guard's own config plugin
|
|
160
|
+
swizzle to read. Guards both edges — the top (Notification Center/Control Center)
|
|
161
|
+
and the bottom (home-indicator swipe, incidentally Reachability too), per that
|
|
162
|
+
package's own doc. Off by default: a deliberate opt-in for whoever's actually hit an
|
|
163
|
+
accidental-swipe-near-an-edge problem mid-game and come looking for a fix, not a
|
|
164
|
+
surprise every player gets by default. */}
|
|
165
|
+
{showEdgeGuard && (
|
|
166
|
+
<TouchableRipple onPress={() => onDeferBottomEdgeGestures!(!deferBottomEdgeGestures)} style={styles.toggleButton} accessibilityLabel={`Edge guard ${deferBottomEdgeGestures ? 'on' : 'off'}`}>
|
|
167
|
+
<View style={styles.toggleContent}>
|
|
168
|
+
<SettingIcon source={deferBottomEdgeGestures ? 'shield-check-outline' : 'shield-off-outline'} color={deferBottomEdgeGestures ? colors.secondary : colors.onSurfaceVariant} containerColor={deferBottomEdgeGestures ? colors.secondaryContainer : colors.surfaceVariant} />
|
|
169
|
+
<View style={styles.flexShrink}>
|
|
170
|
+
<Text variant='bodyLarge' style={{ color: colors.onSurface }}>
|
|
171
|
+
Edge Guard
|
|
172
|
+
</Text>
|
|
173
|
+
<Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
|
|
174
|
+
Requires a second swipe
|
|
175
|
+
</Text>
|
|
176
|
+
</View>
|
|
177
|
+
</View>
|
|
178
|
+
</TouchableRipple>
|
|
179
|
+
)}
|
|
180
|
+
|
|
181
|
+
{/* Side by side, not stacked — neither needs a full row's width (single-line label,
|
|
182
|
+
no description under it the way Lock Orientation has), so sharing one row reads
|
|
183
|
+
just as clearly and takes half the vertical space. toggleRow carries the same
|
|
184
|
+
edge-alignment negative margin toggleButton normally carries itself; the buttons
|
|
185
|
+
inside it use toggleButtonInRow (flex: 1) instead so the two don't double up on it.
|
|
186
|
+
The row itself only renders when at least one of the two will — an empty flex row
|
|
187
|
+
still eats its own gap/margin otherwise. */}
|
|
188
|
+
{(showSound || showHaptics) && (
|
|
189
|
+
<View style={styles.toggleRow}>
|
|
190
|
+
{showSound && (
|
|
191
|
+
<TouchableRipple
|
|
192
|
+
soundDisabled
|
|
193
|
+
onPress={() => {
|
|
194
|
+
const enabled = !soundSettings.enabled
|
|
195
|
+
setSoundSettings({ enabled })
|
|
196
|
+
if (enabled) sound.selection?.()
|
|
197
|
+
}}
|
|
198
|
+
style={styles.toggleButtonInRow}
|
|
199
|
+
accessibilityLabel={`Sound ${soundSettings.enabled ? 'on' : 'off'}`}
|
|
200
|
+
>
|
|
201
|
+
<View style={styles.toggleContent}>
|
|
202
|
+
<SettingIcon source={soundSettings.enabled ? 'volume-high' : 'volume-off'} color={soundSettings.enabled ? colors.tertiary : colors.onSurfaceVariant} containerColor={soundSettings.enabled ? colors.tertiaryContainer : colors.surfaceVariant} />
|
|
203
|
+
<Text variant='bodyLarge' style={{ color: colors.onSurface }}>
|
|
204
|
+
Sound
|
|
205
|
+
</Text>
|
|
206
|
+
</View>
|
|
207
|
+
</TouchableRipple>
|
|
208
|
+
)}
|
|
209
|
+
|
|
210
|
+
{/* Already native-only (see showHaptics's own definition) — hideHaptics can hide
|
|
211
|
+
it there too, for a game that's deliberately silent on vibration. */}
|
|
212
|
+
{showHaptics && (
|
|
213
|
+
<TouchableRipple
|
|
214
|
+
hapticDisabled
|
|
215
|
+
onPress={() => {
|
|
216
|
+
const vibrate = !hapticSettings.vibrate
|
|
217
|
+
setHapticSettings({ vibrate })
|
|
218
|
+
if (vibrate) forceHapticFeedback()
|
|
219
|
+
}}
|
|
220
|
+
style={styles.toggleButtonInRow}
|
|
221
|
+
accessibilityLabel={`Haptics ${hapticSettings.vibrate ? 'on' : 'off'}`}
|
|
222
|
+
>
|
|
223
|
+
<View style={styles.toggleContent}>
|
|
224
|
+
<SettingIcon source={hapticSettings.vibrate ? 'vibrate' : 'vibrate-off'} color={hapticSettings.vibrate ? colors.tertiary : colors.onSurfaceVariant} containerColor={hapticSettings.vibrate ? colors.tertiaryContainer : colors.surfaceVariant} />
|
|
225
|
+
<Text variant='bodyLarge' style={{ color: colors.onSurface }}>
|
|
226
|
+
Haptics
|
|
227
|
+
</Text>
|
|
228
|
+
</View>
|
|
229
|
+
</TouchableRipple>
|
|
230
|
+
)}
|
|
231
|
+
</View>
|
|
232
|
+
)}
|
|
233
|
+
</View>
|
|
234
|
+
|
|
235
|
+
{showAppearance && (
|
|
236
|
+
<View style={styles.section}>
|
|
237
|
+
<Text variant='labelMedium' style={[styles.sectionLabel, { color: colors.onSurfaceVariant }]}>
|
|
238
|
+
APPEARANCE
|
|
239
|
+
</Text>
|
|
240
|
+
<AutoAppearancePicker showLabels={false} />
|
|
241
|
+
</View>
|
|
242
|
+
)}
|
|
243
|
+
|
|
244
|
+
{children}
|
|
245
|
+
|
|
246
|
+
{/* Native only (a web build has no OTA update concept at all, desktop or mobile browser
|
|
247
|
+
alike) — hideUpdateCheck can hide it there too, for a native app that isn't wired up
|
|
248
|
+
for EAS/OTA updates at all. */}
|
|
249
|
+
{showUpdateCheck && (
|
|
250
|
+
<View style={styles.section}>
|
|
251
|
+
<Text variant='labelSmall' style={[styles.sectionLabel, { color: colors.onSurfaceVariant }]}>
|
|
252
|
+
VERSION {version}
|
|
253
|
+
{updateReady ? ' · UPDATE READY' : ''}
|
|
254
|
+
</Text>
|
|
255
|
+
<Button mode='outlined' onPress={check} loading={checking} disabled={checking}>
|
|
256
|
+
Check for Updates
|
|
257
|
+
</Button>
|
|
258
|
+
</View>
|
|
259
|
+
)}
|
|
260
|
+
</ScrollView>
|
|
261
|
+
</Dialog.ScrollArea>
|
|
262
|
+
</Dialog>
|
|
263
|
+
{infoMessage && (
|
|
264
|
+
<Portal>
|
|
265
|
+
<View style={styles.overlay}>
|
|
266
|
+
<View style={[styles.overlayCard, { backgroundColor: cardBg, borderColor: cardBorder }, rotation % 360 !== 0 && { transform: [{ rotate: `${rotation}deg` }] }]}>
|
|
267
|
+
<Icon source='information-outline' size={64} color={colors.secondary} />
|
|
268
|
+
<Text variant='headlineLarge' style={[styles.overlayTitle, { color: colors.secondary }]}>
|
|
269
|
+
{infoMessage.title}
|
|
270
|
+
</Text>
|
|
271
|
+
<Text variant='bodyLarge' style={[styles.overlayBody, { color: fg }]}>
|
|
272
|
+
{infoMessage.message}
|
|
273
|
+
</Text>
|
|
274
|
+
<Button mode='contained' onPress={() => setInfoMessage(null)} style={styles.overlayButton} buttonColor={colors.primary} textColor={colors.onPrimary}>
|
|
275
|
+
OK
|
|
276
|
+
</Button>
|
|
277
|
+
</View>
|
|
278
|
+
</View>
|
|
279
|
+
</Portal>
|
|
280
|
+
)}
|
|
281
|
+
</>
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const styles = StyleSheet.create({
|
|
286
|
+
content: {
|
|
287
|
+
gap: 24,
|
|
288
|
+
paddingVertical: 20
|
|
289
|
+
},
|
|
290
|
+
// Caps the card so it never grows past the screen — without this the dialog just keeps
|
|
291
|
+
// growing to fit its content and the overflow gets clipped by the screen edge, which is
|
|
292
|
+
// what happened in landscape where there's less height to work with. Dialog.ScrollArea +
|
|
293
|
+
// ScrollView below then take over and let the content scroll within that bound.
|
|
294
|
+
dialog: {
|
|
295
|
+
maxHeight: '90%'
|
|
296
|
+
},
|
|
297
|
+
flexShrink: {
|
|
298
|
+
flexShrink: 1
|
|
299
|
+
},
|
|
300
|
+
iconBadge: {
|
|
301
|
+
alignItems: 'center',
|
|
302
|
+
borderRadius: 10,
|
|
303
|
+
height: 36,
|
|
304
|
+
justifyContent: 'center',
|
|
305
|
+
width: 36
|
|
306
|
+
},
|
|
307
|
+
overlay: {
|
|
308
|
+
alignItems: 'center',
|
|
309
|
+
backgroundColor: 'rgba(0,0,0,0.72)',
|
|
310
|
+
bottom: 0,
|
|
311
|
+
justifyContent: 'center',
|
|
312
|
+
left: 0,
|
|
313
|
+
position: 'absolute',
|
|
314
|
+
right: 0,
|
|
315
|
+
top: 0
|
|
316
|
+
},
|
|
317
|
+
overlayBody: { textAlign: 'center' },
|
|
318
|
+
overlayButton: { width: 160 },
|
|
319
|
+
overlayCard: {
|
|
320
|
+
alignItems: 'center',
|
|
321
|
+
borderRadius: 20,
|
|
322
|
+
borderWidth: 1,
|
|
323
|
+
gap: 16,
|
|
324
|
+
maxWidth: 360,
|
|
325
|
+
padding: 32
|
|
326
|
+
},
|
|
327
|
+
overlayTitle: { fontWeight: 'bold' },
|
|
328
|
+
scrollArea: {
|
|
329
|
+
marginBottom: 0
|
|
330
|
+
},
|
|
331
|
+
section: {
|
|
332
|
+
gap: 12
|
|
333
|
+
},
|
|
334
|
+
sectionLabel: {
|
|
335
|
+
letterSpacing: 2
|
|
336
|
+
},
|
|
337
|
+
// Negative margin cancels the padding so the icon still lines up with APPEARANCE/SOUND &
|
|
338
|
+
// HAPTICS below, while the ripple/hover highlight itself gets room to breathe on both sides
|
|
339
|
+
// instead of a flush edge-to-edge slab. overflow: 'hidden' makes sure that highlight actually
|
|
340
|
+
// clips to borderRadius instead of drawing as a plain rectangle.
|
|
341
|
+
toggleButton: {
|
|
342
|
+
borderRadius: 12,
|
|
343
|
+
marginHorizontal: -12,
|
|
344
|
+
overflow: 'hidden',
|
|
345
|
+
paddingHorizontal: 12,
|
|
346
|
+
paddingVertical: 10
|
|
347
|
+
},
|
|
348
|
+
// Same shape as toggleButton but `flex: 1` instead of its own `marginHorizontal: -12` — two of
|
|
349
|
+
// these side by side in toggleRow would otherwise both pull inward and collide in the middle.
|
|
350
|
+
// toggleRow carries that edge-alignment margin once, for the row as a whole, instead.
|
|
351
|
+
toggleButtonInRow: {
|
|
352
|
+
borderRadius: 12,
|
|
353
|
+
flex: 1,
|
|
354
|
+
overflow: 'hidden',
|
|
355
|
+
paddingHorizontal: 12,
|
|
356
|
+
paddingVertical: 10
|
|
357
|
+
},
|
|
358
|
+
toggleContent: {
|
|
359
|
+
alignItems: 'center',
|
|
360
|
+
flexDirection: 'row',
|
|
361
|
+
gap: 12
|
|
362
|
+
},
|
|
363
|
+
toggleGroup: {
|
|
364
|
+
gap: 4
|
|
365
|
+
},
|
|
366
|
+
toggleRow: {
|
|
367
|
+
flexDirection: 'row',
|
|
368
|
+
gap: 4,
|
|
369
|
+
marginHorizontal: -12
|
|
370
|
+
}
|
|
371
|
+
})
|
package/src/index.ts
CHANGED