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 && (