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,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]);
}