chore(lint): hoist inline arrays in app (jsx-no-new-array-as-prop)

This commit is contained in:
Mohamed Boudra
2026-04-23 23:31:39 +07:00
parent de1a7ef74b
commit d1932c9060
16 changed files with 330 additions and 222 deletions

View File

@@ -101,14 +101,9 @@ describe("CalloutCard", () => {
it("renders one action when one is provided", () => {
const onPress = vi.fn();
const actions = [{ label: "Undo", onPress }];
act(() => {
root?.render(
<CalloutCard
description="Saved."
actions={[{ label: "Undo", onPress }]}
testID="callout"
/>,
);
root?.render(<CalloutCard description="Saved." actions={actions} testID="callout" />);
});
const button = container?.querySelector(
@@ -119,15 +114,16 @@ describe("CalloutCard", () => {
});
it("renders up to two actions", () => {
const actions: React.ComponentProps<typeof CalloutCard>["actions"] = [
{ label: "What's new", onPress: vi.fn() },
{ label: "Install & restart", onPress: vi.fn(), variant: "primary" },
];
act(() => {
root?.render(
<CalloutCard
title="Update available"
description="v1 ready."
actions={[
{ label: "What's new", onPress: vi.fn() },
{ label: "Install & restart", onPress: vi.fn(), variant: "primary" },
]}
actions={actions}
testID="callout"
/>,
);

View File

@@ -1,5 +1,5 @@
import { X } from "lucide-react-native";
import type { ReactNode } from "react";
import { useMemo, type ReactNode } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -43,7 +43,10 @@ export function CalloutCard({
const hasHeader = title != null || icon != null;
const hasDescription = description != null && description !== "";
const containerStyle = [styles.container, variant === "error" ? styles.containerError : null];
const containerStyle = useMemo(
() => [styles.container, variant === "error" ? styles.containerError : null],
[variant],
);
return (
<View style={containerStyle} testID={testID} accessibilityRole="alert">
@@ -106,6 +109,10 @@ export function CalloutCard({
function CalloutActionButton({ action, testID }: { action: CalloutAction; testID?: string }) {
const isPrimary = action.variant === "primary";
const labelStyle = useMemo(
() => [styles.actionLabel, isPrimary ? styles.actionLabelPrimary : styles.actionLabelSecondary],
[isPrimary],
);
return (
<Pressable
onPress={action.onPress}
@@ -119,13 +126,7 @@ function CalloutActionButton({ action, testID }: { action: CalloutAction; testID
action.disabled ? styles.actionButtonDisabled : null,
]}
>
<Text
style={[
styles.actionLabel,
isPrimary ? styles.actionLabelPrimary : styles.actionLabelSecondary,
]}
numberOfLines={1}
>
<Text style={labelStyle} numberOfLines={1}>
{action.label}
</Text>
</Pressable>

View File

@@ -16,7 +16,9 @@ import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/a
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
const IS_WEB = platformIsWeb;
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
const EMPTY_COMBOBOX_OPTIONS: ReadonlyArray<ComboboxOption> = [];
import { getProviderIcon } from "@/components/provider-icons";
import {
buildModelRows,
@@ -356,13 +358,18 @@ function ProviderSearchInput({
}
}, [autoFocus]);
const inputStyle = useMemo(
() => [styles.providerSearchInput, platformIsWeb && { outlineStyle: "none" }],
[],
);
return (
<View style={styles.providerSearchContainer}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<InputComponent
ref={inputRef as any}
// @ts-expect-error - outlineStyle is web-only
style={[styles.providerSearchInput, platformIsWeb && { outlineStyle: "none" }]}
style={inputStyle}
placeholder="Search models..."
placeholderTextColor={theme.colors.foregroundMuted}
value={value}
@@ -657,7 +664,7 @@ export function CombinedModelSelector({
)}
</Pressable>
<Combobox
options={[]}
options={EMPTY_COMBOBOX_OPTIONS as ComboboxOption[]}
value=""
onSelect={() => {}}
open={isOpen}

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { useEffect, useMemo, useRef } from "react";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -36,15 +36,17 @@ export function DownloadToast() {
};
}, [activeDownload, dismissDownload]);
const containerStyle = useMemo(
() => [styles.container, { bottom: theme.spacing[4] + insets.bottom }],
[theme.spacing, insets.bottom],
);
if (!activeDownload) {
return null;
}
return (
<View
style={[styles.container, { bottom: theme.spacing[4] + insets.bottom }]}
pointerEvents="box-none"
>
<View style={containerStyle} pointerEvents="box-none">
<View style={styles.toast}>
{activeDownload.status === "downloading" ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
@@ -68,12 +70,7 @@ export function DownloadToast() {
</Text>
{activeDownload.status === "downloading" && activeDownload.progress && (
<View style={styles.progressBar}>
<View
style={[
styles.progressFill,
{ width: `${Math.round(activeDownload.progress.percent * 100)}%` },
]}
/>
<ProgressFill percent={activeDownload.progress.percent} />
</View>
)}
</View>
@@ -91,6 +88,12 @@ export function DownloadToast() {
);
}
function ProgressFill({ percent }: { percent: number }) {
const width: `${number}%` = `${Math.round(percent * 100)}%`;
const fillStyle = useMemo(() => [styles.progressFill, { width }], [width]);
return <View style={fillStyle} />;
}
const styles = StyleSheet.create((theme) => ({
container: {
position: "absolute",

View File

@@ -1,5 +1,5 @@
import { RefreshControl } from "react-native";
import { useCallback, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import DraggableFlatList, {
NestableDraggableFlatList,
type RenderItemParams,
@@ -37,7 +37,15 @@ export function DraggableList<T>({
// Pass the ref directly to DraggableFlatList - it handles gesture
// coordination internally for nestable lists.
const simultaneousHandlers = simultaneousGestureRef ? [simultaneousGestureRef] : undefined;
const simultaneousHandlers = useMemo(
() => (simultaneousGestureRef ? [simultaneousGestureRef] : undefined),
[simultaneousGestureRef],
);
const refreshColors = useMemo(
() => [theme.colors.foregroundMuted],
[theme.colors.foregroundMuted],
);
const handleRenderItem = useCallback(
({ item, drag, isActive, getIndex }: RenderItemParams<T>) => {
@@ -106,7 +114,7 @@ export function DraggableList<T>({
refreshing={refreshing ?? false}
onRefresh={onRefresh}
tintColor={theme.colors.foregroundMuted}
colors={[theme.colors.foregroundMuted]}
colors={refreshColors}
/>
) : undefined
}

View File

@@ -100,10 +100,15 @@ const CodeLine = React.memo(function CodeLine({
colorMap,
baseColor,
}: CodeLineProps) {
const gutterStyle = useMemo(() => [codeLineStyles.gutter, { width: gutterWidth }], [gutterWidth]);
const gutterTextStyle = useMemo(
() => [codeLineStyles.gutterText, { color: baseColor }],
[baseColor],
);
return (
<View style={codeLineStyles.line}>
<View style={[codeLineStyles.gutter, { width: gutterWidth }]}>
<Text style={[codeLineStyles.gutterText, { color: baseColor }]}>{String(lineNumber)}</Text>
<View style={gutterStyle}>
<Text style={gutterTextStyle}>{String(lineNumber)}</Text>
</View>
<Text selectable style={codeLineStyles.lineText}>
{tokens.map((token, index) => (

View File

@@ -1,3 +1,4 @@
import { useMemo } from "react";
import { ActivityIndicator, Pressable, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, MicOff, Square } from "lucide-react-native";
@@ -23,6 +24,19 @@ export function RealtimeVoiceOverlay({
}: RealtimeVoiceOverlayProps) {
const { theme } = useUnistyles();
const { volume, isSpeaking } = useVoiceTelemetry();
const muteButtonStyle = useMemo(
() => [
styles.actionButton,
styles.muteButton,
isMuted ? styles.muteButtonMuted : undefined,
isSwitching ? styles.buttonDisabled : undefined,
],
[isMuted, isSwitching],
);
const stopButtonStyle = useMemo(
() => [styles.actionButton, styles.stopButton, isSwitching ? styles.buttonDisabled : undefined],
[isSwitching],
);
return (
<View style={styles.container}>
<View style={styles.meterContainer}>
@@ -40,12 +54,7 @@ export function RealtimeVoiceOverlay({
disabled={isSwitching}
accessibilityRole="button"
accessibilityLabel={isMuted ? "Unmute realtime voice" : "Mute realtime voice"}
style={[
styles.actionButton,
styles.muteButton,
isMuted ? styles.muteButtonMuted : undefined,
isSwitching ? styles.buttonDisabled : undefined,
]}
style={muteButtonStyle}
>
{isMuted ? (
<MicOff size={theme.iconSize.lg} color={theme.colors.palette.white} strokeWidth={2.5} />
@@ -59,11 +68,7 @@ export function RealtimeVoiceOverlay({
disabled={isSwitching}
accessibilityRole="button"
accessibilityLabel="Stop realtime voice and interrupt turn"
style={[
styles.actionButton,
styles.stopButton,
isSwitching ? styles.buttonDisabled : undefined,
]}
style={stopButtonStyle}
>
{isSwitching ? (
<ActivityIndicator size="small" color={theme.colors.palette.white} />

View File

@@ -1,14 +1,52 @@
import { useEffect, useRef } from "react";
import { useEffect, useMemo, useRef } from "react";
import { Animated, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
const SECTION_OPACITIES: readonly number[] = [1, 0.7, 0.4];
function SkeletonPulse({ pulse, style }: { pulse: Animated.Value; style: StyleProp<ViewStyle> }) {
const opacity = pulse.interpolate({
inputRange: [0, 1],
outputRange: [0.4, 0.8],
});
return <Animated.View style={[style, { opacity }]} />;
const pulseStyle = useMemo(() => [style, { opacity }], [style, opacity]);
return <Animated.View style={pulseStyle} />;
}
function SkeletonSection({
pulse,
sectionOpacity,
sectionIdx,
}: {
pulse: Animated.Value;
sectionOpacity: number;
sectionIdx: number;
}) {
const sectionStyle = useMemo(
() => [styles.section, { opacity: sectionOpacity }],
[sectionOpacity],
);
return (
<View style={sectionStyle}>
<View style={styles.sectionHeader}>
<SkeletonPulse pulse={pulse} style={styles.chevron} />
<SkeletonPulse pulse={pulse} style={styles.projectIcon} />
<SkeletonPulse pulse={pulse} style={styles.sectionTitle} />
</View>
<View style={styles.rows}>
{Array.from({ length: 3 }).map((__, rowIdx) => (
<View key={`skeleton-row-${sectionIdx}-${rowIdx}`} style={styles.row}>
<SkeletonPulse pulse={pulse} style={styles.rowDot} />
<SkeletonPulse pulse={pulse} style={styles.rowTitle} />
<SkeletonPulse pulse={pulse} style={styles.rowBadge} />
</View>
))}
</View>
</View>
);
}
export function SidebarAgentListSkeleton() {
@@ -36,27 +74,13 @@ export function SidebarAgentListSkeleton() {
return (
<View style={styles.container}>
{[1, 0.7, 0.4].map((sectionOpacity, sectionIdx) => (
<View
{SECTION_OPACITIES.map((sectionOpacity, sectionIdx) => (
<SkeletonSection
key={`skeleton-section-${sectionIdx}`}
style={[styles.section, { opacity: sectionOpacity }]}
>
<View style={styles.sectionHeader}>
<SkeletonPulse pulse={pulse} style={styles.chevron} />
<SkeletonPulse pulse={pulse} style={styles.projectIcon} />
<SkeletonPulse pulse={pulse} style={styles.sectionTitle} />
</View>
<View style={styles.rows}>
{Array.from({ length: 3 }).map((__, rowIdx) => (
<View key={`skeleton-row-${sectionIdx}-${rowIdx}`} style={styles.row}>
<SkeletonPulse pulse={pulse} style={styles.rowDot} />
<SkeletonPulse pulse={pulse} style={styles.rowTitle} />
<SkeletonPulse pulse={pulse} style={styles.rowBadge} />
</View>
))}
</View>
</View>
pulse={pulse}
sectionOpacity={sectionOpacity}
sectionIdx={sectionIdx}
/>
))}
</View>
);

View File

@@ -1,4 +1,4 @@
import { useCallback, useState, type ReactElement } from "react";
import { useCallback, useMemo, useState, type ReactElement } from "react";
import {
DndContext,
closestCenter,
@@ -25,6 +25,8 @@ const restrictToHorizontalAxis: Modifier = ({ transform }) => ({
y: 0,
});
const DND_MODIFIERS: Modifier[] = [restrictToHorizontalAxis];
function SortableItem<T>({
id,
item,
@@ -183,7 +185,10 @@ export function SortableInlineList<T>({
[clearDragState, disabled, items, keyExtractor, onDragEnd],
);
const ids = items.map((item, index) => keyExtractor(item, index));
const ids = useMemo(
() => items.map((item, index) => keyExtractor(item, index)),
[items, keyExtractor],
);
const renderedItems = (
<SortableContext items={ids} strategy={horizontalListSortingStrategy}>
@@ -215,7 +220,7 @@ export function SortableInlineList<T>({
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToHorizontalAxis]}
modifiers={DND_MODIFIERS}
onDragStart={handleDragStart}
onDragCancel={clearDragState}
onDragEnd={handleDragEnd}

View File

@@ -7,7 +7,7 @@ import Animated, {
withRepeat,
withTiming,
} from "react-native-reanimated";
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
const SYNCED_LOADER_DURATION_MS = 950;
const SYNCED_LOADER_EPOCH_MS = 0;
@@ -66,6 +66,11 @@ export function SyncedLoader({ size = 10, color }: { size?: number; color: strin
const gridWidth = dotSize * 2 + gap;
const gridHeight = dotSize * 3 + gap * 2;
const gridStyle = useMemo(
() => [animatedStyle, { width: gridWidth, height: gridHeight }],
[animatedStyle, gridWidth, gridHeight],
);
return (
<View
style={{
@@ -75,15 +80,7 @@ export function SyncedLoader({ size = 10, color }: { size?: number; color: strin
justifyContent: "center",
}}
>
<Animated.View
style={[
animatedStyle,
{
width: gridWidth,
height: gridHeight,
},
]}
>
<Animated.View style={gridStyle}>
{Array.from({ length: DOT_COUNT }).map((_, dotIndex) => {
const rowIndex = Math.floor(dotIndex / GRID_COLUMNS);
const columnIndex = dotIndex % GRID_COLUMNS;
@@ -144,18 +141,19 @@ function SpinnerDot({
};
});
return (
<Animated.View
style={[
animatedStyle,
{
width: dotSize,
height: dotSize,
borderRadius: dotSize / 2,
backgroundColor: color,
},
style,
]}
/>
const dotStyle = useMemo(
() => [
animatedStyle,
{
width: dotSize,
height: dotSize,
borderRadius: dotSize / 2,
backgroundColor: color,
},
style,
],
[animatedStyle, dotSize, color, style],
);
return <Animated.View style={dotStyle} />;
}

View File

@@ -543,6 +543,11 @@ export function TerminalPane({
],
);
const containerStyle = useMemo(
() => [styles.container, keyboardPaddingStyle],
[keyboardPaddingStyle],
);
if (!client || !isConnected) {
return (
<View style={styles.centerState}>
@@ -552,7 +557,7 @@ export function TerminalPane({
}
return (
<Animated.View style={[styles.container, keyboardPaddingStyle]}>
<Animated.View style={containerStyle}>
<View style={styles.outputContainer}>
{isWorkspaceFocused ? (
<View style={styles.terminalGestureContainer}>

View File

@@ -212,10 +212,6 @@ export function ToastViewport({
};
}, [clearTimer, opacity, scheduleDismiss, toast, translateY]);
if (!toast) {
return null;
}
const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT;
const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const topOffset =
@@ -223,6 +219,29 @@ export function ToastViewport({
? insets.top + headerTopPadding + headerHeight + theme.spacing[2]
: theme.spacing[3];
const toastVariant = toast?.variant;
const toastAnimatedStyle = useMemo(
() => [
styles.toast,
toastVariant === "success" ? styles.toastSuccess : null,
toastVariant === "error" ? styles.toastError : null,
{
marginTop: topOffset,
opacity,
transform: [{ translateY }],
},
],
[toastVariant, topOffset, opacity, translateY],
);
const toastMessageStyle = useMemo(
() => [styles.message, toastVariant === "error" ? styles.messageError : null],
[toastVariant],
);
if (!toast) {
return null;
}
const icon =
toast.icon ??
(toast.variant === "success" ? (
@@ -237,24 +256,12 @@ export function ToastViewport({
testID={toast.testID ?? "app-toast"}
onPointerEnter={isWeb ? pauseDismiss : undefined}
onPointerLeave={isWeb ? resumeDismiss : undefined}
style={[
styles.toast,
toast.variant === "success" ? styles.toastSuccess : null,
toast.variant === "error" ? styles.toastError : null,
{
marginTop: topOffset,
opacity,
transform: [{ translateY }],
},
]}
style={toastAnimatedStyle}
accessibilityRole="alert"
>
{icon ? <View style={styles.iconSlot}>{icon}</View> : null}
{typeof toast.content === "string" ? (
<Text
testID="app-toast-message"
style={[styles.message, toast.variant === "error" ? styles.messageError : null]}
>
<Text testID="app-toast-message" style={toastMessageStyle}>
{toast.content}
</Text>
) : (

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import { useMemo, type ReactNode } from "react";
import { Pressable, Text, View } from "react-native";
import type { StyleProp, ViewStyle } from "react-native";
import type { StyleProp, TextStyle, ViewStyle } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
type SegmentedControlSize = "sm" | "md";
@@ -40,53 +40,92 @@ export function SegmentedControl<T extends string>({
const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd;
const iconSize = size === "sm" ? theme.iconSize.sm : theme.iconSize.md;
const containerStyle = useMemo(
() => [styles.container, containerSizeStyle, style],
[containerSizeStyle, style],
);
return (
<View style={[styles.container, containerSizeStyle, style]} testID={testID}>
<View style={containerStyle} testID={testID}>
{options.map((option) => {
const isSelected = option.value === value;
const iconColor = isSelected ? theme.colors.foreground : theme.colors.foregroundMuted;
return (
<Pressable
<SegmentItem
key={option.value}
accessibilityRole="button"
accessibilityState={{ selected: isSelected, disabled: option.disabled }}
disabled={option.disabled}
testID={option.testID}
option={option}
isSelected={isSelected}
iconColor={iconColor}
iconSize={iconSize}
hideLabels={hideLabels}
segmentSizeStyle={segmentSizeStyle}
labelSizeStyle={labelSizeStyle}
onPress={() => {
if (!option.disabled && option.value !== value) {
onValueChange(option.value);
}
}}
style={({ hovered, pressed }) => [
styles.segment,
segmentSizeStyle,
isSelected && styles.segmentSelected,
hovered && !isSelected && styles.segmentHover,
pressed && !isSelected && styles.segmentPressed,
option.disabled && styles.segmentDisabled,
]}
>
{option.icon ? (
<View style={styles.iconContainer}>
{option.icon({ color: iconColor, size: iconSize })}
</View>
) : null}
{hideLabels ? null : (
<Text
style={[styles.label, labelSizeStyle, isSelected && styles.labelSelected]}
numberOfLines={1}
>
{option.label}
</Text>
)}
</Pressable>
/>
);
})}
</View>
);
}
function SegmentItem<T extends string>({
option,
isSelected,
iconColor,
iconSize,
hideLabels,
segmentSizeStyle,
labelSizeStyle,
onPress,
}: {
option: SegmentedControlOption<T>;
isSelected: boolean;
iconColor: string;
iconSize: number;
hideLabels: boolean;
segmentSizeStyle: StyleProp<ViewStyle>;
labelSizeStyle: StyleProp<TextStyle>;
onPress: () => void;
}) {
const labelStyle = useMemo(
() => [styles.label, labelSizeStyle, isSelected && styles.labelSelected],
[labelSizeStyle, isSelected],
);
return (
<Pressable
accessibilityRole="button"
accessibilityState={{ selected: isSelected, disabled: option.disabled }}
disabled={option.disabled}
testID={option.testID}
onPress={onPress}
style={({ hovered, pressed }) => [
styles.segment,
segmentSizeStyle,
isSelected && styles.segmentSelected,
hovered && !isSelected && styles.segmentHover,
pressed && !isSelected && styles.segmentPressed,
option.disabled && styles.segmentDisabled,
]}
>
{option.icon ? (
<View style={styles.iconContainer}>
{option.icon({ color: iconColor, size: iconSize })}
</View>
) : null}
{hideLabels ? null : (
<Text style={labelStyle} numberOfLines={1}>
{option.label}
</Text>
)}
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flexDirection: "row",

View File

@@ -1,5 +1,6 @@
import { useMemo } from "react";
import { View, Text } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet } from "react-native-unistyles";
type StatusBadgeVariant = "success" | "error" | "muted";
@@ -9,25 +10,26 @@ interface StatusBadgeProps {
}
export function StatusBadge({ label, variant = "muted" }: StatusBadgeProps) {
const { theme } = useUnistyles();
const pillStyle = useMemo(
() => [
styles.pill,
variant === "success" && styles.pillSuccess,
variant === "error" && styles.pillError,
],
[variant],
);
const textStyle = useMemo(
() => [
styles.pillText,
variant === "success" && styles.pillTextSuccess,
variant === "error" && styles.pillTextError,
],
[variant],
);
return (
<View
style={[
styles.pill,
variant === "success" && styles.pillSuccess,
variant === "error" && styles.pillError,
]}
>
<Text
style={[
styles.pillText,
variant === "success" && styles.pillTextSuccess,
variant === "error" && styles.pillTextError,
]}
>
{label}
</Text>
<View style={pillStyle}>
<Text style={textStyle}>{label}</Text>
</View>
);
}

View File

@@ -480,6 +480,20 @@ export function TooltipContent({
[],
);
const contentStyle = useMemo(
() => [
styles.content,
{ maxWidth },
style,
{
position: "absolute" as const,
top: position?.y ?? -9999,
left: position?.x ?? -9999,
},
],
[maxWidth, style, position?.x, position?.y],
);
if (!ctx.open || !ctx.enabled) return null;
// On web, avoid React Native's <Modal/> implementation (it uses <dialog> and can
@@ -496,16 +510,7 @@ export function TooltipContent({
collapsable={false}
testID={testID}
onLayout={handleLayout}
style={[
styles.content,
{ maxWidth },
style,
{
position: "absolute",
top: position?.y ?? -9999,
left: position?.x ?? -9999,
},
]}
style={contentStyle}
>
{children}
</Animated.View>
@@ -530,16 +535,7 @@ export function TooltipContent({
collapsable={false}
testID={testID}
onLayout={handleLayout}
style={[
styles.content,
{ maxWidth },
style,
{
position: "absolute",
top: position?.y ?? -9999,
left: position?.x ?? -9999,
},
]}
style={contentStyle}
>
{children}
</Animated.View>

View File

@@ -357,26 +357,51 @@ export function WebDesktopScrollbarOverlay({
);
const handleInsetTop = Math.max(0, (thumbRegionHeight - geometry.handleSize) / 2);
const thumbRegionStyle = useMemo(
() => [
styles.thumbRegion,
{
top: 0,
height: thumbRegionHeight,
transform: [{ translateY: thumbRegionOffset }],
},
platformIsWeb &&
({
cursor: handleCursor,
touchAction: "none",
userSelect: "none",
transitionProperty: "transform",
transitionDuration: `${handleTravelDurationMs}ms`,
transitionTimingFunction: "linear",
} as any),
],
[thumbRegionHeight, thumbRegionOffset, handleCursor, handleTravelDurationMs],
);
const handleStyle = useMemo(
() => [
styles.handle,
{
marginTop: handleInsetTop,
height: geometry.handleSize,
width: handleWidth,
backgroundColor: handleColor,
opacity: handleOpacity,
},
platformIsWeb &&
({
transitionProperty: "opacity, width, background-color",
transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`,
transitionTimingFunction: "ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out",
} as any),
],
[handleInsetTop, geometry.handleSize, handleWidth, handleColor, handleOpacity],
);
return (
<View style={styles.overlay} pointerEvents="box-none">
<View
style={[
styles.thumbRegion,
{
top: 0,
height: thumbRegionHeight,
transform: [{ translateY: thumbRegionOffset }],
},
platformIsWeb &&
({
cursor: handleCursor,
touchAction: "none",
userSelect: "none",
transitionProperty: "transform",
transitionDuration: `${handleTravelDurationMs}ms`,
transitionTimingFunction: "linear",
} as any),
]}
style={thumbRegionStyle}
pointerEvents={handleVisible ? "auto" : "none"}
{...(panResponder?.panHandlers ?? {})}
{...(platformIsWeb
@@ -389,25 +414,7 @@ export function WebDesktopScrollbarOverlay({
} as any)
: null)}
>
<View
style={[
styles.handle,
{
marginTop: handleInsetTop,
height: geometry.handleSize,
width: handleWidth,
backgroundColor: handleColor,
opacity: handleOpacity,
},
platformIsWeb &&
({
transitionProperty: "opacity, width, background-color",
transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`,
transitionTimingFunction: "ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out",
} as any),
]}
pointerEvents="none"
/>
<View style={handleStyle} pointerEvents="none" />
</View>
</View>
);