diff --git a/apps/teams-test-app/src/components/ShortcutRelayAPIs.tsx b/apps/teams-test-app/src/components/ShortcutRelayAPIs.tsx
new file mode 100644
index 0000000000..d575ed7fcc
--- /dev/null
+++ b/apps/teams-test-app/src/components/ShortcutRelayAPIs.tsx
@@ -0,0 +1,56 @@
+import { shortcutRelay } from '@microsoft/teams-js';
+import React from 'react';
+
+import { ApiWithoutInput } from './utils';
+import { ModuleWrapper } from './utils/ModuleWrapper';
+
+const CheckShortcutRelayCapability = (): React.ReactElement =>
+ ApiWithoutInput({
+ name: 'shortcutRelay_checkShortcutRelayCapability',
+ title: 'Check Shortcut Relay Capability',
+ onClick: async () => `ShortcutRelay ${shortcutRelay.isSupported() ? 'is' : 'is not'} supported`,
+ });
+
+const EnableShortcutRelayCapability = (): React.ReactElement =>
+ ApiWithoutInput({
+ name: 'shortcutRelay_enableShortcutRelayCapability',
+ title: 'Enable Shortcut Relay Capability and Trigger Ctrl+1 shortcut',
+ onClick: async () => {
+ await shortcutRelay.enableShortcutRelayCapability();
+ document.body.dispatchEvent(new KeyboardEvent('keydown', { key: '1', ctrlKey: true }));
+ return 'called';
+ },
+ });
+
+const SetOverridableShortcutHandler = (): React.ReactElement =>
+ ApiWithoutInput({
+ name: 'shortcutRelay_setOverridableShortcutHandler',
+ title: 'Set Overridable Shortcut Handler',
+ onClick: async () => {
+ shortcutRelay.setOverridableShortcutHandler(() => true);
+ return 'called';
+ },
+ });
+
+const ResetIsShortcutRelayCapabilityEnabled = (): React.ReactElement =>
+ ApiWithoutInput({
+ name: 'shortcutRelay_resetIsShortcutRelayCapabilityEnabled',
+ title: 'Reset Shortcut Relay Capability',
+ onClick: async () => {
+ shortcutRelay.resetIsShortcutRelayCapabilityEnabled();
+ return 'called';
+ },
+ });
+
+const ShortcutRelayAPIs = (): React.ReactElement => (
+ <>
+
+
+
+
+
+
+ >
+);
+
+export default ShortcutRelayAPIs;
diff --git a/apps/teams-test-app/src/pages/TestApp.tsx b/apps/teams-test-app/src/pages/TestApp.tsx
index 5ae331c273..1f8a19c24c 100644
--- a/apps/teams-test-app/src/pages/TestApp.tsx
+++ b/apps/teams-test-app/src/pages/TestApp.tsx
@@ -59,6 +59,7 @@ import RemoteCameraAPIs from '../components/RemoteCameraAPIs';
import SearchAPIs from '../components/SearchAPIs';
import SecondaryBrowserAPIs from '../components/SecondaryBrowserAPIs';
import SharingAPIs from '../components/SharingAPIs';
+import ShortcutRelayAPIs from '../components/ShortcutRelayAPIs';
import StageViewAPIs from '../components/StageViewAPIs';
import StageViewSelfAPIs from '../components/StageViewSelfAPIs';
import StoreAPIs from '../components/StoreApis';
@@ -157,6 +158,7 @@ export const TestApp: React.FC = () => {
{ name: 'SearchAPIs', component: },
{ name: 'SecondaryBrowserAPIs', component: },
{ name: 'SharingAPIs', component: },
+ { name: 'ShortcutRelayAPIs', component: },
{ name: 'WebStorageAPIs', component: },
{ name: 'StageViewAPIs', component: },
{ name: 'StageViewSelfAPIs', component: },
diff --git a/packages/teams-js/src/internal/telemetry.ts b/packages/teams-js/src/internal/telemetry.ts
index 0ac67330f2..45316a44ba 100644
--- a/packages/teams-js/src/internal/telemetry.ts
+++ b/packages/teams-js/src/internal/telemetry.ts
@@ -332,6 +332,9 @@ export const enum ApiName {
Settings_SetValidityState = 'settings.setValidityState',
Sharing_History_GetContent = 'sharing.history.getContent',
Sharing_ShareWebContent = 'sharing.shareWebContent',
+ ShortcutRelay_GetHostShortcuts = 'shortcutRelay.getHostShortcuts',
+ ShortcutRelay_ForwardShortcutEvent = 'shortcutRelay.forwardShortcutEvent',
+ ShortcutRelay_HostShortcutChanged = 'shortcutRelay.hostShortcutChanged',
StageView_Open = 'stageView.open',
StageView_Self_Close = 'stageView.self.close',
Store_OpenFullStore = 'store.openFullStore',
diff --git a/packages/teams-js/src/public/index.ts b/packages/teams-js/src/public/index.ts
index 10a778a61b..1bc8b00f3e 100644
--- a/packages/teams-js/src/public/index.ts
+++ b/packages/teams-js/src/public/index.ts
@@ -139,3 +139,4 @@ export * as liveShare from './liveShareHost';
export { LiveShareHost } from './liveShareHost';
export * as marketplace from './marketplace';
export { ISerializable } from './serializable.interface';
+export * as shortcutRelay from './shortcutRelay';
diff --git a/packages/teams-js/src/public/runtime.ts b/packages/teams-js/src/public/runtime.ts
index 1fcb97e6a0..891679f3f4 100644
--- a/packages/teams-js/src/public/runtime.ts
+++ b/packages/teams-js/src/public/runtime.ts
@@ -298,6 +298,7 @@ interface IRuntimeV4 extends IBaseRuntime {
readonly sharing?: {
readonly history?: {};
};
+ readonly shortcutRelay?: {};
readonly stageView?: {
readonly self?: {};
};
diff --git a/packages/teams-js/src/public/shortcutRelay.ts b/packages/teams-js/src/public/shortcutRelay.ts
new file mode 100644
index 0000000000..81a9fd7b56
--- /dev/null
+++ b/packages/teams-js/src/public/shortcutRelay.ts
@@ -0,0 +1,325 @@
+/**
+ * Allows host shortcuts to function in your application by forwarding keyboard shortcuts to the host.
+ *
+ * This functionality is in Beta.
+ * @beta
+ * @module
+ */
+
+import { callFunctionInHost, callFunctionInHostAndHandleResponse } from '../internal/communication';
+import { registerHandler } from '../internal/handlers';
+import { ensureInitialized } from '../internal/internalAPIs';
+import { ResponseHandler } from '../internal/responseHandler';
+import { ApiName, ApiVersionNumber, getApiVersionTag } from '../internal/telemetry';
+import { errorNotSupportedOnPlatform } from './constants';
+import { runtime } from './runtime';
+import { ISerializable } from './serializable.interface';
+
+/* ------------------------------------------------------------------ */
+/* Types */
+/* ------------------------------------------------------------------ */
+
+type Shortcuts = Set; // stores shortcut key combinations, for example: ["ctrl+/", "ctrl+shift+/"]
+
+type HostShortcutsResponse = {
+ shortcuts: Shortcuts;
+ overridableShortcuts: Shortcuts;
+};
+
+/**
+ * Data passed to the overridable shortcut handler.
+ */
+export type OverridableShortcutHandlerData = {
+ /**
+ * The matched shortcut that triggered the handler.
+ * This is a canonical form of the shortcut, e.g. "ctrl+shift+x".
+ */
+ matchedShortcut: string;
+};
+
+/**
+ * A handler must return `true` when it wants to **consume** the shortcut
+ * (i.e. prevent forwarding to host). Any other return value forwards it.
+ */
+export type OverridableShortcutHandler = (event: KeyboardEvent, data: OverridableShortcutHandlerData) => boolean;
+
+/* ------------------------------------------------------------------ */
+/* Utils */
+/* ------------------------------------------------------------------ */
+
+class SerializableKeyboardEvent implements ISerializable {
+ public constructor(private event: KeyboardEvent) {}
+ public serialize(): object | string {
+ return {
+ altKey: this.event.altKey,
+ bubbles: this.event.bubbles,
+ cancelBubble: this.event.cancelBubble,
+ charCode: this.event.charCode,
+ code: this.event.code,
+ composed: this.event.composed,
+ ctrlKey: this.event.ctrlKey,
+ // currentTarget: skipped
+ defaultPrevented: this.event.defaultPrevented,
+ detail: this.event.detail,
+ eventPhase: this.event.eventPhase,
+ isComposing: this.event.isComposing,
+ isTrusted: this.event.isTrusted,
+ key: this.event.key,
+ keyCode: this.event.keyCode,
+ location: this.event.location,
+ metaKey: this.event.metaKey,
+ // path - skipped,
+ repeat: this.event.repeat,
+ returnValue: this.event.returnValue,
+ shiftKey: this.event.shiftKey,
+ // sourceCapabilities - skipped,
+ // srcElement - slipped.
+ // target - skipped.
+ timeStamp: this.event.timeStamp,
+ type: this.event.type,
+ // view - skipped
+ which: this.event.which,
+ };
+ }
+}
+
+/**
+ * Normalizes a shortcut string to a canonical form.
+ */
+function normalizeShortcut(shortcut: string): string {
+ return shortcut.toLowerCase().split('+').sort().join('+');
+}
+
+/**
+ * Build a canonical, lower-case “ctrl+shift+x” representation of the
+ * currently pressed keys. The array is sorted so the order in which
+ * modifiers are pressed does not matter.
+ */
+function eventToCanonicalShortcut(e: KeyboardEvent): string {
+ return [e.ctrlKey && 'ctrl', e.shiftKey && 'shift', e.altKey && 'alt', e.metaKey && 'meta', e.key.toLowerCase()]
+ .filter(Boolean)
+ .sort()
+ .join('+');
+}
+
+/**
+ * Checks if the event is a valid shortcut event.
+ * A valid shortcut event is one that has at least one modifier key pressed
+ * (ctrl, shift, alt, meta) or the Escape key.
+ */
+function isValidShortcutEvent(e: KeyboardEvent): boolean {
+ return e.ctrlKey || e.shiftKey || e.altKey || e.metaKey || (!!e.key && e.key.toLowerCase() === 'escape');
+}
+
+function isMatchingShortcut(
+ shortcuts: Shortcuts,
+ e: KeyboardEvent,
+): {
+ matchedShortcut: string | undefined;
+ isOverridable: boolean;
+} {
+ if (isValidShortcutEvent(e)) {
+ const pressedShortcut = eventToCanonicalShortcut(e);
+ const isMatching = shortcuts.has(pressedShortcut);
+ if (isMatching) {
+ return {
+ matchedShortcut: pressedShortcut,
+ isOverridable: overridableShortcuts.has(pressedShortcut),
+ };
+ }
+ }
+
+ return {
+ matchedShortcut: undefined,
+ isOverridable: false,
+ };
+}
+
+function updateHostShortcuts(data: HostShortcutsResponse): void {
+ hostShortcuts.clear();
+ data.shortcuts.forEach((shortcut: string) => {
+ hostShortcuts.add(normalizeShortcut(shortcut));
+ });
+
+ overridableShortcuts.clear();
+ data.overridableShortcuts.forEach((shortcut: string) => {
+ overridableShortcuts.add(normalizeShortcut(shortcut));
+ });
+}
+
+class HostShortcutsResponseHandler extends ResponseHandler {
+ public validate(response: HostShortcutsResponse): boolean {
+ return response && Array.isArray(response.shortcuts) && Array.isArray(response.overridableShortcuts);
+ }
+
+ public deserialize(response: HostShortcutsResponse): HostShortcutsResponse {
+ return response;
+ }
+}
+/**
+ * register a handler to be called when shortcuts are updated in the host.
+ */
+function registerOnHostShortcutChangedHandler(handler: (hostShortcuts: HostShortcutsResponse) => void): void {
+ registerHandler(
+ getApiVersionTag(ApiVersionNumber.V_2, ApiName.ShortcutRelay_HostShortcutChanged),
+ ApiName.ShortcutRelay_HostShortcutChanged,
+ handler,
+ );
+}
+
+function keydownHandler(event: KeyboardEvent): void {
+ // Skip if the event target is within an element that has the `data-disable-shortcuts-forwarding` attribute
+ if ((event.target as HTMLElement).closest(`[${DISABLE_SHORTCUT_FORWARDING_ATTRIBUTE}]`)) {
+ return;
+ }
+
+ const { matchedShortcut, isOverridable } = isMatchingShortcut(hostShortcuts, event);
+
+ if (!matchedShortcut) {
+ return; // ignore unrelated events
+ }
+
+ if (isOverridable && overridableShortcutHandler) {
+ const shouldOverride = overridableShortcutHandler(event, { matchedShortcut });
+ if (shouldOverride) {
+ return; // Do not forward shortcut to host
+ }
+ }
+
+ /* Forward shortcut to host */
+ const payload = new SerializableKeyboardEvent(event);
+
+ callFunctionInHost(
+ ApiName.ShortcutRelay_ForwardShortcutEvent,
+ [payload],
+ getApiVersionTag(ApiVersionNumber.V_2, ApiName.ShortcutRelay_ForwardShortcutEvent),
+ );
+
+ event.preventDefault();
+ event.stopImmediatePropagation();
+}
+
+/* ------------------------------------------------------------------ */
+/* In-memory */
+/* ------------------------------------------------------------------ */
+/**
+ * @hidden
+ * @internal
+ * Stores the shortcuts that can be overridden by the app.
+ */
+const overridableShortcuts: Set = new Set();
+
+/**
+ * @hidden
+ * @internal
+ * Stores the shortcuts that are enabled in host.
+ * This set is populated when the host sends the list of enabled shortcuts.
+ */
+const hostShortcuts: Set = new Set();
+
+/**
+ * @hidden
+ * @internal
+ * Stores the handler for overridable shortcuts.
+ */
+let overridableShortcutHandler: OverridableShortcutHandler | undefined = undefined;
+
+/**
+ * @hidden
+ * @internal
+ * Flag to indicate if the shortcut relay capability has been enabled, so that we do not register the event listener multiple times.
+ */
+let isShortcutRelayCapabilityEnabled = false;
+
+/* ------------------------------------------------------------------ */
+/* API */
+/* ------------------------------------------------------------------ */
+
+/**
+ * Replace the current overridable-shortcut handler.
+ *
+ * • Pass `undefined` to remove an existing handler.
+ * • Returns the previous handler so callers can restore it if needed.
+ *
+ * @beta
+ */
+export function setOverridableShortcutHandler(
+ handler: OverridableShortcutHandler | undefined,
+): OverridableShortcutHandler | undefined {
+ if (!isSupported()) {
+ throw errorNotSupportedOnPlatform;
+ }
+
+ const previous = overridableShortcutHandler;
+ overridableShortcutHandler = handler;
+ return previous;
+}
+
+/**
+ * Reset the state of the shortcut relay capability.
+ * This is useful for tests to ensure a clean state.
+ *
+ * @beta
+ */
+export function resetIsShortcutRelayCapabilityEnabled(): void {
+ if (!isSupported()) {
+ throw errorNotSupportedOnPlatform;
+ }
+
+ isShortcutRelayCapabilityEnabled = false;
+ hostShortcuts.clear();
+ overridableShortcuts.clear();
+ overridableShortcutHandler = undefined;
+ document.removeEventListener('keydown', keydownHandler, { capture: true });
+}
+
+/**
+ * Enable capability to support host shortcuts.
+ *
+ * @beta
+ */
+export async function enableShortcutRelayCapability(): Promise {
+ if (!isSupported()) {
+ throw errorNotSupportedOnPlatform;
+ }
+
+ /* 1. Ask host for the list of enabled shortcuts */
+ const response = await callFunctionInHostAndHandleResponse(
+ ApiName.ShortcutRelay_GetHostShortcuts,
+ [],
+ new HostShortcutsResponseHandler(),
+ getApiVersionTag(ApiVersionNumber.V_2, ApiName.ShortcutRelay_GetHostShortcuts),
+ );
+ updateHostShortcuts(response);
+
+ /* 2. Global key-down handler */
+ if (!isShortcutRelayCapabilityEnabled) {
+ document.addEventListener('keydown', keydownHandler, { capture: true });
+ }
+ isShortcutRelayCapabilityEnabled = true;
+
+ /* 3. Register handler for host shortcut updates */
+ registerOnHostShortcutChangedHandler((hostShortcuts: HostShortcutsResponse) => {
+ updateHostShortcuts(hostShortcuts);
+ });
+}
+
+/**
+ * Checks if shortcutRelay capability is supported by the host
+ * @returns boolean to represent whether the shortcutRelay capability is supported
+ *
+ * @throws Error if {@link app.initialize} has not successfully completed
+ *
+ * @beta
+ */
+export function isSupported(): boolean {
+ return ensureInitialized(runtime) && runtime.supports.shortcutRelay ? true : false;
+}
+
+/**
+ * Allow apps to define zones where shortcuts should not be forwarded to the host.
+ * This is useful for input fields for password where shortcuts should not trigger host actions.
+ *
+ * @beta
+ */
+export const DISABLE_SHORTCUT_FORWARDING_ATTRIBUTE = 'data-disable-shortcuts-forwarding';
diff --git a/packages/teams-js/test/public/shortcutRelay.spec.ts b/packages/teams-js/test/public/shortcutRelay.spec.ts
new file mode 100644
index 0000000000..ffc2de7516
--- /dev/null
+++ b/packages/teams-js/test/public/shortcutRelay.spec.ts
@@ -0,0 +1,125 @@
+import { errorLibraryNotInitialized } from '../../src/internal/constants';
+import { GlobalVars } from '../../src/internal/globalVars';
+import { DOMMessageEvent } from '../../src/internal/interfaces';
+import { ApiName } from '../../src/internal/telemetry';
+import * as app from '../../src/public/app/app';
+import { errorNotSupportedOnPlatform, FrameContexts } from '../../src/public/constants';
+import { latestRuntimeApiVersion } from '../../src/public/runtime';
+import * as shortcutRelay from '../../src/public/shortcutRelay';
+import { Utils } from '../utils';
+
+describe('shortcutRelay capability', () => {
+ describe('frameless', () => {
+ let utils: Utils = new Utils();
+ beforeEach(() => {
+ utils = new Utils();
+ utils.mockWindow.parent = undefined;
+ utils.messages = [];
+ GlobalVars.isFramelessWindow = false;
+ });
+ afterEach(() => {
+ app._uninitialize();
+ GlobalVars.isFramelessWindow = false;
+ jest.resetModules();
+ shortcutRelay.resetIsShortcutRelayCapabilityEnabled();
+ });
+
+ describe('isSupported()', () => {
+ it('returns false when runtime says it is not supported', async () => {
+ await utils.initializeWithContext(FrameContexts.content);
+ utils.setRuntimeConfig({ apiVersion: 1, supports: {} });
+ expect(shortcutRelay.isSupported()).toBeFalsy();
+ });
+
+ it('returns true when runtime says it is supported', async () => {
+ await utils.initializeWithContext(FrameContexts.content);
+ utils.setRuntimeConfig({ apiVersion: 1, supports: { shortcutRelay: {} } });
+ expect(shortcutRelay.isSupported()).toBeTruthy();
+ });
+
+ it('throws before initialization', () => {
+ utils.uninitializeRuntimeConfig();
+ expect(() => shortcutRelay.isSupported()).toThrowError(new Error(errorLibraryNotInitialized));
+ });
+ });
+
+ describe('enableShortcutRelayCapability()', () => {
+ it('should reject before initialization', async () => {
+ await expect(shortcutRelay.enableShortcutRelayCapability()).rejects.toThrowError(
+ new Error(errorLibraryNotInitialized),
+ );
+ });
+
+ it('should reject when capability not supported in runtime', async () => {
+ await utils.initializeWithContext(FrameContexts.content);
+ utils.setRuntimeConfig({ apiVersion: latestRuntimeApiVersion, supports: {} });
+
+ await expect(shortcutRelay.enableShortcutRelayCapability()).rejects.toEqual(errorNotSupportedOnPlatform);
+ });
+
+ it('forwards a matching non-overridden shortcut to host', async () => {
+ await utils.initializeWithContext(FrameContexts.content);
+ utils.setRuntimeConfig({ apiVersion: latestRuntimeApiVersion, supports: { shortcutRelay: {} } });
+
+ shortcutRelay.enableShortcutRelayCapability();
+
+ // simulate host response with shortcuts
+ const response = {
+ shortcuts: ['ctrl+s'],
+ overridableShortcuts: [],
+ };
+ const request = utils.findMessageByFunc(ApiName.ShortcutRelay_GetHostShortcuts);
+ utils.respondToFramelessMessage({
+ data: { id: request?.id, args: [response] },
+ } as DOMMessageEvent);
+
+ // fire keydown in next animation frame
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ const evt = new KeyboardEvent('keydown', { key: 's', ctrlKey: true, bubbles: true });
+ document.body.dispatchEvent(evt);
+
+ const fwd = utils.findMessageByFunc(ApiName.ShortcutRelay_ForwardShortcutEvent);
+ expect(fwd).not.toBeNull();
+ });
+
+ it('gives app chance to consume overridable shortcut', async () => {
+ await utils.initializeWithContext(FrameContexts.content);
+ utils.setRuntimeConfig({ apiVersion: latestRuntimeApiVersion, supports: { shortcutRelay: {} } });
+
+ const handler = jest.fn(() => true); // consume event
+ shortcutRelay.setOverridableShortcutHandler(handler);
+ shortcutRelay.enableShortcutRelayCapability();
+
+ const response = {
+ shortcuts: ['ctrl+p'],
+ overridableShortcuts: ['ctrl+p'],
+ };
+ const request = utils.findMessageByFunc(ApiName.ShortcutRelay_GetHostShortcuts);
+ utils.respondToFramelessMessage({
+ data: { id: request?.id, args: [response] },
+ } as DOMMessageEvent);
+
+ // fire keydown in next animation frame
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ const evt = new KeyboardEvent('keydown', { key: 'p', ctrlKey: true, bubbles: true });
+ document.body.dispatchEvent(evt);
+
+ expect(handler).toHaveBeenCalled();
+ const fwd = utils.findMessageByFunc(ApiName.ShortcutRelay_ForwardShortcutEvent);
+ expect(fwd).toBeNull(); // consumed, so not forwarded
+ });
+ });
+
+ describe('setOverridableShortcutHandler()', () => {
+ it('replaces and returns previous handler', () => {
+ const noop = (): boolean => true;
+ const prev = shortcutRelay.setOverridableShortcutHandler(noop);
+ expect(prev).toBeUndefined();
+
+ const next = (): boolean => false;
+ const old = shortcutRelay.setOverridableShortcutHandler(next);
+ expect(old).toBe(noop);
+ });
+ });
+ });
+});