From e74d0c9c8b10de3cc2fbcda51d8a99dee6b6e4aa Mon Sep 17 00:00:00 2001 From: Konstantin Schaper Date: Fri, 4 Nov 2022 18:05:16 +0100 Subject: [PATCH] Add keyboard navigation to repository overview list (#2146) A new api is introduced to allow focus-based list iteration through keyboard shortcuts. The api is initially considered closed and only used in the repository overview. Co-authored-by: Eduard Heimbuch --- docs/de/user/shortcuts/index.md | 32 ++- docs/en/user/shortcuts/index.md | 24 +- .../changelog/repo_overview_navigation.yaml | 2 + .../ui-components/src/layout/GroupEntry.tsx | 3 + scm-ui/ui-shortcuts/package.json | 11 +- scm-ui/ui-shortcuts/src/index.ts | 1 + .../src/iterator/keyboardIterator.test.tsx | 240 ++++++++++++++++++ .../src/iterator/keyboardIterator.tsx | 150 +++++++++++ .../ui-webapp/public/locales/de/commons.json | 6 +- .../ui-webapp/public/locales/en/commons.json | 6 +- .../repos/components/list/RepositoryList.tsx | 11 +- yarn.lock | 10 +- 12 files changed, 469 insertions(+), 27 deletions(-) create mode 100644 gradle/changelog/repo_overview_navigation.yaml create mode 100644 scm-ui/ui-shortcuts/src/iterator/keyboardIterator.test.tsx create mode 100644 scm-ui/ui-shortcuts/src/iterator/keyboardIterator.tsx diff --git a/docs/de/user/shortcuts/index.md b/docs/de/user/shortcuts/index.md index 626927c78e..4cc93a2cc6 100644 --- a/docs/de/user/shortcuts/index.md +++ b/docs/de/user/shortcuts/index.md @@ -5,7 +5,8 @@ Der SCM-Manager unterstützt Tastaturinteraktion und -navigation durch zusätzli ### Übersicht -Während sie den SCM-Manager verwenden, können sie eine Übersicht aller dem aktiven Benutzer auf der aktuellen Seite verfügbaren Tastenkürzel mittels der `?`-Taste aufrufen. +Während Sie den SCM-Manager verwenden, können Sie eine Übersicht aller +verfügbaren Tastenkürzel mittels der `?`-Taste aufrufen. ### Globale Tastenkürzel @@ -18,20 +19,31 @@ Während sie den SCM-Manager verwenden, können sie eine Übersicht aller dem ak | alt g | Navigiere zur Gruppenübersicht | | alt a | Navigiere zur Administration | +### Navigation von Listen + +Einige Seiten mit Listen erlauben die Navigation per Tastatur. +Wenn die Seite dieses unterstützt, tauchen die Tastaturkürzel in der Übersicht im SCM-Manager +auf (`?`). + +| Key Combination | Description | +|-----------------|---------------------------------------------------| +| j | Bewege den Fokus auf den nächsten Listeneintrag | +| k | Bewege den Fokus auf den vorherigen Listeneintrag | + ### Repositoryspezifische Tastenkürzel -| Key Combination | Description | -|-----------------|---------------| -| g i | Info | -| g b | Branches | -| g t | Tags | -| g c | Code | -| g s | Einstellungen | +| Key Combination | Description | +|-----------------|------------------------------| +| g i | Wechsel zur Repository-Info | +| g b | Wechsel zu den Branches | +| g t | Wechsel zu den Tags | +| g c | Wechsel zum Code | +| g s | Wechsel zu den Einstellungen | ### Tastenkürzel aus Plugin Plugins können selbst neue Tastenkürzel definieren. Diese können global oder repository-spezifisch sein oder in einem komplett anderen Kontext angewandt werden. Sie werden automatisch in der Übersicht im SCM-Manager mit aufgelistet. -Um die Tastenkürzel eines Plugins innerhalb der Benutzerdokumentation zu finden, verweisen wir hier auf die Dokumentation -des jeweiligen Plugins. +Um die Tastenkürzel eines Plugins innerhalb der Benutzerdokumentation zu finden, verweisen wir hier auf die +Dokumentation des jeweiligen Plugins. diff --git a/docs/en/user/shortcuts/index.md b/docs/en/user/shortcuts/index.md index adaa139fc6..898c2c38f3 100644 --- a/docs/en/user/shortcuts/index.md +++ b/docs/en/user/shortcuts/index.md @@ -19,15 +19,25 @@ from anywhere by pressing the `?` key. | alt g | Navigate to Groups | | alt a | Navigate to Administration | +### List Navigation + +Some pages with lists on them support keyboard navigation. +If the page supports this feature, the shortcuts show up in the shortcut overview dialog (`?`). + +| Key Combination | Description | +|-----------------|--------------------------| +| j | Focus next list item | +| k | Focus previous list item | + ### Repository-specific Shortcuts -| Key Combination | Description | -|-----------------|-------------| -| g i | Info | -| g b | Branches | -| g t | Tags | -| g c | Code | -| g s | Settings | +| Key Combination | Description | +|-----------------|---------------------------| +| g i | Switch to repository info | +| g b | Switch to branches | +| g t | Switch to tags | +| g c | Switch to code | +| g s | Switch to settings | ### Plugin Shortcuts diff --git a/gradle/changelog/repo_overview_navigation.yaml b/gradle/changelog/repo_overview_navigation.yaml new file mode 100644 index 0000000000..aa3b336398 --- /dev/null +++ b/gradle/changelog/repo_overview_navigation.yaml @@ -0,0 +1,2 @@ +- type: added + description: Add keyboard navigation to repository overview list ([#2146](https://github.com/scm-manager/scm-manager/pull/2146)) diff --git a/scm-ui/ui-components/src/layout/GroupEntry.tsx b/scm-ui/ui-components/src/layout/GroupEntry.tsx index 045befe7c3..673e4e777d 100644 --- a/scm-ui/ui-components/src/layout/GroupEntry.tsx +++ b/scm-ui/ui-components/src/layout/GroupEntry.tsx @@ -26,6 +26,7 @@ import { Link } from "react-router-dom"; import classNames from "classnames"; import styled from "styled-components"; import { useTranslation } from "react-i18next"; +import { useKeyboardIteratorTarget } from "@scm-manager/ui-shortcuts"; const StyledGroupEntry = styled.div` max-height: calc(90px - 1.5rem); @@ -82,9 +83,11 @@ type Props = { const GroupEntry: FC = ({ link, avatar, title, name, description, contentRight, ariaLabel }) => { const [t] = useTranslation("repos"); + const ref = useKeyboardIteratorTarget(); return (
({ + useTranslation: () => [jest.fn()], +})); + +const Wrapper: FC<{ initialIndex?: number }> = ({ children, initialIndex }) => { + return ( + + {children} + + ); +}; + +const DocsWrapper: FC = ({ children }) => {children}; + +const createWrapper = + (initialIndex?: number): FC => + ({ children }) => + {children}; + +const Item: FC<{ callback: () => void }> = ({ callback }) => { + useKeyboardIteratorCallback(callback); + return
  • example
  • ; +}; + +const List: FC<{ callbacks: Array<() => void> }> = ({ callbacks }) => { + return ( +
      + {callbacks.map((cb, idx) => ( + + ))} +
    + ); +}; + +describe("shortcutIterator", () => { + beforeEach(() => Mousetrap.reset()); + + it("should not call callback upon registration", () => { + const callback = jest.fn(); + + renderHook(() => useKeyboardIteratorCallback(callback), { + wrapper: Wrapper, + }); + + expect(callback).not.toHaveBeenCalled(); + }); + + it("should not throw if not inside keyboard iterator context", () => { + const callback = jest.fn(); + + const { result, unmount } = renderHook(() => useKeyboardIteratorCallback(callback), { + wrapper: DocsWrapper, + }); + + unmount(); + + expect(result.error).toBeUndefined(); + }); + + it("should call last callback upon pressing forward in initial state", async () => { + const callback = jest.fn(); + const callback2 = jest.fn(); + const callback3 = jest.fn(); + + render( + + + + ); + + Mousetrap.trigger("j"); + + expect(callback).not.toHaveBeenCalled(); + expect(callback2).not.toHaveBeenCalled(); + expect(callback3).toHaveBeenCalledTimes(1); + }); + + it("should call first callback once upon pressing backward in initial state", async () => { + const callback = jest.fn(); + const callback2 = jest.fn(); + const callback3 = jest.fn(); + + render( + + + + ); + + Mousetrap.trigger("k"); + Mousetrap.trigger("k"); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback2).not.toHaveBeenCalled(); + expect(callback3).not.toHaveBeenCalled(); + }); + + it("should not allow moving past the end of the callback array", async () => { + const callback = jest.fn(); + const callback2 = jest.fn(); + const callback3 = jest.fn(); + + render( + + + + ); + + Mousetrap.trigger("j"); + Mousetrap.trigger("j"); + + expect(callback).not.toHaveBeenCalled(); + expect(callback2).not.toHaveBeenCalled(); + expect(callback3).toHaveBeenCalledTimes(1); + }); + + it("should move to existing index when active index is at the end and last callback is deregistered", async () => { + const callback = jest.fn(); + const callback2 = jest.fn(); + const callback3 = jest.fn(); + + const { rerender } = render(, { + wrapper: createWrapper(2), + }); + + expect(callback).not.toHaveBeenCalled(); + expect(callback2).not.toHaveBeenCalled(); + expect(callback3).not.toHaveBeenCalled(); + + rerender(); + + expect(callback).not.toHaveBeenCalled(); + expect(callback2).toHaveBeenCalledTimes(1); + expect(callback3).not.toHaveBeenCalled(); + }); + + it("should move to existing index when active index is at the beginning and first callback is deregistered", async () => { + const callback = jest.fn(); + const callback2 = jest.fn(); + const callback3 = jest.fn(); + + const { rerender } = render(, { + wrapper: createWrapper(0), + }); + + expect(callback).not.toHaveBeenCalled(); + expect(callback2).not.toHaveBeenCalled(); + expect(callback3).not.toHaveBeenCalled(); + + rerender(); + + expect(callback).not.toHaveBeenCalled(); + expect(callback2).toHaveBeenCalledTimes(1); + expect(callback3).not.toHaveBeenCalled(); + }); + + it("should move to existing index when active index is at the end and first callback is deregistered", async () => { + const callback = jest.fn(); + const callback2 = jest.fn(); + const callback3 = jest.fn(); + + const { rerender } = render(, { + wrapper: createWrapper(2), + }); + + expect(callback).not.toHaveBeenCalled(); + expect(callback2).not.toHaveBeenCalled(); + expect(callback3).not.toHaveBeenCalled(); + + rerender(); + + expect(callback).not.toHaveBeenCalled(); + expect(callback2).toHaveBeenCalledTimes(1); + expect(callback3).not.toHaveBeenCalled(); + }); + + it("should not move on deregistration if iterator is not active", async () => { + const callback = jest.fn(); + const callback2 = jest.fn(); + const callback3 = jest.fn(); + + const { rerender } = render(, { + wrapper: createWrapper(), + }); + + expect(callback).not.toHaveBeenCalled(); + expect(callback2).not.toHaveBeenCalled(); + expect(callback3).not.toHaveBeenCalled(); + + rerender(); + + expect(callback).not.toHaveBeenCalled(); + expect(callback2).not.toHaveBeenCalled(); + expect(callback3).not.toHaveBeenCalled(); + }); + + it("should not explode if the last item in the list is removed", async () => { + const callback = jest.fn(); + + const { rerender } = render(, { + wrapper: createWrapper(), + }); + + expect(callback).not.toHaveBeenCalled(); + + rerender(); + + expect(callback).not.toHaveBeenCalled(); + }); +}); diff --git a/scm-ui/ui-shortcuts/src/iterator/keyboardIterator.tsx b/scm-ui/ui-shortcuts/src/iterator/keyboardIterator.tsx new file mode 100644 index 0000000000..7ff67d1fd7 --- /dev/null +++ b/scm-ui/ui-shortcuts/src/iterator/keyboardIterator.tsx @@ -0,0 +1,150 @@ +/* + * 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, { FC, useCallback, useContext, useEffect, useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { useShortcut } from "../index"; + +type Callback = () => void; + +type KeyboardIteratorContextType = { + register: (callback: Callback) => number; + deregister: (index: number) => void; +}; + +const KeyboardIteratorContext = React.createContext({ + register: () => { + if (process.env.NODE_ENV === "development") { + // eslint-disable-next-line no-console + console.warn("Keyboard iterator targets have to be declared inside a KeyboardIterator"); + } + return 0; + }, + deregister: () => { + if (process.env.NODE_ENV === "development") { + // eslint-disable-next-line no-console + console.warn("Keyboard iterator targets have to be declared inside a KeyboardIterator"); + } + }, +}); + +export const KeyboardIteratorContextProvider: FC<{ initialIndex?: number }> = ({ children, initialIndex = -1 }) => { + const [t] = useTranslation("commons"); + const callbacks = useRef>([]); + const activeIndex = useRef(initialIndex); + const executeCallback = useCallback((index: number) => callbacks.current[index](), []); + + const navigateBackward = useCallback(() => { + if (activeIndex.current === -1) { + activeIndex.current = 0; + executeCallback(activeIndex.current); + } else if (activeIndex.current > 0) { + activeIndex.current -= 1; + executeCallback(activeIndex.current); + } + }, [executeCallback]); + + const navigateForward = useCallback(() => { + if (activeIndex.current === -1) { + activeIndex.current = callbacks.current.length - 1; + executeCallback(activeIndex.current); + } else if (activeIndex.current < callbacks.current.length - 1) { + activeIndex.current += 1; + executeCallback(activeIndex.current); + } + }, [executeCallback]); + + const value = useMemo( + () => ({ + register: (callback: () => void) => callbacks.current.push(callback) - 1, + deregister: (index: number) => { + callbacks.current.splice(index, 1); + if (callbacks.current.length === 0) { + activeIndex.current = -1; + } else if (activeIndex.current === index || activeIndex.current >= callbacks.current.length) { + if (activeIndex.current > 0) { + activeIndex.current -= 1; + } + executeCallback(activeIndex.current); + } + }, + }), + [executeCallback] + ); + + useShortcut("k", navigateBackward, { + description: t("shortcuts.iterator.previous"), + }); + + useShortcut("j", navigateForward, { + description: t("shortcuts.iterator.next"), + }); + + useShortcut("tab", () => { + activeIndex.current = -1; + + return true; + }); + + return {children}; +}; + +export const useKeyboardIteratorCallback = (callback: Callback) => { + const { register, deregister } = useContext(KeyboardIteratorContext); + useEffect(() => { + const index = register(callback); + return () => deregister(index); + }, [callback, register, deregister]); +}; + +/** + * Use the {@link React.RefObject} returned from this hook to register a target to the nearest enclosing {@link KeyboardIterator}. + * + * @example + * const ref = useKeyboardIteratorTarget(); + * const target = + */ +export function useKeyboardIteratorTarget(): React.RefCallback { + const ref = useRef(); + const callback = useCallback(() => ref.current?.focus(), []); + const refCallback: React.RefCallback = useCallback((el) => { + if (el) { + ref.current = el; + } + }, []); + useKeyboardIteratorCallback(callback); + return refCallback; +} + +/** + * Allows keyboard users to iterate through a list of items, defined by enclosed {@link useKeyboardIteratorTarget} invocations. + * + * The order is determined by the render order of the target hooks. + * + * Press `k` to navigate backwards and `j` to navigate forward. + * Pressing `tab` will reset the iterator to its initial state. + */ +export const KeyboardIterator: FC = ({ children }) => ( + {children} +); diff --git a/scm-ui/ui-webapp/public/locales/de/commons.json b/scm-ui/ui-webapp/public/locales/de/commons.json index c44a317c2e..110bccb78f 100644 --- a/scm-ui/ui-webapp/public/locales/de/commons.json +++ b/scm-ui/ui-webapp/public/locales/de/commons.json @@ -278,6 +278,10 @@ "users": "Navigiere zur Benutzerübersicht", "groups": "Navigiere zur Gruppenübersicht", "admin": "Navigiere zur Administration", - "docs": "Öffne die Tastaturkürzelübersicht" + "docs": "Öffne die Tastaturkürzelübersicht", + "iterator": { + "next": "Fokussiere den nächsten Listeneintrag", + "previous": "Fokussiere den vorherigen Listeneintrag" + } } } diff --git a/scm-ui/ui-webapp/public/locales/en/commons.json b/scm-ui/ui-webapp/public/locales/en/commons.json index f808cc39fe..da6ff47c93 100644 --- a/scm-ui/ui-webapp/public/locales/en/commons.json +++ b/scm-ui/ui-webapp/public/locales/en/commons.json @@ -279,6 +279,10 @@ "users": "Navigate to Users", "groups": "Navigate to Groups", "admin": "Navigate to Administration", - "docs": "Open the shortcut summary" + "docs": "Open the shortcut summary", + "iterator": { + "next": "Focus next list item", + "previous": "Focus previous list item" + } } } diff --git a/scm-ui/ui-webapp/src/repos/components/list/RepositoryList.tsx b/scm-ui/ui-webapp/src/repos/components/list/RepositoryList.tsx index bd1c9d6e1d..06c8efdcf7 100644 --- a/scm-ui/ui-webapp/src/repos/components/list/RepositoryList.tsx +++ b/scm-ui/ui-webapp/src/repos/components/list/RepositoryList.tsx @@ -28,6 +28,7 @@ import { NamespaceCollection, Repository } from "@scm-manager/ui-types"; import groupByNamespace from "./groupByNamespace"; import RepositoryGroupEntry from "./RepositoryGroupEntry"; import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; +import { KeyboardIterator } from "@scm-manager/ui-shortcuts"; type Props = { repositories: Repository[]; @@ -50,12 +51,14 @@ class RepositoryList extends React.Component { props={{ page, search, - namespace + namespace, }} /> - {groups.map(group => { - return ; - })} + + {groups.map((group) => { + return ; + })} +
    ); } diff --git a/yarn.lock b/yarn.lock index b33ef59686..9fc4525a6c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3522,6 +3522,14 @@ lz-string "^1.4.4" pretty-format "^27.0.2" +"@testing-library/react-hooks@8.0.1": + version "8.0.1" + resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz#0924bbd5b55e0c0c0502d1754657ada66947ca12" + integrity sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g== + dependencies: + "@babel/runtime" "^7.12.5" + react-error-boundary "^3.1.0" + "@testing-library/react-hooks@^5.0.3": version "5.1.3" resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-5.1.3.tgz#f722cc526025be2c16966a9a081edf47a2528721" @@ -3534,7 +3542,7 @@ filter-console "^0.1.1" react-error-boundary "^3.1.0" -"@testing-library/react@^12.1.5": +"@testing-library/react@12.1.5", "@testing-library/react@^12.1.5": version "12.1.5" resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.5.tgz#bb248f72f02a5ac9d949dea07279095fa577963b" integrity sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==