From b2633691ec6a1820aec60841c8bd6564341b9d7b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:43:41 +0100 Subject: [PATCH] fix: keep Pro tabs readable in a full strip and bring the active tab into view --- components/pro/__tests__/pro-tab-bar.test.tsx | 80 ++++++++++++++++++- components/pro/pro-tab-bar.tsx | 54 +++++++++++-- 2 files changed, 124 insertions(+), 10 deletions(-) diff --git a/components/pro/__tests__/pro-tab-bar.test.tsx b/components/pro/__tests__/pro-tab-bar.test.tsx index e017279a3..7dce36de2 100644 --- a/components/pro/__tests__/pro-tab-bar.test.tsx +++ b/components/pro/__tests__/pro-tab-bar.test.tsx @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { fireEvent, render, screen } from '@testing-library/react'; -import { ProTabBar } from '@/components/pro/pro-tab-bar'; +import { createEvent, fireEvent, render, screen } from '@testing-library/react'; +import { ProTabBar, revealInStrip } from '@/components/pro/pro-tab-bar'; import { PRO_TAB_DRAG_MIME, EMAIL_IDS_DRAG_MIME } from '@/components/pro/pro-shell-drop'; import { useProTabStore, type ProTab } from '@/stores/pro-tab-store'; @@ -210,3 +210,79 @@ describe('ProTabBar keyboard', () => { expect(useProTabStore.getState().tabs.map((t) => t.id)).toEqual(['home-mail', 'cal', 'em']); }); }); + +describe('ProTabBar overflow', () => { + const rect = (left: number, right: number) => ({ left, right, top: 0, bottom: 36, width: right - left, height: 36, x: left, y: 0, toJSON: () => ({}) }); + + beforeEach(() => { + seedStore([HOME, CAL, EM]); + }); + + it('scrolls the strip to a tab past its end, and back to one before its start', () => { + const strip = document.createElement('div'); + const tab = document.createElement('div'); + strip.getBoundingClientRect = () => rect(100, 500) as DOMRect; + strip.scrollLeft = 50; + + tab.getBoundingClientRect = () => rect(480, 576) as DOMRect; + revealInStrip(strip, tab); + expect(strip.scrollLeft).toBe(126); + + tab.getBoundingClientRect = () => rect(60, 156) as DOMRect; + revealInStrip(strip, tab); + expect(strip.scrollLeft).toBe(86); + + tab.getBoundingClientRect = () => rect(200, 296) as DOMRect; + revealInStrip(strip, tab); + expect(strip.scrollLeft).toBe(86); + }); + + it('brings the tab that becomes active into view', () => { + const spy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.getAttribute('role') === 'tablist') return rect(0, 300) as DOMRect; + return (this.dataset.tabId === 'cal' ? rect(290, 386) : rect(0, 96)) as DOMRect; + }); + const bar = (activeTabId: string) => ( + {}} onClose={() => {}} /> + ); + const { rerender } = render(bar(HOME.id)); + const strip = screen.getByRole('tablist'); + expect(strip.scrollLeft).toBe(0); + rerender(bar(CAL.id)); + expect(strip.scrollLeft).toBe(86); + spy.mockRestore(); + }); + + it('moves along the strip with a vertical mouse wheel, the other way in RTL', () => { + const { container } = render( +
+ {}} onClose={() => {}} /> +
, + ); + const strip = container.querySelector('[role=tablist]')!; + strip.style.direction = 'rtl'; + fireEvent.wheel(strip, { deltaY: 40 }); + expect(strip.scrollLeft).toBe(-40); + strip.style.direction = 'ltr'; + fireEvent.wheel(strip, { deltaY: 40 }); + expect(strip.scrollLeft).toBe(0); + }); + + it('inserts on the side of the tab the pointer is on in RTL', () => { + const { container } = render( + {}} onClose={() => {}} />, + ); + const cal = container.querySelector('[data-tab-id=cal]')!; + cal.style.direction = 'rtl'; + cal.getBoundingClientRect = () => rect(0, 100) as DOMRect; + const dataTransfer = new MockDataTransfer(); + dataTransfer.setData(PRO_TAB_DRAG_MIME, 'home-mail'); + // The left half of a tab is its far end in RTL. + const over = createEvent.dragOver(cal, { dataTransfer }); + Object.defineProperty(over, 'clientX', { value: 10 }); + fireEvent(cal, over); + fireEvent.drop(cal, { dataTransfer }); + const order = useProTabStore.getState().tabs.filter((t) => t.paneId === 'main').map((t) => t.id); + expect(order).toEqual(['cal', 'home-mail']); + }); +}); diff --git a/components/pro/pro-tab-bar.tsx b/components/pro/pro-tab-bar.tsx index 741e3a5a3..00d823309 100644 --- a/components/pro/pro-tab-bar.tsx +++ b/components/pro/pro-tab-bar.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef, useState, type DragEvent, type KeyboardEvent } from "react"; +import { useEffect, useRef, useState, type DragEvent, type KeyboardEvent, type WheelEvent } from "react"; import { useTranslations } from "next-intl"; import { Mail, Calendar, BookUser, HardDrive, Settings, PenSquare, MailOpen, Folder, Search, X, type AppIcon } from "@/components/icons"; import { cn } from "@/lib/utils"; @@ -49,6 +49,14 @@ const TAB_ICONS: Record = { type DropIndicator = { targetId: string; edge: "before" | "after" } | null; +/** Scrolls the strip by the least that shows the whole tab. Rects are physical, so this holds in RTL too. */ +export function revealInStrip(strip: HTMLElement, tab: HTMLElement): void { + const s = strip.getBoundingClientRect(); + const r = tab.getBoundingClientRect(); + if (r.left < s.left) strip.scrollLeft -= s.left - r.left; + else if (r.right > s.right) strip.scrollLeft += r.right - s.right; +} + export function ProTabBar({ paneId, tabs, @@ -68,6 +76,32 @@ export function ProTabBar({ // True while an email or folder payload hovers the strip (both drop "anywhere"). const [isPayloadDragOver, setIsPayloadDragOver] = useState(false); const dragLeaveTimer = useRef(null); + const stripRef = useRef(null); + + // A strip fuller than its pane scrolls, so the active tab has to be brought + // into view: a tab that just opened lands past the end. Also when the strip + // itself gets narrower (a split opens, the window shrinks). + const tabCount = tabs.length; + useEffect(() => { + const strip = stripRef.current; + if (!strip || !activeTabId) return; + const reveal = () => { + const active = strip.querySelector(`[data-tab-id="${CSS.escape(activeTabId)}"]`); + if (active) revealInStrip(strip, active); + }; + reveal(); + if (typeof ResizeObserver === "undefined") return; + const ro = new ResizeObserver(reveal); + ro.observe(strip); + return () => ro.disconnect(); + }, [activeTabId, tabCount]); + + // The strip draws no scrollbar: an ordinary mouse wheel moves along it. + const handleStripWheel = (e: WheelEvent) => { + if (e.deltaX !== 0 || e.deltaY === 0) return; + const rtl = getComputedStyle(e.currentTarget).direction === "rtl"; + e.currentTarget.scrollLeft += rtl ? -e.deltaY : e.deltaY; + }; const dragKind = (e: DragEvent) => dragKindFromTypes(e.dataTransfer.types); @@ -95,8 +129,9 @@ export function ProTabBar({ } e.dataTransfer.dropEffect = "move"; const rect = e.currentTarget.getBoundingClientRect(); - const edge: "before" | "after" = - e.clientX < rect.left + rect.width / 2 ? "before" : "after"; + const inFirstHalf = e.clientX < rect.left + rect.width / 2; + const rtl = getComputedStyle(e.currentTarget).direction === "rtl"; + const edge: "before" | "after" = inFirstHalf !== rtl ? "before" : "after"; setDropIndicator((prev) => prev && prev.targetId === tab.id && prev.edge === edge ? prev @@ -218,6 +253,8 @@ export function ProTabBar({ return (