mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
Merge branch 'feat/do-sidebar-drawer' into feat/desktop-offline
This commit is contained in:
@@ -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
@@ -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']));
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user