fix(app): keep web scrollbars from shifting content
Use an overlay scrollbar for desktop chat timelines so scrollbar gutters cannot move centered content. Share its behavior with existing workspace scrollbars and derive resting and hover colors from semantic foreground tokens.
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import React, { useCallback, useLayoutEffect, useState, type RefObject } from "react";
|
||||
import { OverlayScrollbar, type OverlayScrollbarMetrics } from "./overlay-scrollbar";
|
||||
|
||||
export function DomOverlayScrollbar({
|
||||
scrollContainerRef,
|
||||
onUserScrollUp,
|
||||
}: {
|
||||
scrollContainerRef: RefObject<HTMLElement | null>;
|
||||
onUserScrollUp: () => void;
|
||||
}) {
|
||||
const [metrics, setMetrics] = useState<OverlayScrollbarMetrics>({
|
||||
offset: 0,
|
||||
viewportSize: 0,
|
||||
contentSize: 0,
|
||||
});
|
||||
const syncMetrics = useCallback(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (!scrollContainer) return;
|
||||
setMetrics({
|
||||
offset: Math.max(0, scrollContainer.scrollTop),
|
||||
viewportSize: Math.max(0, scrollContainer.clientHeight),
|
||||
contentSize: Math.max(0, scrollContainer.scrollHeight),
|
||||
});
|
||||
}, [scrollContainerRef]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (!scrollContainer) return;
|
||||
syncMetrics();
|
||||
scrollContainer.addEventListener("scroll", syncMetrics, { passive: true });
|
||||
const observer = new ResizeObserver(syncMetrics);
|
||||
observer.observe(scrollContainer);
|
||||
const content = scrollContainer.firstElementChild;
|
||||
if (content) observer.observe(content);
|
||||
return () => {
|
||||
scrollContainer.removeEventListener("scroll", syncMetrics);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [scrollContainerRef, syncMetrics]);
|
||||
|
||||
const scrollToOffset = useCallback(
|
||||
(offset: number) => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (scrollContainer) scrollContainer.scrollTop = offset;
|
||||
},
|
||||
[scrollContainerRef],
|
||||
);
|
||||
|
||||
return (
|
||||
<OverlayScrollbar
|
||||
metrics={metrics}
|
||||
onScrollToOffset={scrollToOffset}
|
||||
onUserScrollUp={onUserScrollUp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pressable, View, type PressableStateCallbackType, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { WEB_SCROLLBAR_RESTING_OPACITY, WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { computeOverlayScrollbarGeometry, scrollOffsetFromThumbDrag } from "./geometry";
|
||||
|
||||
const THUMB_WIDTH = WEB_SCROLLBAR_SIZE_PX - 4;
|
||||
const GRAB_WIDTH = 12;
|
||||
|
||||
export interface OverlayScrollbarMetrics {
|
||||
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;
|
||||
}
|
||||
|
||||
export function OverlayScrollbar({
|
||||
metrics,
|
||||
onScrollToOffset,
|
||||
onUserScrollUp,
|
||||
}: {
|
||||
metrics: OverlayScrollbarMetrics;
|
||||
onScrollToOffset: (offset: number) => void;
|
||||
onUserScrollUp?: () => void;
|
||||
}) {
|
||||
const geometry = useMemo(() => computeOverlayScrollbarGeometry(metrics), [metrics]);
|
||||
const geometryRef = useRef(geometry);
|
||||
const dragStartClientYRef = useRef(0);
|
||||
const dragStartOffsetRef = useRef(0);
|
||||
const lastRequestedOffsetRef = useRef(0);
|
||||
const reportedUpwardIntentRef = useRef(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
geometryRef.current = geometry;
|
||||
}, [geometry]);
|
||||
|
||||
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;
|
||||
lastRequestedOffsetRef.current = metrics.offset;
|
||||
reportedUpwardIntentRef.current = false;
|
||||
setIsDragging(true);
|
||||
},
|
||||
[metrics.offset],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const current = geometryRef.current;
|
||||
const nextOffset = scrollOffsetFromThumbDrag({
|
||||
startOffset: dragStartOffsetRef.current,
|
||||
dragDelta: event.clientY - dragStartClientYRef.current,
|
||||
maxScrollOffset: current.maxScrollOffset,
|
||||
maxThumbOffset: current.maxThumbOffset,
|
||||
});
|
||||
if (nextOffset < lastRequestedOffsetRef.current && !reportedUpwardIntentRef.current) {
|
||||
reportedUpwardIntentRef.current = true;
|
||||
onUserScrollUp?.();
|
||||
}
|
||||
lastRequestedOffsetRef.current = nextOffset;
|
||||
onScrollToOffset(nextOffset);
|
||||
};
|
||||
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, onScrollToOffset, onUserScrollUp]);
|
||||
|
||||
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">
|
||||
<Pressable
|
||||
style={thumbRegionStyle}
|
||||
{...({ onPointerDown: startDrag } as object)}
|
||||
testID="workspace-overlay-scrollbar-grab"
|
||||
>
|
||||
{({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => (
|
||||
<View
|
||||
style={[styles.thumb, hovered || isDragging ? styles.thumbHovered : null]}
|
||||
pointerEvents="none"
|
||||
testID="workspace-overlay-scrollbar-thumb"
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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.foregroundExtraMuted,
|
||||
opacity: WEB_SCROLLBAR_RESTING_OPACITY,
|
||||
},
|
||||
thumbHovered: {
|
||||
backgroundColor: theme.colors.foregroundMuted,
|
||||
opacity: 1,
|
||||
},
|
||||
}));
|
||||
+8
-156
@@ -1,139 +1,18 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import { useCallback, 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 { OverlayScrollbar, type OverlayScrollbarMetrics } from "./overlay-scrollbar";
|
||||
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>({
|
||||
const [metrics, setMetrics] = useState<OverlayScrollbarMetrics>({
|
||||
offset: 0,
|
||||
viewportSize: 0,
|
||||
contentSize: 0,
|
||||
@@ -156,6 +35,10 @@ export function useOverlayFlatListScrollbar<ItemT>(
|
||||
const onContentSizeChange = useCallback((_width: number, height: number) => {
|
||||
setMetrics((previous) => ({ ...previous, contentSize: Math.max(0, height) }));
|
||||
}, []);
|
||||
const scrollToOffset = useCallback(
|
||||
(offset: number) => listRef.current?.scrollToOffset({ offset, animated: false }),
|
||||
[listRef],
|
||||
);
|
||||
|
||||
return {
|
||||
enabled: options.enabled,
|
||||
@@ -163,38 +46,7 @@ export function useOverlayFlatListScrollbar<ItemT>(
|
||||
onLayout,
|
||||
onScroll,
|
||||
overlay: options.enabled ? (
|
||||
<OverlayScrollbar
|
||||
listRef={listRef as RefObject<FlatList<unknown> | null>}
|
||||
metrics={metrics}
|
||||
/>
|
||||
<OverlayScrollbar metrics={metrics} onScrollToOffset={scrollToOffset} />
|
||||
) : 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,
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user