diff --git a/components/layout/__tests__/sidebar-folded-column.test.tsx b/components/layout/__tests__/sidebar-folded-column.test.tsx new file mode 100644 index 000000000..02c0f30b2 --- /dev/null +++ b/components/layout/__tests__/sidebar-folded-column.test.tsx @@ -0,0 +1,124 @@ +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'; + +// Usability test 2026-11-04, pass 2 p2-9: folded to icons (600 to 1024 px), +// the folder column showed eight identical folder icons, no unread marks, +// and no way to reach subfolders without unfolding it. + +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 }), + box('receipts', 'Receipts', null, { totalEmails: 5 }), + box('y2026', '2026', null, { parentId: 'receipts', totalEmails: 3 }), +]; + +const peek = () => document.querySelector('[data-folder-peek]'); + +describe('folded folder column (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 } as never); + }); + afterEach(() => { + cleanup(); + vi.useRealTimers(); + useUIStore.setState({ sidebarCollapsed: false } as never); + }); + + it('tells folders of one\'s own apart by their first letter', () => { + render(); + const initials = screen.getAllByTestId('folder-initial').map((el) => el.textContent); + expect(initials).toEqual(expect.arrayContaining(['N', 'R'])); + // System folders keep their own icons. + expect(initials).toHaveLength(2); + }); + + it('shows the unread count on the icon', () => { + render(); + const inbox = document.querySelector('[data-mailbox-id="inbox"]')!; + expect(inbox.querySelector('[data-testid="folded-unread"]')?.textContent).toBe('4'); + const trash = document.querySelector('[data-mailbox-id="trash"]')!; + expect(trash.querySelector('[data-testid="folded-unread"]')).toBeNull(); + }); + + it('shows a dot when counts are dots', () => { + useSettingsStore.setState({ unreadCountStyle: 'dot' } as never); + render(); + const mark = document.querySelector('[data-mailbox-id="news"] [data-testid="folded-unread"]'); + expect(mark).not.toBeNull(); + expect(mark!.textContent).toBe(''); + }); + + it('opens the whole tree from All folders, keyboard included, and Escape folds it back', () => { + render(); + expect(screen.queryByText('Receipts')).toBeNull(); + fireEvent.click(screen.getByTestId('folded-all-folders')); + expect(peek()).not.toBeNull(); + expect(screen.getByText('Receipts')).toBeTruthy(); + // The focus is in the tree, on the open folder. + expect(document.activeElement?.closest('[data-mailbox-id]')?.getAttribute('data-mailbox-id')).toBe('inbox'); + // Subfolders are reachable: Receipts > 2026. + expect(screen.getByText('2026')).toBeTruthy(); + fireEvent.keyDown(document.activeElement!, { key: 'Escape' }); + expect(peek()).toBeNull(); + }); + + it('folds the tree away after a pick', () => { + const { rerender } = render(); + fireEvent.click(screen.getByTestId('folded-all-folders')); + expect(peek()).not.toBeNull(); + rerender(); + expect(peek()).toBeNull(); + }); + + it('folds it away on a press outside', () => { + render(<>); + fireEvent.click(screen.getByTestId('folded-all-folders')); + fireEvent.pointerDown(screen.getByText('elsewhere')); + expect(peek()).toBeNull(); + }); + + it('floats out after the pointer rests on it, not at once, and not with hover opening off', () => { + vi.useFakeTimers(); + const { container } = render(); + fireEvent.mouseEnter(container.firstElementChild!); + expect(peek()).toBeNull(); + act(() => { vi.advanceTimersByTime(400); }); + expect(peek()).not.toBeNull(); + fireEvent.mouseLeave(container.firstElementChild!); + expect(peek()).toBeNull(); + + useSettingsStore.setState({ openOnHover: false } as never); + fireEvent.mouseEnter(container.firstElementChild!); + act(() => { vi.advanceTimersByTime(400); }); + expect(peek()).toBeNull(); + }); +}); diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index aaf3672e5..64ede6c8f 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -22,6 +22,7 @@ import { ChevronUp, Folder, FolderOpen, + FolderSync, User, Users, Palmtree, @@ -171,6 +172,8 @@ interface SidebarProps { const ROW_PX_BASE = FOLDER_ROW_PX_BASE; 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; // Icons and colours follow the JMAP role only. const getIconForMailbox = (role?: string, hasChildren?: boolean, isExpanded?: boolean, id?: string) => { @@ -496,8 +499,22 @@ function SidebarRow({ )} title={isCollapsed ? label : undefined} > - + {icon} + {/* Folded to icons, the count rides on the icon (p2-9); the name says it already. */} + {isCollapsed && unreadForName > 0 && ( + unreadCountStyle === 'dot' ? ( + {!isCollapsed && ( <> @@ -547,6 +564,20 @@ function SidebarRow({ ); } +/** A folder of one's own on the folded strip: its first letter in a small box. */ +function FolderInitial({ name, className }: { name: string; className?: string }) { + const initial = Array.from(name.trim())[0]?.toLocaleUpperCase() ?? '?'; + return ( + + ); +} + /** A folder the server gave a role (inbox, sent, trash...), as opposed to one the user made. */ const isSystemFolder = (node: MailboxNode) => !!node.role; /** Key of the single-account "Your folders" section in the collapsed set. */ @@ -708,6 +739,9 @@ function MailboxTreeItem({ const isVirtualNode = node.id.startsWith('shared-'); const isSelected = selectedMailbox === node.id; const label = sharedAccountName ?? localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`)); + // Folded to icons, eight folders of one's own were eight identical folder + // icons (p2-9): each shows its first letter instead. + const letterIcon = isCollapsed && !node.role && !sharedAccountName && !isVirtualNode; // Only for the active login's folders: its client tells a missing // accountId (own mailbox) apart. const muted = useMutedMailbox(isVirtualNode || dragAccountId || pickOnly ? null : node); @@ -751,7 +785,9 @@ function MailboxTreeItem({ return ( <> } + icon={letterIcon + ? + : } label={label} testRole={node.role} testName={node.name} @@ -1143,8 +1179,23 @@ function SidebarImpl({ // floats rather than widening the column, or every hover would reflow the // list behind it. const [railHovered, setRailHovered] = useState(false); - const hoverExpanded = mvShell && sidebarCollapsed && railHovered; + // The default layout's folded column floats the whole tree out too, so + // subfolders and folder names are a hover or a click away (p2-9). A hover + // waits a moment there, so passing over the column on the way to the list + // doesn't open it; "All folders" opens it by click, tap or keyboard. + const [railOpen, setRailOpen] = useState(false); + const railHoverTimer = useRef | null>(null); + const rootRef = useRef(null); + const allFoldersRef = useRef(null); + const hoverExpanded = sidebarCollapsed && !isDrawer && (railHovered || railOpen); const isCollapsed = sidebarCollapsed && !hoverExpanded && !isDrawer; + const closeRailPeek = useCallback(() => { + if (railHoverTimer.current) clearTimeout(railHoverTimer.current); + railHoverTimer.current = null; + setRailHovered(false); + setRailOpen(false); + }, []); + useEffect(() => () => { if (railHoverTimer.current) clearTimeout(railHoverTimer.current); }, []); 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()); @@ -1242,6 +1293,31 @@ function SidebarImpl({ crossAccountActive, restoring: restoringAccounts, scope: unifiedScope, accounts, }), [crossAccountActive, restoringAccounts, accounts, unifiedScope]); const t = useTranslations('sidebar'); + const tP2 = useTranslations('mail_p2_folders'); + + // A pick in the floated tree folds it away again (the hover peek of the + // default layout, and the one "All folders" opened). + const pickedRef = useRef({ mailbox: selectedMailbox, keyword: selectedKeyword }); + useEffect(() => { + const before = pickedRef.current; + pickedRef.current = { mailbox: selectedMailbox, keyword: selectedKeyword }; + if (before.mailbox === selectedMailbox && before.keyword === selectedKeyword) return; + if (railOpen || !mvShell) closeRailPeek(); + }, [selectedMailbox, selectedKeyword, railOpen, mvShell, closeRailPeek]); + // Opened from "All folders": the focus goes to the open folder in the tree, + // and a press anywhere outside folds it away. + useEffect(() => { + if (!railOpen) return; + const root = rootRef.current; + const target = root?.querySelector('nav [aria-current="page"]') + ?? root?.querySelector('nav button:not([disabled])'); + target?.focus(); + const onPointerDown = (e: PointerEvent) => { + if (root && !root.contains(e.target as Node)) closeRailPeek(); + }; + document.addEventListener('pointerdown', onPointerDown, true); + return () => document.removeEventListener('pointerdown', onPointerDown, true); + }, [railOpen, closeRailPeek]); useEffect(() => { const stored = localStorage.getItem('expandedMailboxes'); @@ -1868,10 +1944,32 @@ function SidebarImpl({ return (
{ if (hoverOpens()) setRailHovered(true); } : undefined} - onMouseLeave={mvShell && sidebarCollapsed ? () => setRailHovered(false) : undefined} - data-mv-rail-peek={hoverExpanded ? "" : undefined} + 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} + onMouseLeave={sidebarCollapsed && !isDrawer ? () => { + if (railHoverTimer.current) clearTimeout(railHoverTimer.current); + railHoverTimer.current = null; + setRailHovered(false); + } : undefined} + onKeyDown={railOpen || railHovered ? (e) => { + if (e.key !== 'Escape' || e.defaultPrevented) return; + e.preventDefault(); + const hadFocus = rootRef.current?.contains(document.activeElement); + closeRailPeek(); + if (hadFocus) requestAnimationFrame(() => allFoldersRef.current?.focus()); + } : undefined} + onBlur={railOpen ? (e) => { + const next = e.relatedTarget as Node | null; + if (next && !e.currentTarget.contains(next)) closeRailPeek(); + } : undefined} + data-mv-rail-peek={hoverExpanded && mvShell ? "" : undefined} + data-folder-peek={hoverExpanded && !mvShell ? "" : 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 @@ -1880,7 +1978,7 @@ 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" : "desk:w-full", + isCollapsed && !mvShell ? "desk:w-12" : hoverExpanded && !mvShell ? "desk:w-64" : "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. @@ -2254,6 +2352,20 @@ function SidebarImpl({ {!isCollapsed && } + {/* Folded: the whole tree, subfolders included, one click away (p2-9). */} + {isCollapsed && !mvShell && ( +
+ setRailOpen(true)} + > + + +
+ )} +