feat(chat): implement pull-to-refresh functionality and pagination for chat history
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { usePullToRefresh } from "../use-pull-to-refresh";
|
||||
|
||||
function Harness({
|
||||
disabled = false,
|
||||
refreshing,
|
||||
onRefresh,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
refreshing?: boolean;
|
||||
onRefresh: () => Promise<void> | void;
|
||||
}) {
|
||||
const pull = usePullToRefresh(() => onRefresh(), {
|
||||
disabled,
|
||||
refreshing,
|
||||
});
|
||||
return (
|
||||
<div
|
||||
aria-label="Pull container"
|
||||
data-distance={pull.pullDistance}
|
||||
data-pulling={pull.isPulling}
|
||||
{...pull.touchHandlers}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("usePullToRefresh", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("triggers once after pulling beyond the threshold", () => {
|
||||
const onRefresh = vi.fn();
|
||||
act(() => root.render(<Harness onRefresh={onRefresh} />));
|
||||
const target = getTarget(container);
|
||||
|
||||
pull(target, 180);
|
||||
|
||||
expect(onRefresh).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not trigger below the threshold or while disabled", () => {
|
||||
const onRefresh = vi.fn();
|
||||
act(() => root.render(<Harness onRefresh={onRefresh} />));
|
||||
pull(getTarget(container), 80);
|
||||
expect(onRefresh).not.toHaveBeenCalled();
|
||||
|
||||
act(() =>
|
||||
root.render(<Harness disabled onRefresh={onRefresh} />),
|
||||
);
|
||||
pull(getTarget(container), 180);
|
||||
expect(onRefresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks another gesture while refresh is externally controlled", () => {
|
||||
const onRefresh = vi.fn();
|
||||
act(() =>
|
||||
root.render(<Harness refreshing onRefresh={onRefresh} />),
|
||||
);
|
||||
|
||||
pull(getTarget(container), 180);
|
||||
|
||||
expect(onRefresh).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function getTarget(container: HTMLElement): HTMLDivElement {
|
||||
const target = container.querySelector<HTMLDivElement>(
|
||||
'[aria-label="Pull container"]',
|
||||
);
|
||||
if (!target) throw new Error("Pull container was not rendered");
|
||||
return target;
|
||||
}
|
||||
|
||||
function pull(target: HTMLDivElement, distance: number): void {
|
||||
act(() => {
|
||||
target.dispatchEvent(createTouchEvent("touchstart", 0));
|
||||
target.dispatchEvent(createTouchEvent("touchmove", distance));
|
||||
target.dispatchEvent(createTouchEvent("touchend", distance, true));
|
||||
});
|
||||
}
|
||||
|
||||
function createTouchEvent(
|
||||
type: string,
|
||||
clientY: number,
|
||||
ended = false,
|
||||
): Event {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, "touches", {
|
||||
value: ended ? [] : [{ identifier: 1, clientY }],
|
||||
});
|
||||
return event;
|
||||
}
|
||||
@@ -1,69 +1,139 @@
|
||||
"use client";
|
||||
/**
|
||||
* 下拉刷新 hook(占位实现)
|
||||
*
|
||||
*
|
||||
*
|
||||
* 当前实现:最小可用版本 —— 监听顶部 overscroll 触发 onRefresh。
|
||||
* 后续可替换为 react-use / react-easy-refresh。
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type TouchEvent as ReactTouchEvent,
|
||||
} from "react";
|
||||
|
||||
export interface PullToRefreshOptions {
|
||||
threshold?: number;
|
||||
maxPullDistance?: number;
|
||||
disabled?: boolean;
|
||||
refreshing?: boolean;
|
||||
}
|
||||
|
||||
export interface PullToRefreshTouchHandlers {
|
||||
onTouchStart: (event: ReactTouchEvent<HTMLDivElement>) => void;
|
||||
onTouchMove: (event: ReactTouchEvent<HTMLDivElement>) => void;
|
||||
onTouchEnd: (event: ReactTouchEvent<HTMLDivElement>) => void;
|
||||
onTouchCancel: () => void;
|
||||
}
|
||||
|
||||
export interface PullToRefreshState {
|
||||
isRefreshing: boolean;
|
||||
isPulling: boolean;
|
||||
isReleaseReady: boolean;
|
||||
pullDistance: number;
|
||||
touchHandlers: PullToRefreshTouchHandlers;
|
||||
}
|
||||
|
||||
export function usePullToRefresh(
|
||||
onRefresh: () => Promise<void> | void,
|
||||
options: { threshold?: number; disabled?: boolean } = {},
|
||||
): {
|
||||
isRefreshing: boolean;
|
||||
pullDistance: number;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
} {
|
||||
const { threshold = 80, disabled = false } = options;
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
onRefresh: (container: HTMLDivElement) => Promise<void> | void,
|
||||
options: PullToRefreshOptions = {},
|
||||
): PullToRefreshState {
|
||||
const {
|
||||
threshold = 72,
|
||||
maxPullDistance = threshold * 1.5,
|
||||
disabled = false,
|
||||
refreshing,
|
||||
} = options;
|
||||
const [internalRefreshing, setInternalRefreshing] = useState(false);
|
||||
const [isPulling, setIsPulling] = useState(false);
|
||||
const [pullDistance, setPullDistance] = useState(0);
|
||||
const startY = useRef<number | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const startYRef = useRef<number | null>(null);
|
||||
const touchIdRef = useRef<number | null>(null);
|
||||
const pullDistanceRef = useRef(0);
|
||||
const refreshPendingRef = useRef(false);
|
||||
const mountedRef = useRef(true);
|
||||
const isRefreshing = refreshing ?? internalRefreshing;
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled || typeof window === "undefined") return;
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
if (el.scrollTop > 0) return;
|
||||
startY.current = e.touches[0]?.clientY ?? null;
|
||||
};
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
if (startY.current == null) return;
|
||||
const y = e.touches[0]?.clientY ?? startY.current;
|
||||
const delta = y - startY.current;
|
||||
if (delta > 0 && el.scrollTop <= 0) {
|
||||
setPullDistance(Math.min(delta, threshold * 1.5));
|
||||
}
|
||||
};
|
||||
const onTouchEnd = async () => {
|
||||
if (startY.current == null) return;
|
||||
if (pullDistance >= threshold && !isRefreshing) {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await onRefresh();
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
setPullDistance(0);
|
||||
}
|
||||
} else {
|
||||
setPullDistance(0);
|
||||
}
|
||||
startY.current = null;
|
||||
};
|
||||
el.addEventListener("touchstart", onTouchStart, { passive: true });
|
||||
el.addEventListener("touchmove", onTouchMove, { passive: true });
|
||||
el.addEventListener("touchend", onTouchEnd);
|
||||
return () => {
|
||||
el.removeEventListener("touchstart", onTouchStart);
|
||||
el.removeEventListener("touchmove", onTouchMove);
|
||||
el.removeEventListener("touchend", onTouchEnd);
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, [threshold, disabled, isRefreshing, onRefresh, pullDistance]);
|
||||
}, []);
|
||||
|
||||
return { isRefreshing, pullDistance, containerRef };
|
||||
const resetGesture = () => {
|
||||
startYRef.current = null;
|
||||
touchIdRef.current = null;
|
||||
pullDistanceRef.current = 0;
|
||||
setIsPulling(false);
|
||||
setPullDistance(0);
|
||||
};
|
||||
|
||||
const runRefresh = async (container: HTMLDivElement) => {
|
||||
if (refreshPendingRef.current || isRefreshing) return;
|
||||
refreshPendingRef.current = true;
|
||||
if (refreshing === undefined) setInternalRefreshing(true);
|
||||
try {
|
||||
await onRefresh(container);
|
||||
} finally {
|
||||
refreshPendingRef.current = false;
|
||||
if (refreshing === undefined && mountedRef.current) {
|
||||
setInternalRefreshing(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchStart = (event: ReactTouchEvent<HTMLDivElement>) => {
|
||||
const element = event.currentTarget;
|
||||
if (disabled || isRefreshing || element.scrollTop > 0) return;
|
||||
const touch = event.touches[0];
|
||||
if (!touch || event.touches.length !== 1) return;
|
||||
startYRef.current = touch.clientY;
|
||||
touchIdRef.current = touch.identifier;
|
||||
setIsPulling(true);
|
||||
};
|
||||
|
||||
const onTouchMove = (event: ReactTouchEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
disabled ||
|
||||
isRefreshing ||
|
||||
startYRef.current === null ||
|
||||
touchIdRef.current === null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const touch = Array.from(event.touches).find(
|
||||
(item) => item.identifier === touchIdRef.current,
|
||||
);
|
||||
if (!touch) return;
|
||||
const delta = touch.clientY - startYRef.current;
|
||||
if (delta <= 0 || event.currentTarget.scrollTop > 0) {
|
||||
pullDistanceRef.current = 0;
|
||||
setPullDistance(0);
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const nextDistance = Math.min(maxPullDistance, delta * 0.55);
|
||||
pullDistanceRef.current = nextDistance;
|
||||
setPullDistance(nextDistance);
|
||||
};
|
||||
|
||||
const onTouchEnd = (event: ReactTouchEvent<HTMLDivElement>) => {
|
||||
if (startYRef.current === null) return;
|
||||
const shouldRefresh =
|
||||
!disabled &&
|
||||
!isRefreshing &&
|
||||
pullDistanceRef.current >= threshold;
|
||||
resetGesture();
|
||||
if (shouldRefresh) void runRefresh(event.currentTarget);
|
||||
};
|
||||
|
||||
const effectivePullDistance = disabled || isRefreshing ? 0 : pullDistance;
|
||||
return {
|
||||
isRefreshing,
|
||||
isPulling: !disabled && !isRefreshing && isPulling,
|
||||
isReleaseReady: effectivePullDistance >= threshold,
|
||||
pullDistance: effectivePullDistance,
|
||||
touchHandlers: {
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
onTouchCancel: resetGesture,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user