mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
feat: tell folded folders apart, show their unread mail, and float out the whole tree on hover or from All folders
This commit is contained in:
@@ -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<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 }),
|
||||
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(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
|
||||
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(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
|
||||
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(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
|
||||
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(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
|
||||
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(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
|
||||
fireEvent.click(screen.getByTestId('folded-all-folders'));
|
||||
expect(peek()).not.toBeNull();
|
||||
rerender(<Sidebar mailboxes={mailboxes} selectedMailbox="news" />);
|
||||
expect(peek()).toBeNull();
|
||||
});
|
||||
|
||||
it('folds it away on a press outside', () => {
|
||||
render(<><Sidebar mailboxes={mailboxes} selectedMailbox="inbox" /><button type="button">elsewhere</button></>);
|
||||
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(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
>
|
||||
<span className="flex items-center justify-center flex-shrink-0 w-4 h-4">
|
||||
<span className="relative flex items-center justify-center flex-shrink-0 w-4 h-4">
|
||||
{icon}
|
||||
{/* Folded to icons, the count rides on the icon (p2-9); the name says it already. */}
|
||||
{isCollapsed && unreadForName > 0 && (
|
||||
unreadCountStyle === 'dot' ? (
|
||||
<span aria-hidden="true" data-testid="folded-unread" className="absolute -top-0.5 -end-1 w-2 h-2 rounded-full bg-primary ring-2 ring-secondary" />
|
||||
) : (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-testid="folded-unread"
|
||||
className="absolute -top-2 -end-3 min-w-4 h-4 px-1 rounded-full bg-primary text-primary-foreground text-[0.625rem] leading-4 font-semibold tabular-nums text-center ring-2 ring-secondary"
|
||||
>
|
||||
{unreadForName > 99 ? '99+' : unreadForName}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
{!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 (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-testid="folder-initial"
|
||||
className={cn(className, "grid place-items-center rounded-[0.25rem] border border-current text-[0.625rem] font-semibold leading-none")}
|
||||
>
|
||||
{initial}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<>
|
||||
<SidebarRow
|
||||
icon={<Icon className={getIconClass(isSelected, isVirtualNode, colorful, sharedAccountName ? undefined : node.role)} />}
|
||||
icon={letterIcon
|
||||
? <FolderInitial name={label} className={getIconClass(isSelected, false, false)} />
|
||||
: <Icon className={getIconClass(isSelected, isVirtualNode, colorful, sharedAccountName ? undefined : node.role)} />}
|
||||
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<ReturnType<typeof setTimeout> | null>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const allFoldersRef = useRef<HTMLButtonElement>(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<Set<string>>(new Set());
|
||||
const [expandedTags, setExpandedTags] = useState<Set<string>>(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<HTMLElement>('nav [aria-current="page"]')
|
||||
?? root?.querySelector<HTMLElement>('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 (
|
||||
<div
|
||||
ref={rootRef}
|
||||
// The peek of the collapsed rail; not with hover opening turned off.
|
||||
onMouseEnter={mvShell && sidebarCollapsed ? () => { 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 && <PluginSlot name="sidebar-widget" className="border-t border-border" />}
|
||||
</nav>
|
||||
|
||||
{/* Folded: the whole tree, subfolders included, one click away (p2-9). */}
|
||||
{isCollapsed && !mvShell && (
|
||||
<div className="flex justify-center border-t border-border py-2">
|
||||
<IconButton
|
||||
ref={allFoldersRef}
|
||||
label={tP2("all_folders")}
|
||||
data-testid="folded-all-folders"
|
||||
onClick={() => setRailOpen(true)}
|
||||
>
|
||||
<FolderSync />
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MailboxContextMenu
|
||||
target={mailboxContextMenu.data}
|
||||
position={mailboxContextMenu.position}
|
||||
|
||||
Reference in New Issue
Block a user