diff --git a/package-lock.json b/package-lock.json
index 1232778a9e..e053ca9add 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4606,6 +4606,10 @@
"resolved": "packages/infrastructure/jest",
"link": true
},
+ "node_modules/@k8slens/keyboard-shortcuts": {
+ "resolved": "packages/business-features/keyboard-shortcuts",
+ "link": true
+ },
"node_modules/@k8slens/legacy-extension-example": {
"resolved": "packages/legacy-extension-example",
"link": true
@@ -37831,7 +37835,6 @@
"packages/business-features/keyboard-shortcuts": {
"name": "@k8slens/keyboard-shortcuts",
"version": "1.0.0-alpha.0",
- "extraneous": true,
"license": "MIT",
"devDependencies": {
"@async-fn/jest": "^1.6.4",
diff --git a/packages/business-features/keyboard-shortcuts/.eslintrc.json b/packages/business-features/keyboard-shortcuts/.eslintrc.json
new file mode 100644
index 0000000000..b15115cb69
--- /dev/null
+++ b/packages/business-features/keyboard-shortcuts/.eslintrc.json
@@ -0,0 +1,6 @@
+{
+ "extends": "@k8slens/eslint-config/eslint",
+ "parserOptions": {
+ "project": "./tsconfig.json"
+ }
+}
diff --git a/packages/business-features/keyboard-shortcuts/.prettierrc b/packages/business-features/keyboard-shortcuts/.prettierrc
new file mode 100644
index 0000000000..edd47b479e
--- /dev/null
+++ b/packages/business-features/keyboard-shortcuts/.prettierrc
@@ -0,0 +1 @@
+"@k8slens/eslint-config/prettier"
diff --git a/packages/business-features/keyboard-shortcuts/README.md b/packages/business-features/keyboard-shortcuts/README.md
new file mode 100644
index 0000000000..25cc2f8cdb
--- /dev/null
+++ b/packages/business-features/keyboard-shortcuts/README.md
@@ -0,0 +1,21 @@
+# @k8slens/keyboard-shortcuts
+
+This Feature enables keyboard shortcuts in Lens
+
+# Usage
+
+```bash
+$ npm install @k8slens/keyboard-shortcuts
+```
+
+```typescript
+import { keyboardShortcutsFeature } from "@k8slens/keyboard-shortcuts";
+import { registerFeature } from "@k8slens/feature-core";
+import { createContainer } from "@ogre-tools/injectable";
+
+const di = createContainer("some-container");
+
+registerFeature(di, keyboardShortcutsFeature);
+```
+
+## Extendability
diff --git a/packages/business-features/keyboard-shortcuts/index.ts b/packages/business-features/keyboard-shortcuts/index.ts
new file mode 100644
index 0000000000..fc3337dbac
--- /dev/null
+++ b/packages/business-features/keyboard-shortcuts/index.ts
@@ -0,0 +1,10 @@
+export { KeyboardShortcutListener } from "./src/keyboard-shortcut-listener";
+export type { KeyboardShortcutListenerProps } from "./src/keyboard-shortcut-listener";
+
+export { KeyboardShortcutScope } from "./src/keyboard-shortcut-scope";
+export type { KeyboardShortcutScopeProps } from "./src/keyboard-shortcut-scope";
+
+export { keyboardShortcutInjectionToken } from "./src/keyboard-shortcut-injection-token";
+export type { Binding, KeyboardShortcut } from "./src/keyboard-shortcut-injection-token";
+
+export { keyboardShortcutsFeature } from "./src/feature";
diff --git a/packages/business-features/keyboard-shortcuts/jest.config.js b/packages/business-features/keyboard-shortcuts/jest.config.js
new file mode 100644
index 0000000000..38d54ab7b6
--- /dev/null
+++ b/packages/business-features/keyboard-shortcuts/jest.config.js
@@ -0,0 +1 @@
+module.exports = require("@k8slens/jest").monorepoPackageConfig(__dirname).configForReact;
diff --git a/packages/business-features/keyboard-shortcuts/package.json b/packages/business-features/keyboard-shortcuts/package.json
new file mode 100644
index 0000000000..d5b31044a3
--- /dev/null
+++ b/packages/business-features/keyboard-shortcuts/package.json
@@ -0,0 +1,46 @@
+{
+ "name": "@k8slens/keyboard-shortcuts",
+ "private": false,
+ "version": "1.0.0-alpha.0",
+ "description": "Keyboard shortcuts for Lens",
+ "type": "commonjs",
+ "files": [
+ "dist"
+ ],
+ "publishConfig": {
+ "access": "public",
+ "registry": "https://registry.npmjs.org/"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/lensapp/lens.git"
+ },
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "author": {
+ "name": "OpenLens Authors",
+ "email": "info@k8slens.dev"
+ },
+ "license": "MIT",
+ "homepage": "https://github.com/lensapp/lens",
+ "scripts": {
+ "build": "webpack",
+ "dev": "webpack --mode=development --watch",
+ "test:unit": "jest --coverage --runInBand",
+ "lint": "lens-lint",
+ "lint:fix": "lens-lint --fix"
+ },
+ "peerDependencies": {
+ "@k8slens/feature-core": "^6.5.0-alpha.0",
+ "@k8slens/application": "6.5.0-alpha.2",
+ "@ogre-tools/injectable": "^15.1.2",
+ "@ogre-tools/injectable-extension-for-auto-registration": "^15.1.2",
+ "@ogre-tools/fp": "^15.1.2",
+ "lodash": "^4.17.21"
+ },
+ "devDependencies": {
+ "@async-fn/jest": "^1.6.4",
+ "@k8slens/eslint-config": "6.5.0-alpha.1",
+ "@k8slens/react-testing-library-discovery": "^1.0.0-alpha.0"
+ }
+}
diff --git a/packages/business-features/keyboard-shortcuts/src/__snapshots__/keyboard-shortcuts.test.tsx.snap b/packages/business-features/keyboard-shortcuts/src/__snapshots__/keyboard-shortcuts.test.tsx.snap
new file mode 100644
index 0000000000..21c6d79944
--- /dev/null
+++ b/packages/business-features/keyboard-shortcuts/src/__snapshots__/keyboard-shortcuts.test.tsx.snap
@@ -0,0 +1,19 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`keyboard-shortcuts when rendered renders 1`] = `
+
+
+
+`;
diff --git a/packages/business-features/keyboard-shortcuts/src/feature.ts b/packages/business-features/keyboard-shortcuts/src/feature.ts
new file mode 100644
index 0000000000..5595e3a34e
--- /dev/null
+++ b/packages/business-features/keyboard-shortcuts/src/feature.ts
@@ -0,0 +1,17 @@
+import { getFeature } from "@k8slens/feature-core";
+import { autoRegister } from "@ogre-tools/injectable-extension-for-auto-registration";
+import { applicationFeature } from "@k8slens/application";
+
+export const keyboardShortcutsFeature = getFeature({
+ id: "keyboard-shortcuts",
+
+ register: (di) => {
+ autoRegister({
+ di,
+ targetModule: module,
+ getRequireContexts: () => [require.context("./", true, /\.injectable\.(ts|tsx)$/)],
+ });
+ },
+
+ dependencies: [applicationFeature],
+});
diff --git a/packages/business-features/keyboard-shortcuts/src/invoke-shortcut.injectable.ts b/packages/business-features/keyboard-shortcuts/src/invoke-shortcut.injectable.ts
new file mode 100644
index 0000000000..00c7734a4e
--- /dev/null
+++ b/packages/business-features/keyboard-shortcuts/src/invoke-shortcut.injectable.ts
@@ -0,0 +1,71 @@
+import { pipeline } from "@ogre-tools/fp";
+import { filter, isString } from "lodash/fp";
+import { getInjectable } from "@ogre-tools/injectable";
+import {
+ Binding,
+ KeyboardShortcut,
+ keyboardShortcutInjectionToken,
+} from "./keyboard-shortcut-injection-token";
+
+export type InvokeShortcut = (event: KeyboardEvent) => void;
+
+const toShortcutsWithMatchingScope = (shortcut: KeyboardShortcut) => {
+ const activeScopeElement = document.activeElement?.closest("[data-keyboard-shortcut-scope]");
+
+ if (!activeScopeElement) {
+ const shortcutIsRootLevel = !shortcut.scope;
+
+ return shortcutIsRootLevel;
+ }
+
+ const castedActiveScopeElementHtml = activeScopeElement as HTMLDivElement;
+
+ // eslint-disable-next-line xss/no-mixed-html
+ const activeScope = castedActiveScopeElementHtml.dataset.keyboardShortcutScope;
+
+ return shortcut.scope === activeScope;
+};
+
+const toBindingWithDefaults = (binding: Binding) =>
+ isString(binding)
+ ? { code: binding, shift: false, ctrl: false, altOrOption: false, meta: false }
+ : { ctrl: false, shift: false, altOrOption: false, meta: false, ...binding };
+
+const toShortcutsWithMatchingBinding = (event: KeyboardEvent) => (shortcut: KeyboardShortcut) => {
+ const binding = toBindingWithDefaults(shortcut.binding);
+
+ const shiftModifierMatches = binding.shift === event.shiftKey;
+ const ctrlModifierMatches = binding.ctrl === event.ctrlKey;
+ const altModifierMatches = binding.altOrOption === event.altKey;
+ const metaModifierMatches = binding.meta === event.metaKey;
+
+ return (
+ event.code === binding.code &&
+ shiftModifierMatches &&
+ ctrlModifierMatches &&
+ altModifierMatches &&
+ metaModifierMatches
+ );
+};
+
+const invokeShortcutInjectable = getInjectable({
+ id: "invoke-shortcut",
+
+ instantiate: (di): InvokeShortcut => {
+ const getShortcuts = () => di.injectMany(keyboardShortcutInjectionToken);
+
+ return (event) => {
+ const shortcutsToInvoke = pipeline(
+ getShortcuts(),
+ filter(toShortcutsWithMatchingBinding(event)),
+ filter(toShortcutsWithMatchingScope),
+ );
+
+ if (shortcutsToInvoke.length) {
+ shortcutsToInvoke.forEach((shortcut) => shortcut.invoke());
+ }
+ };
+ },
+});
+
+export default invokeShortcutInjectable;
diff --git a/packages/business-features/keyboard-shortcuts/src/keyboard-shortcut-injection-token.ts b/packages/business-features/keyboard-shortcuts/src/keyboard-shortcut-injection-token.ts
new file mode 100644
index 0000000000..299775bbe3
--- /dev/null
+++ b/packages/business-features/keyboard-shortcuts/src/keyboard-shortcut-injection-token.ts
@@ -0,0 +1,15 @@
+import { getInjectionToken } from "@ogre-tools/injectable";
+
+export type Binding =
+ | string
+ | { code: string; shift?: boolean; ctrl?: boolean; altOrOption?: boolean; meta?: boolean };
+
+export type KeyboardShortcut = {
+ binding: Binding;
+ invoke: () => void;
+ scope?: string;
+};
+
+export const keyboardShortcutInjectionToken = getInjectionToken({
+ id: "keyboard-shortcut-injection-token",
+});
diff --git a/packages/business-features/keyboard-shortcuts/src/keyboard-shortcut-listener.tsx b/packages/business-features/keyboard-shortcuts/src/keyboard-shortcut-listener.tsx
new file mode 100644
index 0000000000..5f2479d059
--- /dev/null
+++ b/packages/business-features/keyboard-shortcuts/src/keyboard-shortcut-listener.tsx
@@ -0,0 +1,41 @@
+import { withInjectables } from "@ogre-tools/injectable-react";
+import React, { useEffect } from "react";
+
+import invokeShortcutInjectable, { InvokeShortcut } from "./invoke-shortcut.injectable";
+
+export interface KeyboardShortcutListenerProps {
+ children: React.ReactNode;
+}
+
+interface Dependencies {
+ invokeShortcut: InvokeShortcut;
+}
+
+const NonInjectedKeyboardShortcutListener = ({
+ children,
+ invokeShortcut,
+}: KeyboardShortcutListenerProps & Dependencies) => {
+ useEffect(() => {
+ document.addEventListener("keydown", invokeShortcut);
+
+ return () => {
+ document.removeEventListener("keydown", invokeShortcut);
+ };
+ });
+
+ return <>{children}>;
+};
+
+export const KeyboardShortcutListener = withInjectables<
+ Dependencies,
+ KeyboardShortcutListenerProps
+>(
+ NonInjectedKeyboardShortcutListener,
+
+ {
+ getProps: (di, props) => ({
+ invokeShortcut: di.inject(invokeShortcutInjectable),
+ ...props,
+ }),
+ },
+);
diff --git a/packages/business-features/keyboard-shortcuts/src/keyboard-shortcut-scope.tsx b/packages/business-features/keyboard-shortcuts/src/keyboard-shortcut-scope.tsx
new file mode 100644
index 0000000000..0e725cc4d4
--- /dev/null
+++ b/packages/business-features/keyboard-shortcuts/src/keyboard-shortcut-scope.tsx
@@ -0,0 +1,12 @@
+import React from "react";
+
+export interface KeyboardShortcutScopeProps {
+ id: string;
+ children: React.ReactNode;
+}
+
+export const KeyboardShortcutScope = ({ id, children }: KeyboardShortcutScopeProps) => (
+
+ {children}
+
+);
diff --git a/packages/business-features/keyboard-shortcuts/src/keyboard-shortcuts.test.tsx b/packages/business-features/keyboard-shortcuts/src/keyboard-shortcuts.test.tsx
new file mode 100644
index 0000000000..750573b396
--- /dev/null
+++ b/packages/business-features/keyboard-shortcuts/src/keyboard-shortcuts.test.tsx
@@ -0,0 +1,209 @@
+import userEvent from "@testing-library/user-event";
+import type { RenderResult } from "@testing-library/react";
+import { createContainer, DiContainer, getInjectable } from "@ogre-tools/injectable";
+import { registerInjectableReact } from "@ogre-tools/injectable-react";
+import { keyboardShortcutInjectionToken } from "./keyboard-shortcut-injection-token";
+import { registerFeature } from "@k8slens/feature-core";
+import { keyboardShortcutsFeature } from "./feature";
+import { renderFor } from "@k8slens/test-utils";
+import React from "react";
+import { KeyboardShortcutScope } from "./keyboard-shortcut-scope";
+import { Discover, discoverFor } from "@k8slens/react-testing-library-discovery";
+import { KeyboardShortcutListener } from "./keyboard-shortcut-listener";
+
+const TestComponent = () => (
+
+
+
+);
+
+describe("keyboard-shortcuts", () => {
+ let di: DiContainer;
+ let invokeMock: jest.Mock;
+
+ beforeEach(() => {
+ di = createContainer("irrelevant");
+
+ registerInjectableReact(di);
+
+ registerFeature(di, keyboardShortcutsFeature);
+
+ invokeMock = jest.fn();
+
+ const someKeyboardShortcutInjectable = getInjectable({
+ id: "some-keyboard-shortcut",
+
+ instantiate: () => ({
+ binding: "Escape",
+ invoke: () => invokeMock("esc-in-root"),
+ }),
+
+ injectionToken: keyboardShortcutInjectionToken,
+ });
+
+ const someScopedKeyboardShortcutInjectable = getInjectable({
+ id: "some-scoped-keyboard-shortcut",
+
+ instantiate: () => ({
+ binding: "Escape",
+ invoke: () => invokeMock("esc-in-scope"),
+ scope: "some-scope",
+ }),
+
+ injectionToken: keyboardShortcutInjectionToken,
+ });
+
+ const someOtherKeyboardShortcutInjectable = getInjectable({
+ id: "some-other-keyboard-shortcut",
+
+ instantiate: () => ({
+ binding: "something-else-than-esc",
+ invoke: () => invokeMock("something-else-than-esc"),
+ }),
+
+ injectionToken: keyboardShortcutInjectionToken,
+ });
+
+ di.register(
+ someKeyboardShortcutInjectable,
+ someScopedKeyboardShortcutInjectable,
+ someOtherKeyboardShortcutInjectable,
+ );
+ });
+
+ describe("when rendered", () => {
+ let rendered: RenderResult;
+ let discover: Discover;
+
+ beforeEach(async () => {
+ const render = renderFor(di);
+
+ rendered = render();
+
+ discover = discoverFor(() => rendered);
+ });
+
+ it("renders", () => {
+ expect(rendered.baseElement).toMatchSnapshot();
+ });
+
+ it("given focus is in the body, when pressing the shortcut, calls shortcut in global scope", () => {
+ userEvent.keyboard("{Escape}");
+
+ expect(invokeMock.mock.calls).toEqual([["esc-in-root"]]);
+ });
+
+ it("given focus inside a nested scope, when pressing the shortcut, calls only the callback for the scope", () => {
+ const result = discover.getSingleElement("keyboard-shortcut-scope", "some-scope");
+
+ const discoveredHtml = result.discovered as HTMLDivElement;
+
+ discoveredHtml.focus();
+
+ userEvent.keyboard("{Escape}");
+
+ expect(invokeMock.mock.calls).toEqual([["esc-in-scope"]]);
+ });
+
+ it("given conflicting shortcut, when pressing the shortcut, calls both callbacks", () => {
+ const conflictingShortcutInjectable = getInjectable({
+ id: "some-conflicting-keyboard-shortcut",
+
+ instantiate: () => ({
+ binding: "Escape",
+ invoke: () => invokeMock("conflicting-esc-in-root"),
+ }),
+
+ injectionToken: keyboardShortcutInjectionToken,
+ });
+
+ di.register(conflictingShortcutInjectable);
+
+ userEvent.keyboard("{Escape}");
+
+ expect(invokeMock.mock.calls).toEqual([["esc-in-root"], ["conflicting-esc-in-root"]]);
+ });
+
+ [
+ {
+ scenario: "given shortcut without modifiers, when shortcut is pressed, calls the callback",
+ binding: { code: "Escape" },
+ keyboard: "{Escape}",
+ shouldCallCallback: true,
+ },
+ {
+ scenario:
+ "given shortcut without modifiers, when shortcut is pressed but with modifier, does not call the callback",
+ binding: { code: "F1" },
+ keyboard: "{Meta>}[F1]",
+ shouldCallCallback: false,
+ },
+ {
+ scenario: "given shortcut with meta modifier, when shortcut is pressed, calls the callback",
+
+ binding: { meta: true, code: "F1" },
+ keyboard: "{Meta>}[F1]",
+ shouldCallCallback: true,
+ },
+ {
+ scenario:
+ "given shortcut with shift modifier, when shortcut is pressed, calls the callback",
+
+ binding: { shift: true, code: "F1" },
+ keyboard: "{Shift>}[F1]",
+ shouldCallCallback: true,
+ },
+ {
+ scenario: "given shortcut with alt modifier, when shortcut is pressed, calls the callback",
+ binding: { altOrOption: true, code: "F1" },
+ keyboard: "{Alt>}[F1]",
+ shouldCallCallback: true,
+ },
+ {
+ scenario: "given shortcut with ctrl modifier, when shortcut is pressed, calls the callback",
+ binding: { ctrl: true, code: "F1" },
+ keyboard: "{Control>}[F1]",
+ shouldCallCallback: true,
+ },
+ {
+ scenario: "given shortcut with all modifiers, when shortcut is pressed, calls the callback",
+
+ binding: { ctrl: true, altOrOption: true, shift: true, meta: true, code: "F1" },
+ keyboard: "{Meta>}{Shift>}{Alt>}{Control>}[F1]",
+ shouldCallCallback: true,
+ },
+ ].forEach(({ binding, keyboard, scenario, shouldCallCallback }) => {
+ // eslint-disable-next-line jest/valid-title
+ it(scenario, () => {
+ const shortcutInjectable = getInjectable({
+ id: "shortcut",
+
+ instantiate: () => ({
+ binding,
+ invoke: invokeMock,
+ }),
+
+ injectionToken: keyboardShortcutInjectionToken,
+ });
+
+ di.register(shortcutInjectable);
+
+ userEvent.keyboard(keyboard);
+
+ if (shouldCallCallback) {
+ // eslint-disable-next-line jest/no-conditional-expect
+ expect(invokeMock).toHaveBeenCalled();
+ } else {
+ // eslint-disable-next-line jest/no-conditional-expect
+ expect(invokeMock).not.toHaveBeenCalled();
+ }
+ });
+ });
+ });
+});
diff --git a/packages/business-features/keyboard-shortcuts/tsconfig.json b/packages/business-features/keyboard-shortcuts/tsconfig.json
new file mode 100644
index 0000000000..9e140d79da
--- /dev/null
+++ b/packages/business-features/keyboard-shortcuts/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "@k8slens/typescript/config/base.json",
+ "include": ["**/*.ts", "**/*.tsx"],
+}
diff --git a/packages/business-features/keyboard-shortcuts/webpack.config.js b/packages/business-features/keyboard-shortcuts/webpack.config.js
new file mode 100644
index 0000000000..1cda407f5a
--- /dev/null
+++ b/packages/business-features/keyboard-shortcuts/webpack.config.js
@@ -0,0 +1 @@
+module.exports = require("@k8slens/webpack").configForReact;