Merge branch 'feat/do-sidebar-drawer' into feat/desktop-offline

This commit is contained in:
Linus Rath
2026-11-04 16:15:40 +01:00
5 changed files with 524 additions and 38 deletions
+12
View File
@@ -617,6 +617,9 @@ optgroup {
--titlebar-right-inset: calc(100vw - env(titlebar-area-x, 0px) - env(titlebar-area-width, 100vw));
--titlebar-left-inset: env(titlebar-area-x, 0px);
--titlebar-row-height: env(titlebar-area-height, 0px);
/* Where a drawer from the window's edge starts (the folder drawer): under
the top bar, and under the window's controls when they are over the page. */
--drawer-top: max(var(--app-top-inset), var(--titlebar-row-height));
}
/* A skeleton's row that will hold the controls has them from the first frame. */
@@ -1587,6 +1590,15 @@ html[data-window-controls-left] [data-window-rail] {
}
}
/* The folded folder column's drawer (components/layout/sidebar.tsx) is at its
full width from the first frame, Reduce motion included, whose rule makes
everything transition for 0.01ms. Grown from the column's 48px at the
window's edge, it left the pointer that opened it outside for a frame, and
the leave closed it again. Specificity (0,3,0) beats that rule's (0,2,0). */
:root [data-folder-drawer][data-folder-drawer] {
transition: none !important;
}
/* A list row while its right-click menu is open (components/ui/context-menu.tsx). */
[data-menu-row][data-menu-opener] {
outline: 2px solid var(--color-ring);
@@ -0,0 +1,180 @@
import { act, render, screen, fireEvent, cleanup } from '@testing-library/react';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { Mailbox } from '@/lib/jmap/types';
// The folded folder column (600 to 1024 px, or folded by hand) floated its
// tree out beside the app rail, with nothing dimming the list it covered: the
// rail showed at its side, the list was cut off at its edge, and the collapse
// button in it lit up blue under the pointer that opened it. It is a drawer
// now, as in Outlook and Thunderbird: from the window's edge, over the rail,
// the page dimmed by the caller (mail-app), the focus kept inside.
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/tour/tour-provider', () => ({
useTour: () => ({ startTour: vi.fn(), resetTourCompletion: vi.fn(), activeTour: null, endTour: vi.fn(), registerStep: vi.fn() }),
}));
vi.mock('@/contexts/drag-drop-context', () => ({
useDragDropContext: () => ({ isDragging: false }),
}));
vi.mock('@/hooks/use-mailbox-drop', () => ({
useMailboxDrop: () => ({ dropHandlers: {}, isValidDropTarget: false, isInvalidDropTarget: false }),
}));
vi.mock('@/hooks/use-tag-drop', () => ({
useTagDrop: () => ({ dropHandlers: {}, isValidDropTarget: false }),
}));
vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null }));
const { Sidebar } = await import('../sidebar');
const { useAuthStore } = await import('@/stores/auth-store');
const { useSettingsStore } = await import('@/stores/settings-store');
const { useUIStore } = await import('@/stores/ui-store');
const box = (id: string, name: string, role: string | null, extra: Partial<Mailbox> = {}) => ({
id, name, role, parentId: null, totalEmails: 0, unreadEmails: 0, myRights: { mayAddItems: true }, ...extra,
}) as unknown as Mailbox;
const mailboxes: Mailbox[] = [
box('inbox', 'Inbox', 'inbox', { totalEmails: 120, unreadEmails: 4 }),
box('trash', 'Trash', 'trash'),
box('news', 'Newsletters', null, { totalEmails: 30, unreadEmails: 2 }),
];
const drawer = () => document.querySelector<HTMLElement>('[data-folder-drawer]');
const nextFrame = () => act(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
const openFromAllFolders = () => fireEvent.click(screen.getByTestId('folded-all-folders'));
describe('the folded column\'s drawer (default layout)', () => {
beforeEach(() => {
localStorage.clear();
useAuthStore.setState({ activeAccountId: 'login-a', client: { getAccountId: () => 'me', getCapabilities: () => ({}) } } as never);
useSettingsStore.setState({ hideAccountSwitcher: true, interfaceLayout: 'default', openOnHover: true, unreadCountStyle: 'count' } as never);
useUIStore.setState({ sidebarCollapsed: true, compactSidebarExpanded: false } as never);
});
afterEach(() => {
cleanup();
vi.useRealTimers();
useUIStore.setState({ sidebarCollapsed: false } as never);
});
it('opens at the window\'s start edge, from under the top bar to the bottom, over the app rail', () => {
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
openFromAllFolders();
const classes = drawer()!.className.split(/\s+/);
// Fixed to the window, not placed in the folded column beside the rail.
expect(classes).toEqual(expect.arrayContaining(['fixed', 'start-0', 'bottom-0', 'top-[var(--drawer-top)]', 'z-50']));
expect(classes).not.toContain('desk:absolute');
expect(classes).not.toContain('h-full');
});
it('asks the caller to dim the page while it is open, and closes from there', () => {
const onDrawerChange = vi.fn();
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" onDrawerChange={onDrawerChange} />);
expect(onDrawerChange).not.toHaveBeenCalled();
openFromAllFolders();
const close = onDrawerChange.mock.calls.at(-1)![0];
expect(typeof close).toBe('function');
act(() => close());
expect(drawer()).toBeNull();
expect(onDrawerChange).toHaveBeenLastCalledWith(null);
});
it('closes on the collapse button, without docking the folders', () => {
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
openFromAllFolders();
fireEvent.click(screen.getByTestId('sidebar-collapse'));
expect(drawer()).toBeNull();
expect(useUIStore.getState().sidebarCollapsed).toBe(true);
expect(useUIStore.getState().compactSidebarExpanded).toBe(false);
});
it('draws the collapse button with the sidebar\'s grey hover, in the drawer and docked alike', () => {
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
openFromAllFolders();
const inDrawer = screen.getByTestId('sidebar-collapse').className;
cleanup();
useUIStore.setState({ sidebarCollapsed: false } as never);
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
const docked = screen.getByTestId('sidebar-collapse').className;
for (const classes of [inDrawer, docked]) {
expect(classes).toContain('hover:bg-muted');
expect(classes).not.toContain('hover:bg-accent');
}
expect(inDrawer).toBe(docked);
});
it('mirrors the collapse arrows in RTL, where the drawer comes from the right', () => {
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
expect(screen.getByTestId('sidebar-collapse').querySelector('svg')!.getAttribute('class')).toContain('rtl:-scale-x-100');
openFromAllFolders();
expect(screen.getByTestId('sidebar-collapse').querySelector('svg')!.getAttribute('class')).toContain('rtl:-scale-x-100');
});
it('keeps Tab and Shift+Tab inside while open', () => {
render(<><Sidebar mailboxes={mailboxes} selectedMailbox="inbox" /><button type="button">after</button></>);
openFromAllFolders();
const root = drawer()!;
const focusable = Array.from(root.querySelectorAll<HTMLElement>('button:not(:disabled)'));
const first = focusable[0];
const last = focusable[focusable.length - 1];
last.focus();
fireEvent.keyDown(last, { key: 'Tab' });
expect(document.activeElement).toBe(first);
fireEvent.keyDown(first, { key: 'Tab', shiftKey: true });
expect(document.activeElement).toBe(last);
});
it('gives the focus back to All folders when the scrim closes it', async () => {
const onDrawerChange = vi.fn();
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" onDrawerChange={onDrawerChange} />);
openFromAllFolders();
expect(drawer()!.contains(document.activeElement)).toBe(true);
// A press on the scrim drops the focus to the page; its click closes.
(document.activeElement as HTMLElement | null)?.blur();
act(() => onDrawerChange.mock.calls.at(-1)![0]());
await nextFrame();
expect(drawer()).toBeNull();
expect(document.activeElement).toBe(screen.getByTestId('folded-all-folders'));
});
it('stays open on the press on the scrim, so its click doesn\'t reach the message under it', () => {
render(<><Sidebar mailboxes={mailboxes} selectedMailbox="inbox" /><div data-drawer-scrim="" data-testid="scrim" /></>);
openFromAllFolders();
fireEvent.pointerDown(screen.getByTestId('scrim'));
expect(drawer()).not.toBeNull();
});
it('gives the focus back to All folders after a pick', async () => {
const { rerender } = render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
openFromAllFolders();
rerender(<Sidebar mailboxes={mailboxes} selectedMailbox="news" />);
await nextFrame();
expect(drawer()).toBeNull();
expect(document.activeElement).toBe(screen.getByTestId('folded-all-folders'));
});
it('does not float out while the pointer is on the header\'s buttons', () => {
vi.useFakeTimers();
const { container } = render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
const root = container.firstElementChild!;
fireEvent.mouseEnter(root);
fireEvent.mouseMove(screen.getByTestId('sidebar-collapse'));
act(() => { vi.advanceTimersByTime(400); });
expect(drawer()).toBeNull();
// On the folders it does.
fireEvent.mouseMove(document.querySelector('[data-mailbox-id="inbox"]')!);
act(() => { vi.advanceTimersByTime(400); });
expect(drawer()).not.toBeNull();
});
it('keeps Mountain View\'s peek beside its rail', () => {
vi.useFakeTimers();
useSettingsStore.setState({ interfaceLayout: 'mountain-view' } as never);
const onDrawerChange = vi.fn();
const { container } = render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" mvShell onDrawerChange={onDrawerChange} />);
fireEvent.mouseEnter(container.firstElementChild!);
const peek = document.querySelector<HTMLElement>('[data-mv-rail-peek]')!;
expect(peek.className).toContain('desk:absolute');
expect(drawer()).toBeNull();
expect(onDrawerChange).not.toHaveBeenCalled();
});
});
+112 -14
View File
@@ -168,6 +168,12 @@ interface SidebarProps {
* to that login and opens it, since shared mail is reached through the
* active login. */
onLoginSharedMailboxSelect?: (loginId: string, mailboxId: string) => void;
/**
* The folded column's tree is open over the page as a drawer (default
* layout): called with a function that closes it, and with null once it is
* closed. The caller dims the page behind it.
*/
onDrawerChange?: (close: (() => void) | null) => void;
}
const ROW_PX_BASE = FOLDER_ROW_PX_BASE;
@@ -175,6 +181,32 @@ const CHEVRON_SLOT = FOLDER_CHEVRON_SLOT;
const INDENT_STEP = 12;
/** How long the pointer rests on the folded column before the tree floats out. */
const RAIL_HOVER_DELAY_MS = 350;
/**
* The header's collapse and close buttons: muted, with the grey hover of the
* other icon buttons in the sidebar. The ghost button's accent hover is a
* strong blue in the dark theme, and a drawer that opens under the pointer
* showed it as if the button were switched on.
*/
const SIDEBAR_HEAD_BUTTON = "text-muted-foreground hover:bg-muted hover:text-foreground";
/** Keeps Tab and Shift+Tab inside `root`, wrapping at either end. */
function trapTab(e: React.KeyboardEvent, root: HTMLElement | null) {
if (!root) return;
const focusable = Array.from(root.querySelectorAll<HTMLElement>(
'button:not(:disabled), [href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]'
)).filter((el) => el.getAttribute('tabindex') !== '-1' && !el.closest('[inert]'));
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;
if (e.shiftKey && (active === first || active === root)) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
}
// Icons and colours follow the JMAP role only.
const getIconForMailbox = (role?: string, hasChildren?: boolean, isExpanded?: boolean, id?: string) => {
@@ -1200,6 +1232,7 @@ function SidebarImpl({
accountSharedMailboxes,
onLoginSharedMailboxSelect,
mvShell = false,
onDrawerChange,
}: SidebarProps) {
const router = useRouter();
// The rail is a desktop thing. Below that the sidebar is a drawer, opened to
@@ -1223,6 +1256,12 @@ function SidebarImpl({
const allFoldersRef = useRef<HTMLButtonElement>(null);
const hoverExpanded = sidebarCollapsed && !isDrawer && (railHovered || railOpen);
const isCollapsed = sidebarCollapsed && !hoverExpanded && !isDrawer;
// Outside Mountain View the floated tree is a drawer, as in Outlook and
// Thunderbird: from the window's start edge, over the app rail and the
// folded column, with the page dimmed behind it (the caller draws that).
// Floating beside the rail with nothing behind it, it looked like a second
// sidebar cut into the list. Mountain View keeps Gmail's peek beside its rail.
const isFolderDrawer = hoverExpanded && !mvShell;
const closeRailPeek = useCallback(() => {
if (railHoverTimer.current) clearTimeout(railHoverTimer.current);
railHoverTimer.current = null;
@@ -1230,6 +1269,49 @@ function SidebarImpl({
setRailOpen(false);
}, []);
useEffect(() => () => { if (railHoverTimer.current) clearTimeout(railHoverTimer.current); }, []);
// Unfolded or turned into the phone drawer meanwhile: nothing floats any more.
useEffect(() => {
if (!sidebarCollapsed || isDrawer) closeRailPeek();
}, [sidebarCollapsed, isDrawer, closeRailPeek]);
const onDrawerChangeRef = useRef(onDrawerChange);
useEffect(() => { onDrawerChangeRef.current = onDrawerChange; });
useEffect(() => {
if (!isFolderDrawer) return;
onDrawerChangeRef.current?.(closeRailPeek);
return () => onDrawerChangeRef.current?.(null);
}, [isFolderDrawer, closeRailPeek]);
// Opened from All folders, the drawer gives the focus back there however it
// closes: Escape, the collapse button, a pick, a press on the dimmed page.
const railWasOpen = useRef(false);
useEffect(() => {
if (railOpen) { railWasOpen.current = true; return; }
if (!railWasOpen.current) return;
railWasOpen.current = false;
// A frame later: a press on the scrim closes it on pointerdown, and the
// press itself then drops the focus to the page.
const frame = requestAnimationFrame(() => {
const active = document.activeElement;
if (active && active !== document.body && !rootRef.current?.contains(active)) return;
allFoldersRef.current?.focus();
});
return () => cancelAnimationFrame(frame);
}, [railOpen]);
// The pointer resting on the folders floats the tree out; on the header
// (expand, compose) it doesn't, so the expand button can still be clicked.
const armRailHover = useCallback((target: EventTarget | null) => {
if (!hoverOpens()) return;
if (mvShell) { setRailHovered(true); return; }
if (target instanceof Element && target.closest('[data-sidebar-head]')) {
if (railHoverTimer.current) clearTimeout(railHoverTimer.current);
railHoverTimer.current = null;
return;
}
if (railHoverTimer.current) return;
railHoverTimer.current = setTimeout(() => {
railHoverTimer.current = null;
setRailHovered(true);
}, RAIL_HOVER_DELAY_MS);
}, [mvShell]);
const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore(useShallow((state) => ({ primaryIdentity: state.primaryIdentity, activeAccountId: state.activeAccountId })));
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [expandedTags, setExpandedTags] = useState<Set<string>>(new Set());
@@ -1347,6 +1429,9 @@ function SidebarImpl({
?? root?.querySelector<HTMLElement>('nav button:not([disabled])');
target?.focus();
const onPointerDown = (e: PointerEvent) => {
// The scrim behind the drawer closes it on its own click: gone at the
// press, it would let that click through to the message under it.
if (e.target instanceof Element && e.target.closest('[data-drawer-scrim]')) return;
if (root && !root.contains(e.target as Node)) closeRailPeek();
};
document.addEventListener('pointerdown', onPointerDown, true);
@@ -1980,18 +2065,19 @@ function SidebarImpl({
<div
ref={rootRef}
// The peek of the collapsed rail; not with hover opening turned off.
onMouseEnter={sidebarCollapsed && !isDrawer ? () => {
if (!hoverOpens()) return;
if (mvShell) { setRailHovered(true); return; }
if (railHoverTimer.current) clearTimeout(railHoverTimer.current);
railHoverTimer.current = setTimeout(() => setRailHovered(true), RAIL_HOVER_DELAY_MS);
} : undefined}
onMouseEnter={sidebarCollapsed && !isDrawer ? (e) => armRailHover(e.target) : undefined}
onMouseMove={sidebarCollapsed && !isDrawer && !mvShell && !railHovered ? (e) => armRailHover(e.target) : undefined}
onMouseLeave={sidebarCollapsed && !isDrawer ? () => {
if (railHoverTimer.current) clearTimeout(railHoverTimer.current);
railHoverTimer.current = null;
setRailHovered(false);
} : undefined}
onKeyDown={railOpen || railHovered ? (e) => {
// Opened from All folders, the drawer keeps the focus until it closes.
if (e.key === 'Tab' && railOpen && !mvShell) {
trapTab(e, rootRef.current);
return;
}
if (e.key !== 'Escape' || e.defaultPrevented) return;
e.preventDefault();
const hadFocus = rootRef.current?.contains(document.activeElement);
@@ -2003,7 +2089,8 @@ function SidebarImpl({
if (next && !e.currentTarget.contains(next)) closeRailPeek();
} : undefined}
data-mv-rail-peek={hoverExpanded && mvShell ? "" : undefined}
data-folder-peek={hoverExpanded && !mvShell ? "" : undefined}
data-folder-peek={isFolderDrawer ? "" : undefined}
data-folder-drawer={isFolderDrawer ? "" : undefined}
className={cn(
"relative flex flex-col h-full border-e transition-all duration-300 overflow-hidden",
// Mountain View's navigation sits on the canvas, with no rule between
@@ -2012,11 +2099,18 @@ function SidebarImpl({
"max-desk:w-full",
// Mountain View's collapsed column is 72px (--mv-rail-w), wide enough
// for its round compose button; the default layout's rail is 48px.
isCollapsed && !mvShell ? "desk:w-12" : hoverExpanded && !mvShell ? "desk:w-64" : "desk:w-full",
isCollapsed && !mvShell ? "desk:w-12" : isFolderDrawer ? "desk:w-72" : "desk:w-full",
// Floats out of the rail's own column rather than widening it, so the
// message list underneath never moves while the pointer passes over.
// Width comes from --mv-rail-peek-w, keyed on data-mv-rail-peek.
hoverExpanded && "desk:absolute desk:inset-y-0 desk:start-0 desk:z-40 desk:shadow-xl",
hoverExpanded && mvShell && "desk:absolute desk:inset-y-0 desk:start-0 desk:z-40 desk:shadow-xl",
// The drawer: at the window's start edge, from under the top bar and
// the window's controls (--drawer-top) to the bottom; in a Pro pane, at
// the pane's. At its full width at once: grown from the folded
// column's 48px at the edge, it would leave the pointer that opened it
// outside, which closes it again.
isFolderDrawer && "transition-none shadow-xl z-50",
isFolderDrawer && (isEmbedded ? "absolute inset-y-0 start-0" : "fixed start-0 bottom-0 top-[var(--drawer-top)] h-auto"),
className
)}
>
@@ -2025,13 +2119,13 @@ function SidebarImpl({
{!isEmbedded && !mvShell && (
// Border lives on the wrapper (outside the h-14 box) so the bar's total
// height matches the search/reply toolbars, which border-b their wrapper too.
<div className="border-b border-border">
<div className="border-b border-border" data-sidebar-head="">
<div className={cn("flex items-center h-14", isCollapsed ? "justify-center px-2" : "gap-1 px-2")}>
<Button
variant="ghost"
size="icon"
onClick={onSidebarClose}
className="desk:hidden h-9 w-9 flex-shrink-0"
className={cn("desk:hidden h-9 w-9 flex-shrink-0", SIDEBAR_HEAD_BUTTON)}
aria-label={t("close")}
>
<X className="w-5 h-5" />
@@ -2040,11 +2134,15 @@ function SidebarImpl({
<Button
variant="ghost"
size="icon"
onClick={toggleSidebarCollapsed}
className="hidden desk:flex h-8 w-8 flex-shrink-0"
// In the drawer it closes the drawer, as the phone's X does;
// docking the folders is the folded column's expand button.
onClick={isFolderDrawer ? closeRailPeek : toggleSidebarCollapsed}
className={cn("hidden desk:flex h-8 w-8 flex-shrink-0", SIDEBAR_HEAD_BUTTON)}
data-testid="sidebar-collapse"
title={isCollapsed ? t("expand_tooltip") : t("collapse_tooltip")}
>
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
{/* The arrows point the way the column goes: mirrored in RTL, where it sits at the right. */}
{isCollapsed ? <ChevronsRight className="w-4 h-4 rtl:-scale-x-100" /> : <ChevronsLeft className="w-4 h-4 rtl:-scale-x-100" />}
</Button>
{/* Compose at the head of the folders on a desktop, as in Gmail
@@ -0,0 +1,176 @@
import React from 'react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Email, Mailbox } from '@/lib/jmap/types';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { useSettingsStore } from '@/stores/settings-store';
import { usePluginStore } from '@/stores/plugin-store';
import { useThemeStore } from '@/stores/theme-store';
import { useUIStore } from '@/stores/ui-store';
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
// The folder list over the page (the folded column's drawer on a compact
// desktop, the phone's drawer) dims the page behind it, and the phone's keeps
// the focus until it closes and then hands it back to the menu button. The
// folded column's drawer opened beside the app rail with nothing dimming the
// list (usability report 2026-11-04, screenshots at 525 and 587 px).
const device = vi.hoisted(() => ({
current: { isMobile: false, isCompact: true, isDesktop: true },
drawerChange: null as null | ((close: (() => void) | null) => void),
}));
vi.mock('@/hooks/use-media-query', async (importOriginal) => ({
...(await importOriginal<typeof import('@/hooks/use-media-query')>()),
useDeviceDetection: () => device.current,
useMediaQuery: () => false,
useIsDesktop: () => device.current.isDesktop,
useIsMobile: () => device.current.isMobile,
}));
vi.mock('@/hooks/use-split-panes-fit', () => ({ useSplitPanesFit: () => true }));
vi.mock('@/components/layout/navigation-rail', () => ({
NavigationRail: ({ orientation = 'vertical' }: { orientation?: string }) => <nav data-rail={orientation}>rail</nav>,
}));
vi.mock('@/components/layout/sidebar', () => ({
Sidebar: ({ onDrawerChange }: { onDrawerChange?: (close: (() => void) | null) => void }) => {
device.drawerChange = onDrawerChange ?? null;
return <div><button type="button">first folder</button><button type="button">last folder</button></div>;
},
}));
vi.mock('@/components/layout/mobile-header', async () => {
const { useUIStore: store } = await import('@/stores/ui-store');
return {
MobileHeader: () => (
<button type="button" data-sidebar-toggle="" onClick={() => store.getState().setSidebarOpen(true)}>menu</button>
),
};
});
vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () => <p>stub composer</p> }));
vi.mock('@/components/email/email-viewer', () => ({ EmailViewer: () => <div>message viewer</div> }));
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
vi.mock('@/components/search/mv-search-form', () => ({ MvSearchForm: () => null }));
vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null }));
vi.mock('@/components/plugins/plugin-side-panels', () => ({ PluginSidePanel: () => null }));
vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null }));
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
vi.mock('@/components/email/email-list', () => ({ EmailList: () => <div>message list</div> }));
const { MailApp } = await import('../mail-app');
const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 1, unreadEmails: 0 } as Mailbox;
const message = { id: 'm1', threadId: 't-m1', subject: 'm1', receivedAt: '2026-10-31T10:00:00Z', keywords: { $seen: true }, mailboxIds: { inbox: true } } as unknown as Email;
const compact = { isMobile: false, isCompact: true, isDesktop: true };
const phone = { isMobile: true, isCompact: false, isDesktop: false };
beforeEach(() => {
device.current = compact;
device.drawerChange = null;
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
const client = {
getAccountId: () => 'acc', getUsername: () => 'a@example.org',
getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
};
useAuthStore.setState({
isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [],
getClientForAccount: () => client as never,
getAllConnectedClients: () => new Map([['login-a', client]]) as never,
});
useSettingsStore.setState({ enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'split', simpleMode: false } as never);
useUIStore.setState({
activeView: 'list', sidebarOpen: false, sidebarWidth: 256,
sidebarCollapsed: false, compactSidebarExpanded: false,
isMobile: false, isCompact: true, isDesktop: true,
});
useEmailStore.setState({
emails: [message], selectedEmail: null, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: [inbox],
viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', isAdvancedSearchOpen: false,
selectedEmailKeys: new Set(), unreadSection: null,
fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
fetchEmailContent: vi.fn().mockResolvedValue(undefined),
markAsRead: vi.fn().mockResolvedValue(undefined),
} as never);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
const scrim = () => screen.queryByTestId('folder-drawer-scrim');
/** The element holding the folder sidebar (the one with its id). */
const folders = () => screen.getByText('first folder').closest<HTMLElement>('[id]')!;
describe('the compact desktop\'s folder drawer', () => {
it('dims the whole page while the folded column\'s drawer is open', () => {
render(<MailApp />);
expect(scrim()).toBeNull();
const close = vi.fn();
act(() => device.drawerChange!(close));
const classes = scrim()!.className.split(/\s+/);
expect(classes).toEqual(expect.arrayContaining(['fixed', 'inset-0']));
// Not the phone's scrim, which the desktop breakpoint hides.
expect(classes).not.toContain('desk:hidden');
fireEvent.click(scrim()!);
expect(close).toHaveBeenCalledTimes(1);
act(() => device.drawerChange!(null));
expect(scrim()).toBeNull();
});
it('leaves the drawer the window to be placed in, not the folder column', () => {
render(<MailApp />);
const classes = folders().className.split(/\s+/);
// A translate, even translate-x-0, makes the column the drawer's
// containing block, and the drawer opened beside the app rail.
expect(classes).toContain('desk:translate-none');
expect(classes.some((c) => /^desk:-?translate-x-/.test(c))).toBe(false);
});
});
describe('the phone\'s folder drawer', () => {
beforeEach(() => {
device.current = phone;
useUIStore.setState({ isMobile: true, isCompact: false, isDesktop: false });
});
it('keeps Tab inside and gives the focus back to the menu button on Escape', () => {
render(<MailApp />);
const toggle = screen.getByText('menu');
toggle.focus();
fireEvent.click(toggle);
const first = screen.getByText('first folder');
const last = screen.getByText('last folder');
expect(document.activeElement).toBe(first);
last.focus();
fireEvent.keyDown(last, { key: 'Tab' });
expect(document.activeElement).toBe(first);
fireEvent.keyDown(first, { key: 'Escape' });
expect(useUIStore.getState().sidebarOpen).toBe(false);
expect(document.activeElement).toBe(toggle);
});
it('starts under the top bar and the window\'s controls', () => {
render(<MailApp />);
const classes = folders().className.split(/\s+/);
expect(classes).toEqual(expect.arrayContaining(['max-desk:fixed', 'max-desk:start-0', 'max-desk:top-[var(--drawer-top)]', 'max-desk:bottom-0']));
});
});
+44 -24
View File
@@ -79,6 +79,7 @@ import { useContactStore } from "@/stores/contact-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useUIStore, sidebarFolded, listPaneCssSize, SIDEBAR_MIN, SIDEBAR_MAX, EMAIL_LIST_MIN, EMAIL_LIST_MAX, EMAIL_LIST_HEIGHT_MIN, EMAIL_LIST_HEIGHT_MAX, READING_PANE_MIN_WIDTH, READING_PANE_MIN_HEIGHT, COMPOSER_PANE_MIN_HEIGHT } from "@/stores/ui-store";
import { atLeast, PHONE_BELOW, useDeviceDetection } from "@/hooks/use-media-query";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { usePaneSize } from "@/hooks/use-pane-size";
import { useIsFocusedProTab, usePaneId } from "@/hooks/use-pane-context";
import { useKeyboardShortcuts, type GoToMailboxTarget } from "@/hooks/use-keyboard-shortcuts";
@@ -2593,28 +2594,35 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Pro can mount two mail panes at once, so the sidebar id must be per-instance.
const sidebarId = useId();
const listHeadingId = useId();
const sidebarContainerRef = useRef<HTMLDivElement>(null);
// The phone drawer keeps Tab inside until it closes, and Escape closes it.
const phoneDrawerOpen = isMobile && sidebarOpen;
const sidebarContainerRef = useFocusTrap({
isActive: phoneDrawerOpen,
onEscape: () => setSidebarOpen(false),
restoreFocus: false,
});
const sidebarWasOpen = useRef(false);
useEffect(() => {
const isOverlay = isMobile;
if (isOverlay && sidebarOpen) {
if (phoneDrawerOpen) {
sidebarWasOpen.current = true;
const frame = requestAnimationFrame(() => {
const first = sidebarContainerRef.current?.querySelector<HTMLElement>(
'button:not(:disabled),[href],input:not(:disabled),[tabindex]:not([tabindex="-1"])'
);
first?.focus();
});
return () => cancelAnimationFrame(frame);
return;
}
if (!sidebarWasOpen.current) return;
sidebarWasOpen.current = false;
// The toggle lives in whichever MobileHeader the current view renders, so
// Back to the toggle however it closed (Escape, X, a pick, the scrim),
// unless the focus has moved on to something outside the drawer. The
// toggle lives in whichever MobileHeader the current view renders, so
// look it up inside this app instance rather than threading a ref through.
const active = document.activeElement;
if (active && active !== document.body) return;
if (active && active !== document.body && !sidebarContainerRef.current?.contains(active)) return;
appRootRef.current?.querySelector<HTMLElement>('[data-sidebar-toggle]')?.focus();
}, [isMobile, sidebarOpen]);
}, [phoneDrawerOpen, sidebarContainerRef]);
// The folded column's tree open over the page as a drawer (Sidebar's
// onDrawerChange): the function that closes it, for the scrim.
const [closeFolderDrawer, setCloseFolderDrawer] = useState<(() => void) | null>(null);
const handleFolderDrawerChange = useCallback((close: (() => void) | null) => {
setCloseFolderDrawer(() => close);
}, []);
// The message a composer session answers, pinned when the session starts.
// Mountain View keeps its compose window open while other mail is read
@@ -5841,13 +5849,21 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
`desk:hidden` viewport-variant alone wouldn't gate this overlay;
scope to the pane via `absolute` so the backdrop stays inside
the pane instead of covering the whole window. */}
{isMobile && sidebarOpen && !inlineApp && (
{((isMobile && sidebarOpen) || closeFolderDrawer) && !inlineApp && (
<div
aria-hidden="true"
data-testid="folder-drawer-scrim"
data-drawer-scrim=""
className={cn(
"inset-0 bg-black/50 z-40",
isEmbedded ? "absolute" : "fixed desk:hidden"
// The folded column's drawer opens on a desktop, so only the
// phone's scrim goes with the phone layout.
isEmbedded ? "absolute" : cn("fixed", !closeFolderDrawer && "desk:hidden")
)}
onClick={() => setSidebarOpen(false)}
onClick={() => {
if (closeFolderDrawer) closeFolderDrawer();
else setSidebarOpen(false);
}}
/>
)}
@@ -5861,9 +5877,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
`inert` it stays in the accessibility tree and screen readers can
reach a menu the Toggle menu button reports as collapsed (#721). */
inert={isMobile && !sidebarOpen}
onKeyDown={isMobile && sidebarOpen ? (e) => {
if (e.key === 'Escape') setSidebarOpen(false);
} : undefined}
className={cn(
"flex-shrink-0 h-full z-50",
// The z-index is for the phone drawer. On a desktop under Mountain
@@ -5880,16 +5893,22 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// sign or the drawer slides off the wrong edge.
!sidebarOpen && "-translate-x-full rtl:translate-x-full"
)
: "relative translate-x-0")
// No translate here: it would make this column the box the
// folded column's drawer is placed in.
: "relative")
: cn(
// Mobile/Tablet: fixed overlay
// Phone: a drawer from the window's start edge, from under
// the top bar and the window's controls (--drawer-top).
// start-0 rather than left-0: in RTL the drawer belongs on
// the right, matching the hamburger that opens it.
"max-desk:fixed max-desk:inset-y-0 max-desk:start-0 max-desk:w-72 max-desk:pt-[var(--app-top-inset)]",
"max-desk:fixed max-desk:start-0 max-desk:top-[var(--drawer-top)] max-desk:bottom-0 max-desk:h-auto max-desk:w-72",
"max-desk:transform max-desk:transition-transform max-desk:duration-300 max-desk:ease-in-out",
!sidebarOpen && "max-desk:-translate-x-full max-desk:rtl:translate-x-full",
// Desktop: normal flow
"desk:relative desk:translate-x-0"
// Desktop: normal flow. translate-none, not translate-x-0:
// any translate makes this column the containing block of
// the folded column's fixed drawer, which then opens beside
// the app rail instead of at the window's edge.
"desk:relative desk:translate-none"
),
inlineApp && "hidden",
fullscreenReading && "hidden"
@@ -5916,6 +5935,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
accountMailboxes={accountMailboxes}
accountSharedMailboxes={accountSharedMailboxes}
viewingAccountId={viewingAccountId}
onDrawerChange={handleFolderDrawerChange}
/>
</ErrorBoundary>
</div>