From b22968a7dfcb160af74e88417e33bbec428fb7fa Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:56:27 +0100 Subject: [PATCH] fix: show the message list, not only the reader, with a large browser text size and wrap cut-off labels --- app/accessibility.css | 31 +++++++++++++++ .../admin/__tests__/admin-shell.test.tsx | 2 +- .../contacts/__tests__/contacts-app.test.tsx | 4 +- components/settings/settings-app.tsx | 4 +- components/tour/__tests__/tour.test.tsx | 2 +- components/ui/__tests__/toast-layout.test.tsx | 2 +- components/ui/toast.tsx | 4 +- .../use-media-query-breakpoints.test.ts | 39 +++++++++++++++++++ hooks/use-media-query.ts | 31 +++++++++++---- lib/__tests__/accessibility-css.test.ts | 17 ++++++++ 10 files changed, 119 insertions(+), 17 deletions(-) create mode 100644 hooks/__tests__/use-media-query-breakpoints.test.ts diff --git a/app/accessibility.css b/app/accessibility.css index 5ebb211ed..556c85142 100644 --- a/app/accessibility.css +++ b/app/accessibility.css @@ -139,6 +139,37 @@ scroll-padding-block: 4rem; } +/* ---------- Large text in narrow panes (WCAG 1.4.4, 1.4.10) ---------- + * + * The folder list and the settings navigation keep their width in px while + * the text grows with the text size setting, so at 200% their labels were + * cut to a few letters ("Inb..."). Both are size containers; once one is + * narrower than 14rem of the current text size (224px at 100%, so never at + * the default width), labels wrap instead of being cut. Anywhere else, a + * cut label shows in full while its button, link, menu item, tab or tree + * item has the keyboard focus (not list rows, whose height must not jump). + */ +nav:is([data-tour="sidebar"], [data-tour="settings-tabs"]) { + container: a11y-nav / inline-size; +} +@container a11y-nav (width < 14rem) { + .truncate { + overflow: visible; + text-overflow: clip; + white-space: normal; + overflow-wrap: break-word; + hyphens: auto; + } +} +:is(button, a[href], [role="menuitem"], [role="tab"], [role="treeitem"]):focus-visible:is(.truncate), +:is(button, a[href], [role="menuitem"], [role="tab"], [role="treeitem"]):focus-visible .truncate { + overflow: visible; + text-overflow: clip; + white-space: normal; + overflow-wrap: break-word; + hyphens: auto; +} + /* ---------- More contrast (WCAG 1.4.6 as a setting) ---------- */ @media (prefers-contrast: more) { diff --git a/components/admin/__tests__/admin-shell.test.tsx b/components/admin/__tests__/admin-shell.test.tsx index c564c1461..d1d081e7e 100644 --- a/components/admin/__tests__/admin-shell.test.tsx +++ b/components/admin/__tests__/admin-shell.test.tsx @@ -16,7 +16,7 @@ import { useUpdateStore } from '@/stores/update-store'; function setDesktop(desktop: boolean) { window.matchMedia = vi.fn((query: string) => ({ - matches: desktop && query.includes('min-width: 1024px'), + matches: desktop && query === '(width >= 64rem)', media: query, addEventListener: () => {}, removeEventListener: () => {}, diff --git a/components/contacts/__tests__/contacts-app.test.tsx b/components/contacts/__tests__/contacts-app.test.tsx index 95d65033a..cd153f9e8 100644 --- a/components/contacts/__tests__/contacts-app.test.tsx +++ b/components/contacts/__tests__/contacts-app.test.tsx @@ -222,7 +222,7 @@ describe('ContactsApp', () => { it('has a create button on the phone, as Mail and Files do', () => { const matchMedia = window.matchMedia; - window.matchMedia = ((query: string) => ({ ...matchMedia(query), matches: query.includes('max-width') })) as typeof window.matchMedia; + window.matchMedia = ((query: string) => ({ ...matchMedia(query), matches: query.includes('width <') })) as typeof window.matchMedia; try { renderContacts(); fireEvent.click(screen.getByRole('button', { name: 'create_new' })); @@ -253,7 +253,7 @@ describe('ContactsApp', () => { it('puts New group and Import in the phone header menu', () => { const matchMedia = window.matchMedia; - window.matchMedia = ((query: string) => ({ ...matchMedia(query), matches: query.includes('max-width') })) as typeof window.matchMedia; + window.matchMedia = ((query: string) => ({ ...matchMedia(query), matches: query.includes('width <') })) as typeof window.matchMedia; try { renderContacts(); fireEvent.click(screen.getByRole('button', { name: 'more' })); diff --git a/components/settings/settings-app.tsx b/components/settings/settings-app.tsx index 270baf3ef..d7c846125 100644 --- a/components/settings/settings-app.tsx +++ b/components/settings/settings-app.tsx @@ -98,7 +98,7 @@ import { archiveActionSearchPaths, } from '@/lib/settings-search'; import { useAccountStore } from '@/stores/account-store'; -import { useMediaQuery } from '@/hooks/use-media-query'; +import { useMediaQuery, atLeast } from '@/hooks/use-media-query'; import { useLiteLinkSegments } from '@/hooks/use-lite-link-segments'; import { useDocumentTitle } from '@/hooks/use-document-title'; import { MvTopBar } from "@/components/layout/mv-top-bar"; @@ -536,7 +536,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps = if (typeof window === 'undefined') return; if (!mobileShowContent) return; // The first hydration pass reports a phone layout on every screen. - if (!isEmbedded && window.matchMedia('(min-width: 1024px)').matches) return; + if (!isEmbedded && window.matchMedia(atLeast(1024)).matches) return; // A page opened straight from a link has no list below it yet: turn the // entry it arrived on into the list, then push the page on top. Inside diff --git a/components/tour/__tests__/tour.test.tsx b/components/tour/__tests__/tour.test.tsx index f01678478..28a29759a 100644 --- a/components/tour/__tests__/tour.test.tsx +++ b/components/tour/__tests__/tour.test.tsx @@ -217,7 +217,7 @@ describe('tour text on a phone', () => { const step = tour.steps[0] as { phoneDescriptionKey?: string }; step.phoneDescriptionKey = 'one_desc_phone'; const spy = vi.spyOn(window, 'matchMedia').mockImplementation((query: string) => ({ - matches: query === '(max-width: 767px)', media: query, onchange: null, + matches: query === '(width < 48rem)', media: query, onchange: null, addEventListener: () => {}, removeEventListener: () => {}, addListener: () => {}, removeListener: () => {}, dispatchEvent: () => false, }) as MediaQueryList); diff --git a/components/ui/__tests__/toast-layout.test.tsx b/components/ui/__tests__/toast-layout.test.tsx index 54d575b60..a7422ab58 100644 --- a/components/ui/__tests__/toast-layout.test.tsx +++ b/components/ui/__tests__/toast-layout.test.tsx @@ -122,7 +122,7 @@ describe('toasts and open menus', () => { useToastStore.setState({ toasts: [] }); phone = true; vi.spyOn(window, 'matchMedia').mockImplementation((query: string) => ({ - matches: query === '(max-width: 639px)' ? phone : false, + matches: query === '(width < 40rem)' ? phone : false, media: query, onchange: null, addEventListener: () => {}, removeEventListener: () => {}, addListener: () => {}, removeListener: () => {}, dispatchEvent: () => false, diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx index c577e6197..106df1542 100644 --- a/components/ui/toast.tsx +++ b/components/ui/toast.tsx @@ -7,7 +7,7 @@ import { X, CheckCircle, AlertCircle, Info, AlertTriangle, Loader2 } from "@/com import { cn } from "@/lib/utils"; import { TOUCH_HIT_AREA } from "@/components/ui/button"; import { useContextMenuOpen } from "@/components/ui/context-menu"; -import { useMediaQuery } from "@/hooks/use-media-query"; +import { useMediaQuery, narrowerThan } from "@/hooks/use-media-query"; export type ToastType = "success" | "error" | "info" | "warning"; @@ -361,7 +361,7 @@ export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose: // last items. While a menu is open they step aside, and their countdown // waits, so an Undo is still there when the menu closes. const menuOpen = useContextMenuOpen(); - const phone = useMediaQuery("(max-width: 639px)"); + const phone = useMediaQuery(narrowerThan(640)); const held = menuOpen && phone; const containerRef = useRef(null); // Where the focus was before the shortcut moved it into a toast. diff --git a/hooks/__tests__/use-media-query-breakpoints.test.ts b/hooks/__tests__/use-media-query-breakpoints.test.ts new file mode 100644 index 000000000..6e3472565 --- /dev/null +++ b/hooks/__tests__/use-media-query-breakpoints.test.ts @@ -0,0 +1,39 @@ +/** + * The scripts' breakpoints follow the browser's default text size, as the + * CSS breakpoints (Tailwind's rem) do. With a 32px default a 1440px window is + * 45rem wide: the CSS draws the phone layout, so the scripts must pick it too, + * or the mail screen showed only the reading pane with no list (LV-11). + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useIsMobile, useIsDesktop, narrowerThan, atLeast } from '../use-media-query'; + +/** A window `px` wide whose default text size is `rootPx`. */ +function viewport(px: number, rootPx: number) { + vi.stubGlobal('matchMedia', (query: string) => { + const m = /\(width (<|>=) ([\d.]+)rem\)/.exec(query); + const matches = m ? (m[1] === '<' ? px < Number(m[2]) * rootPx : px >= Number(m[2]) * rootPx) : false; + return { matches, media: query, addEventListener() {}, removeEventListener() {} }; + }); +} + +afterEach(() => vi.unstubAllGlobals()); + +describe('breakpoints', () => { + it('writes the queries in rem, as Tailwind does', () => { + expect(narrowerThan(768)).toBe('(width < 48rem)'); + expect(atLeast(1024)).toBe('(width >= 64rem)'); + }); + + it('is a desktop at 1440px with the default 16px text', () => { + viewport(1440, 16); + expect(renderHook(() => useIsDesktop()).result.current).toBe(true); + expect(renderHook(() => useIsMobile()).result.current).toBe(false); + }); + + it('is a phone at 1440px when the browser text size is 32px', () => { + viewport(1440, 32); + expect(renderHook(() => useIsDesktop()).result.current).toBe(false); + expect(renderHook(() => useIsMobile()).result.current).toBe(true); + }); +}); diff --git a/hooks/use-media-query.ts b/hooks/use-media-query.ts index 67de1f276..fc933a4a2 100644 --- a/hooks/use-media-query.ts +++ b/hooks/use-media-query.ts @@ -5,7 +5,7 @@ import { useUIStore } from "@/stores/ui-store"; import { useShallow } from "zustand/react/shallow"; import { usePaneSize } from "@/hooks/use-pane-size"; -// Tailwind v4 breakpoints +// Tailwind v4 breakpoints, in px at the default 16px text size. const BREAKPOINTS = { sm: 640, md: 768, @@ -16,6 +16,23 @@ const BREAKPOINTS = { const getMediaQueryServerSnapshot = () => false; +/** + * The media query for "narrower than `px`" in rem, as Tailwind writes its own + * breakpoints (`max-md:` is `width < 48rem`). A rem in a media query is the + * browser's default text size, so someone who sets a larger default (say + * 32px) gets the layout the CSS draws at that size: a 1440px window then + * counts as a phone in both, instead of the scripts choosing the desktop + * panes while the CSS hides them. + */ +export function narrowerThan(px: number): string { + return `(width < ${px / 16}rem)`; +} + +/** The media query for "at least `px` wide", in rem (see narrowerThan). */ +export function atLeast(px: number): string { + return `(width >= ${px / 16}rem)`; +} + /** * SSR-safe media query hook. On SSR and the first hydration pass we report * `false`; on all subsequent client renders (including client-side navigation @@ -73,11 +90,9 @@ export function useDeviceDetection() { const paneWidth = usePaneSize(); const paneClassification = classifyPane(paneWidth); - const isMobileQuery = useMediaQuery(`(max-width: ${BREAKPOINTS.md - 1}px)`); - const isTabletQuery = useMediaQuery( - `(min-width: ${BREAKPOINTS.md}px) and (max-width: ${BREAKPOINTS.lg - 1}px)` - ); - const isDesktopQuery = useMediaQuery(`(min-width: ${BREAKPOINTS.lg}px)`); + const isMobileQuery = useMediaQuery(narrowerThan(BREAKPOINTS.md)); + const isTabletQuery = useMediaQuery(`${atLeast(BREAKPOINTS.md)} and ${narrowerThan(BREAKPOINTS.lg)}`); + const isDesktopQuery = useMediaQuery(atLeast(BREAKPOINTS.lg)); useEffect(() => { if (paneClassification) return; @@ -93,12 +108,12 @@ export function useDeviceDetection() { */ export function useIsMobile() { const paneWidth = usePaneSize(); - const viewport = useMediaQuery(`(max-width: ${BREAKPOINTS.md - 1}px)`); + const viewport = useMediaQuery(narrowerThan(BREAKPOINTS.md)); return paneWidth !== null ? paneWidth < BREAKPOINTS.md : viewport; } export function useIsDesktop() { const paneWidth = usePaneSize(); - const viewport = useMediaQuery(`(min-width: ${BREAKPOINTS.lg}px)`); + const viewport = useMediaQuery(atLeast(BREAKPOINTS.lg)); return paneWidth !== null ? paneWidth >= BREAKPOINTS.lg : viewport; } diff --git a/lib/__tests__/accessibility-css.test.ts b/lib/__tests__/accessibility-css.test.ts index e75842ce0..7782152e8 100644 --- a/lib/__tests__/accessibility-css.test.ts +++ b/lib/__tests__/accessibility-css.test.ts @@ -160,3 +160,20 @@ describe('large targets', () => { for (const s of selectorsOf('min-height: 44px;')) expect(s.startsWith(':root[data-large-targets="on"]')).toBe(true); }); }); + +describe('large text in narrow panes', () => { + it('lets labels in the folder list and settings navigation wrap once the pane is narrow for the text', () => { + expect(selectorsOf('container: a11y-nav / inline-size;')).toEqual(['nav:is([data-tour="sidebar"], [data-tour="settings-tabs"])']); + expect(css).toMatch(/@container a11y-nav \(width < 14rem\) \{\s*\.truncate \{[^}]*white-space: normal;/); + }); + + it('shows a cut label in full while its control has the keyboard focus', () => { + const wrap = selectorsOf('hyphens: auto;\n}').map((s) => s.replace(':focus-visible', '')); + const label = el('').querySelector('.truncate')!; + document.body.append(label.parentElement!); + expect(wrap.some((s) => label.matches(s))).toBe(true); + const rowLabel = el('
Subject
').querySelector('.truncate')!; + document.body.append(rowLabel.parentElement!); + expect(wrap.some((s) => rowLabel.matches(s))).toBe(false); + }); +});