@xpufx/paseo-top 0.4.4 → 0.4.5

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.
Files changed (129) hide show
  1. package/OVERVIEW.md +17 -0
  2. package/client/host-ui.tsx +184 -0
  3. package/client/multi-host-view.tsx +57 -43
  4. package/client/multi-host.ts +36 -13
  5. package/client/pill-labels.ts +94 -1
  6. package/client/pill.tsx +690 -614
  7. package/client/resources-query.ts +1 -1
  8. package/client/settings-events.ts +1 -1
  9. package/client/settings-ui.tsx +86 -9
  10. package/client/surface.tsx +308 -276
  11. package/client/telemetry-copy.ts +8 -2
  12. package/client/telemetry.tsx +372 -321
  13. package/client/turn-panel.tsx +126 -104
  14. package/client/vendor/paseo-plugin-helper/{host.ts → core/host.ts} +9 -3
  15. package/client/vendor/paseo-plugin-helper/core/index.ts +76 -0
  16. package/client/vendor/paseo-plugin-helper/core/plugin-load-count.ts +89 -0
  17. package/client/vendor/paseo-plugin-helper/{query-refresh.ts → core/query-refresh.ts} +2 -2
  18. package/client/vendor/paseo-plugin-helper/{query.ts → core/query.ts} +2 -2
  19. package/client/vendor/paseo-plugin-helper/{settings.ts → core/settings.ts} +1 -1
  20. package/client/vendor/paseo-plugin-helper/{shared-settings.ts → core/shared-settings.ts} +2 -2
  21. package/client/vendor/paseo-plugin-helper/{utils → lifecycle}/clipboard.ts +2 -2
  22. package/client/vendor/paseo-plugin-helper/lifecycle/flair.ts +26 -0
  23. package/client/vendor/paseo-plugin-helper/{theme/color-utils.ts → lifecycle/host-color.ts} +95 -38
  24. package/client/vendor/paseo-plugin-helper/lifecycle/host-theme.tsx +109 -0
  25. package/client/vendor/paseo-plugin-helper/lifecycle/index.ts +23 -0
  26. package/client/vendor/paseo-plugin-helper/lifecycle/multi-host.ts +66 -0
  27. package/client/vendor/paseo-plugin-helper/{panel.tsx → lifecycle/panel.tsx} +7 -7
  28. package/client/vendor/paseo-plugin-helper/{pill.tsx → lifecycle/pill.tsx} +51 -18
  29. package/client/vendor/paseo-plugin-helper/lifecycle/providers.tsx +46 -0
  30. package/client/vendor/paseo-plugin-helper/lifecycle/scroll-owner.ts +26 -0
  31. package/client/vendor/paseo-plugin-helper/{surface.tsx → lifecycle/surface.tsx} +6 -6
  32. package/client/vendor/paseo-plugin-helper/ui/color.ts +1 -0
  33. package/client/vendor/paseo-plugin-helper/ui/content.tsx +1326 -0
  34. package/client/vendor/paseo-plugin-helper/ui/controls.tsx +1018 -0
  35. package/client/vendor/paseo-plugin-helper/{components/DataTable.tsx → ui/data.tsx} +50 -58
  36. package/client/vendor/paseo-plugin-helper/ui/index.ts +44 -0
  37. package/client/vendor/paseo-plugin-helper/ui/layout.tsx +263 -0
  38. package/client/vendor/paseo-plugin-helper/ui/modal.tsx +111 -0
  39. package/client/vendor/paseo-plugin-helper/ui/plugin-reload-banner.tsx +119 -0
  40. package/client/vendor/paseo-plugin-helper/{settings-screen.tsx → ui/settings-screen.tsx} +3 -3
  41. package/client/vendor/paseo-plugin-helper/ui/settings.ts +36 -0
  42. package/client/vendor/paseo-plugin-helper/ui/text.tsx +821 -0
  43. package/client/vendor/paseo-plugin-helper/ui/theme.tsx +1 -0
  44. package/client/vendor/permission-audit/client.tsx +706 -0
  45. package/client/vendor/permission-audit/index.tsx +1 -0
  46. package/index.client.tsx +58 -37
  47. package/index.server.ts +256 -234
  48. package/package.json +7 -4
  49. package/paseo-plugin.json +2 -0
  50. package/screenshots/1-paseo-top-modal-system.jpg +0 -0
  51. package/screenshots/2-paseo-top-modal-workspace.jpg +0 -0
  52. package/screenshots/3-paseo-top-modal-settings.jpg +0 -0
  53. package/screenshots/4-paseo-top-modal-about.jpg +0 -0
  54. package/screenshots/paseo-top-dashboard.jpg +0 -0
  55. package/screenshots/paseo-top-general-view.jpg +0 -0
  56. package/server/resources.ts +98 -0
  57. package/server/vendor/paseo-plugin-helper/custom-pills.ts +70 -0
  58. package/server/vendor/paseo-plugin-helper/index.ts +2 -0
  59. package/server/vendor/paseo-plugin-helper/logger.ts +5 -16
  60. package/server/vendor/paseo-plugin-helper/rpc-guard.ts +2 -2
  61. package/server/vendor/paseo-plugin-helper/storage-guard.ts +294 -0
  62. package/server/vendor/paseo-plugin-helper/storage.ts +36 -2
  63. package/server/vendor/paseo-plugin-helper/telemetry.ts +486 -0
  64. package/server/vendor/paseo-plugin-helper/tickets.ts +18 -67
  65. package/server/vendor/permission-audit/index.ts +1 -0
  66. package/server/vendor/permission-audit/server.ts +839 -0
  67. package/shared/helper-version.ts +1 -1
  68. package/shared/multi-host.ts +153 -159
  69. package/shared/resources.ts +145 -0
  70. package/shared/vendor/paseo-plugin-helper/async.ts +14 -7
  71. package/shared/vendor/paseo-plugin-helper/features.ts +244 -0
  72. package/shared/vendor/paseo-plugin-helper/forge.ts +27 -0
  73. package/shared/vendor/paseo-plugin-helper/index.ts +1 -0
  74. package/shared/vendor/paseo-plugin-helper/tickets.ts +122 -28
  75. package/shared/vendor/permission-audit/filter.ts +61 -0
  76. package/shared/vendor/permission-audit/index.ts +2 -0
  77. package/shared/vendor/permission-audit/shared.ts +119 -0
  78. package/shared/version.ts +1 -1
  79. package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +0 -493
  80. package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +0 -250
  81. package/client/vendor/paseo-plugin-helper/components/Badge.tsx +0 -168
  82. package/client/vendor/paseo-plugin-helper/components/Button.tsx +0 -204
  83. package/client/vendor/paseo-plugin-helper/components/Card.tsx +0 -257
  84. package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +0 -196
  85. package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +0 -277
  86. package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +0 -172
  87. package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +0 -180
  88. package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +0 -97
  89. package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +0 -70
  90. package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +0 -88
  91. package/client/vendor/paseo-plugin-helper/components/InteractiveRow.tsx +0 -142
  92. package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +0 -446
  93. package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +0 -247
  94. package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +0 -117
  95. package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +0 -53
  96. package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +0 -132
  97. package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +0 -87
  98. package/client/vendor/paseo-plugin-helper/components/Select.tsx +0 -283
  99. package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +0 -80
  100. package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +0 -319
  101. package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +0 -150
  102. package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +0 -163
  103. package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +0 -157
  104. package/client/vendor/paseo-plugin-helper/components/index.ts +0 -26
  105. package/client/vendor/paseo-plugin-helper/custom-pills.tsx +0 -225
  106. package/client/vendor/paseo-plugin-helper/forge-icon.tsx +0 -79
  107. package/client/vendor/paseo-plugin-helper/icon.tsx +0 -39
  108. package/client/vendor/paseo-plugin-helper/index.ts +0 -30
  109. package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +0 -49
  110. package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +0 -103
  111. package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +0 -65
  112. package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +0 -368
  113. package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +0 -51
  114. package/client/vendor/paseo-plugin-helper/layout/Row.tsx +0 -39
  115. package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +0 -39
  116. package/client/vendor/paseo-plugin-helper/layout/index.ts +0 -7
  117. package/client/vendor/paseo-plugin-helper/styles/index.ts +0 -1
  118. package/client/vendor/paseo-plugin-helper/styles/recipes.ts +0 -550
  119. package/client/vendor/paseo-plugin-helper/theme/flair.ts +0 -76
  120. package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +0 -121
  121. package/client/vendor/paseo-plugin-helper/theme/index.ts +0 -7
  122. package/client/vendor/paseo-plugin-helper/theme/provider.tsx +0 -178
  123. package/client/vendor/paseo-plugin-helper/theme/responsive.ts +0 -213
  124. package/client/vendor/paseo-plugin-helper/theme/tokens.ts +0 -200
  125. package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +0 -57
  126. package/client/vendor/paseo-plugin-helper/tickets.tsx +0 -766
  127. /package/client/vendor/paseo-plugin-helper/{command-center.ts → core/command-center.ts} +0 -0
  128. /package/client/vendor/paseo-plugin-helper/{snapshot.ts → core/snapshot.ts} +0 -0
  129. /package/client/vendor/paseo-plugin-helper/{utils → lifecycle}/haptics.ts +0 -0
@@ -1,117 +0,0 @@
1
- import React from "react";
2
- import { StyleSheet, Text, View, type StyleProp, type ViewStyle } from "react-native";
3
- import { usePluginTheme } from "../theme/provider";
4
- import { resolveMetricStatus, type MetricThresholds } from "../../../../shared/vendor/paseo-plugin-helper/formatters";
5
-
6
- export interface ProgressBarProps {
7
- value: number; // 0 to 100
8
- color?: string;
9
- autoStatusColor?: boolean;
10
- thresholds?: MetricThresholds;
11
- label?: string;
12
- showValueText?: boolean;
13
- height?: number;
14
- style?: StyleProp<ViewStyle>;
15
- }
16
-
17
- export function ProgressBar({
18
- value,
19
- color,
20
- autoStatusColor = true,
21
- thresholds,
22
- label,
23
- showValueText = false,
24
- height = 8,
25
- style,
26
- }: ProgressBarProps) {
27
- const { colors, resolveRadius, typography } = usePluginTheme();
28
-
29
- const clamped = Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0));
30
- const radius = resolveRadius("pill");
31
-
32
- let barColor = color || colors.accent;
33
- if (!color && autoStatusColor) {
34
- const status = resolveMetricStatus(clamped, thresholds);
35
- if (status === "danger") {
36
- barColor = colors.statusDanger;
37
- } else if (status === "warning") {
38
- barColor = colors.statusWarning;
39
- } else {
40
- barColor = colors.statusSuccess;
41
- }
42
- }
43
-
44
- return (
45
- <View style={[styles.container, style]}>
46
- {(label || showValueText) && (
47
- <View style={styles.labelRow}>
48
- {label ? (
49
- <Text
50
- style={[
51
- styles.labelText,
52
- { color: colors.foregroundMuted, ...typography.caption },
53
- ]}
54
- >
55
- {label}
56
- </Text>
57
- ) : null}
58
- {showValueText ? (
59
- <Text
60
- style={[
61
- styles.valueText,
62
- { color: colors.foreground, ...typography.caption, fontWeight: "600" },
63
- ]}
64
- >
65
- {Math.round(clamped)}%
66
- </Text>
67
- ) : null}
68
- </View>
69
- )}
70
- <View
71
- style={[
72
- styles.track,
73
- {
74
- backgroundColor: colors.surface2,
75
- height,
76
- borderRadius: radius,
77
- },
78
- ]}
79
- >
80
- <View
81
- style={[
82
- styles.fill,
83
- {
84
- width: `${clamped}%`,
85
- backgroundColor: barColor,
86
- borderRadius: radius,
87
- },
88
- ]}
89
- />
90
- </View>
91
- </View>
92
- );
93
- }
94
-
95
- const styles = StyleSheet.create({
96
- container: {
97
- gap: 4,
98
- },
99
- labelRow: {
100
- flexDirection: "row",
101
- justifyContent: "space-between",
102
- alignItems: "center",
103
- },
104
- labelText: {
105
- fontWeight: "500",
106
- },
107
- valueText: {
108
- fontWeight: "600",
109
- },
110
- track: {
111
- width: "100%",
112
- overflow: "hidden",
113
- },
114
- fill: {
115
- height: "100%",
116
- },
117
- });
@@ -1,53 +0,0 @@
1
- import React, { type ReactNode } from "react";
2
- import { useResponsive, type UseResponsiveResult } from "../theme/useResponsive";
3
-
4
- export interface ResponsiveProps {
5
- /**
6
- * Content to render on desktop / wide viewports.
7
- */
8
- desktop?: ReactNode;
9
- /**
10
- * Content to render on mobile platforms ('ios' | 'android').
11
- */
12
- mobile?: ReactNode;
13
- /**
14
- * Content to render when in compact mode (narrow trackbar, mobile bottom sheet, or split view).
15
- */
16
- compact?: ReactNode;
17
- /**
18
- * Content to render when in wide mode (not compact).
19
- */
20
- wide?: ReactNode;
21
- /**
22
- * Render function taking `UseResponsiveResult` or children.
23
- */
24
- children?: ReactNode | ((responsive: UseResponsiveResult) => ReactNode);
25
- }
26
-
27
- /**
28
- * Declarative component for rendering different UI elements across desktop, mobile, and compact layouts.
29
- *
30
- * @example
31
- * ```tsx
32
- * <Responsive
33
- * desktop={<DataTable columns={["ID", "Name", "Status", "Latency"]} data={items} />}
34
- * mobile={<DataTable columns={["Name", "Status"]} data={items} />}
35
- * />
36
- * ```
37
- */
38
- export function Responsive({ desktop, mobile, compact, wide, children }: ResponsiveProps) {
39
- const responsive = useResponsive();
40
-
41
- if (typeof children === "function") {
42
- return <>{children(responsive)}</>;
43
- }
44
-
45
- const selected = responsive.select<ReactNode>({
46
- desktop,
47
- mobile,
48
- compact,
49
- wide,
50
- });
51
-
52
- return <>{selected ?? children ?? null}</>;
53
- }
@@ -1,132 +0,0 @@
1
- import React, { type ComponentType, type Ref } from "react";
2
- import {
3
- StyleSheet,
4
- TextInput as RNTextInput,
5
- View,
6
- Pressable,
7
- type StyleProp,
8
- type ViewStyle,
9
- type TextStyle,
10
- type TextInputProps as RNTextInputProps,
11
- type TextInput as RNTextInputInstance,
12
- } from "react-native";
13
- import { getClientHost, getOptionalClientHost } from "../host";
14
- import { usePluginTheme } from "../theme/provider";
15
-
16
- export interface SearchInputProps {
17
- value: string;
18
- onChangeText: (text: string) => void;
19
- placeholder?: string;
20
- onClear?: () => void;
21
- /**
22
- * Painted box height, opt-in per call site. Unset keeps the original
23
- * `isCompact ? 36 : 40` box so no untouched surface changes appearance.
24
- * 26 matches the compact `size="sm"` button recipe (~26px) so a search field
25
- * can sit beside filter pills in one row without inflating it (#645); a
26
- * height of 26 or less also switches the input to the 12px `sm` label size,
27
- * anything taller keeps the prior 13/14.
28
- */
29
- height?: number;
30
- style?: StyleProp<ViewStyle>;
31
- inputStyle?: StyleProp<TextStyle>;
32
- testID?: string;
33
- }
34
-
35
- /**
36
- * Standardized search input with search icon, clear button, and theme support.
37
- */
38
- export function SearchInput({
39
- value,
40
- onChangeText,
41
- placeholder = "Search...",
42
- onClear,
43
- height,
44
- style,
45
- inputStyle,
46
- testID,
47
- }: SearchInputProps) {
48
- const { Icon } = getClientHost();
49
- const ResolvedInput = (getOptionalClientHost()?.TextInput ??
50
- RNTextInput) as ComponentType<RNTextInputProps & { ref?: Ref<RNTextInputInstance> }>;
51
- const { colors, resolveRadius, isCompact } = usePluginTheme();
52
- const radius = resolveRadius("sm");
53
- // Unspecified keeps the original compact-aware default so every call site
54
- // that does not opt in renders exactly as before (#645's scope was the
55
- // uppidi-fleet filter rows only).
56
- const resolvedHeight = height ?? (isCompact ? 36 : 40);
57
-
58
- const handleClear = () => {
59
- onChangeText("");
60
- if (onClear) onClear();
61
- };
62
-
63
- return (
64
- <View
65
- style={[
66
- styles.container,
67
- {
68
- backgroundColor: colors.surface1,
69
- borderColor: colors.border,
70
- borderRadius: radius,
71
- height: resolvedHeight,
72
- },
73
- style,
74
- ]}
75
- >
76
- <View style={styles.iconWrapper}>
77
- <Icon name="Search" size={16} color={colors.foregroundMuted} />
78
- </View>
79
- <ResolvedInput
80
- testID={testID}
81
- value={value}
82
- onChangeText={onChangeText}
83
- placeholder={placeholder}
84
- placeholderTextColor={colors.foregroundMuted}
85
- style={[
86
- styles.input,
87
- {
88
- color: colors.foreground,
89
- fontSize: height !== undefined && height <= 26 ? 12 : isCompact ? 13 : 14,
90
- },
91
- inputStyle,
92
- ]}
93
- returnKeyType="search"
94
- autoCapitalize="none"
95
- autoCorrect={false}
96
- />
97
- {Boolean(value) && (
98
- <Pressable
99
- onPress={handleClear}
100
- style={styles.clearButton}
101
- hitSlop={8}
102
- accessibilityLabel="Clear search"
103
- >
104
- <Icon name="X" size={14} color={colors.foregroundMuted} />
105
- </Pressable>
106
- )}
107
- </View>
108
- );
109
- }
110
-
111
- const styles = StyleSheet.create({
112
- container: {
113
- flexDirection: "row",
114
- alignItems: "center",
115
- borderWidth: 1,
116
- paddingHorizontal: 10,
117
- },
118
- iconWrapper: {
119
- marginRight: 8,
120
- alignItems: "center",
121
- justifyContent: "center",
122
- },
123
- input: {
124
- flex: 1,
125
- paddingVertical: 0,
126
- outlineWidth: 0,
127
- },
128
- clearButton: {
129
- padding: 4,
130
- marginLeft: 4,
131
- },
132
- });
@@ -1,87 +0,0 @@
1
- import React from "react";
2
- import {
3
- StyleSheet,
4
- Text,
5
- View,
6
- type StyleProp,
7
- type TextStyle,
8
- type ViewStyle,
9
- } from "react-native";
10
- import { usePluginTheme } from "../theme/provider";
11
- import type { StatusVariant } from "../../../../shared/vendor/paseo-plugin-helper/types";
12
- import { Badge } from "./Badge";
13
-
14
- export interface SectionHeaderProps {
15
- title: string;
16
- /** Optional count — shown as a Badge (warning variant when > 0, neutral otherwise) */
17
- count?: number;
18
- /** Badge variant override */
19
- badgeVariant?: StatusVariant;
20
- style?: StyleProp<ViewStyle>;
21
- textStyle?: StyleProp<TextStyle>;
22
- }
23
-
24
- export function SectionHeader({
25
- title,
26
- count,
27
- badgeVariant,
28
- style,
29
- textStyle,
30
- }: SectionHeaderProps): React.ReactElement | null {
31
- const theme = usePluginTheme();
32
- const titleType = theme.typography?.bodyStrong ?? {
33
- fontSize: 13,
34
- lineHeight: 19,
35
- fontWeight: "600" as const,
36
- };
37
- const headingTransform = (theme.flair?.headingTransform ?? "none") === "uppercase"
38
- ? "uppercase"
39
- : "none";
40
-
41
- return (
42
- <View style={[styles.container, style]}>
43
- <Text
44
- numberOfLines={1}
45
- ellipsizeMode="tail"
46
- style={[
47
- styles.title,
48
- {
49
- color: theme.colors.foregroundMuted,
50
- textTransform: headingTransform,
51
- fontSize: titleType.fontSize,
52
- lineHeight: titleType.lineHeight,
53
- fontWeight: titleType.fontWeight,
54
- },
55
- textStyle,
56
- ]}
57
- >
58
- {title}
59
- </Text>
60
- {count !== undefined ? (
61
- <Badge
62
- label={String(count)}
63
- variant={badgeVariant ?? (count > 0 ? "warning" : "neutral")}
64
- styleVariant={count > 0 ? "solid" : "tinted"}
65
- />
66
- ) : null}
67
- </View>
68
- );
69
- }
70
-
71
- const styles = StyleSheet.create({
72
- container: {
73
- flexDirection: "row",
74
- alignItems: "center",
75
- gap: 8,
76
- marginTop: 8,
77
- marginBottom: 2,
78
- flexShrink: 1,
79
- maxWidth: "100%",
80
- },
81
- title: {
82
- letterSpacing: 0.8,
83
- // Section titles are caller-supplied and unbounded; the title must be
84
- // allowed to compress for `numberOfLines` to engage.
85
- flexShrink: 1,
86
- },
87
- });
@@ -1,283 +0,0 @@
1
- import React, { useRef, useState } from "react";
2
- import {
3
- Modal,
4
- Pressable,
5
- ScrollView as FallbackScrollView,
6
- StyleSheet,
7
- Text,
8
- TouchableWithoutFeedback,
9
- View,
10
- type StyleProp,
11
- type ViewStyle,
12
- } from "react-native";
13
- import { getClientHost } from "../host";
14
- import { usePluginTheme } from "../theme/provider";
15
- import { spacing } from "../theme/tokens";
16
- import type { BadgeSize } from "./Badge";
17
-
18
- export interface SelectOption {
19
- label: string;
20
- value: string;
21
- }
22
-
23
- export interface SelectProps {
24
- value: string;
25
- options: SelectOption[];
26
- onValueChange: (value: string) => void;
27
- label?: string;
28
- /** Compact/pill scale shared with {@link Badge}: `"md"` (default) or `"sm"`. */
29
- size?: BadgeSize;
30
- placeholder?: string;
31
- disabled?: boolean;
32
- style?: StyleProp<ViewStyle>;
33
- }
34
-
35
- const OPTION_LIST_MAX_HEIGHT = 216;
36
-
37
- interface TriggerCoords {
38
- x: number;
39
- y: number;
40
- width: number;
41
- height: number;
42
- }
43
-
44
- // When `measureInWindow` is unavailable or never invokes its callback (node
45
- // test renderers, host shims), the menu still opens anchored to a zeroed box
46
- // rather than swallowing the press.
47
- const FALLBACK_COORDS: TriggerCoords = { x: 0, y: 0, width: 0, height: 0 };
48
-
49
- /**
50
- * Compact single-choice picker sized to sit inside a {@link FormRow}. The
51
- * closed trigger stays one line tall; opening mounts the menu in a root
52
- * transparent `<Modal>` overlay portal, so a long list never expands the
53
- * trigger's parent container, is never clipped by an ancestor
54
- * `overflow: "hidden"` (Card/Tabs/Modal), and always paints above later
55
- * siblings regardless of local stacking context.
56
- */
57
- export function Select({
58
- value,
59
- options,
60
- onValueChange,
61
- label,
62
- size = "md",
63
- placeholder = "Select…",
64
- disabled = false,
65
- style,
66
- }: SelectProps) {
67
- const { Icon } = getClientHost();
68
- // The option list is mounted in the root modal overlay, so it scrolls with
69
- // a plain React Native ScrollView — never the host sheet-gesture scroller
70
- // (#219) and never a scroller owned by the trigger's parent.
71
- const { colors, resolveRadius, typography, isCompact, touchTargetMin, alpha } =
72
- usePluginTheme();
73
- const [open, setOpen] = useState(false);
74
- const [coords, setCoords] = useState<TriggerCoords | null>(null);
75
- const triggerRef = useRef<View>(null);
76
-
77
- const caption = typography?.caption ?? {
78
- fontSize: 11,
79
- lineHeight: 15,
80
- fontWeight: "400" as const,
81
- };
82
- const fontSize = size === "sm" ? 10 : caption.fontSize;
83
- const lineHeight = size === "sm" ? 12 : caption.lineHeight;
84
- const paddingVertical = size === "sm" ? 3 : 6;
85
- const paddingHorizontal = size === "sm" ? 8 : 10;
86
- const iconSize = size === "sm" ? 12 : 14;
87
- const radius = resolveRadius("md");
88
-
89
- // A free-text value (advanced entry) may not be one of the options; surface
90
- // it on the trigger rather than masking it behind the placeholder.
91
- const selected = options.find((option) => option.value === value);
92
- const display = selected?.label ?? (value ? value : placeholder);
93
- const canOpen = !disabled && options.length > 0;
94
- const isOpen = open && canOpen;
95
-
96
- const triggerMinHeight = size === "sm" ? 28 : Math.max(34, touchTargetMin);
97
-
98
- const openAt = (next: TriggerCoords) => {
99
- setCoords(next);
100
- setOpen(true);
101
- };
102
-
103
- const handleToggle = () => {
104
- if (isOpen) {
105
- setOpen(false);
106
- return;
107
- }
108
-
109
- const node = triggerRef.current;
110
- // A node/shim without `measureInWindow` (node test renderers, stripped
111
- // host shims) still opens anchored to a zeroed box rather than swallowing
112
- // the press (#520).
113
- if (!node || typeof node.measureInWindow !== "function") {
114
- openAt(FALLBACK_COORDS);
115
- return;
116
- }
117
-
118
- node.measureInWindow((x, y, width, height) => {
119
- openAt({ x, y, width, height });
120
- });
121
- };
122
-
123
- return (
124
- <View style={[styles.container, style]}>
125
- <Pressable
126
- ref={triggerRef}
127
- accessibilityRole="button"
128
- accessibilityLabel={label ? `${label}: ${display}` : display}
129
- accessibilityState={{ expanded: isOpen, disabled }}
130
- disabled={!canOpen}
131
- onPress={handleToggle}
132
- style={({ pressed }) => [
133
- styles.trigger,
134
- {
135
- minHeight: triggerMinHeight,
136
- borderRadius: radius,
137
- borderColor: isOpen ? colors.accent : colors.border,
138
- backgroundColor:
139
- disabled || !canOpen
140
- ? alpha(colors.surface1, 0.5)
141
- : pressed
142
- ? colors.surface2
143
- : colors.surface0,
144
- paddingVertical,
145
- paddingHorizontal,
146
- },
147
- ]}
148
- >
149
- <Text
150
- numberOfLines={1}
151
- style={[
152
- styles.triggerText,
153
- {
154
- color: disabled || !canOpen ? colors.foregroundMuted : colors.foreground,
155
- fontSize,
156
- lineHeight,
157
- fontWeight: selected ? "600" : "500",
158
- },
159
- ]}
160
- >
161
- {display}
162
- </Text>
163
- <Icon
164
- name={isOpen ? "ChevronUp" : "ChevronDown"}
165
- size={iconSize}
166
- color={colors.foregroundMuted}
167
- />
168
- </Pressable>
169
-
170
- {isOpen && coords ? (
171
- <Modal
172
- transparent
173
- visible={isOpen}
174
- animationType="none"
175
- onRequestClose={() => setOpen(false)}
176
- >
177
- <TouchableWithoutFeedback
178
- accessibilityRole="button"
179
- accessibilityLabel="Dismiss options"
180
- onPress={() => setOpen(false)}
181
- >
182
- <View style={StyleSheet.absoluteFill} />
183
- </TouchableWithoutFeedback>
184
-
185
- <View
186
- style={[
187
- styles.overlayList,
188
- {
189
- top: coords.y + coords.height + spacing.xs,
190
- left: coords.x,
191
- minWidth: coords.width,
192
- borderRadius: radius,
193
- borderColor: colors.border,
194
- backgroundColor: colors.surface0,
195
- },
196
- ]}
197
- >
198
- <FallbackScrollView
199
- nestedScrollEnabled
200
- keyboardShouldPersistTaps="handled"
201
- style={{ maxHeight: OPTION_LIST_MAX_HEIGHT }}
202
- >
203
- {options.map((option) => {
204
- const isSelected = option.value === value;
205
- return (
206
- <Pressable
207
- key={option.value}
208
- accessibilityRole="button"
209
- accessibilityLabel={option.label}
210
- accessibilityState={{ selected: isSelected }}
211
- onPress={() => {
212
- onValueChange(option.value);
213
- setOpen(false);
214
- }}
215
- style={({ pressed }) => [
216
- styles.option,
217
- {
218
- paddingVertical,
219
- paddingHorizontal,
220
- backgroundColor: isSelected
221
- ? colors.surface2
222
- : pressed
223
- ? alpha(colors.surface2, 0.5)
224
- : "transparent",
225
- },
226
- ]}
227
- >
228
- <Text
229
- numberOfLines={1}
230
- style={[
231
- styles.optionText,
232
- {
233
- color: isSelected ? colors.foreground : colors.foregroundMuted,
234
- fontSize: isCompact ? fontSize : fontSize + 1,
235
- lineHeight,
236
- fontWeight: isSelected ? "600" : "400",
237
- },
238
- ]}
239
- >
240
- {option.label}
241
- </Text>
242
- </Pressable>
243
- );
244
- })}
245
- </FallbackScrollView>
246
- </View>
247
- </Modal>
248
- ) : null}
249
- </View>
250
- );
251
- }
252
-
253
- const styles = StyleSheet.create({
254
- container: {
255
- width: "100%",
256
- },
257
- trigger: {
258
- flexDirection: "row",
259
- alignItems: "center",
260
- justifyContent: "space-between",
261
- gap: spacing.sm,
262
- borderWidth: 1,
263
- },
264
- triggerText: {
265
- flexShrink: 1,
266
- minWidth: 0,
267
- },
268
- overlayList: {
269
- position: "absolute",
270
- borderWidth: 1,
271
- overflow: "hidden",
272
- elevation: 20,
273
- shadowColor: "#000",
274
- shadowOffset: { width: 0, height: 4 },
275
- shadowOpacity: 0.25,
276
- shadowRadius: 8,
277
- },
278
- option: {},
279
- optionText: {
280
- flexShrink: 1,
281
- minWidth: 0,
282
- },
283
- });