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:
@@ -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