fix: show the message list, not only the reader, with a large browser text size and wrap cut-off labels

This commit is contained in:
Linus Rath
2026-10-29 19:38:45 +01:00
parent e57e922950
commit b22968a7df
10 changed files with 119 additions and 17 deletions
+31
View File
@@ -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) {
@@ -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: () => {},
@@ -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' }));
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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);
@@ -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,
+2 -2
View File
@@ -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<HTMLDivElement>(null);
// Where the focus was before the shortcut moved it into a toast.
@@ -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);
});
});
+23 -8
View File
@@ -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;
}
+17
View File
@@ -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('<button><span class="truncate">Notifications</span></button>').querySelector('.truncate')!;
document.body.append(label.parentElement!);
expect(wrap.some((s) => label.matches(s))).toBe(true);
const rowLabel = el('<div role="option"><span class="truncate">Subject</span></div>').querySelector('.truncate')!;
document.body.append(rowLabel.parentElement!);
expect(wrap.some((s) => rowLabel.matches(s))).toBe(false);
});
});