mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
fix: keep Pro tabs readable in a full strip and bring the active tab into view
This commit is contained in:
@@ -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) => (
|
||||
<ProTabBar paneId="main" tabs={[HOME, CAL]} activeTabId={activeTabId} isFocused onActivate={() => {}} 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(
|
||||
<div style={{ direction: 'rtl' }}>
|
||||
<ProTabBar paneId="main" tabs={[HOME, CAL]} activeTabId={HOME.id} isFocused onActivate={() => {}} onClose={() => {}} />
|
||||
</div>,
|
||||
);
|
||||
const strip = container.querySelector<HTMLElement>('[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(
|
||||
<ProTabBar paneId="main" tabs={[HOME, CAL]} activeTabId={HOME.id} isFocused onActivate={() => {}} onClose={() => {}} />,
|
||||
);
|
||||
const cal = container.querySelector<HTMLElement>('[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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ProTabKind, AppIcon> = {
|
||||
|
||||
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<number | null>(null);
|
||||
const stripRef = useRef<HTMLDivElement | null>(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<HTMLElement>(`[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<HTMLDivElement>) => {
|
||||
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 (
|
||||
<div
|
||||
ref={stripRef}
|
||||
onWheel={handleStripWheel}
|
||||
className={cn(
|
||||
"relative flex items-stretch h-9 bg-secondary px-1 overflow-x-auto scroll-hidden flex-shrink-0",
|
||||
isPayloadDragOver && "bg-primary/10",
|
||||
@@ -259,7 +296,7 @@ export function ProTabBar({
|
||||
onDragEnd={handleDragEnd}
|
||||
className={cn(
|
||||
"group relative flex items-center gap-1.5 px-3 h-9 text-sm cursor-pointer select-none transition-colors",
|
||||
"min-w-0 flex-1 basis-0 max-w-[200px] [min-width:80px]",
|
||||
"min-w-24 flex-1 basis-0 max-w-[200px]",
|
||||
"border-e border-border first:border-s",
|
||||
isActive
|
||||
? "bg-background text-foreground"
|
||||
@@ -282,9 +319,10 @@ export function ProTabBar({
|
||||
onClose(tab.id);
|
||||
}}
|
||||
className={cn(
|
||||
"ms-1 flex items-center justify-center w-4 h-4 rounded-sm transition-colors flex-shrink-0",
|
||||
"ms-1 items-center justify-center w-4 h-4 rounded-sm transition-colors flex-shrink-0",
|
||||
"text-muted-foreground hover:bg-muted-foreground/20 hover:text-foreground",
|
||||
!isActive && "opacity-0 group-hover:opacity-100 focus-visible:opacity-100",
|
||||
// Takes no room until wanted: in a full strip the label needs it.
|
||||
isActive ? "flex" : "hidden group-hover:flex",
|
||||
)}
|
||||
aria-label={tSidebar("close")}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
@@ -311,13 +349,13 @@ export function ProTabBar({
|
||||
|
||||
{showBefore && (
|
||||
<span
|
||||
className="pointer-events-none absolute top-1 bottom-1 left-0 w-0.5 -translate-x-1/2 bg-primary rounded-full"
|
||||
className="pointer-events-none absolute top-1 bottom-1 start-0 w-0.5 -translate-x-1/2 rtl:translate-x-1/2 bg-primary rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{showAfter && (
|
||||
<span
|
||||
className="pointer-events-none absolute top-1 bottom-1 right-0 w-0.5 translate-x-1/2 bg-primary rounded-full"
|
||||
className="pointer-events-none absolute top-1 bottom-1 end-0 w-0.5 translate-x-1/2 rtl:-translate-x-1/2 bg-primary rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user