diff --git a/frontend/src/App.sidebar-collapse.test.ts b/frontend/src/App.sidebar-collapse.test.ts index 3e5036ff..fe0cfe7d 100644 --- a/frontend/src/App.sidebar-collapse.test.ts +++ b/frontend/src/App.sidebar-collapse.test.ts @@ -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(''); - 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('', explorerToggleIndex); const explorerToggleStartIndex = sidebarSource.lastIndexOf(''); + const railPrimaryActionsIndex = connectionRailSource.indexOf('
{ 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', () => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c343d139..2749caa6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(null); + const sidebarCollapsedToggleRef = useRef(null); const sidebarExplorerToggleRef = useRef(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() { }} /> GoNavi - {(!isV2Ui || isSidebarCollapsed) && ( + {!isV2Ui && (
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 28ce369d..52c92d18 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -511,8 +511,11 @@ const Sidebar: React.FC<{ uiVersion?: 'legacy' | 'v2'; onFocusCommandSearch?: () => void; onCollapseSidebar?: () => void; + onExpandSidebar?: () => void; collapseSidebarLabel?: string; collapseSidebarButtonRef?: React.Ref; + expandSidebarLabel?: string; + expandSidebarButtonRef?: React.Ref; 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: ( <> { 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( + , + ); + + 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(); diff --git a/frontend/src/components/sidebar/SidebarConnectionRail.tsx b/frontend/src/components/sidebar/SidebarConnectionRail.tsx index c2c99739..7ca40fa9 100644 --- a/frontend/src/components/sidebar/SidebarConnectionRail.tsx +++ b/frontend/src/components/sidebar/SidebarConnectionRail.tsx @@ -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; + }; workbenchActions?: React.ReactNode; } -const SidebarConnectionRail: React.FC = ({ labels, handlers, canLocateActiveTab, workbenchActions }) => ( +const SidebarConnectionRail: React.FC = ({ labels, handlers, canLocateActiveTab, sidebarExpandAction, workbenchActions }) => (
+ {sidebarExpandAction && ( +
+ + + +
+ )}