rn-backstage 1.4.2 → 1.4.3

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.
@@ -1,207 +0,0 @@
1
- import React, { useMemo, useState } from 'react'
2
- import { Platform, ScrollView, StyleSheet, Switch, Text, TextInput, View } from 'react-native'
3
- import { MonospaceFont } from '../constants'
4
- import { useBackstageTheme } from '../ThemeContext'
5
- import type { FeatureFlag, BackstageTheme } from '../types'
6
-
7
- // iOS 26+ uses the Liquid Glass design system which conflicts with custom
8
- // ios_backgroundColor / thumbColor on Switch — let the system style it instead.
9
- const isLiquidGlass =
10
- Platform.OS === 'ios' && parseInt(String(Platform.Version), 10) >= 26
11
-
12
- // ─── Types ───────────────────────────────────────────────────────────────────
13
-
14
- interface FlagsTabProps {
15
- flags: FeatureFlag[]
16
- onToggle?: (key: string, value: boolean) => void
17
- }
18
-
19
- // ─── Component ───────────────────────────────────────────────────────────────
20
-
21
- export const FlagsTab: React.FC<FlagsTabProps> = ({ flags, onToggle }) => {
22
- const theme = useBackstageTheme()
23
- const s = useMemo(() => createStyles(theme), [theme])
24
- const [searchText, setSearchText] = useState('')
25
-
26
- const filteredFlags = useMemo(() => {
27
- if (!searchText.trim()) return flags
28
- const query = searchText.toLowerCase()
29
- return flags.filter(
30
- f =>
31
- f.label.toLowerCase().includes(query) ||
32
- f.key.toLowerCase().includes(query) ||
33
- (f.description && f.description.toLowerCase().includes(query)),
34
- )
35
- }, [flags, searchText])
36
-
37
- const enabledCount = flags.filter(f => f.value).length
38
-
39
- return (
40
- <View style={s.container}>
41
- <View style={s.searchContainer}>
42
- <View style={s.searchInputWrapper}>
43
- <Text style={s.searchIcon}>⌕</Text>
44
- <TextInput
45
- style={s.searchInput}
46
- placeholder="Filter flags..."
47
- placeholderTextColor={theme.textMuted}
48
- value={searchText}
49
- onChangeText={setSearchText}
50
- autoCapitalize="none"
51
- autoCorrect={false}
52
- clearButtonMode="while-editing"
53
- returnKeyType="search"
54
- />
55
- </View>
56
- </View>
57
-
58
- <View style={s.statsBar}>
59
- <Text style={s.statText}>
60
- {filteredFlags.length === flags.length
61
- ? `${flags.length} flag${flags.length !== 1 ? 's' : ''}`
62
- : `${filteredFlags.length} of ${flags.length}`}
63
- </Text>
64
- <Text style={s.statText}>
65
- <Text style={s.statEnabled}>{enabledCount} on</Text>
66
- {' · '}
67
- <Text style={s.statDisabled}>{flags.length - enabledCount} off</Text>
68
- </Text>
69
- </View>
70
-
71
- <ScrollView
72
- style={s.list}
73
- contentContainerStyle={filteredFlags.length === 0 ? s.emptyContainer : s.listContent}
74
- showsVerticalScrollIndicator={false}
75
- >
76
- {filteredFlags.length === 0 ? (
77
- <View style={s.emptyState}>
78
- <Text style={s.emptyIcon}>🎚</Text>
79
- <Text style={s.emptyTitle}>No flags match</Text>
80
- <Text style={s.emptySubtitle}>Try adjusting your search</Text>
81
- </View>
82
- ) : (
83
- filteredFlags.map((flag, index) => (
84
- <View key={flag.key}>
85
- <View style={s.flagRow}>
86
- <View style={s.flagInfo}>
87
- <View style={s.flagHeader}>
88
- <Text style={s.flagLabel} numberOfLines={2}>{flag.label}</Text>
89
- </View>
90
- <Text style={[s.flagKey, flag.value && s.flagKeyActive]}>{flag.key}</Text>
91
- {flag.description && <Text style={s.flagDescription}>{flag.description}</Text>}
92
- </View>
93
- <Switch
94
- testID={`backstage.flag.${flag.key}`}
95
- value={flag.value}
96
- onValueChange={(newValue: boolean) => onToggle?.(flag.key, newValue)}
97
- trackColor={{ false: theme.border, true: theme.accent }}
98
- thumbColor={isLiquidGlass ? undefined : (flag.value ? '#FFFFFF' : theme.textMuted)}
99
- ios_backgroundColor={isLiquidGlass ? undefined : theme.border}
100
- style={s.switch}
101
- />
102
- </View>
103
- {index < filteredFlags.length - 1 && <View style={s.divider} />}
104
- </View>
105
- ))
106
- )}
107
- </ScrollView>
108
- </View>
109
- )
110
- }
111
-
112
- // ─── Styles ──────────────────────────────────────────────────────────────────
113
-
114
- const createStyles = (t: BackstageTheme) =>
115
- StyleSheet.create({
116
- container: { flex: 1 },
117
- searchContainer: {
118
- paddingHorizontal: 14,
119
- paddingVertical: 10,
120
- borderBottomWidth: 1,
121
- borderBottomColor: t.border,
122
- },
123
- searchInputWrapper: {
124
- flexDirection: 'row',
125
- alignItems: 'center',
126
- backgroundColor: t.surfaceElevated,
127
- borderRadius: 10,
128
- borderWidth: 1,
129
- borderColor: t.border,
130
- paddingHorizontal: 12,
131
- height: 38,
132
- },
133
- searchIcon: { fontSize: 16, color: t.textMuted, marginRight: 8 },
134
- searchInput: {
135
- flex: 1,
136
- fontFamily: MonospaceFont,
137
- fontSize: 13,
138
- color: t.text,
139
- padding: 0,
140
- },
141
- statsBar: {
142
- flexDirection: 'row',
143
- justifyContent: 'space-between',
144
- alignItems: 'center',
145
- paddingHorizontal: 16,
146
- paddingVertical: 6,
147
- backgroundColor: t.surface,
148
- },
149
- statText: { fontFamily: MonospaceFont, fontSize: 11, color: t.textMuted },
150
- statEnabled: { color: t.success, fontWeight: '700' },
151
- statDisabled: { color: t.textMuted },
152
- list: { flex: 1 },
153
- listContent: { paddingHorizontal: 16, paddingVertical: 8 },
154
- emptyContainer: { flex: 1, justifyContent: 'center' },
155
- emptyState: { alignItems: 'center', justifyContent: 'center', padding: 40 },
156
- emptyIcon: { fontSize: 48, marginBottom: 16, opacity: 0.5 },
157
- emptyTitle: {
158
- fontFamily: MonospaceFont,
159
- fontSize: 16,
160
- fontWeight: '700',
161
- color: t.textSecondary,
162
- marginBottom: 8,
163
- },
164
- emptySubtitle: {
165
- fontFamily: MonospaceFont,
166
- fontSize: 13,
167
- color: t.textMuted,
168
- textAlign: 'center',
169
- },
170
- flagRow: {
171
- flexDirection: 'row',
172
- justifyContent: 'space-between',
173
- alignItems: 'center',
174
- paddingVertical: 14,
175
- paddingHorizontal: 4,
176
- },
177
- flagInfo: { flex: 1, marginRight: 16 },
178
- flagHeader: { flexDirection: 'row', alignItems: 'center' },
179
- flagLabel: {
180
- fontFamily: MonospaceFont,
181
- fontSize: 14,
182
- color: t.text,
183
- fontWeight: '600',
184
- },
185
- flagKey: {
186
- fontFamily: MonospaceFont,
187
- fontSize: 10,
188
- color: t.textMuted,
189
- backgroundColor: t.surfaceElevated,
190
- borderRadius: 4,
191
- paddingHorizontal: 6,
192
- paddingVertical: 1,
193
- overflow: 'hidden',
194
- alignSelf: 'flex-start',
195
- marginTop: 4,
196
- },
197
- flagKeyActive: { color: t.accent, backgroundColor: t.accentDim },
198
- flagDescription: {
199
- fontFamily: MonospaceFont,
200
- fontSize: 12,
201
- color: t.textMuted,
202
- marginTop: 4,
203
- lineHeight: 18,
204
- },
205
- divider: { height: 1, backgroundColor: t.border, marginHorizontal: 4 },
206
- switch: { flexShrink: 0 },
207
- })
@@ -1,247 +0,0 @@
1
- import React, { useCallback, useRef, useState } from 'react'
2
- import {
3
- Animated,
4
- Dimensions,
5
- LayoutChangeEvent,
6
- PanResponder,
7
- SafeAreaView,
8
- StyleSheet,
9
- Text,
10
- View,
11
- } from 'react-native'
12
- import { Metrics, MonospaceFont, TestIDs } from '../constants'
13
- import { useBackstageTheme } from '../ThemeContext'
14
- import type { BackstageStyleOverrides } from '../types'
15
-
16
- // ─── Types ───────────────────────────────────────────────────────────────────
17
-
18
- interface FloatingPillProps {
19
- text: string
20
- hasError: boolean
21
- onPress: () => void
22
- initialX?: number
23
- initialY?: number
24
- pillWidth?: number
25
- pillHeight?: number
26
- styles?: BackstageStyleOverrides
27
- }
28
-
29
- // ─── Helpers ─────────────────────────────────────────────────────────────────
30
-
31
- const PILL_WIDTH = Metrics.pillWidth
32
- const PILL_HEIGHT = Metrics.pillHeight
33
- const DRAG_THRESHOLD = 5
34
- const INSET_PADDING = 8 // extra padding from safe area edges
35
-
36
- interface SafeAreaInsets {
37
- top: number
38
- bottom: number
39
- left: number
40
- right: number
41
- }
42
-
43
- function clampPosition(
44
- x: number,
45
- y: number,
46
- insets: SafeAreaInsets,
47
- pillW: number,
48
- pillH: number,
49
- ): { x: number; y: number } {
50
- const { width, height } = Dimensions.get('window')
51
- return {
52
- x: Math.max(
53
- insets.left + INSET_PADDING,
54
- Math.min(x, width - pillW - insets.right - INSET_PADDING),
55
- ),
56
- y: Math.max(
57
- insets.top + INSET_PADDING,
58
- Math.min(y, height - pillH - insets.bottom - INSET_PADDING),
59
- ),
60
- }
61
- }
62
-
63
- // ─── Component ───────────────────────────────────────────────────────────────
64
-
65
- export const FloatingPill: React.FC<FloatingPillProps> = ({
66
- text,
67
- hasError,
68
- onPress,
69
- initialX,
70
- initialY,
71
- pillWidth: propWidth,
72
- pillHeight: propHeight,
73
- styles: propStyles,
74
- }) => {
75
- const pillW = propWidth ?? PILL_WIDTH
76
- const pillH = propHeight ?? PILL_HEIGHT
77
-
78
- const { width: screenW, height: screenH } = Dimensions.get('window')
79
- const defaultX = initialX ?? screenW - pillW - 16
80
- const defaultY = initialY ?? screenH - pillH - 120
81
-
82
- const pan = useRef(new Animated.ValueXY({ x: defaultX, y: defaultY })).current
83
- const lastPosition = useRef({ x: defaultX, y: defaultY })
84
- const isDragging = useRef(false)
85
- const dragDistance = useRef(0)
86
-
87
- // Safe area insets measured from invisible SafeAreaView
88
- const [insets, setInsets] = useState<SafeAreaInsets>({
89
- top: 0,
90
- bottom: 0,
91
- left: 0,
92
- right: 0,
93
- })
94
-
95
- const handleSafeAreaLayout = useCallback(
96
- (event: LayoutChangeEvent) => {
97
- const { x, y, width, height } = event.nativeEvent.layout
98
- const screen = Dimensions.get('window')
99
- const newInsets: SafeAreaInsets = {
100
- top: y,
101
- left: x,
102
- bottom: screen.height - y - height,
103
- right: screen.width - x - width,
104
- }
105
- setInsets(newInsets)
106
-
107
- // Re-clamp current position to new safe area
108
- const clamped = clampPosition(lastPosition.current.x, lastPosition.current.y, newInsets, pillW, pillH)
109
- if (clamped.x !== lastPosition.current.x || clamped.y !== lastPosition.current.y) {
110
- lastPosition.current = clamped
111
- pan.setValue(clamped)
112
- }
113
- },
114
- [pan],
115
- )
116
-
117
- const insetsRef = useRef(insets)
118
- insetsRef.current = insets
119
-
120
- const panResponder = useRef(
121
- PanResponder.create({
122
- onStartShouldSetPanResponder: () => true,
123
- onMoveShouldSetPanResponder: (_, gestureState) => {
124
- return (
125
- Math.abs(gestureState.dx) > DRAG_THRESHOLD || Math.abs(gestureState.dy) > DRAG_THRESHOLD
126
- )
127
- },
128
- onPanResponderGrant: () => {
129
- isDragging.current = false
130
- dragDistance.current = 0
131
- pan.setOffset({
132
- x: lastPosition.current.x,
133
- y: lastPosition.current.y,
134
- })
135
- pan.setValue({ x: 0, y: 0 })
136
- },
137
- onPanResponderMove: (_, gestureState) => {
138
- dragDistance.current = Math.sqrt(
139
- gestureState.dx * gestureState.dx + gestureState.dy * gestureState.dy,
140
- )
141
- if (dragDistance.current > DRAG_THRESHOLD) {
142
- isDragging.current = true
143
- }
144
-
145
- // Allow free dragging — bounce back happens on release
146
- Animated.event([null, { dx: pan.x, dy: pan.y }], {
147
- useNativeDriver: false,
148
- })(_, gestureState)
149
- },
150
- onPanResponderRelease: (_, gestureState) => {
151
- pan.flattenOffset()
152
-
153
- const newPos = clampPosition(
154
- lastPosition.current.x + gestureState.dx,
155
- lastPosition.current.y + gestureState.dy,
156
- insetsRef.current,
157
- pillW,
158
- pillH,
159
- )
160
-
161
- lastPosition.current = newPos
162
-
163
- Animated.spring(pan, {
164
- toValue: newPos,
165
- useNativeDriver: false,
166
- friction: 7,
167
- tension: 40,
168
- }).start()
169
-
170
- // Only trigger tap if drag distance was minimal
171
- if (!isDragging.current) {
172
- onPress()
173
- }
174
- },
175
- }),
176
- ).current
177
-
178
- const theme = useBackstageTheme()
179
-
180
- const backgroundColor = hasError ? theme.error : theme.accent
181
- const shadowColor = hasError ? theme.error : theme.accent
182
-
183
- return (
184
- <>
185
- {/* Invisible SafeAreaView to measure insets */}
186
- <SafeAreaView style={componentStyles.measurer} pointerEvents="none">
187
- <View style={componentStyles.measurerInner} onLayout={handleSafeAreaLayout} />
188
- </SafeAreaView>
189
-
190
- <Animated.View
191
- testID={TestIDs.floatingPill}
192
- {...panResponder.panHandlers}
193
- style={[
194
- componentStyles.pill,
195
- {
196
- minWidth: pillW,
197
- height: pillH,
198
- borderRadius: pillH / 2,
199
- backgroundColor,
200
- transform: pan.getTranslateTransform(),
201
- shadowColor,
202
- },
203
- propStyles?.pillStyle,
204
- ]}
205
- >
206
- <Text
207
- testID={TestIDs.floatingPillText}
208
- style={[componentStyles.pillText, propStyles?.pillTextStyle]}
209
- numberOfLines={1}
210
- >
211
- {text}
212
- </Text>
213
- </Animated.View>
214
- </>
215
- )
216
- }
217
-
218
- // ─── Styles ──────────────────────────────────────────────────────────────────
219
-
220
- const componentStyles = StyleSheet.create({
221
- pill: {
222
- position: 'absolute',
223
- paddingHorizontal: 14,
224
- alignItems: 'center',
225
- justifyContent: 'center',
226
- zIndex: 99999,
227
- elevation: 10,
228
- shadowOffset: { width: 0, height: 4 },
229
- shadowOpacity: 0.4,
230
- shadowRadius: 8,
231
- },
232
- pillText: {
233
- fontFamily: MonospaceFont,
234
- fontSize: 12,
235
- fontWeight: '700',
236
- color: '#FFFFFF',
237
- letterSpacing: 0.5,
238
- },
239
- measurer: {
240
- ...StyleSheet.absoluteFillObject,
241
- zIndex: -1,
242
- opacity: 0,
243
- },
244
- measurerInner: {
245
- flex: 1,
246
- },
247
- })
@@ -1,231 +0,0 @@
1
- import React, { useMemo } from 'react'
2
- import { Platform, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'
3
- import { MonospaceFont, TestIDs } from '../constants'
4
- import { useBackstageTheme } from '../ThemeContext'
5
- import { JsonTreeView } from './JsonTreeView'
6
- import type { AppInfoItem, BackstageStyleOverrides, BackstageTheme, QuickAction } from '../types'
7
-
8
- // ─── Types ───────────────────────────────────────────────────────────────────
9
-
10
- interface InfoTabProps {
11
- appVersion?: string
12
- buildNumber?: string
13
- bundleId?: string
14
- deviceInfo?: AppInfoItem[]
15
- state?: Record<string, unknown>
16
- quickActions?: QuickAction[]
17
- jsonMaxDepth?: number
18
- onClosePanel?: () => void
19
- styles?: BackstageStyleOverrides
20
- children?: React.ReactNode
21
- }
22
-
23
- // ─── Main Component ──────────────────────────────────────────────────────────
24
-
25
- export const InfoTab: React.FC<InfoTabProps> = ({
26
- appVersion,
27
- buildNumber,
28
- bundleId,
29
- deviceInfo = [],
30
- state,
31
- quickActions = [],
32
- jsonMaxDepth,
33
- onClosePanel,
34
- styles: propStyles,
35
- children,
36
- }) => {
37
- const theme = useBackstageTheme()
38
- const s = useMemo(() => createStyles(theme), [theme])
39
-
40
- // Built-in device info from Platform API
41
- const builtInInfo: AppInfoItem[] = [
42
- { label: 'Platform', value: Platform.OS.toUpperCase() },
43
- { label: 'OS Version', value: String(Platform.Version) },
44
- ]
45
-
46
- if (appVersion) {
47
- builtInInfo.push({ label: 'App Version', value: appVersion })
48
- }
49
- if (buildNumber) {
50
- builtInInfo.push({ label: 'Build Number', value: buildNumber })
51
- }
52
- if (bundleId) {
53
- builtInInfo.push({ label: 'Bundle ID', value: bundleId })
54
- }
55
-
56
- const allInfo = [...builtInInfo, ...deviceInfo]
57
-
58
- return (
59
- <ScrollView
60
- testID={TestIDs.infoTab.container}
61
- style={s.container}
62
- contentContainerStyle={s.scrollContent}
63
- showsVerticalScrollIndicator={false}
64
- >
65
- {/* ── Device Info Section ─────────────────────────────────── */}
66
- <View testID={TestIDs.infoTab.deviceInfo} style={s.section}>
67
- <Text style={[s.sectionTitle, propStyles?.sectionTitleStyle]}>DEVICE INFO</Text>
68
- <View style={s.card}>
69
- {allInfo.map((item, index) => (
70
- <React.Fragment key={`info_${index}`}>
71
- <View style={s.infoRow}>
72
- <Text style={[s.infoLabel, propStyles?.infoLabelStyle]} numberOfLines={1}>
73
- {item.label}
74
- </Text>
75
- <Text style={[s.infoValue, propStyles?.infoValueStyle]} selectable>
76
- {item.value}
77
- </Text>
78
- </View>
79
- {index < allInfo.length - 1 && <View style={s.divider} />}
80
- </React.Fragment>
81
- ))}
82
- </View>
83
- </View>
84
-
85
- {/* ── State Tree Section ──────────────────────────────────── */}
86
- {state && Object.keys(state).length > 0 && (
87
- <View testID={TestIDs.infoTab.stateTree} style={s.section}>
88
- <Text style={[s.sectionTitle, propStyles?.sectionTitleStyle]}>STATE TREE</Text>
89
- <View style={s.card}>
90
- <JsonTreeView data={state} hideRoot maxDepth={jsonMaxDepth} />
91
- </View>
92
- </View>
93
- )}
94
-
95
- {/* ── Quick Actions Section ─────────────────────────────────── */}
96
- {quickActions.length > 0 && (
97
- <View testID={TestIDs.infoTab.quickActions} style={s.section}>
98
- <Text style={[s.sectionTitle, propStyles?.sectionTitleStyle]}>QUICK ACTIONS</Text>
99
- <View style={s.actionsGrid}>
100
- {quickActions.map((action, index) => {
101
- const handlePress = () => {
102
- action.onPress()
103
- if (action.closeOnPress && onClosePanel) {
104
- onClosePanel()
105
- }
106
- }
107
- return (
108
- <TouchableOpacity
109
- key={`action_${index}`}
110
- testID={action.testID || TestIDs.infoTab.actionButton(index)}
111
- style={[
112
- s.actionButton,
113
- action.destructive && s.actionButtonDestructive,
114
- propStyles?.actionButtonStyle,
115
- ]}
116
- onPress={handlePress}
117
- activeOpacity={0.7}
118
- >
119
- {action.icon && <Text style={s.actionIcon}>{action.icon}</Text>}
120
- <Text
121
- style={[
122
- s.actionButtonTitle,
123
- action.destructive && s.actionButtonTitleDestructive,
124
- propStyles?.actionButtonTitleStyle,
125
- ]}
126
- >
127
- {action.title}
128
- </Text>
129
- </TouchableOpacity>
130
- )
131
- })}
132
- </View>
133
- </View>
134
- )}
135
-
136
- {/* ── Custom Children ─────────────────────────────────────── */}
137
- {children && <View style={s.section}>{children}</View>}
138
- </ScrollView>
139
- )
140
- }
141
-
142
- // ─── Styles ──────────────────────────────────────────────────────────────────
143
-
144
- const createStyles = (t: BackstageTheme) =>
145
- StyleSheet.create({
146
- container: {
147
- flex: 1,
148
- },
149
- scrollContent: {
150
- padding: 16,
151
- paddingBottom: 32,
152
- },
153
- section: {
154
- marginBottom: 20,
155
- },
156
- sectionTitle: {
157
- fontFamily: MonospaceFont,
158
- fontSize: 11,
159
- fontWeight: '700',
160
- color: t.textMuted,
161
- letterSpacing: 1.5,
162
- marginBottom: 8,
163
- paddingLeft: 4,
164
- },
165
- card: {
166
- backgroundColor: t.surfaceElevated,
167
- borderRadius: 12,
168
- borderWidth: 1,
169
- borderColor: t.border,
170
- padding: 12,
171
- overflow: 'hidden',
172
- },
173
- infoRow: {
174
- flexDirection: 'row',
175
- justifyContent: 'space-between',
176
- alignItems: 'center',
177
- paddingVertical: 8,
178
- paddingHorizontal: 4,
179
- },
180
- infoLabel: {
181
- fontFamily: MonospaceFont,
182
- fontSize: 13,
183
- color: t.textSecondary,
184
- },
185
- infoValue: {
186
- fontFamily: MonospaceFont,
187
- fontSize: 13,
188
- color: t.text,
189
- fontWeight: '600',
190
- textAlign: 'right',
191
- flex: 1,
192
- marginLeft: 12,
193
- },
194
- divider: {
195
- height: 1,
196
- backgroundColor: t.border,
197
- marginHorizontal: 4,
198
- },
199
- actionsGrid: {
200
- flexDirection: 'row',
201
- flexWrap: 'wrap',
202
- gap: 10,
203
- },
204
- actionButton: {
205
- backgroundColor: t.accentDim,
206
- borderRadius: 10,
207
- borderWidth: 1,
208
- borderColor: t.accent,
209
- paddingVertical: 10,
210
- paddingHorizontal: 18,
211
- flexDirection: 'row',
212
- alignItems: 'center',
213
- },
214
- actionButtonDestructive: {
215
- backgroundColor: t.errorDim,
216
- borderColor: t.error,
217
- },
218
- actionIcon: {
219
- fontSize: 14,
220
- marginRight: 6,
221
- },
222
- actionButtonTitle: {
223
- fontFamily: MonospaceFont,
224
- fontSize: 13,
225
- fontWeight: '600',
226
- color: t.accent,
227
- },
228
- actionButtonTitleDestructive: {
229
- color: t.error,
230
- },
231
- })