Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions apps/desktop/src/renderer/src/components/tabs/tab-bar-scroll.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ReactNode } from 'react'
import { TabBarWithDrag } from './tab-bar-with-drag'

const mocks = vi.hoisted(() => ({
tabGroup: null as Record<string, unknown> | null
}))

vi.mock('@memry/i18n/renderer', () => ({
useT: () => ({ t: (key: string) => key })
}))

vi.mock('@dnd-kit/sortable', () => ({
SortableContext: ({ children }: { children: ReactNode }) => <div>{children}</div>,
horizontalListSortingStrategy: {}
}))

vi.mock('@/contexts/day-panel-context', () => ({
useDayPanel: () => ({ isOpen: false, width: 320, isResizing: false, toggle: vi.fn() })
}))

vi.mock('@/contexts/tabs', () => ({
useTabGroup: () => mocks.tabGroup
}))

vi.mock('@/components/ui/sidebar', () => ({
useSidebar: () => ({ state: 'expanded' })
}))

vi.mock('./sortable-tab', () => ({
SortableTab: ({ tab }: { tab: { id: string; title: string } }) => (
<div data-tab-id={tab.id}>{tab.title}</div>
)
}))

vi.mock('./pinned-tab', () => ({
PinnedTab: ({ tab }: { tab: { title: string } }) => <div>{tab.title}</div>
}))

vi.mock('./tab-bar-action', () => ({
TabBarAction: ({ tooltip, onClick }: { tooltip: string; onClick: () => void }) => (
<button type="button" onClick={onClick}>
{tooltip}
</button>
)
}))

vi.mock('./new-tab-menu', () => ({
NewTabMenu: () => <button type="button">new tab</button>
}))

vi.mock('./tab-bar-context-menu', () => ({
TabBarContextMenu: ({ children }: { children: ReactNode }) => <div>{children}</div>
}))

vi.mock('./tab-context-menu', () => ({
TabContextMenu: ({ children }: { children: ReactNode }) => <div>{children}</div>
}))

/** Strip geometry jsdom does not compute — drives the chevron gutter state. */
const strip = { scrollLeft: 0, scrollWidth: 1000, clientWidth: 300 }

const defineMetric = (name: 'scrollWidth' | 'clientWidth' | 'scrollLeft'): PropertyDescriptor =>
({
configurable: true,
get: () => strip[name],
set: (value: number) => {
strip[name] = value
}
}) as PropertyDescriptor

const originalDescriptors = new Map<string, PropertyDescriptor | undefined>()

const group = (activeTabId: string): Record<string, unknown> => ({
id: 'group-1',
activeTabId,
tabs: [
{ id: 'tab-1', title: 'First', isPinned: false, type: 'note' },
{ id: 'tab-2', title: 'Second', isPinned: false, type: 'note' },
{ id: 'tab-3', title: 'Third', isPinned: false, type: 'note' }
]
})

describe('TabBarWithDrag active-tab scrolling', () => {
let scrollIntoView: ReturnType<typeof vi.fn>

beforeEach(() => {
strip.scrollLeft = 0
strip.scrollWidth = 1000
strip.clientWidth = 300

for (const name of ['scrollWidth', 'clientWidth', 'scrollLeft'] as const) {
originalDescriptors.set(name, Object.getOwnPropertyDescriptor(HTMLElement.prototype, name))
Object.defineProperty(HTMLElement.prototype, name, defineMetric(name))
}

scrollIntoView = vi.fn()
HTMLElement.prototype.scrollIntoView =
scrollIntoView as unknown as HTMLElement['scrollIntoView']
mocks.tabGroup = group('tab-1')
})

afterEach(() => {
for (const [name, descriptor] of originalDescriptors) {
if (descriptor) {
Object.defineProperty(HTMLElement.prototype, name, descriptor)
} else {
delete (HTMLElement.prototype as unknown as Record<string, unknown>)[name]
}
}
originalDescriptors.clear()
vi.restoreAllMocks()
})

it('scrolls the active tab into view once, not again as the chevron gutters resize', () => {
// Mount: the strip overflows, so checkScroll flips canScrollToEnd in the same
// commit as the scroll — the gutter re-render must not re-animate.
render(<TabBarWithDrag groupId="group-1" />)
expect(scrollIntoView).toHaveBeenCalledTimes(1)

// Scrolling past the start edge flips canScrollToStart, adding the second
// gutter. The active tab is still on screen, so no second animation.
strip.scrollLeft = 50
fireEvent.scroll(screen.getByTestId('tab-strip'))
expect(
screen.getByRole('button', { name: 'phaseF.componentsTabsTabBarWithDrag.scrollTabsLeft' })
).toBeInTheDocument()
expect(scrollIntoView).toHaveBeenCalledTimes(1)
})

it('still scrolls when a different tab becomes active', () => {
const { rerender } = render(<TabBarWithDrag groupId="group-1" />)
expect(scrollIntoView).toHaveBeenCalledTimes(1)
expect(scrollIntoView.mock.instances[0]).toHaveAttribute('data-tab-id', 'tab-1')

mocks.tabGroup = group('tab-3')
rerender(<TabBarWithDrag groupId="group-1" />)

expect(scrollIntoView).toHaveBeenCalledTimes(2)
expect(scrollIntoView.mock.instances[1]).toHaveAttribute('data-tab-id', 'tab-3')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,21 @@ const handleWheel = (e: React.WheelEvent<HTMLDivElement>): void => {
e.currentTarget.scrollLeft += e.deltaY
}

/**
* Is the tab already fully inside the strip's visible area?
* The chevron gutters are inline padding on the strip, so they are subtracted —
* a tab sitting under a chevron does not count as visible. Physical sides are
* used because that is what the rects report in both LTR and RTL.
*/
const isTabFullyVisible = (strip: HTMLElement, tabEl: Element): boolean => {
const stripRect = strip.getBoundingClientRect()
const tabRect = tabEl.getBoundingClientRect()
const style = getComputedStyle(strip)
const visibleLeft = stripRect.left + (parseFloat(style.paddingLeft) || 0)
const visibleRight = stripRect.right - (parseFloat(style.paddingRight) || 0)
return tabRect.left >= visibleLeft - 1 && tabRect.right <= visibleRight + 1
}

/**
* Tab bar with drag-to-reorder support and context menu
* DndContext is provided by SplitViewContainer for cross-panel support
Expand Down Expand Up @@ -107,12 +122,20 @@ export const TabBarWithDrag = ({
// Keep the active tab visible — without this the strip stays pinned at the start
// and a newly opened (or newly activated) tab sits past the end edge.
// Re-runs on the chevron gutters too: they widen the scroll content one render
// after the scroll fires, which would push the tab back out of view.
// after the scroll fires, which would push the tab back out of view. Those
// follow-up runs used to fire a second and third smooth scrollIntoView for the
// same activation, restarting the animation mid-flight; they now only re-scroll
// when the resized gutters actually pushed the tab out of the strip.
const scrolledTabIdRef = useRef<string | null>(null)
useLayoutEffect(() => {
if (!activeTabId || activeDragItem) return
const tabEl = scrollRef.current?.querySelector(`[data-tab-id="${CSS.escape(activeTabId)}"]`)
const strip = scrollRef.current
const tabEl = strip?.querySelector(`[data-tab-id="${CSS.escape(activeTabId)}"]`)
if (!strip || !tabEl) return
if (scrolledTabIdRef.current === activeTabId && isTabFullyVisible(strip, tabEl)) return
scrolledTabIdRef.current = activeTabId
// scrollIntoView is not implemented in jsdom
tabEl?.scrollIntoView?.({ inline: 'nearest', block: 'nearest', behavior: 'smooth' })
tabEl.scrollIntoView?.({ inline: 'nearest', block: 'nearest', behavior: 'smooth' })
}, [activeTabId, regularTabsLength, activeDragItem, canScrollToStart, canScrollToEnd])

// If group doesn't exist, don't render (after all hooks)
Expand Down
4 changes: 3 additions & 1 deletion apps/docs/src/user-guide/tabs-split-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ Every note, view, search, project, journal entry, or settings panel opens in a t

Across the top of the app. Drag to reorder. Drag onto a pane edge to split.

Tabs share the width of the bar evenly. Widen the window and they grow, up to a comfortable maximum; open more tabs, or narrow the window, and they compress — first the close button tucks away, then the title, leaving just the icon. Once tabs reach that icon-only minimum the bar scrolls sideways instead of shrinking further: scroll over it with a trackpad or mouse wheel, or use the chevrons that appear at either end. The active tab is always scrolled into view, so opening a new tab never leaves it hidden off the end. The **+** button stays pinned at the end of the bar while it scrolls.
Tabs share the width of the bar evenly. Widen the window and they grow, up to a comfortable maximum; open more tabs, or narrow the window, and they compress — first the close button tucks away, then the title, leaving just the icon. Once tabs reach that icon-only minimum the bar scrolls sideways instead of shrinking further: scroll over it with a trackpad or mouse wheel, or use the chevrons that appear at either end. The active tab is always scrolled into view, so opening a new tab never leaves it hidden off the end. That scroll animates once per tab you activate — the chevrons appearing part-way through it no longer restart the animation, and a tab already fully in view is left where it is. The **+** button stays pinned at the end of the bar while it scrolls.

There is no limit on how many tabs you can have open, and memrynote never closes one for you: use the tab context menu (**Close others**, **Close to the right**) when the bar gets long.

### Tab Context Menu

Expand Down
Loading