mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-03 03:55:51 +01:00
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:
committed by
GitHub
parent
1e72eb52bd
commit
af9aaec095
2
gradle/changelog/global_search_shortcut.yaml
Normal file
2
gradle/changelog/global_search_shortcut.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
- type: added
|
||||
description: Keyboard shortcut for global search ([#2118](https://github.com/scm-manager/scm-manager/pull/2118))
|
||||
@@ -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.`;
|
||||
|
||||
@@ -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.
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
29
scm-ui/ui-components/src/modals/activeModalCountContext.ts
Normal file
29
scm-ui/ui-components/src/modals/activeModalCountContext.ts
Normal 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);
|
||||
@@ -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";
|
||||
|
||||
36
scm-ui/ui-components/src/modals/useActiveModals.ts
Normal file
36
scm-ui/ui-components/src/modals/useActiveModals.ts
Normal 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;
|
||||
88
scm-ui/ui-components/src/modals/useRegisterModal.test.tsx
Normal file
88
scm-ui/ui-components/src/modals/useRegisterModal.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
53
scm-ui/ui-components/src/modals/useRegisterModal.ts
Normal file
53
scm-ui/ui-components/src/modals/useRegisterModal.ts
Normal 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]);
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 : (
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
43
scm-ui/ui-webapp/src/shortcuts/usePauseShortcuts.ts
Normal file
43
scm-ui/ui-webapp/src/shortcuts/usePauseShortcuts.ts
Normal 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]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
73
scm-ui/ui-webapp/src/shortcuts/useShortcut.ts
Normal file
73
scm-ui/ui-webapp/src/shortcuts/useShortcut.ts
Normal 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]);
|
||||
}
|
||||
10
yarn.lock
10
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user