fix(app): polish workspace navigation surfaces
Use mobile action sheets and long-press file menus while preserving provider context across sheet portals. Keep sidebar and file-pane glyphs on shared visual rails, avoid scrollbar layout shifts, and hide keyboard affordances where shortcuts are unavailable.
This commit is contained in:
@@ -674,6 +674,7 @@ export function AdaptiveModalSheet({
|
||||
return (
|
||||
<IsolatedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
contextBridge={null}
|
||||
snapPoints={resolvedSnapPoints}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
type ProjectPickerOption,
|
||||
} from "@/components/project-picker-options";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { pickDirectory } from "@/desktop/pick-directory";
|
||||
@@ -287,6 +288,9 @@ function FlowRow({ option, active }: { option: FlowRowOption; active: boolean })
|
||||
}
|
||||
|
||||
function FlowHint({ keys, action }: { keys: string[]; action: string }) {
|
||||
const shortcutsAvailable = useKeyboardShortcutsAvailable();
|
||||
if (!shortcutsAvailable) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.footerHint}>
|
||||
<Shortcut keys={keys} textStyle={styles.footerKeyText} />
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { ChevronDown, Eye, EyeOff, RotateCw } from "lucide-react-native";
|
||||
import { MaterialFileIcon } from "@/components/material-file-icon";
|
||||
@@ -20,9 +20,14 @@ import {
|
||||
TreeChevron,
|
||||
TreeIndentGuides,
|
||||
treeRowPaddingLeft,
|
||||
WORKSPACE_FILE_ROW_TRAILING_PADDING,
|
||||
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||
} from "@/components/tree-primitives";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import {
|
||||
useOverlayFlatListScrollbar,
|
||||
type OverlayFlatListScrollbar,
|
||||
} from "@/components/ui/overlay-scrollbar/use-overlay-flat-list-scrollbar";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type {
|
||||
AgentFileExplorerState,
|
||||
@@ -174,12 +179,7 @@ function TreeRowItem({
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger
|
||||
enabledOnMobile={false}
|
||||
onPress={handlePress}
|
||||
style={pressableStyle}
|
||||
testID={testID}
|
||||
>
|
||||
<ContextMenuTrigger onPress={handlePress} style={pressableStyle} testID={testID}>
|
||||
<TreeIndentGuides depth={depth} />
|
||||
<View ref={dragSourceRef} style={styles.entryInfo}>
|
||||
<View style={styles.entryIcon}>
|
||||
@@ -226,6 +226,7 @@ export function FileExplorerPane({
|
||||
onAddToChat,
|
||||
}: FileExplorerPaneProps) {
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
|
||||
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||
const workspaceStateKey = useMemo(
|
||||
@@ -278,6 +279,7 @@ export function FileExplorerPane({
|
||||
);
|
||||
|
||||
const treeListRef = useRef<FlatList<ExplorerTreeRow>>(null);
|
||||
const scrollbar = useOverlayFlatListScrollbar(treeListRef, { enabled: !isCompact });
|
||||
|
||||
const hasInitializedRef = useRef(false);
|
||||
|
||||
@@ -518,6 +520,7 @@ export function FileExplorerPane({
|
||||
currentSortLabel={currentSortLabel}
|
||||
isRefreshFetching={isRefreshFetching}
|
||||
treeListRef={treeListRef}
|
||||
scrollbar={scrollbar}
|
||||
renderTreeRow={renderTreeRow}
|
||||
handleSortCycle={handleSortCycle}
|
||||
handleToggleHiddenFiles={handleToggleHiddenFiles}
|
||||
@@ -539,6 +542,7 @@ interface FileExplorerPaneContentProps {
|
||||
currentSortLabel: string;
|
||||
isRefreshFetching: boolean;
|
||||
treeListRef: RefObject<FlatList<ExplorerTreeRow> | null>;
|
||||
scrollbar: OverlayFlatListScrollbar;
|
||||
renderTreeRow: (info: ListRenderItemInfo<ExplorerTreeRow>) => ReactElement;
|
||||
handleSortCycle: () => void;
|
||||
handleToggleHiddenFiles: () => void;
|
||||
@@ -560,6 +564,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
currentSortLabel,
|
||||
isRefreshFetching,
|
||||
treeListRef,
|
||||
scrollbar,
|
||||
renderTreeRow,
|
||||
handleSortCycle,
|
||||
handleToggleHiddenFiles,
|
||||
@@ -682,12 +687,17 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
keyExtractor={treeRowKeyExtractor}
|
||||
testID="file-explorer-tree-scroll"
|
||||
contentContainerStyle={styles.entriesContent}
|
||||
showsVerticalScrollIndicator
|
||||
onLayout={scrollbar.onLayout}
|
||||
onScroll={scrollbar.onScroll}
|
||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!scrollbar.enabled}
|
||||
initialNumToRender={24}
|
||||
maxToRenderPerBatch={40}
|
||||
windowSize={12}
|
||||
/>
|
||||
)}
|
||||
{treeRows.length > 0 ? scrollbar.overlay : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1055,7 +1065,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||
paddingRight: theme.spacing[3],
|
||||
paddingRight: WORKSPACE_FILE_ROW_TRAILING_PADDING,
|
||||
},
|
||||
entryRowActive: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
|
||||
@@ -988,11 +988,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
// Rendered inside the scroll's listContent (paddingHorizontal spacing[2]), so the
|
||||
// title lands at spacing[2] left to align with project icons, and the trailing
|
||||
// pill sits flush with the list edge on the right.
|
||||
// Rendered inside the scroll's listContent (paddingHorizontal spacing[2]). The title
|
||||
// lands at spacing[2] left to align with project icons. Settings2's painted path stops
|
||||
// inside its 14px SVG, so 4px aligns the ink rather than the SVG box to the row rail.
|
||||
paddingLeft: theme.spacing[2],
|
||||
paddingRight: 0,
|
||||
paddingRight: 4,
|
||||
paddingTop: theme.spacing[1],
|
||||
paddingBottom: theme.spacing[1],
|
||||
},
|
||||
@@ -1030,7 +1030,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
pointerEvents: "box-none",
|
||||
},
|
||||
mobileCloseButton: {
|
||||
marginRight: theme.spacing[4],
|
||||
// The 16px X paints farther inside its 32px hit target than the 14px Settings2 glyph.
|
||||
// This optical inset puts their painted right edges on the same sidebar rail.
|
||||
marginRight: theme.spacing[2] + 1.5,
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: "center",
|
||||
|
||||
@@ -495,7 +495,7 @@ function ProjectKebabMenu({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenu compactMode="sheet">
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={projectKebabStyle}
|
||||
@@ -505,7 +505,7 @@ function ProjectKebabMenu({
|
||||
>
|
||||
{renderKebabTriggerIcon}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={220}>
|
||||
<DropdownMenuContent align="end" width={220} sheetTitle={t("sidebar.project.actions.menu")}>
|
||||
<ProjectMenuItems
|
||||
surface="dropdown"
|
||||
projectViewKey={projectViewKey}
|
||||
@@ -2541,6 +2541,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
flexShrink: 0,
|
||||
// MoreVertical paints only around the center of its 14px SVG. Keep the 24px controls,
|
||||
// but pull their painted edge through the unused view-box space onto the row rail.
|
||||
marginRight: -6,
|
||||
},
|
||||
projectKebabButton: {
|
||||
width: 24,
|
||||
|
||||
@@ -15,9 +15,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useAppDiagnosticStore } from "@/diagnostics/store";
|
||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -78,11 +77,10 @@ function HostVersionHint({ host }: { host: HostProfile }) {
|
||||
|
||||
export function SidebarHelpMenu() {
|
||||
const { t } = useTranslation();
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const shortcutsAvailable = useKeyboardShortcutsAvailable();
|
||||
const openAppDiagnostic = useAppDiagnosticStore((state) => state.open);
|
||||
const setShortcutsDialogOpen = useKeyboardShortcutsStore((state) => state.setShortcutsDialogOpen);
|
||||
const [open, setOpen] = useState(false);
|
||||
const showKeyboardShortcuts = !isNative && !isCompactLayout;
|
||||
const version = formatVersionWithPrefix(resolveAppVersion());
|
||||
const hosts = useHosts();
|
||||
|
||||
@@ -128,7 +126,7 @@ export function SidebarHelpMenu() {
|
||||
</Tooltip>
|
||||
<DropdownMenuContent side="top" align="end" offset={8} width={280} testID="sidebar-help-menu">
|
||||
<DropdownMenuLabel>{t("sidebar.help.sectionHelp")}</DropdownMenuLabel>
|
||||
{showKeyboardShortcuts ? (
|
||||
{shortcutsAvailable ? (
|
||||
<DropdownMenuItem
|
||||
testID="sidebar-help-shortcuts"
|
||||
leading={shortcutsLeadingIcon}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Archive, CircleCheck, Copy, MoreVertical, Pencil, Pin, PinOff } from "lucide-react-native";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { getForgePresentation, normalizeForge } from "@/git/forge";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
@@ -110,7 +110,7 @@ function SidebarWorkspaceMenuItems({
|
||||
}: SidebarWorkspaceMenuItemsProps & { surface: MenuSurface }): ReactNode {
|
||||
const { t } = useTranslation();
|
||||
const archiveTrailing = useMemo(
|
||||
() => (archiveShortcutKeys && !isNative ? <Shortcut chord={archiveShortcutKeys} /> : null),
|
||||
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
|
||||
[archiveShortcutKeys],
|
||||
);
|
||||
|
||||
@@ -205,7 +205,7 @@ export function SidebarWorkspaceMenu({
|
||||
}: SidebarWorkspaceMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenu compactMode="sheet">
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={triggerStyle}
|
||||
@@ -215,7 +215,7 @@ export function SidebarWorkspaceMenu({
|
||||
>
|
||||
{renderTriggerIcon}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={260}>
|
||||
<DropdownMenuContent align="end" width={260} sheetTitle={t("sidebar.workspace.actions.menu")}>
|
||||
<SidebarWorkspaceMenuItems
|
||||
surface="dropdown"
|
||||
workspaceKey={workspaceKey}
|
||||
@@ -339,6 +339,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: 2,
|
||||
borderRadius: 4,
|
||||
marginLeft: 2,
|
||||
// MoreVertical paints only around the center of its SVG. Keep the padded hit box, but
|
||||
// pull the painted dots through that unused view-box space onto the trailing-content rail.
|
||||
marginRight: -7,
|
||||
},
|
||||
triggerHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
|
||||
@@ -31,9 +31,6 @@ export { selectWorkspaceScriptSummary, type WorkspaceScriptSummary } from "./scr
|
||||
*/
|
||||
const META_ICON_SIZE = 12;
|
||||
|
||||
/** Carried over from the host pill: a host can carry an arbitrary custom label. */
|
||||
const MAX_HOST_LABEL_WIDTH = 96;
|
||||
|
||||
const ThemedServer = withUnistyles(Server);
|
||||
const ThemedExternalLink = withUnistyles(ExternalLink);
|
||||
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
||||
@@ -283,16 +280,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
minWidth: 0,
|
||||
flexShrink: 0,
|
||||
},
|
||||
// A host label is arbitrary user text and the only item on the line that can be long, so
|
||||
// it is the one that gets capped and truncated. The change request, CI and service items
|
||||
// are bounded by their own content and must never be the thing that gives way.
|
||||
// The host is the only unbounded item, so it consumes the remaining width and gives way
|
||||
// before the bounded change request, CI, and service items.
|
||||
hostItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
minWidth: 0,
|
||||
flexShrink: 0,
|
||||
maxWidth: MAX_HOST_LABEL_WIDTH,
|
||||
flexShrink: 1,
|
||||
},
|
||||
itemPressed: {
|
||||
opacity: 0.82,
|
||||
|
||||
@@ -62,7 +62,7 @@ export function SidebarWorkspaceTrailingContent({
|
||||
function WorkspaceTimestamp({ enteredAt }: { enteredAt: Date }) {
|
||||
const label = useCompactTimeAgo(enteredAt);
|
||||
return (
|
||||
<Text style={styles.timestamp} numberOfLines={1}>
|
||||
<Text style={styles.timestamp} numberOfLines={1} testID="sidebar-workspace-timestamp">
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -114,6 +114,7 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
|
||||
{children}
|
||||
<IsolatedBottomSheetModal
|
||||
ref={bottomSheetRef}
|
||||
contextBridge={null}
|
||||
snapPoints={snapPoints}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
|
||||
@@ -12,6 +12,12 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
// drift apart.
|
||||
export const TREE_INDENT_PER_LEVEL = 16;
|
||||
export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1.5];
|
||||
/**
|
||||
* Trailing glyph rail shared with the explorer X and Changes options chevron.
|
||||
* The extra 2px is optical: text ink ends inside its layout box, while the
|
||||
* header icons' strokes extend to theirs.
|
||||
*/
|
||||
export const WORKSPACE_FILE_ROW_TRAILING_PADDING = SPACING[4] + 2;
|
||||
|
||||
/** Left padding for a tree row at `depth`. Shared by folder rows and file headers
|
||||
* in the Changes tree so their indentation can't drift apart. */
|
||||
@@ -52,7 +58,13 @@ export function TreeIndentGuides({ depth }: { depth: number }) {
|
||||
/** Rotating disclosure chevron for a directory row (points right; rotates down when expanded). */
|
||||
export function TreeChevron({ expanded }: { expanded: boolean }) {
|
||||
return (
|
||||
<View style={expanded ? [styles.chevron, styles.chevronExpanded] : styles.chevron}>
|
||||
<View
|
||||
style={
|
||||
expanded
|
||||
? [styles.chevron, styles.chevronExpanded]
|
||||
: [styles.chevron, styles.chevronCollapsed]
|
||||
}
|
||||
>
|
||||
<ThemedChevronRight size={16} uniProps={foregroundMutedIconColorMapping} />
|
||||
</View>
|
||||
);
|
||||
@@ -73,7 +85,13 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
// Lucide's right-pointing path occupies the middle of its square view box.
|
||||
// Move the painted edge—not the 16px layout slot—onto the tree's glyph rail.
|
||||
chevronCollapsed: {
|
||||
left: -4,
|
||||
},
|
||||
chevronExpanded: {
|
||||
left: -2,
|
||||
transform: [{ rotate: "90deg" }],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -994,6 +994,7 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
|
||||
return (
|
||||
<IsolatedBottomSheetModal
|
||||
ref={props.bottomSheetRef}
|
||||
contextBridge={null}
|
||||
snapPoints={props.snapPoints}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { useCallback, type PropsWithChildren, type ReactElement, type Ref } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
type ComponentProps,
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import {
|
||||
Platform,
|
||||
Pressable,
|
||||
@@ -31,13 +37,23 @@ import {
|
||||
* docs/menus.md. Only the way it opens is different from `dropdown-menu.tsx`.
|
||||
*/
|
||||
|
||||
export { MenuRoot as ContextMenu };
|
||||
export { MenuItem as ContextMenuItem };
|
||||
export { MenuLabel as ContextMenuLabel };
|
||||
export { MenuSeparator as ContextMenuSeparator };
|
||||
export { MenuHint as ContextMenuHint };
|
||||
export type { ActionStatus } from "@/components/ui/menu";
|
||||
|
||||
/**
|
||||
* Context menus use the mobile menu convention by default: long press opens a bottom sheet on a
|
||||
* compact layout, while right click opens an anchored popover on a wide layout.
|
||||
*/
|
||||
export function ContextMenu({
|
||||
compactMode = "sheet",
|
||||
...props
|
||||
}: ComponentProps<typeof MenuRoot>): ReactElement {
|
||||
return <MenuRoot {...props} compactMode={compactMode} />;
|
||||
}
|
||||
|
||||
export function ContextMenuContent(props: MenuSurfaceProps): ReactElement | null {
|
||||
return <MenuSurface {...props} />;
|
||||
}
|
||||
@@ -85,7 +101,7 @@ export function ContextMenuTrigger({
|
||||
disabled,
|
||||
style,
|
||||
enabled = true,
|
||||
enabledOnMobile = false,
|
||||
enabledOnMobile = true,
|
||||
enabledOnWeb = true,
|
||||
longPressDelayMs,
|
||||
triggerRef,
|
||||
|
||||
@@ -1,34 +1,75 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React, { act } from "react";
|
||||
import React, { act, createContext, useContext, useSyncExternalStore } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* A faithful stand-in for `@gorhom/portal`: it renders *nothing* where it sits and hands its
|
||||
* element to a host somewhere else in the tree, which is what makes the real thing lose context.
|
||||
* A passthrough fake would keep context and quietly pass every test in this file — which is
|
||||
* exactly what happened, and why the menu sheet shipped broken.
|
||||
*/
|
||||
const portalStore = vi.hoisted(() => {
|
||||
const listeners = new Set<() => void>();
|
||||
let node: React.ReactNode = null;
|
||||
return {
|
||||
put(next: React.ReactNode) {
|
||||
node = next;
|
||||
for (const listener of listeners) listener();
|
||||
},
|
||||
read: () => node,
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
reset() {
|
||||
node = null;
|
||||
listeners.clear();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@gorhom/portal", () => ({
|
||||
Portal: ({ children, hostName }: { children?: React.ReactNode; hostName?: string }) =>
|
||||
React.createElement("div", { "data-portal-host": hostName }, children),
|
||||
PortalHost: ({ name }: { name?: string }) => React.createElement("div", { "data-host": name }),
|
||||
Portal: ({ children }: { children?: React.ReactNode }) => {
|
||||
React.useEffect(() => {
|
||||
portalStore.put(children);
|
||||
}, [children]);
|
||||
return null;
|
||||
},
|
||||
PortalHost: () => React.createElement("div", { "data-host": true }),
|
||||
}));
|
||||
|
||||
vi.mock("@gorhom/bottom-sheet", () => ({
|
||||
BottomSheetModalProvider: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("div", { "data-bottom-sheet-provider": true }, children),
|
||||
BottomSheetModal: React.forwardRef(
|
||||
(
|
||||
{
|
||||
children,
|
||||
stackBehavior,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
stackBehavior?: string;
|
||||
},
|
||||
_ref,
|
||||
) => React.createElement("div", { "data-stack-behavior": stackBehavior }, children),
|
||||
),
|
||||
}));
|
||||
vi.mock("@gorhom/bottom-sheet", async () => {
|
||||
const { Portal } = await import("@gorhom/portal");
|
||||
return {
|
||||
BottomSheetModalProvider: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("div", { "data-bottom-sheet-provider": true }, children),
|
||||
BottomSheetModal: React.forwardRef(
|
||||
({ children, stackBehavior }: { children?: React.ReactNode; stackBehavior?: string }, _ref) =>
|
||||
React.createElement(
|
||||
Portal,
|
||||
null,
|
||||
React.createElement("div", { "data-stack-behavior": stackBehavior }, children),
|
||||
),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
import { IsolatedBottomSheetModal } from ".";
|
||||
import { IsolatedBottomSheetModal, type ContextBridge } from ".";
|
||||
|
||||
/** Renders whatever the portal is currently holding, from its own place in the tree. */
|
||||
function PortalHostProbe() {
|
||||
const node = useSyncExternalStore(portalStore.subscribe, portalStore.read, portalStore.read);
|
||||
return <div data-portal-host>{node}</div>;
|
||||
}
|
||||
|
||||
const LabelContext = createContext("unbridged");
|
||||
|
||||
function Label() {
|
||||
return <div data-label={useContext(LabelContext)} />;
|
||||
}
|
||||
|
||||
describe("IsolatedBottomSheetModal presentation", () => {
|
||||
let container: HTMLDivElement;
|
||||
@@ -43,34 +84,94 @@ describe("IsolatedBottomSheetModal presentation", () => {
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
portalStore.reset();
|
||||
});
|
||||
|
||||
function stackBehaviors(): (string | null)[] {
|
||||
return Array.from(container.querySelectorAll("[data-stack-behavior]")).map((node) =>
|
||||
node.getAttribute("data-stack-behavior"),
|
||||
);
|
||||
}
|
||||
|
||||
it("defaults sibling top-level sheets to push instead of replacing by React ancestry", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<>
|
||||
<IsolatedBottomSheetModal>Settings</IsolatedBottomSheetModal>
|
||||
<IsolatedBottomSheetModal>Diagnostic</IsolatedBottomSheetModal>
|
||||
<IsolatedBottomSheetModal contextBridge={null}>Settings</IsolatedBottomSheetModal>
|
||||
<PortalHostProbe />
|
||||
</>,
|
||||
);
|
||||
});
|
||||
expect(stackBehaviors()).toEqual(["push"]);
|
||||
|
||||
expect(
|
||||
Array.from(container.querySelectorAll("[data-stack-behavior]")).map((node) =>
|
||||
node.getAttribute("data-stack-behavior"),
|
||||
),
|
||||
).toEqual(["push", "push"]);
|
||||
act(() => {
|
||||
root.render(
|
||||
<>
|
||||
<IsolatedBottomSheetModal contextBridge={null}>Diagnostic</IsolatedBottomSheetModal>
|
||||
<PortalHostProbe />
|
||||
</>,
|
||||
);
|
||||
});
|
||||
expect(stackBehaviors()).toEqual(["push"]);
|
||||
});
|
||||
|
||||
it("only replaces when the callsite asks for replacement", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<IsolatedBottomSheetModal presentation="replace">Selector</IsolatedBottomSheetModal>,
|
||||
<>
|
||||
<IsolatedBottomSheetModal presentation="replace" contextBridge={null}>
|
||||
Selector
|
||||
</IsolatedBottomSheetModal>
|
||||
<PortalHostProbe />
|
||||
</>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelector("[data-stack-behavior]")?.getAttribute("data-stack-behavior"),
|
||||
).toBe("replace");
|
||||
expect(stackBehaviors()).toEqual(["replace"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IsolatedBottomSheetModal context bridging", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
portalStore.reset();
|
||||
});
|
||||
|
||||
function renderSheet(contextBridge: ContextBridge | null) {
|
||||
act(() => {
|
||||
root.render(
|
||||
<>
|
||||
<LabelContext.Provider value="from callsite">
|
||||
<IsolatedBottomSheetModal contextBridge={contextBridge}>
|
||||
<Label />
|
||||
</IsolatedBottomSheetModal>
|
||||
</LabelContext.Provider>
|
||||
{/* Outside the provider, standing in for the app-root portal host. */}
|
||||
<PortalHostProbe />
|
||||
</>,
|
||||
);
|
||||
});
|
||||
return container.querySelector("[data-label]")?.getAttribute("data-label");
|
||||
}
|
||||
|
||||
it("loses callsite context without a bridge", () => {
|
||||
expect(renderSheet(null)).toBe("unbridged");
|
||||
});
|
||||
|
||||
it("carries callsite context across the portal when bridged", () => {
|
||||
const bridge: ContextBridge = (children) => (
|
||||
<LabelContext.Provider value="from callsite">{children}</LabelContext.Provider>
|
||||
);
|
||||
expect(renderSheet(bridge)).toBe("from callsite");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import React from "react";
|
||||
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { ElementRef } from "react";
|
||||
import type { ElementRef, ReactNode } from "react";
|
||||
import {
|
||||
type BottomSheetController,
|
||||
createBottomSheetVisibilityTracker,
|
||||
@@ -12,11 +12,42 @@ import {
|
||||
|
||||
type GorhomBottomSheetModalMethods = ElementRef<typeof GorhomBottomSheetModal>;
|
||||
|
||||
/**
|
||||
* Re-establishes React context on the far side of the portal.
|
||||
*
|
||||
* `@gorhom/portal` is not a React portal. It stores the element and a host elsewhere in the tree
|
||||
* renders it, so context resolves at the *host's* position: everything provided between
|
||||
* `PortalProvider` (see `app/_layout.tsx`) and this sheet is invisible to its content. React
|
||||
* cannot copy contexts reflectively, so the only way across is to render the providers again —
|
||||
* with values captured out here, where they are still readable.
|
||||
*
|
||||
* Write it as a closure over what you already have:
|
||||
*
|
||||
* ```tsx
|
||||
* const contextBridge = useCallback<ContextBridge>(
|
||||
* (content) => <ThingContext.Provider value={thing}>{content}</ThingContext.Provider>,
|
||||
* [thing],
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export type ContextBridge = (children: ReactNode) => ReactNode;
|
||||
|
||||
type IsolatedBottomSheetModalProps = Omit<
|
||||
BottomSheetModalProps,
|
||||
"enableDismissOnClose" | "stackBehavior"
|
||||
"enableDismissOnClose" | "stackBehavior" | "children"
|
||||
> & {
|
||||
/**
|
||||
* Nodes only. Gorhom also accepts a render function, but nothing here uses it and a bridge
|
||||
* would have to reach around it.
|
||||
*/
|
||||
children?: ReactNode;
|
||||
presentation?: "push" | "replace";
|
||||
/**
|
||||
* Required, and `null` is a real answer: a sheet that needs nothing from its call site should
|
||||
* have to say so. The failure it prevents is invisible until someone adds a `useContext` deep
|
||||
* inside the sheet and it throws on device only.
|
||||
*/
|
||||
contextBridge: ContextBridge | null;
|
||||
};
|
||||
|
||||
export type IsolatedBottomSheetModalRef = GorhomBottomSheetModalMethods;
|
||||
@@ -25,7 +56,7 @@ export const IsolatedBottomSheetModal = forwardRef<
|
||||
IsolatedBottomSheetModalRef,
|
||||
IsolatedBottomSheetModalProps
|
||||
>(function IsolatedBottomSheetModal(props, ref) {
|
||||
const { children, presentation = "push", ...bottomSheetProps } = props;
|
||||
const { children, presentation = "push", contextBridge, ...bottomSheetProps } = props;
|
||||
const modal = (
|
||||
<GorhomBottomSheetModal
|
||||
{...bottomSheetProps}
|
||||
@@ -33,7 +64,7 @@ export const IsolatedBottomSheetModal = forwardRef<
|
||||
enableDismissOnClose
|
||||
stackBehavior={presentation}
|
||||
>
|
||||
{children}
|
||||
{contextBridge ? contextBridge(children) : children}
|
||||
</GorhomBottomSheetModal>
|
||||
);
|
||||
|
||||
|
||||
@@ -17,9 +17,10 @@ import { ChevronLeft } from "lucide-react-native";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
type ContextBridge,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useMenuContext, MenuDepthProvider } from "./menu-context";
|
||||
import { useMenuContext, MenuContextProvider, MenuDepthProvider } from "./menu-context";
|
||||
import { currentPageId, isSubPageOpen } from "./menu-navigation";
|
||||
import { AnchoredSurface, MenuOverlay } from "./menu-overlay";
|
||||
import type { Alignment, Placement } from "./menu-anchor";
|
||||
@@ -327,40 +328,51 @@ function MenuSheetSurface({
|
||||
const openPage = openPageId ? pages.find((page) => page.id === openPageId) : null;
|
||||
const depth = menu.path.length;
|
||||
|
||||
// The sheet's content is teleported out of this subtree, so both menu contexts have to be
|
||||
// rebuilt on the other side. Providing them around the modal instead would put them on the
|
||||
// wrong side of the portal and every item inside would throw. See `ContextBridge`.
|
||||
const contextBridge = useCallback<ContextBridge>(
|
||||
(content) => (
|
||||
<MenuContextProvider value={menu}>
|
||||
<MenuSurfaceContext.Provider value={surfaceValue}>{content}</MenuSurfaceContext.Provider>
|
||||
</MenuContextProvider>
|
||||
),
|
||||
[menu, surfaceValue],
|
||||
);
|
||||
|
||||
return (
|
||||
<MenuSurfaceContext.Provider value={surfaceValue}>
|
||||
<ThemedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
// Content-sized rather than fixed snap points: a pushed page is rarely the same height
|
||||
// as the page it replaced, and a fixed sheet would either clip it or leave dead space.
|
||||
enableDynamicSizing
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleSheetDismiss}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
<ThemedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
contextBridge={contextBridge}
|
||||
// Content-sized rather than fixed snap points: a pushed page is rarely the same height
|
||||
// as the page it replaced, and a fixed sheet would either clip it or leave dead space.
|
||||
enableDynamicSizing
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleSheetDismiss}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
>
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={styles.sheetScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID={testID ? `${testID}-content` : undefined}
|
||||
>
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={styles.sheetScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID={testID ? `${testID}-content` : undefined}
|
||||
>
|
||||
{openPage ? (
|
||||
<>
|
||||
<MenuSheetHeader title={openPage.title} onBack={menu.goBack} />
|
||||
<MenuDepthProvider value={depth}>{openPage.content}</MenuDepthProvider>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{sheetTitle ? <MenuSheetHeader title={sheetTitle} onBack={null} /> : null}
|
||||
<MenuDepthProvider value={0}>{children}</MenuDepthProvider>
|
||||
</>
|
||||
)}
|
||||
</BottomSheetScrollView>
|
||||
</ThemedBottomSheetModal>
|
||||
</MenuSurfaceContext.Provider>
|
||||
{openPage ? (
|
||||
<>
|
||||
<MenuSheetHeader title={openPage.title} onBack={menu.goBack} />
|
||||
<MenuDepthProvider value={depth}>{openPage.content}</MenuDepthProvider>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{sheetTitle ? <MenuSheetHeader title={sheetTitle} onBack={null} /> : null}
|
||||
<MenuDepthProvider value={0}>{children}</MenuDepthProvider>
|
||||
</>
|
||||
)}
|
||||
</BottomSheetScrollView>
|
||||
</ThemedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeOverlayScrollbarGeometry, scrollOffsetFromThumbDrag } from "./geometry";
|
||||
|
||||
describe("computeOverlayScrollbarGeometry", () => {
|
||||
it("hides the thumb when the content fits", () => {
|
||||
expect(
|
||||
computeOverlayScrollbarGeometry({ viewportSize: 500, contentSize: 500, offset: 0 }),
|
||||
).toEqual({
|
||||
isVisible: false,
|
||||
maxScrollOffset: 0,
|
||||
thumbSize: 0,
|
||||
thumbOffset: 0,
|
||||
maxThumbOffset: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("places a proportional thumb for overflowing content", () => {
|
||||
expect(
|
||||
computeOverlayScrollbarGeometry({ viewportSize: 500, contentSize: 2000, offset: 375 }),
|
||||
).toEqual({
|
||||
isVisible: true,
|
||||
maxScrollOffset: 1500,
|
||||
thumbSize: 125,
|
||||
thumbOffset: 93.75,
|
||||
maxThumbOffset: 375,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("scrollOffsetFromThumbDrag", () => {
|
||||
it("maps thumb travel to the scroll range and clamps it", () => {
|
||||
expect(
|
||||
scrollOffsetFromThumbDrag({
|
||||
startOffset: 250,
|
||||
dragDelta: 50,
|
||||
maxScrollOffset: 1000,
|
||||
maxThumbOffset: 200,
|
||||
}),
|
||||
).toBe(500);
|
||||
expect(
|
||||
scrollOffsetFromThumbDrag({
|
||||
startOffset: 900,
|
||||
dragDelta: 1000,
|
||||
maxScrollOffset: 1000,
|
||||
maxThumbOffset: 200,
|
||||
}),
|
||||
).toBe(1000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
const DEFAULT_MIN_THUMB_SIZE = 36;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export interface OverlayScrollbarGeometryInput {
|
||||
viewportSize: number;
|
||||
contentSize: number;
|
||||
offset: number;
|
||||
minThumbSize?: number;
|
||||
}
|
||||
|
||||
export interface OverlayScrollbarGeometry {
|
||||
isVisible: boolean;
|
||||
maxScrollOffset: number;
|
||||
thumbSize: number;
|
||||
thumbOffset: number;
|
||||
maxThumbOffset: number;
|
||||
}
|
||||
|
||||
export function computeOverlayScrollbarGeometry(
|
||||
input: OverlayScrollbarGeometryInput,
|
||||
): OverlayScrollbarGeometry {
|
||||
const viewportSize = Number.isFinite(input.viewportSize) ? Math.max(0, input.viewportSize) : 0;
|
||||
const contentSize = Number.isFinite(input.contentSize) ? Math.max(0, input.contentSize) : 0;
|
||||
const minThumbSize = Number.isFinite(input.minThumbSize)
|
||||
? Math.max(0, input.minThumbSize ?? DEFAULT_MIN_THUMB_SIZE)
|
||||
: DEFAULT_MIN_THUMB_SIZE;
|
||||
const maxScrollOffset = Math.max(0, contentSize - viewportSize);
|
||||
|
||||
if (maxScrollOffset <= 0 || viewportSize <= 0 || contentSize <= 0) {
|
||||
return {
|
||||
isVisible: false,
|
||||
maxScrollOffset: 0,
|
||||
thumbSize: 0,
|
||||
thumbOffset: 0,
|
||||
maxThumbOffset: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const thumbSize = clamp((viewportSize * viewportSize) / contentSize, minThumbSize, viewportSize);
|
||||
const maxThumbOffset = Math.max(0, viewportSize - thumbSize);
|
||||
const thumbOffset = (clamp(input.offset, 0, maxScrollOffset) / maxScrollOffset) * maxThumbOffset;
|
||||
|
||||
return { isVisible: true, maxScrollOffset, thumbSize, thumbOffset, maxThumbOffset };
|
||||
}
|
||||
|
||||
export function scrollOffsetFromThumbDrag(input: {
|
||||
startOffset: number;
|
||||
dragDelta: number;
|
||||
maxScrollOffset: number;
|
||||
maxThumbOffset: number;
|
||||
}): number {
|
||||
if (input.maxScrollOffset <= 0 || input.maxThumbOffset <= 0) {
|
||||
return clamp(input.startOffset, 0, Math.max(0, input.maxScrollOffset));
|
||||
}
|
||||
return clamp(
|
||||
input.startOffset + input.dragDelta * (input.maxScrollOffset / input.maxThumbOffset),
|
||||
0,
|
||||
input.maxScrollOffset,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ReactNode, RefObject } from "react";
|
||||
import type {
|
||||
FlatList,
|
||||
LayoutChangeEvent,
|
||||
NativeScrollEvent,
|
||||
NativeSyntheticEvent,
|
||||
} from "react-native";
|
||||
|
||||
export interface OverlayFlatListScrollbar {
|
||||
enabled: boolean;
|
||||
onContentSizeChange: (width: number, height: number) => void;
|
||||
onLayout: (event: LayoutChangeEvent) => void;
|
||||
onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
|
||||
overlay: ReactNode;
|
||||
}
|
||||
|
||||
const ignoreContentSize = () => {};
|
||||
const ignoreLayout = (_event: LayoutChangeEvent) => {};
|
||||
const ignoreScroll = (_event: NativeSyntheticEvent<NativeScrollEvent>) => {};
|
||||
|
||||
export function useOverlayFlatListScrollbar<ItemT>(
|
||||
_listRef: RefObject<FlatList<ItemT> | null>,
|
||||
_options: { enabled: boolean },
|
||||
): OverlayFlatListScrollbar {
|
||||
return {
|
||||
enabled: false,
|
||||
onContentSizeChange: ignoreContentSize,
|
||||
onLayout: ignoreLayout,
|
||||
onScroll: ignoreScroll,
|
||||
overlay: null,
|
||||
};
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import {
|
||||
FlatList,
|
||||
View,
|
||||
type LayoutChangeEvent,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { computeOverlayScrollbarGeometry, scrollOffsetFromThumbDrag } from "./geometry";
|
||||
import type { OverlayFlatListScrollbar } from "./use-overlay-flat-list-scrollbar";
|
||||
|
||||
const THUMB_WIDTH = WEB_SCROLLBAR_SIZE_PX - 4;
|
||||
const THUMB_OPACITY = 0.62;
|
||||
const GRAB_WIDTH = 12;
|
||||
|
||||
interface ScrollbarMetrics {
|
||||
offset: number;
|
||||
viewportSize: number;
|
||||
contentSize: number;
|
||||
}
|
||||
|
||||
interface PointerLikeEvent {
|
||||
clientY?: number;
|
||||
nativeEvent?: { clientY?: number; preventDefault?: () => void };
|
||||
preventDefault?: () => void;
|
||||
stopPropagation?: () => void;
|
||||
}
|
||||
|
||||
function readClientY(event: PointerLikeEvent): number | null {
|
||||
const value = event.nativeEvent?.clientY ?? event.clientY;
|
||||
return typeof value === "number" ? value : null;
|
||||
}
|
||||
|
||||
function OverlayScrollbar({
|
||||
listRef,
|
||||
metrics,
|
||||
}: {
|
||||
listRef: RefObject<FlatList<unknown> | null>;
|
||||
metrics: ScrollbarMetrics;
|
||||
}) {
|
||||
const geometry = useMemo(() => computeOverlayScrollbarGeometry(metrics), [metrics]);
|
||||
const geometryRef = useRef(geometry);
|
||||
const dragStartClientYRef = useRef(0);
|
||||
const dragStartOffsetRef = useRef(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
geometryRef.current = geometry;
|
||||
}, [geometry]);
|
||||
|
||||
const scrollFromDrag = useCallback(
|
||||
(dragDelta: number) => {
|
||||
const current = geometryRef.current;
|
||||
listRef.current?.scrollToOffset({
|
||||
offset: scrollOffsetFromThumbDrag({
|
||||
startOffset: dragStartOffsetRef.current,
|
||||
dragDelta,
|
||||
maxScrollOffset: current.maxScrollOffset,
|
||||
maxThumbOffset: current.maxThumbOffset,
|
||||
}),
|
||||
animated: false,
|
||||
});
|
||||
},
|
||||
[listRef],
|
||||
);
|
||||
|
||||
const startDrag = useCallback(
|
||||
(event: PointerLikeEvent) => {
|
||||
const clientY = readClientY(event);
|
||||
if (clientY === null) return;
|
||||
event.preventDefault?.();
|
||||
event.stopPropagation?.();
|
||||
event.nativeEvent?.preventDefault?.();
|
||||
dragStartClientYRef.current = clientY;
|
||||
dragStartOffsetRef.current = metrics.offset;
|
||||
setIsDragging(true);
|
||||
},
|
||||
[metrics.offset],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
scrollFromDrag(event.clientY - dragStartClientYRef.current);
|
||||
};
|
||||
const stopDragging = () => setIsDragging(false);
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", stopDragging);
|
||||
window.addEventListener("pointercancel", stopDragging);
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", stopDragging);
|
||||
window.removeEventListener("pointercancel", stopDragging);
|
||||
};
|
||||
}, [isDragging, scrollFromDrag]);
|
||||
|
||||
const thumbRegionStyle = useMemo(
|
||||
() => [
|
||||
styles.thumbRegion,
|
||||
inlineUnistylesStyle({
|
||||
height: geometry.thumbSize,
|
||||
transform: [{ translateY: geometry.thumbOffset }],
|
||||
cursor: isDragging ? "grabbing" : "grab",
|
||||
} as unknown as ViewStyle),
|
||||
],
|
||||
[geometry.thumbOffset, geometry.thumbSize, isDragging],
|
||||
);
|
||||
|
||||
if (!geometry.isVisible) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.overlay} pointerEvents="box-none" testID="workspace-overlay-scrollbar">
|
||||
<View
|
||||
style={thumbRegionStyle}
|
||||
{...({ onPointerDown: startDrag } as object)}
|
||||
testID="workspace-overlay-scrollbar-grab"
|
||||
>
|
||||
<View
|
||||
style={styles.thumb}
|
||||
pointerEvents="none"
|
||||
testID="workspace-overlay-scrollbar-thumb"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function useOverlayFlatListScrollbar<ItemT>(
|
||||
listRef: RefObject<FlatList<ItemT> | null>,
|
||||
options: { enabled: boolean },
|
||||
): OverlayFlatListScrollbar {
|
||||
const [metrics, setMetrics] = useState<ScrollbarMetrics>({
|
||||
offset: 0,
|
||||
viewportSize: 0,
|
||||
contentSize: 0,
|
||||
});
|
||||
|
||||
const onScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
setMetrics((previous) => ({
|
||||
...previous,
|
||||
offset: Math.max(0, event.nativeEvent.contentOffset.y),
|
||||
viewportSize: Math.max(0, event.nativeEvent.layoutMeasurement.height),
|
||||
contentSize: Math.max(0, event.nativeEvent.contentSize.height),
|
||||
}));
|
||||
}, []);
|
||||
const onLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
setMetrics((previous) => ({
|
||||
...previous,
|
||||
viewportSize: Math.max(0, event.nativeEvent.layout.height),
|
||||
}));
|
||||
}, []);
|
||||
const onContentSizeChange = useCallback((_width: number, height: number) => {
|
||||
setMetrics((previous) => ({ ...previous, contentSize: Math.max(0, height) }));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
enabled: options.enabled,
|
||||
onContentSizeChange,
|
||||
onLayout,
|
||||
onScroll,
|
||||
overlay: options.enabled ? (
|
||||
<OverlayScrollbar
|
||||
listRef={listRef as RefObject<FlatList<unknown> | null>}
|
||||
metrics={metrics}
|
||||
/>
|
||||
) : null,
|
||||
};
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
overlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: WEB_SCROLLBAR_SIZE_PX,
|
||||
zIndex: 10,
|
||||
},
|
||||
thumbRegion: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: -(GRAB_WIDTH - WEB_SCROLLBAR_SIZE_PX) / 2,
|
||||
width: GRAB_WIDTH,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
touchAction: "none",
|
||||
userSelect: "none",
|
||||
},
|
||||
thumb: {
|
||||
width: THUMB_WIDTH,
|
||||
height: "100%",
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.scrollbarHandle,
|
||||
opacity: THUMB_OPACITY,
|
||||
},
|
||||
}));
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useMemo, type ReactElement } from "react";
|
||||
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
|
||||
@@ -14,7 +15,8 @@ export function Shortcut({
|
||||
chord?: ShortcutKey[][];
|
||||
style?: StyleProp<ViewStyle>;
|
||||
textStyle?: StyleProp<TextStyle>;
|
||||
}): ReactElement {
|
||||
}): ReactElement | null {
|
||||
const shortcutsAvailable = useKeyboardShortcutsAvailable();
|
||||
const displayChord = chord ?? (keys ? [keys] : []);
|
||||
const shortcutOs = getShortcutOs();
|
||||
const singleCombo = displayChord[0];
|
||||
@@ -23,6 +25,10 @@ export function Shortcut({
|
||||
const textCombinedStyle = useMemo(() => [styles.text, textStyle], [textStyle]);
|
||||
const sequenceStyle = useMemo(() => [styles.sequence, style], [style]);
|
||||
|
||||
if (!shortcutsAvailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!singleCombo) {
|
||||
return <View style={style} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user