diff --git a/components/__tests__/keyboard-shortcuts-sections.test.tsx b/components/__tests__/keyboard-shortcuts-sections.test.tsx new file mode 100644 index 000000000..08610f89b --- /dev/null +++ b/components/__tests__/keyboard-shortcuts-sections.test.tsx @@ -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( {}} />); + 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( {}} />); + expect(screen.getAllByText('or').length).toBeGreaterThan(0); + expect(screen.getAllByText('Ctrl + K').length).toBe(1); + }); +}); diff --git a/components/command-palette/__tests__/command-registry.test.tsx b/components/command-palette/__tests__/command-registry.test.tsx index 5a440c1e7..0d6ed7a2b 100644 --- a/components/command-palette/__tests__/command-registry.test.tsx +++ b/components/command-palette/__tests__/command-registry.test.tsx @@ -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(<>); + 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(); + }); +}); diff --git a/components/command-palette/command-palette.tsx b/components/command-palette/command-palette.tsx index 9bad0d600..7adbc0286 100644 --- a/components/command-palette/command-palette.tsx +++ b/components/command-palette/command-palette.tsx @@ -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 && setOpen(false)} onShowShortcuts={() => setShowShortcuts(true)} />} diff --git a/components/keyboard-shortcuts-modal.tsx b/components/keyboard-shortcuts-modal.tsx index 1196c50e9..6ba8ccb69 100644 --- a/components/keyboard-shortcuts-modal.tsx +++ b/components/keyboard-shortcuts-modal.tsx @@ -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(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(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) => ( + + )) + : null; return ( -
{ if (e.target === e.currentTarget) onClose(); }} + + + {t("shortcuts.title")} + + } + initialFocus={contentRef} > -
- {/* Header */} -
-
- -

- {t("shortcuts.title")} -

+
+ {apps.length > 1 && ( +
+ {apps.map((a) => ( + + ))}
- + )} + +
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 ( +
+

+ {t(section.title)} +

+
+ {rows.map((shortcut) => ( + + ))} + {extra} +
+
+ ); + })}
- {/* Content */} -
-
- {/* Navigation Section */} -
-

- {t("shortcuts.sections.navigation")} -

-
- {shown(KEYBOARD_SHORTCUTS.navigation).map((shortcut) => ( - - ))} -
-
- - {/* Actions Section */} -
-

- {t("shortcuts.sections.actions")} -

-
- {shown(KEYBOARD_SHORTCUTS.actions).map((shortcut) => ( - - ))} -
-
- - {/* Global Section */} -
-

- {t("shortcuts.sections.global")} -

-
- {shown(KEYBOARD_SHORTCUTS.global).map((shortcut) => ( - - ))} -
-
- - {/* Threads Section */} -
-

- {t("shortcuts.sections.threads")} -

-
- {shown(KEYBOARD_SHORTCUTS.threads).map((shortcut) => ( - - ))} -
-
- - {/* Composer Section */} -
-

- {t("shortcuts.sections.composer")} -

-
- {shown(KEYBOARD_SHORTCUTS.composer).map((shortcut) => ( - - ))} - {pluginComposerShortcuts.map((action) => ( - - ))} -
-
-
- - {/* Footer tip */} -
-

- {t("shortcuts.tip")} -

-

- -

-
+ {/* Footer tip */} +
+

+ {t("shortcuts.tip")} +

+

+ +

-
+
); } @@ -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 (
@@ -199,7 +181,7 @@ function ShortcutRow({
{keys.map((key, index) => ( - {index > 0 && or} + {index > 0 && {t("or")}} { + 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 })]); + }); +}); diff --git a/lib/app-commands.ts b/lib/app-commands.ts index c3bad3e1b..e220b6cf5 100644 --- a/lib/app-commands.ts +++ b/lib/app-commands.ts @@ -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; diff --git a/lib/keyboard-shortcuts/calendar.ts b/lib/keyboard-shortcuts/calendar.ts new file mode 100644 index 000000000..9f899bd43 --- /dev/null +++ b/lib/keyboard-shortcuts/calendar.ts @@ -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[] = []; diff --git a/lib/keyboard-shortcuts/contacts.ts b/lib/keyboard-shortcuts/contacts.ts new file mode 100644 index 000000000..a1969832b --- /dev/null +++ b/lib/keyboard-shortcuts/contacts.ts @@ -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[] = []; diff --git a/lib/keyboard-shortcuts/files.ts b/lib/keyboard-shortcuts/files.ts new file mode 100644 index 000000000..a47645cbf --- /dev/null +++ b/lib/keyboard-shortcuts/files.ts @@ -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[] = []; diff --git a/lib/keyboard-shortcuts/index.ts b/lib/keyboard-shortcuts/index.ts new file mode 100644 index 000000000..b858cce7d --- /dev/null +++ b/lib/keyboard-shortcuts/index.ts @@ -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"; +} diff --git a/lib/keyboard-shortcuts/mail.ts b/lib/keyboard-shortcuts/mail.ts new file mode 100644 index 000000000..1ee3eec54 --- /dev/null +++ b/lib/keyboard-shortcuts/mail.ts @@ -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 }, +]; diff --git a/lib/keyboard-shortcuts/settings.ts b/lib/keyboard-shortcuts/settings.ts new file mode 100644 index 000000000..80174c7ad --- /dev/null +++ b/lib/keyboard-shortcuts/settings.ts @@ -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[] = []; diff --git a/lib/keyboard-shortcuts/types.ts b/lib/keyboard-shortcuts/types.ts new file mode 100644 index 000000000..9ca58a5a1 --- /dev/null +++ b/lib/keyboard-shortcuts/types.ts @@ -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"; diff --git a/locales/ar/common.json b/locales/ar/common.json index 7a28d35b6..f302c0a3a 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -2703,6 +2703,7 @@ "shortcuts": { "title": "اختصارات لوحة المفاتيح", "tip": "اضغط ؟ في أي وقت لإظهار هذه المساعدة", + "or": "أو", "sections": { "navigation": "التنقل", "actions": "إجراءات البريد", diff --git a/locales/ca/common.json b/locales/ca/common.json index fb9de1d32..c74f3a6e6 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -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", diff --git a/locales/cs/common.json b/locales/cs/common.json index 463f624b4..04311030b 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -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", diff --git a/locales/da/common.json b/locales/da/common.json index a02bf02aa..517caa46f 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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", diff --git a/locales/de/common.json b/locales/de/common.json index bc6f7798e..141274200 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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", diff --git a/locales/en/common.json b/locales/en/common.json index 9edc1a53e..e25ebab8b 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -2703,6 +2703,7 @@ "shortcuts": { "title": "Keyboard Shortcuts", "tip": "Press ? anytime to show this help", + "or": "or", "sections": { "navigation": "Navigation", "actions": "Email Actions", diff --git a/locales/es/common.json b/locales/es/common.json index b3e7eb1e1..5dd4f01d5 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -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", diff --git a/locales/et/common.json b/locales/et/common.json index 82292091f..36cbb878a 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -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", diff --git a/locales/fa/common.json b/locales/fa/common.json index 214f1c793..09ab7af4f 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -2703,6 +2703,7 @@ "shortcuts": { "title": "میانبرهای صفحه کلید", "tip": "هر زمان کلید ? را فشار دهید", + "or": "یا", "sections": { "navigation": "ناوبری", "actions": "عملیات ایمیل", diff --git a/locales/fr/common.json b/locales/fr/common.json index 10d6f2dfe..8be4a0523 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -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", diff --git a/locales/he/common.json b/locales/he/common.json index 4ca1d3929..9ad7d19ef 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -2621,6 +2621,7 @@ "shortcuts": { "title": "קיצורי מקלדת", "tip": "לחץ על? בכל עת כדי להראות את העזרה הזו", + "or": "או", "sections": { "navigation": "ניווט", "actions": "פעולות אימייל", diff --git a/locales/hu/common.json b/locales/hu/common.json index b4530757b..758e15995 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -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", diff --git a/locales/it/common.json b/locales/it/common.json index f09daade5..cb2f68866 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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", diff --git a/locales/ja/common.json b/locales/ja/common.json index 64865d541..a7a76b7bb 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -2703,6 +2703,7 @@ "shortcuts": { "title": "キーボードショートカット", "tip": "? キーを押すといつでもこのヘルプを表示できます", + "or": "または", "sections": { "navigation": "ナビゲーション", "actions": "メール操作", diff --git a/locales/ko/common.json b/locales/ko/common.json index 5ac77dbec..432de0376 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -2703,6 +2703,7 @@ "shortcuts": { "title": "단축키", "tip": "언제든 ? 키를 누르면 이 도움말을 볼 수 있어요", + "or": "또는", "sections": { "navigation": "이동", "actions": "메일 작업", diff --git a/locales/lv/common.json b/locales/lv/common.json index de2e3b48b..be3aa7cb9 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -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", diff --git a/locales/mn/common.json b/locales/mn/common.json index 5cb0e8ba9..3ac66f6cb 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -2703,6 +2703,7 @@ "shortcuts": { "title": "Гарын товчлол", "tip": "Энэ тусламжийг хүссэн үедээ ? дарж нээж болно", + "or": "эсвэл", "sections": { "navigation": "Навигаци", "actions": "Имэйлийн үйлдлүүд", diff --git a/locales/nb/common.json b/locales/nb/common.json index e6abc3663..bce385f8e 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -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", diff --git a/locales/nl/common.json b/locales/nl/common.json index 7307fc0e7..5b111f177 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -2703,6 +2703,7 @@ "shortcuts": { "title": "Sneltoetsen", "tip": "Druk op ? om deze hulp te tonen", + "or": "of", "sections": { "navigation": "Navigatie", "actions": "E-mailacties", diff --git a/locales/pl/common.json b/locales/pl/common.json index 048e32f3b..079d25f31 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -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", diff --git a/locales/pt/common.json b/locales/pt/common.json index 5079841a4..38fcaf6d8 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -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", diff --git a/locales/ro/common.json b/locales/ro/common.json index bad3efbe9..88868221f 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -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", diff --git a/locales/ru/common.json b/locales/ru/common.json index 5b20c7b0f..63eafde10 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -2703,6 +2703,7 @@ "shortcuts": { "title": "Сочетания клавиш", "tip": "Нажмите ? в любое время для отображения справки", + "or": "или", "sections": { "navigation": "Навигация", "actions": "Действия с письмом", diff --git a/locales/sk/common.json b/locales/sk/common.json index 303cb32fd..29ba9a7e2 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -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", diff --git a/locales/th/common.json b/locales/th/common.json index c26810660..8ed6ed3ef 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -590,6 +590,7 @@ "shortcuts": { "title": "แป้นพิมพ์ลัด", "tip": "กด ? ได้ทุกเมื่อเพื่อแสดงความช่วยเหลือนี้", + "or": "หรือ", "sections": { "navigation": "การนำทาง", "actions": "การจัดการอีเมล", diff --git a/locales/tr/common.json b/locales/tr/common.json index d6adde4a7..6175b06c0 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -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", diff --git a/locales/uk/common.json b/locales/uk/common.json index 182f17cbe..b6c42bf7c 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -2703,6 +2703,7 @@ "shortcuts": { "title": "Комбінації клавіш", "tip": "Натисніть ? у будь-який час, щоб показати цю допомогу", + "or": "або", "sections": { "navigation": "Навігація", "actions": "Дії електронної пошти", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 4b59f807b..3c122fa20 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -2703,6 +2703,7 @@ "shortcuts": { "title": "鍵盤快速鍵", "tip": "隨時按下 ? 即可顯示此說明", + "or": "或", "sections": { "navigation": "導覽", "actions": "郵件動作", diff --git a/locales/zh/common.json b/locales/zh/common.json index 552803ce4..890c771e7 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -2703,6 +2703,7 @@ "shortcuts": { "title": "键盘快捷键", "tip": "按?随时显示此帮助", + "or": "或", "sections": { "navigation": "导航", "actions": "邮件操作",