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:
Mohamed Boudra
2026-08-03 21:40:21 +02:00
parent ab8151d33f
commit a361d1cb2f
37 changed files with 1153 additions and 208 deletions
@@ -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,
};
}
@@ -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,
},
}));
+7 -1
View File
@@ -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} />;
}