修复顶部标签切换时位置重置
This commit is contained in:
@@ -51,7 +51,6 @@
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
overscroll-behavior-x: contain;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ComponentType } from 'react';
|
||||
import { Dropdown, Tooltip } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
@@ -28,7 +28,9 @@ import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||
import './WorkspaceTabs.css';
|
||||
|
||||
const OPEN_TABS_KEY = 'game-admin-open-tabs';
|
||||
const OPEN_TABS_SCROLL_KEY = 'game-admin-open-tabs-scroll-left';
|
||||
const HOME_PATH = '/';
|
||||
let savedTabsScrollLeft = 0;
|
||||
|
||||
interface WorkspaceTabDefinition {
|
||||
path: string;
|
||||
@@ -65,6 +67,23 @@ function saveOpenedPaths(paths: string[]) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function saveTabsScrollLeft(scrollLeft: number) {
|
||||
savedTabsScrollLeft = Math.max(0, scrollLeft);
|
||||
try {
|
||||
sessionStorage.setItem(OPEN_TABS_SCROLL_KEY, String(savedTabsScrollLeft));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function readTabsScrollLeft() {
|
||||
try {
|
||||
const storedScrollLeft = Number(sessionStorage.getItem(OPEN_TABS_SCROLL_KEY));
|
||||
if (Number.isFinite(storedScrollLeft) && storedScrollLeft >= 0) {
|
||||
savedTabsScrollLeft = storedScrollLeft;
|
||||
}
|
||||
} catch {}
|
||||
return savedTabsScrollLeft;
|
||||
}
|
||||
|
||||
function readOpenedPaths(activePath: string) {
|
||||
let storedPaths: string[] = [];
|
||||
|
||||
@@ -94,8 +113,11 @@ export default function WorkspaceTabs() {
|
||||
const [openedPaths, setOpenedPaths] = useState(() => readOpenedPaths(activePath));
|
||||
const tabsRailRef = useRef<HTMLDivElement>(null);
|
||||
const tabsTrackRef = useRef<HTMLDivElement>(null);
|
||||
const tabScrollBeforePointerRef = useRef<number | null>(null);
|
||||
const preserveScrollOnUnmountRef = useRef(false);
|
||||
const tabViewportReadyRef = useRef(false);
|
||||
const [tabScrollState, setTabScrollState] = useState({
|
||||
hasOverflow: false,
|
||||
hasOverflow: true,
|
||||
canScrollLeft: false,
|
||||
canScrollRight: false,
|
||||
});
|
||||
@@ -147,35 +169,85 @@ export default function WorkspaceTabs() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleTabScroll = useCallback(() => {
|
||||
const track = tabsTrackRef.current;
|
||||
if (!track) return;
|
||||
if (!tabViewportReadyRef.current) return;
|
||||
if (tabScrollBeforePointerRef.current !== null || preserveScrollOnUnmountRef.current) return;
|
||||
|
||||
saveTabsScrollLeft(track.scrollLeft);
|
||||
updateTabScrollState();
|
||||
}, [updateTabScrollState]);
|
||||
|
||||
const rememberTabScrollBeforePointer = useCallback(() => {
|
||||
tabScrollBeforePointerRef.current = tabsTrackRef.current?.scrollLeft ?? savedTabsScrollLeft;
|
||||
}, []);
|
||||
|
||||
const openWorkspaceTab = useCallback(
|
||||
(path: string) => {
|
||||
const track = tabsTrackRef.current;
|
||||
saveTabsScrollLeft(tabScrollBeforePointerRef.current ?? track?.scrollLeft ?? 0);
|
||||
tabScrollBeforePointerRef.current = null;
|
||||
preserveScrollOnUnmountRef.current = path !== activePath;
|
||||
navigate(path);
|
||||
},
|
||||
[activePath, navigate],
|
||||
);
|
||||
|
||||
const syncTabViewport = useCallback(() => {
|
||||
const track = tabsTrackRef.current;
|
||||
if (!track || track.clientWidth === 0) return;
|
||||
|
||||
const maxScrollLeft = Math.max(0, track.scrollWidth - track.clientWidth);
|
||||
const restoredScrollLeft = Math.min(readTabsScrollLeft(), maxScrollLeft);
|
||||
track.scrollLeft = restoredScrollLeft;
|
||||
|
||||
const activeTabElement = track.querySelector<HTMLElement>('.workspace-tab.is-active');
|
||||
if (activeTabElement) {
|
||||
const trackRect = track.getBoundingClientRect();
|
||||
const activeTabRect = activeTabElement.getBoundingClientRect();
|
||||
let nextScrollLeft = track.scrollLeft;
|
||||
|
||||
if (activeTabRect.left < trackRect.left) {
|
||||
nextScrollLeft += activeTabRect.left - trackRect.left;
|
||||
} else if (activeTabRect.right > trackRect.right) {
|
||||
nextScrollLeft += activeTabRect.right - trackRect.right;
|
||||
}
|
||||
|
||||
track.scrollLeft = Math.max(0, Math.min(nextScrollLeft, maxScrollLeft));
|
||||
}
|
||||
|
||||
saveTabsScrollLeft(track.scrollLeft);
|
||||
tabViewportReadyRef.current = true;
|
||||
updateTabScrollState();
|
||||
}, [updateTabScrollState]);
|
||||
|
||||
useEffect(() => {
|
||||
saveOpenedPaths(openedPaths);
|
||||
}, [openedPaths]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const animationFrame = window.requestAnimationFrame(syncTabViewport);
|
||||
return () => window.cancelAnimationFrame(animationFrame);
|
||||
}, [activePath, openedTabs, syncTabViewport]);
|
||||
|
||||
useEffect(() => {
|
||||
const rail = tabsRailRef.current;
|
||||
const track = tabsTrackRef.current;
|
||||
if (!rail || !track) return;
|
||||
|
||||
updateTabScrollState();
|
||||
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updateTabScrollState);
|
||||
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(syncTabViewport);
|
||||
resizeObserver?.observe(rail);
|
||||
resizeObserver?.observe(track);
|
||||
window.addEventListener('resize', updateTabScrollState);
|
||||
window.addEventListener('resize', syncTabViewport);
|
||||
|
||||
return () => {
|
||||
if (!preserveScrollOnUnmountRef.current) saveTabsScrollLeft(track.scrollLeft);
|
||||
resizeObserver?.disconnect();
|
||||
window.removeEventListener('resize', updateTabScrollState);
|
||||
window.removeEventListener('resize', syncTabViewport);
|
||||
};
|
||||
}, [openedTabs, updateTabScrollState]);
|
||||
|
||||
useEffect(() => {
|
||||
const track = tabsTrackRef.current;
|
||||
const activeTabElement = track?.querySelector<HTMLElement>('.workspace-tab.is-active');
|
||||
activeTabElement?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
|
||||
updateTabScrollState();
|
||||
}, [activePath, updateTabScrollState]);
|
||||
}, [syncTabViewport]);
|
||||
|
||||
const closeTab = useCallback(
|
||||
(path: string) => {
|
||||
@@ -223,6 +295,7 @@ export default function WorkspaceTabs() {
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
sessionStorage.removeItem(OPEN_TABS_KEY);
|
||||
sessionStorage.removeItem(OPEN_TABS_SCROLL_KEY);
|
||||
} catch {}
|
||||
await HttpUtil.post('/logout');
|
||||
window.location.href = window.ADMIN_UI_BASE_PATH || '/';
|
||||
@@ -335,7 +408,7 @@ export default function WorkspaceTabs() {
|
||||
className="workspace-tabs__track"
|
||||
role="tablist"
|
||||
aria-label="已打开页面"
|
||||
onScroll={updateTabScrollState}
|
||||
onScroll={handleTabScroll}
|
||||
>
|
||||
{openedTabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
@@ -349,7 +422,8 @@ export default function WorkspaceTabs() {
|
||||
aria-label={tab.title}
|
||||
aria-selected={active}
|
||||
title={tab.title}
|
||||
onClick={() => navigate(tab.path)}
|
||||
onPointerDown={rememberTabScrollBeforePointer}
|
||||
onClick={() => openWorkspaceTab(tab.path)}
|
||||
>
|
||||
<Icon />
|
||||
<span className="workspace-tab__label">{tab.title}</span>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router';
|
||||
import { afterEach, expect, test } from 'vitest';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import WorkspaceTabs from '@/layouts/WorkspaceTabs';
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
const workspaceLayoutSpies: Array<{ mockRestore: () => void }> = [];
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return <output aria-label="当前路由">{location.pathname}</output>;
|
||||
@@ -30,6 +32,7 @@ function renderTabs(initialPath: string) {
|
||||
|
||||
afterEach(() => {
|
||||
sessionStorage.clear();
|
||||
workspaceLayoutSpies.splice(0).forEach((spy) => spy.mockRestore());
|
||||
});
|
||||
|
||||
test('opens the current route beside the pinned overview tab', () => {
|
||||
@@ -138,3 +141,81 @@ test('opens and closes the order management tab', () => {
|
||||
expect(screen.getByLabelText('当前路由').textContent).toBe('/');
|
||||
expect(screen.queryByRole('tab', { name: '订单管理' })).toBeNull();
|
||||
});
|
||||
|
||||
test('keeps the horizontal tab position when switching between visible tabs', async () => {
|
||||
const makeRect = (left: number, right: number) =>
|
||||
({
|
||||
x: left,
|
||||
y: 0,
|
||||
top: 0,
|
||||
bottom: 40,
|
||||
left,
|
||||
right,
|
||||
width: right - left,
|
||||
height: 40,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
|
||||
workspaceLayoutSpies.push(
|
||||
vi
|
||||
.spyOn(HTMLElement.prototype, 'clientWidth', 'get')
|
||||
.mockImplementation(function (this: HTMLElement) {
|
||||
return this.classList.contains('workspace-tabs__track') ||
|
||||
this.classList.contains('workspace-tabs__rail')
|
||||
? 300
|
||||
: 0;
|
||||
}),
|
||||
vi
|
||||
.spyOn(HTMLElement.prototype, 'scrollWidth', 'get')
|
||||
.mockImplementation(function (this: HTMLElement) {
|
||||
return this.classList.contains('workspace-tabs__track') ? 600 : this.clientWidth;
|
||||
}),
|
||||
vi
|
||||
.spyOn(Element.prototype, 'getBoundingClientRect')
|
||||
.mockImplementation(function (this: Element) {
|
||||
if (this.classList.contains('workspace-tabs__track')) return makeRect(0, 300);
|
||||
if (this.classList.contains('workspace-tab')) return makeRect(50, 150);
|
||||
return makeRect(0, 0);
|
||||
}),
|
||||
);
|
||||
|
||||
sessionStorage.setItem('game-admin-open-tabs', '["/","/players","/accounts"]');
|
||||
sessionStorage.setItem('game-admin-open-tabs-scroll-left', '200');
|
||||
|
||||
renderWithProviders(
|
||||
<MemoryRouter initialEntries={['/players']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/players"
|
||||
element={
|
||||
<>
|
||||
<WorkspaceTabs key="players" />
|
||||
<LocationProbe />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/accounts"
|
||||
element={
|
||||
<>
|
||||
<WorkspaceTabs key="accounts" />
|
||||
<LocationProbe />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const playerTrack = document.querySelector<HTMLElement>('.workspace-tabs__track')!;
|
||||
await waitFor(() => expect(playerTrack.scrollLeft).toBe(200));
|
||||
|
||||
const accountTab = screen.getByRole('tab', { name: '账号管理' });
|
||||
fireEvent.pointerDown(accountTab);
|
||||
fireEvent.click(accountTab);
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText('当前路由').textContent).toBe('/accounts'));
|
||||
const accountTrack = document.querySelector<HTMLElement>('.workspace-tabs__track')!;
|
||||
expect(accountTrack).not.toBe(playerTrack);
|
||||
await waitFor(() => expect(accountTrack.scrollLeft).toBe(200));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user