🐛 fix(sidebar): 调整折叠状态下的展开按钮位置

- 将 V2 侧栏展开按钮从标题栏移至固定工具栏顶部
- 保持展开与折叠后的焦点回迁和无障碍属性
- 补充固定栏按钮顺序、交互和样式回归测试
This commit is contained in:
Syngnat
2026-07-24 13:59:43 +08:00
parent 300eb3d48e
commit aee4da47db
6 changed files with 128 additions and 15 deletions

View File

@@ -30,6 +30,7 @@ describe('app sidebar tree panel collapse', () => {
expect(appSource).toContain('data-sidebar-collapse-trigger="true"');
expect(appSource).toContain('data-titlebar-brand-region="true"');
expect(appSource).toContain('data-sidebar-toggle-placement="titlebar"');
expect(connectionRailSource).toContain('data-sidebar-toggle-placement="fixed-rail"');
expect(appSource).toContain('data-no-titlebar-toggle="true"');
expect(appSource).toContain('aria-controls="gonavi-sidebar-tree-panel"');
expect(appSource).toContain('aria-expanded={!isSidebarCollapsed}');
@@ -40,13 +41,15 @@ describe('app sidebar tree panel collapse', () => {
expect(appSource).toContain("case 'focusSidebarSearch':");
expect(appSource).toContain('handleFocusSidebarSearch();');
expect(appSource).toContain('<TabManager onFocusSidebarSearch={handleFocusSidebarSearch} />');
expect(appSource).toContain('{(!isV2Ui || isSidebarCollapsed) && (');
expect(appSource).toContain('{!isV2Ui && (');
expect(appSource).toContain('onCollapseSidebar={isV2Ui && !isSidebarCollapsed ? handleCollapseSidebarPanel : undefined}');
expect(appSource).toContain('onExpandSidebar={isV2Ui && isSidebarCollapsed ? handleExpandSidebarPanel : undefined}');
expect(appSource).toContain('collapseSidebarButtonRef={sidebarExplorerToggleRef}');
expect(appSource).toContain('ref={sidebarTitlebarToggleRef}');
expect(appSource).toContain("pendingSidebarToggleFocusRef.current = 'titlebar'");
expect(appSource).toContain('expandSidebarButtonRef={sidebarCollapsedToggleRef}');
expect(appSource).toContain('ref={sidebarCollapsedToggleRef}');
expect(appSource).toContain("pendingSidebarToggleFocusRef.current = 'collapsed'");
expect(appSource).toContain("pendingSidebarToggleFocusRef.current = 'explorer'");
expect(appSource).toContain("(target === 'titlebar' ? sidebarTitlebarToggleRef : sidebarExplorerToggleRef).current?.focus()");
expect(appSource).toContain("(target === 'collapsed' ? sidebarCollapsedToggleRef : sidebarExplorerToggleRef).current?.focus()");
expect(sidebarSource).toContain('data-sidebar-toggle-placement="explorer-header"');
const titlebarToggleIndex = appSource.indexOf('data-sidebar-collapse-trigger="true"');
@@ -64,6 +67,10 @@ describe('app sidebar tree panel collapse', () => {
const explorerToggleEndIndex = sidebarSource.indexOf('</Tooltip>', explorerToggleIndex);
const explorerToggleStartIndex = sidebarSource.lastIndexOf('<Button', explorerToggleIndex);
const explorerToggleSource = sidebarSource.slice(explorerToggleStartIndex, explorerToggleEndIndex);
const fixedRailToggleIndex = connectionRailSource.indexOf('data-sidebar-toggle-placement="fixed-rail"');
const railItemsIndex = connectionRailSource.indexOf('<div className="gn-v2-rail-items">');
const railPrimaryActionsIndex = connectionRailSource.indexOf('<div className="gn-v2-rail-primary-actions"');
const firstRailObjectActionIndex = connectionRailSource.indexOf('data-sidebar-create-group-action="true"');
expect(titlebarToggleIndex).toBeGreaterThan(appSource.indexOf('data-titlebar-brand-region="true"'));
expect(titlebarToggleIndex).toBeLessThan(siderIndex);
expect(triggerSource).toContain('type="text"');
@@ -75,6 +82,10 @@ describe('app sidebar tree panel collapse', () => {
expect(explorerToggleIndex).toBeGreaterThan(connectionMenuIndex);
expect(explorerToggleSource).toContain('ref={collapseSidebarButtonRef}');
expect(explorerToggleSource).not.toContain('disabled=');
expect(fixedRailToggleIndex).toBeGreaterThan(-1);
expect(fixedRailToggleIndex).toBeGreaterThan(railItemsIndex);
expect(fixedRailToggleIndex).toBeGreaterThan(railPrimaryActionsIndex);
expect(fixedRailToggleIndex).toBeLessThan(firstRailObjectActionIndex);
});
it('overrides normal resize bounds with the retained rail width and removes the collapsed resize target', () => {

View File

@@ -1073,24 +1073,25 @@ function App() {
const sidebarWidth = useStore(state => state.sidebarWidth);
const setSidebarWidth = useStore(state => state.setSidebarWidth);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const sidebarTitlebarToggleRef = useRef<HTMLButtonElement>(null);
const sidebarCollapsedToggleRef = useRef<HTMLButtonElement>(null);
const sidebarExplorerToggleRef = useRef<HTMLButtonElement>(null);
const pendingSidebarToggleFocusRef = useRef<'titlebar' | 'explorer' | null>(null);
const pendingSidebarToggleFocusRef = useRef<'collapsed' | 'explorer' | null>(null);
const handleCollapseSidebarPanel = useCallback(() => {
pendingSidebarToggleFocusRef.current = 'titlebar';
pendingSidebarToggleFocusRef.current = 'collapsed';
setIsSidebarCollapsed(true);
}, []);
const handleExpandSidebarPanel = useCallback(() => {
pendingSidebarToggleFocusRef.current = 'explorer';
setIsSidebarCollapsed(false);
}, []);
const handleTitlebarSidebarToggle = useCallback(() => {
if (isV2Ui && isSidebarCollapsed) {
pendingSidebarToggleFocusRef.current = 'explorer';
}
setIsSidebarCollapsed((collapsed) => !collapsed);
}, [isSidebarCollapsed, isV2Ui]);
}, []);
useEffect(() => {
const target = pendingSidebarToggleFocusRef.current;
if (!target) return;
pendingSidebarToggleFocusRef.current = null;
(target === 'titlebar' ? sidebarTitlebarToggleRef : sidebarExplorerToggleRef).current?.focus();
(target === 'collapsed' ? sidebarCollapsedToggleRef : sidebarExplorerToggleRef).current?.focus();
}, [isSidebarCollapsed]);
const sidebarCollapsedWidth = isV2Ui ? 38 * effectiveUiScale * effectiveSidebarRailScale : 0;
const renderedSidebarWidth = isSidebarCollapsed ? sidebarCollapsedWidth : sidebarWidth;
@@ -7522,10 +7523,10 @@ function App() {
}}
/>
<span>GoNavi</span>
{(!isV2Ui || isSidebarCollapsed) && (
{!isV2Ui && (
<Tooltip title={sidebarPanelToggleLabel} placement="bottom" mouseEnterDelay={0.35}>
<Button
ref={sidebarTitlebarToggleRef}
ref={sidebarCollapsedToggleRef}
type="text"
size="small"
className="gonavi-sidebar-collapse-trigger"
@@ -7665,8 +7666,11 @@ function App() {
uiVersion={appearance.uiVersion}
onFocusCommandSearch={handleFocusSidebarSearch}
onCollapseSidebar={isV2Ui && !isSidebarCollapsed ? handleCollapseSidebarPanel : undefined}
onExpandSidebar={isV2Ui && isSidebarCollapsed ? handleExpandSidebarPanel : undefined}
collapseSidebarLabel={sidebarPanelToggleLabel}
collapseSidebarButtonRef={sidebarExplorerToggleRef}
expandSidebarLabel={sidebarPanelToggleLabel}
expandSidebarButtonRef={sidebarCollapsedToggleRef}
isTreePanelCollapsed={isV2Ui && isSidebarCollapsed}
/>
</div>

View File

@@ -511,8 +511,11 @@ const Sidebar: React.FC<{
uiVersion?: 'legacy' | 'v2';
onFocusCommandSearch?: () => void;
onCollapseSidebar?: () => void;
onExpandSidebar?: () => void;
collapseSidebarLabel?: string;
collapseSidebarButtonRef?: React.Ref<HTMLButtonElement>;
expandSidebarLabel?: string;
expandSidebarButtonRef?: React.Ref<HTMLButtonElement>;
isTreePanelCollapsed?: boolean;
}> = React.memo(({
onCreateConnection,
@@ -523,8 +526,11 @@ const Sidebar: React.FC<{
uiVersion,
onFocusCommandSearch,
onCollapseSidebar,
onExpandSidebar,
collapseSidebarLabel,
collapseSidebarButtonRef,
expandSidebarLabel,
expandSidebarButtonRef,
isTreePanelCollapsed = false,
}) => {
const connections = useStore(state => state.connections);
@@ -3007,6 +3013,11 @@ const Sidebar: React.FC<{
openSettings: onOpenSettings ?? (() => {}),
},
canLocateActiveTab,
sidebarExpandAction: onExpandSidebar && expandSidebarLabel ? {
label: expandSidebarLabel,
onClick: onExpandSidebar,
buttonRef: expandSidebarButtonRef,
} : undefined,
workbenchActions: (
<>
<SlowQueryRailButton

View File

@@ -16,6 +16,7 @@ vi.mock('@ant-design/icons', () => {
FileAddOutlined: Icon,
FolderOpenOutlined: Icon,
ImportOutlined: Icon,
MenuUnfoldOutlined: Icon,
RobotOutlined: Icon,
SettingOutlined: Icon,
TableOutlined: Icon,
@@ -23,6 +24,55 @@ vi.mock('@ant-design/icons', () => {
});
describe('SidebarConnectionRail', () => {
it('renders the collapsed sidebar toggle at the top of the fixed rail', () => {
const expandSidebar = vi.fn();
const noop = vi.fn();
const renderer = create(
<SidebarConnectionRail
labels={{
railSystemActions: 'System actions',
railObjectActions: 'Object actions',
newGroup: 'New group',
batchTables: 'Batch tables',
batchDatabases: 'Batch databases',
dataImport: 'Data import',
openExternalSqlFile: 'Open SQL file',
locateCurrentTable: 'Locate table',
locateCurrentTableUnavailable: 'No table',
aiAssistant: 'AI assistant',
settings: 'Settings',
}}
handlers={{
openCreateTagModal: noop,
openBatchTableExport: noop,
openBatchDatabaseExport: noop,
openDataImport: noop,
openExternalSqlFile: noop,
locateActiveTab: noop,
toggleAI: noop,
openSettings: noop,
}}
canLocateActiveTab
sidebarExpandAction={{ label: 'Expand sidebar', onClick: expandSidebar }}
/>,
);
const rail = renderer.root.findByProps({ 'data-sidebar-fixed-rail': 'true' });
const primaryActions = rail.findByProps({ className: 'gn-v2-rail-primary-actions' });
const toggleSlot = rail.findByProps({ className: 'gn-v2-rail-sidebar-toggle-slot' });
const toggle = rail.findByProps({ 'data-sidebar-toggle-placement': 'fixed-rail' });
expect(primaryActions.children.indexOf(toggleSlot)).toBe(0);
expect(primaryActions.findAllByType('button').slice(0, 2).map((button) => button.props['aria-label'])).toEqual([
'Expand sidebar',
'New group',
]);
expect(toggle.props['aria-label']).toBe('Expand sidebar');
expect(toggle.props['aria-expanded']).toBe(false);
toggle.props.onClick();
expect(expandSidebar).toHaveBeenCalledTimes(1);
});
it('opens the data import workbench from its dedicated rail action', () => {
const openDataImport = vi.fn();
const noop = vi.fn();

View File

@@ -7,6 +7,7 @@ import {
ImportOutlined,
FileAddOutlined,
AimOutlined,
MenuUnfoldOutlined,
RobotOutlined,
SettingOutlined,
} from '@ant-design/icons';
@@ -45,13 +46,37 @@ export interface SidebarConnectionRailProps {
openSettings: () => void;
};
canLocateActiveTab: boolean;
sidebarExpandAction?: {
label: string;
onClick: () => void;
buttonRef?: React.Ref<HTMLButtonElement>;
};
workbenchActions?: React.ReactNode;
}
const SidebarConnectionRail: React.FC<SidebarConnectionRailProps> = ({ labels, handlers, canLocateActiveTab, workbenchActions }) => (
const SidebarConnectionRail: React.FC<SidebarConnectionRailProps> = ({ labels, handlers, canLocateActiveTab, sidebarExpandAction, workbenchActions }) => (
<div className="gn-v2-connection-rail" data-sidebar-fixed-rail="true" aria-label={labels.railSystemActions}>
<div className="gn-v2-rail-items">
<div className="gn-v2-rail-primary-actions" aria-label={labels.railObjectActions}>
{sidebarExpandAction && (
<div className="gn-v2-rail-sidebar-toggle-slot">
<Tooltip title={sidebarExpandAction.label} placement="right" mouseEnterDelay={0.35}>
<button
ref={sidebarExpandAction.buttonRef}
type="button"
className="gn-v2-rail-tool gn-v2-rail-sidebar-toggle"
data-sidebar-collapse-trigger="true"
data-sidebar-toggle-placement="fixed-rail"
aria-label={sidebarExpandAction.label}
aria-controls="gonavi-sidebar-tree-panel"
aria-expanded={false}
onClick={sidebarExpandAction.onClick}
>
<MenuUnfoldOutlined />
</button>
</Tooltip>
</div>
)}
<Tooltip title={labels.newGroup} placement="right">
<button
type="button"

View File

@@ -1820,6 +1820,18 @@ body[data-ui-version="v2"] .gn-v2-connection-rail {
background: transparent;
}
body[data-ui-version="v2"] .gn-v2-rail-sidebar-toggle-slot {
width: 100%;
flex: 0 0 auto;
display: flex;
justify-content: center;
}
body[data-ui-version="v2"] .gn-v2-rail-sidebar-toggle:focus-visible {
outline: 2px solid var(--gn-accent);
outline-offset: 1px;
}
body[data-ui-version="v2"] .gn-v2-rail-items {
flex: 1 1 auto;
min-height: 0;