From e3a6162448e85588ed9290b7b5c4c94127d880e4 Mon Sep 17 00:00:00 2001 From: Janne Savolainen Date: Thu, 30 Mar 2023 09:35:51 +0300 Subject: [PATCH] Introduce feature for assigning keyboard shortcuts Signed-off-by: Janne Savolainen --- package-lock.json | 5 +- .../keyboard-shortcuts/.eslintrc.json | 6 + .../keyboard-shortcuts/.prettierrc | 1 + .../keyboard-shortcuts/README.md | 21 ++ .../keyboard-shortcuts/index.ts | 10 + .../keyboard-shortcuts/jest.config.js | 1 + .../keyboard-shortcuts/package.json | 46 ++++ .../keyboard-shortcuts.test.tsx.snap | 19 ++ .../keyboard-shortcuts/src/feature.ts | 17 ++ .../src/invoke-shortcut.injectable.ts | 71 ++++++ .../src/keyboard-shortcut-injection-token.ts | 15 ++ .../src/keyboard-shortcut-listener.tsx | 41 ++++ .../src/keyboard-shortcut-scope.tsx | 12 + .../src/keyboard-shortcuts.test.tsx | 209 ++++++++++++++++++ .../keyboard-shortcuts/tsconfig.json | 4 + .../keyboard-shortcuts/webpack.config.js | 1 + 16 files changed, 478 insertions(+), 1 deletion(-) create mode 100644 packages/business-features/keyboard-shortcuts/.eslintrc.json create mode 100644 packages/business-features/keyboard-shortcuts/.prettierrc create mode 100644 packages/business-features/keyboard-shortcuts/README.md create mode 100644 packages/business-features/keyboard-shortcuts/index.ts create mode 100644 packages/business-features/keyboard-shortcuts/jest.config.js create mode 100644 packages/business-features/keyboard-shortcuts/package.json create mode 100644 packages/business-features/keyboard-shortcuts/src/__snapshots__/keyboard-shortcuts.test.tsx.snap create mode 100644 packages/business-features/keyboard-shortcuts/src/feature.ts create mode 100644 packages/business-features/keyboard-shortcuts/src/invoke-shortcut.injectable.ts create mode 100644 packages/business-features/keyboard-shortcuts/src/keyboard-shortcut-injection-token.ts create mode 100644 packages/business-features/keyboard-shortcuts/src/keyboard-shortcut-listener.tsx create mode 100644 packages/business-features/keyboard-shortcuts/src/keyboard-shortcut-scope.tsx create mode 100644 packages/business-features/keyboard-shortcuts/src/keyboard-shortcuts.test.tsx create mode 100644 packages/business-features/keyboard-shortcuts/tsconfig.json create mode 100644 packages/business-features/keyboard-shortcuts/webpack.config.js 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;