Add keyboard shortcut for global search (#2118)

Enable users to jump to the global search bar by pressing the "/" key from anywhere. Open modals block this shortcut. This PR also introduces a generic system for declaring global shortcuts by utilizing the third-party library mousetrap.
This commit is contained in:
Konstantin Schaper
2022-09-15 14:16:22 +02:00
committed by GitHub
parent 1e72eb52bd
commit af9aaec095
21 changed files with 483 additions and 21 deletions

View File

@@ -0,0 +1,2 @@
- type: added
description: Keyboard shortcut for global search ([#2118](https://github.com/scm-manager/scm-manager/pull/2118))

View File

@@ -30,4 +30,8 @@ Um mehr über die fortgeschrittene Suche zu erfahren, lesen sie unsere [Experten
- Die relevantesten Repositories werden in den Quick Results angezeigt.
- Über die Eingabe-Taste oder den Button "Alle Ergebnisse anzeigen" bekommen Sie Ergebnisse aller durchsuchten Entitäten wie Nutzern oder Gruppen.
- Eine Wildcard für eine beliebige Anzahl an beliebigen Zeichen wird Ihrer Suche standardmäßig angehängt.
- Geben Sie keine Wildcards vor dem Suchbegriff ein!`;
- Geben Sie keine Wildcards vor dem Suchbegriff ein!
### Tastenkombinationen
Sie können von überall aus "/" drücken, um den Tastaturfokus auf die globale Suchleiste bewegen.`;

View File

@@ -30,4 +30,9 @@ To learn about advanced search read our [Expert Search Site](/help/search-syntax
- The most relevant repositories are shown in the quick results.
- Press "enter" or click the "Show all results" button to find more results for all entities like users or groups.
- A multi-character wildcard (*) is added to your search by default.
- Do not enter Wildcards in front of the search!`;
- Do not enter Wildcards in front of the search!
### Shortcuts
You can press "/" from anywhere to move keyboard focus to the global search bar.
`;

View File

@@ -0,0 +1,49 @@
/*
* 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.
*/
import ActiveModalCountContext from "./activeModalCountContext";
import React, { FC, useCallback, useState } from "react";
/**
* A simple counter that allows developers to keep track of how many modals are currently open.
*
* A default provider instance wraps the SCM-Manager so there is no need for plugins to use this component.
*
* Required by {@link useActiveModals}.
*/
const ActiveModalCountContextProvider: FC = ({ children }) => {
const [activeModalCount, setActiveModalCount] = useState(0);
const incrementModalCount = useCallback(() => setActiveModalCount((prev) => prev + 1), []);
const decrementModalCount = useCallback(() => setActiveModalCount((prev) => prev - 1), []);
return (
<ActiveModalCountContext.Provider
value={{ value: activeModalCount, increment: incrementModalCount, decrement: decrementModalCount }}
>
{children}
</ActiveModalCountContext.Provider>
);
};
export default ActiveModalCountContextProvider;

View File

@@ -26,6 +26,7 @@ import { storiesOf } from "@storybook/react";
import { MemoryRouter } from "react-router-dom";
import * as React from "react";
import ConfirmAlert, { confirmAlert } from "./ConfirmAlert";
import ActiveModalCountContext from "./activeModalCountContext";
const body =
"Mind-paralyzing change needed improbability vortex machine sorts sought same theory upending job just allows\n " +
@@ -37,27 +38,36 @@ const buttons = [
{
className: "is-outlined",
label: "Cancel",
onClick: () => null
onClick: () => null,
},
{
label: "Submit"
}
label: "Submit",
},
];
const buttonsWithAutofocus = [
{
label: "Cancel",
onClick: () => null
onClick: () => null,
},
{
className: "is-info",
label: "I should be focused",
autofocus: true
}
autofocus: true,
},
];
const doNothing = () => {
// Do nothing
};
storiesOf("Modal/ConfirmAlert", module)
.addDecorator(story => <MemoryRouter initialEntries={["/"]}>{story()}</MemoryRouter>)
.addDecorator((story) => <MemoryRouter initialEntries={["/"]}>{story()}</MemoryRouter>)
.addDecorator((story) => (
<ActiveModalCountContext.Provider value={{ value: 0, increment: doNothing, decrement: doNothing }}>
{story()}
</ActiveModalCountContext.Provider>
))
.add("Default", () => <ConfirmAlert message={body} title={"Are you sure about that?"} buttons={buttons} />)
.add("WithButton", () => {
const buttonClick = () => {

View File

@@ -34,6 +34,7 @@ import { Button, ButtonGroup } from "../buttons";
import Notification from "../Notification";
import { Autocomplete } from "../index";
import { SelectValue } from "@scm-manager/ui-types";
import ActiveModalCountContext from "./activeModalCountContext";
const TopAndBottomMargin = styled.div`
margin: 0.75rem 0; // only for aesthetic reasons
@@ -75,20 +76,25 @@ const withFormElementsFooter = (
);
const loadSuggestions: (p: string) => Promise<SelectValue[]> = () =>
new Promise(resolve => {
new Promise((resolve) => {
setTimeout(() => {
resolve([
{ value: { id: "trillian", displayName: "Tricia McMillan" }, label: "Tricia McMillan" },
{ value: { id: "zaphod", displayName: "Zaphod Beeblebrox" }, label: "Zaphod Beeblebrox" },
{ value: { id: "ford", displayName: "Ford Prefect" }, label: "Ford Prefect" },
{ value: { id: "dent", displayName: "Arthur Dent" }, label: "Arthur Dent" },
{ value: { id: "marvin", displayName: "Marvin" }, label: "Marvin the Paranoid Android " }
{ value: { id: "marvin", displayName: "Marvin" }, label: "Marvin the Paranoid Android " },
]);
});
});
storiesOf("Modal/Modal", module)
.addDecorator(story => <MemoryRouter initialEntries={["/"]}>{story()}</MemoryRouter>)
.addDecorator((story) => <MemoryRouter initialEntries={["/"]}>{story()}</MemoryRouter>)
.addDecorator((story) => (
<ActiveModalCountContext.Provider value={{ value: 0, increment: doNothing, decrement: doNothing }}>
{story()}
</ActiveModalCountContext.Provider>
))
.add("Default", () => (
<NonCloseableModal>
<p>{text}</p>

View File

@@ -26,6 +26,7 @@ import classNames from "classnames";
import styled from "styled-components";
import { Dialog } from "@headlessui/react";
import { devices } from "../devices";
import useRegisterModal from "./useRegisterModal";
type ModalSize = "S" | "M" | "L";
@@ -67,6 +68,7 @@ export const Modal: FC<Props> = ({
initialFocusRef,
overflowVisible
}) => {
useRegisterModal(active);
const closeButtonRef = useRef<HTMLButtonElement | null>(null);
let showFooter = null;

View File

@@ -0,0 +1,29 @@
/*
* 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.
*/
import React from "react";
export type ModalStateContextType = { value: number; increment: () => void; decrement: () => void };
export default React.createContext<ModalStateContextType>({} as ModalStateContextType);

View File

@@ -27,3 +27,5 @@
export { default as ConfirmAlert, confirmAlert } from "./ConfirmAlert";
export { default as Modal } from "./Modal";
export { default as FullscreenModal } from "./FullscreenModal";
export { default as ActiveModalCountContextProvider } from "./ActiveModalCountContextProvider";
export { default as useActiveModals } from "./useActiveModals";

View File

@@ -0,0 +1,36 @@
/*
* 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.
*/
import { useContext } from "react";
import ActiveModalCountContext from "./activeModalCountContext";
/**
* @returns Whether any modals are currently open
*/
const useActiveModals = () => {
const { value } = useContext(ActiveModalCountContext);
return value > 0;
};
export default useActiveModals;

View File

@@ -0,0 +1,88 @@
/*
* 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.
*/
import { renderHook } from "@testing-library/react-hooks";
import React, { FC } from "react";
import ActiveModalCountContext, { ModalStateContextType } from "./activeModalCountContext";
import useRegisterModal from "./useRegisterModal";
const createWrapper =
(context: ModalStateContextType): FC =>
({ children }) => {
return <ActiveModalCountContext.Provider value={context}>{children}</ActiveModalCountContext.Provider>;
};
describe("useRegisterModal", () => {
it("should increment and not decrement on active registration", () => {
const increment = jest.fn();
const decrement = jest.fn();
renderHook(() => useRegisterModal(true), {
wrapper: createWrapper({ value: 0, increment, decrement }),
});
expect(decrement).not.toHaveBeenCalled();
expect(increment).toHaveBeenCalled();
});
it("should not decrement on inactive registration", () => {
const increment = jest.fn();
const decrement = jest.fn();
renderHook(() => useRegisterModal(false), {
wrapper: createWrapper({ value: 0, increment, decrement }),
});
expect(decrement).not.toHaveBeenCalled();
expect(increment).not.toHaveBeenCalled();
});
it("should not decrement on inactive de-registration", () => {
const increment = jest.fn();
const decrement = jest.fn();
const result = renderHook(() => useRegisterModal(false), {
wrapper: createWrapper({ value: 0, increment, decrement }),
});
result.unmount();
expect(decrement).not.toHaveBeenCalled();
expect(increment).not.toHaveBeenCalled();
});
it("should decrement on active de-registration", () => {
const increment = jest.fn();
const decrement = jest.fn();
const result = renderHook(() => useRegisterModal(false, true), {
wrapper: createWrapper({ value: 0, increment, decrement }),
});
result.unmount();
expect(decrement).toHaveBeenCalled();
expect(increment).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,53 @@
/*
* 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.
*/
import { useContext, useEffect, useRef } from "react";
import ActiveModalCount from "./activeModalCountContext";
/**
* Should not yet be part of the public API, as it is exclusively used by the {@link Modal} component.
*
* @param active Whether the modal is currently open
* @param initialValue DO NOT USE - Used only for testing purposes
*/
export default function useRegisterModal(active: boolean, initialValue: boolean | null = null) {
const { increment, decrement } = useContext(ActiveModalCount);
const previousActiveState = useRef<boolean | null>(initialValue);
useEffect(() => {
if (active) {
previousActiveState.current = true;
increment();
} else {
if (previousActiveState.current !== null) {
decrement();
}
previousActiveState.current = false;
}
return () => {
if (previousActiveState.current) {
decrement();
}
};
}, [active, decrement, increment]);
}

View File

@@ -26,7 +26,8 @@
"react-select": "^2.1.2",
"string_score": "^0.1.22",
"styled-components": "^5.3.5",
"systemjs": "0.21.6"
"systemjs": "0.21.6",
"mousetrap": "^1.6.5"
},
"scripts": {
"test": "jest",
@@ -48,6 +49,7 @@
"@types/react-router-dom": "^5.3.3",
"@types/styled-components": "^5.1.25",
"@types/systemjs": "^0.20.6",
"@types/mousetrap": "^1.6.9",
"fetch-mock": "^7.5.1",
"react-test-renderer": "^17.0.1"
},

View File

@@ -31,6 +31,7 @@ import { useIndex, useSubject } from "@scm-manager/ui-api";
import NavigationBar from "./NavigationBar";
import styled from "styled-components";
import Feedback from "./Feedback";
import usePauseShortcutsWhenModalsActive from "../shortcuts/usePauseShortcutsWhenModalsActive";
const AppWrapper = styled.div`
min-height: 100vh;
@@ -41,6 +42,7 @@ const App: FC = () => {
const { data: index } = useIndex();
const { isLoading, error, isAuthenticated, isAnonymous, me } = useSubject();
const [t] = useTranslation("commons");
usePauseShortcutsWhenModalsActive();
if (!index) {
return null;

View File

@@ -23,11 +23,11 @@
*/
import React, { FC, useState } from "react";
import App from "./App";
import { ErrorBoundary, Header, Loading } from "@scm-manager/ui-components";
import { ActiveModalCountContextProvider, ErrorBoundary, Header, Loading } from "@scm-manager/ui-components";
import PluginLoader from "./PluginLoader";
import ScrollToTop from "./ScrollToTop";
import IndexErrorPage from "./IndexErrorPage";
import { useIndex, NamespaceAndNameContextProvider } from "@scm-manager/ui-api";
import { NamespaceAndNameContextProvider, useIndex } from "@scm-manager/ui-api";
import { Link } from "@scm-manager/ui-types";
import i18next from "i18next";
import { binder, extensionPoints } from "@scm-manager/ui-extensions";
@@ -60,11 +60,13 @@ const Index: FC = () => {
return (
<ErrorBoundary fallback={IndexErrorPage}>
<ScrollToTop>
<ActiveModalCountContextProvider>
<NamespaceAndNameContextProvider>
<PluginLoader link={link} loaded={pluginsLoaded} callback={() => setPluginsLoaded(true)}>
<App />
</PluginLoader>
</NamespaceAndNameContextProvider>
</ActiveModalCountContextProvider>
</ScrollToTop>
</ErrorBoundary>
);

View File

@@ -30,7 +30,7 @@ import React, {
SetStateAction,
useCallback,
useEffect,
useMemo,
useMemo, useRef,
useState,
} from "react";
import { Hit, Links, Repository, ValueHitField } from "@scm-manager/ui-types";
@@ -45,6 +45,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";
const Input = styled.input`
border-radius: 4px !important;
@@ -329,6 +330,7 @@ const OmniSearch: FC = () => {
const [t] = useTranslation("commons");
const { searchType, initialQuery } = useSearchParams();
const [query, setQuery] = useState(initialQuery);
const searchInputRef = useRef<HTMLInputElement>(null);
const debouncedQuery = useDebounce(query, 250);
const context = useNamespaceAndNameContext();
const { data, isLoading, error } = useOmniSearch(debouncedQuery, {
@@ -355,6 +357,7 @@ const OmniSearch: FC = () => {
searchTypes.sort(orderTypes(t));
const id = useCallback(namespaceAndName, []);
useShortcut("/", () => searchInputRef.current?.focus());
const entries = useMemo(() => {
const newEntries = [];
@@ -435,6 +438,7 @@ const OmniSearch: FC = () => {
aria-label={t("search.ariaLabel")}
aria-owns="omni-search-results"
aria-activedescendant={index >= 0 ? "omni-search-selected-option" : undefined}
ref={searchInputRef}
{...handlers}
/>
{isLoading ? null : (

View File

@@ -36,6 +36,10 @@ 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";
binder.bind<extensionPoints.ChangesetDescriptionTokens>("changeset.description.tokens", ChangesetShortLink);

View File

@@ -0,0 +1,43 @@
/*
* 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.
*/
import { useEffect } from "react";
import Mousetrap from "mousetrap";
/**
* Pauses or unpauses all shortcuts provided by {@link useShortcut}.
*
* @param pause Whether shortcuts should be paused
*/
export default function usePauseShortcuts(pause: boolean) {
useEffect(() => {
if (pause) {
// @ts-ignore method comes from plugin
Mousetrap.pause();
} else {
// @ts-ignore method comes from plugin
Mousetrap.unpause();
}
}, [pause]);
}

View File

@@ -0,0 +1,36 @@
/*
* 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.
*/
import { useActiveModals } from "@scm-manager/ui-components";
import usePauseShortcuts from "./usePauseShortcuts";
/**
* Keyboard shortcuts are not active in modals using {@link useActiveModals} to determine whether any modals are open.
*
* Has to be used inside a {@link ActiveModalCountContextProvider}.
*/
export default function usePauseShortcutsWhenModalsActive() {
const areModalsActive = useActiveModals();
usePauseShortcuts(areModalsActive);
}

View File

@@ -0,0 +1,73 @@
/*
* 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.
*/
import { useEffect } from "react";
import Mousetrap from "mousetrap";
/**
* ## Summary
*
* Binds a global keyboard shortcut to a given callback.
*
* The callback is automatically cleaned up upon unmount.
*
* ## Supported keys
*
* For modifier keys you can use shift, ctrl, alt, or meta.
*
* You can substitute option for alt and command for meta.
*
* Other special keys are backspace, tab, enter, return, capslock, esc, escape, space, pageup, pagedown, end, home, left, up, right, down, ins, del, and plus.
*
* Any other key you should be able to reference by name like a, /, $, *, or =.
*
* A "mod" helper exists which maps to either "command" on Mac and "ctrl" on Windows and Linux.
*
* ## Combinations
*
* Keys can be combined with the "+" separator, but without extra whitespaces.
*
* @param key
* @param callback
* @example useShortcut("/", ...)
* @example useShortcut("ctrl+shift+k", ...)
* @see https://github.com/ccampbell/mousetrap
*/
export default function useShortcut(key: string, callback: (e: KeyboardEvent) => void) {
useEffect(() => {
Mousetrap.bind(key, (e) => {
callback(e);
/*
* Returning false disables default 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.
*/
return false;
});
return () => {
Mousetrap.unbind(key);
};
}, [key, callback]);
}

View File

@@ -3869,6 +3869,11 @@
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/node-fetch@^2.5.7":
version "2.6.2"
resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.2.tgz#d1a9c5fd049d9415dce61571557104dec3ec81da"
@@ -13490,6 +13495,11 @@ 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:
version "1.6.5"
resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.6.5.tgz#8a766d8c272b08393d5f56074e0b5ec183485bf9"
integrity sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA==
move-concurrently@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"