feat: add sidebar navigation arrows

This commit is contained in:
lucaronin
2026-04-30 04:39:49 +02:00
parent a7ed9adaae
commit a1a4cb50d0
22 changed files with 306 additions and 21 deletions

View File

@@ -65,6 +65,10 @@ interface SidebarProps {
locale?: AppLocale
onCollapse?: () => void
loading?: boolean
canGoBack?: boolean
canGoForward?: boolean
onGoBack?: () => void
onGoForward?: () => void
}
interface SidebarNavigationProps extends Pick<
@@ -633,7 +637,14 @@ export const Sidebar = memo(function Sidebar(props: SidebarProps) {
return (
<aside className="flex h-full flex-col overflow-hidden border-r border-[var(--sidebar-border)] bg-sidebar text-sidebar-foreground">
<SidebarTitleBar locale={locale} onCollapse={props.onCollapse} />
<SidebarTitleBar
locale={locale}
onCollapse={props.onCollapse}
canGoBack={props.canGoBack}
canGoForward={props.canGoForward}
onGoBack={props.onGoBack}
onGoForward={props.onGoForward}
/>
<SidebarRuntimeNavigation props={props} runtime={runtime} />
<SidebarInteractionOverlays locale={locale} runtime={runtime} />
</aside>

View File

@@ -0,0 +1,87 @@
import type { ComponentProps } from 'react'
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { SidebarNavArrows } from './SidebarNavArrows'
const { trackNavigationHistoryButtonClickedMock } = vi.hoisted(() => ({
trackNavigationHistoryButtonClickedMock: vi.fn(),
}))
vi.mock('../../lib/productAnalytics', () => ({
trackNavigationHistoryButtonClicked: trackNavigationHistoryButtonClickedMock,
}))
function renderArrows(props: Partial<ComponentProps<typeof SidebarNavArrows>> = {}) {
const defaults = {
canGoBack: true,
canGoForward: true,
onGoBack: vi.fn(),
onGoForward: vi.fn(),
}
const merged = { ...defaults, ...props }
return {
...render(
<TooltipProvider>
<SidebarNavArrows {...merged} />
</TooltipProvider>,
),
props: merged,
}
}
describe('SidebarNavArrows', () => {
beforeEach(() => {
trackNavigationHistoryButtonClickedMock.mockClear()
})
it('renders back and forward buttons with default English labels', () => {
renderArrows()
expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Forward' })).toBeInTheDocument()
})
it('localizes button labels', () => {
renderArrows({ locale: 'zh-CN' })
expect(screen.getByRole('button', { name: '返回' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '前进' })).toBeInTheDocument()
})
it('calls and tracks back navigation when back is clicked', () => {
const { props } = renderArrows()
fireEvent.click(screen.getByRole('button', { name: 'Back' }))
expect(props.onGoBack).toHaveBeenCalledOnce()
expect(trackNavigationHistoryButtonClickedMock).toHaveBeenCalledWith('back')
})
it('calls and tracks forward navigation when forward is clicked', () => {
const { props } = renderArrows()
fireEvent.click(screen.getByRole('button', { name: 'Forward' }))
expect(props.onGoForward).toHaveBeenCalledOnce()
expect(trackNavigationHistoryButtonClickedMock).toHaveBeenCalledWith('forward')
})
it('disables back navigation when history is unavailable', () => {
const { props } = renderArrows({ canGoBack: false })
const back = screen.getByRole('button', { name: 'Back' })
expect(back).toBeDisabled()
fireEvent.click(back)
expect(props.onGoBack).not.toHaveBeenCalled()
expect(trackNavigationHistoryButtonClickedMock).not.toHaveBeenCalled()
})
it('disables forward navigation when future history is unavailable', () => {
const { props } = renderArrows({ canGoForward: false })
const forward = screen.getByRole('button', { name: 'Forward' })
expect(forward).toBeDisabled()
fireEvent.click(forward)
expect(props.onGoForward).not.toHaveBeenCalled()
expect(trackNavigationHistoryButtonClickedMock).not.toHaveBeenCalled()
})
it('marks both buttons as non-drag controls', () => {
renderArrows()
expect(screen.getByRole('button', { name: 'Back' })).toHaveAttribute('data-no-drag')
expect(screen.getByRole('button', { name: 'Forward' })).toHaveAttribute('data-no-drag')
})
})

View File

@@ -0,0 +1,73 @@
import { CaretLeft, CaretRight } from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
import { ActionTooltip } from '@/components/ui/action-tooltip'
import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../../hooks/appCommandCatalog'
import { translate, type AppLocale } from '../../lib/i18n'
import { trackNavigationHistoryButtonClicked } from '../../lib/productAnalytics'
export interface SidebarNavArrowsProps {
canGoBack: boolean
canGoForward: boolean
onGoBack: () => void
onGoForward: () => void
locale?: AppLocale
}
const navButtonClass = 'h-6 w-6 rounded text-muted-foreground hover:text-foreground disabled:opacity-40'
export function SidebarNavArrows({
canGoBack,
canGoForward,
onGoBack,
onGoForward,
locale = 'en',
}: SidebarNavArrowsProps) {
const backLabel = translate(locale, 'sidebar.nav.back')
const forwardLabel = translate(locale, 'sidebar.nav.forward')
const backShortcut = getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewGoBack)
const forwardShortcut = getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewGoForward)
const handleBackClick = () => {
trackNavigationHistoryButtonClicked('back')
onGoBack()
}
const handleForwardClick = () => {
trackNavigationHistoryButtonClicked('forward')
onGoForward()
}
return (
<div className="flex items-center gap-1" data-testid="sidebar-nav-arrows">
<ActionTooltip copy={{ label: backLabel, shortcut: backShortcut }} side="bottom">
<Button
type="button"
variant="ghost"
size="icon-xs"
className={navButtonClass}
disabled={!canGoBack}
onClick={handleBackClick}
aria-label={backLabel}
data-no-drag
data-testid="sidebar-nav-back"
>
<CaretLeft size={14} weight="bold" />
</Button>
</ActionTooltip>
<ActionTooltip copy={{ label: forwardLabel, shortcut: forwardShortcut }} side="bottom">
<Button
type="button"
variant="ghost"
size="icon-xs"
className={navButtonClass}
disabled={!canGoForward}
onClick={handleForwardClick}
aria-label={forwardLabel}
data-no-drag
data-testid="sidebar-nav-forward"
>
<CaretRight size={14} weight="bold" />
</Button>
</ActionTooltip>
</div>
)
}

View File

@@ -14,6 +14,7 @@ import {
CaretLeft, Plus,
} from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
import { TooltipProvider } from '@/components/ui/tooltip'
import {
type SectionGroup, isSelectionActive, SectionContent, VisibilityPopover,
} from '../SidebarParts'
@@ -21,6 +22,7 @@ import { TypeCustomizePopover } from '../TypeCustomizePopover'
import { useDragRegion } from '../../hooks/useDragRegion'
import { SidebarGroupHeader } from './SidebarGroupHeader'
import { SidebarViewItem } from './SidebarViewItem'
import { SidebarNavArrows } from './SidebarNavArrows'
import { computeReorder } from './sidebarHooks'
import { countByFilter } from '../../utils/noteListHelpers'
import { translate, type AppLocale } from '../../lib/i18n'
@@ -302,27 +304,55 @@ export function TypesSection({
)
}
export function SidebarTitleBar({ locale = 'en', onCollapse }: { locale?: AppLocale; onCollapse?: () => void }) {
const noopNavigation = () => {}
export function SidebarTitleBar({
locale = 'en',
onCollapse,
canGoBack = false,
canGoForward = false,
onGoBack = noopNavigation,
onGoForward = noopNavigation,
}: {
locale?: AppLocale
onCollapse?: () => void
canGoBack?: boolean
canGoForward?: boolean
onGoBack?: () => void
onGoForward?: () => void
}) {
const { onMouseDown } = useDragRegion()
return (
<div
className="shrink-0 flex items-center justify-end border-b border-border"
style={{ height: 52, padding: '0 8px', paddingLeft: 80, cursor: 'default' }}
onMouseDown={onMouseDown}
>
{onCollapse && (
<button
className="flex shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
style={{ width: 24, height: 24 }}
onClick={onCollapse}
aria-label={translate(locale, 'sidebar.action.collapse')}
title={translate(locale, 'sidebar.action.collapse')}
>
<CaretLeft size={14} weight="bold" />
</button>
)}
</div>
<TooltipProvider>
<div
className="flex shrink-0 items-center justify-between gap-2 border-b border-border"
style={{ height: 52, padding: '0 8px', paddingLeft: 80, cursor: 'default' }}
onMouseDown={onMouseDown}
>
<SidebarNavArrows
canGoBack={canGoBack}
canGoForward={canGoForward}
onGoBack={onGoBack}
onGoForward={onGoForward}
locale={locale}
/>
{onCollapse && (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-6 w-6 rounded text-muted-foreground hover:text-foreground"
onClick={onCollapse}
aria-label={translate(locale, 'sidebar.action.collapse')}
title={translate(locale, 'sidebar.action.collapse')}
data-no-drag
>
<CaretLeft size={14} weight="bold" />
</Button>
)}
</div>
</TooltipProvider>
)
}

View File

@@ -0,0 +1,51 @@
import type { ComponentProps } from 'react'
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { SidebarTitleBar } from './SidebarSections'
function renderTitleBar(overrides: Partial<ComponentProps<typeof SidebarTitleBar>> = {}) {
const props = {
canGoBack: true,
canGoForward: true,
onGoBack: vi.fn(),
onGoForward: vi.fn(),
onCollapse: vi.fn(),
...overrides,
}
return {
...render(<SidebarTitleBar {...props} />),
props,
}
}
describe('SidebarTitleBar', () => {
it('renders navigation arrows and the collapse button together', () => {
renderTitleBar()
expect(screen.getByTestId('sidebar-nav-arrows')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Forward' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Collapse sidebar' })).toBeInTheDocument()
})
it('forwards back and forward clicks', () => {
const { props } = renderTitleBar()
fireEvent.click(screen.getByRole('button', { name: 'Back' }))
fireEvent.click(screen.getByRole('button', { name: 'Forward' }))
expect(props.onGoBack).toHaveBeenCalledOnce()
expect(props.onGoForward).toHaveBeenCalledOnce()
})
it('keeps the collapse button clickable and outside the drag region', () => {
const { props } = renderTitleBar()
const collapse = screen.getByRole('button', { name: 'Collapse sidebar' })
fireEvent.click(collapse)
expect(props.onCollapse).toHaveBeenCalledOnce()
expect(collapse).toHaveAttribute('data-no-drag')
})
it('hides collapse when unavailable but keeps history controls visible', () => {
renderTitleBar({ onCollapse: undefined })
expect(screen.getByTestId('sidebar-nav-arrows')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Collapse sidebar' })).not.toBeInTheDocument()
})
})