feat: open the keyboard shortcuts with ? in every app, on that app's keys

This commit is contained in:
Linus Rath
2026-10-28 02:04:37 +01:00
parent 41ffc8af29
commit 22629c0c6c
43 changed files with 444 additions and 205 deletions
@@ -0,0 +1,37 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
const nav = vi.hoisted(() => ({ pathname: '/' }));
vi.mock('@/i18n/navigation', () => ({ usePathname: () => nav.pathname }));
vi.mock('@/components/pro/pro-interface-redirect', () => ({ useProInterfaceActive: () => false }));
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ startTour: vi.fn() }) }));
vi.mock('@/lib/keyboard-shortcuts/calendar', () => ({
CALENDAR_SECTIONS: [{ id: 'views', title: 'calendar.views', entries: [{ key: 'd', description: 'calendar.day_view' }] }],
}));
const { KeyboardShortcutsModal } = await import('../keyboard-shortcuts-modal');
describe('keyboard shortcuts help per app', () => {
it("opens on the current app's tab and lists the general keys there too", () => {
nav.pathname = '/calendar';
render(<KeyboardShortcutsModal isOpen onClose={() => {}} />);
const tabs = screen.getAllByRole('tab');
// Contacts, Files and Settings have no keys yet: no tabs.
expect(tabs.map((tab) => tab.textContent)).toEqual(['sidebar.mail', 'sidebar.calendar']);
expect(screen.getByRole('tab', { name: 'sidebar.calendar' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByText('calendar.day_view')).toBeInTheDocument();
expect(screen.getByText('shortcuts.global.help')).toBeInTheDocument();
expect(screen.queryByText('shortcuts.actions.reply')).toBeNull();
act(() => { fireEvent.click(screen.getByRole('tab', { name: 'sidebar.mail' })); });
expect(screen.getByText('shortcuts.actions.reply')).toBeInTheDocument();
expect(screen.getAllByText('shortcuts.global.help')).toHaveLength(1);
});
it('translates "or" between alternatives and uses Ctrl off Apple devices', () => {
nav.pathname = '/';
render(<KeyboardShortcutsModal isOpen onClose={() => {}} />);
expect(screen.getAllByText('or').length).toBeGreaterThan(0);
expect(screen.getAllByText('Ctrl + K').length).toBe(1);
});
});
@@ -123,3 +123,20 @@ describe('global commands', () => {
expect(logout).toHaveBeenCalled();
});
});
describe('the ? key', () => {
it('opens the keyboard shortcuts in any app, but not while typing or with a modifier', () => {
render(<><input aria-label="field" /><CommandPaletteHost /></>);
act(() => { fireEvent.keyDown(screen.getByLabelText('field'), { key: '?', code: 'Slash', shiftKey: true }); });
act(() => { fireEvent.keyDown(document.body, { key: '?', code: 'Slash', shiftKey: true, ctrlKey: true }); });
expect(screen.queryByText('shortcuts help')).toBeNull();
const appHandler = vi.fn();
document.addEventListener('keydown', appHandler);
act(() => { fireEvent.keyDown(document.body, { key: '?', code: 'Slash', shiftKey: true }); });
document.removeEventListener('keydown', appHandler);
expect(screen.getByText('shortcuts help')).toBeInTheDocument();
// Mail's own handler doesn't open a second copy.
expect(appHandler).not.toHaveBeenCalled();
});
});
+26 -1
View File
@@ -29,8 +29,9 @@ import { useThemeStore } from "@/stores/theme-store";
import { usePluginActions } from "@/components/plugins/plugin-actions";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { GlobalSearchPalette } from "@/components/global-search/global-search-palette";
import { isContextMenuOpen } from "@/components/ui/context-menu";
import { runAction } from "@/lib/plugin-sandbox/actions";
import { COMMAND_PALETTE_OPEN_EVENT, parkAppCommand, requestAppCommand, type AppCommand } from "@/lib/app-commands";
import { COMMAND_PALETTE_OPEN_EVENT, KEYBOARD_SHORTCUTS_OPEN_EVENT, parkAppCommand, requestAppCommand, type AppCommand } from "@/lib/app-commands";
import { commandMatches, useRegisteredCommandGroups } from "@/lib/command-registry";
import { shortcutAlternatives } from "@/lib/shortcut-keys";
import { toUnicodeEmail } from "@/lib/idn";
@@ -94,6 +95,30 @@ export function CommandPaletteHost() {
if (!enabled) setOpen(false);
}, [enabled]);
// `?` opens the keyboard shortcuts in every app (R8), on the app's own
// section. Captured before the apps' own key handlers, so Mail's doesn't
// open a second copy.
useEffect(() => {
if (!isAuthenticated) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.ctrlKey || event.metaKey || event.altKey) return;
const question = event.key === "?" || (event.code === "Slash" && event.shiftKey);
if (!question || isEditableEventTarget(event) || isContextMenuOpen()) return;
// Another dialog is open: the key belongs to it.
if (document.querySelector('[aria-modal="true"]')) return;
event.preventDefault();
event.stopImmediatePropagation();
setShowShortcuts(true);
};
const onOpen = () => setShowShortcuts(true);
window.addEventListener("keydown", onKeyDown, true);
window.addEventListener(KEYBOARD_SHORTCUTS_OPEN_EVENT, onOpen);
return () => {
window.removeEventListener("keydown", onKeyDown, true);
window.removeEventListener(KEYBOARD_SHORTCUTS_OPEN_EVENT, onOpen);
};
}, [isAuthenticated]);
return (
<>
{open && <CommandPalette onClose={() => setOpen(false)} onShowShortcuts={() => setShowShortcuts(true)} />}
+129 -147
View File
@@ -1,11 +1,21 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { X, Keyboard } from "@/components/icons";
import { KEYBOARD_SHORTCUTS, type ShortcutEntry } from "@/hooks/use-keyboard-shortcuts";
import { Keyboard } from "@/components/icons";
import { Dialog } from "@/components/ui/dialog";
import {
SHORTCUT_APPS,
sectionsWithGeneral,
shortcutAppForPath,
type ShortcutAppId,
type ShortcutEntry,
type ShortcutSection,
} from "@/lib/keyboard-shortcuts";
import { shortcutAlternatives } from "@/lib/shortcut-keys";
import { useSettingsStore } from "@/stores/settings-store";
import { cn } from "@/lib/utils";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { usePathname } from "@/i18n/navigation";
import { useTour } from "@/components/tour/tour-provider";
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
import { usePluginActions } from "@/components/plugins/plugin-actions";
@@ -14,16 +24,29 @@ import { usePolicyStore } from "@/stores/policy-store";
interface KeyboardShortcutsModalProps {
isOpen: boolean;
onClose: () => void;
/** The app to open on. Default: the app of the current page. */
app?: ShortcutAppId;
}
export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsModalProps) {
/**
* The keyboard shortcuts help (`?`): one tab per app that has keys, opened
* on the current app's, each with the keys that work everywhere under
* "Global".
*/
export function KeyboardShortcutsModal({ isOpen, onClose, app }: KeyboardShortcutsModalProps) {
const t = useTranslations();
const { startTour } = useTour();
const pathname = usePathname();
const tabsId = useId();
// The focus starts on the list itself: on the first control it would
// scroll to the tour link at the bottom when there are no tabs.
const contentRef = useRef<HTMLDivElement>(null);
const interfaceLayout = useSettingsStore((state) => state.interfaceLayout);
const shell = useProInterfaceActive() ? "pro" : "standard";
const features = usePolicyStore((s) => s.policy.features);
// Plugin buttons in the composer that have a shortcut (composer-toolbar-actions).
const pluginComposerShortcuts = usePluginActions("composer-toolbar-actions").filter((a) => a.shortcut);
// Rows that belong to one interface layout (or one shell) are listed only under it,
// and a row of a gated feature only while the admin has it on.
const shown = (entries: readonly ShortcutEntry[]) =>
@@ -31,155 +54,114 @@ export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsMod
&& (!entry.shell || entry.shell === shell)
&& (!entry.feature || (features[entry.feature] ?? true)));
const modalRef = useFocusTrap({
isActive: isOpen,
onEscape: onClose,
restoreFocus: true,
});
const apps = SHORTCUT_APPS.filter((a) => a.sections.some((s) => shown(s.entries).length > 0));
const currentApp = app ?? shortcutAppForPath(pathname ?? "/");
const initial = apps.some((a) => a.id === currentApp) ? currentApp : apps[0]?.id ?? "mail";
const [selected, setSelected] = useState<ShortcutAppId>(initial);
// Each time it opens, it opens on the current app.
useEffect(() => {
if (isOpen) setSelected(initial);
}, [isOpen, initial]);
if (!isOpen) return null;
const selectedApp = SHORTCUT_APPS.find((a) => a.id === selected);
const sections = sectionsWithGeneral(selectedApp?.sections ?? []);
const extraRows = (section: ShortcutSection) =>
selected === "mail" && section.id === "composer"
? pluginComposerShortcuts.map((action) => (
<ShortcutRow
key={`${action.pluginId}:${action.id}`}
shortcutKey={action.shortcut!}
description={`${action.label} (${action.pluginName})`}
/>
))
: null;
return (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-50 p-4 animate-in fade-in duration-150"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
<Dialog
isOpen={isOpen}
onClose={onClose}
size="lg"
title={
<span className="flex items-center gap-3">
<Keyboard className="w-5 h-5 text-muted-foreground" />
{t("shortcuts.title")}
</span>
}
initialFocus={contentRef}
>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="shortcuts-dialog-title"
className={cn(
"bg-background border border-border rounded-lg shadow-xl",
"w-full max-w-2xl max-h-[90vh] overflow-hidden",
"animate-in zoom-in-95 duration-200"
)}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-3">
<Keyboard className="w-5 h-5 text-muted-foreground" />
<h2 id="shortcuts-dialog-title" className="text-lg font-semibold text-foreground">
{t("shortcuts.title")}
</h2>
<div ref={contentRef} tabIndex={-1} className="outline-none">
{apps.length > 1 && (
<div role="tablist" aria-label={t("shortcuts.title")} className="mt-4 flex flex-wrap gap-1 border-b border-border">
{apps.map((a) => (
<button
key={a.id}
type="button"
role="tab"
id={`${tabsId}-${a.id}`}
aria-selected={a.id === selected}
aria-controls={`${tabsId}-panel`}
onClick={() => setSelected(a.id)}
className={cn(
"-mb-px px-3 py-2 text-sm border-b-2 transition-colors",
a.id === selected
? "border-primary text-foreground font-medium"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
{t(a.title)}
</button>
))}
</div>
<button
onClick={onClose}
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
aria-label={t("common.close")}
>
<X className="w-5 h-5" />
</button>
)}
<div
id={`${tabsId}-panel`}
role={apps.length > 1 ? "tabpanel" : undefined}
aria-labelledby={apps.length > 1 ? `${tabsId}-${selected}` : undefined}
className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-8"
>
{sections.map((section) => {
const rows = shown(section.entries);
const extra = extraRows(section);
if (rows.length === 0 && !extra?.length) return null;
return (
<section key={section.id} className={cn(section.wide && "md:col-span-2")}>
<h3 className="text-sm font-semibold text-foreground mb-3 uppercase tracking-wider">
{t(section.title)}
</h3>
<div className={cn(section.wide ? "grid grid-cols-1 md:grid-cols-2 gap-2" : "space-y-2")}>
{rows.map((shortcut) => (
<ShortcutRow
key={`${shortcut.key}:${shortcut.description}`}
shortcutKey={shortcut.key}
description={t(shortcut.description)}
/>
))}
{extra}
</div>
</section>
);
})}
</div>
{/* Content */}
<div className="p-4 md:p-6 overflow-y-auto max-h-[calc(90vh-80px)]">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Navigation Section */}
<section>
<h3 className="text-sm font-semibold text-foreground mb-3 uppercase tracking-wider">
{t("shortcuts.sections.navigation")}
</h3>
<div className="space-y-2">
{shown(KEYBOARD_SHORTCUTS.navigation).map((shortcut) => (
<ShortcutRow
key={shortcut.key}
shortcutKey={shortcut.key}
description={t(shortcut.description)}
/>
))}
</div>
</section>
{/* Actions Section */}
<section>
<h3 className="text-sm font-semibold text-foreground mb-3 uppercase tracking-wider">
{t("shortcuts.sections.actions")}
</h3>
<div className="space-y-2">
{shown(KEYBOARD_SHORTCUTS.actions).map((shortcut) => (
<ShortcutRow
key={shortcut.key}
shortcutKey={shortcut.key}
description={t(shortcut.description)}
/>
))}
</div>
</section>
{/* Global Section */}
<section className="md:col-span-2">
<h3 className="text-sm font-semibold text-foreground mb-3 uppercase tracking-wider">
{t("shortcuts.sections.global")}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{shown(KEYBOARD_SHORTCUTS.global).map((shortcut) => (
<ShortcutRow
key={shortcut.key}
shortcutKey={shortcut.key}
description={t(shortcut.description)}
/>
))}
</div>
</section>
{/* Threads Section */}
<section className="md:col-span-2">
<h3 className="text-sm font-semibold text-foreground mb-3 uppercase tracking-wider">
{t("shortcuts.sections.threads")}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{shown(KEYBOARD_SHORTCUTS.threads).map((shortcut) => (
<ShortcutRow
key={shortcut.key}
shortcutKey={shortcut.key}
description={t(shortcut.description)}
/>
))}
</div>
</section>
{/* Composer Section */}
<section className="md:col-span-2">
<h3 className="text-sm font-semibold text-foreground mb-3 uppercase tracking-wider">
{t("shortcuts.sections.composer")}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{shown(KEYBOARD_SHORTCUTS.composer).map((shortcut) => (
<ShortcutRow
key={shortcut.key}
shortcutKey={shortcut.key}
description={t(shortcut.description)}
/>
))}
{pluginComposerShortcuts.map((action) => (
<ShortcutRow
key={`${action.pluginId}:${action.id}`}
shortcutKey={action.shortcut!}
description={`${action.label} (${action.pluginName})`}
/>
))}
</div>
</section>
</div>
{/* Footer tip */}
<div className="mt-6 pt-4 border-t border-border">
<p className="text-sm text-muted-foreground text-center">
{t("shortcuts.tip")}
</p>
<p className="text-sm text-center mt-2">
<button
onClick={() => { onClose(); startTour(); }}
className="text-primary hover:text-primary/80 underline underline-offset-2 transition-colors"
>
{t("tour.take_a_tour")}
</button>
</p>
</div>
{/* Footer tip */}
<div className="mt-6 pt-4 border-t border-border">
<p className="text-sm text-muted-foreground text-center">
{t("shortcuts.tip")}
</p>
<p className="text-sm text-center mt-2">
<button
type="button"
onClick={() => { onClose(); startTour(); }}
className="text-primary hover:text-primary/80 underline underline-offset-2 transition-colors"
>
{t("tour.take_a_tour")}
</button>
</p>
</div>
</div>
</div>
</Dialog>
);
}
@@ -190,8 +172,8 @@ function ShortcutRow({
shortcutKey: string;
description: string;
}) {
// Split keys by " / " to render multiple key badges
const keys = shortcutKey.split(" / ");
const t = useTranslations("shortcuts");
const keys = shortcutAlternatives(shortcutKey);
return (
<div className="flex items-center justify-between py-1.5">
@@ -199,7 +181,7 @@ function ShortcutRow({
<div className="flex items-center gap-1.5 ms-4">
{keys.map((key, index) => (
<span key={index}>
{index > 0 && <span className="text-muted-foreground/50 mx-1 text-xs">or</span>}
{index > 0 && <span className="text-muted-foreground/50 mx-1 text-xs">{t("or")}</span>}
<kbd
className={cn(
"inline-flex items-center justify-center",
+4 -57
View File
@@ -4,8 +4,6 @@ import { useEffect, useCallback, useRef } from "react";
import { Email } from "@/lib/jmap/types";
import { isEditableEventTarget } from "@/lib/keyboard";
import { keyboardHooks } from "@/lib/plugin-hooks";
import type { InterfaceLayout } from "@/stores/settings-store";
import type { FeatureGates } from "@/lib/admin/types";
export interface KeyboardShortcutHandlers {
// Navigation
@@ -379,58 +377,7 @@ export function useKeyboardShortcuts({
return { getAdjacentEmailIndex };
}
/**
* One row of the help modal. `layout` limits a row to one interface layout:
* Mountain View adds the `g` sequences and gives `x` to selection.
*/
export interface ShortcutEntry {
readonly key: string;
readonly description: string;
readonly layout?: InterfaceLayout;
/** Listed only in the Pro shell, or only outside it. */
readonly shell?: "pro" | "standard";
/** Listed only while this admin policy feature gate is on. */
readonly feature?: keyof FeatureGates;
}
// Shortcut definitions for the help modal
export const KEYBOARD_SHORTCUTS = {
navigation: [
{ key: "j / ↓", description: "shortcuts.navigation.next_email" },
{ key: "k / ↑", description: "shortcuts.navigation.previous_email" },
{ key: "Enter / o", description: "shortcuts.navigation.open_email" },
{ key: "Esc", description: "shortcuts.navigation.close_email" },
{ key: "n", description: "shortcuts.navigation.next_unread" },
{ key: "g → i / s / t / d / a", description: "shortcuts.navigation.go_to_folder", layout: "mountain-view" },
],
actions: [
{ key: "r", description: "shortcuts.actions.reply" },
{ key: "R / a", description: "shortcuts.actions.reply_all" },
{ key: "f", description: "shortcuts.actions.forward" },
{ key: "s", description: "shortcuts.actions.star" },
{ key: "e", description: "shortcuts.actions.archive", feature: "archiveActionEnabled" },
{ key: "# / Del", description: "shortcuts.actions.delete" },
{ key: "u", description: "shortcuts.actions.mark_unread" },
{ key: "Shift + I", description: "shortcuts.actions.mark_read" },
{ key: "!", description: "shortcuts.actions.toggle_spam" },
],
global: [
{ key: "c", description: "shortcuts.global.compose" },
{ key: "/", description: "shortcuts.global.search" },
{ key: "Ctrl/⌘ + K", description: "shortcuts.global.global_search", shell: "pro" },
{ key: "Ctrl/⌘ + K", description: "command_palette.open", shell: "standard" },
{ key: "?", description: "shortcuts.global.help" },
{ key: "Shift + G", description: "shortcuts.global.refresh" },
{ key: "Ctrl + A", description: "shortcuts.global.select_all" },
{ key: "Shift + ↓ / ↑", description: "shortcuts.global.extend_selection" },
],
threads: [
{ key: "x", description: "shortcuts.threads.expand_collapse", layout: "default" },
{ key: "x", description: "shortcuts.threads.select_mountain_view", layout: "mountain-view" },
],
composer: [
{ key: "Ctrl/Cmd + Enter", description: "shortcuts.composer.send" },
{ key: "Ctrl/Cmd + Shift + Enter", description: "shortcuts.composer.schedule_send" },
{ key: "t", description: "shortcuts.composer.template_picker" },
],
} as const;
// The help modal's rows live per app in lib/keyboard-shortcuts; Mail's are
// re-exported here under their old names.
export type { ShortcutEntry } from "@/lib/keyboard-shortcuts";
export { MAIL_SHORTCUTS as KEYBOARD_SHORTCUTS } from "@/lib/keyboard-shortcuts";
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { formatShortcutCombo, shortcutAlternatives } from '../shortcut-keys';
import { GENERAL_SHORTCUTS, SHORTCUT_APPS, sectionsWithGeneral, shortcutAppForPath } from '../keyboard-shortcuts';
describe('shortcut notation', () => {
it('writes Mod as ⌘ on Apple devices and Ctrl elsewhere, Alt as ⌥', () => {
expect(formatShortcutCombo('Mod + K', true)).toBe('⌘ + K');
expect(formatShortcutCombo('Mod + K', false)).toBe('Ctrl + K');
expect(formatShortcutCombo('Ctrl/⌘ + K', false)).toBe('Ctrl + K');
expect(formatShortcutCombo('Ctrl/Cmd + Enter', true)).toBe('⌘ + Enter');
expect(formatShortcutCombo('Alt + ↑', true)).toBe('⌥ + ↑');
expect(formatShortcutCombo('Ctrl+Shift+Y', false)).toBe('Ctrl+Shift+Y');
});
it('splits alternatives', () => {
expect(shortcutAlternatives('j / ↓', false)).toEqual(['j', '↓']);
expect(shortcutAlternatives('Mod + A', true)).toEqual(['⌘ + A']);
});
});
describe('shortcut sections', () => {
it('knows the app of a path', () => {
expect(shortcutAppForPath('/')).toBe('mail');
expect(shortcutAppForPath('/mail/folder/inbox')).toBe('mail');
expect(shortcutAppForPath('/calendar')).toBe('calendar');
expect(shortcutAppForPath('/files/a/b')).toBe('files');
expect(shortcutAppForPath('/de/contacts')).toBe('contacts');
expect(shortcutAppForPath('/settings/appearance')).toBe('settings');
});
it('adds the general keys to every app, once', () => {
const mail = sectionsWithGeneral(SHORTCUT_APPS.find((a) => a.id === 'mail')!.sections);
const global = mail.filter((s) => s.id === 'global');
expect(global).toHaveLength(1);
expect(global[0].entries).toEqual(expect.arrayContaining([...GENERAL_SHORTCUTS]));
const empty = sectionsWithGeneral([]);
expect(empty).toEqual([expect.objectContaining({ id: 'global', entries: GENERAL_SHORTCUTS })]);
});
});
+7
View File
@@ -17,6 +17,13 @@ export function openCommandPalette(): void {
if (typeof window !== 'undefined') window.dispatchEvent(new Event(COMMAND_PALETTE_OPEN_EVENT));
}
export const KEYBOARD_SHORTCUTS_OPEN_EVENT = 'bulwark:keyboard-shortcuts';
/** Open the keyboard shortcuts help, on the current app's section, like `?` does. */
export function openKeyboardShortcuts(): void {
if (typeof window !== 'undefined') window.dispatchEvent(new Event(KEYBOARD_SHORTCUTS_OPEN_EVENT));
}
const EVENT = 'bulwark:app-command';
let pending: AppCommand | null = null;
+6
View File
@@ -0,0 +1,6 @@
import type { ShortcutSection } from "./types";
// calendar's keys for the help modal: one or more sections like MAIL_SECTIONS
// in ./mail.ts. Empty sections are not drawn, and an app without any rows
// gets no tab.
export const CALENDAR_SECTIONS: readonly ShortcutSection[] = [];
+6
View File
@@ -0,0 +1,6 @@
import type { ShortcutSection } from "./types";
// contacts's keys for the help modal: one or more sections like MAIL_SECTIONS
// in ./mail.ts. Empty sections are not drawn, and an app without any rows
// gets no tab.
export const CONTACTS_SECTIONS: readonly ShortcutSection[] = [];
+6
View File
@@ -0,0 +1,6 @@
import type { ShortcutSection } from "./types";
// files's keys for the help modal: one or more sections like MAIL_SECTIONS
// in ./mail.ts. Empty sections are not drawn, and an app without any rows
// gets no tab.
export const FILES_SECTIONS: readonly ShortcutSection[] = [];
+53
View File
@@ -0,0 +1,53 @@
import { CALENDAR_SECTIONS } from "./calendar";
import { CONTACTS_SECTIONS } from "./contacts";
import { FILES_SECTIONS } from "./files";
import { MAIL_SECTIONS } from "./mail";
import { SETTINGS_SECTIONS } from "./settings";
import type { ShortcutAppId, ShortcutEntry, ShortcutSection } from "./types";
export type { ShortcutAppId, ShortcutEntry, ShortcutSection } from "./types";
export { MAIL_SHORTCUTS } from "./mail";
export interface ShortcutApp {
readonly id: ShortcutAppId;
/** Translation key of the tab. */
readonly title: string;
readonly sections: readonly ShortcutSection[];
}
/** Each app's keys, in rail order. Each app fills its own file next to this one. */
export const SHORTCUT_APPS: readonly ShortcutApp[] = [
{ id: "mail", title: "sidebar.mail", sections: MAIL_SECTIONS },
{ id: "calendar", title: "sidebar.calendar", sections: CALENDAR_SECTIONS },
{ id: "contacts", title: "sidebar.contacts", sections: CONTACTS_SECTIONS },
{ id: "files", title: "sidebar.files", sections: FILES_SECTIONS },
{ id: "settings", title: "sidebar.settings", sections: SETTINGS_SECTIONS },
];
/**
* Keys that work in every app (the shell handles them). Listed under each
* app's "global" section, or in a "Global" section of their own when the
* app has none.
*/
export const GENERAL_SHORTCUTS: readonly ShortcutEntry[] = [
{ key: "Mod + K", description: "shortcuts.global.global_search", shell: "pro" },
{ key: "Mod + K", description: "command_palette.open", shell: "standard" },
{ key: "?", description: "shortcuts.global.help" },
];
/** An app's sections with the general keys added (see GENERAL_SHORTCUTS). */
export function sectionsWithGeneral(sections: readonly ShortcutSection[]): ShortcutSection[] {
if (sections.some((s) => s.id === "global")) {
return sections.map((s) => (s.id === "global" ? { ...s, entries: [...s.entries, ...GENERAL_SHORTCUTS] } : s));
}
return [...sections, { id: "global", title: "shortcuts.sections.global", entries: GENERAL_SHORTCUTS, wide: true }];
}
/** The app a path belongs to; everything that isn't another app is Mail. */
export function shortcutAppForPath(pathname: string): ShortcutAppId {
const path = pathname.replace(/^\/[a-z]{2}(-[A-Z]{2})?(?=\/|$)/, "");
for (const id of ["calendar", "contacts", "files", "settings"] as const) {
if (path === `/${id}` || path.startsWith(`/${id}/`)) return id;
}
return "mail";
}
+49
View File
@@ -0,0 +1,49 @@
import type { ShortcutSection } from "./types";
// Mail's keys (hooks/use-keyboard-shortcuts.ts handles them).
export const MAIL_SHORTCUTS = {
navigation: [
{ key: "j / ↓", description: "shortcuts.navigation.next_email" },
{ key: "k / ↑", description: "shortcuts.navigation.previous_email" },
{ key: "Enter / o", description: "shortcuts.navigation.open_email" },
{ key: "Esc", description: "shortcuts.navigation.close_email" },
{ key: "n", description: "shortcuts.navigation.next_unread" },
{ key: "g → i / s / t / d / a", description: "shortcuts.navigation.go_to_folder", layout: "mountain-view" },
],
actions: [
{ key: "r", description: "shortcuts.actions.reply" },
{ key: "R / a", description: "shortcuts.actions.reply_all" },
{ key: "f", description: "shortcuts.actions.forward" },
{ key: "s", description: "shortcuts.actions.star" },
{ key: "e", description: "shortcuts.actions.archive", feature: "archiveActionEnabled" },
{ key: "# / Del", description: "shortcuts.actions.delete" },
{ key: "u", description: "shortcuts.actions.mark_unread" },
{ key: "Shift + I", description: "shortcuts.actions.mark_read" },
{ key: "!", description: "shortcuts.actions.toggle_spam" },
],
// Shown under "Global" together with GENERAL_SHORTCUTS (./index.ts).
global: [
{ key: "c", description: "shortcuts.global.compose" },
{ key: "/", description: "shortcuts.global.search" },
{ key: "Shift + G", description: "shortcuts.global.refresh" },
{ key: "Mod + A", description: "shortcuts.global.select_all" },
{ key: "Shift + ↓ / ↑", description: "shortcuts.global.extend_selection" },
],
threads: [
{ key: "x", description: "shortcuts.threads.expand_collapse", layout: "default" },
{ key: "x", description: "shortcuts.threads.select_mountain_view", layout: "mountain-view" },
],
composer: [
{ key: "Mod + Enter", description: "shortcuts.composer.send" },
{ key: "Mod + Shift + Enter", description: "shortcuts.composer.schedule_send" },
{ key: "t", description: "shortcuts.composer.template_picker" },
],
} as const;
export const MAIL_SECTIONS: readonly ShortcutSection[] = [
{ id: "navigation", title: "shortcuts.sections.navigation", entries: MAIL_SHORTCUTS.navigation },
{ id: "actions", title: "shortcuts.sections.actions", entries: MAIL_SHORTCUTS.actions },
{ id: "global", title: "shortcuts.sections.global", entries: MAIL_SHORTCUTS.global, wide: true },
{ id: "threads", title: "shortcuts.sections.threads", entries: MAIL_SHORTCUTS.threads, wide: true },
{ id: "composer", title: "shortcuts.sections.composer", entries: MAIL_SHORTCUTS.composer, wide: true },
];
+6
View File
@@ -0,0 +1,6 @@
import type { ShortcutSection } from "./types";
// settings's keys for the help modal: one or more sections like MAIL_SECTIONS
// in ./mail.ts. Empty sections are not drawn, and an app without any rows
// gets no tab.
export const SETTINGS_SECTIONS: readonly ShortcutSection[] = [];
+30
View File
@@ -0,0 +1,30 @@
import type { InterfaceLayout } from "@/stores/settings-store";
import type { FeatureGates } from "@/lib/admin/types";
/**
* One row of the help modal. `key` is written in the shortcut notation of
* lib/shortcut-keys ("Mod + K", alternatives "j / ↓"); `description` is a
* translation key. `layout` limits a row to one interface layout: Mountain
* View adds the `g` sequences and gives `x` to selection.
*/
export interface ShortcutEntry {
readonly key: string;
readonly description: string;
readonly layout?: InterfaceLayout;
/** Listed only in the Pro shell, or only outside it. */
readonly shell?: "pro" | "standard";
/** Listed only while this admin policy feature gate is on. */
readonly feature?: keyof FeatureGates;
}
/** A titled group of rows. A section with no rows (after filtering) is not drawn. */
export interface ShortcutSection {
readonly id: string;
/** Translation key of the heading. */
readonly title: string;
readonly entries: readonly ShortcutEntry[];
/** Spans both columns of the modal. */
readonly wide?: boolean;
}
export type ShortcutAppId = "mail" | "calendar" | "contacts" | "files" | "settings";
+1
View File
@@ -2703,6 +2703,7 @@
"shortcuts": {
"title": "اختصارات لوحة المفاتيح",
"tip": "اضغط ؟ في أي وقت لإظهار هذه المساعدة",
"or": "أو",
"sections": {
"navigation": "التنقل",
"actions": "إجراءات البريد",
+1
View File
@@ -2703,6 +2703,7 @@
"shortcuts": {
"title": "Dreceres de teclat",
"tip": "Premeu ? en qualsevol moment per mostrar aquesta ajuda",
"or": "o",
"sections": {
"navigation": "Navegació",
"actions": "Accions de correu",
+1
View File
@@ -2703,6 +2703,7 @@
"shortcuts": {
"title": "Klávesové zkratky",
"tip": "Kdykoli stiskněte ? pro zobrazení této nápovědy",
"or": "nebo",
"sections": {
"navigation": "Navigace",
"actions": "Akce zprávy",
+1
View File
@@ -2703,6 +2703,7 @@
"shortcuts": {
"title": "Tastaturgenveje",
"tip": "Tryk ? når som helst for at se denne hjælp",
"or": "eller",
"sections": {
"navigation": "Navigation",
"actions": "E-mail-handlinger",
+1
View File
@@ -2703,6 +2703,7 @@
"shortcuts": {
"title": "Tastaturkürzel",
"tip": "Drücken Sie ? jederzeit, um diese Hilfe anzuzeigen",
"or": "oder",
"sections": {
"navigation": "Navigation",
"actions": "E-Mail-Aktionen",
+1
View File
@@ -2703,6 +2703,7 @@
"shortcuts": {
"title": "Keyboard Shortcuts",
"tip": "Press ? anytime to show this help",
"or": "or",
"sections": {
"navigation": "Navigation",
"actions": "Email Actions",
+1
View File
@@ -2703,6 +2703,7 @@
"shortcuts": {
"title": "Atajos de Teclado",
"tip": "Presione ? en cualquier momento para mostrar esta ayuda",
"or": "o",
"sections": {
"navigation": "Navegación",
"actions": "Acciones de Correo",
+1
View File
@@ -2703,6 +2703,7 @@
"shortcuts": {
"title": "Kiirklahvid",
"tip": "Selle abi nägemiseks vajuta igal ajal ?",
"or": "või",
"sections": {
"navigation": "Navigeerimine",
"actions": "E-kirja toimingud",
+1
View File
@@ -2703,6 +2703,7 @@
"shortcuts": {
"title": "میانبرهای صفحه کلید",
"tip": "هر زمان کلید ? را فشار دهید",
"or": "یا",
"sections": {
"navigation": "ناوبری",
"actions": "عملیات ایمیل",
+1
View File
@@ -2703,6 +2703,7 @@
"shortcuts": {
"title": "Raccourcis clavier",
"tip": "Appuyez sur ? à tout moment pour afficher cette aide",
"or": "ou",
"sections": {
"navigation": "Navigation",
"actions": "Actions email",
+1
View File
@@ -2621,6 +2621,7 @@
"shortcuts": {
"title": "קיצורי מקלדת",
"tip": "לחץ על? בכל עת כדי להראות את העזרה הזו",
"or": "או",
"sections": {
"navigation": "ניווט",
"actions": "פעולות אימייל",
+1
View File
@@ -2703,6 +2703,7 @@
"shortcuts": {
"title": "Billentyűparancsok",
"tip": "Nyomd meg a ? gombot bármikor a súgó megjelenítéséhez",
"or": "vagy",
"sections": {
"navigation": "Navigáció",
"actions": "E-mail műveletek",
+1
View File
@@ -2703,6 +2703,7 @@
"shortcuts": {
"title": "Scorciatoie da tastiera",
"tip": "Premi ? in qualsiasi momento per mostrare questo aiuto",
"or": "o",
"sections": {
"navigation": "Navigazione",
"actions": "Azioni sui messaggi",
+1
View File
@@ -2703,6 +2703,7 @@
"shortcuts": {
"title": "キーボードショートカット",
"tip": "? キーを押すといつでもこのヘルプを表示できます",
"or": "または",
"sections": {
"navigation": "ナビゲーション",
"actions": "メール操作",
+1
View File
@@ -2703,6 +2703,7 @@
"shortcuts": {
"title": "단축키",
"tip": "언제든 ? 키를 누르면 이 도움말을 볼 수 있어요",
"or": "또는",
"sections": {
"navigation": "이동",
"actions": "메일 작업",
+1
View File
@@ -2703,6 +2703,7 @@
"shortcuts": {
"title": "Īsinājumtaustiņi",
"tip": "Nospiediet ? jebkurā laikā, lai skatītu palīdzību",
"or": "vai",
"sections": {
"navigation": "Navigācija",
"actions": "Vēstules darbības",
+1
View File
@@ -2703,6 +2703,7 @@
"shortcuts": {
"title": "Гарын товчлол",
"tip": "Энэ тусламжийг хүссэн үедээ ? дарж нээж болно",
"or": "эсвэл",
"sections": {
"navigation": "Навигаци",
"actions": "Имэйлийн үйлдлүүд",
+1
View File
@@ -2703,6 +2703,7 @@
"shortcuts": {
"title": "Tastatursnarveier",
"tip": "Trykk på ? når som helst for å vise denne hjelpen",
"or": "eller",
"sections": {
"navigation": "Navigasjon",
"actions": "E-posthandlinger",
+1
View File
@@ -2703,6 +2703,7 @@
"shortcuts": {
"title": "Sneltoetsen",
"tip": "Druk op ? om deze hulp te tonen",
"or": "of",
"sections": {
"navigation": "Navigatie",
"actions": "E-mailacties",
+1
View File
@@ -2703,6 +2703,7 @@
"shortcuts": {
"title": "Skróty klawiszowe",
"tip": "Naciśnij ? w dowolnym momencie, aby wyświetlić tę pomoc",
"or": "lub",
"sections": {
"navigation": "Nawigacja",
"actions": "Akcje wiadomości",
+1
View File
@@ -2703,6 +2703,7 @@
"shortcuts": {
"title": "Atalhos de Teclado",
"tip": "Pressione ? a qualquer momento para mostrar esta ajuda",
"or": "ou",
"sections": {
"navigation": "Navegação",
"actions": "Ações de E-mail",
+1
View File
@@ -2703,6 +2703,7 @@
"shortcuts": {
"title": "Comenzi rapide de la tastatură",
"tip": "Apăsați ? oricând pentru a afișa acest ajutor",
"or": "sau",
"sections": {
"navigation": "Navigare",
"actions": "Acțiuni de e-mail",
+1
View File
@@ -2703,6 +2703,7 @@
"shortcuts": {
"title": "Сочетания клавиш",
"tip": "Нажмите ? в любое время для отображения справки",
"or": "или",
"sections": {
"navigation": "Навигация",
"actions": "Действия с письмом",
+1
View File
@@ -2703,6 +2703,7 @@
"shortcuts": {
"title": "Klávesové skratky",
"tip": "Kedykoľvek stlačte ? na zobrazenie tejto nápovedy",
"or": "alebo",
"sections": {
"navigation": "Navigácia",
"actions": "Akcie správy",
+1
View File
@@ -590,6 +590,7 @@
"shortcuts": {
"title": "แป้นพิมพ์ลัด",
"tip": "กด ? ได้ทุกเมื่อเพื่อแสดงความช่วยเหลือนี้",
"or": "หรือ",
"sections": {
"navigation": "การนำทาง",
"actions": "การจัดการอีเมล",
+1
View File
@@ -2703,6 +2703,7 @@
"shortcuts": {
"title": "Klavye Kısayolları",
"tip": "Bu yardımı görmek için istediğiniz zaman ? tuşuna basın",
"or": "veya",
"sections": {
"navigation": "Gezinme",
"actions": "E-posta İşlemleri",
+1
View File
@@ -2703,6 +2703,7 @@
"shortcuts": {
"title": "Комбінації клавіш",
"tip": "Натисніть ? у будь-який час, щоб показати цю допомогу",
"or": "або",
"sections": {
"navigation": "Навігація",
"actions": "Дії електронної пошти",
+1
View File
@@ -2703,6 +2703,7 @@
"shortcuts": {
"title": "鍵盤快速鍵",
"tip": "隨時按下 ? 即可顯示此說明",
"or": "或",
"sections": {
"navigation": "導覽",
"actions": "郵件動作",
+1
View File
@@ -2703,6 +2703,7 @@
"shortcuts": {
"title": "键盘快捷键",
"tip": "按?随时显示此帮助",
"or": "或",
"sections": {
"navigation": "导航",
"actions": "邮件操作",