From dcc504fcfa3d740ca79f9e26350c57397b32e8db Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:50:40 +0100 Subject: [PATCH] feat: add landmarks, an h1 per app, skip links and F6 between panes --- app/(main)/[locale]/layout.tsx | 2 + components/calendar/calendar-app.tsx | 9 +- components/contacts/contacts-app.tsx | 19 ++- .../thread-conversation-actions.test.tsx | 2 +- components/email/email-list.tsx | 1 + components/email/email-viewer.tsx | 10 +- components/email/thread-conversation-view.tsx | 4 +- components/files/file-browser.tsx | 20 ++- components/files/file-views.tsx | 4 +- components/files/files-app.tsx | 11 +- .../layout/__tests__/app-landmarks.test.tsx | 119 ++++++++++++++++++ components/layout/app-landmarks.tsx | 65 ++++++++++ components/layout/mobile-header.tsx | 3 +- components/layout/pane-cycle.tsx | 67 ++++++++++ components/layout/sidebar.tsx | 4 +- components/mail/mail-app.tsx | 22 +++- components/settings/settings-app.tsx | 20 +-- lib/keyboard.ts | 15 +++ locales/ar/common.json | 6 +- locales/ca/common.json | 6 +- locales/cs/common.json | 6 +- locales/da/common.json | 6 +- locales/de/common.json | 6 +- locales/en/common.json | 6 +- locales/es/common.json | 6 +- locales/et/common.json | 6 +- locales/fa/common.json | 6 +- locales/fr/common.json | 6 +- locales/he/common.json | 6 +- locales/hu/common.json | 6 +- locales/it/common.json | 6 +- locales/ja/common.json | 6 +- locales/ko/common.json | 6 +- locales/lv/common.json | 6 +- locales/mn/common.json | 6 +- locales/nb/common.json | 6 +- locales/nl/common.json | 6 +- locales/pl/common.json | 6 +- locales/pt/common.json | 6 +- locales/ro/common.json | 6 +- locales/ru/common.json | 6 +- locales/sk/common.json | 6 +- locales/th/common.json | 6 +- locales/tr/common.json | 6 +- locales/uk/common.json | 6 +- locales/zh-TW/common.json | 6 +- locales/zh/common.json | 6 +- 47 files changed, 501 insertions(+), 70 deletions(-) create mode 100644 components/layout/__tests__/app-landmarks.test.tsx create mode 100644 components/layout/app-landmarks.tsx create mode 100644 components/layout/pane-cycle.tsx diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index eb4793724..3539ff19a 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -12,6 +12,7 @@ import { ImpersonationReconciler } from "@/components/impersonation/impersonatio import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host"; import { ExternalRecipientConfirmHost } from "@/components/email/external-recipient-confirm"; import { PluginPageNavigator } from "@/components/plugins/plugin-page-navigator"; +import { PaneCycleHost } from "@/components/layout/pane-cycle"; import { CommandPaletteHost } from "@/components/command-palette/command-palette"; import { AccessibilityQuickPanelHost } from "@/components/accessibility/accessibility-quick-panel"; import { TotpReauthDialog } from "@/components/totp-reauth-dialog"; @@ -79,6 +80,7 @@ export default async function LocaleLayout({ + diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 2f0159efe..909071cda 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -105,6 +105,7 @@ import { MvTopBar } from "@/components/layout/mv-top-bar"; import { useMountainViewShell } from "@/hooks/use-mountain-view-shell"; import { calendarHooks } from "@/lib/plugin-hooks"; import { eventToView } from "@/lib/plugin-projection"; +import { AppMain, SkipLink } from "@/components/layout/app-landmarks"; import { calendarShortcutAction } from "@/lib/calendar-shortcuts"; import { isAppShortcutBlocked, isArrowKeyWidgetTarget } from "@/lib/keyboard"; import { isContextMenuOpen } from "@/components/ui/context-menu"; @@ -1612,6 +1613,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = // The command palette's Calendar group, while this calendar is the one in // front (in the Pro shell: its focused tab). const tCommon = useTranslations("common"); + const tShell = useTranslations("shell"); const paletteViews: { mode: CalendarViewMode; key: string }[] = [ { mode: "day", key: "d" }, { mode: "week", key: "w" }, @@ -2044,6 +2046,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = return (
+ {!isEmbedded && {tShell("skip_to_content")}} {refreshIndicator} {mvShell && ( @@ -2094,6 +2097,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = {(isNarrow || !sidebarCollapsed) && ( <>
)} -
+ {calendarToolbar} {/* isolate keeps the views' sticky headers (z-50) below the toolbar's @@ -2285,7 +2290,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = )}
-
+
)} diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index d5868d668..2897db310 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -1,5 +1,6 @@ "use client"; +import { AppMain, SkipLink } from "@/components/layout/app-landmarks"; import { Suspense, useState, useEffect, useCallback, useRef, useMemo } from "react"; import { useLocale, useTranslations } from "next-intl"; import { useSearchParams } from "next/navigation"; @@ -89,6 +90,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = const t = useTranslations("contacts"); const tSidebar = useTranslations("sidebar"); const tCommon = useTranslations("common"); + const tShell = useTranslations("shell"); useDocumentTitle(tSidebar("contacts")); const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled')); const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); @@ -1156,6 +1158,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = return (
+ {!isEmbedded && {tShell("skip_to_content")}} {refreshIndicator} {mvShell && ( @@ -1200,13 +1203,12 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = onClick={() => setNarrowSidebarOpen(false)} /> )} - {showListPanel && ( - <> - {/* Panel 1: Categories sidebar (in-flow on desktop, overlay on narrow) */} - {(!isMobile || isNarrow) && ( + {/* Panel 1: Categories sidebar (in-flow on desktop, overlay on narrow) */} + {showListPanel && (!isMobile || isNarrow) && ( <>
)} + {/* The list and the contact: the main content, after the sidebar. */} + + {showListPanel && ( + <> {/* Panel 2: Contact list */}
)} +
{isMobile && !isEmbedded && ( diff --git a/components/email/__tests__/thread-conversation-actions.test.tsx b/components/email/__tests__/thread-conversation-actions.test.tsx index 808a64617..293c7cee2 100644 --- a/components/email/__tests__/thread-conversation-actions.test.tsx +++ b/components/email/__tests__/thread-conversation-actions.test.tsx @@ -67,7 +67,7 @@ describe('phone conversation header', () => { it('keeps a long subject to two lines', () => { renderView({}); - expect(screen.getByRole('heading', { level: 1 }).className).toContain('line-clamp-2'); + expect(screen.getByRole('heading', { level: 2 }).className).toContain('line-clamp-2'); }); }); diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index abfccfc25..3636b5245 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -1067,6 +1067,7 @@ export function EmailList({ // The mail shell's "Skip to message list" lands here. id="message-list" tabIndex={-1} + data-pane="list" // Room under the last row for the round Compose button, which would // otherwise sit on that row's actions (Mountain View has no such button). className={cn("flex-1 overflow-y-auto bg-background relative outline-none", !mountainViewList && "pb-20")} diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 9c51c7d57..e11f183d6 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -3988,7 +3988,7 @@ export function EmailViewer({ alt="Bulwark Mail" className="h-12 mx-auto mb-6" /> -

{tDemoWelcome('title')}

+

{tDemoWelcome('title')}

{tDemoWelcome('description')}

@@ -4031,7 +4031,7 @@ export function EmailViewer({
-

{t('no_conversation_selected')}

+

{t('no_conversation_selected')}

{t('no_conversation_description')}

{onCompose && ( + + , + ); + act(() => { fireEvent.click(screen.getByRole('link', { name: 'Skip to content' })); }); + const main = screen.getByRole('main'); + expect(main).toHaveFocus(); + // The tabindex lasts only while focused, so clicks inside don't keep the focus there. + act(() => { screen.getByRole('button', { name: 'first' }).focus(); }); + expect(main).not.toHaveAttribute('tabindex'); + }); +}); + +describe('F6 between panes', () => { + function layout() { + render( + <> + + +
row
+
+ , + ); + } + const f6 = (shiftKey = false) => act(() => { fireEvent.keyDown(document.body, { key: 'F6', shiftKey }); }); + + it('goes folders, list, reader and round again', () => { + layout(); + f6(); + expect(screen.getByRole('button', { name: 'Inbox' })).toHaveFocus(); + f6(); + expect(document.getElementById('message-list')).toHaveFocus(); + f6(); + expect(screen.getByRole('region', { name: 'Reading pane' })).toHaveFocus(); + f6(); + expect(screen.getByRole('button', { name: 'Inbox' })).toHaveFocus(); + }); + + it('goes back with Shift+F6', () => { + layout(); + act(() => { document.getElementById('message-list')!.focus(); }); + f6(true); + expect(screen.getByRole('button', { name: 'Inbox' })).toHaveFocus(); + f6(true); + expect(screen.getByRole('region', { name: 'Reading pane' })).toHaveFocus(); + }); + + it('moves on from wherever the focus is inside a pane', () => { + layout(); + act(() => { screen.getByRole('button', { name: 'Reply' }).focus(); }); + f6(); + expect(screen.getByRole('button', { name: 'Inbox' })).toHaveFocus(); + }); + + it('stays put while a dialog is open', () => { + layout(); + const dialog = document.createElement('div'); + dialog.setAttribute('role', 'dialog'); + dialog.setAttribute('aria-modal', 'true'); + const inside = document.createElement('button'); + dialog.appendChild(inside); + document.body.appendChild(dialog); + act(() => inside.focus()); + f6(); + expect(inside).toHaveFocus(); + }); + + it('skips a pane that is hidden', () => { + render( + <> + +
+ , + ); + expect(cyclePane(false)).toBe(true); + expect(screen.getByRole('region', { name: 'Content' })).toHaveFocus(); + }); +}); diff --git a/components/layout/app-landmarks.tsx b/components/layout/app-landmarks.tsx new file mode 100644 index 000000000..abbf79204 --- /dev/null +++ b/components/layout/app-landmarks.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { forwardRef, useId, type HTMLAttributes, type ReactNode } from "react"; +import { focusTemporarily } from "@/lib/keyboard"; + +/** + * The skip link at the top of an app: hidden until it has the focus, then + * it moves the focus past the rail and the sidebar (WCAG 2.4.1). + */ +export function SkipLink({ targetId, children }: { targetId?: string; children: ReactNode }) { + return ( + { + e.preventDefault(); + // Without a target, the page's
(there is one per app). + const target = targetId ? document.getElementById(targetId) : document.querySelector("main"); + if (target) focusTemporarily(target); + }} + className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:start-2 focus:z-[70] focus:rounded-md focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:text-foreground focus:shadow-md focus:outline-none focus:ring-2 focus:ring-ring" + > + {children} + + ); +} + +interface AppMainProps extends Omit, "role"> { + /** + * Inside the Pro shell, where several apps share one page: a named region + * instead of a second
. + */ + embedded?: boolean; + /** An h1 for screen readers, naming where you are ("Inbox – Mail"). */ + heading?: string; + /** The landmark's name when the app shows its own h1 (the calendar's date). */ + label?: string; + /** False when the panes inside it (Mail's list and reader) take F6 instead. */ + pane?: boolean; + children: ReactNode; +} + +/** + * An app's main content: one
around it (not around the rail or the + * sidebar), a pane for F6, and with an h1 when the app has no visible one. + */ +export const AppMain = forwardRef(function AppMain( + { embedded = false, heading, label, pane = true, className, children, ...rest }, + ref, +) { + const headingId = useId(); + const Tag = embedded ? "section" : "main"; + return ( + + {heading &&

{heading}

} + {children} +
+ ); +}); diff --git a/components/layout/mobile-header.tsx b/components/layout/mobile-header.tsx index c04c36b67..3e2c99b10 100644 --- a/components/layout/mobile-header.tsx +++ b/components/layout/mobile-header.tsx @@ -113,7 +113,8 @@ export function MobileHeader({ {/* Title, unless the bar is in search-first mode */} - {!onOpenSearch &&

{title}

} + {/* Not a heading: the app's h1 already names the folder ("Inbox – Mail"). */} + {!onOpenSearch &&
{title}
}
{onOpenSearch && ( diff --git a/components/layout/pane-cycle.tsx b/components/layout/pane-cycle.tsx new file mode 100644 index 000000000..cf587048e --- /dev/null +++ b/components/layout/pane-cycle.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useEffect } from "react"; +import { focusTemporarily, isKeyboardLayerOpen } from "@/lib/keyboard"; + +/** + * F6 and Shift+F6 move the focus between an app's main panes, as in desktop + * mail clients: Mail's folders, message list and reading pane; the sidebar + * and the content elsewhere. A pane is any visible element marked + * `data-pane`, in page order. + */ + +function isShown(el: HTMLElement): boolean { + if (el.closest("[hidden], [inert], [aria-hidden='true']")) return false; + // checkVisibility() sees display:none and closed drawers; older engines + // (and jsdom) don't have it and count every pane. + const check = (el as HTMLElement & { checkVisibility?: () => boolean }).checkVisibility; + return typeof check === "function" ? check.call(el) : true; +} + +/** Focuses a pane's selected item (the open folder), else the pane itself. */ +function focusPane(pane: HTMLElement): void { + const preferred = pane.querySelector( + "[data-pane-focus], [aria-current='page'], [aria-current='true'], [aria-selected='true']", + ); + if (preferred && isShown(preferred) && preferred.matches("a[href], button, input, select, textarea, [tabindex]")) { + preferred.focus(); + return; + } + focusTemporarily(pane); +} + +export function panesInOrder(root: ParentNode = document): HTMLElement[] { + return Array.from(root.querySelectorAll("[data-pane]")).filter(isShown); +} + +/** Moves the focus to the next pane (or the previous one with `back`). */ +export function cyclePane(back: boolean, root: ParentNode = document): boolean { + const panes = panesInOrder(root); + if (panes.length === 0) return false; + const active = document.activeElement; + // The innermost pane holding the focus, so a pane inside another counts. + let current = -1; + panes.forEach((pane, i) => { + if (active && pane.contains(active)) current = i; + }); + const next = current === -1 + ? (back ? panes.length - 1 : 0) + : (current + (back ? -1 : 1) + panes.length) % panes.length; + focusPane(panes[next]); + return true; +} + +export function PaneCycleHost() { + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "F6" || event.ctrlKey || event.metaKey || event.altKey) return; + if (event.defaultPrevented) return; + // A dialog keeps the focus inside it. + if (isKeyboardLayerOpen()) return; + if (cyclePane(event.shiftKey)) event.preventDefault(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, []); + return null; +} diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index 08db0b4af..cf11e4adb 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -1733,7 +1733,7 @@ function SidebarImpl({ {!isCollapsed && } {/* Mailbox List */} -
+ (null); // Pro can mount two mail panes at once, so the sidebar id must be per-instance. const sidebarId = useId(); + const listHeadingId = useId(); const sidebarContainerRef = useRef(null); const sidebarWasOpen = useRef(false); useEffect(() => { @@ -4891,13 +4893,18 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { {/* Main Content Area. Under Mountain View it is the rounded card inset from the canvas. */} -
{/* Email List - full width on mobile, fixed width/height on tablet/desktop */} -
+

{t('shell.message_list')}

{/* Mobile Header for List View */} {mvMobile && {t('sidebar.compose')}} } -
+ {/* Email list resize handle (desktop only) */} {!isMobile && !isTablet && !isFocusedMailLayout && !isHorizontalMailLayout && !shouldHideViewerPane && !fullscreenReading && ( @@ -5320,7 +5328,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { )} {/* Email Viewer / Composer - full screen on mobile, flex on tablet/desktop */} -
)} -
+
{/* Bottom Navigation - mobile and tablet (hidden when embedded) */} @@ -5560,7 +5570,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { activeAppId={inlineApp?.id ?? null} /> )} -
+ {/* Mountain View: Gmail's side panel - calendar, tasks and contacts next to the mail, with their icons down the right edge. */} {mvShell && !inlineApp && !fullscreenReading && } diff --git a/components/settings/settings-app.tsx b/components/settings/settings-app.tsx index ee651d86e..a0c92051b 100644 --- a/components/settings/settings-app.tsx +++ b/components/settings/settings-app.tsx @@ -1,5 +1,6 @@ "use client"; +import { AppMain, SkipLink } from "@/components/layout/app-landmarks"; import { useState, useEffect, useCallback, useRef, useMemo, useSyncExternalStore } from 'react'; import { useRouter } from '@/i18n/navigation'; import { useTranslations, useMessages } from 'next-intl'; @@ -276,6 +277,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps = const router = useRouter(); const t = useTranslations('settings'); const tSidebar = useTranslations('sidebar'); + const tShell = useTranslations('shell'); const tDelegates = useTranslations('delegates'); const tPalette = useTranslations('command_palette'); useDocumentTitle(tSidebar('settings')); @@ -928,9 +930,9 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
-
+ {renderTabContent()} -
+ {!isEmbedded && (
-
+
{tSidebar('sign_out')}
-
+ {!isEmbedded && ( + {!isEmbedded && {tShell("skip_to_content")}} {mvShell && (
)} -
+
+
{showAdvanced && } @@ -1193,14 +1197,14 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps = onDoubleClick={() => { setSettingsSidebarWidth(256); localStorage.setItem('settings-sidebar-width', '256'); }} /> -
+ void} embedded={isEmbedded} label={activeTabLabel} className="flex-1 overflow-y-auto">
{/* The page's heading for screen readers; the sidebar shows which page is open (the phone header has a visible one). */}

{activeTabLabel}

{renderTabContent()}
-
+ )} diff --git a/lib/keyboard.ts b/lib/keyboard.ts index ffe9e57ee..17a3bc65c 100644 --- a/lib/keyboard.ts +++ b/lib/keyboard.ts @@ -104,3 +104,18 @@ export function isArrowKeyWidgetTarget(event: Event): boolean { 'select, [role="slider"], [role="separator"], [role="scrollbar"], [role="spinbutton"], [role="tablist"], [role="radiogroup"], [role="grid"], [role="treegrid"], [role="tree"], [role="listbox"], [role="menu"], [role="menubar"], [role="toolbar"]', ); } + +/** + * Focuses an element that may not take the focus by itself (a landmark, a + * pane): it gets tabindex="-1" until the focus leaves it again. Kept only + * while focused, since a container with tabindex keeps the focus on a click + * inside it, and the arrow keys then no longer scroll the list in it. + */ +export function focusTemporarily(el: HTMLElement, options?: FocusOptions): void { + const focusable = el.matches("a[href], button, input, select, textarea, [tabindex]"); + if (!focusable) { + el.setAttribute("tabindex", "-1"); + el.addEventListener("blur", () => el.removeAttribute("tabindex"), { once: true }); + } + el.focus(options); +} diff --git a/locales/ar/common.json b/locales/ar/common.json index 4ed9131d1..91cc4802c 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5630,7 +5630,11 @@ "update_available": "يتوفر تحديث", "update_important": "يتوفر تحديث مهم", "folder_unread": "{count} غير مقروءة", - "folder_counts": "{unread} غير مقروءة، {total} إجمالًا" + "folder_counts": "{unread} غير مقروءة، {total} إجمالًا", + "skip_to_content": "انتقل إلى المحتوى", + "heading": "{place} – {app}", + "message_list": "قائمة الرسائل", + "reading_pane": "جزء القراءة" }, "plugin_pages": { "loading": "جارٍ تحميل الصفحة", diff --git a/locales/ca/common.json b/locales/ca/common.json index e336ced11..fb6c42ed9 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5630,7 +5630,11 @@ "update_available": "Hi ha una actualització", "update_important": "Hi ha una actualització important", "folder_unread": "{count} sense llegir", - "folder_counts": "{unread} sense llegir, {total} en total" + "folder_counts": "{unread} sense llegir, {total} en total", + "skip_to_content": "Salta al contingut", + "heading": "{place} – {app}", + "message_list": "Llista de missatges", + "reading_pane": "Panell de lectura" }, "plugin_pages": { "loading": "S'està carregant la pàgina", diff --git a/locales/cs/common.json b/locales/cs/common.json index c6ddd6053..e2c213369 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5630,7 +5630,11 @@ "update_available": "Je k dispozici aktualizace", "update_important": "Je k dispozici důležitá aktualizace", "folder_unread": "Nepřečteno: {count}", - "folder_counts": "Nepřečteno: {unread}, celkem: {total}" + "folder_counts": "Nepřečteno: {unread}, celkem: {total}", + "skip_to_content": "Přejít na obsah", + "heading": "{place} – {app}", + "message_list": "Seznam zpráv", + "reading_pane": "Podokno čtení" }, "plugin_pages": { "loading": "Načítání stránky", diff --git a/locales/da/common.json b/locales/da/common.json index 552cb4b23..ad48d20e3 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5630,7 +5630,11 @@ "update_available": "Opdatering tilgængelig", "update_important": "Vigtig opdatering tilgængelig", "folder_unread": "{count} ulæste", - "folder_counts": "{unread} ulæste, {total} i alt" + "folder_counts": "{unread} ulæste, {total} i alt", + "skip_to_content": "Gå til indhold", + "heading": "{place} – {app}", + "message_list": "Beskedliste", + "reading_pane": "Læserude" }, "plugin_pages": { "loading": "Indlæser side", diff --git a/locales/de/common.json b/locales/de/common.json index 7ccb10ad0..c09811b89 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5630,7 +5630,11 @@ "update_available": "Update verfügbar", "update_important": "Wichtiges Update verfügbar", "folder_unread": "{count} ungelesen", - "folder_counts": "{unread} ungelesen, {total} insgesamt" + "folder_counts": "{unread} ungelesen, {total} insgesamt", + "skip_to_content": "Zum Inhalt springen", + "heading": "{place} – {app}", + "message_list": "Nachrichtenliste", + "reading_pane": "Lesebereich" }, "plugin_pages": { "loading": "Seite wird geladen", diff --git a/locales/en/common.json b/locales/en/common.json index afd60c181..7c8044987 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5630,7 +5630,11 @@ "update_available": "Update available", "update_important": "Important update available", "folder_unread": "{count} unread", - "folder_counts": "{unread} unread, {total} total" + "folder_counts": "{unread} unread, {total} total", + "skip_to_content": "Skip to content", + "heading": "{place} – {app}", + "message_list": "Message list", + "reading_pane": "Reading pane" }, "plugin_pages": { "loading": "Loading page", diff --git a/locales/es/common.json b/locales/es/common.json index ebc32274e..f945c0e61 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5630,7 +5630,11 @@ "update_available": "Actualización disponible", "update_important": "Actualización importante disponible", "folder_unread": "{count} sin leer", - "folder_counts": "{unread} sin leer, {total} en total" + "folder_counts": "{unread} sin leer, {total} en total", + "skip_to_content": "Ir al contenido", + "heading": "{place} – {app}", + "message_list": "Lista de mensajes", + "reading_pane": "Panel de lectura" }, "plugin_pages": { "loading": "Cargando la página", diff --git a/locales/et/common.json b/locales/et/common.json index 7b57ae703..fade3bb55 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5630,7 +5630,11 @@ "update_available": "Saadaval on värskendus", "update_important": "Saadaval on oluline värskendus", "folder_unread": "Lugemata: {count}", - "folder_counts": "Lugemata: {unread}, kokku: {total}" + "folder_counts": "Lugemata: {unread}, kokku: {total}", + "skip_to_content": "Liigu sisu juurde", + "heading": "{place} – {app}", + "message_list": "Kirjade loend", + "reading_pane": "Lugemispaan" }, "plugin_pages": { "loading": "Lehe laadimine", diff --git a/locales/fa/common.json b/locales/fa/common.json index 3cc4d2592..9b765363d 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5630,7 +5630,11 @@ "update_available": "به‌روزرسانی موجود است", "update_important": "به‌روزرسانی مهم موجود است", "folder_unread": "{count} خوانده‌نشده", - "folder_counts": "{unread} خوانده‌نشده، {total} در مجموع" + "folder_counts": "{unread} خوانده‌نشده، {total} در مجموع", + "skip_to_content": "رفتن به محتوا", + "heading": "{place} – {app}", + "message_list": "فهرست پیام‌ها", + "reading_pane": "قاب خواندن" }, "plugin_pages": { "loading": "در حال بارگذاری صفحه", diff --git a/locales/fr/common.json b/locales/fr/common.json index ccb05c365..a67b39f7e 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5630,7 +5630,11 @@ "update_available": "Mise à jour disponible", "update_important": "Mise à jour importante disponible", "folder_unread": "{count} non lus", - "folder_counts": "{unread} non lus, {total} au total" + "folder_counts": "{unread} non lus, {total} au total", + "skip_to_content": "Aller au contenu", + "heading": "{place} – {app}", + "message_list": "Liste des messages", + "reading_pane": "Volet de lecture" }, "plugin_pages": { "loading": "Chargement de la page", diff --git a/locales/he/common.json b/locales/he/common.json index 33b688afe..9a911faab 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5631,7 +5631,11 @@ "update_available": "יש עדכון", "update_important": "יש עדכון חשוב", "folder_unread": "{count} שלא נקראו", - "folder_counts": "{unread} שלא נקראו, {total} בסך הכול" + "folder_counts": "{unread} שלא נקראו, {total} בסך הכול", + "skip_to_content": "דילוג לתוכן", + "heading": "{place} – {app}", + "message_list": "רשימת הודעות", + "reading_pane": "חלונית קריאה" }, "plugin_pages": { "loading": "טוען את הדף", diff --git a/locales/hu/common.json b/locales/hu/common.json index 0b89078d4..7c9b20a89 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5630,7 +5630,11 @@ "update_available": "Frissítés érhető el", "update_important": "Fontos frissítés érhető el", "folder_unread": "{count} olvasatlan", - "folder_counts": "{unread} olvasatlan, összesen {total}" + "folder_counts": "{unread} olvasatlan, összesen {total}", + "skip_to_content": "Ugrás a tartalomhoz", + "heading": "{place} – {app}", + "message_list": "Üzenetlista", + "reading_pane": "Olvasóablak" }, "plugin_pages": { "loading": "Oldal betöltése", diff --git a/locales/it/common.json b/locales/it/common.json index ac7b5186a..44132e974 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5630,7 +5630,11 @@ "update_available": "Aggiornamento disponibile", "update_important": "Aggiornamento importante disponibile", "folder_unread": "{count} non letti", - "folder_counts": "{unread} non letti, {total} in totale" + "folder_counts": "{unread} non letti, {total} in totale", + "skip_to_content": "Vai al contenuto", + "heading": "{place} – {app}", + "message_list": "Elenco messaggi", + "reading_pane": "Riquadro di lettura" }, "plugin_pages": { "loading": "Caricamento della pagina", diff --git a/locales/ja/common.json b/locales/ja/common.json index 097f93dd8..dc682537e 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5630,7 +5630,11 @@ "update_available": "アップデートがあります", "update_important": "重要なアップデートがあります", "folder_unread": "未読 {count} 件", - "folder_counts": "未読 {unread} 件 / 全 {total} 件" + "folder_counts": "未読 {unread} 件 / 全 {total} 件", + "skip_to_content": "コンテンツへスキップ", + "heading": "{place} – {app}", + "message_list": "メッセージ一覧", + "reading_pane": "閲覧ウィンドウ" }, "plugin_pages": { "loading": "ページを読み込んでいます", diff --git a/locales/ko/common.json b/locales/ko/common.json index a155d8a46..5cd0177c0 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5630,7 +5630,11 @@ "update_available": "업데이트가 있습니다", "update_important": "중요한 업데이트가 있습니다", "folder_unread": "읽지 않음 {count}개", - "folder_counts": "읽지 않음 {unread}개, 전체 {total}개" + "folder_counts": "읽지 않음 {unread}개, 전체 {total}개", + "skip_to_content": "콘텐츠로 건너뛰기", + "heading": "{place} – {app}", + "message_list": "메시지 목록", + "reading_pane": "읽기 창" }, "plugin_pages": { "loading": "페이지를 불러오는 중", diff --git a/locales/lv/common.json b/locales/lv/common.json index f1dd22645..0ba6a51cd 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5630,7 +5630,11 @@ "update_available": "Pieejams atjauninājums", "update_important": "Pieejams svarīgs atjauninājums", "folder_unread": "Nelasīti: {count}", - "folder_counts": "Nelasīti: {unread}, kopā: {total}" + "folder_counts": "Nelasīti: {unread}, kopā: {total}", + "skip_to_content": "Pāriet uz saturu", + "heading": "{place} – {app}", + "message_list": "Ziņojumu saraksts", + "reading_pane": "Lasīšanas rūts" }, "plugin_pages": { "loading": "Ielādē lapu", diff --git a/locales/mn/common.json b/locales/mn/common.json index d6b2a07ba..ca7725f2b 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5630,7 +5630,11 @@ "update_available": "Шинэчлэл гарсан", "update_important": "Чухал шинэчлэл гарсан", "folder_unread": "{count} уншаагүй", - "folder_counts": "{unread} уншаагүй, нийт {total}" + "folder_counts": "{unread} уншаагүй, нийт {total}", + "skip_to_content": "Агуулга руу шилжих", + "heading": "{place} – {app}", + "message_list": "Захидлын жагсаалт", + "reading_pane": "Унших хэсэг" }, "plugin_pages": { "loading": "Хуудсыг ачаалж байна", diff --git a/locales/nb/common.json b/locales/nb/common.json index 47e15e417..c018d9510 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5630,7 +5630,11 @@ "update_available": "Oppdatering tilgjengelig", "update_important": "Viktig oppdatering tilgjengelig", "folder_unread": "{count} uleste", - "folder_counts": "{unread} uleste, {total} totalt" + "folder_counts": "{unread} uleste, {total} totalt", + "skip_to_content": "Gå til innhold", + "heading": "{place} – {app}", + "message_list": "Meldingsliste", + "reading_pane": "Leserute" }, "plugin_pages": { "loading": "Laster inn siden", diff --git a/locales/nl/common.json b/locales/nl/common.json index 0cd188cbf..0d9c76aef 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5630,7 +5630,11 @@ "update_available": "Update beschikbaar", "update_important": "Belangrijke update beschikbaar", "folder_unread": "{count} ongelezen", - "folder_counts": "{unread} ongelezen, {total} totaal" + "folder_counts": "{unread} ongelezen, {total} totaal", + "skip_to_content": "Naar inhoud", + "heading": "{place} – {app}", + "message_list": "Berichtenlijst", + "reading_pane": "Leesvenster" }, "plugin_pages": { "loading": "Pagina laden", diff --git a/locales/pl/common.json b/locales/pl/common.json index 25cdeb226..c594bf36c 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5630,7 +5630,11 @@ "update_available": "Dostępna aktualizacja", "update_important": "Dostępna ważna aktualizacja", "folder_unread": "Nieprzeczytane: {count}", - "folder_counts": "Nieprzeczytane: {unread}, łącznie: {total}" + "folder_counts": "Nieprzeczytane: {unread}, łącznie: {total}", + "skip_to_content": "Przejdź do treści", + "heading": "{place} – {app}", + "message_list": "Lista wiadomości", + "reading_pane": "Okienko odczytu" }, "plugin_pages": { "loading": "Wczytywanie strony", diff --git a/locales/pt/common.json b/locales/pt/common.json index a64e1cabf..5b72eab46 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5630,7 +5630,11 @@ "update_available": "Atualização disponível", "update_important": "Atualização importante disponível", "folder_unread": "{count} não lidas", - "folder_counts": "{unread} não lidas, {total} no total" + "folder_counts": "{unread} não lidas, {total} no total", + "skip_to_content": "Ir para o conteúdo", + "heading": "{place} – {app}", + "message_list": "Lista de mensagens", + "reading_pane": "Painel de leitura" }, "plugin_pages": { "loading": "Carregando a página", diff --git a/locales/ro/common.json b/locales/ro/common.json index d04270fba..8bcde9555 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5630,7 +5630,11 @@ "update_available": "Actualizare disponibilă", "update_important": "Actualizare importantă disponibilă", "folder_unread": "{count} necitite", - "folder_counts": "{unread} necitite, {total} în total" + "folder_counts": "{unread} necitite, {total} în total", + "skip_to_content": "Salt la conținut", + "heading": "{place} – {app}", + "message_list": "Lista de mesaje", + "reading_pane": "Panoul de citire" }, "plugin_pages": { "loading": "Se încarcă pagina", diff --git a/locales/ru/common.json b/locales/ru/common.json index 5d010e36c..5850024d8 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5630,7 +5630,11 @@ "update_available": "Доступно обновление", "update_important": "Доступно важное обновление", "folder_unread": "Непрочитанных: {count}", - "folder_counts": "Непрочитанных: {unread}, всего: {total}" + "folder_counts": "Непрочитанных: {unread}, всего: {total}", + "skip_to_content": "Перейти к содержимому", + "heading": "{place} – {app}", + "message_list": "Список писем", + "reading_pane": "Область чтения" }, "plugin_pages": { "loading": "Загрузка страницы", diff --git a/locales/sk/common.json b/locales/sk/common.json index 11a9c1b58..90b565f0a 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5630,7 +5630,11 @@ "update_available": "K dispozícii je aktualizácia", "update_important": "K dispozícii je dôležitá aktualizácia", "folder_unread": "Neprečítané: {count}", - "folder_counts": "Neprečítané: {unread}, spolu: {total}" + "folder_counts": "Neprečítané: {unread}, spolu: {total}", + "skip_to_content": "Prejsť na obsah", + "heading": "{place} – {app}", + "message_list": "Zoznam správ", + "reading_pane": "Tabla na čítanie" }, "plugin_pages": { "loading": "Načítava sa stránka", diff --git a/locales/th/common.json b/locales/th/common.json index 007c80547..42d7d4c6f 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -47,7 +47,11 @@ "update_available": "มีอัปเดต", "update_important": "มีอัปเดตสำคัญ", "folder_unread": "ยังไม่ได้อ่าน {count}", - "folder_counts": "ยังไม่ได้อ่าน {unread} จากทั้งหมด {total}" + "folder_counts": "ยังไม่ได้อ่าน {unread} จากทั้งหมด {total}", + "skip_to_content": "ข้ามไปยังเนื้อหา", + "heading": "{place} – {app}", + "message_list": "รายการข้อความ", + "reading_pane": "บานหน้าต่างอ่าน" }, "date": { "today": "วันนี้", diff --git a/locales/tr/common.json b/locales/tr/common.json index e714b3592..2f71bb8aa 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5630,7 +5630,11 @@ "update_available": "Güncelleme var", "update_important": "Önemli güncelleme var", "folder_unread": "{count} okunmamış", - "folder_counts": "{unread} okunmamış, toplam {total}" + "folder_counts": "{unread} okunmamış, toplam {total}", + "skip_to_content": "İçeriğe atla", + "heading": "{place} – {app}", + "message_list": "İleti listesi", + "reading_pane": "Okuma bölmesi" }, "plugin_pages": { "loading": "Sayfa yükleniyor", diff --git a/locales/uk/common.json b/locales/uk/common.json index ed7640e46..460b2b71d 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5630,7 +5630,11 @@ "update_available": "Доступне оновлення", "update_important": "Доступне важливе оновлення", "folder_unread": "Непрочитаних: {count}", - "folder_counts": "Непрочитаних: {unread}, усього: {total}" + "folder_counts": "Непрочитаних: {unread}, усього: {total}", + "skip_to_content": "Перейти до вмісту", + "heading": "{place} – {app}", + "message_list": "Список листів", + "reading_pane": "Область читання" }, "plugin_pages": { "loading": "Завантаження сторінки", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index bb743a2c7..bb2a18a9f 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5630,7 +5630,11 @@ "update_available": "有可用的更新", "update_important": "有重要更新", "folder_unread": "{count} 封未讀", - "folder_counts": "{unread} 封未讀,共 {total} 封" + "folder_counts": "{unread} 封未讀,共 {total} 封", + "skip_to_content": "跳到內容", + "heading": "{place} – {app}", + "message_list": "郵件清單", + "reading_pane": "閱讀窗格" }, "plugin_pages": { "loading": "正在載入頁面", diff --git a/locales/zh/common.json b/locales/zh/common.json index 92c7fa111..b4404e59a 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5630,7 +5630,11 @@ "update_available": "有可用更新", "update_important": "有重要更新", "folder_unread": "{count} 封未读", - "folder_counts": "{unread} 封未读,共 {total} 封" + "folder_counts": "{unread} 封未读,共 {total} 封", + "skip_to_content": "跳到内容", + "heading": "{place} – {app}", + "message_list": "邮件列表", + "reading_pane": "阅读窗格" }, "plugin_pages": { "loading": "正在加载页面",