@omercnet/paseo-shared-browser 1.2.1 → 1.2.2-next.237.1
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/README.md +312 -25
- package/REMOTE_BROWSER_RESEARCH.md +395 -0
- package/STREAMING_DESIGN.md +191 -0
- package/client/browser-auto-capture-density.test.ts +102 -0
- package/client/browser-auto-capture-density.ts +30 -0
- package/client/browser-canvas-input.test.ts +198 -0
- package/client/browser-canvas-input.ts +202 -0
- package/client/browser-canvas-keyboard-web.test.ts +113 -0
- package/client/browser-canvas-keyboard-web.ts +230 -0
- package/client/browser-canvas-keyboard.test.ts +123 -0
- package/client/browser-canvas-keyboard.ts +126 -0
- package/client/browser-canvas-layout.test.ts +109 -0
- package/client/browser-canvas-layout.ts +51 -0
- package/client/browser-canvas-viewport-web.test.ts +77 -0
- package/client/browser-canvas-viewport-web.ts +63 -0
- package/client/browser-canvas-viewport.tsx +152 -0
- package/client/browser-capture-cadence.test.ts +9 -0
- package/client/browser-capture-cadence.ts +6 -0
- package/client/browser-capture-density-controls.tsx +75 -0
- package/client/browser-chrome.tsx +234 -0
- package/client/browser-client-presence.ts +184 -0
- package/client/browser-compose-controls.test.tsx +275 -0
- package/client/browser-compose-controls.tsx +174 -0
- package/client/browser-control-button.tsx +109 -0
- package/client/browser-control-dialog.tsx +71 -0
- package/client/browser-control-request.test.ts +27 -0
- package/client/browser-control-request.ts +57 -0
- package/client/browser-control-tooltip-web.ts +10 -0
- package/client/browser-dialog-body.tsx +22 -0
- package/client/browser-emulation-mode.test.ts +75 -0
- package/client/browser-emulation-mode.ts +98 -0
- package/client/browser-fill-viewport.test.ts +45 -0
- package/client/browser-fill-viewport.ts +28 -0
- package/client/browser-frame-buffer.test.ts +157 -0
- package/client/browser-frame-buffer.ts +148 -0
- package/client/browser-frame-image.tsx +62 -0
- package/client/browser-image-load.test.ts +23 -0
- package/client/browser-image-load.ts +26 -0
- package/client/browser-input-queue.test.ts +1201 -0
- package/client/browser-input-queue.ts +505 -0
- package/client/browser-menu-placement.test.ts +90 -0
- package/client/browser-menu-placement.ts +78 -0
- package/client/browser-panel-styles.ts +384 -0
- package/client/browser-quality-controls.test.ts +26 -0
- package/client/browser-quality-controls.tsx +139 -0
- package/client/browser-resolution-picker.tsx +189 -0
- package/client/browser-tabs.tsx +117 -0
- package/client/browser-toolbar-menu-web.test.ts +185 -0
- package/client/browser-toolbar-menu-web.ts +159 -0
- package/client/browser-toolbar-menu.tsx +255 -0
- package/client/browser-toolbar-submenu.tsx +93 -0
- package/client/browser-video-bytes.test.ts +44 -0
- package/client/browser-video-decoder.test.ts +473 -0
- package/client/browser-video-decoder.ts +311 -0
- package/client/browser-video-surface.tsx +21 -0
- package/client/browser-video-visibility.test.ts +45 -0
- package/client/browser-viewer-recovery.test.ts +111 -0
- package/client/browser-viewer-recovery.ts +78 -0
- package/client/browser.tsx +1642 -1569
- package/client/favorite-star.tsx +23 -0
- package/client/frame-lifecycle.ts +62 -59
- package/client/use-browser-auto-capture-density.test.ts +104 -0
- package/client/use-browser-auto-capture-density.ts +43 -0
- package/client/use-browser-canvas-input.ts +370 -0
- package/client/use-browser-capture-density.ts +87 -0
- package/client/use-browser-emulation-mode.ts +56 -0
- package/client/use-browser-fill-viewport.test.ts +108 -0
- package/client/use-browser-fill-viewport.ts +62 -0
- package/client/use-browser-frame-buffer.ts +66 -0
- package/client/use-browser-image-capture.ts +139 -0
- package/client/use-browser-scoped-mutation.test.ts +118 -0
- package/client/use-browser-scoped-mutation.ts +59 -0
- package/client/use-browser-video.test.ts +991 -0
- package/client/use-browser-video.ts +474 -0
- package/client/use-browser-viewer-recovery.ts +26 -0
- package/client/use-resolution-favorites.ts +90 -0
- package/client/web.test.ts +125 -0
- package/client/web.ts +405 -0
- package/index.client.tsx +0 -10
- package/index.server.ts +32 -0
- package/npm-shrinkwrap.json +1834 -0
- package/package.json +13 -8
- package/paseo-plugin.json +3 -1
- package/scripts/prepare-dependencies.mjs +1 -1
- package/scripts/prepare-runtime.mjs +1 -1
- package/server/agent-browser-runtime.ts +1598 -314
- package/server/browser-cursor.test.ts +64 -0
- package/server/browser-cursor.ts +69 -0
- package/server/browser-frame-context.ts +22 -0
- package/server/browser-frame-epoch.test.ts +95 -0
- package/server/browser-frame-rejection.ts +17 -0
- package/server/browser-gesture.ts +120 -0
- package/server/browser-operation-diagnostics.test.ts +79 -0
- package/server/browser-operation-diagnostics.ts +103 -0
- package/server/browser-policy.ts +1313 -151
- package/server/browser-video-input-authority.test.ts +208 -0
- package/server/browser-video-policy.test.ts +564 -0
- package/server/browser-video-socket.test.ts +368 -0
- package/server/browser.ts +151 -16
- package/server/capture-transport-policy.test.ts +105 -0
- package/server/capture-transport-policy.ts +154 -0
- package/server/capture-transport-runtime.test.ts +213 -0
- package/server/daemon-launch-chain.test.ts +70 -0
- package/server/daemon-shutdown.test.ts +289 -0
- package/server/discrete-input-freshness.test.ts +364 -0
- package/server/discrete-input-native-lifetime.test.ts +150 -0
- package/server/display-loss-recovery.test.ts +294 -0
- package/server/emulation-restoration.test.ts +262 -0
- package/server/input-cleanup.ts +23 -0
- package/server/input-generation.test.ts +31 -0
- package/server/input-generation.ts +36 -0
- package/server/jpeg-capture-demand.test.ts +76 -0
- package/server/jpeg-capture-demand.ts +98 -0
- package/server/jpeg-frame.ts +29 -0
- package/server/keyboard-input.test.ts +61 -0
- package/server/keyboard-input.ts +104 -0
- package/server/live-input-policy.test.ts +958 -0
- package/server/live-input-runtime.test.ts +732 -0
- package/server/live-input-supervisor.test.ts +147 -0
- package/server/mcp-entry.ts +44 -2
- package/server/native-video-backpressure.test.ts +228 -0
- package/server/native-video-buffer.test.ts +141 -0
- package/server/native-video-buffer.ts +114 -0
- package/server/native-video-capture.test.ts +599 -0
- package/server/native-video-capture.ts +497 -0
- package/server/native-video-extension-source.test.ts +201 -0
- package/server/native-video-extension-source.ts +364 -0
- package/server/native-video-extension.test.ts +41 -0
- package/server/native-video-extension.ts +83 -0
- package/server/native-video-packet.ts +9 -0
- package/server/native-video-settings.test.ts +245 -0
- package/server/navigation-budget.ts +8 -0
- package/server/navigation-commit.ts +125 -0
- package/server/navigation-policy.test.ts +153 -0
- package/server/process-identity.ts +58 -0
- package/server/reattach-held-input.test.ts +143 -0
- package/server/runtime-frame-reuse.test.ts +295 -0
- package/server/runtime-jpeg-demand.test.ts +200 -0
- package/server/runtime-jpeg-shutdown.test.ts +210 -0
- package/server/runtime-jpeg-source-order.test.ts +201 -0
- package/server/runtime-launch-environment.test.ts +111 -0
- package/server/runtime-navigation.test.ts +327 -0
- package/server/runtime-owner-display.test.ts +152 -0
- package/server/runtime-owner.ts +305 -31
- package/server/runtime-page-state.test.ts +249 -0
- package/server/runtime-protocol.ts +31 -1
- package/server/runtime-target-retention.test.ts +181 -0
- package/server/runtime-video-opt-in.test.ts +82 -0
- package/server/runtime-video.test.ts +444 -0
- package/server/screencast-source-time.ts +46 -0
- package/server/stale-wheel-admission.test.ts +136 -0
- package/server/supervisor-client.ts +28 -4
- package/server/supervisor-heartbeat.test.ts +80 -0
- package/server/supervisor.ts +553 -55
- package/server/video-source-recovery.test.ts +20 -0
- package/server/video-source-recovery.ts +64 -0
- package/server/viewer-capture-lifetime.ts +51 -0
- package/server/virtual-display.test.ts +184 -0
- package/server/virtual-display.ts +236 -0
- package/shared/browser-display-preferences.ts +46 -0
- package/shared/browser-video.ts +80 -0
- package/shared/browser.ts +286 -65
- package/shared/capture-density.ts +16 -0
- package/shared/capture-settings.ts +17 -0
- package/shared/device-presets.ts +90 -0
- package/shared/resolution-menu.test.ts +120 -0
- package/shared/resolution-menu.ts +72 -0
- package/shared/video-settings.test.ts +38 -0
- package/shared/video-settings.ts +34 -0
- package/shared/viewport-limits.ts +5 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/** Grouped resolution choices with independent host favorites and capture quality. */
|
|
2
|
+
import type { PluginHostProps } from "@getpaseo/plugin/client";
|
|
3
|
+
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
4
|
+
import { Pressable, StyleSheet, Text, View } from "react-native";
|
|
5
|
+
import type { BrowserDisplayPreferences } from "../shared/browser-display-preferences";
|
|
6
|
+
import type { DevicePresetId } from "../shared/device-presets";
|
|
7
|
+
import type { ResolutionGroup } from "../shared/resolution-menu";
|
|
8
|
+
import type { CaptureDensityMode } from "./browser-auto-capture-density";
|
|
9
|
+
import { BrowserQualityControls } from "./browser-quality-controls";
|
|
10
|
+
import { FavoriteStar } from "./favorite-star";
|
|
11
|
+
|
|
12
|
+
export interface BrowserResolutionPickerProps {
|
|
13
|
+
theme: PluginHostProps["theme"];
|
|
14
|
+
groups: readonly ResolutionGroup[];
|
|
15
|
+
selectedPresetId: DevicePresetId | null;
|
|
16
|
+
fillViewportResolution: { width: number; height: number } | null;
|
|
17
|
+
fillViewportSelected: boolean;
|
|
18
|
+
fillViewportDetail: string;
|
|
19
|
+
onFillViewport(): void;
|
|
20
|
+
favoritePresetIds: readonly DevicePresetId[];
|
|
21
|
+
selectDisabled: boolean;
|
|
22
|
+
favoriteDisabled: boolean;
|
|
23
|
+
density: number;
|
|
24
|
+
densityMode: CaptureDensityMode;
|
|
25
|
+
viewport: { width: number; height: number } | null;
|
|
26
|
+
onDensityChange(value: CaptureDensityMode): void;
|
|
27
|
+
captureQuality: BrowserDisplayPreferences["captureQuality"];
|
|
28
|
+
videoBitrate: BrowserDisplayPreferences["videoBitrate"];
|
|
29
|
+
videoFps: BrowserDisplayPreferences["videoFps"];
|
|
30
|
+
onVideoBitrateChange(value: BrowserDisplayPreferences["videoBitrate"]): void;
|
|
31
|
+
onVideoFpsChange(value: BrowserDisplayPreferences["videoFps"]): void;
|
|
32
|
+
onSelect(id: DevicePresetId): void;
|
|
33
|
+
onToggleFavorite(id: DevicePresetId): void;
|
|
34
|
+
onQualityChange(quality: BrowserDisplayPreferences["captureQuality"]): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const STYLES = StyleSheet.create({
|
|
38
|
+
group: { gap: 8 },
|
|
39
|
+
header: { fontSize: 11, fontWeight: "600" },
|
|
40
|
+
row: { flexDirection: "row", gap: 8, alignItems: "center" },
|
|
41
|
+
choice: {
|
|
42
|
+
minHeight: 46,
|
|
43
|
+
paddingHorizontal: 12,
|
|
44
|
+
paddingVertical: 8,
|
|
45
|
+
borderWidth: 1,
|
|
46
|
+
borderRadius: 8,
|
|
47
|
+
flexDirection: "row",
|
|
48
|
+
alignItems: "center",
|
|
49
|
+
gap: 8,
|
|
50
|
+
flex: 1,
|
|
51
|
+
},
|
|
52
|
+
title: { flex: 1, fontSize: 13, fontWeight: "600" },
|
|
53
|
+
detail: { fontSize: 11 },
|
|
54
|
+
star: {
|
|
55
|
+
width: 44,
|
|
56
|
+
minHeight: 44,
|
|
57
|
+
alignItems: "center",
|
|
58
|
+
justifyContent: "center",
|
|
59
|
+
borderRadius: 8,
|
|
60
|
+
},
|
|
61
|
+
quality: {
|
|
62
|
+
minHeight: 44,
|
|
63
|
+
paddingHorizontal: 12,
|
|
64
|
+
paddingVertical: 8,
|
|
65
|
+
borderWidth: 1,
|
|
66
|
+
borderRadius: 8,
|
|
67
|
+
flexDirection: "row",
|
|
68
|
+
alignItems: "center",
|
|
69
|
+
gap: 8,
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
/** Keeps star presses outside the resolution button; favoriting never resizes the browser. */
|
|
73
|
+
export function BrowserResolutionPicker(props: BrowserResolutionPickerProps) {
|
|
74
|
+
const { theme } = props;
|
|
75
|
+
const colors = theme.colors;
|
|
76
|
+
const selectedStyle = {
|
|
77
|
+
borderColor: colors.accent,
|
|
78
|
+
backgroundColor: colors.surface2,
|
|
79
|
+
};
|
|
80
|
+
const baseStyle = {
|
|
81
|
+
borderColor: colors.border,
|
|
82
|
+
backgroundColor: colors.surface1,
|
|
83
|
+
};
|
|
84
|
+
const fillDisabled = props.selectDisabled || !props.fillViewportResolution;
|
|
85
|
+
return (
|
|
86
|
+
<View style={{ gap: 12 }}>
|
|
87
|
+
<View style={STYLES.group}>
|
|
88
|
+
<Pressable
|
|
89
|
+
accessibilityRole="button"
|
|
90
|
+
accessibilityLabel={`Fill viewport, ${props.fillViewportDetail}`}
|
|
91
|
+
accessibilityState={{ disabled: fillDisabled, selected: props.fillViewportSelected }}
|
|
92
|
+
disabled={fillDisabled}
|
|
93
|
+
onPress={props.onFillViewport}
|
|
94
|
+
style={({ pressed }) => [
|
|
95
|
+
STYLES.quality,
|
|
96
|
+
baseStyle,
|
|
97
|
+
props.fillViewportSelected ? selectedStyle : null,
|
|
98
|
+
{ opacity: fillDisabled ? 0.45 : pressed ? 0.72 : 1 },
|
|
99
|
+
]}
|
|
100
|
+
>
|
|
101
|
+
<Icon name="Maximize" size={18} color={colors.foregroundMuted} />
|
|
102
|
+
<Text style={[STYLES.title, { color: colors.foreground }]}>Fill viewport</Text>
|
|
103
|
+
</Pressable>
|
|
104
|
+
<Text style={[STYLES.detail, { color: colors.foregroundMuted }]}>
|
|
105
|
+
{props.fillViewportDetail} · Follow the visible browser area while controlling
|
|
106
|
+
</Text>
|
|
107
|
+
</View>
|
|
108
|
+
{props.groups.map((group) => (
|
|
109
|
+
<View key={group.id} style={STYLES.group}>
|
|
110
|
+
<Text style={[STYLES.header, { color: colors.foregroundMuted }]}>{group.label}</Text>
|
|
111
|
+
{group.presets.map((preset) => {
|
|
112
|
+
const selected = preset.id === props.selectedPresetId;
|
|
113
|
+
const favorite = props.favoritePresetIds.includes(preset.id);
|
|
114
|
+
return (
|
|
115
|
+
<View key={preset.id} style={STYLES.row}>
|
|
116
|
+
<Pressable
|
|
117
|
+
accessibilityRole="button"
|
|
118
|
+
accessibilityLabel={`Emulate ${preset.label}`}
|
|
119
|
+
accessibilityState={{
|
|
120
|
+
selected,
|
|
121
|
+
disabled: props.selectDisabled,
|
|
122
|
+
}}
|
|
123
|
+
disabled={props.selectDisabled}
|
|
124
|
+
onPress={() => props.onSelect(preset.id)}
|
|
125
|
+
style={({ pressed }) => [
|
|
126
|
+
STYLES.choice,
|
|
127
|
+
baseStyle,
|
|
128
|
+
selected ? selectedStyle : null,
|
|
129
|
+
{
|
|
130
|
+
opacity: props.selectDisabled ? 0.45 : pressed ? 0.72 : 1,
|
|
131
|
+
},
|
|
132
|
+
]}
|
|
133
|
+
>
|
|
134
|
+
<Icon
|
|
135
|
+
name={preset.isMobile ? "Smartphone" : "Monitor"}
|
|
136
|
+
size={18}
|
|
137
|
+
color={selected ? colors.accent : colors.foregroundMuted}
|
|
138
|
+
/>
|
|
139
|
+
<Text style={[STYLES.title, { color: colors.foreground }]}>{preset.label}</Text>
|
|
140
|
+
<Text style={[STYLES.detail, { color: colors.foregroundMuted }]}>
|
|
141
|
+
{preset.viewport.width} × {preset.viewport.height}
|
|
142
|
+
</Text>
|
|
143
|
+
</Pressable>
|
|
144
|
+
<Pressable
|
|
145
|
+
accessibilityRole="button"
|
|
146
|
+
accessibilityLabel={`${favorite ? "Remove" : "Add"} ${preset.label} ${favorite ? "from" : "to"} favorites`}
|
|
147
|
+
accessibilityState={{
|
|
148
|
+
selected: favorite,
|
|
149
|
+
disabled: props.favoriteDisabled,
|
|
150
|
+
}}
|
|
151
|
+
disabled={props.favoriteDisabled}
|
|
152
|
+
onPress={() => props.onToggleFavorite(preset.id)}
|
|
153
|
+
style={({ pressed }) => [
|
|
154
|
+
STYLES.star,
|
|
155
|
+
{
|
|
156
|
+
backgroundColor: "transparent",
|
|
157
|
+
opacity: props.favoriteDisabled ? 0.45 : pressed ? 0.72 : 1,
|
|
158
|
+
},
|
|
159
|
+
]}
|
|
160
|
+
>
|
|
161
|
+
<FavoriteStar
|
|
162
|
+
filled={favorite}
|
|
163
|
+
size={20}
|
|
164
|
+
color={favorite ? colors.accent : colors.foregroundMuted}
|
|
165
|
+
/>
|
|
166
|
+
</Pressable>
|
|
167
|
+
</View>
|
|
168
|
+
);
|
|
169
|
+
})}
|
|
170
|
+
</View>
|
|
171
|
+
))}
|
|
172
|
+
<BrowserQualityControls
|
|
173
|
+
theme={theme}
|
|
174
|
+
density={props.density}
|
|
175
|
+
densityMode={props.densityMode}
|
|
176
|
+
viewport={props.viewport}
|
|
177
|
+
densityDisabled={props.selectDisabled}
|
|
178
|
+
onDensityChange={props.onDensityChange}
|
|
179
|
+
disabled={props.favoriteDisabled}
|
|
180
|
+
captureQuality={props.captureQuality}
|
|
181
|
+
videoBitrate={props.videoBitrate}
|
|
182
|
+
videoFps={props.videoFps}
|
|
183
|
+
onQualityChange={props.onQualityChange}
|
|
184
|
+
onVideoBitrateChange={props.onVideoBitrateChange}
|
|
185
|
+
onVideoFpsChange={props.onVideoFpsChange}
|
|
186
|
+
/>
|
|
187
|
+
</View>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/** Local tab selection. Each viewer picks a page without changing another viewer's attachment. */
|
|
2
|
+
import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { Pressable, ScrollView, Text, View } from "react-native";
|
|
5
|
+
import type { BrowserState, BrowserTab } from "../shared/browser";
|
|
6
|
+
import { ChromeIconButton } from "./browser-chrome";
|
|
7
|
+
import type { createStyles } from "./browser-panel-styles";
|
|
8
|
+
|
|
9
|
+
/** Use the live selected-tab state when available, and the tab snapshot otherwise. */
|
|
10
|
+
function controllerSummary(tab: BrowserTab, selectedState: BrowserState | null): string {
|
|
11
|
+
if (selectedState) {
|
|
12
|
+
if (selectedState.controller === "self") return "You control";
|
|
13
|
+
if (selectedState.controller === "other") {
|
|
14
|
+
return `${selectedState.controllerLabel ?? "Another viewer"} controls`;
|
|
15
|
+
}
|
|
16
|
+
return "No controller";
|
|
17
|
+
}
|
|
18
|
+
return tab.controllerLabel ? `${tab.controllerLabel} controls` : "No controller";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Show compact per-tab presence while keeping control actions on the selected tab only. */
|
|
22
|
+
export function BrowserTabs({
|
|
23
|
+
styles,
|
|
24
|
+
theme,
|
|
25
|
+
tabs,
|
|
26
|
+
selectedId,
|
|
27
|
+
selectedState,
|
|
28
|
+
statusColor,
|
|
29
|
+
statusLabel,
|
|
30
|
+
controlAction,
|
|
31
|
+
canCreate,
|
|
32
|
+
canClose,
|
|
33
|
+
onSelect,
|
|
34
|
+
onCreate,
|
|
35
|
+
onClose,
|
|
36
|
+
}: {
|
|
37
|
+
styles: ReturnType<typeof createStyles>;
|
|
38
|
+
theme: PluginWorkspacePanelProps["theme"];
|
|
39
|
+
tabs: BrowserTab[];
|
|
40
|
+
selectedId: string | null;
|
|
41
|
+
selectedState: BrowserState | null;
|
|
42
|
+
statusColor: string;
|
|
43
|
+
statusLabel: string;
|
|
44
|
+
controlAction: ReactNode;
|
|
45
|
+
canCreate: boolean;
|
|
46
|
+
canClose: boolean;
|
|
47
|
+
onSelect(id: string): void;
|
|
48
|
+
onCreate(): void;
|
|
49
|
+
onClose(): void;
|
|
50
|
+
}) {
|
|
51
|
+
return (
|
|
52
|
+
<View style={styles.tabBar}>
|
|
53
|
+
<ScrollView
|
|
54
|
+
horizontal
|
|
55
|
+
showsHorizontalScrollIndicator={false}
|
|
56
|
+
style={styles.tabList}
|
|
57
|
+
contentContainerStyle={styles.tabListContent}
|
|
58
|
+
>
|
|
59
|
+
{tabs.length === 0 ? <Text style={styles.tabMeta}>Connecting browser…</Text> : null}
|
|
60
|
+
{tabs.map((tab) => {
|
|
61
|
+
const selected = tab.id === selectedId;
|
|
62
|
+
const currentState = selected && selectedState?.tabId === tab.id ? selectedState : null;
|
|
63
|
+
const viewerCount = currentState?.viewerCount ?? tab.viewerCount;
|
|
64
|
+
const controller = controllerSummary(tab, currentState);
|
|
65
|
+
const presence = `${viewerCount} viewer${viewerCount === 1 ? "" : "s"} · ${controller}`;
|
|
66
|
+
return (
|
|
67
|
+
<View key={tab.id} style={[styles.tabItem, selected ? styles.tabItemSelected : null]}>
|
|
68
|
+
<Pressable
|
|
69
|
+
accessibilityRole="tab"
|
|
70
|
+
accessibilityLabel={`${tab.title || tab.url || "New tab"}, ${presence}${selected ? `, ${statusLabel}` : ""}`}
|
|
71
|
+
accessibilityState={{ selected }}
|
|
72
|
+
onPress={() => onSelect(tab.id)}
|
|
73
|
+
style={styles.tabSelect}
|
|
74
|
+
>
|
|
75
|
+
<Text
|
|
76
|
+
numberOfLines={1}
|
|
77
|
+
style={[styles.tabLabel, selected ? styles.tabLabelSelected : null]}
|
|
78
|
+
>
|
|
79
|
+
{tab.title || tab.url || "New tab"}
|
|
80
|
+
</Text>
|
|
81
|
+
<View style={styles.tabMetaRow}>
|
|
82
|
+
{selected ? (
|
|
83
|
+
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
|
|
84
|
+
) : null}
|
|
85
|
+
<Text numberOfLines={1} style={styles.tabMeta}>
|
|
86
|
+
{presence}
|
|
87
|
+
</Text>
|
|
88
|
+
</View>
|
|
89
|
+
</Pressable>
|
|
90
|
+
{selected ? (
|
|
91
|
+
<View style={styles.tabActions}>
|
|
92
|
+
{currentState ? controlAction : null}
|
|
93
|
+
<ChromeIconButton
|
|
94
|
+
styles={styles}
|
|
95
|
+
theme={theme}
|
|
96
|
+
label="Close selected browser tab"
|
|
97
|
+
icon="X"
|
|
98
|
+
disabled={!currentState || !canClose}
|
|
99
|
+
onPress={onClose}
|
|
100
|
+
/>
|
|
101
|
+
</View>
|
|
102
|
+
) : null}
|
|
103
|
+
</View>
|
|
104
|
+
);
|
|
105
|
+
})}
|
|
106
|
+
</ScrollView>
|
|
107
|
+
<ChromeIconButton
|
|
108
|
+
styles={styles}
|
|
109
|
+
theme={theme}
|
|
110
|
+
label="New browser tab"
|
|
111
|
+
icon="Plus"
|
|
112
|
+
disabled={!canCreate}
|
|
113
|
+
onPress={onCreate}
|
|
114
|
+
/>
|
|
115
|
+
</View>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("react-native", () => ({ Platform: { OS: "web" } }));
|
|
4
|
+
|
|
5
|
+
import { bindBrowserToolbarMenuWeb } from "./browser-toolbar-menu-web";
|
|
6
|
+
|
|
7
|
+
/** Deterministic host nodes exercise focus/ownership, without a browser or shared page. */
|
|
8
|
+
interface MenuEvent {
|
|
9
|
+
key?: string;
|
|
10
|
+
shiftKey?: boolean;
|
|
11
|
+
target: unknown;
|
|
12
|
+
preventDefault(): void;
|
|
13
|
+
stopPropagation(): void;
|
|
14
|
+
}
|
|
15
|
+
function fixture(hierarchy?: Parameters<typeof bindBrowserToolbarMenuWeb>[5]) {
|
|
16
|
+
const listeners = new Map<string, (event: MenuEvent) => void>();
|
|
17
|
+
const doc = {
|
|
18
|
+
activeElement: null as unknown,
|
|
19
|
+
addEventListener: (name: string, fn: (event: MenuEvent) => void) => listeners.set(name, fn),
|
|
20
|
+
removeEventListener: (name: string) => listeners.delete(name),
|
|
21
|
+
};
|
|
22
|
+
const item = (disabled = false) => {
|
|
23
|
+
const result = {
|
|
24
|
+
focus: vi.fn(() => {
|
|
25
|
+
doc.activeElement = result;
|
|
26
|
+
listeners.get("focusin")?.({ target: result, preventDefault() {}, stopPropagation() {} });
|
|
27
|
+
}),
|
|
28
|
+
contains: (node: unknown) => node === result,
|
|
29
|
+
getAttribute: (name: string) => (name === "aria-disabled" ? String(disabled) : null),
|
|
30
|
+
};
|
|
31
|
+
return result;
|
|
32
|
+
};
|
|
33
|
+
const items = [item(), item(true), item()];
|
|
34
|
+
const triggerButton = item();
|
|
35
|
+
const menu = {
|
|
36
|
+
ownerDocument: doc,
|
|
37
|
+
contains: (node: unknown) => items.some((item) => item === node),
|
|
38
|
+
querySelectorAll: () => items,
|
|
39
|
+
};
|
|
40
|
+
const trigger = {
|
|
41
|
+
contains: (node: unknown) => node === triggerButton,
|
|
42
|
+
querySelectorAll: () => [triggerButton],
|
|
43
|
+
};
|
|
44
|
+
const backdrop = {};
|
|
45
|
+
const overlay = { contains: (node: unknown) => node === backdrop || menu.contains(node) };
|
|
46
|
+
const close = vi.fn();
|
|
47
|
+
let restore = true;
|
|
48
|
+
const dispose = bindBrowserToolbarMenuWeb(
|
|
49
|
+
menu,
|
|
50
|
+
trigger,
|
|
51
|
+
close,
|
|
52
|
+
() => restore,
|
|
53
|
+
overlay,
|
|
54
|
+
hierarchy,
|
|
55
|
+
);
|
|
56
|
+
const event = (key: string, target = doc.activeElement) => ({
|
|
57
|
+
key,
|
|
58
|
+
target,
|
|
59
|
+
preventDefault: vi.fn(),
|
|
60
|
+
stopPropagation: vi.fn(),
|
|
61
|
+
});
|
|
62
|
+
return {
|
|
63
|
+
doc,
|
|
64
|
+
menu,
|
|
65
|
+
items,
|
|
66
|
+
triggerButton,
|
|
67
|
+
backdrop,
|
|
68
|
+
listeners,
|
|
69
|
+
close,
|
|
70
|
+
dispose,
|
|
71
|
+
event,
|
|
72
|
+
handoff: () => {
|
|
73
|
+
restore = false;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe("local toolbar menu keyboard and dismissal", () => {
|
|
79
|
+
it("Tab and Shift+Tab exit through the surrounding host controls", () => {
|
|
80
|
+
for (const backwards of [false, true]) {
|
|
81
|
+
const f = fixture();
|
|
82
|
+
const before = { focus: vi.fn(), getAttribute: () => null };
|
|
83
|
+
const after = { focus: vi.fn(), getAttribute: () => null };
|
|
84
|
+
Object.assign(f.doc, {
|
|
85
|
+
querySelectorAll: () => [before, f.triggerButton, after, ...f.items],
|
|
86
|
+
});
|
|
87
|
+
f.listeners.get("keydown")!({ ...f.event("Tab"), shiftKey: backwards });
|
|
88
|
+
expect(f.close).toHaveBeenCalledOnce();
|
|
89
|
+
f.dispose();
|
|
90
|
+
expect((backwards ? before : after).focus).toHaveBeenCalledOnce();
|
|
91
|
+
expect(f.triggerButton.focus).not.toHaveBeenCalled();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
it("focuses the first item and uses arrows/Home/End without forwarding keys", () => {
|
|
95
|
+
const f = fixture();
|
|
96
|
+
expect(f.doc.activeElement).toBe(f.items[0]);
|
|
97
|
+
for (const [key, index] of [
|
|
98
|
+
["ArrowDown", 2],
|
|
99
|
+
["ArrowDown", 0],
|
|
100
|
+
["ArrowUp", 2],
|
|
101
|
+
["Home", 0],
|
|
102
|
+
["End", 2],
|
|
103
|
+
] as const) {
|
|
104
|
+
const event = f.event(key);
|
|
105
|
+
f.listeners.get("keydown")!(event);
|
|
106
|
+
expect(f.doc.activeElement).toBe(f.items[index]);
|
|
107
|
+
expect(event.preventDefault).toHaveBeenCalledOnce();
|
|
108
|
+
expect(event.stopPropagation).toHaveBeenCalledOnce();
|
|
109
|
+
}
|
|
110
|
+
f.dispose();
|
|
111
|
+
});
|
|
112
|
+
it("Escape dismisses and returns focus even if the row is removed before cleanup", () => {
|
|
113
|
+
const f = fixture();
|
|
114
|
+
const event = f.event("Escape");
|
|
115
|
+
f.listeners.get("keydown")!(event);
|
|
116
|
+
expect(f.close).toHaveBeenCalledOnce();
|
|
117
|
+
f.doc.activeElement = {};
|
|
118
|
+
f.dispose();
|
|
119
|
+
expect(f.triggerButton.focus).toHaveBeenCalledOnce();
|
|
120
|
+
expect(f.listeners.size).toBe(0);
|
|
121
|
+
});
|
|
122
|
+
it("does not restore focus when an action hands it to another dialog or keyboard", () => {
|
|
123
|
+
const f = fixture();
|
|
124
|
+
f.handoff();
|
|
125
|
+
f.dispose();
|
|
126
|
+
expect(f.triggerButton.focus).not.toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
it("consumes local backdrop dismissal so it cannot click through to the remote page", () => {
|
|
129
|
+
const f = fixture();
|
|
130
|
+
const event = f.event("", f.backdrop);
|
|
131
|
+
f.listeners.get("pointerdown")!(event);
|
|
132
|
+
expect(f.close).toHaveBeenCalledOnce();
|
|
133
|
+
expect(event.preventDefault).toHaveBeenCalledOnce();
|
|
134
|
+
expect(event.stopPropagation).toHaveBeenCalledOnce();
|
|
135
|
+
f.dispose();
|
|
136
|
+
});
|
|
137
|
+
it("leaves deliberate outside-host focus alone", () => {
|
|
138
|
+
const f = fixture();
|
|
139
|
+
const event = f.event("", {});
|
|
140
|
+
f.listeners.get("pointerdown")!(event);
|
|
141
|
+
expect(f.close).toHaveBeenCalledOnce();
|
|
142
|
+
expect(event.preventDefault).not.toHaveBeenCalled();
|
|
143
|
+
f.dispose();
|
|
144
|
+
expect(f.triggerButton.focus).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("root-owned submenu Tab exit", () => {
|
|
149
|
+
it("Tab and Shift+Tab from a descendant skip the whole popup", () => {
|
|
150
|
+
for (const backwards of [false, true]) {
|
|
151
|
+
const f = fixture();
|
|
152
|
+
const childMenu = {};
|
|
153
|
+
const child = {
|
|
154
|
+
focus: vi.fn(),
|
|
155
|
+
getAttribute: () => null,
|
|
156
|
+
closest: () => childMenu,
|
|
157
|
+
};
|
|
158
|
+
const originalContains = f.menu.contains;
|
|
159
|
+
f.menu.contains = (node) => node === child || originalContains(node);
|
|
160
|
+
const before = { focus: vi.fn(), getAttribute: () => null };
|
|
161
|
+
const after = { focus: vi.fn(), getAttribute: () => null };
|
|
162
|
+
Object.assign(f.doc, {
|
|
163
|
+
querySelectorAll: () => [before, f.triggerButton, after, ...f.items, child],
|
|
164
|
+
});
|
|
165
|
+
f.doc.activeElement = child;
|
|
166
|
+
const event = { ...f.event("Tab", child), shiftKey: backwards };
|
|
167
|
+
f.listeners.get("keydown")!(event);
|
|
168
|
+
expect(f.close).toHaveBeenCalledOnce();
|
|
169
|
+
f.dispose();
|
|
170
|
+
expect((backwards ? before : after).focus).toHaveBeenCalledOnce();
|
|
171
|
+
expect(child.focus).not.toHaveBeenCalled();
|
|
172
|
+
expect(f.triggerButton.focus).not.toHaveBeenCalled();
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
it("nested binders leave Tab to the root without publishing a second close", () => {
|
|
176
|
+
const closeRoot = vi.fn();
|
|
177
|
+
const f = fixture({ nested: true, closeRoot });
|
|
178
|
+
const event = f.event("Tab");
|
|
179
|
+
f.listeners.get("keydown")!(event);
|
|
180
|
+
expect(f.close).not.toHaveBeenCalled();
|
|
181
|
+
expect(closeRoot).not.toHaveBeenCalled();
|
|
182
|
+
expect(event.preventDefault).not.toHaveBeenCalled();
|
|
183
|
+
f.dispose();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/** Web focus and dismissal for local menus; never forwards menu keys to Chromium. */
|
|
2
|
+
import { Platform } from "react-native";
|
|
3
|
+
|
|
4
|
+
interface FocusNode {
|
|
5
|
+
focus(): void;
|
|
6
|
+
contains(node: unknown): boolean;
|
|
7
|
+
getAttribute(name: string): string | null;
|
|
8
|
+
getClientRects?(): ArrayLike<unknown>;
|
|
9
|
+
closest?(selector: string): unknown;
|
|
10
|
+
}
|
|
11
|
+
interface MenuNode extends FocusNode {
|
|
12
|
+
querySelectorAll(selector: string): ArrayLike<FocusNode>;
|
|
13
|
+
ownerDocument: {
|
|
14
|
+
activeElement: unknown;
|
|
15
|
+
querySelectorAll?(selector: string): ArrayLike<FocusNode>;
|
|
16
|
+
addEventListener(type: string, listener: (event: MenuEvent) => void, capture: boolean): void;
|
|
17
|
+
removeEventListener(type: string, listener: (event: MenuEvent) => void, capture: boolean): void;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
interface MenuEvent {
|
|
21
|
+
key?: string;
|
|
22
|
+
shiftKey?: boolean;
|
|
23
|
+
target: unknown;
|
|
24
|
+
preventDefault(): void;
|
|
25
|
+
stopPropagation(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Restore trigger focus only if the menu still owns it and the action did not hand it away. */
|
|
29
|
+
export function bindBrowserToolbarMenuWeb(
|
|
30
|
+
node: unknown,
|
|
31
|
+
trigger: unknown,
|
|
32
|
+
close: () => void,
|
|
33
|
+
restoreFocus: () => boolean,
|
|
34
|
+
overlay?: unknown,
|
|
35
|
+
hierarchy?: { openSubmenu?(): void; back?(): void; closeRoot?(): void; nested?: boolean },
|
|
36
|
+
): () => void {
|
|
37
|
+
if (Platform.OS !== "web" || !node || typeof node !== "object" || !("ownerDocument" in node)) {
|
|
38
|
+
return () => {};
|
|
39
|
+
}
|
|
40
|
+
const menu = node as MenuNode;
|
|
41
|
+
const anchor = trigger as MenuNode | null;
|
|
42
|
+
const enabledItems = () =>
|
|
43
|
+
Array.from(menu.querySelectorAll('[role="menuitem"]')).filter(
|
|
44
|
+
(item) =>
|
|
45
|
+
item.getAttribute("aria-disabled") !== "true" &&
|
|
46
|
+
item.getAttribute("disabled") === null &&
|
|
47
|
+
(!item.closest || item.closest('[role="menu"]') === menu),
|
|
48
|
+
);
|
|
49
|
+
enabledItems()[0]?.focus();
|
|
50
|
+
let focusOwned = menu.contains(menu.ownerDocument.activeElement);
|
|
51
|
+
let returnTarget: FocusNode | null = null;
|
|
52
|
+
const focusin = (event: MenuEvent) => {
|
|
53
|
+
focusOwned = menu.contains(event.target);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const keydown = (event: MenuEvent) => {
|
|
57
|
+
if (!menu.contains(event.target)) return;
|
|
58
|
+
const target = event.target as FocusNode;
|
|
59
|
+
// Only the root owns Tab exit. Its host-order calculation excludes the
|
|
60
|
+
// entire popup, and its cleanup performs the one final focus restoration.
|
|
61
|
+
if (event.key === "Tab" && hierarchy?.nested) return;
|
|
62
|
+
if (event.key !== "Tab" && target.closest && target.closest('[role="menu"]') !== menu) return;
|
|
63
|
+
if (
|
|
64
|
+
event.key === "ArrowRight" &&
|
|
65
|
+
target.getAttribute("aria-expanded") !== null &&
|
|
66
|
+
hierarchy?.openSubmenu
|
|
67
|
+
) {
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
event.stopPropagation();
|
|
70
|
+
hierarchy.openSubmenu();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if ((event.key === "ArrowLeft" || event.key === "Escape") && hierarchy?.back) {
|
|
74
|
+
event.preventDefault();
|
|
75
|
+
event.stopPropagation();
|
|
76
|
+
hierarchy.back();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (event.key === "Escape" || event.key === "Tab") {
|
|
80
|
+
if (event.key === "Tab") {
|
|
81
|
+
// Menu Tab leaves the popup rather than walking its command rows or
|
|
82
|
+
// trapping focus. Follow the trigger's surrounding host tab order.
|
|
83
|
+
const triggerButton = Array.from(
|
|
84
|
+
anchor?.querySelectorAll('[role="button"], [role="menuitem"]') ?? [],
|
|
85
|
+
)[0];
|
|
86
|
+
const focusable = Array.from(
|
|
87
|
+
menu.ownerDocument.querySelectorAll?.(
|
|
88
|
+
'button, [role="button"], input:not([type="hidden"]), select, textarea, a[href], [tabindex]',
|
|
89
|
+
) ?? [],
|
|
90
|
+
).filter(
|
|
91
|
+
(item) =>
|
|
92
|
+
!menu.contains(item) &&
|
|
93
|
+
item.getAttribute("disabled") === null &&
|
|
94
|
+
item.getAttribute("aria-disabled") !== "true" &&
|
|
95
|
+
Number(item.getAttribute("tabindex") ?? 0) >= 0 &&
|
|
96
|
+
!item.closest?.('[aria-hidden="true"], [inert]') &&
|
|
97
|
+
(!item.getClientRects || item.getClientRects().length > 0),
|
|
98
|
+
);
|
|
99
|
+
const index = triggerButton ? focusable.indexOf(triggerButton) : -1;
|
|
100
|
+
if (index >= 0)
|
|
101
|
+
returnTarget = focusable[index + (event.shiftKey ? -1 : 1)] ?? triggerButton ?? null;
|
|
102
|
+
}
|
|
103
|
+
event.preventDefault();
|
|
104
|
+
event.stopPropagation();
|
|
105
|
+
if (event.key === "Tab" && hierarchy?.closeRoot) hierarchy.closeRoot();
|
|
106
|
+
else close();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key ?? "")) return;
|
|
110
|
+
event.preventDefault();
|
|
111
|
+
event.stopPropagation();
|
|
112
|
+
const items = enabledItems();
|
|
113
|
+
if (!items.length) return;
|
|
114
|
+
const index = items.indexOf(menu.ownerDocument.activeElement as FocusNode);
|
|
115
|
+
let next: number;
|
|
116
|
+
if (event.key === "Home") next = 0;
|
|
117
|
+
else if (event.key === "End") next = items.length - 1;
|
|
118
|
+
else if (event.key === "ArrowDown") next = (index + 1) % items.length;
|
|
119
|
+
else next = index <= 0 ? items.length - 1 : index - 1;
|
|
120
|
+
items[next]?.focus();
|
|
121
|
+
};
|
|
122
|
+
const outside = (event: MenuEvent) => {
|
|
123
|
+
if (hierarchy?.nested || menu.contains(event.target) || anchor?.contains(event.target)) return;
|
|
124
|
+
// Consume backdrop presses before re-enabling the image below it. A local
|
|
125
|
+
// dismiss must never become a newly controlled remote mouse-down.
|
|
126
|
+
if (overlay && (overlay as FocusNode).contains(event.target)) {
|
|
127
|
+
event.preventDefault();
|
|
128
|
+
event.stopPropagation();
|
|
129
|
+
} else {
|
|
130
|
+
// The user deliberately focused another host surface; leave it alone.
|
|
131
|
+
focusOwned = false;
|
|
132
|
+
}
|
|
133
|
+
close();
|
|
134
|
+
};
|
|
135
|
+
menu.ownerDocument.addEventListener("keydown", keydown, true);
|
|
136
|
+
menu.ownerDocument.addEventListener("pointerdown", outside, true);
|
|
137
|
+
menu.ownerDocument.addEventListener("focusin", focusin, true);
|
|
138
|
+
return () => {
|
|
139
|
+
menu.ownerDocument.removeEventListener("keydown", keydown, true);
|
|
140
|
+
menu.ownerDocument.removeEventListener("pointerdown", outside, true);
|
|
141
|
+
menu.ownerDocument.removeEventListener("focusin", focusin, true);
|
|
142
|
+
// Native React unmount can remove the focused row before passive cleanup.
|
|
143
|
+
// Remember ownership rather than consulting only the now-detached node.
|
|
144
|
+
if (restoreFocus() && focusOwned) {
|
|
145
|
+
(
|
|
146
|
+
returnTarget ??
|
|
147
|
+
Array.from(anchor?.querySelectorAll('[role="button"], [role="menuitem"]') ?? [])[0]
|
|
148
|
+
)?.focus();
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Restore an in-menu trigger after compact Back, without touching native hosts. */
|
|
154
|
+
export function focusBrowserMenuTrigger(node: unknown): void {
|
|
155
|
+
if (Platform.OS !== "web" || !node || typeof node !== "object" || !("querySelectorAll" in node))
|
|
156
|
+
return;
|
|
157
|
+
const trigger = node as MenuNode;
|
|
158
|
+
Array.from(trigger.querySelectorAll('[role="menuitem"], [role="button"]'))[0]?.focus();
|
|
159
|
+
}
|