diff --git a/app/accessibility.css b/app/accessibility.css index b4dcf6a84..9047eae2a 100644 --- a/app/accessibility.css +++ b/app/accessibility.css @@ -253,3 +253,9 @@ html.dark[data-contrast="more"] { outline-color: Highlight !important; } } + +/* ---------- Simple mode: no side panels ---------- */ + +:root[data-simple-mode="on"] [data-simple-hide] { + display: none !important; +} diff --git a/components/email/__tests__/email-list-paged.test.tsx b/components/email/__tests__/email-list-paged.test.tsx new file mode 100644 index 000000000..5742d8bb0 --- /dev/null +++ b/components/email/__tests__/email-list-paged.test.tsx @@ -0,0 +1,78 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; +import { EmailList } from '../email-list'; +import { useEmailStore } from '@/stores/email-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import type { Email } from '@/lib/jmap/types'; + +// SR-21: simple mode can show the list in pages of 50 with Previous and Next. + +vi.mock('@/hooks/use-email-drag', () => ({ + useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }), +})); +vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null })); +vi.mock('@/stores/auth-store', async () => { + const { create } = await import('zustand'); + return { + useAuthStore: create(() => ({ client: null, identities: [], username: null, activeAccountId: 'acct-1' })), + }; +}); +vi.mock('@/stores/account-store', () => { + const state = { getAccountById: () => undefined }; + return { + useAccountStore: Object.assign( + (selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state), + { getState: () => state }, + ), + }; +}); + +const MAILS: Email[] = Array.from({ length: 120 }, (_, i) => ({ + id: `m${i}`, + threadId: `t${i}`, + mailboxIds: { inbox: true }, + keywords: { $seen: true }, + size: 1000, + receivedAt: new Date(Date.UTC(2026, 8, 30, 12, 0) - i * 60_000).toISOString(), + from: [{ name: 'Alice', email: 'alice@example.com' }], + subject: `Mail ${i}`, + hasAttachment: false, +})); + +const initialEmailState = useEmailStore.getState(); + +beforeEach(() => { + useEmailStore.setState(initialEmailState, true); + useEmailStore.setState({ selectedMailbox: 'inbox', emails: MAILS, hasMoreEmails: false }); + useSettingsStore.setState({ simpleMode: true, simpleModePagedList: true, disableThreading: true } as never); +}); + +describe('paged message list (SR-21)', () => { + it('shows 50 rows at a time and pages with Next and Previous', () => { + render(); + expect(screen.getAllByRole('row')).toHaveLength(50); + expect(screen.getByRole('row', { name: /Mail 0,/ })).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'pages.next' })); + expect(screen.getByRole('row', { name: /Mail 50,/ })).toBeTruthy(); + expect(screen.queryByRole('row', { name: /Mail 0,/ })).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'pages.next' })); + expect(screen.getAllByRole('row')).toHaveLength(20); + expect(screen.getByRole('button', { name: 'pages.next' })).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: 'pages.previous' })); + expect(screen.getByRole('row', { name: /Mail 50,/ })).toBeTruthy(); + }); + + it('tells each row its place in the whole list', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'pages.next' })); + expect(screen.getByRole('row', { name: /Mail 50,/ })).toHaveAttribute('aria-posinset', '51'); + expect(screen.getByRole('row', { name: /Mail 50,/ })).toHaveAttribute('aria-setsize', '120'); + }); + + it('scrolls endlessly as before without the paged option', () => { + useSettingsStore.setState({ simpleModePagedList: false } as never); + render(); + expect(screen.queryByRole('button', { name: 'pages.next' })).toBeNull(); + }); +}); diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 3183e4bc0..aaab68082 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -143,6 +143,9 @@ function LoadingSkeleton() { ); } +/** Rows per page in simple mode's paged list. */ +const LIST_PAGE_SIZE = 50; + export function EmailList({ emails, selectedEmailKey, @@ -503,13 +506,6 @@ export function EmailList({ entry?.borderBoxSize?.[0]?.blockSize ?? element.getBoundingClientRect().height, }); - // One Tab stop in the list, and the focus going along with j/k (SR-01/02). - const renderedRows = virtualizer.getVirtualItems(); - useListboxFocus( - parentRef, - selectedEmailKey, - `${items.length}:${renderedRows[0]?.index ?? -1}-${renderedRows[renderedRows.length - 1]?.index ?? -1}:${expandedThreadIds.size}`, - ); // Another folder, tag, account, unified view or plugin tab - or another // search - opens at the top. The scroll container outlives the switch, so @@ -892,6 +888,104 @@ export function EmailList({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [density, isFocusedMailLayout, showPreview]); + // One list item's content, for the virtual list and the paged one. + const renderItemContent = (index: number) => { + const listItem = items[index]; + const thread = listItem?.kind === 'row' ? listItem.thread : null; + return ( + <> + {listItem?.kind === 'header' && unreadSection && ( + listItem.section === 'unread' ? ( + updateSetting('mvUnreadSectionCollapsed', !unreadCollapsed)} + shown={sectionRowCount} + total={unreadSection.total} + /> + ) : ( + updateSetting('mvEverythingElseCollapsed', !restCollapsed)} + shown={emails.length - sectionRowCount} + total={totalEmails} + /> + ) + )} + {listItem?.kind === 'unread-empty' && ( +

+ {t('sections.unread_empty')} +

+ )} + {thread && } + + ); + }; + + // Simple mode's paged list (SR-21): pages of 50 rows with Previous and + // Next instead of endless scrolling, which a screen reader handles better. + const paged = useSettingsStore((state) => state.simpleMode && state.simpleModePagedList); + const [page, setPage] = useState(0); + const pageCount = Math.max(1, Math.ceil(itemOfThread.length / LIST_PAGE_SIZE)); + const pageItems = useMemo( + () => (paged ? itemOfThread.slice(page * LIST_PAGE_SIZE, (page + 1) * LIST_PAGE_SIZE) : []), + [paged, page, itemOfThread], + ); + const canGoNext = (page + 1) * LIST_PAGE_SIZE < itemOfThread.length || footerHasMore; + const pendingNextPage = useRef(false); + const goToNextPage = useCallback(() => { + if ((page + 1) * LIST_PAGE_SIZE < itemOfThread.length) setPage(page + 1); + else if (footerHasMore) { pendingNextPage.current = true; handleLoadMore(); } + }, [page, itemOfThread.length, footerHasMore, handleLoadMore]); + // The next page arrives from the server: show it. + useEffect(() => { + if (!pendingNextPage.current || (page + 1) * LIST_PAGE_SIZE >= itemOfThread.length) return; + pendingNextPage.current = false; + setPage(page + 1); + }, [itemOfThread.length, page]); + // Another folder or search starts on its first page. + useEffect(() => { setPage(0); }, [viewKey]); + // j/k past the end of a page turn the page. + useEffect(() => { + if (!paged || !selectedEmailKey) return; + const index = threadGroups.findIndex((thread) => thread.emails.some((e) => emailKeyFor(e) === selectedEmailKey)); + if (index < 0) return; + const target = Math.floor(index / LIST_PAGE_SIZE); + if (target !== page) setPage(target); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [paged, selectedEmailKey]); + + // One Tab stop in the list, and the focus going along with j/k (SR-01/02). + const renderedRows = virtualizer.getVirtualItems(); + useListboxFocus( + parentRef, + selectedEmailKey, + `${items.length}:${renderedRows[0]?.index ?? -1}-${renderedRows[renderedRows.length - 1]?.index ?? -1}:${expandedThreadIds.size}:${paged ? page : -1}`, + ); + return (
@@ -1128,6 +1222,32 @@ export function EmailList({
) : ( + paged ? ( + <> +
+ {pageItems.map((index) => ( +
+ {renderItemContent(index)} +
+ ))} +
+ + + ) : ( <>
- {listItem?.kind === 'header' && unreadSection && ( - listItem.section === 'unread' ? ( - updateSetting('mvUnreadSectionCollapsed', !unreadCollapsed)} - shown={sectionRowCount} - total={unreadSection.total} - /> - ) : ( - updateSetting('mvEverythingElseCollapsed', !restCollapsed)} - shown={emails.length - sectionRowCount} - total={totalEmails} - /> - ) - )} - {listItem?.kind === 'unread-empty' && ( -

- {t('sections.unread_empty')} -

- )} - {thread && } + {renderItemContent(virtualItem.index)}
); })} @@ -1229,6 +1303,7 @@ export function EmailList({ )} } + ) )} diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 00d980158..ab4953535 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -1504,7 +1504,9 @@ export function EmailViewer({ /> ); // Mountain View's toolbars are icon-only, on the phone as on the desktop. - const showToolbarLabels = showToolbarLabelsSetting && interfaceLayout !== 'mountain-view'; + const simpleMode = useSettingsStore((state) => state.simpleMode); + // Simple mode always names the toolbar's buttons in words. + const showToolbarLabels = (showToolbarLabelsSetting || simpleMode) && interfaceLayout !== 'mountain-view'; // Inside a Pro pane, `isMobile` above is pane-width based: overlays that // would go viewport-fixed must instead cover just the pane (via // PaneOverlay + absolute positioning), and viewport CSS breakpoints like diff --git a/components/layout/mv-side-panel.tsx b/components/layout/mv-side-panel.tsx index fdbfb054f..6d63c8796 100644 --- a/components/layout/mv-side-panel.tsx +++ b/components/layout/mv-side-panel.tsx @@ -64,6 +64,7 @@ export function MvSidePanel() { <> {active && (