Expose api for declaring keyboard shortcuts (#2139)

In recent weeks we have created an api for declaring keyboard shortcuts and tested its usage in internal modules. After successfully verifying it, we are now exposing it for plugins to use. The api has also received some tweaks in the process to make it more flexible, such as allowing bound shortcuts not to appear in the documentation dialog or allowing shortcuts to explicitly allow event bubbling.
This commit is contained in:
Konstantin Schaper
2022-10-21 08:47:42 +02:00
committed by GitHub
parent 8db2e76ecc
commit da70fc0946
22 changed files with 172 additions and 70 deletions

View File

@@ -0,0 +1,2 @@
- type: changed
description: Expose api for declaring keyboard shortcuts ([#2139](https://github.com/scm-manager/scm-manager/pull/2139))

View File

@@ -82,6 +82,7 @@ export { default as CardColumn } from "./CardColumn";
export { default as CardColumnSmall } from "./CardColumnSmall";
export { default as CommaSeparatedList } from "./CommaSeparatedList";
export { SplitAndReplace, Replacement } from "@scm-manager/ui-text";
export { useShortcut } from "@scm-manager/ui-shortcuts";
export { regExpPattern as changesetShortLinkRegex } from "./markdown/remarkChangesetShortLinkParser";
export * from "./markdown/PluginApi";
export * from "./devices";

View File

@@ -25,17 +25,20 @@ import React, { FC, ReactNode } from "react";
import { ColumnProps } from "./types";
type Props = ColumnProps & {
children: (row: any, columnIndex: number) => ReactNode;
children: (row: any, columnIndex: number, rowIndex: number) => ReactNode;
};
const Column: FC<Props> = ({ row, columnIndex, children }) => {
const Column: FC<Props> = ({ row, columnIndex, rowIndex, children }) => {
if (row === undefined) {
throw new Error("missing row, use column only as child of Table");
}
if (columnIndex === undefined) {
throw new Error("missing row, use column only as child of Table");
}
return <>{children(row, columnIndex)}</>;
if (rowIndex === undefined) {
throw new Error("missing row, use column only as child of Table");
}
return <>{children(row, columnIndex, rowIndex)}</>;
};
export default Column;

View File

@@ -63,7 +63,7 @@ const Table: FC<Props> = ({ data, sortable, children, emptyMessage, className })
{React.Children.map(children, (child, columnIndex) => {
const { className: columnClassName, ...childProperties } = (child as ReactElement).props;
return (
<td className={columnClassName}>{React.cloneElement((child as ReactElement), { ...childProperties, columnIndex, row })}</td>
<td className={columnClassName}>{React.cloneElement((child as ReactElement), { ...childProperties, columnIndex, rowIndex, row })}</td>
);
})}
</tr>

View File

@@ -30,6 +30,7 @@ export type ColumnProps = {
header: ReactNode;
row?: any;
columnIndex?: number;
rowIndex?: number;
createComparator?: (props: any, columnIndex: number) => Comparator;
ascendingIcon?: string;
descendingIcon?: string;

View File

@@ -0,0 +1,42 @@
{
"name": "@scm-manager/ui-shortcuts",
"version": "2.39.2-SNAPSHOT",
"license": "MIT",
"private": true,
"main": "build/index.js",
"module": "build/index.mjs",
"types": "build/index.d.ts",
"files": [
"build"
],
"scripts": {
"build": "tsup ./src/index.ts -d build --format esm,cjs --dts",
"lint": "eslint src",
"typecheck": "tsc"
},
"peerDependencies": {
"react": "17"
},
"dependencies": {
"mousetrap": "1.6.5"
},
"devDependencies": {
"@types/mousetrap": "1.6.5",
"@scm-manager/babel-preset": "^2.13.1",
"@scm-manager/prettier-config": "^2.10.1",
"@scm-manager/eslint-config": "^2.16.0",
"@scm-manager/tsconfig": "^2.13.0"
},
"babel": {
"presets": [
"@scm-manager/babel-preset"
]
},
"prettier": "@scm-manager/prettier-config",
"eslintConfig": {
"extends": "@scm-manager/eslint-config"
},
"publishConfig": {
"access": "public"
}
}

View File

@@ -0,0 +1,27 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
export { default as useShortcut } from "./useShortcut";
export { default as useShortcutDocs, ShortcutDocsContextProvider } from "./useShortcutDocs";
export { default as usePauseShortcuts } from "./usePauseShortcuts";

View File

@@ -24,6 +24,7 @@
import { useEffect } from "react";
import Mousetrap from "mousetrap";
import "mousetrap/plugins/pause/mousetrap-pause.min.js";
/**
* Pauses or unpauses all shortcuts provided by {@link useShortcut}.

View File

@@ -33,6 +33,13 @@ export type UseShortcutOptions = {
* @default true
*/
active?: boolean;
/**
* The translated description used for the shortcut documentation.
*
* If no description is supplied, there will be no entry in the shortcut summary table.
*/
description?: string;
};
/**
@@ -62,8 +69,7 @@ export type UseShortcutOptions = {
* Please also refer to the examples.
*
* @param key The keycode combination that triggers the callback
* @param callback The function that is executed when the key combination is pressed
* @param description The translated description used for the shortcut documentation
* @param callback The function that is executed when the key combination is pressed, returning `true` additionally executes default browser behaviour
* @param options Whether the shortcut is currently active, defaults to true
* @example useShortcut("a b", ...)
* @example useShortcut("ctrl+shift+k", ...)
@@ -72,29 +78,33 @@ export type UseShortcutOptions = {
*/
export default function useShortcut(
key: string,
callback: (e: KeyboardEvent) => void,
description: string,
callback: (e: KeyboardEvent) => void | boolean,
options?: UseShortcutOptions
) {
const { add, remove } = useShortcutDocs();
useEffect(() => {
const active = !options || options.active === undefined || options.active;
const active = options?.active ?? true;
const description = options?.description;
if (active) {
add(key, description);
if (description) {
add(key, description);
}
Mousetrap.bind(key, (e) => {
callback(e);
const callbackResult = callback(e);
/*
* Returning false disables default event behaviour and stops event bubbling.
* Returning false by default disables standard browser event behaviour and stops event bubbling.
* Otherwise, a shortcut that moves focus to an input field would cause the key to be entered into the input at the same time.
* We could move the decision to the callback, but this behaviour is an implementation detail of Mousetrap which we would like to hide.
* Shortcuts can explicitly return `true` to re-enable event bubbling and browser behaviour.
*/
return false;
return callbackResult ?? false;
});
}
return () => {
remove(key);
if (description) {
remove(key);
}
Mousetrap.unbind(key);
};
}, [key, callback, add, remove, options, description]);
}, [key, callback, add, remove, options]);
}

View File

@@ -22,7 +22,8 @@
* SOFTWARE.
*/
import React, { FC, useContext, useMemo, useRef } from "react";
import type { FC } from "react";
import React, { useContext, useMemo, useRef } from "react";
export type ShortcutDocsContextType = {
docs: Readonly<Record<string, string>>;

View File

@@ -0,0 +1,6 @@
{
"extends": "@scm-manager/tsconfig",
"include": [
"./src"
]
}

View File

@@ -10,6 +10,7 @@
"@scm-manager/ui-modules": "2.39.2-SNAPSHOT",
"@scm-manager/ui-syntaxhighlighting": "2.39.2-SNAPSHOT",
"@scm-manager/ui-text": "2.39.2-SNAPSHOT",
"@scm-manager/ui-shortcuts": "2.39.2-SNAPSHOT",
"@scm-manager/ui-legacy": "2.39.2-SNAPSHOT",
"classnames": "^2.2.5",
"history": "^4.10.1",
@@ -27,7 +28,6 @@
"string_score": "^0.1.22",
"styled-components": "^5.3.5",
"systemjs": "0.21.6",
"mousetrap": "^1.6.5",
"ua-parser-js": "^1.0.2"
},
"scripts": {
@@ -50,7 +50,6 @@
"@types/react-router-dom": "^5.3.3",
"@types/styled-components": "^5.1.25",
"@types/systemjs": "^0.20.6",
"@types/mousetrap": "^1.6.9",
"@types/ua-parser-js": "^0.7.36",
"fetch-mock": "^7.5.1",
"react-test-renderer": "^17.0.1"

View File

@@ -540,10 +540,10 @@
}
},
"shortcuts": {
"info": "Info",
"branches": "Branches",
"tags": "Tags",
"code": "Code",
"settings": "Einstellungen"
"info": "Wechsel zur Repository-Info",
"branches": "Wechsel zu den Branches",
"tags": "Wechsel zu den Tags",
"code": "Wechsel zum Code",
"settings": "Wechsel zu den Einstellungen"
}
}

View File

@@ -547,10 +547,10 @@
}
},
"shortcuts": {
"info": "Info",
"branches": "Branches",
"tags": "Tags",
"code": "Code",
"settings": "Settings"
"info": "Switch to repository info",
"branches": "Switch to branches",
"tags": "Switch to tags",
"code": "Switch to code",
"settings": "Switch to settings"
}
}

View File

@@ -30,7 +30,7 @@ import { useIndex, useSubject } from "@scm-manager/ui-api";
import { ErrorPage, Footer, Header, Loading } from "@scm-manager/ui-components";
import { binder } from "@scm-manager/ui-extensions";
import usePauseShortcutsWhenModalsActive from "../shortcuts/usePauseShortcutsWhenModalsActive";
import useShortcut from "../shortcuts/useShortcut";
import { useShortcut } from "@scm-manager/ui-shortcuts";
import Login from "./Login";
import NavigationBar from "./NavigationBar";
import styled from "styled-components";
@@ -41,6 +41,7 @@ const AppWrapper = styled.div`
display: flex;
flex-direction: column;
`;
const App: FC = () => {
const { data: index } = useIndex();
const { isLoading, error, isAuthenticated, isAnonymous, me } = useSubject();
@@ -49,17 +50,21 @@ const App: FC = () => {
const history = useHistory();
useShortcut("option+r", () => history.push("/repos/"), t("shortcuts.repositories"), {
useShortcut("option+r", () => history.push("/repos/"), {
active: !!index?._links["repositories"],
description: t("shortcuts.repositories"),
});
useShortcut("option+u", () => history.push("/users/"), t("shortcuts.users"), {
useShortcut("option+u", () => history.push("/users/"), {
active: !!index?._links["users"],
description: t("shortcuts.users"),
});
useShortcut("option+g", () => history.push("/groups/"), t("shortcuts.groups"), {
useShortcut("option+g", () => history.push("/groups/"), {
active: !!index?._links["groups"],
description: t("shortcuts.groups"),
});
useShortcut("option+a", () => history.push("/admin/"), t("shortcuts.admin"), {
useShortcut("option+a", () => history.push("/admin/"), {
active: !!index?._links["config"],
description: t("shortcuts.admin"),
});
if (!index) {
@@ -85,7 +90,7 @@ const App: FC = () => {
}
return (
<AppWrapper className={classNames("App", { "has-navbar-fixed-top": authenticatedOrAnonymous})}>
<AppWrapper className={classNames("App", { "has-navbar-fixed-top": authenticatedOrAnonymous })}>
<Header authenticated={authenticatedOrAnonymous}>
<NavigationBar links={index._links} />
</Header>

View File

@@ -23,7 +23,7 @@
*/
import React, { FC, useState } from "react";
import App from "./App";
import { ActiveModalCountContextProvider, ErrorBoundary, Header, Loading } from "@scm-manager/ui-components";
import { ErrorBoundary, Header, Loading } from "@scm-manager/ui-components";
import PluginLoader from "./PluginLoader";
import ScrollToTop from "./ScrollToTop";
import IndexErrorPage from "./IndexErrorPage";
@@ -33,7 +33,6 @@ import i18next from "i18next";
import { binder, extensionPoints } from "@scm-manager/ui-extensions";
import InitializationAdminAccountStep from "./InitializationAdminAccountStep";
import InitializationPluginWizardStep from "./InitializationPluginWizardStep";
import { ShortcutDocsContextProvider } from "../shortcuts/useShortcutDocs";
const Index: FC = () => {
const { isLoading, error, data } = useIndex();
@@ -61,15 +60,11 @@ const Index: FC = () => {
return (
<ErrorBoundary fallback={IndexErrorPage}>
<ScrollToTop>
<ShortcutDocsContextProvider>
<ActiveModalCountContextProvider>
<NamespaceAndNameContextProvider>
<PluginLoader link={link} loaded={pluginsLoaded} callback={() => setPluginsLoaded(true)}>
<App />
</PluginLoader>
</NamespaceAndNameContextProvider>
</ActiveModalCountContextProvider>
</ShortcutDocsContextProvider>
<NamespaceAndNameContextProvider>
<PluginLoader link={link} loaded={pluginsLoaded} callback={() => setPluginsLoaded(true)}>
<App />
</PluginLoader>
</NamespaceAndNameContextProvider>
</ScrollToTop>
</ErrorBoundary>
);

View File

@@ -46,7 +46,7 @@ import SyntaxModal from "../search/SyntaxModal";
import SearchErrorNotification from "../search/SearchErrorNotification";
import queryString from "query-string";
import { orderTypes } from "../search/Search";
import useShortcut from "../shortcuts/useShortcut";
import { useShortcut } from "@scm-manager/ui-shortcuts";
const Input = styled.input`
border-radius: 4px !important;
@@ -358,7 +358,9 @@ const OmniSearch: FC = () => {
searchTypes.sort(orderTypes(t));
const id = useCallback(namespaceAndName, []);
useShortcut("/", () => searchInputRef.current?.focus(), t("shortcuts.search"));
useShortcut("/", () => searchInputRef.current?.focus(), {
description: t("shortcuts.search"),
});
const entries = useMemo(() => {
const newEntries = [];

View File

@@ -30,16 +30,13 @@ import i18n from "./i18n";
import { BrowserRouter as Router } from "react-router-dom";
import { urls } from "@scm-manager/ui-components";
import { ActiveModalCountContextProvider, urls } from "@scm-manager/ui-components";
import { binder, extensionPoints } from "@scm-manager/ui-extensions";
import ChangesetShortLink from "./repos/components/changesets/ChangesetShortLink";
import "./tokenExpired";
import { ApiProvider } from "@scm-manager/ui-api";
// Used by useShortcut
import "mousetrap";
// Used by usePauseShortcuts
import "mousetrap/plugins/pause/mousetrap-pause.min";
import { ShortcutDocsContextProvider } from "@scm-manager/ui-shortcuts";
binder.bind<extensionPoints.ChangesetDescriptionTokens>("changeset.description.tokens", ChangesetShortLink);
@@ -51,9 +48,13 @@ if (!root) {
ReactDOM.render(
<ApiProvider>
<I18nextProvider i18n={i18n}>
<Router basename={urls.contextPath}>
<Index />
</Router>
<ShortcutDocsContextProvider>
<ActiveModalCountContextProvider>
<Router basename={urls.contextPath}>
<Index />
</Router>
</ActiveModalCountContextProvider>
</ShortcutDocsContextProvider>
</I18nextProvider>
</ApiProvider>,
root

View File

@@ -62,7 +62,7 @@ import CompareRoot from "../compare/CompareRoot";
import TagRoot from "../tags/container/TagRoot";
import { useIndexLinks, useNamespaceAndNameContext, useRepository } from "@scm-manager/ui-api";
import styled from "styled-components";
import useShortcut from "../../shortcuts/useShortcut";
import { useShortcut } from "@scm-manager/ui-shortcuts";
const TagGroup = styled.span`
& > * {
@@ -111,17 +111,24 @@ const RepositoryRoot = () => {
return "";
}, [repository]);
useShortcut("g i", () => history.push(`${url}/info`), t("shortcuts.info"));
useShortcut("g b", () => history.push(`${url}/branches/`), t("shortcuts.branches"), {
useShortcut("g i", () => history.push(`${url}/info`), {
description: t("shortcuts.info"),
});
useShortcut("g b", () => history.push(`${url}/branches/`), {
active: !!repository?._links["branches"],
description: t("shortcuts.branches"),
});
useShortcut("g t", () => history.push(`${url}/tags/`), t("shortcuts.tags"), {
useShortcut("g t", () => history.push(`${url}/tags/`), {
active: !!repository?._links["tags"],
description: t("shortcuts.tags"),
});
useShortcut("g c", () => history.push(evaluateDestinationForCodeLink()), t("shortcuts.code"), {
useShortcut("g c", () => history.push(evaluateDestinationForCodeLink()), {
active: !!repository?._links[codeLinkname],
description: t("shortcuts.code"),
});
useShortcut("g s", () => history.push(`${url}/settings/general`), {
description: t("shortcuts.settings"),
});
useShortcut("g s", () => history.push(`${url}/settings/general`), t("shortcuts.settings"));
useEffect(() => {
if (repository) {

View File

@@ -22,9 +22,8 @@
* SOFTWARE.
*/
import React, { useState } from "react";
import useShortcutDocs from "./useShortcutDocs";
import { useShortcutDocs, useShortcut } from "@scm-manager/ui-shortcuts";
import { Column, Modal, Table } from "@scm-manager/ui-components";
import useShortcut from "./useShortcut";
import { useTranslation } from "react-i18next";
import classNames from "classnames";
import splitKeyCombination from "./splitKeyCombination";

View File

@@ -23,7 +23,7 @@
*/
import { useActiveModals } from "@scm-manager/ui-components";
import usePauseShortcuts from "./usePauseShortcuts";
import { usePauseShortcuts } from "@scm-manager/ui-shortcuts";
/**
* Keyboard shortcuts are not active in modals using {@link useActiveModals} to determine whether any modals are open.

View File

@@ -3889,10 +3889,10 @@
resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.2.tgz#ee771e2ba4b3dc5b372935d549fd9617bf345b8c"
integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==
"@types/mousetrap@^1.6.9":
version "1.6.9"
resolved "https://registry.yarnpkg.com/@types/mousetrap/-/mousetrap-1.6.9.tgz#f1ef9adbd1eac3466f21b6988b1c82c633a45340"
integrity sha512-HUAiN65VsRXyFCTicolwb5+I7FM6f72zjMWr+ajGk+YTvzBgXqa2A5U7d+rtsouAkunJ5U4Sb5lNJjo9w+nmXg==
"@types/mousetrap@1.6.5":
version "1.6.5"
resolved "https://registry.yarnpkg.com/@types/mousetrap/-/mousetrap-1.6.5.tgz#e95569aa6273dbe0ed1814f86287547cc06e93c9"
integrity sha512-OwVhKFim9Y/MprzCe4I6a59p31pMy8+LrtP6qS7J0kaOxYmW6VVJPBw5NYm+g7nSbgPUz22FvqU1F1hC5YGTfg==
"@types/node-fetch@^2.5.7":
version "2.6.2"
@@ -13520,7 +13520,7 @@ moo@^0.5.0:
resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.1.tgz#7aae7f384b9b09f620b6abf6f74ebbcd1b65dbc4"
integrity sha512-I1mnb5xn4fO80BH9BLcF0yLypy2UKl+Cb01Fu0hJRkJjlCRtxZMWkTdAtDd5ZqCOxtCkhmRwyI57vWT+1iZ67w==
mousetrap@^1.6.5:
mousetrap@1.6.5:
version "1.6.5"
resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.6.5.tgz#8a766d8c272b08393d5f56074e0b5ec183485bf9"
integrity sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA==