@xpufx/paseo-slash 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/LICENSE +21 -0
- package/README.md +59 -0
- package/client/commands.ts +122 -0
- package/client/console.tsx +484 -0
- package/client/vendor/paseo-plugin-helper/command-center.ts +43 -0
- package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +493 -0
- package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +250 -0
- package/client/vendor/paseo-plugin-helper/components/Badge.tsx +156 -0
- package/client/vendor/paseo-plugin-helper/components/Button.tsx +178 -0
- package/client/vendor/paseo-plugin-helper/components/Card.tsx +225 -0
- package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +196 -0
- package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +277 -0
- package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +172 -0
- package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +180 -0
- package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +200 -0
- package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +97 -0
- package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +70 -0
- package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +73 -0
- package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +446 -0
- package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +247 -0
- package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +117 -0
- package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +53 -0
- package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +118 -0
- package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/components/Select.tsx +215 -0
- package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +319 -0
- package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +150 -0
- package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +163 -0
- package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +157 -0
- package/client/vendor/paseo-plugin-helper/components/index.ts +25 -0
- package/client/vendor/paseo-plugin-helper/custom-pills.tsx +224 -0
- package/client/vendor/paseo-plugin-helper/forge-icon.tsx +79 -0
- package/client/vendor/paseo-plugin-helper/host.ts +277 -0
- package/client/vendor/paseo-plugin-helper/icon.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/index.ts +28 -0
- package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +49 -0
- package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +103 -0
- package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +65 -0
- package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +378 -0
- package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +49 -0
- package/client/vendor/paseo-plugin-helper/layout/Row.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/layout/index.ts +7 -0
- package/client/vendor/paseo-plugin-helper/panel.tsx +81 -0
- package/client/vendor/paseo-plugin-helper/pill.tsx +884 -0
- package/client/vendor/paseo-plugin-helper/query-refresh.ts +79 -0
- package/client/vendor/paseo-plugin-helper/query.ts +66 -0
- package/client/vendor/paseo-plugin-helper/settings-screen.tsx +372 -0
- package/client/vendor/paseo-plugin-helper/settings.ts +181 -0
- package/client/vendor/paseo-plugin-helper/shared-settings.ts +46 -0
- package/client/vendor/paseo-plugin-helper/snapshot.ts +68 -0
- package/client/vendor/paseo-plugin-helper/surface.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/theme/color-utils.ts +118 -0
- package/client/vendor/paseo-plugin-helper/theme/flair.ts +76 -0
- package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +121 -0
- package/client/vendor/paseo-plugin-helper/theme/index.ts +7 -0
- package/client/vendor/paseo-plugin-helper/theme/provider.tsx +214 -0
- package/client/vendor/paseo-plugin-helper/theme/responsive.ts +213 -0
- package/client/vendor/paseo-plugin-helper/theme/tokens.ts +161 -0
- package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +57 -0
- package/client/vendor/paseo-plugin-helper/utils/clipboard.ts +149 -0
- package/client/vendor/paseo-plugin-helper/utils/haptics.ts +34 -0
- package/docs/screenshots/slash-autocomplete.jpg +0 -0
- package/docs/screenshots/slash-console.jpg +0 -0
- package/package.json +27 -0
- package/paseo-plugin.json +4 -0
- package/server/orchestrate.ts +81 -0
- package/server/resources.ts +132 -0
- package/server/vendor/paseo-plugin-helper/agent.ts +85 -0
- package/server/vendor/paseo-plugin-helper/custom-pills.ts +344 -0
- package/server/vendor/paseo-plugin-helper/index.ts +18 -0
- package/server/vendor/paseo-plugin-helper/jsonc.ts +78 -0
- package/server/vendor/paseo-plugin-helper/logger.ts +210 -0
- package/server/vendor/paseo-plugin-helper/mcp-config.ts +367 -0
- package/server/vendor/paseo-plugin-helper/mcp-injection.ts +85 -0
- package/server/vendor/paseo-plugin-helper/network.ts +91 -0
- package/server/vendor/paseo-plugin-helper/plugins.ts +160 -0
- package/server/vendor/paseo-plugin-helper/process.ts +186 -0
- package/server/vendor/paseo-plugin-helper/redact.ts +86 -0
- package/server/vendor/paseo-plugin-helper/rpc-guard.ts +77 -0
- package/server/vendor/paseo-plugin-helper/settings.ts +97 -0
- package/server/vendor/paseo-plugin-helper/shared-settings.ts +243 -0
- package/server/vendor/paseo-plugin-helper/storage.ts +244 -0
- package/server/vendor/paseo-plugin-helper/system.ts +128 -0
- package/server/vendor/paseo-plugin-helper/task.ts +116 -0
- package/server/vendor/paseo-plugin-helper/version.ts +153 -0
- package/server/vendor/paseo-plugin-helper/workspace-beacon.ts +418 -0
- package/shared/resources.ts +289 -0
- package/shared/vendor/paseo-plugin-helper/README.md +11 -0
- package/shared/vendor/paseo-plugin-helper/async.ts +35 -0
- package/shared/vendor/paseo-plugin-helper/custom-pills.ts +169 -0
- package/shared/vendor/paseo-plugin-helper/forge.ts +110 -0
- package/shared/vendor/paseo-plugin-helper/formatters.ts +271 -0
- package/shared/vendor/paseo-plugin-helper/highlight.ts +184 -0
- package/shared/vendor/paseo-plugin-helper/index.ts +10 -0
- package/shared/vendor/paseo-plugin-helper/rpc.ts +72 -0
- package/shared/vendor/paseo-plugin-helper/settings.ts +138 -0
- package/shared/vendor/paseo-plugin-helper/suite-settings.ts +17 -0
- package/shared/vendor/paseo-plugin-helper/suppressed.ts +31 -0
- package/shared/vendor/paseo-plugin-helper/types.ts +36 -0
- package/shared/version.ts +2 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Pressable,
|
|
4
|
+
ScrollView as FallbackScrollView,
|
|
5
|
+
StyleSheet,
|
|
6
|
+
Text,
|
|
7
|
+
View,
|
|
8
|
+
type StyleProp,
|
|
9
|
+
type ViewStyle,
|
|
10
|
+
} from "react-native";
|
|
11
|
+
import { getClientHost } from "../host";
|
|
12
|
+
import { usePluginTheme } from "../theme/provider";
|
|
13
|
+
import { spacing } from "../theme/tokens";
|
|
14
|
+
import type { BadgeSize } from "./Badge";
|
|
15
|
+
|
|
16
|
+
export interface SelectOption {
|
|
17
|
+
label: string;
|
|
18
|
+
value: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SelectProps {
|
|
22
|
+
value: string;
|
|
23
|
+
options: SelectOption[];
|
|
24
|
+
onValueChange: (value: string) => void;
|
|
25
|
+
label?: string;
|
|
26
|
+
/** Compact/pill scale shared with {@link Badge}: `"md"` (default) or `"sm"`. */
|
|
27
|
+
size?: BadgeSize;
|
|
28
|
+
placeholder?: string;
|
|
29
|
+
disabled?: boolean;
|
|
30
|
+
style?: StyleProp<ViewStyle>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const OPTION_LIST_MAX_HEIGHT = 216;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Compact single-choice picker sized to sit inside a {@link FormRow}. The
|
|
37
|
+
* closed trigger stays one line tall; opening reveals a bounded, scrollable
|
|
38
|
+
* option list, so a long list degrades to scrolling rather than overflow.
|
|
39
|
+
*/
|
|
40
|
+
export function Select({
|
|
41
|
+
value,
|
|
42
|
+
options,
|
|
43
|
+
onValueChange,
|
|
44
|
+
label,
|
|
45
|
+
size = "md",
|
|
46
|
+
placeholder = "Select…",
|
|
47
|
+
disabled = false,
|
|
48
|
+
style,
|
|
49
|
+
}: SelectProps) {
|
|
50
|
+
const { Icon } = getClientHost();
|
|
51
|
+
// The dropdown option list is a bounded box nested inside the host sheet,
|
|
52
|
+
// so it scrolls with a plain React Native ScrollView — never the host
|
|
53
|
+
// sheet-gesture scroller (#219).
|
|
54
|
+
const { colors, resolveRadius, typography, isCompact, touchTargetMin, alpha } =
|
|
55
|
+
usePluginTheme();
|
|
56
|
+
const [open, setOpen] = useState(false);
|
|
57
|
+
|
|
58
|
+
const caption = typography?.caption ?? {
|
|
59
|
+
fontSize: 11,
|
|
60
|
+
lineHeight: 15,
|
|
61
|
+
fontWeight: "400" as const,
|
|
62
|
+
};
|
|
63
|
+
const fontSize = size === "sm" ? 10 : caption.fontSize;
|
|
64
|
+
const lineHeight = size === "sm" ? 12 : caption.lineHeight;
|
|
65
|
+
const paddingVertical = size === "sm" ? 3 : 6;
|
|
66
|
+
const paddingHorizontal = size === "sm" ? 8 : 10;
|
|
67
|
+
const iconSize = size === "sm" ? 12 : 14;
|
|
68
|
+
const radius = resolveRadius("md");
|
|
69
|
+
|
|
70
|
+
// A free-text value (advanced entry) may not be one of the options; surface
|
|
71
|
+
// it on the trigger rather than masking it behind the placeholder.
|
|
72
|
+
const selected = options.find((option) => option.value === value);
|
|
73
|
+
const display = selected?.label ?? (value ? value : placeholder);
|
|
74
|
+
const canOpen = !disabled && options.length > 0;
|
|
75
|
+
const isOpen = open && canOpen;
|
|
76
|
+
|
|
77
|
+
const triggerMinHeight = size === "sm" ? 28 : Math.max(34, touchTargetMin);
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<View style={[styles.container, style]}>
|
|
81
|
+
<Pressable
|
|
82
|
+
accessibilityRole="button"
|
|
83
|
+
accessibilityLabel={label ? `${label}: ${display}` : display}
|
|
84
|
+
accessibilityState={{ expanded: isOpen, disabled }}
|
|
85
|
+
disabled={!canOpen}
|
|
86
|
+
onPress={() => setOpen((prev) => !prev)}
|
|
87
|
+
style={({ pressed }) => [
|
|
88
|
+
styles.trigger,
|
|
89
|
+
{
|
|
90
|
+
minHeight: triggerMinHeight,
|
|
91
|
+
borderRadius: radius,
|
|
92
|
+
borderColor: isOpen ? colors.accent : colors.border,
|
|
93
|
+
backgroundColor:
|
|
94
|
+
disabled || !canOpen
|
|
95
|
+
? alpha(colors.surface1, 0.5)
|
|
96
|
+
: pressed
|
|
97
|
+
? colors.surface2
|
|
98
|
+
: colors.surface0,
|
|
99
|
+
paddingVertical,
|
|
100
|
+
paddingHorizontal,
|
|
101
|
+
},
|
|
102
|
+
]}
|
|
103
|
+
>
|
|
104
|
+
<Text
|
|
105
|
+
numberOfLines={1}
|
|
106
|
+
style={[
|
|
107
|
+
styles.triggerText,
|
|
108
|
+
{
|
|
109
|
+
color: disabled || !canOpen ? colors.foregroundMuted : colors.foreground,
|
|
110
|
+
fontSize,
|
|
111
|
+
lineHeight,
|
|
112
|
+
fontWeight: selected ? "600" : "500",
|
|
113
|
+
},
|
|
114
|
+
]}
|
|
115
|
+
>
|
|
116
|
+
{display}
|
|
117
|
+
</Text>
|
|
118
|
+
<Icon
|
|
119
|
+
name={isOpen ? "ChevronUp" : "ChevronDown"}
|
|
120
|
+
size={iconSize}
|
|
121
|
+
color={colors.foregroundMuted}
|
|
122
|
+
/>
|
|
123
|
+
</Pressable>
|
|
124
|
+
|
|
125
|
+
{isOpen ? (
|
|
126
|
+
<View
|
|
127
|
+
style={[
|
|
128
|
+
styles.optionList,
|
|
129
|
+
{
|
|
130
|
+
borderRadius: radius,
|
|
131
|
+
borderColor: colors.border,
|
|
132
|
+
backgroundColor: colors.surface0,
|
|
133
|
+
marginTop: spacing.xs,
|
|
134
|
+
},
|
|
135
|
+
]}
|
|
136
|
+
>
|
|
137
|
+
<FallbackScrollView
|
|
138
|
+
nestedScrollEnabled
|
|
139
|
+
keyboardShouldPersistTaps="handled"
|
|
140
|
+
style={{ maxHeight: OPTION_LIST_MAX_HEIGHT }}
|
|
141
|
+
>
|
|
142
|
+
{options.map((option) => {
|
|
143
|
+
const isSelected = option.value === value;
|
|
144
|
+
return (
|
|
145
|
+
<Pressable
|
|
146
|
+
key={option.value}
|
|
147
|
+
accessibilityRole="button"
|
|
148
|
+
accessibilityLabel={option.label}
|
|
149
|
+
accessibilityState={{ selected: isSelected }}
|
|
150
|
+
onPress={() => {
|
|
151
|
+
onValueChange(option.value);
|
|
152
|
+
setOpen(false);
|
|
153
|
+
}}
|
|
154
|
+
style={({ pressed }) => [
|
|
155
|
+
styles.option,
|
|
156
|
+
{
|
|
157
|
+
paddingVertical,
|
|
158
|
+
paddingHorizontal,
|
|
159
|
+
backgroundColor: isSelected
|
|
160
|
+
? colors.surface2
|
|
161
|
+
: pressed
|
|
162
|
+
? alpha(colors.surface2, 0.5)
|
|
163
|
+
: "transparent",
|
|
164
|
+
},
|
|
165
|
+
]}
|
|
166
|
+
>
|
|
167
|
+
<Text
|
|
168
|
+
numberOfLines={1}
|
|
169
|
+
style={[
|
|
170
|
+
styles.optionText,
|
|
171
|
+
{
|
|
172
|
+
color: isSelected ? colors.foreground : colors.foregroundMuted,
|
|
173
|
+
fontSize: isCompact ? fontSize : fontSize + 1,
|
|
174
|
+
lineHeight,
|
|
175
|
+
fontWeight: isSelected ? "600" : "400",
|
|
176
|
+
},
|
|
177
|
+
]}
|
|
178
|
+
>
|
|
179
|
+
{option.label}
|
|
180
|
+
</Text>
|
|
181
|
+
</Pressable>
|
|
182
|
+
);
|
|
183
|
+
})}
|
|
184
|
+
</FallbackScrollView>
|
|
185
|
+
</View>
|
|
186
|
+
) : null}
|
|
187
|
+
</View>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const styles = StyleSheet.create({
|
|
192
|
+
container: {
|
|
193
|
+
width: "100%",
|
|
194
|
+
},
|
|
195
|
+
trigger: {
|
|
196
|
+
flexDirection: "row",
|
|
197
|
+
alignItems: "center",
|
|
198
|
+
justifyContent: "space-between",
|
|
199
|
+
gap: spacing.sm,
|
|
200
|
+
borderWidth: 1,
|
|
201
|
+
},
|
|
202
|
+
triggerText: {
|
|
203
|
+
flexShrink: 1,
|
|
204
|
+
minWidth: 0,
|
|
205
|
+
},
|
|
206
|
+
optionList: {
|
|
207
|
+
borderWidth: 1,
|
|
208
|
+
overflow: "hidden",
|
|
209
|
+
},
|
|
210
|
+
option: {},
|
|
211
|
+
optionText: {
|
|
212
|
+
flexShrink: 1,
|
|
213
|
+
minWidth: 0,
|
|
214
|
+
},
|
|
215
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from "react";
|
|
2
|
+
import { Animated, StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
|
|
3
|
+
import { usePluginTheme } from "../theme/provider";
|
|
4
|
+
import type { StatusVariant } from "../../../../shared/vendor/paseo-plugin-helper/types";
|
|
5
|
+
|
|
6
|
+
export interface StatusDotProps {
|
|
7
|
+
variant?: StatusVariant;
|
|
8
|
+
size?: "sm" | "md" | "lg";
|
|
9
|
+
pulse?: boolean;
|
|
10
|
+
style?: StyleProp<ViewStyle>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function StatusDot({ variant = "neutral", size = "md", pulse = false, style }: StatusDotProps) {
|
|
14
|
+
const { getStatusColor, alpha } = usePluginTheme();
|
|
15
|
+
const color = getStatusColor(variant);
|
|
16
|
+
const pulseAnim = useRef(new Animated.Value(1)).current;
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (!pulse) return;
|
|
20
|
+
|
|
21
|
+
const loop = Animated.loop(
|
|
22
|
+
Animated.sequence([
|
|
23
|
+
Animated.timing(pulseAnim, {
|
|
24
|
+
toValue: 0.35,
|
|
25
|
+
duration: 900,
|
|
26
|
+
useNativeDriver: true,
|
|
27
|
+
}),
|
|
28
|
+
Animated.timing(pulseAnim, {
|
|
29
|
+
toValue: 1,
|
|
30
|
+
duration: 900,
|
|
31
|
+
useNativeDriver: true,
|
|
32
|
+
}),
|
|
33
|
+
]),
|
|
34
|
+
);
|
|
35
|
+
loop.start();
|
|
36
|
+
|
|
37
|
+
return () => loop.stop();
|
|
38
|
+
}, [pulse, pulseAnim]);
|
|
39
|
+
|
|
40
|
+
const dimension = size === "sm" ? 6 : size === "lg" ? 10 : 8;
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<View
|
|
44
|
+
style={[
|
|
45
|
+
styles.container,
|
|
46
|
+
{
|
|
47
|
+
width: dimension,
|
|
48
|
+
height: dimension,
|
|
49
|
+
},
|
|
50
|
+
style,
|
|
51
|
+
]}
|
|
52
|
+
>
|
|
53
|
+
<Animated.View
|
|
54
|
+
style={[
|
|
55
|
+
styles.dot,
|
|
56
|
+
{
|
|
57
|
+
width: dimension,
|
|
58
|
+
height: dimension,
|
|
59
|
+
borderRadius: dimension / 2,
|
|
60
|
+
backgroundColor: color,
|
|
61
|
+
shadowColor: color,
|
|
62
|
+
opacity: pulseAnim,
|
|
63
|
+
},
|
|
64
|
+
]}
|
|
65
|
+
/>
|
|
66
|
+
</View>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const styles = StyleSheet.create({
|
|
71
|
+
container: {
|
|
72
|
+
alignItems: "center",
|
|
73
|
+
justifyContent: "center",
|
|
74
|
+
},
|
|
75
|
+
dot: {
|
|
76
|
+
shadowOffset: { width: 0, height: 0 },
|
|
77
|
+
shadowOpacity: 0.4,
|
|
78
|
+
shadowRadius: 3,
|
|
79
|
+
},
|
|
80
|
+
});
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState, type ComponentType, type Ref } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Platform,
|
|
4
|
+
Pressable,
|
|
5
|
+
ScrollView as FallbackScrollView,
|
|
6
|
+
StyleSheet,
|
|
7
|
+
Text,
|
|
8
|
+
View,
|
|
9
|
+
type LayoutChangeEvent,
|
|
10
|
+
type ScrollView as ScrollViewInstance,
|
|
11
|
+
type StyleProp,
|
|
12
|
+
type ViewStyle,
|
|
13
|
+
} from "react-native";
|
|
14
|
+
import { getClientHost } from "../host";
|
|
15
|
+
import { usePluginTheme } from "../theme/provider";
|
|
16
|
+
import {
|
|
17
|
+
FALLBACK_ACCENT_FOREGROUND,
|
|
18
|
+
spacing,
|
|
19
|
+
} from "../theme/tokens";
|
|
20
|
+
|
|
21
|
+
export interface TabItem {
|
|
22
|
+
id: string;
|
|
23
|
+
label: string;
|
|
24
|
+
/**
|
|
25
|
+
* Optional abbreviated label for compact viewports in fit mode.
|
|
26
|
+
* e.g. label: "Interactive Controls", shortLabel: "Controls"
|
|
27
|
+
*/
|
|
28
|
+
shortLabel?: string;
|
|
29
|
+
icon?: string;
|
|
30
|
+
badge?: string | number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface TabsProps {
|
|
34
|
+
tabs: TabItem[];
|
|
35
|
+
activeTab: string;
|
|
36
|
+
onTabChange: (tabId: string) => void;
|
|
37
|
+
mode?: "auto" | "fit" | "scroll";
|
|
38
|
+
style?: StyleProp<ViewStyle>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Minimal structural views of the DOM scroll node react-native-web exposes. */
|
|
42
|
+
interface WheelScrollEvent {
|
|
43
|
+
deltaX?: number;
|
|
44
|
+
deltaY?: number;
|
|
45
|
+
preventDefault(): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface WheelScrollNode {
|
|
49
|
+
scrollLeft: number;
|
|
50
|
+
clientWidth: number;
|
|
51
|
+
scrollWidth: number;
|
|
52
|
+
addEventListener(type: "wheel", listener: (event: WheelScrollEvent) => void, options?: { passive?: boolean }): void;
|
|
53
|
+
removeEventListener(type: "wheel", listener: (event: WheelScrollEvent) => void): void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface WheelScrollInstance {
|
|
57
|
+
getScrollableNode?(): WheelScrollNode | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function Tabs({
|
|
61
|
+
tabs,
|
|
62
|
+
activeTab,
|
|
63
|
+
onTabChange,
|
|
64
|
+
mode = "auto",
|
|
65
|
+
style,
|
|
66
|
+
}: TabsProps) {
|
|
67
|
+
const { Icon } = getClientHost();
|
|
68
|
+
// The tab strip scrolls horizontally, so it must be a plain React Native
|
|
69
|
+
// ScrollView: the host ScrollView is a vertical sheet-gesture controller
|
|
70
|
+
// and collapses/crashes when used horizontally (#219).
|
|
71
|
+
const { colors, resolveRadius, touchTargetMin, isCompact, alpha } = usePluginTheme();
|
|
72
|
+
const scrollRef = useRef<ScrollViewInstance>(null);
|
|
73
|
+
const tabLayouts = useRef<Record<string, { x: number; width: number }>>({});
|
|
74
|
+
const [viewportWidth, setViewportWidth] = useState<number>(0);
|
|
75
|
+
|
|
76
|
+
const radius = resolveRadius("sm");
|
|
77
|
+
|
|
78
|
+
// On compact/mobile or with <= 4 tabs, auto mode defaults to full-width fitting track
|
|
79
|
+
const shouldFit = mode === "fit" || (mode === "auto" && (isCompact || tabs.length <= 4));
|
|
80
|
+
|
|
81
|
+
// Accurately center the active tab inside the viewport ONLY when activeTab changes
|
|
82
|
+
const prevActiveTab = useRef<string>(activeTab);
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!shouldFit && scrollRef.current && tabLayouts.current[activeTab] && viewportWidth > 0) {
|
|
85
|
+
if (prevActiveTab.current !== activeTab) {
|
|
86
|
+
prevActiveTab.current = activeTab;
|
|
87
|
+
const { x, width } = tabLayouts.current[activeTab];
|
|
88
|
+
const targetX = Math.max(0, x - (viewportWidth - width) / 2);
|
|
89
|
+
scrollRef.current.scrollTo({
|
|
90
|
+
x: targetX,
|
|
91
|
+
animated: true,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}, [activeTab, shouldFit, viewportWidth]);
|
|
96
|
+
|
|
97
|
+
const handleTabLayout = (tabId: string, event: LayoutChangeEvent) => {
|
|
98
|
+
const { x, width } = event.nativeEvent.layout;
|
|
99
|
+
tabLayouts.current[tabId] = { x, width };
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const handleContainerLayout = (event: LayoutChangeEvent) => {
|
|
103
|
+
const width = event.nativeEvent.layout.width;
|
|
104
|
+
setViewportWidth(width);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Translate vertical mouse-wheel motion into horizontal tab-strip scrolling
|
|
108
|
+
// on web, while allowing the surrounding modal to scroll at either edge.
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (shouldFit || Platform.OS !== "web") return;
|
|
111
|
+
const instance = scrollRef.current as unknown as WheelScrollInstance | null;
|
|
112
|
+
const node = instance?.getScrollableNode?.() ?? (instance as unknown as WheelScrollNode | null);
|
|
113
|
+
if (!node || typeof node.addEventListener !== "function") return;
|
|
114
|
+
const onWheel = (event: WheelScrollEvent) => {
|
|
115
|
+
const deltaX = event.deltaX ?? 0;
|
|
116
|
+
const deltaY = event.deltaY ?? 0;
|
|
117
|
+
if (Math.abs(deltaY) <= Math.abs(deltaX)) return;
|
|
118
|
+
const max = node.scrollWidth - node.clientWidth;
|
|
119
|
+
const next = Math.min(Math.max(0, node.scrollLeft + deltaY), max);
|
|
120
|
+
if (next === node.scrollLeft) return;
|
|
121
|
+
event.preventDefault();
|
|
122
|
+
node.scrollLeft = next;
|
|
123
|
+
};
|
|
124
|
+
node.addEventListener("wheel", onWheel, { passive: false });
|
|
125
|
+
return () => node.removeEventListener("wheel", onWheel);
|
|
126
|
+
}, [shouldFit]);
|
|
127
|
+
|
|
128
|
+
const renderTab = (tab: TabItem) => {
|
|
129
|
+
const isActive = tab.id === activeTab;
|
|
130
|
+
const displayLabel = shouldFit && isCompact && tab.shortLabel ? tab.shortLabel : tab.label;
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<Pressable
|
|
134
|
+
key={tab.id}
|
|
135
|
+
onPress={() => {
|
|
136
|
+
onTabChange(tab.id);
|
|
137
|
+
}}
|
|
138
|
+
onLayout={(e) => handleTabLayout(tab.id, e)}
|
|
139
|
+
accessibilityRole="tab"
|
|
140
|
+
accessibilityState={{ selected: isActive }}
|
|
141
|
+
style={({ pressed }) => [
|
|
142
|
+
styles.tab,
|
|
143
|
+
shouldFit ? styles.tabFit : styles.tabScroll,
|
|
144
|
+
{
|
|
145
|
+
borderRadius: radius - 2,
|
|
146
|
+
minHeight: Math.max(30, touchTargetMin - 8),
|
|
147
|
+
backgroundColor: isActive
|
|
148
|
+
? colors.surface2
|
|
149
|
+
: pressed
|
|
150
|
+
? alpha(colors.surface2, 0.5)
|
|
151
|
+
: "transparent",
|
|
152
|
+
paddingHorizontal: shouldFit
|
|
153
|
+
? isCompact
|
|
154
|
+
? (tabs.length > 3 ? spacing.xs : spacing.sm)
|
|
155
|
+
: (tabs.length >= 3 ? spacing.sm : spacing.md)
|
|
156
|
+
: spacing.md,
|
|
157
|
+
paddingVertical: isCompact ? spacing.xs : spacing.sm,
|
|
158
|
+
},
|
|
159
|
+
]}
|
|
160
|
+
>
|
|
161
|
+
{tab.icon ? (
|
|
162
|
+
<Icon
|
|
163
|
+
name={tab.icon}
|
|
164
|
+
size={isCompact ? 11 : 13}
|
|
165
|
+
color={isActive ? colors.foreground : colors.foregroundMuted}
|
|
166
|
+
/>
|
|
167
|
+
) : null}
|
|
168
|
+
<Text
|
|
169
|
+
numberOfLines={1}
|
|
170
|
+
style={[
|
|
171
|
+
styles.tabText,
|
|
172
|
+
{
|
|
173
|
+
color: isActive ? colors.foreground : colors.foregroundMuted,
|
|
174
|
+
fontSize: isCompact ? 11 : 12,
|
|
175
|
+
fontWeight: isActive ? "600" : "500",
|
|
176
|
+
},
|
|
177
|
+
]}
|
|
178
|
+
>
|
|
179
|
+
{displayLabel}
|
|
180
|
+
</Text>
|
|
181
|
+
{tab.badge !== undefined ? (
|
|
182
|
+
<View
|
|
183
|
+
style={[
|
|
184
|
+
styles.badge,
|
|
185
|
+
{
|
|
186
|
+
backgroundColor: isActive ? colors.accent : alpha(colors.foregroundMuted, 0.2),
|
|
187
|
+
},
|
|
188
|
+
]}
|
|
189
|
+
>
|
|
190
|
+
<Text
|
|
191
|
+
style={[
|
|
192
|
+
styles.badgeText,
|
|
193
|
+
{
|
|
194
|
+
color: isActive
|
|
195
|
+
? colors.accentForeground || FALLBACK_ACCENT_FOREGROUND
|
|
196
|
+
: colors.foregroundMuted,
|
|
197
|
+
},
|
|
198
|
+
]}
|
|
199
|
+
>
|
|
200
|
+
{tab.badge}
|
|
201
|
+
</Text>
|
|
202
|
+
</View>
|
|
203
|
+
) : null}
|
|
204
|
+
</Pressable>
|
|
205
|
+
);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// 1. FIT MODE: Closed bounded track frame
|
|
209
|
+
if (shouldFit) {
|
|
210
|
+
return (
|
|
211
|
+
<View
|
|
212
|
+
style={[
|
|
213
|
+
styles.frame,
|
|
214
|
+
{
|
|
215
|
+
backgroundColor: colors.surface1,
|
|
216
|
+
borderRadius: radius,
|
|
217
|
+
borderColor: colors.border,
|
|
218
|
+
},
|
|
219
|
+
style,
|
|
220
|
+
]}
|
|
221
|
+
>
|
|
222
|
+
<View style={styles.trackFit}>
|
|
223
|
+
{tabs.map((tab) => renderTab(tab))}
|
|
224
|
+
</View>
|
|
225
|
+
</View>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// 2. SCROLL MODE: plain React Native horizontal scroller.
|
|
230
|
+
return (
|
|
231
|
+
<View
|
|
232
|
+
onLayout={handleContainerLayout}
|
|
233
|
+
style={[
|
|
234
|
+
styles.frame,
|
|
235
|
+
{
|
|
236
|
+
backgroundColor: colors.surface1,
|
|
237
|
+
borderRadius: radius,
|
|
238
|
+
borderColor: colors.border,
|
|
239
|
+
},
|
|
240
|
+
style,
|
|
241
|
+
]}
|
|
242
|
+
>
|
|
243
|
+
<FallbackScrollView
|
|
244
|
+
ref={scrollRef}
|
|
245
|
+
horizontal
|
|
246
|
+
nestedScrollEnabled={true}
|
|
247
|
+
directionalLockEnabled={true}
|
|
248
|
+
keyboardShouldPersistTaps="handled"
|
|
249
|
+
showsHorizontalScrollIndicator={!isCompact}
|
|
250
|
+
style={styles.scrollView}
|
|
251
|
+
contentContainerStyle={styles.scrollContent}
|
|
252
|
+
>
|
|
253
|
+
{tabs.map((tab) => renderTab(tab))}
|
|
254
|
+
</FallbackScrollView>
|
|
255
|
+
|
|
256
|
+
</View>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const styles = StyleSheet.create({
|
|
261
|
+
frame: {
|
|
262
|
+
width: "100%",
|
|
263
|
+
maxWidth: "100%",
|
|
264
|
+
borderWidth: 1,
|
|
265
|
+
overflow: "hidden",
|
|
266
|
+
position: "relative",
|
|
267
|
+
justifyContent: "center",
|
|
268
|
+
},
|
|
269
|
+
trackFit: {
|
|
270
|
+
flexDirection: "row",
|
|
271
|
+
flexWrap: "nowrap",
|
|
272
|
+
alignItems: "center",
|
|
273
|
+
width: "100%",
|
|
274
|
+
padding: 3,
|
|
275
|
+
gap: 2,
|
|
276
|
+
},
|
|
277
|
+
scrollView: {
|
|
278
|
+
width: "100%",
|
|
279
|
+
maxWidth: "100%",
|
|
280
|
+
},
|
|
281
|
+
scrollContent: {
|
|
282
|
+
flexDirection: "row",
|
|
283
|
+
alignItems: "center",
|
|
284
|
+
padding: 3,
|
|
285
|
+
gap: 4,
|
|
286
|
+
},
|
|
287
|
+
tab: {
|
|
288
|
+
flexDirection: "row",
|
|
289
|
+
alignItems: "center",
|
|
290
|
+
justifyContent: "center",
|
|
291
|
+
gap: 4,
|
|
292
|
+
overflow: "hidden",
|
|
293
|
+
},
|
|
294
|
+
tabFit: {
|
|
295
|
+
flex: 1,
|
|
296
|
+
flexShrink: 1,
|
|
297
|
+
minWidth: 0,
|
|
298
|
+
},
|
|
299
|
+
tabScroll: {
|
|
300
|
+
flexShrink: 0,
|
|
301
|
+
},
|
|
302
|
+
tabText: {
|
|
303
|
+
textAlign: "center",
|
|
304
|
+
flexShrink: 1,
|
|
305
|
+
minWidth: 0,
|
|
306
|
+
},
|
|
307
|
+
badge: {
|
|
308
|
+
borderRadius: 9999,
|
|
309
|
+
paddingHorizontal: 5,
|
|
310
|
+
paddingVertical: 1,
|
|
311
|
+
minWidth: 16,
|
|
312
|
+
alignItems: "center",
|
|
313
|
+
justifyContent: "center",
|
|
314
|
+
},
|
|
315
|
+
badgeText: {
|
|
316
|
+
fontSize: 10,
|
|
317
|
+
fontWeight: "700",
|
|
318
|
+
},
|
|
319
|
+
});
|