From bfd871704d959c3e338441b6b2fc5a0eaf51b9dc Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 2 Mar 2026 11:57:26 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20responsive=20tab=20width=20=E2=80=94=20?= =?UTF-8?q?shrink=20tabs=20to=20fit=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tabs now dynamically reduce their max-width when the total width exceeds the TabBar container, similar to browser tab behavior. Uses a ResizeObserver on the tab area to calculate per-tab max-width as min(360, containerWidth / tabCount), floored at 60px minimum. Co-Authored-By: Claude Opus 4.6 --- design/tab-responsive-width.pen | 1 + src/components/TabBar.test.tsx | 56 ++++++++++++++++++++++++++ src/components/TabBar.tsx | 70 +++++++++++++++++++++------------ src/test/setup.ts | 7 ++++ src/utils/tabLayout.ts | 5 +++ 5 files changed, 113 insertions(+), 26 deletions(-) create mode 100644 design/tab-responsive-width.pen create mode 100644 src/utils/tabLayout.ts diff --git a/design/tab-responsive-width.pen b/design/tab-responsive-width.pen new file mode 100644 index 00000000..747b5ab3 --- /dev/null +++ b/design/tab-responsive-width.pen @@ -0,0 +1 @@ +{"children":[],"variables":{}} diff --git a/src/components/TabBar.test.tsx b/src/components/TabBar.test.tsx index d9b13c13..6bca6518 100644 --- a/src/components/TabBar.test.tsx +++ b/src/components/TabBar.test.tsx @@ -1,6 +1,7 @@ import { render, screen, fireEvent } from '@testing-library/react' import { describe, it, expect, vi } from 'vitest' import { TabBar } from './TabBar' +import { computeTabMaxWidth } from '../utils/tabLayout' import type { VaultEntry } from '../types' function makeEntry(path: string, title: string): VaultEntry { @@ -257,4 +258,59 @@ describe('TabBar', () => { fireEvent.click(screen.getByText('Beta')) expect(onSwitchTab).toHaveBeenCalledWith(tabs[1].entry.path) }) + + describe('responsive tab width', () => { + it('wraps tabs in an overflow-hidden flex container', () => { + const tabs = makeTabs(['Alpha']) + const { container } = render( + + ) + const tabArea = container.querySelector('.overflow-hidden') + expect(tabArea).toBeInTheDocument() + expect(tabArea?.classList.contains('flex')).toBe(true) + expect(tabArea?.classList.contains('min-w-0')).toBe(true) + expect(tabArea?.classList.contains('flex-1')).toBe(true) + }) + + it('tab elements are shrinkable with min-w-0', () => { + const tabs = makeTabs(['Alpha', 'Beta']) + const { container } = render( + + ) + const tabEls = container.querySelectorAll('[draggable="true"]') + expect(tabEls).toHaveLength(2) + for (const el of tabEls) { + expect(el.classList.contains('shrink-0')).toBe(false) + expect(el.classList.contains('min-w-0')).toBe(true) + } + }) + }) + + describe('computeTabMaxWidth', () => { + it('caps at 360px when container is wide', () => { + expect(computeTabMaxWidth(1200, 2)).toBe(360) + }) + + it('divides space equally among tabs', () => { + expect(computeTabMaxWidth(500, 5)).toBe(100) + }) + + it('enforces minimum of 60px', () => { + expect(computeTabMaxWidth(300, 10)).toBe(60) + }) + + it('returns 360 for zero tabs', () => { + expect(computeTabMaxWidth(800, 0)).toBe(360) + }) + + it('floors the result to integer pixels', () => { + // 1000 / 3 = 333.33 → 333 + expect(computeTabMaxWidth(1000, 3)).toBe(333) + }) + + it('handles single tab', () => { + expect(computeTabMaxWidth(200, 1)).toBe(200) + expect(computeTabMaxWidth(500, 1)).toBe(360) + }) + }) }) diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index ecfc7e3a..0a9e9cf2 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -4,6 +4,7 @@ import type { VaultEntry, NoteStatus } from '../types' import { cn } from '@/lib/utils' import { X } from 'lucide-react' import { Plus, Columns, ArrowsOutSimple, ArrowLeft, ArrowRight } from '@phosphor-icons/react' +import { computeTabMaxWidth } from '@/utils/tabLayout' interface Tab { entry: VaultEntry @@ -199,7 +200,7 @@ function StatusDot({ status }: { status: NoteStatus }) { ) } -function TabItem({ tab, isActive, isEditing, noteStatus, isDragging, showDropBefore, showDropAfter, onSwitch, onClose, onDoubleClick, onRenameSave, onRenameCancel, dragProps }: { +function TabItem({ tab, isActive, isEditing, noteStatus, isDragging, showDropBefore, showDropAfter, tabMaxWidth, onSwitch, onClose, onDoubleClick, onRenameSave, onRenameCancel, dragProps }: { tab: Tab isActive: boolean isEditing: boolean @@ -207,6 +208,7 @@ function TabItem({ tab, isActive, isEditing, noteStatus, isDragging, showDropBef isDragging: boolean showDropBefore: boolean showDropAfter: boolean + tabMaxWidth: number onSwitch: () => void onClose: () => void onDoubleClick: () => void @@ -219,10 +221,11 @@ function TabItem({ tab, isActive, isEditing, noteStatus, isDragging, showDropBef draggable={!isEditing} {...dragProps} className={cn( - "group flex shrink-0 items-center gap-1.5 whitespace-nowrap max-w-[360px] transition-all relative", + "group flex min-w-0 items-center gap-1.5 whitespace-nowrap transition-all relative", isActive ? "text-foreground" : "text-muted-foreground hover:text-secondary-foreground" )} style={{ + maxWidth: tabMaxWidth, background: isActive ? 'var(--background)' : 'transparent', borderRight: `1px solid ${isActive ? 'var(--border)' : 'var(--sidebar-border)'}`, borderBottom: isActive ? 'none' : '1px solid var(--sidebar-border)', @@ -331,8 +334,20 @@ export const TabBar = memo(function TabBar({ }: TabBarProps) { const { dragIndex, dropIndex, handleDragStart, handleDragEnd, handleDragOver, handleDrop, handleBarDragLeave } = useTabDrag(onReorderTabs) const [editingPath, setEditingPath] = useState(null) + const tabAreaRef = useRef(null) + const [tabMaxWidth, setTabMaxWidth] = useState(360) const { onMouseDown: onDragMouseDown } = useDragRegion() + useEffect(() => { + const el = tabAreaRef.current + if (!el) return + const recalc = () => setTabMaxWidth(computeTabMaxWidth(el.clientWidth, tabs.length)) + recalc() + const observer = new ResizeObserver(recalc) + observer.observe(el) + return () => observer.disconnect() + }, [tabs.length]) + return (
- {tabs.map((tab, index) => ( - onSwitchTab(tab.entry.path)} - onClose={() => onCloseTab(tab.entry.path)} - onDoubleClick={() => onRenameTab && setEditingPath(tab.entry.path)} - onRenameSave={(newTitle) => { setEditingPath(null); onRenameTab?.(tab.entry.path, newTitle) }} - onRenameCancel={() => setEditingPath(null)} - dragProps={{ - onDragStart: (e) => handleDragStart(e, index), - onDragEnd: handleDragEnd, - onDragOver: (e) => handleDragOver(e, index), - onDrop: handleDrop, - }} - /> - ))} -
+
+ {tabs.map((tab, index) => ( + onSwitchTab(tab.entry.path)} + onClose={() => onCloseTab(tab.entry.path)} + onDoubleClick={() => onRenameTab && setEditingPath(tab.entry.path)} + onRenameSave={(newTitle) => { setEditingPath(null); onRenameTab?.(tab.entry.path, newTitle) }} + onRenameCancel={() => setEditingPath(null)} + dragProps={{ + onDragStart: (e) => handleDragStart(e, index), + onDragEnd: handleDragEnd, + onDragOver: (e) => handleDragOver(e, index), + onDrop: handleDrop, + }} + /> + ))} +
+
) diff --git a/src/test/setup.ts b/src/test/setup.ts index 898a1f2e..e0b56cd8 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -5,6 +5,13 @@ import { createElement, type ReactNode, type ComponentType } from 'react' // Mock scrollIntoView for jsdom (not implemented) Element.prototype.scrollIntoView = vi.fn() +// Mock ResizeObserver for jsdom (not implemented) +global.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} +} as unknown as typeof ResizeObserver + // Mock @tauri-apps/plugin-opener for test environment vi.mock('@tauri-apps/plugin-opener', () => ({ openUrl: vi.fn(), diff --git a/src/utils/tabLayout.ts b/src/utils/tabLayout.ts new file mode 100644 index 00000000..56ef1c04 --- /dev/null +++ b/src/utils/tabLayout.ts @@ -0,0 +1,5 @@ +/** Compute per-tab max-width so all tabs fit within the container. */ +export function computeTabMaxWidth(containerWidth: number, tabCount: number): number { + if (tabCount === 0) return 360 + return Math.max(60, Math.min(360, Math.floor(containerWidth / tabCount))) +}