diff --git a/components/layout/__tests__/sidebar-folder-title.test.tsx b/components/layout/__tests__/sidebar-folder-title.test.tsx
new file mode 100644
index 000000000..2c7ae3e1f
--- /dev/null
+++ b/components/layout/__tests__/sidebar-folder-title.test.tsx
@@ -0,0 +1,63 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { Mailbox } from '@/lib/jmap/types';
+
+// R11: text cut with an ellipsis gets a title with the full text. A folder
+// name the sidebar had no shorter form for was cut by CSS with no title
+// ("Gemeinsamer Postein…").
+
+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 box = (id: string, name: string) => ({
+ id, name, role: null, parentId: null, totalEmails: 0, unreadEmails: 0, myRights: {},
+}) as unknown as Mailbox;
+const mailboxes: Mailbox[] = [box('a', 'A very long folder name that does not fit'), box('b', 'Short')];
+
+function label(name: string): HTMLElement {
+ return screen.getByText(name);
+}
+function setWidths(el: HTMLElement, scroll: number, client: number) {
+ Object.defineProperty(el, 'scrollWidth', { configurable: true, value: scroll });
+ Object.defineProperty(el, 'clientWidth', { configurable: true, value: client });
+}
+
+describe('cut folder names', () => {
+ beforeEach(() => {
+ useAuthStore.setState({ activeAccountId: 'login-a', client: { getAccountId: () => 'me', getCapabilities: () => ({}) } } as never);
+ useSettingsStore.setState({ hideAccountSwitcher: true } as never);
+ });
+ afterEach(() => vi.restoreAllMocks());
+
+ it('show the full name as a title when the ellipsis cuts them', () => {
+ render();
+ const long = label('A very long folder name that does not fit');
+ setWidths(long, 300, 120);
+ fireEvent.pointerEnter(long);
+ expect(long.title).toBe('A very long folder name that does not fit');
+ });
+
+ it('get no title when they fit', () => {
+ render();
+ const short = label('Short');
+ setWidths(short, 40, 120);
+ fireEvent.pointerEnter(short);
+ expect(short.title).toBe('');
+ });
+});
diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx
index e320c46e2..a71939103 100644
--- a/components/layout/sidebar.tsx
+++ b/components/layout/sidebar.tsx
@@ -304,6 +304,15 @@ function SidebarRowCounts({
* What a row's menu handler needs from an event, for a menu opened by a
* long-press (at the finger) or from the row's â‹® (under the button).
*/
+/**
+ * R11: a name cut with an ellipsis gets a title with the full text. Measured
+ * when the pointer arrives, since only then is the title shown: a shortened
+ * form, or one the row's width still cuts.
+ */
+function titleIfCut(element: HTMLElement, full: string, shortened: boolean) {
+ element.title = shortened || element.scrollWidth > element.clientWidth ? full : "";
+}
+
function menuEventAt(element: HTMLElement, point?: { clientX: number; clientY: number }): React.MouseEvent {
return {
type: point ? "contextmenu" : "click",
@@ -460,7 +469,14 @@ function SidebarRow({
{!isCollapsed && (
<>
- {shortenedLabel}
+ titleIfCut(e.currentTarget, label, shortenedLabel !== label)}
+ >
+ {shortenedLabel}
+
{muted && (