feat: add landmarks, an h1 per app, skip links and F6 between panes

This commit is contained in:
Linus Rath
2026-10-29 17:53:35 +01:00
parent 13d697ddfb
commit dcc504fcfa
47 changed files with 501 additions and 70 deletions
+2
View File
@@ -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({
<ExternalRecipientConfirmHost />
<CommandPaletteHost />
<AccessibilityQuickPanelHost />
<PaneCycleHost />
<TotpReauthDialog />
<PluginConnectDialog />
<QuickRuleHost />
+7 -2
View File
@@ -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 (
<div className={cn("flex flex-col overflow-hidden pt-[env(safe-area-inset-top)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
{!isEmbedded && <SkipLink>{tShell("skip_to_content")}</SkipLink>}
<AppTopBannerSlot />
{refreshIndicator}
{mvShell && (
@@ -2094,6 +2097,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
{(isNarrow || !sidebarCollapsed) && (
<>
<div
// A closed drawer is off-screen: not a pane for F6.
data-pane={!isNarrow || narrowSidebarOpen ? "sidebar" : undefined}
className={cn(
"border-e border-border bg-secondary flex flex-col overflow-hidden flex-shrink-0",
!isResizing && "transition-[width] duration-300",
@@ -2205,7 +2210,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
</>
)}
<div className="flex flex-col flex-1 min-w-0 min-h-0">
<AppMain embedded={isEmbedded} label={tSidebar("calendar")} className="flex flex-col flex-1 min-w-0 min-h-0">
{calendarToolbar}
{/* isolate keeps the views' sticky headers (z-50) below the toolbar's
@@ -2285,7 +2290,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
</Button>
)}
</div>
</div>
</AppMain>
</div>
</div>
)}
+15 -4
View File
@@ -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 (
<div className={cn("flex flex-col overflow-hidden pt-[env(safe-area-inset-top)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
{!isEmbedded && <SkipLink>{tShell("skip_to_content")}</SkipLink>}
<AppTopBannerSlot />
{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) && (
<>
<div
data-testid={isNarrow ? "contacts-sidebar-drawer" : undefined}
data-pane="sidebar"
className={cn(
"border-e border-border flex flex-col flex-shrink-0 bg-background",
!isSidebarResizing && "transition-[width] duration-300",
@@ -1285,6 +1287,14 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
</>
)}
{/* The list and the contact: the main content, after the sidebar. */}
<AppMain
embedded={isEmbedded}
heading={tShell("heading", { place: listTitle, app: tSidebar("contacts") })}
className="relative flex flex-1 min-w-0 min-h-0"
>
{showListPanel && (
<>
{/* Panel 2: Contact list */}
<div
data-tour="contacts-list"
@@ -1413,6 +1423,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
</div>
</div>
)}
</AppMain>
</div>
{isMobile && !isEmbedded && (
@@ -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');
});
});
+1
View File
@@ -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")}
+6 -4
View File
@@ -3988,7 +3988,7 @@ export function EmailViewer({
alt="Bulwark Mail"
className="h-12 mx-auto mb-6"
/>
<h3 className="text-xl font-semibold text-foreground mb-3">{tDemoWelcome('title')}</h3>
<h2 className="text-xl font-semibold text-foreground mb-3">{tDemoWelcome('title')}</h2>
<p className="text-sm text-muted-foreground mb-6 leading-relaxed">{tDemoWelcome('description')}</p>
<div className="flex flex-col gap-3 items-center">
<div className="grid grid-cols-2 gap-3 text-start text-sm text-muted-foreground w-full">
@@ -4031,7 +4031,7 @@ export function EmailViewer({
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-background shadow-lg flex items-center justify-center">
<Mail className="w-10 h-10 text-muted-foreground" />
</div>
<h3 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h3>
<h2 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h2>
<p className="text-muted-foreground">{t('no_conversation_description')}</p>
{onCompose && (
<Button onClick={onCompose} className="mt-6" title={t('compose_hint')}>
@@ -4677,12 +4677,14 @@ export function EmailViewer({
)}
<div className="flex-1 min-w-0">
<div className="flex items-start gap-2">
<h1 dir="auto" className={cn(
{/* h2 under the app's h1 ("Inbox – Mail"); the reader's
blocks below are h3 and h4, so no level is skipped. */}
<h2 dir="auto" className={cn(
"text-foreground break-words min-w-0",
mvViewer ? "text-[22px] leading-7 font-normal" : "text-lg lg:text-xl font-bold tracking-tight"
)}>
{email.subject || t('no_subject')}
</h1>
</h2>
{/* Mountain View: the folder the message sits in, as a chip on the subject line. */}
{mvViewer && mvFolderName && (
<span className="self-center flex-shrink-0 rounded px-1.5 py-0.5 text-xs bg-muted text-muted-foreground" data-mv-folder-chip="">
@@ -266,9 +266,9 @@ export function ThreadConversationView({
</IconButton>
<div className="flex-1 min-w-0">
{/* Two lines at most, so a long subject leaves the actions their room. */}
<h1 className="font-semibold text-foreground break-words line-clamp-2" dir="auto" title={thread.latestEmail.subject || undefined}>
<h2 className="font-semibold text-foreground break-words line-clamp-2" dir="auto" title={thread.latestEmail.subject || undefined}>
{thread.latestEmail.subject || t("email_viewer.no_subject")}
</h1>
</h2>
<p className="text-sm text-muted-foreground">
{t("threads.messages_count", { count: emails.length })}
</p>
+15 -5
View File
@@ -29,6 +29,8 @@ import { FileGridView, FileListView, type FileItemHandlers } from "@/components/
import { FileDetailsPanel } from "@/components/files/file-details-panel";
import { INTERNAL_DRAG_TYPE, anchorEvent, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { AppMain } from "@/components/layout/app-landmarks";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
import { resourceToView, useFileStore, isSharedFilesPage, sharedFilePages, type FileResource } from "@/stores/file-store";
import { TRASH_LOCATION, TRASH_PATH, isTrashLocation } from "@/lib/files-trash";
@@ -986,6 +988,8 @@ export function FileBrowser({
// ---- Command palette ---------------------------------------------------------
const tSidebar = useTranslations("sidebar");
const tShell = useTranslations("shell");
const isEmbedded = useIsEmbedded();
useRegisterCommands(tSidebar("files"), [
{ id: "upload-files", label: t("upload_files"), icon: Upload, run: () => fileInputRef.current?.click(), when: () => canCreate },
{ id: "upload-folder", label: t("upload_folder"), icon: FolderUp, run: () => folderInputRef.current?.click(), when: () => canCreate },
@@ -1037,7 +1041,7 @@ export function FileBrowser({
if (accountPickerMode) {
return accountFolders && accountFolders.length > 0 && onSelectAccount ? (
<div className="p-4">
<h3 className="text-xs font-medium text-muted-foreground mb-2 px-0.5">{t("choose_account")}</h3>
<h2 className="text-xs font-medium text-muted-foreground mb-2 px-0.5">{t("choose_account")}</h2>
<div className="grid gap-3 grid-cols-[repeat(auto-fill,minmax(14rem,1fr))]">
{accountFolders.map((acc) => (
<button
@@ -1196,6 +1200,7 @@ export function FileBrowser({
{sidebar && (isNarrow ? (
<div
data-testid="files-sidebar-drawer"
data-pane="sidebar"
className={cn(
"absolute inset-y-0 start-0 z-50 w-72 border-e border-border",
"transform transition-[transform,visibility] duration-300 ease-in-out",
@@ -1209,6 +1214,7 @@ export function FileBrowser({
) : (
<>
<div
data-pane="sidebar"
className={cn("border-e border-border flex-shrink-0", !isResizing && "transition-[width] duration-300")}
style={{ width: `${sidebarWidth}px` }}
>
@@ -1226,7 +1232,11 @@ export function FileBrowser({
</>
))}
<div className="flex flex-col flex-1 min-w-0">
<AppMain
embedded={isEmbedded}
heading={tShell("heading", { place: folderName, app: tSidebar("files") })}
className="flex flex-col flex-1 min-w-0"
>
<FilesHeader
crumbs={crumbs}
accountLabel={accountLabel}
@@ -1328,7 +1338,7 @@ export function FileBrowser({
)}
</div>
</div>
</AppMain>
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleInputChange(false)} />
<input
@@ -1453,10 +1463,10 @@ function SharedWithMe({ folders, pages, onOpen, iconOptions }: {
const t = useTranslations("files");
return (
<section aria-label={t("shared_with_me")} className="px-4 pt-5 pb-4">
<h3 className="text-xs font-medium text-muted-foreground mb-2 px-0.5 flex items-center gap-1.5">
<h2 className="text-xs font-medium text-muted-foreground mb-2 px-0.5 flex items-center gap-1.5">
<Share2 className="w-3 h-3" />
{t("shared_with_me")}
</h3>
</h2>
<div className="grid grid-cols-[repeat(auto-fill,minmax(13rem,1fr))] gap-2">
{folders.map((folder) => (
<button
+2 -2
View File
@@ -330,7 +330,7 @@ export function FileGridView({
<div role="grid" aria-label={t("file_list")} aria-multiselectable className="p-4 space-y-5">
{folders.length > 0 && (
<section aria-label={t("folders")}>
<h3 className="text-xs font-medium text-muted-foreground mb-2 px-0.5">{t("folders")}</h3>
<h2 className="text-xs font-medium text-muted-foreground mb-2 px-0.5">{t("folders")}</h2>
<div role="row" className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-2">
{folders.map((resource) => (
<FolderTile
@@ -349,7 +349,7 @@ export function FileGridView({
)}
{files.length > 0 && (
<section aria-label={t("files_heading")}>
{folders.length > 0 && <h3 className="text-xs font-medium text-muted-foreground mb-2 px-0.5">{t("files_heading")}</h3>}
{folders.length > 0 && <h2 className="text-xs font-medium text-muted-foreground mb-2 px-0.5">{t("files_heading")}</h2>}
<div role="row" className="grid grid-cols-[repeat(auto-fill,minmax(9.5rem,1fr))] gap-3">
{files.map((resource) => (
<FileTile
+7 -4
View File
@@ -17,6 +17,7 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { AppMain, SkipLink } from "@/components/layout/app-landmarks";
import { useIsFocusedProTab } from "@/hooks/use-pane-context";
import { useIsMobile } from "@/hooks/use-media-query";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
@@ -51,6 +52,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
const linkSegments = useLiteLinkSegments('files', routeSegments);
const t = useTranslations("files");
const tSidebar = useTranslations("sidebar");
const tShell = useTranslations("shell");
useDocumentTitle(tSidebar("files"));
const tDeepLink = useTranslations("deep_link");
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
@@ -797,6 +799,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
return (
<div className={cn("flex flex-col overflow-hidden pt-[env(safe-area-inset-top)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
{!isEmbedded && <SkipLink>{tShell("skip_to_content")}</SkipLink>}
<AppTopBannerSlot />
{refreshIndicator}
{mvShell && (
@@ -832,17 +835,17 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
<div className="flex-1 min-w-0 flex flex-col">
<div className="flex-1 min-h-0 flex flex-col">
{!filesEnabled ? (
<div className="flex items-center justify-center h-full">
<AppMain embedded={isEmbedded} heading={tSidebar("files")} className="flex items-center justify-center h-full">
<div className="max-w-lg text-center space-y-3 px-4">
<AlertTriangle className="w-10 h-10 text-amber-600 dark:text-amber-400 mx-auto" />
<p className="text-sm font-medium">{t("disabled_title")}</p>
<p className="text-xs text-muted-foreground">{t("disabled_description")}</p>
</div>
</div>
</AppMain>
) : supportsFiles === false ? (
<div className="flex items-center justify-center h-full">
<AppMain embedded={isEmbedded} heading={tSidebar("files")} className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">{t("not_available")}</p>
</div>
</AppMain>
) : (
<FileBrowser
currentPath={currentPath}
@@ -0,0 +1,119 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { AppMain, SkipLink } from '../app-landmarks';
import { PaneCycleHost, cyclePane } from '../pane-cycle';
// SR-04 / SR-05: one <main> per app with an h1 naming the place, a skip link
// to it, and F6 / Shift+F6 between the panes.
afterEach(() => {
document.body.innerHTML = '';
});
describe('AppMain', () => {
it('is the main landmark, named by its h1', () => {
render(<AppMain heading="Inbox – Mail"><p>list</p></AppMain>);
const main = screen.getByRole('main', { name: 'Inbox – Mail' });
expect(screen.getByRole('heading', { level: 1, name: 'Inbox – Mail' })).toBeInTheDocument();
expect(main).toHaveAttribute('data-pane', 'main');
});
it('is a named region, not a second main, inside the Pro shell', () => {
render(<AppMain embedded label="Calendar"><p>grid</p></AppMain>);
expect(screen.queryByRole('main')).toBeNull();
expect(screen.getByRole('region', { name: 'Calendar' })).toBeInTheDocument();
expect(screen.queryByRole('heading')).toBeNull();
});
it('leaves F6 to the panes inside it when asked', () => {
render(<AppMain heading="x" pane={false}><p>list</p></AppMain>);
expect(screen.getByRole('main')).not.toHaveAttribute('data-pane');
});
});
describe('SkipLink', () => {
it('moves the focus to the main content and lets it go again', () => {
render(
<>
<SkipLink>Skip to content</SkipLink>
<button>rail</button>
<AppMain heading="Contacts"><button>first</button></AppMain>
</>,
);
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(
<>
<PaneCycleHost />
<nav aria-label="Folders" data-pane="folders">
<button>Sent</button>
<button aria-current="page">Inbox</button>
</nav>
<div id="message-list" tabIndex={-1} data-pane="list"><span>row</span></div>
<section aria-label="Reading pane" data-pane="reader"><button>Reply</button></section>
</>,
);
}
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(
<>
<div data-pane="sidebar" hidden><button aria-current="page">x</button></div>
<section aria-label="Content" data-pane="main"><button>y</button></section>
</>,
);
expect(cyclePane(false)).toBe(true);
expect(screen.getByRole('region', { name: 'Content' })).toHaveFocus();
});
});
+65
View File
@@ -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 (
<a
href={targetId ? `#${targetId}` : "#main"}
onClick={(e) => {
e.preventDefault();
// Without a target, the page's <main> (there is one per app).
const target = targetId ? document.getElementById(targetId) : document.querySelector<HTMLElement>("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}
</a>
);
}
interface AppMainProps extends Omit<HTMLAttributes<HTMLElement>, "role"> {
/**
* Inside the Pro shell, where several apps share one page: a named region
* instead of a second <main>.
*/
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 <main> 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<HTMLElement, AppMainProps>(function AppMain(
{ embedded = false, heading, label, pane = true, className, children, ...rest },
ref,
) {
const headingId = useId();
const Tag = embedded ? "section" : "main";
return (
<Tag
ref={ref as never}
data-pane={pane ? "main" : undefined}
aria-labelledby={heading ? headingId : undefined}
aria-label={heading ? undefined : label}
className={className}
{...rest}
>
{heading && <h1 id={headingId} className="sr-only">{heading}</h1>}
{children}
</Tag>
);
});
+2 -1
View File
@@ -113,7 +113,8 @@ export function MobileHeader({
</Button>
{/* Title, unless the bar is in search-first mode */}
{!onOpenSearch && <h1 className="font-semibold text-lg truncate">{title}</h1>}
{/* Not a heading: the app's h1 already names the folder ("Inbox – Mail"). */}
{!onOpenSearch && <div className="font-semibold text-lg truncate">{title}</div>}
</div>
{onOpenSearch && (
+67
View File
@@ -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<HTMLElement>(
"[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<HTMLElement>("[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;
}
+2 -2
View File
@@ -1733,7 +1733,7 @@ function SidebarImpl({
{!isCollapsed && <VacationBanner />}
{/* Mailbox List */}
<div className="flex-1 overflow-y-auto" data-tour="sidebar" data-folder-nav="">
<nav aria-label={t("folders")} className="flex-1 overflow-y-auto" data-tour="sidebar" data-folder-nav="" data-pane="folders">
{(showUnified || showCrossUnread || showCrossStarred || showCrossAll) && (
<div>
<SidebarSectionHeader
@@ -2060,7 +2060,7 @@ function SidebarImpl({
{!isCollapsed && <PluginSidebarSections mailboxId={selectedKeyword ? null : selectedMailbox || null} />}
{!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />}
</div>
</nav>
<MailboxContextMenu
target={mailboxContextMenu.data}
+16 -6
View File
@@ -97,6 +97,7 @@ import { peekListAttachments, requestListAttachments, type ListAttachmentSource,
import { useSearchHistoryStore } from "@/stores/search-history-store";
import { WelcomeBanner } from "@/components/ui/welcome-banner";
import { NavigationRail } from "@/components/layout/navigation-rail";
import { AppMain } from "@/components/layout/app-landmarks";
import { MvTopBar } from "@/components/layout/mv-top-bar";
import { PluginActions } from "@/components/plugins/plugin-actions";
import { useAppCommands } from "@/lib/app-commands";
@@ -2059,6 +2060,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const appRootRef = useRef<HTMLDivElement>(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<HTMLDivElement>(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. */}
<div
<AppMain
embedded={isEmbedded}
// The list and the reading pane inside take F6, not the whole.
pane={false}
heading={t('shell.heading', { place: currentMailboxName, app: t('sidebar.mail') })}
data-mv-card={mvShell ? "" : undefined}
className={cn("flex flex-col flex-1 min-w-0", mvShell ? "mb-4 me-4" : "h-full", inlineApp && "hidden")}
>
<div className={cn("relative flex flex-1 min-h-0", isHorizontalMailLayout && "md:flex-col")}>
{/* Email List - full width on mobile, fixed width/height on tablet/desktop */}
<div
<section
aria-labelledby={listHeadingId}
className={cn(
"relative flex flex-col bg-background min-w-0",
isHorizontalMailLayout
@@ -4928,6 +4935,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
: (!shouldCollapseListPane && !shouldHideViewerPane ? { width: clampEmailListWidth(emailListWidth) } : undefined)
}
>
<h2 id={listHeadingId} className="sr-only">{t('shell.message_list')}</h2>
{/* Mobile Header for List View */}
<MobileHeader
title={currentMailboxName}
@@ -5298,7 +5306,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
<PenSquare className={isMobile ? "h-6 w-6" : "h-5 w-5"} />
{mvMobile && <span className="text-sm font-medium">{t('sidebar.compose')}</span>}
</Button>}
</div>
</section>
{/* 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 */}
<div
<section
aria-label={t('shell.reading_pane')}
data-pane="reader"
className={cn(
"flex flex-col bg-background flex-1 min-w-0",
isHorizontalMailLayout ? "min-h-0" : "h-full",
@@ -5547,7 +5557,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
)}
</>
)}
</div>
</section>
</div>
{/* Bottom Navigation - mobile and tablet (hidden when embedded) */}
@@ -5560,7 +5570,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
activeAppId={inlineApp?.id ?? null}
/>
)}
</div>
</AppMain>
{/* Mountain View: Gmail's side panel - calendar, tasks and contacts
next to the mail, with their icons down the right edge. */}
{mvShell && !inlineApp && !fullscreenReading && <MvSidePanel />}
+12 -8
View File
@@ -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 =
<PhoneHeaderActions />
</div>
<div className="flex-1 overflow-y-auto p-4">
<AppMain embedded={isEmbedded} label={activeTabLabel} className="flex-1 overflow-y-auto p-4">
{renderTabContent()}
</div>
</AppMain>
{!isEmbedded && (
<NavigationRail
@@ -960,7 +962,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
<PhoneHeaderActions />
</div>
<div className="flex-1 overflow-y-auto" data-tour="settings-tabs">
<AppMain embedded={isEmbedded} label={t('title')} className="flex-1 overflow-y-auto" data-tour="settings-tabs">
<div className="px-4 pt-3 pb-1">
<ToolbarSearch
value={searchQuery}
@@ -1036,7 +1038,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
<span>{tSidebar('sign_out')}</span>
</button>
</div>
</div>
</AppMain>
{!isEmbedded && (
<NavigationRail
@@ -1055,6 +1057,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
// Desktop layout
return (
<div className={cn("flex flex-col pt-[env(safe-area-inset-top)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
{!isEmbedded && <SkipLink>{tShell("skip_to_content")}</SkipLink>}
<AppTopBannerSlot />
{mvShell && (
<MvTopBar
@@ -1087,6 +1090,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
{!inlineApp && (
<>
<div
data-pane="sidebar"
className={cn(
"border-e border-border bg-secondary flex flex-col",
!isResizing && "transition-[width] duration-300"
@@ -1110,7 +1114,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
</div>
)}
<div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs">
<nav aria-label={t('title')} className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs">
<div className="px-3 pt-1 pb-1">
<ToolbarSearch
value={searchQuery}
@@ -1176,7 +1180,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
</div>
))}
</div>
</div>
</nav>
<div className="border-t border-border px-1 py-1">
{showAdvanced && <AboutFooter />}
<AdvancedSettingsSwitch />
@@ -1193,14 +1197,14 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
onDoubleClick={() => { setSettingsSidebarWidth(256); localStorage.setItem('settings-sidebar-width', '256'); }}
/>
<div ref={attachContentScroll} className="flex-1 overflow-y-auto">
<AppMain ref={attachContentScroll as (el: HTMLElement | null) => void} embedded={isEmbedded} label={activeTabLabel} className="flex-1 overflow-y-auto">
<div className={effectiveActiveTab.startsWith(PLUGIN_PAGE_PREFIX) ? "px-6 py-6" : "max-w-3xl mx-auto px-6 py-6"}>
{/* The page's heading for screen readers; the sidebar shows which
page is open (the phone header has a visible one). */}
<h1 className="sr-only">{activeTabLabel}</h1>
{renderTabContent()}
</div>
</div>
</AppMain>
</>
)}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
+15
View File
@@ -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);
}
+5 -1
View File
@@ -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": "جارٍ تحميل الصفحة",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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": "در حال بارگذاری صفحه",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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": "טוען את הדף",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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": "ページを読み込んでいます",
+5 -1
View File
@@ -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": "페이지를 불러오는 중",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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": "Хуудсыг ачаалж байна",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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": "Загрузка страницы",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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": "วันนี้",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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": "Завантаження сторінки",
+5 -1
View File
@@ -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": "正在載入頁面",
+5 -1
View File
@@ -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": "正在加载页面",