react-native-apple-key-commands 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bogdan Georgian Alexa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,165 @@
1
+ # react-native-apple-key-commands
2
+
3
+ > ☕ **If this library has helped you, consider [buying me a coffee](https://buymeacoffee.com/boogdan)!** Your support keeps development going.
4
+
5
+ Dynamic hardware-keyboard shortcuts (UIKeyCommand) for React Native and Expo. Register key commands from JavaScript, get an event back when one fires. Works on iPad with a connected keyboard and on iPad apps running on an Apple silicon Mac. iOS only, built with the Expo Modules API.
6
+
7
+ ```ts
8
+ import { setKeyCommands, addKeyCommandListener } from 'react-native-apple-key-commands';
9
+
10
+ setKeyCommands([{ id: 'launch', input: 'return', modifiers: ['command'], title: 'Launch' }]);
11
+ const sub = addKeyCommandListener(id => { if (id === 'launch') runSearch(); });
12
+ ```
13
+
14
+ ## Why
15
+
16
+ React Native and Expo expose no API for hardware-keyboard shortcuts. On iPad (and iPad apps running on Mac), UIKit has a first-class mechanism for this: `UIKeyCommand`. This module bridges it -- commands are defined dynamically from JavaScript (not hardcoded native), and a single JS event fires when any registered command is pressed.
17
+
18
+ Note: **there is no menu bar integration.** The `title` field feeds only the iPad ⌘-hold discoverbility overlay; `UIMenuBuilder` and the macOS menu bar are not involved.
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ npm install react-native-apple-key-commands
24
+ # or
25
+ yarn add react-native-apple-key-commands
26
+ # or
27
+ bun add react-native-apple-key-commands
28
+ ```
29
+
30
+ Then rebuild the native project so autolinking picks up the module:
31
+
32
+ ```bash
33
+ npx expo prebuild
34
+ npx expo run:ios
35
+ ```
36
+
37
+ There is **no config plugin and no entitlements** to set up -- installing and rebuilding is all that is required.
38
+
39
+ ### Prerequisites
40
+
41
+ - Expo `>= 51.0.0`
42
+ - React Native `>= 0.74.0`
43
+ - iOS only (Android and other platforms are safely inert -- see [Platform behavior](#platform-behavior))
44
+ - A native build. Key commands register and fire on a device or simulator build; in Jest the native module is absent and all helpers no-op.
45
+
46
+ ## API Reference
47
+
48
+ ```ts
49
+ import {
50
+ setKeyCommands,
51
+ clearKeyCommands,
52
+ addKeyCommandListener,
53
+ isKeyCommandsSupported,
54
+ } from 'react-native-apple-key-commands';
55
+ import type { KeyCommand, KeyModifier } from 'react-native-apple-key-commands';
56
+ ```
57
+
58
+ ### Types
59
+
60
+ ```ts
61
+ type KeyModifier = 'command' | 'shift' | 'option' | 'control';
62
+
63
+ interface KeyCommand {
64
+ /** Stable id echoed back to the listener when this command fires. */
65
+ id: string;
66
+ /**
67
+ * The key to bind. Use a single character, or one of the named keys:
68
+ * return | escape | space | tab | up | down | left | right
69
+ */
70
+ input: string;
71
+ modifiers?: KeyModifier[];
72
+ /** Shown in the iPad ⌘-hold overlay. No effect on the menu bar. */
73
+ title?: string;
74
+ }
75
+ ```
76
+
77
+ ### `setKeyCommands(commands)`
78
+
79
+ ```ts
80
+ function setKeyCommands(commands: KeyCommand[]): void;
81
+ ```
82
+
83
+ Registers (or replaces) the full set of active key commands. The previous set is removed before the new one is applied. No-op when the native module is unavailable (Android, Jest).
84
+
85
+ ### `clearKeyCommands()`
86
+
87
+ ```ts
88
+ function clearKeyCommands(): void;
89
+ ```
90
+
91
+ Removes all currently registered key commands. No-op when unsupported.
92
+
93
+ ### `addKeyCommandListener(cb)`
94
+
95
+ ```ts
96
+ function addKeyCommandListener(cb: (id: string) => void): { remove(): void };
97
+ ```
98
+
99
+ Subscribes to key command presses. The callback receives the `id` of the command that fired. Returns an object with a `remove()` method to unsubscribe. When the native module is unavailable, the callback is never called and `remove()` is a no-op.
100
+
101
+ ### `isKeyCommandsSupported()`
102
+
103
+ ```ts
104
+ function isKeyCommandsSupported(): boolean;
105
+ ```
106
+
107
+ Returns `true` on iOS when the native module is loaded, `false` otherwise (Android, Jest). Use this to conditionally render keyboard-shortcut hints.
108
+
109
+ ## Example: a default key map
110
+
111
+ A full key map defined entirely from JavaScript -- no native rebuild needed when commands change:
112
+
113
+ ```ts
114
+ import { setKeyCommands, addKeyCommandListener } from 'react-native-apple-key-commands';
115
+ import { useEffect } from 'react';
116
+
117
+ const KEY_MAP = [
118
+ { id: 'launch', input: 'return', modifiers: ['command'], title: 'Launch search' },
119
+ { id: 'palette', input: 'k', modifiers: ['command'], title: 'Open palette' },
120
+ { id: 'copy', input: 'c', modifiers: ['command'], title: 'Copy query' },
121
+ { id: 'dismiss', input: 'escape', modifiers: [], title: 'Dismiss' },
122
+ { id: 'engine1', input: '1', modifiers: ['option'], title: 'Engine 1' },
123
+ { id: 'engine2', input: '2', modifiers: ['option'], title: 'Engine 2' },
124
+ { id: 'engine3', input: '3', modifiers: ['option'], title: 'Engine 3' },
125
+ { id: 'engine4', input: '4', modifiers: ['option'], title: 'Engine 4' },
126
+ { id: 'engine5', input: '5', modifiers: ['option'], title: 'Engine 5' },
127
+ { id: 'engine6', input: '6', modifiers: ['option'], title: 'Engine 6' },
128
+ { id: 'engine7', input: '7', modifiers: ['option'], title: 'Engine 7' },
129
+ { id: 'engine8', input: '8', modifiers: ['option'], title: 'Engine 8' },
130
+ ];
131
+
132
+ export function useKeyMap(handlers: Record<string, () => void>) {
133
+ useEffect(() => {
134
+ setKeyCommands(KEY_MAP);
135
+ const sub = addKeyCommandListener(id => handlers[id]?.());
136
+ return () => { sub.remove(); };
137
+ }, []);
138
+ }
139
+ ```
140
+
141
+ > **Note:** bare `escape` and `return` (no modifier) may be swallowed by a focused text field. Commands with a `command` modifier fire reliably even while text input is active.
142
+
143
+ ## Platform behavior
144
+
145
+ | Environment | Commands fire | Listener fires |
146
+ | ------------------------------------ | :-----------: | :------------: |
147
+ | iPad with keyboard | yes | yes |
148
+ | iPad app on Mac (Apple silicon) | yes | yes |
149
+ | iPhone / no keyboard | no | no |
150
+ | Android | no | no |
151
+ | Jest / no native runtime | no | no |
152
+
153
+ All exports are safe to call on any platform. On non-iOS and when the native module is absent (Jest), `setKeyCommands` and `clearKeyCommands` are no-ops, `addKeyCommandListener` returns a no-op `{ remove() {} }`, and `isKeyCommandsSupported()` returns `false`.
154
+
155
+ ## How it works
156
+
157
+ When `setKeyCommands` is called, the native module dispatches to the main thread and attaches `UIKeyCommand` objects to the root view controller of the key window (`rootViewController.addKeyCommand`). Each command stores its `id` in `UIKeyCommand.propertyList`.
158
+
159
+ The first call installs a single action method (`ak_handleKeyCommand:`) on the root view controller class at runtime using `class_addMethod`. Because the method is on the view controller (not a text field), it sits above focused responders in the responder chain -- ⌘-modified commands reach it even while a text field has first responder. When the action fires, the module reads the `id` from `propertyList` and emits an `onKeyCommand` event to JavaScript.
160
+
161
+ There is no `UIMenuBuilder` involvement and no macOS menu bar integration. The `title` field controls only the discoverbility label shown in the iPad ⌘-hold overlay.
162
+
163
+ ## License
164
+
165
+ MIT © [Bogdan Georgian Alexa](https://github.com/BogdanGeorgian91)
@@ -0,0 +1,21 @@
1
+ export type KeyModifier = 'command' | 'shift' | 'option' | 'control';
2
+ export interface KeyCommand {
3
+ /** stable id echoed back on press */
4
+ id: string;
5
+ /** single char, or: return | escape | space | tab | up | down | left | right */
6
+ input: string;
7
+ modifiers?: KeyModifier[];
8
+ /** shown in the iPad ⌘-hold overlay (no menu bar) */
9
+ title?: string;
10
+ }
11
+ /** iOS only; false on Android / when the native module is absent (Jest). */
12
+ export declare function isKeyCommandsSupported(): boolean;
13
+ /** Register/replace the active set of key commands. No-op when unsupported. */
14
+ export declare function setKeyCommands(commands: KeyCommand[]): void;
15
+ /** Remove all registered key commands. No-op when unsupported. */
16
+ export declare function clearKeyCommands(): void;
17
+ /** Subscribe to command presses; the callback receives the command id. */
18
+ export declare function addKeyCommandListener(cb: (id: string) => void): {
19
+ remove(): void;
20
+ };
21
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,WAAW,GAAG,SAAS,GAAG,OAAO,GAAG,QAAQ,GAAG,SAAS,CAAC;AACrE,MAAM,WAAW,UAAU;IACzB,qCAAqC;IACrC,EAAE,EAAE,MAAM,CAAC;IACX,gFAAgF;IAChF,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,WAAW,EAAE,CAAC;IAC1B,qDAAqD;IACrD,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AASD,4EAA4E;AAC5E,wBAAgB,sBAAsB,IAAI,OAAO,CAEhD;AAED,+EAA+E;AAC/E,wBAAgB,cAAc,CAAC,QAAQ,EAAE,UAAU,EAAE,GAAG,IAAI,CAE3D;AAED,kEAAkE;AAClE,wBAAgB,gBAAgB,IAAI,IAAI,CAEvC;AAED,0EAA0E;AAC1E,wBAAgB,qBAAqB,CAAC,EAAE,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,GAAG;IAAE,MAAM,IAAI,IAAI,CAAA;CAAE,CAIlF"}
package/build/index.js ADDED
@@ -0,0 +1,28 @@
1
+ import { Platform } from 'react-native';
2
+ import { requireOptionalNativeModule } from 'expo-modules-core';
3
+ // In expo-modules-core (Expo 56), a native module that declares `Events(...)` IS an
4
+ // EventEmitter — call `addListener` on it directly. Constructing `new EventEmitter(module)`
5
+ // is the legacy form and types the event name as `never`.
6
+ const Native = Platform.OS === 'ios'
7
+ ? requireOptionalNativeModule('AppleKeyCommands')
8
+ : null;
9
+ /** iOS only; false on Android / when the native module is absent (Jest). */
10
+ export function isKeyCommandsSupported() {
11
+ return !!Native;
12
+ }
13
+ /** Register/replace the active set of key commands. No-op when unsupported. */
14
+ export function setKeyCommands(commands) {
15
+ Native?.setKeyCommands(commands.map(c => ({ ...c, modifiers: c.modifiers ?? [] })));
16
+ }
17
+ /** Remove all registered key commands. No-op when unsupported. */
18
+ export function clearKeyCommands() {
19
+ Native?.clearKeyCommands();
20
+ }
21
+ /** Subscribe to command presses; the callback receives the command id. */
22
+ export function addKeyCommandListener(cb) {
23
+ if (!Native)
24
+ return { remove() { } };
25
+ const sub = Native.addListener('onKeyCommand', (e) => cb(e.id));
26
+ return { remove: () => sub.remove() };
27
+ }
28
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AACxC,OAAO,EAAE,2BAA2B,EAAE,MAAM,mBAAmB,CAAC;AAahE,oFAAoF;AACpF,4FAA4F;AAC5F,0DAA0D;AAC1D,MAAM,MAAM,GAAG,QAAQ,CAAC,EAAE,KAAK,KAAK;IAClC,CAAC,CAAC,2BAA2B,CAAM,kBAAkB,CAAC;IACtD,CAAC,CAAC,IAAI,CAAC;AAET,4EAA4E;AAC5E,MAAM,UAAU,sBAAsB;IACpC,OAAO,CAAC,CAAC,MAAM,CAAC;AAClB,CAAC;AAED,+EAA+E;AAC/E,MAAM,UAAU,cAAc,CAAC,QAAsB;IACnD,MAAM,EAAE,cAAc,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,SAAS,EAAE,CAAC,CAAC,SAAS,IAAI,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC;AACtF,CAAC;AAED,kEAAkE;AAClE,MAAM,UAAU,gBAAgB;IAC9B,MAAM,EAAE,gBAAgB,EAAE,CAAC;AAC7B,CAAC;AAED,0EAA0E;AAC1E,MAAM,UAAU,qBAAqB,CAAC,EAAwB;IAC5D,IAAI,CAAC,MAAM;QAAE,OAAO,EAAE,MAAM,KAAI,CAAC,EAAE,CAAC;IACpC,MAAM,GAAG,GAAG,MAAM,CAAC,WAAW,CAAC,cAAc,EAAE,CAAC,CAAiB,EAAE,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IAChF,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,GAAG,CAAC,MAAM,EAAE,EAAE,CAAC;AACxC,CAAC","sourcesContent":["import { Platform } from 'react-native';\nimport { requireOptionalNativeModule } from 'expo-modules-core';\n\nexport type KeyModifier = 'command' | 'shift' | 'option' | 'control';\nexport interface KeyCommand {\n /** stable id echoed back on press */\n id: string;\n /** single char, or: return | escape | space | tab | up | down | left | right */\n input: string;\n modifiers?: KeyModifier[];\n /** shown in the iPad ⌘-hold overlay (no menu bar) */\n title?: string;\n}\n\n// In expo-modules-core (Expo 56), a native module that declares `Events(...)` IS an\n// EventEmitter — call `addListener` on it directly. Constructing `new EventEmitter(module)`\n// is the legacy form and types the event name as `never`.\nconst Native = Platform.OS === 'ios'\n ? requireOptionalNativeModule<any>('AppleKeyCommands')\n : null;\n\n/** iOS only; false on Android / when the native module is absent (Jest). */\nexport function isKeyCommandsSupported(): boolean {\n return !!Native;\n}\n\n/** Register/replace the active set of key commands. No-op when unsupported. */\nexport function setKeyCommands(commands: KeyCommand[]): void {\n Native?.setKeyCommands(commands.map(c => ({ ...c, modifiers: c.modifiers ?? [] })));\n}\n\n/** Remove all registered key commands. No-op when unsupported. */\nexport function clearKeyCommands(): void {\n Native?.clearKeyCommands();\n}\n\n/** Subscribe to command presses; the callback receives the command id. */\nexport function addKeyCommandListener(cb: (id: string) => void): { remove(): void } {\n if (!Native) return { remove() {} };\n const sub = Native.addListener('onKeyCommand', (e: { id: string }) => cb(e.id));\n return { remove: () => sub.remove() };\n}\n"]}
@@ -0,0 +1 @@
1
+ { "platforms": ["apple"], "apple": { "modules": ["AppleKeyCommandsModule"] } }
@@ -0,0 +1,21 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'AppleKeyCommands'
7
+ s.version = package['version']
8
+ s.summary = package['description']
9
+ s.description = package['description']
10
+ s.license = package['license']
11
+ s.author = package['author']
12
+ s.homepage = package['homepage']
13
+ s.platforms = { :ios => '15.1' }
14
+ s.swift_version = '5.4'
15
+ s.source = { git: 'https://github.com/BogdanGeorgian91/react-native-apple-key-commands.git' }
16
+ s.static_framework = true
17
+
18
+ s.dependency 'ExpoModulesCore'
19
+
20
+ s.source_files = "**/*.swift"
21
+ end
@@ -0,0 +1,80 @@
1
+ import ExpoModulesCore
2
+ import UIKit
3
+ import ObjectiveC.runtime
4
+
5
+ public class AppleKeyCommandsModule: Module {
6
+ public func definition() -> ModuleDefinition {
7
+ Name("AppleKeyCommands")
8
+ Events("onKeyCommand")
9
+
10
+ Function("isSupported") { () -> Bool in true } // the iOS module only loads on iOS
11
+
12
+ Function("setKeyCommands") { (commands: [[String: Any]]) in
13
+ DispatchQueue.main.async {
14
+ KeyCommandCenter.shared.emit = { [weak self] id in self?.sendEvent("onKeyCommand", ["id": id]) }
15
+ KeyCommandCenter.shared.setCommands(commands)
16
+ }
17
+ }
18
+ Function("clearKeyCommands") {
19
+ DispatchQueue.main.async { KeyCommandCenter.shared.setCommands([]) }
20
+ }
21
+ }
22
+ }
23
+
24
+ final class KeyCommandCenter: NSObject {
25
+ static let shared = KeyCommandCenter()
26
+ var emit: ((String) -> Void)?
27
+ private weak var hostVC: UIViewController?
28
+ private var actionInstalled = false
29
+
30
+ func setCommands(_ commands: [[String: Any]]) {
31
+ guard let rootVC = Self.keyWindow()?.rootViewController else { return }
32
+ installActionIfNeeded(on: rootVC)
33
+
34
+ (hostVC?.keyCommands ?? []).forEach { hostVC?.removeKeyCommand($0) }
35
+ hostVC = rootVC
36
+ let sel = NSSelectorFromString("ak_handleKeyCommand:")
37
+ for c in commands {
38
+ guard let id = c["id"] as? String, let raw = c["input"] as? String else { continue }
39
+ var mods: UIKeyModifierFlags = []
40
+ for m in (c["modifiers"] as? [String] ?? []) {
41
+ switch m {
42
+ case "command": mods.insert(.command); case "shift": mods.insert(.shift)
43
+ case "option": mods.insert(.alternate); case "control": mods.insert(.control); default: break
44
+ }
45
+ }
46
+ let cmd = UIKeyCommand(title: (c["title"] as? String) ?? "", image: nil, action: sel,
47
+ input: Self.mapInput(raw), modifierFlags: mods, propertyList: id)
48
+ rootVC.addKeyCommand(cmd)
49
+ }
50
+ }
51
+
52
+ private func installActionIfNeeded(on vc: UIViewController) {
53
+ guard !actionInstalled else { return }
54
+ actionInstalled = true
55
+ let sel = NSSelectorFromString("ak_handleKeyCommand:")
56
+ let block: @convention(block) (AnyObject, UIKeyCommand) -> Void = { _, cmd in
57
+ if let id = cmd.propertyList as? String { KeyCommandCenter.shared.emit?(id) }
58
+ }
59
+ let imp = imp_implementationWithBlock(block)
60
+ class_addMethod(type(of: vc), sel, imp, "v@:@")
61
+ }
62
+
63
+ static func keyWindow() -> UIWindow? {
64
+ UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
65
+ .flatMap { $0.windows }.first { $0.isKeyWindow } ?? UIApplication.shared.windows.first
66
+ }
67
+ static func mapInput(_ raw: String) -> String {
68
+ switch raw.lowercased() {
69
+ case "return","enter": return "\r"
70
+ case "escape","esc": return UIKeyCommand.inputEscape
71
+ case "space": return " "
72
+ case "tab": return "\t"
73
+ case "up": return UIKeyCommand.inputUpArrow
74
+ case "down": return UIKeyCommand.inputDownArrow
75
+ case "left": return UIKeyCommand.inputLeftArrow
76
+ case "right": return UIKeyCommand.inputRightArrow
77
+ default: return raw
78
+ }
79
+ }
80
+ }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "react-native-apple-key-commands",
3
+ "version": "0.1.0",
4
+ "description": "Dynamic hardware-keyboard shortcuts (UIKeyCommand) for React Native and Expo. Register key commands from JS and get an event back when one fires. Works on iPad and on iPad apps running on Mac. iOS only. Built with the Expo Modules API.",
5
+ "main": "build/index.js",
6
+ "types": "build/index.d.ts",
7
+ "scripts": {
8
+ "build": "expo-module build",
9
+ "clean": "expo-module clean",
10
+ "lint": "expo-module lint",
11
+ "test": "expo-module test",
12
+ "prepare": "expo-module prepare",
13
+ "prepublishOnly": "expo-module prepublishOnly",
14
+ "expo-module": "expo-module"
15
+ },
16
+ "keywords": ["react-native","expo","ios","macos","ipad","keyboard","shortcuts","uikeycommand","key-command","hotkey"],
17
+ "repository": { "type": "git", "url": "https://github.com/BogdanGeorgian91/react-native-apple-key-commands.git" },
18
+ "bugs": { "url": "https://github.com/BogdanGeorgian91/react-native-apple-key-commands/issues" },
19
+ "author": "Bogdan Georgian Alexa <bogdan.georgian370@gmail.com> (https://github.com/BogdanGeorgian91)",
20
+ "license": "MIT",
21
+ "homepage": "https://github.com/BogdanGeorgian91/react-native-apple-key-commands#readme",
22
+ "files": ["build/","ios/","src/","expo-module.config.json"],
23
+ "dependencies": {},
24
+ "devDependencies": { "@types/react": "~19.1.1", "expo": "^55.0.3", "expo-module-scripts": "^55.0.2", "react-native": "0.82.1" },
25
+ "peerDependencies": { "expo": ">=51.0.0", "react-native": ">=0.74.0" }
26
+ }
@@ -0,0 +1,11 @@
1
+ import { isKeyCommandsSupported, setKeyCommands, clearKeyCommands, addKeyCommandListener } from '../index';
2
+
3
+ describe('react-native-apple-key-commands (native module absent)', () => {
4
+ it('is inert and never throws when unsupported', () => {
5
+ expect(isKeyCommandsSupported()).toBe(false);
6
+ expect(() => setKeyCommands([{ id: 'palette', input: 'k', modifiers: ['command'] }])).not.toThrow();
7
+ expect(() => clearKeyCommands()).not.toThrow();
8
+ const sub = addKeyCommandListener(() => {});
9
+ expect(() => sub.remove()).not.toThrow();
10
+ });
11
+ });
package/src/index.ts ADDED
@@ -0,0 +1,42 @@
1
+ import { Platform } from 'react-native';
2
+ import { requireOptionalNativeModule } from 'expo-modules-core';
3
+
4
+ export type KeyModifier = 'command' | 'shift' | 'option' | 'control';
5
+ export interface KeyCommand {
6
+ /** stable id echoed back on press */
7
+ id: string;
8
+ /** single char, or: return | escape | space | tab | up | down | left | right */
9
+ input: string;
10
+ modifiers?: KeyModifier[];
11
+ /** shown in the iPad ⌘-hold overlay (no menu bar) */
12
+ title?: string;
13
+ }
14
+
15
+ // In expo-modules-core (Expo 56), a native module that declares `Events(...)` IS an
16
+ // EventEmitter — call `addListener` on it directly. Constructing `new EventEmitter(module)`
17
+ // is the legacy form and types the event name as `never`.
18
+ const Native = Platform.OS === 'ios'
19
+ ? requireOptionalNativeModule<any>('AppleKeyCommands')
20
+ : null;
21
+
22
+ /** iOS only; false on Android / when the native module is absent (Jest). */
23
+ export function isKeyCommandsSupported(): boolean {
24
+ return !!Native;
25
+ }
26
+
27
+ /** Register/replace the active set of key commands. No-op when unsupported. */
28
+ export function setKeyCommands(commands: KeyCommand[]): void {
29
+ Native?.setKeyCommands(commands.map(c => ({ ...c, modifiers: c.modifiers ?? [] })));
30
+ }
31
+
32
+ /** Remove all registered key commands. No-op when unsupported. */
33
+ export function clearKeyCommands(): void {
34
+ Native?.clearKeyCommands();
35
+ }
36
+
37
+ /** Subscribe to command presses; the callback receives the command id. */
38
+ export function addKeyCommandListener(cb: (id: string) => void): { remove(): void } {
39
+ if (!Native) return { remove() {} };
40
+ const sub = Native.addListener('onKeyCommand', (e: { id: string }) => cb(e.id));
41
+ return { remove: () => sub.remove() };
42
+ }