mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-12-22 16:29: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
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user