mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-15 08:16:05 +00:00
fix: keep the folded folder column tidy with many folders: one rule between sections, shared accounts as items, counters beside the icons, and the drawer opening at the hovered folder
This commit is contained in:
@@ -313,6 +313,18 @@ optgroup {
|
||||
background-color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
/* The folded folder column (components/layout/sidebar.tsx) leaves its 48px
|
||||
to the icons: a 4px scrollbar, in a gutter kept on both sides. */
|
||||
:root:not([data-native-scrollbars="on"]) [data-folder-rail]::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
@supports not selector(::-webkit-scrollbar) {
|
||||
:root:not([data-native-scrollbars="on"]) [data-folder-rail] {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced Email Content Styling */
|
||||
|
||||
.email-content-wrapper {
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
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) of a mailbox with many folders,
|
||||
// scrolled: shared accounts left only their headings' rules behind, stacked
|
||||
// into a ladder of lines; the unread counter sat on the icon; one folder of
|
||||
// one's own was a letter in a box; and the drawer a hover opened kept the
|
||||
// column's scroll offset, so the hovered folder was somewhere else.
|
||||
|
||||
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 shared = (id: string, name: string, role: string | null, extra: Partial<Mailbox> = {}) =>
|
||||
box(`g1:${id}`, name, role, { originalId: id, accountId: 'g1', accountName: 'Sam Ortega', isShared: true, ...extra });
|
||||
|
||||
const own = [
|
||||
box('inbox', 'Inbox', 'inbox', { totalEmails: 120, unreadEmails: 9 }),
|
||||
box('clients', 'Clients', null, { parentId: 'inbox', unreadEmails: 132 }),
|
||||
box('trash', 'Trash', 'trash', { unreadEmails: 3 }),
|
||||
box('news', 'Newsletters', null, { unreadEmails: 150 }),
|
||||
];
|
||||
// A shared account without an inbox: two folders someone shared.
|
||||
const sharedNoInbox = [shared('team', 'Team', null, { unreadEmails: 2 }), shared('board', 'Board', null, { unreadEmails: 1 })];
|
||||
|
||||
const nav = () => document.querySelector<HTMLElement>('[data-folder-nav]')!;
|
||||
const drawer = () => document.querySelector<HTMLElement>('[data-folder-drawer]');
|
||||
const row = (key: string) => document.querySelector<HTMLElement>(`[data-rail-key="${key}"]`)!;
|
||||
const railButtons = () => Array.from(nav().querySelectorAll<HTMLElement>('[data-testid="folder-row"] > button'));
|
||||
|
||||
describe('the folded folder column with many folders', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useAuthStore.setState({ activeAccountId: 'login-a', client: { getAccountId: () => 'me', getCapabilities: () => ({}) } } as never);
|
||||
useSettingsStore.setState({
|
||||
hideAccountSwitcher: true, interfaceLayout: 'default', openOnHover: true, unreadCountStyle: 'count',
|
||||
showSharedMailAccounts: true, hiddenSharedMailAccounts: {}, compactSharedMailboxes: false,
|
||||
} as never);
|
||||
useUIStore.setState({ sidebarCollapsed: true, compactSidebarExpanded: false } as never);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
useUIStore.setState({ sidebarCollapsed: false } as never);
|
||||
});
|
||||
|
||||
it('draws one rule between sections, never two in a row', () => {
|
||||
render(<Sidebar mailboxes={[...own, ...sharedNoInbox]} selectedMailbox="inbox" />);
|
||||
// Folders, Your folders, Shared (with its account inside), Tags.
|
||||
const rules = nav().querySelectorAll('[data-rail-separator]');
|
||||
expect(rules).toHaveLength(2 + 1);
|
||||
// Walk the column in order: something to click between any two rules.
|
||||
const order = Array.from(nav().querySelectorAll('[data-rail-separator], [data-testid="folder-row"]'));
|
||||
order.forEach((el, i) => {
|
||||
if (i === 0) return;
|
||||
const both = el.hasAttribute('data-rail-separator') && order[i - 1].hasAttribute('data-rail-separator');
|
||||
expect(both, `two rules in a row at ${i}`).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('lists a shared account as one item, which opens the drawer with it unfolded when it has no inbox', () => {
|
||||
render(<Sidebar mailboxes={[...own, ...sharedNoInbox]} selectedMailbox="inbox" />);
|
||||
const item = row('shared-account-g1');
|
||||
expect(item.getAttribute('data-testid')).toBe('folder-row');
|
||||
const button = item.querySelector('button')!;
|
||||
expect(button.getAttribute('title')).toBe('name_unread');
|
||||
expect(item.querySelector('[data-testid="folded-unread"]')?.textContent).toBe('3');
|
||||
fireEvent.click(button);
|
||||
expect(drawer()).not.toBeNull();
|
||||
expect(screen.getByText('Team')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('opens a shared account\'s inbox from its item, and marks it while one of its folders is open', () => {
|
||||
const onMailboxSelect = vi.fn();
|
||||
const mailboxes = [...own, shared('in', 'Inbox', 'inbox', { unreadEmails: 4 }), ...sharedNoInbox];
|
||||
const { rerender } = render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" onMailboxSelect={onMailboxSelect} />);
|
||||
fireEvent.click(row('shared-account-g1').querySelector('button')!);
|
||||
expect(onMailboxSelect).toHaveBeenCalledWith('g1:in');
|
||||
rerender(<Sidebar mailboxes={mailboxes} selectedMailbox="g1:team" onMailboxSelect={onMailboxSelect} />);
|
||||
expect(row('shared-account-g1').querySelector('[data-testid="folded-selected"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('puts the counter at the icon\'s corner, beside the glyph, and says 99+ for more', () => {
|
||||
render(<Sidebar mailboxes={own} selectedMailbox="inbox" />);
|
||||
const badge = row('trash').querySelector<HTMLElement>('[data-testid="folded-unread"]')!;
|
||||
expect(badge.textContent).toBe('3');
|
||||
// On the 36px item, above the 16px icon's box, not inside it over the glyph.
|
||||
expect(badge.parentElement!.tagName).toBe('BUTTON');
|
||||
expect(badge.parentElement!.className).toContain('h-9');
|
||||
expect(badge.className.split(/\s+/)).toEqual(expect.arrayContaining(['absolute', 'top-0', 'end-0', 'h-3']));
|
||||
expect(badge.className).not.toContain('ring-2');
|
||||
expect(row('news').querySelector('[data-testid="folded-unread"]')!.textContent).toBe('99+');
|
||||
});
|
||||
|
||||
it('gives every item a tooltip with its name and count', () => {
|
||||
render(<Sidebar mailboxes={[...own, ...sharedNoInbox]} selectedMailbox="inbox" />);
|
||||
for (const button of railButtons()) {
|
||||
const title = button.getAttribute('title');
|
||||
expect(title, button.outerHTML.slice(0, 120)).toBeTruthy();
|
||||
expect(title).toBe(button.getAttribute('aria-label'));
|
||||
}
|
||||
// With unread mail the name carries the count.
|
||||
expect(row('inbox').querySelector('button')!.getAttribute('title')).toBe('name_unread');
|
||||
});
|
||||
|
||||
it('draws one folder of one\'s own as a plain folder, several with their first letter inside the folder', () => {
|
||||
render(<Sidebar mailboxes={own} selectedMailbox="inbox" />);
|
||||
expect(row('news').querySelector('[data-testid="folder-initial"]')).toBeNull();
|
||||
expect(row('news').querySelector('svg')).not.toBeNull();
|
||||
cleanup();
|
||||
render(<Sidebar mailboxes={[...own, box('receipts', 'receipts', null)]} selectedMailbox="inbox" />);
|
||||
const letter = row('receipts').querySelector('[data-testid="folder-initial"]')!;
|
||||
expect(letter.textContent).toBe('R');
|
||||
// Inside the folder glyph, an icon like the others, not a box of its own.
|
||||
expect(letter.closest('svg')?.querySelector('path')).not.toBeNull();
|
||||
expect(letter.closest('svg')!.getAttribute('class')).toContain('w-4');
|
||||
});
|
||||
|
||||
it('marks the open folder with a bar at the edge and a filled item', () => {
|
||||
render(<Sidebar mailboxes={own} selectedMailbox="trash" />);
|
||||
// Over the filled item, not under it.
|
||||
expect(row('trash').querySelector('[data-testid="folded-selected"]')!.className).toContain('z-10');
|
||||
expect(row('trash').querySelector('button')!.className).toContain('bg-accent');
|
||||
expect(row('inbox').querySelector('[data-testid="folded-selected"]')).toBeNull();
|
||||
expect(row('inbox').querySelector('button')!.className).not.toContain('bg-accent');
|
||||
});
|
||||
|
||||
it('scrolls on its own, never sideways, with a thin scrollbar', () => {
|
||||
render(<Sidebar mailboxes={own} selectedMailbox="inbox" />);
|
||||
expect(nav().hasAttribute('data-folder-rail')).toBe(true);
|
||||
expect(nav().className.split(/\s+/)).toEqual(expect.arrayContaining(['overflow-y-auto', 'overflow-x-hidden', 'min-h-0']));
|
||||
});
|
||||
|
||||
it('moves through the items with the arrows, Home and End', () => {
|
||||
render(<Sidebar mailboxes={[...own, ...sharedNoInbox]} selectedMailbox="inbox" />);
|
||||
const buttons = railButtons().filter((b) => !(b as HTMLButtonElement).disabled);
|
||||
buttons[0].focus();
|
||||
fireEvent.keyDown(buttons[0], { key: 'ArrowDown' });
|
||||
expect(document.activeElement).toBe(buttons[1]);
|
||||
fireEvent.keyDown(buttons[1], { key: 'ArrowUp' });
|
||||
expect(document.activeElement).toBe(buttons[0]);
|
||||
fireEvent.keyDown(buttons[0], { key: 'End' });
|
||||
expect(document.activeElement).toBe(buttons[buttons.length - 1]);
|
||||
fireEvent.keyDown(buttons[buttons.length - 1], { key: 'Home' });
|
||||
expect(document.activeElement).toBe(buttons[0]);
|
||||
});
|
||||
|
||||
describe('the drawer opened from the scrolled column', () => {
|
||||
// jsdom has no layout: the list's items sit at fixed places in the
|
||||
// content, further down in the longer tree of the drawer.
|
||||
let scrollTop = 0;
|
||||
const NAV_TOP = 100;
|
||||
const place = (el: Element) => {
|
||||
const key = (el as HTMLElement).dataset?.railKey;
|
||||
if (key !== 'news') return null;
|
||||
return drawer() ? 900 : 500;
|
||||
};
|
||||
beforeEach(() => {
|
||||
scrollTop = 0;
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
|
||||
// The list shows 600px; anything else is somewhere inside it.
|
||||
const isNav = this.hasAttribute('data-folder-nav');
|
||||
const at = place(this);
|
||||
const top = isNav ? NAV_TOP : at === null ? NAV_TOP + 10 : NAV_TOP + at - scrollTop;
|
||||
const height = isNav ? 600 : 36;
|
||||
return { top, bottom: top + height, left: 0, right: 48, width: 48, height, x: 0, y: top, toJSON: () => ({}) } as DOMRect;
|
||||
});
|
||||
});
|
||||
const trackScroll = () => Object.defineProperty(nav(), 'scrollTop', {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
set: (v: number) => { scrollTop = v; },
|
||||
});
|
||||
|
||||
it('puts the hovered item back under the pointer, and the column back where it was', () => {
|
||||
vi.useFakeTimers();
|
||||
const { container } = render(<Sidebar mailboxes={own} selectedMailbox="inbox" />);
|
||||
trackScroll();
|
||||
scrollTop = 200;
|
||||
const news = row('news');
|
||||
document.elementFromPoint = vi.fn(() => news.querySelector('button'));
|
||||
// At 400 on screen in the column: 100 + 500 - 200.
|
||||
fireEvent.mouseEnter(container.firstElementChild!, { clientX: 20, clientY: 410 });
|
||||
act(() => { vi.advanceTimersByTime(400); });
|
||||
expect(drawer()).not.toBeNull();
|
||||
// In the drawer it is 900 down the list: scrolled so it is at 400 again.
|
||||
expect(scrollTop).toBe(600);
|
||||
fireEvent.mouseLeave(container.firstElementChild!);
|
||||
expect(drawer()).toBeNull();
|
||||
expect(scrollTop).toBe(200);
|
||||
});
|
||||
|
||||
it('starts at the top when All folders opens it', () => {
|
||||
render(<Sidebar mailboxes={own} selectedMailbox="inbox" />);
|
||||
trackScroll();
|
||||
scrollTop = 200;
|
||||
fireEvent.click(screen.getByTestId('folded-all-folders'));
|
||||
expect(drawer()).not.toBeNull();
|
||||
expect(scrollTop).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
+215
-40
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { hoverOpens } from "@/lib/open-on-hover";
|
||||
import { memo, useState, useEffect, useMemo, useCallback, useRef, useId, Fragment, ReactNode } from "react";
|
||||
import { memo, useState, useEffect, useLayoutEffect, useMemo, useCallback, useRef, useId, Fragment, ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
@@ -396,6 +396,9 @@ interface SidebarRowProps {
|
||||
testName?: string;
|
||||
testMailboxId?: string;
|
||||
testShared?: boolean;
|
||||
/** Names the row the same folded and unfolded, so the drawer opened by a
|
||||
* hover can put the hovered item back under the pointer. */
|
||||
railKey?: string;
|
||||
}
|
||||
|
||||
function SidebarRow({
|
||||
@@ -420,6 +423,7 @@ function SidebarRow({
|
||||
testName,
|
||||
testMailboxId,
|
||||
testShared,
|
||||
railKey,
|
||||
}: SidebarRowProps) {
|
||||
const t = useTranslations('sidebar');
|
||||
const tShell = useTranslations('shell');
|
||||
@@ -436,6 +440,8 @@ function SidebarRow({
|
||||
unread, total, role: testRole, mode: countMode, showTotalGlobally: showFolderTotalCount, mountainView,
|
||||
}).unread;
|
||||
const accessibleName = unreadForName > 0 ? tShell('name_unread', { name: label, count: unreadForName }) : undefined;
|
||||
// The default layout's folded column: one grid of square items.
|
||||
const railItem = isCollapsed && !mountainView;
|
||||
const [labelRef, shortenedLabel] = useShortenedText(labelCandidates ?? [label]);
|
||||
// Touch has no right-click: a long-press opens the same menu (R7), and a
|
||||
// ⋮ shows it too.
|
||||
@@ -473,21 +479,31 @@ function SidebarRow({
|
||||
data-shared={testShared ? 'true' : undefined}
|
||||
data-selected={isSelected ? 'true' : undefined}
|
||||
data-has-unread={(unread ?? 0) > 0 && testRole !== 'drafts' ? '' : undefined}
|
||||
style={{ paddingBlock: 'var(--density-sidebar-py)' }}
|
||||
data-rail-key={railKey ?? testMailboxId ?? undefined}
|
||||
style={railItem ? undefined : { paddingBlock: 'var(--density-sidebar-py)' }}
|
||||
className={cn(
|
||||
"group w-full flex items-center tap:min-h-[44px] text-sm transition-colors duration-150",
|
||||
isCollapsed ? "justify-center px-1" : "pe-2",
|
||||
// Folded, every item is the same square on one grid, as in Outlook's
|
||||
// folded pane: the button carries the hover and the selection, and a
|
||||
// bar at the column's edge marks the open one.
|
||||
// Mountain View's 72px rail keeps its own pill rows (globals.css).
|
||||
railItem ? "relative justify-center py-px" : isCollapsed ? "justify-center px-1" : "pe-2",
|
||||
openFromRow && "cursor-pointer select-none",
|
||||
isVirtual
|
||||
? "text-muted-foreground"
|
||||
: isSelected
|
||||
? "bg-accent text-accent-foreground font-semibold border-s-2 border-primary"
|
||||
: "hover:bg-muted/50 text-foreground border-s-2 border-transparent",
|
||||
railItem
|
||||
? (isVirtual ? "text-muted-foreground" : isSelected ? "text-accent-foreground font-semibold" : "text-foreground")
|
||||
: isVirtual
|
||||
? "text-muted-foreground"
|
||||
: isSelected
|
||||
? "bg-accent text-accent-foreground font-semibold border-s-2 border-primary"
|
||||
: "hover:bg-muted/50 text-foreground border-s-2 border-transparent",
|
||||
// Only a folder that takes the drop lights up. Elsewhere the pointer
|
||||
// says no (dropEffect none), as in a native mail client.
|
||||
isValidDropTarget && "bg-primary/20 ring-2 ring-primary ring-inset"
|
||||
)}
|
||||
>
|
||||
{railItem && isSelected && (
|
||||
<span aria-hidden="true" data-testid="folded-selected" className="absolute z-10 start-0 top-1/2 -translate-y-1/2 h-5 w-[0.1875rem] rounded-e-full bg-primary" />
|
||||
)}
|
||||
{!isCollapsed && (
|
||||
<div
|
||||
className="flex items-center flex-shrink-0"
|
||||
@@ -528,28 +544,36 @@ function SidebarRow({
|
||||
aria-describedby={muted && !isCollapsed ? mutedId : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-2 min-w-0 transition-colors",
|
||||
isCollapsed ? "justify-center" : "flex-1 text-start",
|
||||
railItem
|
||||
? cn(
|
||||
// 36px high: the icon sits 10px down, under the counter's
|
||||
// 12px, so the counter never covers the glyph.
|
||||
"relative justify-center w-full max-w-10 h-9 rounded-md focus-visible:outline-2 focus-visible:outline-ring focus-visible:-outline-offset-2",
|
||||
!isVirtual && (isSelected ? "bg-accent" : "hover:bg-muted/60"),
|
||||
)
|
||||
: isCollapsed ? "relative justify-center w-10 h-8" : "flex-1 text-start",
|
||||
isVirtual && "cursor-default select-none"
|
||||
)}
|
||||
title={isCollapsed ? label : undefined}
|
||||
// Folded, the name and count are only in the tooltip.
|
||||
title={isCollapsed ? (accessibleName ?? label) : undefined}
|
||||
>
|
||||
<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>
|
||||
{/* Folded to icons, a small counter at the icon's top corner (p2-9); the name says it already. */}
|
||||
{isCollapsed && unreadForName > 0 && (
|
||||
unreadCountStyle === 'dot' ? (
|
||||
<span aria-hidden="true" data-testid="folded-unread" className="absolute top-1.5 end-1.5 w-1.5 h-1.5 rounded-full bg-primary" />
|
||||
) : (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-testid="folded-unread"
|
||||
className="absolute top-0 end-0 min-w-3 h-3 px-[0.1875rem] rounded-full bg-primary text-primary-foreground text-[0.5625rem] leading-3 font-semibold tabular-nums text-center"
|
||||
>
|
||||
{unreadForName > 99 ? '99+' : unreadForName}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<span
|
||||
@@ -598,20 +622,52 @@ function SidebarRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** A folder of one's own on the folded strip: its first letter in a small box. */
|
||||
/**
|
||||
* A folder of one's own on the folded strip, when there are several: the
|
||||
* folder glyph the other rows use, same size and stroke, with the folder's
|
||||
* first letter inside it. A letter in a box of its own looked like no other
|
||||
* icon in the column.
|
||||
*/
|
||||
function FolderInitial({ name, className }: { name: string; className?: string }) {
|
||||
const initial = Array.from(name.trim())[0]?.toLocaleUpperCase() ?? '?';
|
||||
return (
|
||||
<span
|
||||
<svg
|
||||
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")}
|
||||
data-testid="folder-initial-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
{initial}
|
||||
</span>
|
||||
{/* Lucide's folder outline. */}
|
||||
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||
<text
|
||||
data-testid="folder-initial"
|
||||
x="12"
|
||||
y="13"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
fontSize="10"
|
||||
fontWeight="700"
|
||||
>
|
||||
{initial}
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the folder `id` is this node or below it. */
|
||||
const subtreeHas = (node: MailboxNode, id: string): boolean =>
|
||||
node.id === id || node.children.some((child) => subtreeHas(child, id));
|
||||
/** Unread mail in the folders below a node (a shared account counts nothing itself). */
|
||||
const subtreeUnread = (node: MailboxNode): number =>
|
||||
node.children.reduce((sum, child) => sum + (child.unreadEmails ?? 0) + subtreeUnread(child), 0);
|
||||
|
||||
/** 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. */
|
||||
@@ -634,6 +690,7 @@ function SidebarSectionHeader({
|
||||
status,
|
||||
onAdd,
|
||||
addTitle,
|
||||
railKey,
|
||||
}: {
|
||||
label: string;
|
||||
expanded: boolean;
|
||||
@@ -650,9 +707,14 @@ function SidebarSectionHeader({
|
||||
onContextMenu?: (event: React.MouseEvent) => void;
|
||||
/** A short note after the label, e.g. how many accounts have loaded. */
|
||||
status?: ReactNode;
|
||||
/** The folded column's item for the same thing (see SidebarRow). */
|
||||
railKey?: string;
|
||||
}) {
|
||||
if (isCollapsed) {
|
||||
return first ? null : <div className="h-px bg-border/50 mx-2 my-2" aria-hidden />;
|
||||
// One thin rule between sections. A sub-section (a shared account, the
|
||||
// folders inside a Pro account group) draws none: under a section's own
|
||||
// rule they stacked into a ladder of lines.
|
||||
return first || sub ? null : <div className="h-px bg-border mx-2.5 my-1.5" data-rail-separator="" aria-hidden />;
|
||||
}
|
||||
|
||||
const paddingY = sub ? "pt-2" : first ? "pt-3" : "pt-5";
|
||||
@@ -670,6 +732,7 @@ function SidebarSectionHeader({
|
||||
return (
|
||||
<div
|
||||
onContextMenu={onContextMenu}
|
||||
data-rail-key={railKey}
|
||||
className={cn(
|
||||
"group w-full flex items-center pb-1 select-none rounded-sm hover:bg-muted/40 transition-colors",
|
||||
paddingX,
|
||||
@@ -740,6 +803,7 @@ function MailboxTreeItem({
|
||||
pickOnly = false,
|
||||
sharedAccountName,
|
||||
renderAfterChild,
|
||||
initials = false,
|
||||
}: {
|
||||
node: MailboxNode;
|
||||
selectedMailbox: string;
|
||||
@@ -763,19 +827,23 @@ function MailboxTreeItem({
|
||||
/** Rendered after each direct child's subtree, e.g. the Scheduled row after
|
||||
* Drafts. Not passed further down. */
|
||||
renderAfterChild?: (child: MailboxNode) => React.ReactNode;
|
||||
/** Folded: several folders of one's own are listed, so each carries its
|
||||
* first letter. One alone is a plain folder. */
|
||||
initials?: boolean;
|
||||
}) {
|
||||
const tNotifications = useTranslations('notifications');
|
||||
const tListExtra = useTranslations('mail_list_extra');
|
||||
const tSidebar = useTranslations('sidebar');
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isExpanded = expandedFolders.has(node.id);
|
||||
const Icon = sharedAccountName ? User : getIconForMailbox(node.role, hasChildren, isExpanded, node.id);
|
||||
// Folded, no subfolders show, so no folder is drawn open.
|
||||
const Icon = sharedAccountName ? User : getIconForMailbox(node.role, hasChildren, isExpanded && !isCollapsed, node.id);
|
||||
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;
|
||||
// icons (p2-9): each carries its first letter inside the folder glyph.
|
||||
const letterIcon = isCollapsed && initials && !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);
|
||||
@@ -969,6 +1037,7 @@ function TagItem({
|
||||
isCollapsed={isCollapsed}
|
||||
dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined}
|
||||
isValidDropTarget={isValidDropTarget}
|
||||
railKey={`tag:${node.id}`}
|
||||
/>
|
||||
|
||||
{hasChildren && isExpanded && !isCollapsed && node.children.map((child) => (
|
||||
@@ -1299,9 +1368,26 @@ function SidebarImpl({
|
||||
}, [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) => {
|
||||
const navRef = useRef<HTMLElement>(null);
|
||||
// The item the pointer rests on in the folded column, and where on screen.
|
||||
const hoveredRailItem = useRef<{ key: string; top: number } | null>(null);
|
||||
const lastPointer = useRef<{ x: number; y: number; target: EventTarget | null }>({ x: 0, y: 0, target: null });
|
||||
// Read when the tree floats out, not when the pointer arrived: the column
|
||||
// may have been scrolled under a resting pointer in between.
|
||||
const noteHoveredRailItem = useCallback(() => {
|
||||
const { x, y, target } = lastPointer.current;
|
||||
const under = typeof document.elementFromPoint === 'function' ? document.elementFromPoint(x, y) : null;
|
||||
const from = under ?? (target instanceof Element ? target : null);
|
||||
const item = from?.closest<HTMLElement>('[data-rail-key]');
|
||||
hoveredRailItem.current = item?.dataset.railKey && navRef.current?.contains(item)
|
||||
? { key: item.dataset.railKey, top: item.getBoundingClientRect().top }
|
||||
: null;
|
||||
}, []);
|
||||
const armRailHover = useCallback((e: React.MouseEvent) => {
|
||||
if (!hoverOpens()) return;
|
||||
if (mvShell) { setRailHovered(true); return; }
|
||||
const target = e.target;
|
||||
lastPointer.current = { x: e.clientX, y: e.clientY, target };
|
||||
if (mvShell) { noteHoveredRailItem(); setRailHovered(true); return; }
|
||||
if (target instanceof Element && target.closest('[data-sidebar-head]')) {
|
||||
if (railHoverTimer.current) clearTimeout(railHoverTimer.current);
|
||||
railHoverTimer.current = null;
|
||||
@@ -1310,9 +1396,59 @@ function SidebarImpl({
|
||||
if (railHoverTimer.current) return;
|
||||
railHoverTimer.current = setTimeout(() => {
|
||||
railHoverTimer.current = null;
|
||||
noteHoveredRailItem();
|
||||
setRailHovered(true);
|
||||
}, RAIL_HOVER_DELAY_MS);
|
||||
}, [mvShell]);
|
||||
}, [mvShell, noteHoveredRailItem]);
|
||||
// The folded column and the tree floated out of it share one scrolling
|
||||
// list. Scrolled down the column, the tree opened at that same offset, which
|
||||
// in the longer tree was somewhere else: a hover now puts the hovered item
|
||||
// back under the pointer, All folders starts at the top (and the focus
|
||||
// brings the open folder into view), and folding back returns the column
|
||||
// to where it was.
|
||||
const railScrollTop = useRef<number | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
const nav = navRef.current;
|
||||
if (!nav) return;
|
||||
if (hoverExpanded) {
|
||||
if (railScrollTop.current !== null) return;
|
||||
railScrollTop.current = nav.scrollTop;
|
||||
const hovered = railOpen ? null : hoveredRailItem.current;
|
||||
const item = hovered
|
||||
? Array.from(nav.querySelectorAll<HTMLElement>('[data-rail-key]')).find((el) => el.dataset.railKey === hovered.key)
|
||||
: null;
|
||||
nav.scrollTop = item && hovered ? nav.scrollTop + item.getBoundingClientRect().top - hovered.top : 0;
|
||||
return;
|
||||
}
|
||||
if (railScrollTop.current === null) return;
|
||||
const railTop = railScrollTop.current;
|
||||
railScrollTop.current = null;
|
||||
// Unfolded meanwhile: the full list keeps its own place.
|
||||
if (!isCollapsed) return;
|
||||
nav.scrollTop = railTop;
|
||||
// A folder picked in the tree is shown in the column too.
|
||||
const current = nav.querySelector<HTMLElement>('[aria-current="page"]');
|
||||
if (!current) return;
|
||||
const view = nav.getBoundingClientRect();
|
||||
const box = current.getBoundingClientRect();
|
||||
if (box.top < view.top) nav.scrollTop += box.top - view.top;
|
||||
else if (box.bottom > view.bottom) nav.scrollTop += box.bottom - view.bottom;
|
||||
}, [hoverExpanded, railOpen, isCollapsed]);
|
||||
// Folded, the arrows, Home and End go from item to item, Enter opens.
|
||||
const moveInRail = useCallback((e: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Home' && e.key !== 'End') return;
|
||||
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||||
const items = Array.from(e.currentTarget.querySelectorAll<HTMLElement>('[data-testid="folder-row"] > button:not(:disabled)'));
|
||||
if (items.length === 0) return;
|
||||
const at = items.indexOf(document.activeElement as HTMLElement);
|
||||
const next = e.key === 'Home' ? 0
|
||||
: e.key === 'End' ? items.length - 1
|
||||
: at < 0 ? 0
|
||||
: e.key === 'ArrowDown' ? Math.min(at + 1, items.length - 1) : Math.max(at - 1, 0);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
items[next].focus();
|
||||
}, []);
|
||||
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());
|
||||
@@ -1681,6 +1817,7 @@ function SidebarImpl({
|
||||
colorful={colorfulSidebarIcons}
|
||||
onContextMenu={active ? handleMailboxContextMenu : undefined}
|
||||
dragAccountId={dragAccountId}
|
||||
initials={tree.length > 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1770,6 +1907,31 @@ function SidebarImpl({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isCollapsed) {
|
||||
// Folded, a shared account is one item: its inbox opens, or
|
||||
// without one the drawer with the account unfolded. It was
|
||||
// left out, and only its heading's rule stayed behind.
|
||||
const inbox = account.children.find((child) => child.role === 'inbox');
|
||||
const selectedHere = !!selected && subtreeHas(account, selected);
|
||||
return (
|
||||
<SidebarRow
|
||||
key={account.id}
|
||||
icon={<User className={getIconClass(selectedHere, false, false)} />}
|
||||
label={account.name}
|
||||
isSelected={selectedHere}
|
||||
unread={subtreeUnread(account)}
|
||||
onClick={() => {
|
||||
if (inbox) { onSelect?.(inbox.id); return; }
|
||||
if (!expanded) onToggle();
|
||||
if (!accountExpanded) toggleSharedAccount(account.id);
|
||||
setRailOpen(true);
|
||||
}}
|
||||
isCollapsed
|
||||
testShared
|
||||
railKey={account.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={account.id}>
|
||||
<SidebarSectionHeader
|
||||
@@ -1780,6 +1942,7 @@ function SidebarImpl({
|
||||
sub
|
||||
icon={<User className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
testId="section-shared-account"
|
||||
railKey={account.id}
|
||||
onContextMenu={active && menuAccountId
|
||||
? (e) => handleSharedAccountContextMenu(e, menuAccountId)
|
||||
: undefined}
|
||||
@@ -2067,8 +2230,8 @@ function SidebarImpl({
|
||||
<div
|
||||
ref={rootRef}
|
||||
// The peek of the collapsed rail; not with hover opening turned off.
|
||||
onMouseEnter={sidebarCollapsed && !isDrawer ? (e) => armRailHover(e.target) : undefined}
|
||||
onMouseMove={sidebarCollapsed && !isDrawer && !mvShell && !railHovered ? (e) => armRailHover(e.target) : undefined}
|
||||
onMouseEnter={sidebarCollapsed && !isDrawer ? armRailHover : undefined}
|
||||
onMouseMove={sidebarCollapsed && !isDrawer && !mvShell && !railHovered ? armRailHover : undefined}
|
||||
onMouseLeave={sidebarCollapsed && !isDrawer ? () => {
|
||||
if (railHoverTimer.current) clearTimeout(railHoverTimer.current);
|
||||
railHoverTimer.current = null;
|
||||
@@ -2211,7 +2374,19 @@ function SidebarImpl({
|
||||
{!isCollapsed && <VacationBanner />}
|
||||
|
||||
{/* Mailbox List */}
|
||||
<nav aria-label={t("folders")} className="flex-1 overflow-y-auto" data-tour="sidebar" data-folder-nav="" data-pane="folders">
|
||||
<nav
|
||||
ref={navRef}
|
||||
aria-label={t("folders")}
|
||||
// Folded, the column scrolls on its own, never sideways, with a thin
|
||||
// scrollbar (globals.css) and the same gutter on both sides so the
|
||||
// icons stay centred under the buttons above and below it.
|
||||
className={cn("flex-1 min-h-0 overflow-y-auto", isCollapsed && "overflow-x-hidden py-1 [scrollbar-gutter:stable_both-edges]")}
|
||||
data-tour="sidebar"
|
||||
data-folder-nav=""
|
||||
data-folder-rail={isCollapsed && !mvShell ? "" : undefined}
|
||||
data-pane="folders"
|
||||
onKeyDown={isCollapsed ? moveInRail : undefined}
|
||||
>
|
||||
{unifiedSectionShown && (
|
||||
<div>
|
||||
<SidebarSectionHeader
|
||||
|
||||
Reference in New Issue
Block a user