@xpufx/paseo-helper-demo 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +57 -0
- package/client/pill.tsx +1249 -0
- package/client/suite-settings.tsx +77 -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/core/index.ts +64 -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/ui/index.ts +25 -0
- package/client/vendor/paseo-plugin-helper/ui/modal.tsx +105 -0
- package/client/vendor/paseo-plugin-helper/ui/settings.ts +36 -0
- package/client/vendor/paseo-plugin-helper/utils/clipboard.ts +149 -0
- package/client/vendor/paseo-plugin-helper/utils/haptics.ts +34 -0
- package/package.json +35 -0
- package/paseo-plugin.json +6 -0
- package/server/demo.ts +195 -0
- package/server/suite-settings.ts +23 -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/demo.ts +155 -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,103 @@
|
|
|
1
|
+
import React, { type ReactNode } from "react";
|
|
2
|
+
import { StyleSheet, Text, View, type StyleProp, type ViewStyle } from "react-native";
|
|
3
|
+
import { usePluginTheme } from "../theme/provider";
|
|
4
|
+
|
|
5
|
+
export interface FormRowProps {
|
|
6
|
+
label: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
/**
|
|
10
|
+
* "stacked" (default) keeps the historical label/description above the
|
|
11
|
+
* control. "inline" puts the label + description in a left column and the
|
|
12
|
+
* control on the right of the SAME line, which is what a short control
|
|
13
|
+
* (Toggle, StatusDot, Badge, small Button) wants. Stacking a one-line
|
|
14
|
+
* control under its label doubles a settings row's height for no gain
|
|
15
|
+
* (xpufx-org/paseo#213).
|
|
16
|
+
*/
|
|
17
|
+
layout?: "stacked" | "inline";
|
|
18
|
+
style?: StyleProp<ViewStyle>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function FormRow({
|
|
22
|
+
label,
|
|
23
|
+
description,
|
|
24
|
+
children,
|
|
25
|
+
layout = "stacked",
|
|
26
|
+
style,
|
|
27
|
+
}: FormRowProps) {
|
|
28
|
+
const { colors, flair, typography } = usePluginTheme();
|
|
29
|
+
|
|
30
|
+
const labelBlock = (
|
|
31
|
+
<View style={styles.labelBlock}>
|
|
32
|
+
<Text
|
|
33
|
+
style={[
|
|
34
|
+
styles.label,
|
|
35
|
+
{
|
|
36
|
+
color: colors.foreground,
|
|
37
|
+
...typography.label,
|
|
38
|
+
textTransform: flair.headingTransform === "uppercase" ? "uppercase" : "none",
|
|
39
|
+
},
|
|
40
|
+
]}
|
|
41
|
+
>
|
|
42
|
+
{label}
|
|
43
|
+
</Text>
|
|
44
|
+
{description && (
|
|
45
|
+
<Text
|
|
46
|
+
style={[
|
|
47
|
+
styles.description,
|
|
48
|
+
{ color: colors.foregroundMuted, ...typography.caption },
|
|
49
|
+
]}
|
|
50
|
+
>
|
|
51
|
+
{description}
|
|
52
|
+
</Text>
|
|
53
|
+
)}
|
|
54
|
+
</View>
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
if (layout === "inline") {
|
|
58
|
+
return (
|
|
59
|
+
<View style={[styles.container, styles.inlineContainer, style]}>
|
|
60
|
+
{labelBlock}
|
|
61
|
+
<View style={styles.inlineContent}>{children}</View>
|
|
62
|
+
</View>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<View style={[styles.container, style]}>
|
|
68
|
+
{labelBlock}
|
|
69
|
+
<View style={styles.content}>{children}</View>
|
|
70
|
+
</View>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const styles = StyleSheet.create({
|
|
75
|
+
container: {
|
|
76
|
+
gap: 4,
|
|
77
|
+
width: "100%",
|
|
78
|
+
},
|
|
79
|
+
inlineContainer: {
|
|
80
|
+
flexDirection: "row",
|
|
81
|
+
alignItems: "center",
|
|
82
|
+
justifyContent: "space-between",
|
|
83
|
+
gap: 8,
|
|
84
|
+
},
|
|
85
|
+
labelBlock: {
|
|
86
|
+
gap: 2,
|
|
87
|
+
flexShrink: 1,
|
|
88
|
+
minWidth: 0,
|
|
89
|
+
},
|
|
90
|
+
inlineContent: {
|
|
91
|
+
flexShrink: 0,
|
|
92
|
+
alignItems: "flex-end",
|
|
93
|
+
},
|
|
94
|
+
label: {
|
|
95
|
+
fontWeight: "600",
|
|
96
|
+
},
|
|
97
|
+
description: {
|
|
98
|
+
lineHeight: 16,
|
|
99
|
+
},
|
|
100
|
+
content: {
|
|
101
|
+
marginTop: 2,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import React, { type ReactNode } from "react";
|
|
2
|
+
import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
|
|
3
|
+
import { usePluginTheme } from "../theme/provider";
|
|
4
|
+
import { resolveSpacing, type SpacingValue } from "../theme/tokens";
|
|
5
|
+
import { resolveGridColumns } from "../theme/responsive";
|
|
6
|
+
|
|
7
|
+
export interface GridProps {
|
|
8
|
+
children?: ReactNode;
|
|
9
|
+
/** Maximum column count. Default: 2. */
|
|
10
|
+
columns?: number;
|
|
11
|
+
/**
|
|
12
|
+
* Minimum width a column should keep. When set and the container width is
|
|
13
|
+
* known, the grid uses as many columns as fit (up to `columns`) and wraps
|
|
14
|
+
* down instead of collapsing to one.
|
|
15
|
+
*/
|
|
16
|
+
minColumnWidth?: number;
|
|
17
|
+
/** Gap between cells: a spacing token or raw px. Defaults to the theme gap. */
|
|
18
|
+
gap?: SpacingValue;
|
|
19
|
+
style?: StyleProp<ViewStyle>;
|
|
20
|
+
testID?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Width-aware wrapping grid. Cells keep a percentage basis driven by the
|
|
25
|
+
* effective column count, so they reflow across rows rather than stacking
|
|
26
|
+
* one-per-line or collapsing to a single column.
|
|
27
|
+
*/
|
|
28
|
+
export function Grid({ children, columns = 2, minColumnWidth, gap, style, testID }: GridProps) {
|
|
29
|
+
const { padding, isCompact, layout } = usePluginTheme();
|
|
30
|
+
const resolvedGap = resolveSpacing(gap, padding.gap);
|
|
31
|
+
const effectiveColumns = resolveGridColumns({
|
|
32
|
+
columns,
|
|
33
|
+
minColumnWidth,
|
|
34
|
+
gap: resolvedGap,
|
|
35
|
+
width: layout.width,
|
|
36
|
+
isCompact,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const cells = React.Children.toArray(children).filter(Boolean);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<View testID={testID} style={[styles.container, { gap: resolvedGap }, style]}>
|
|
43
|
+
{cells.map((child, index) => (
|
|
44
|
+
<View
|
|
45
|
+
key={index}
|
|
46
|
+
style={[styles.cell, { flexBasis: `${Math.floor(100 / effectiveColumns) - 2}%` }]}
|
|
47
|
+
>
|
|
48
|
+
{child}
|
|
49
|
+
</View>
|
|
50
|
+
))}
|
|
51
|
+
</View>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const styles = StyleSheet.create({
|
|
56
|
+
container: {
|
|
57
|
+
flexDirection: "row",
|
|
58
|
+
flexWrap: "wrap",
|
|
59
|
+
width: "100%",
|
|
60
|
+
},
|
|
61
|
+
cell: {
|
|
62
|
+
flexGrow: 1,
|
|
63
|
+
flexShrink: 1,
|
|
64
|
+
},
|
|
65
|
+
});
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useRef,
|
|
5
|
+
type ComponentType,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
type Ref,
|
|
8
|
+
} from "react";
|
|
9
|
+
import {
|
|
10
|
+
RefreshControl,
|
|
11
|
+
ScrollView as FallbackScrollView,
|
|
12
|
+
StyleSheet,
|
|
13
|
+
View,
|
|
14
|
+
type ScrollView as ScrollViewInstance,
|
|
15
|
+
type ScrollViewProps,
|
|
16
|
+
type StyleProp,
|
|
17
|
+
type ViewStyle,
|
|
18
|
+
} from "react-native";
|
|
19
|
+
import { getOptionalClientHost, selectHostScrollView, type HostScrollView } from "../host";
|
|
20
|
+
import { usePluginTheme } from "../theme/provider";
|
|
21
|
+
|
|
22
|
+
export type ModalBodySize = "default" | "large";
|
|
23
|
+
|
|
24
|
+
export interface ModalBodyProps {
|
|
25
|
+
children: ReactNode;
|
|
26
|
+
style?: StyleProp<ViewStyle>;
|
|
27
|
+
contentContainerStyle?: StyleProp<ViewStyle>;
|
|
28
|
+
header?: ReactNode;
|
|
29
|
+
headerStyle?: StyleProp<ViewStyle>;
|
|
30
|
+
/**
|
|
31
|
+
* Host dialog size preset. "default" (default) is fully fluid inside the
|
|
32
|
+
* host-allocated dialog. "large" opts into the helper's documented wide
|
|
33
|
+
* extent on desktop so data-dense modals/surfaces get room, and is ignored on
|
|
34
|
+
* mobile (the bottom sheet is already full-bleed) and inside composer
|
|
35
|
+
* popovers (the host owns that narrow viewport). Use this instead of adding a
|
|
36
|
+
* per-plugin width/minWidth literal; the host still owns the final size.
|
|
37
|
+
*/
|
|
38
|
+
size?: ModalBodySize;
|
|
39
|
+
/**
|
|
40
|
+
* Optional upper bound (px) on the content column width. On large viewports
|
|
41
|
+
* the host still allocates a wide dialog, but the body's content column stays
|
|
42
|
+
* readable instead of stretching edge-to-edge: `width: "100%"` keeps it fluid
|
|
43
|
+
* below the cap and `alignSelf: "center"` centers the capped column. Undefined
|
|
44
|
+
* (default) preserves the fully fluid body. This does not dictate the dialog
|
|
45
|
+
* frame; widen the frame with `size` when dense content genuinely needs room.
|
|
46
|
+
*/
|
|
47
|
+
maxContentWidth?: number;
|
|
48
|
+
/**
|
|
49
|
+
* "scroll" (default): header renders INSIDE the helper-owned compact/mobile
|
|
50
|
+
* ScrollView and moves with content. "pinned": header renders above that
|
|
51
|
+
* compact/mobile scroller; on desktop the host remains the scroll owner.
|
|
52
|
+
*/
|
|
53
|
+
headerMode?: "pinned" | "scroll";
|
|
54
|
+
/**
|
|
55
|
+
* Scroll ownership override. "auto" (default) renders the helper-owned
|
|
56
|
+
* scroller only on compact/mobile surfaces and defers to the host elsewhere.
|
|
57
|
+
* "always" makes the helper the scroll owner on every surface; use it when
|
|
58
|
+
* the host supplies no scroller because the content view is bounded
|
|
59
|
+
* (`ModalContent` passes it for `<Modal.Content scrollable={false}>`).
|
|
60
|
+
*/
|
|
61
|
+
scrollMode?: "auto" | "always";
|
|
62
|
+
/**
|
|
63
|
+
* When set, ModalBody logs measured layout values (viewport height,
|
|
64
|
+
* content height) via onLayout/onContentSizeChange under this tag, e.g.
|
|
65
|
+
* `[ModalBody:mcp] viewport=… content=…`. Use on-device to see which
|
|
66
|
+
* container actually scrolls instead of guessing from theory (#110).
|
|
67
|
+
*/
|
|
68
|
+
debugTag?: string;
|
|
69
|
+
extraBottomInset?: number;
|
|
70
|
+
refreshing?: boolean;
|
|
71
|
+
onRefresh?: () => void | Promise<void>;
|
|
72
|
+
stickToEnd?: boolean;
|
|
73
|
+
scrollRef?: Ref<ScrollViewInstance>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Scroll-ownership signal for `ModalBody`.
|
|
78
|
+
*
|
|
79
|
+
* - `"helper"` — the default; `ModalBody` decides from the surface
|
|
80
|
+
* (compact/mobile) and `scrollMode`.
|
|
81
|
+
* - `"host"` — an ancestor host view already provides the one scroller
|
|
82
|
+
* (0.8 composer popovers). `ModalBody` renders plain content.
|
|
83
|
+
* - `"required"` — the ancestor has NO host scroller and the content is
|
|
84
|
+
* host-sized, so `ModalBody` MUST own the scroll on every surface. Set by
|
|
85
|
+
* `registerSidebarSurface` (a plugin surface is a full host page whose body
|
|
86
|
+
* is not wrapped in a host scroller) and by `ModalContent`.
|
|
87
|
+
*
|
|
88
|
+
* Adding the `"required"` member is additive: the existing two values keep
|
|
89
|
+
* their meaning and the default stays `"helper"`.
|
|
90
|
+
*/
|
|
91
|
+
export type ModalBodyScrollOwner = "helper" | "host" | "required";
|
|
92
|
+
|
|
93
|
+
export const ModalBodyScrollOwnerContext = createContext<ModalBodyScrollOwner>("helper");
|
|
94
|
+
|
|
95
|
+
// The single documented home for the large dialog preset. Prefer
|
|
96
|
+
// `ModalBody size="large"` over adding a per-plugin width/minWidth literal.
|
|
97
|
+
const LARGE_DIALOG_MIN_WIDTH = 640;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Mobile-safe scrollable body for Paseo <Modal.Content>.
|
|
101
|
+
*
|
|
102
|
+
* Size contract: a modal takes the host-allocated dialog size and is fluid
|
|
103
|
+
* within it. `ModalBody` fills that allocation (`flex: 1`, `minHeight: 0`,
|
|
104
|
+
* `width: "100%"`) and never lets its children drive the dialog frame, so the
|
|
105
|
+
* modal stays stable while content loads, refreshes, or grows. Do not wrap it
|
|
106
|
+
* in a container that hardcodes `minWidth`/`minHeight`/`width`/`height` or that
|
|
107
|
+
* sizes itself to its children - that reintroduces content-driven resize/redraw.
|
|
108
|
+
* Shrinkable text uses `minWidth: 0` + `flexShrink: 1`, never a fixed dimension.
|
|
109
|
+
*
|
|
110
|
+
* Host behavior differs by platform: on desktop the host owns a bounded dialog
|
|
111
|
+
* and its outer scroll, so `ModalBody` renders plain content and adds no second
|
|
112
|
+
* scroll region. On mobile the host presents a bottom sheet
|
|
113
|
+
* (`AdaptiveModalSheet`) that already owns the viewport and sheet gesture, so
|
|
114
|
+
* `ModalBody` defers to a host-provided scroller (or a plain view) and only adds
|
|
115
|
+
* the safe bottom inset. Plugin code must not guess either size.
|
|
116
|
+
*
|
|
117
|
+
* Scroll ownership: ordinary compact/mobile modal content uses this helper
|
|
118
|
+
* scroller. A 0.8 composer popover is different: Paseo's MenuSurface already
|
|
119
|
+
* supplies the sole outer scroller, and registerComposerPill marks that
|
|
120
|
+
* subtree through ModalBodyScrollOwnerContext so this component renders plain
|
|
121
|
+
* content instead. Pass `scrollMode="always"` when the host content view is
|
|
122
|
+
* bounded and supplies no scroller (`ModalContent` does this), so the helper
|
|
123
|
+
* scrolls on desktop too instead of clipping the bounded dialog.
|
|
124
|
+
* "pinned" is opt-in. Desktop surfaces retain host-owned scrolling so they do
|
|
125
|
+
* not create a second scrollbar; web hosts pin the header with sticky layout.
|
|
126
|
+
* Automatically calculates responsive bottom padding so controls are not cut off
|
|
127
|
+
* by mobile home bars or virtual keyboards.
|
|
128
|
+
* Supports pull-to-refresh on mobile via `refreshing` and `onRefresh`.
|
|
129
|
+
* Uses the host ScrollView from initClientHelpers when supplied (sheet-gesture
|
|
130
|
+
* integrated on Paseo v0.8), otherwise plain React Native ScrollView.
|
|
131
|
+
* Pass `stickToEnd` for conversation-style views that track new content, or
|
|
132
|
+
* `scrollRef` for imperative scrolling.
|
|
133
|
+
* Pass `header` for a pinned navbar (e.g. <Tabs>): it renders above the
|
|
134
|
+
* scroller in a flex column, so the header stays fixed while the body scrolls.
|
|
135
|
+
* Requires the host <Modal.Content scrollable={false}> so no outer sheet
|
|
136
|
+
* scroller drags the header along.
|
|
137
|
+
*/
|
|
138
|
+
export function ModalBody({
|
|
139
|
+
children,
|
|
140
|
+
style,
|
|
141
|
+
contentContainerStyle,
|
|
142
|
+
header,
|
|
143
|
+
headerStyle,
|
|
144
|
+
headerMode = "scroll",
|
|
145
|
+
size = "default",
|
|
146
|
+
maxContentWidth,
|
|
147
|
+
scrollMode = "auto",
|
|
148
|
+
debugTag,
|
|
149
|
+
extraBottomInset = 0,
|
|
150
|
+
refreshing = false,
|
|
151
|
+
onRefresh,
|
|
152
|
+
stickToEnd = false,
|
|
153
|
+
scrollRef,
|
|
154
|
+
}: ModalBodyProps) {
|
|
155
|
+
const { isCompact, isMobile, layout, padding, colors } = usePluginTheme();
|
|
156
|
+
const scrollOwner = useContext(ModalBodyScrollOwnerContext);
|
|
157
|
+
const hostOwnsScroll = scrollOwner === "host";
|
|
158
|
+
// "required": an ancestor proved the host supplies no scroller (plugin
|
|
159
|
+
// surface) — own the scroll on every surface, including non-compact desktop.
|
|
160
|
+
const helperOwnsScroll =
|
|
161
|
+
!hostOwnsScroll &&
|
|
162
|
+
(scrollOwner === "required" || scrollMode === "always" || isCompact || isMobile);
|
|
163
|
+
// The large preset is the ONE documented place a plugin can ask for a wide
|
|
164
|
+
// dialog frame. It is a desktop-only content minimum: the mobile sheet is
|
|
165
|
+
// already full-bleed, and a composer popover viewport is host-owned and
|
|
166
|
+
// intentionally narrow, so both ignore the preset.
|
|
167
|
+
const sizeStyle =
|
|
168
|
+
size === "large" && !isMobile && !isCompact && !hostOwnsScroll
|
|
169
|
+
? styles.largeDialog
|
|
170
|
+
: undefined;
|
|
171
|
+
const ResolvedScrollView = selectHostScrollView(
|
|
172
|
+
getOptionalClientHost(),
|
|
173
|
+
FallbackScrollView as unknown as HostScrollView,
|
|
174
|
+
);
|
|
175
|
+
// Optional readability cap on the content column.
|
|
176
|
+
//
|
|
177
|
+
// `width: "100%"` keeps the column fluid up to the cap. Centering must use
|
|
178
|
+
// auto side margins, NOT `alignSelf: "center"`: on a ScrollView content
|
|
179
|
+
// container `alignSelf` stops the column stretching to the viewport, so it
|
|
180
|
+
// sizes to its children instead — a wrapping pill row then grows as wide as
|
|
181
|
+
// its pills and never wraps (reported as "pills row rigid" on x-comms).
|
|
182
|
+
// Auto margins center the column while preserving full-width stretch.
|
|
183
|
+
const columnStyle =
|
|
184
|
+
maxContentWidth !== undefined
|
|
185
|
+
? ({
|
|
186
|
+
width: "100%",
|
|
187
|
+
maxWidth: maxContentWidth,
|
|
188
|
+
marginLeft: "auto",
|
|
189
|
+
marginRight: "auto",
|
|
190
|
+
} as ViewStyle)
|
|
191
|
+
: undefined;
|
|
192
|
+
const innerRef = useRef<ScrollViewInstance>(null);
|
|
193
|
+
|
|
194
|
+
const setRefs = (node: ScrollViewInstance | null) => {
|
|
195
|
+
(innerRef as { current: ScrollViewInstance | null }).current = node;
|
|
196
|
+
if (typeof scrollRef === "function") {
|
|
197
|
+
scrollRef(node);
|
|
198
|
+
} else if (scrollRef) {
|
|
199
|
+
(scrollRef as { current: ScrollViewInstance | null }).current = node;
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// Only real mobile platforms reserve the large bottom inset that clears
|
|
204
|
+
// navigation bars; a compact desktop popover does not need it.
|
|
205
|
+
const bottomPadding = (isMobile ? 48 : 20) + extraBottomInset;
|
|
206
|
+
|
|
207
|
+
const refreshControl = onRefresh ? (
|
|
208
|
+
<RefreshControl
|
|
209
|
+
refreshing={refreshing}
|
|
210
|
+
onRefresh={onRefresh}
|
|
211
|
+
tintColor={colors.accent}
|
|
212
|
+
colors={[colors.accent]}
|
|
213
|
+
/>
|
|
214
|
+
) : undefined;
|
|
215
|
+
|
|
216
|
+
// Compact without an injected host scroller still scrolls via the plain
|
|
217
|
+
// fallback: the helper pill sets <Modal.Content scrollable={false}>, so no
|
|
218
|
+
// outer sheet scroller fights the inner one.
|
|
219
|
+
const log = debugTag
|
|
220
|
+
? (kind: string, v: Record<string, unknown>) =>
|
|
221
|
+
// eslint-disable-next-line no-console
|
|
222
|
+
console.log(`[ModalBody:${debugTag}] ${kind}`, JSON.stringify(v))
|
|
223
|
+
: undefined;
|
|
224
|
+
const scrollBody = (
|
|
225
|
+
<ResolvedScrollView
|
|
226
|
+
ref={setRefs}
|
|
227
|
+
style={[{ backgroundColor: colors.surface0 }, styles.container, sizeStyle, style]}
|
|
228
|
+
nestedScrollEnabled={true}
|
|
229
|
+
keyboardShouldPersistTaps="handled"
|
|
230
|
+
showsVerticalScrollIndicator={true}
|
|
231
|
+
refreshControl={refreshControl}
|
|
232
|
+
onLayout={
|
|
233
|
+
log
|
|
234
|
+
? (e: { nativeEvent: { layout: { height: number; width: number } } }) =>
|
|
235
|
+
log("viewport", e.nativeEvent.layout)
|
|
236
|
+
: undefined
|
|
237
|
+
}
|
|
238
|
+
onContentSizeChange={(w: number, h: number) => {
|
|
239
|
+
log?.("content", { width: w, height: h });
|
|
240
|
+
if (stickToEnd) innerRef.current?.scrollToEnd({ animated: true });
|
|
241
|
+
}}
|
|
242
|
+
contentContainerStyle={[
|
|
243
|
+
styles.content,
|
|
244
|
+
{
|
|
245
|
+
paddingHorizontal: padding.horizontal,
|
|
246
|
+
paddingTop: padding.vertical,
|
|
247
|
+
paddingBottom: bottomPadding,
|
|
248
|
+
gap: padding.gap,
|
|
249
|
+
},
|
|
250
|
+
columnStyle,
|
|
251
|
+
contentContainerStyle,
|
|
252
|
+
]}
|
|
253
|
+
>
|
|
254
|
+
{children}
|
|
255
|
+
</ResolvedScrollView>
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
const stickyHeaderStyle =
|
|
259
|
+
headerMode === "pinned" && layout.platform === "web"
|
|
260
|
+
? ({
|
|
261
|
+
position: "sticky",
|
|
262
|
+
top: 0,
|
|
263
|
+
zIndex: 10,
|
|
264
|
+
backgroundColor: colors.surface0,
|
|
265
|
+
} as unknown as ViewStyle)
|
|
266
|
+
: undefined;
|
|
267
|
+
const plainBody = (
|
|
268
|
+
<View style={[styles.plainBody, sizeStyle, style]}>
|
|
269
|
+
{header ? (
|
|
270
|
+
<View style={[styles.header, stickyHeaderStyle, headerStyle]}>{header}</View>
|
|
271
|
+
) : null}
|
|
272
|
+
<View
|
|
273
|
+
style={[
|
|
274
|
+
styles.plainContent,
|
|
275
|
+
{
|
|
276
|
+
paddingHorizontal: padding.horizontal,
|
|
277
|
+
paddingTop: padding.vertical,
|
|
278
|
+
paddingBottom: bottomPadding,
|
|
279
|
+
gap: padding.gap,
|
|
280
|
+
},
|
|
281
|
+
columnStyle,
|
|
282
|
+
contentContainerStyle,
|
|
283
|
+
]}
|
|
284
|
+
>
|
|
285
|
+
{children}
|
|
286
|
+
</View>
|
|
287
|
+
</View>
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
if (!helperOwnsScroll) return plainBody;
|
|
291
|
+
if (!header) return scrollBody;
|
|
292
|
+
|
|
293
|
+
// "scroll" keeps the header in the content flow for compact/mobile hosts.
|
|
294
|
+
// "pinned" is handled below with a helper-owned scroller on every platform.
|
|
295
|
+
if (headerMode === "scroll") {
|
|
296
|
+
return (
|
|
297
|
+
<ResolvedScrollView
|
|
298
|
+
ref={setRefs}
|
|
299
|
+
style={[{ backgroundColor: colors.surface0 }, styles.container, sizeStyle, style]}
|
|
300
|
+
nestedScrollEnabled={true}
|
|
301
|
+
keyboardShouldPersistTaps="handled"
|
|
302
|
+
showsVerticalScrollIndicator={true}
|
|
303
|
+
refreshControl={refreshControl}
|
|
304
|
+
onLayout={
|
|
305
|
+
log
|
|
306
|
+
? (e: { nativeEvent: { layout: { height: number; width: number } } }) =>
|
|
307
|
+
log("viewport", e.nativeEvent.layout)
|
|
308
|
+
: undefined
|
|
309
|
+
}
|
|
310
|
+
onContentSizeChange={(w: number, h: number) => {
|
|
311
|
+
log?.("content", { width: w, height: h });
|
|
312
|
+
if (stickToEnd) innerRef.current?.scrollToEnd({ animated: true });
|
|
313
|
+
}}
|
|
314
|
+
contentContainerStyle={[
|
|
315
|
+
styles.content,
|
|
316
|
+
{
|
|
317
|
+
paddingHorizontal: padding.horizontal,
|
|
318
|
+
paddingTop: padding.vertical,
|
|
319
|
+
paddingBottom: bottomPadding,
|
|
320
|
+
gap: padding.gap,
|
|
321
|
+
},
|
|
322
|
+
columnStyle,
|
|
323
|
+
contentContainerStyle,
|
|
324
|
+
]}
|
|
325
|
+
>
|
|
326
|
+
<View style={[styles.header, headerStyle]}>{header}</View>
|
|
327
|
+
{children}
|
|
328
|
+
</ResolvedScrollView>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return (
|
|
333
|
+
<View style={[styles.screen, sizeStyle]}>
|
|
334
|
+
<View style={[styles.header, headerStyle]}>{header}</View>
|
|
335
|
+
{scrollBody}
|
|
336
|
+
</View>
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const styles = StyleSheet.create({
|
|
341
|
+
// Desktop-only wide extent for `size="large"`. The host still owns the final
|
|
342
|
+
// dialog size; this only asks a content-sizing host for more room.
|
|
343
|
+
largeDialog: {
|
|
344
|
+
minWidth: LARGE_DIALOG_MIN_WIDTH,
|
|
345
|
+
},
|
|
346
|
+
screen: {
|
|
347
|
+
flex: 1,
|
|
348
|
+
minHeight: 0,
|
|
349
|
+
width: "100%",
|
|
350
|
+
},
|
|
351
|
+
header: {
|
|
352
|
+
width: "100%",
|
|
353
|
+
flexShrink: 0,
|
|
354
|
+
},
|
|
355
|
+
plainBody: {
|
|
356
|
+
flex: 1,
|
|
357
|
+
minHeight: 0,
|
|
358
|
+
width: "100%",
|
|
359
|
+
maxWidth: "100%",
|
|
360
|
+
},
|
|
361
|
+
plainContent: {
|
|
362
|
+
flexGrow: 1,
|
|
363
|
+
minHeight: 0,
|
|
364
|
+
width: "100%",
|
|
365
|
+
maxWidth: "100%",
|
|
366
|
+
},
|
|
367
|
+
container: {
|
|
368
|
+
flex: 1,
|
|
369
|
+
minHeight: 0,
|
|
370
|
+
width: "100%",
|
|
371
|
+
maxWidth: "100%",
|
|
372
|
+
},
|
|
373
|
+
content: {
|
|
374
|
+
flexGrow: 1,
|
|
375
|
+
width: "100%",
|
|
376
|
+
maxWidth: "100%",
|
|
377
|
+
},
|
|
378
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import React, { type ReactNode } from "react";
|
|
2
|
+
import { getClientHost } from "../host";
|
|
3
|
+
import { ModalBody, type ModalBodyProps } from "./ModalBody";
|
|
4
|
+
|
|
5
|
+
export interface ModalContentProps extends Omit<ModalBodyProps, "scrollMode"> {
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Helper-owned modal body for plugins that open their own host `<Modal>`.
|
|
11
|
+
*
|
|
12
|
+
* Use this instead of the raw host `<Modal.Content>`: it wraps the host content
|
|
13
|
+
* view AND the shared `ModalBody` contract in one element, so a plugin cannot
|
|
14
|
+
* accidentally end up content-sized.
|
|
15
|
+
*
|
|
16
|
+
* Why the raw host `Modal.Content` resizes: Paseo maps
|
|
17
|
+
* `<Modal.Content scrollable={true}>` (the host default) to a desktop card with
|
|
18
|
+
* no explicit height, so the dialog grows/shrinks with its children on every
|
|
19
|
+
* data change. This wrapper always passes `scrollable={false}`, which makes the
|
|
20
|
+
* host allocate a bounded dialog (`desktopHeight: "85%"`). Because that host
|
|
21
|
+
* content view then supplies no scroller, the wrapper also forces
|
|
22
|
+
* `ModalBody scrollMode="always"`, so the helper owns the one scroll region on
|
|
23
|
+
* every surface and the bounded dialog scrolls instead of clipping.
|
|
24
|
+
*
|
|
25
|
+
* The size contract is `ModalBody`'s: it takes the host-allocated dialog size
|
|
26
|
+
* and is fluid within it; `size?: "default" | "large"` is the only size escape
|
|
27
|
+
* hatch. Do not add per-plugin width/minWidth/height literals around it.
|
|
28
|
+
*
|
|
29
|
+
* ```tsx
|
|
30
|
+
* <Modal title="…" open={open} onOpenChange={setOpen}>
|
|
31
|
+
* <ModalContent size="default">
|
|
32
|
+
* …cards, controls, rows; content never drives the dialog frame…
|
|
33
|
+
* </ModalContent>
|
|
34
|
+
* </Modal>
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* Accepts every `ModalBody` prop (header, headerMode, refreshing, onRefresh,
|
|
38
|
+
* stickToEnd, scrollRef, debugTag, style, contentContainerStyle, …).
|
|
39
|
+
*/
|
|
40
|
+
export function ModalContent({ children, ...bodyProps }: ModalContentProps) {
|
|
41
|
+
const { Modal } = getClientHost();
|
|
42
|
+
return (
|
|
43
|
+
<Modal.Content scrollable={false}>
|
|
44
|
+
<ModalBody scrollMode="always" {...bodyProps}>
|
|
45
|
+
{children}
|
|
46
|
+
</ModalBody>
|
|
47
|
+
</Modal.Content>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React, { type ReactNode } from "react";
|
|
2
|
+
import { View, type StyleProp, type ViewStyle } from "react-native";
|
|
3
|
+
import { usePluginTheme } from "../theme/provider";
|
|
4
|
+
import { resolveSpacing, type SpacingValue } from "../theme/tokens";
|
|
5
|
+
|
|
6
|
+
export interface RowProps {
|
|
7
|
+
children?: ReactNode;
|
|
8
|
+
/** Gap between children: a spacing token or raw px. Defaults to the theme gap. */
|
|
9
|
+
gap?: SpacingValue;
|
|
10
|
+
/** Allow children to wrap onto the next line. Default: false. */
|
|
11
|
+
wrap?: boolean;
|
|
12
|
+
align?: ViewStyle["alignItems"];
|
|
13
|
+
justify?: ViewStyle["justifyContent"];
|
|
14
|
+
style?: StyleProp<ViewStyle>;
|
|
15
|
+
testID?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Horizontal flexbox row with theme-derived gap. A thin vocabulary wrapper so
|
|
20
|
+
* plugins stop hand-rolling `<View style={{ flexDirection: "row", gap }}>`.
|
|
21
|
+
*/
|
|
22
|
+
export function Row({ children, gap, wrap = false, align, justify, style, testID }: RowProps) {
|
|
23
|
+
const { padding } = usePluginTheme();
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<View
|
|
27
|
+
testID={testID}
|
|
28
|
+
style={[
|
|
29
|
+
{ flexDirection: "row", gap: resolveSpacing(gap, padding.gap) },
|
|
30
|
+
wrap && { flexWrap: "wrap" },
|
|
31
|
+
align !== undefined && { alignItems: align },
|
|
32
|
+
justify !== undefined && { justifyContent: justify },
|
|
33
|
+
style,
|
|
34
|
+
]}
|
|
35
|
+
>
|
|
36
|
+
{children}
|
|
37
|
+
</View>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React, { type ReactNode } from "react";
|
|
2
|
+
import { View, type StyleProp, type ViewStyle } from "react-native";
|
|
3
|
+
import { usePluginTheme } from "../theme/provider";
|
|
4
|
+
import { resolveSpacing, type SpacingValue } from "../theme/tokens";
|
|
5
|
+
|
|
6
|
+
export interface StackProps {
|
|
7
|
+
children?: ReactNode;
|
|
8
|
+
/** Gap between children: a spacing token or raw px. Defaults to the theme gap. */
|
|
9
|
+
gap?: SpacingValue;
|
|
10
|
+
align?: ViewStyle["alignItems"];
|
|
11
|
+
justify?: ViewStyle["justifyContent"];
|
|
12
|
+
style?: StyleProp<ViewStyle>;
|
|
13
|
+
testID?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Vertical flexbox stack with theme-derived gap. The deliberate column default,
|
|
18
|
+
* named so it composes visually alongside {@link Row}.
|
|
19
|
+
*/
|
|
20
|
+
export function Stack({ children, gap, align, justify, style, testID }: StackProps) {
|
|
21
|
+
const { padding } = usePluginTheme();
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<View
|
|
25
|
+
testID={testID}
|
|
26
|
+
style={[
|
|
27
|
+
{ flexDirection: "column", gap: resolveSpacing(gap, padding.gap) },
|
|
28
|
+
align !== undefined && { alignItems: align },
|
|
29
|
+
justify !== undefined && { justifyContent: justify },
|
|
30
|
+
style,
|
|
31
|
+
]}
|
|
32
|
+
>
|
|
33
|
+
{children}
|
|
34
|
+
</View>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Explicit vertical-stack alias; identical to {@link Stack}. */
|
|
39
|
+
export const VStack = Stack;
|