@xpufx/paseo-top 0.4.4 → 0.4.7

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 (130) hide show
  1. package/OVERVIEW.md +18 -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 +692 -616
  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/npm-shrinkwrap.json +44 -0
  49. package/package.json +8 -4
  50. package/paseo-plugin.json +2 -0
  51. package/screenshots/1-paseo-top-modal-system.jpg +0 -0
  52. package/screenshots/2-paseo-top-modal-workspace.jpg +0 -0
  53. package/screenshots/3-paseo-top-modal-settings.jpg +0 -0
  54. package/screenshots/4-paseo-top-modal-about.jpg +0 -0
  55. package/screenshots/paseo-top-dashboard.jpg +0 -0
  56. package/screenshots/paseo-top-general-view.jpg +0 -0
  57. package/server/resources.ts +98 -0
  58. package/server/vendor/paseo-plugin-helper/custom-pills.ts +70 -0
  59. package/server/vendor/paseo-plugin-helper/index.ts +2 -0
  60. package/server/vendor/paseo-plugin-helper/logger.ts +5 -16
  61. package/server/vendor/paseo-plugin-helper/rpc-guard.ts +2 -2
  62. package/server/vendor/paseo-plugin-helper/storage-guard.ts +294 -0
  63. package/server/vendor/paseo-plugin-helper/storage.ts +36 -2
  64. package/server/vendor/paseo-plugin-helper/telemetry.ts +486 -0
  65. package/server/vendor/paseo-plugin-helper/tickets.ts +18 -67
  66. package/server/vendor/permission-audit/index.ts +1 -0
  67. package/server/vendor/permission-audit/server.ts +839 -0
  68. package/shared/helper-version.ts +1 -1
  69. package/shared/multi-host.ts +153 -159
  70. package/shared/resources.ts +145 -0
  71. package/shared/vendor/paseo-plugin-helper/async.ts +14 -7
  72. package/shared/vendor/paseo-plugin-helper/features.ts +244 -0
  73. package/shared/vendor/paseo-plugin-helper/forge.ts +27 -0
  74. package/shared/vendor/paseo-plugin-helper/index.ts +1 -0
  75. package/shared/vendor/paseo-plugin-helper/tickets.ts +122 -28
  76. package/shared/vendor/permission-audit/filter.ts +61 -0
  77. package/shared/vendor/permission-audit/index.ts +2 -0
  78. package/shared/vendor/permission-audit/shared.ts +119 -0
  79. package/shared/version.ts +1 -1
  80. package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +0 -493
  81. package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +0 -250
  82. package/client/vendor/paseo-plugin-helper/components/Badge.tsx +0 -168
  83. package/client/vendor/paseo-plugin-helper/components/Button.tsx +0 -204
  84. package/client/vendor/paseo-plugin-helper/components/Card.tsx +0 -257
  85. package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +0 -196
  86. package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +0 -277
  87. package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +0 -172
  88. package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +0 -180
  89. package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +0 -97
  90. package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +0 -70
  91. package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +0 -88
  92. package/client/vendor/paseo-plugin-helper/components/InteractiveRow.tsx +0 -142
  93. package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +0 -446
  94. package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +0 -247
  95. package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +0 -117
  96. package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +0 -53
  97. package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +0 -132
  98. package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +0 -87
  99. package/client/vendor/paseo-plugin-helper/components/Select.tsx +0 -283
  100. package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +0 -80
  101. package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +0 -319
  102. package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +0 -150
  103. package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +0 -163
  104. package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +0 -157
  105. package/client/vendor/paseo-plugin-helper/components/index.ts +0 -26
  106. package/client/vendor/paseo-plugin-helper/custom-pills.tsx +0 -225
  107. package/client/vendor/paseo-plugin-helper/forge-icon.tsx +0 -79
  108. package/client/vendor/paseo-plugin-helper/icon.tsx +0 -39
  109. package/client/vendor/paseo-plugin-helper/index.ts +0 -30
  110. package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +0 -49
  111. package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +0 -103
  112. package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +0 -65
  113. package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +0 -368
  114. package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +0 -51
  115. package/client/vendor/paseo-plugin-helper/layout/Row.tsx +0 -39
  116. package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +0 -39
  117. package/client/vendor/paseo-plugin-helper/layout/index.ts +0 -7
  118. package/client/vendor/paseo-plugin-helper/styles/index.ts +0 -1
  119. package/client/vendor/paseo-plugin-helper/styles/recipes.ts +0 -550
  120. package/client/vendor/paseo-plugin-helper/theme/flair.ts +0 -76
  121. package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +0 -121
  122. package/client/vendor/paseo-plugin-helper/theme/index.ts +0 -7
  123. package/client/vendor/paseo-plugin-helper/theme/provider.tsx +0 -178
  124. package/client/vendor/paseo-plugin-helper/theme/responsive.ts +0 -213
  125. package/client/vendor/paseo-plugin-helper/theme/tokens.ts +0 -200
  126. package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +0 -57
  127. package/client/vendor/paseo-plugin-helper/tickets.tsx +0 -766
  128. /package/client/vendor/paseo-plugin-helper/{command-center.ts → core/command-center.ts} +0 -0
  129. /package/client/vendor/paseo-plugin-helper/{snapshot.ts → core/snapshot.ts} +0 -0
  130. /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
- });