fix(ui): reserve space for pinned sidebar

Keep page content accessible when the desktop sidebar remains expanded and cover the complete pin lifecycle.
This commit is contained in:
PathGao committed 2026-07-30 14:52:38 +08:00
1 parent 91c5d7b19f
commit ac584cfc90
3 files changed
+31 -11

No files matched your search

+1 -1
View File
@@ -12,7 +12,7 @@
align-self: flex-start; align-self: flex-start;
} }
.ant-sidebar > .ant-layout-sider:not(.ant-layout-sider-collapsed) { .ant-sidebar:not(.sidebar-pinned) > .ant-layout-sider:not(.ant-layout-sider-collapsed) {
box-shadow: 0 0 32px rgba(0, 0, 0, 0.22); box-shadow: 0 0 32px rgba(0, 0, 0, 0.22);
} }
+11 -9
View File
@@ -46,8 +46,8 @@ const DOCS_URL = 'https://docs.sanaei.dev/';
const REPO_URL = 'https://github.com/MHSanaei/3x-ui'; const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
const LOGOUT_KEY = '__logout__'; const LOGOUT_KEY = '__logout__';
const RAIL_WIDTH = 72; const RAIL_WIDTH = 72;
const SIDER_WIDTH = 220;
const SIDEBAR_PINNED_KEY = 'sidebar-pinned'; const SIDEBAR_PINNED_KEY = 'sidebar-pinned';
const railStyle = { '--sider-rail': `${RAIL_WIDTH}px` } as CSSProperties;
let hoveredAcrossRemounts = false; let hoveredAcrossRemounts = false;
@@ -164,6 +164,10 @@ export default function AppSidebar() {
const [pinned, setPinned] = useState(readSidebarPinned); const [pinned, setPinned] = useState(readSidebarPinned);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const railCollapsed = !hovered && !pinned; const railCollapsed = !hovered && !pinned;
const railStyle = useMemo(
() => ({ '--sider-rail': `${pinned ? SIDER_WIDTH : RAIL_WIDTH}px` }) as CSSProperties,
[pinned],
);
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const updateHovered = useCallback((value: boolean) => { const updateHovered = useCallback((value: boolean) => {
@@ -172,12 +176,10 @@ export default function AppSidebar() {
}, []); }, []);
const togglePinned = useCallback(() => { const togglePinned = useCallback(() => {
setPinned((value) => { const next = !pinned;
const next = !value;
saveSidebarPinned(next); saveSidebarPinned(next);
return next; setPinned(next);
}); }, [pinned]);
}, []);
useEffect(() => { useEffect(() => {
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
@@ -287,14 +289,14 @@ export default function AppSidebar() {
return ( return (
<div <div
ref={rootRef} ref={rootRef}
className="ant-sidebar" className={`ant-sidebar${pinned ? ' sidebar-pinned' : ''}`}
style={railStyle} style={railStyle}
onMouseEnter={() => updateHovered(true)} onMouseEnter={() => updateHovered(true)}
onMouseLeave={() => updateHovered(false)} onMouseLeave={() => updateHovered(false)}
> >
<Layout.Sider <Layout.Sider
theme={currentTheme} theme={currentTheme}
width={220} width={SIDER_WIDTH}
collapsedWidth={RAIL_WIDTH} collapsedWidth={RAIL_WIDTH}
collapsed={railCollapsed} collapsed={railCollapsed}
> >
@@ -307,7 +309,7 @@ export default function AppSidebar() {
<button <button
type="button" type="button"
className="sidebar-pin" className="sidebar-pin"
aria-label={t(pinned ? 'menu.unpinSidebar' : 'menu.pinSidebar')} aria-label={t('menu.pinSidebar')}
aria-pressed={pinned} aria-pressed={pinned}
title={t(pinned ? 'menu.unpinSidebar' : 'menu.pinSidebar')} title={t(pinned ? 'menu.unpinSidebar' : 'menu.pinSidebar')}
onClick={togglePinned} onClick={togglePinned}
+19 -1
View File
@@ -37,13 +37,31 @@ test('keeps the sidebar expanded after pinning it from the header and restores t
fireEvent.mouseLeave(sidebarRoot!); fireEvent.mouseLeave(sidebarRoot!);
expect(sidebar?.classList.contains('ant-layout-sider-collapsed')).toBe(false); expect(sidebar?.classList.contains('ant-layout-sider-collapsed')).toBe(false);
expect(sidebarRoot?.getAttribute('style')).toContain('--sider-rail: 220px');
expect(localStorage.getItem('sidebar-pinned')).toBe('true'); expect(localStorage.getItem('sidebar-pinned')).toBe('true');
first.unmount(); first.unmount();
const second = renderSidebar(); const second = renderSidebar();
const restoredSidebar = second.container.querySelector('.ant-layout-sider'); const restoredSidebar = second.container.querySelector('.ant-layout-sider');
const restoredSidebarRoot = second.container.querySelector('.ant-sidebar');
expect(restoredSidebar?.classList.contains('ant-layout-sider-collapsed')).toBe(false); expect(restoredSidebar?.classList.contains('ant-layout-sider-collapsed')).toBe(false);
expect(screen.getByRole('button', { name: 'Unpin sidebar' })).not.toBeNull(); expect(restoredSidebarRoot?.getAttribute('style')).toContain('--sider-rail: 220px');
expect(screen.getByRole('button', { name: 'Pin sidebar' })).not.toBeNull();
});
test('returns to the compact rail after unpinning', () => {
const view = renderSidebar();
const sidebar = view.container.querySelector('.ant-layout-sider');
const sidebarRoot = view.container.querySelector('.ant-sidebar');
fireEvent.mouseEnter(sidebarRoot!);
fireEvent.click(screen.getByRole('button', { name: 'Pin sidebar' }));
fireEvent.click(screen.getByRole('button', { name: 'Pin sidebar' }));
fireEvent.mouseLeave(sidebarRoot!);
expect(sidebar?.classList.contains('ant-layout-sider-collapsed')).toBe(true);
expect(sidebarRoot?.getAttribute('style')).toContain('--sider-rail: 72px');
expect(localStorage.getItem('sidebar-pinned')).toBe('false');
}); });