mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
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:
@@ -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' }));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user