diff --git a/app/globals.css b/app/globals.css index 315f59981..7a0579c93 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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. */ @@ -1579,6 +1582,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); diff --git a/components/layout/__tests__/sidebar-folder-drawer.test.tsx b/components/layout/__tests__/sidebar-folder-drawer.test.tsx new file mode 100644 index 000000000..413d7316b --- /dev/null +++ b/components/layout/__tests__/sidebar-folder-drawer.test.tsx @@ -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 = {}) => ({ + 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('[data-folder-drawer]'); +const nextFrame = () => act(() => new Promise((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(); + 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(); + 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(); + 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(); + openFromAllFolders(); + const inDrawer = screen.getByTestId('sidebar-collapse').className; + cleanup(); + useUIStore.setState({ sidebarCollapsed: false } as never); + render(); + 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(); + 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(<>); + openFromAllFolders(); + const root = drawer()!; + const focusable = Array.from(root.querySelectorAll('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(); + 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(<>
); + 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(); + openFromAllFolders(); + rerender(); + 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(); + 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(); + fireEvent.mouseEnter(container.firstElementChild!); + const peek = document.querySelector('[data-mv-rail-peek]')!; + expect(peek.className).toContain('desk:absolute'); + expect(drawer()).toBeNull(); + expect(onDrawerChange).not.toHaveBeenCalled(); + }); +}); diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index b07adb2d4..42de678cd 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -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( + '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(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>(new Set()); const [expandedTags, setExpandedTags] = useState>(new Set()); @@ -1347,6 +1429,9 @@ function SidebarImpl({ ?? root?.querySelector('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({
{ - 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. -
+
{/* Compose at the head of the folders on a desktop, as in Gmail diff --git a/components/mail/__tests__/mail-app-folder-drawer.test.tsx b/components/mail/__tests__/mail-app-folder-drawer.test.tsx new file mode 100644 index 000000000..43b611a84 --- /dev/null +++ b/components/mail/__tests__/mail-app-folder-drawer.test.tsx @@ -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()), + 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 }) => , +})); +vi.mock('@/components/layout/sidebar', () => ({ + Sidebar: ({ onDrawerChange }: { onDrawerChange?: (close: (() => void) | null) => void }) => { + device.drawerChange = onDrawerChange ?? null; + return
; + }, +})); +vi.mock('@/components/layout/mobile-header', async () => { + const { useUIStore: store } = await import('@/stores/ui-store'); + return { + MobileHeader: () => ( + + ), + }; +}); +vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () =>

stub composer

})); +vi.mock('@/components/email/email-viewer', () => ({ EmailViewer: () =>
message viewer
})); +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: () =>
message list
})); + +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('[id]')!; + +describe('the compact desktop\'s folder drawer', () => { + it('dims the whole page while the folded column\'s drawer is open', () => { + render(); + 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(); + 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(); + 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(); + 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'])); + }); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 7833a66e2..8ac8ecbe9 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -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(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( - '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('[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 && (