修复顶部标签切换时位置重置
This commit is contained in:
@@ -51,7 +51,6 @@
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
overscroll-behavior-x: contain;
|
overscroll-behavior-x: contain;
|
||||||
scroll-behavior: smooth;
|
|
||||||
scrollbar-width: none;
|
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 type { ComponentType } from 'react';
|
||||||
import { Dropdown, Tooltip } from 'antd';
|
import { Dropdown, Tooltip } from 'antd';
|
||||||
import type { MenuProps } from 'antd';
|
import type { MenuProps } from 'antd';
|
||||||
@@ -28,7 +28,9 @@ import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
|||||||
import './WorkspaceTabs.css';
|
import './WorkspaceTabs.css';
|
||||||
|
|
||||||
const OPEN_TABS_KEY = 'game-admin-open-tabs';
|
const OPEN_TABS_KEY = 'game-admin-open-tabs';
|
||||||
|
const OPEN_TABS_SCROLL_KEY = 'game-admin-open-tabs-scroll-left';
|
||||||
const HOME_PATH = '/';
|
const HOME_PATH = '/';
|
||||||
|
let savedTabsScrollLeft = 0;
|
||||||
|
|
||||||
interface WorkspaceTabDefinition {
|
interface WorkspaceTabDefinition {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -65,6 +67,23 @@ function saveOpenedPaths(paths: string[]) {
|
|||||||
} catch {}
|
} 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) {
|
function readOpenedPaths(activePath: string) {
|
||||||
let storedPaths: string[] = [];
|
let storedPaths: string[] = [];
|
||||||
|
|
||||||
@@ -94,8 +113,11 @@ export default function WorkspaceTabs() {
|
|||||||
const [openedPaths, setOpenedPaths] = useState(() => readOpenedPaths(activePath));
|
const [openedPaths, setOpenedPaths] = useState(() => readOpenedPaths(activePath));
|
||||||
const tabsRailRef = useRef<HTMLDivElement>(null);
|
const tabsRailRef = useRef<HTMLDivElement>(null);
|
||||||
const tabsTrackRef = 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({
|
const [tabScrollState, setTabScrollState] = useState({
|
||||||
hasOverflow: false,
|
hasOverflow: true,
|
||||||
canScrollLeft: false,
|
canScrollLeft: false,
|
||||||
canScrollRight: 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(() => {
|
useEffect(() => {
|
||||||
saveOpenedPaths(openedPaths);
|
saveOpenedPaths(openedPaths);
|
||||||
}, [openedPaths]);
|
}, [openedPaths]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const animationFrame = window.requestAnimationFrame(syncTabViewport);
|
||||||
|
return () => window.cancelAnimationFrame(animationFrame);
|
||||||
|
}, [activePath, openedTabs, syncTabViewport]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const rail = tabsRailRef.current;
|
const rail = tabsRailRef.current;
|
||||||
const track = tabsTrackRef.current;
|
const track = tabsTrackRef.current;
|
||||||
if (!rail || !track) return;
|
if (!rail || !track) return;
|
||||||
|
|
||||||
updateTabScrollState();
|
|
||||||
|
|
||||||
const resizeObserver =
|
const resizeObserver =
|
||||||
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updateTabScrollState);
|
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(syncTabViewport);
|
||||||
resizeObserver?.observe(rail);
|
resizeObserver?.observe(rail);
|
||||||
resizeObserver?.observe(track);
|
resizeObserver?.observe(track);
|
||||||
window.addEventListener('resize', updateTabScrollState);
|
window.addEventListener('resize', syncTabViewport);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
if (!preserveScrollOnUnmountRef.current) saveTabsScrollLeft(track.scrollLeft);
|
||||||
resizeObserver?.disconnect();
|
resizeObserver?.disconnect();
|
||||||
window.removeEventListener('resize', updateTabScrollState);
|
window.removeEventListener('resize', syncTabViewport);
|
||||||
};
|
};
|
||||||
}, [openedTabs, updateTabScrollState]);
|
}, [syncTabViewport]);
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const closeTab = useCallback(
|
const closeTab = useCallback(
|
||||||
(path: string) => {
|
(path: string) => {
|
||||||
@@ -223,6 +295,7 @@ export default function WorkspaceTabs() {
|
|||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
sessionStorage.removeItem(OPEN_TABS_KEY);
|
sessionStorage.removeItem(OPEN_TABS_KEY);
|
||||||
|
sessionStorage.removeItem(OPEN_TABS_SCROLL_KEY);
|
||||||
} catch {}
|
} catch {}
|
||||||
await HttpUtil.post('/logout');
|
await HttpUtil.post('/logout');
|
||||||
window.location.href = window.ADMIN_UI_BASE_PATH || '/';
|
window.location.href = window.ADMIN_UI_BASE_PATH || '/';
|
||||||
@@ -335,7 +408,7 @@ export default function WorkspaceTabs() {
|
|||||||
className="workspace-tabs__track"
|
className="workspace-tabs__track"
|
||||||
role="tablist"
|
role="tablist"
|
||||||
aria-label="已打开页面"
|
aria-label="已打开页面"
|
||||||
onScroll={updateTabScrollState}
|
onScroll={handleTabScroll}
|
||||||
>
|
>
|
||||||
{openedTabs.map((tab) => {
|
{openedTabs.map((tab) => {
|
||||||
const Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
@@ -349,7 +422,8 @@ export default function WorkspaceTabs() {
|
|||||||
aria-label={tab.title}
|
aria-label={tab.title}
|
||||||
aria-selected={active}
|
aria-selected={active}
|
||||||
title={tab.title}
|
title={tab.title}
|
||||||
onClick={() => navigate(tab.path)}
|
onPointerDown={rememberTabScrollBeforePointer}
|
||||||
|
onClick={() => openWorkspaceTab(tab.path)}
|
||||||
>
|
>
|
||||||
<Icon />
|
<Icon />
|
||||||
<span className="workspace-tab__label">{tab.title}</span>
|
<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 { 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 WorkspaceTabs from '@/layouts/WorkspaceTabs';
|
||||||
import { renderWithProviders } from './test-utils';
|
import { renderWithProviders } from './test-utils';
|
||||||
|
|
||||||
|
const workspaceLayoutSpies: Array<{ mockRestore: () => void }> = [];
|
||||||
|
|
||||||
function LocationProbe() {
|
function LocationProbe() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
return <output aria-label="当前路由">{location.pathname}</output>;
|
return <output aria-label="当前路由">{location.pathname}</output>;
|
||||||
@@ -30,6 +32,7 @@ function renderTabs(initialPath: string) {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
sessionStorage.clear();
|
sessionStorage.clear();
|
||||||
|
workspaceLayoutSpies.splice(0).forEach((spy) => spy.mockRestore());
|
||||||
});
|
});
|
||||||
|
|
||||||
test('opens the current route beside the pinned overview tab', () => {
|
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.getByLabelText('当前路由').textContent).toBe('/');
|
||||||
expect(screen.queryByRole('tab', { name: '订单管理' })).toBeNull();
|
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