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 <eduard.heimbuch@cloudogu.com>
This commit is contained in:
Konstantin Schaper
2022-11-04 18:05:16 +01:00
committed by GitHub
parent 7b933c6821
commit e74d0c9c8b
12 changed files with 469 additions and 27 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -0,0 +1,2 @@
- type: added
description: Add keyboard navigation to repository overview list ([#2146](https://github.com/scm-manager/scm-manager/pull/2146))

View File

@@ -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<Props> = ({ link, avatar, title, name, description, contentRight, ariaLabel }) => {
const [t] = useTranslation("repos");
const ref = useKeyboardIteratorTarget();
return (
<div className="is-relative">
<OverlayLink
ref={ref}
to={link}
className="has-hover-background-blue"
aria-label={t("overview.ariaLabel", { name: ariaLabel })}

View File

@@ -12,10 +12,13 @@
"scripts": {
"build": "tsup ./src/index.ts -d build --format esm,cjs --dts",
"lint": "eslint src",
"typecheck": "tsc"
"typecheck": "tsc",
"depcheck": "depcheck",
"test": "jest"
},
"peerDependencies": {
"react": "17"
"react": "17",
"react-i18next": "10"
},
"dependencies": {
"mousetrap": "1.6.5"
@@ -25,7 +28,9 @@
"@scm-manager/babel-preset": "^2.13.1",
"@scm-manager/prettier-config": "^2.10.1",
"@scm-manager/eslint-config": "^2.16.0",
"@scm-manager/tsconfig": "^2.13.0"
"@scm-manager/tsconfig": "^2.13.0",
"@testing-library/react-hooks": "8.0.1",
"@testing-library/react": "12.1.5"
},
"babel": {
"presets": [

View File

@@ -25,3 +25,4 @@
export { default as useShortcut } from "./useShortcut";
export { default as useShortcutDocs, ShortcutDocsContextProvider } from "./useShortcutDocs";
export { default as usePauseShortcuts } from "./usePauseShortcuts";
export { useKeyboardIteratorTarget, KeyboardIterator } from "./iterator/keyboardIterator";

View File

@@ -0,0 +1,240 @@
/*
* 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 { KeyboardIteratorContextProvider, useKeyboardIteratorCallback } from "./keyboardIterator";
import { render } from "@testing-library/react";
import { ShortcutDocsContextProvider } from "../useShortcutDocs";
import Mousetrap from "mousetrap";
jest.mock("react-i18next", () => ({
useTranslation: () => [jest.fn()],
}));
const Wrapper: FC<{ initialIndex?: number }> = ({ children, initialIndex }) => {
return (
<ShortcutDocsContextProvider>
<KeyboardIteratorContextProvider initialIndex={initialIndex}>{children}</KeyboardIteratorContextProvider>
</ShortcutDocsContextProvider>
);
};
const DocsWrapper: FC = ({ children }) => <ShortcutDocsContextProvider>{children}</ShortcutDocsContextProvider>;
const createWrapper =
(initialIndex?: number): FC =>
({ children }) =>
<Wrapper initialIndex={initialIndex}>{children}</Wrapper>;
const Item: FC<{ callback: () => void }> = ({ callback }) => {
useKeyboardIteratorCallback(callback);
return <li>example</li>;
};
const List: FC<{ callbacks: Array<() => void> }> = ({ callbacks }) => {
return (
<ul data-testid="list">
{callbacks.map((cb, idx) => (
<Item key={idx} callback={cb} />
))}
</ul>
);
};
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(
<Wrapper>
<List callbacks={[callback, callback2, callback3]} />
</Wrapper>
);
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(
<Wrapper>
<List callbacks={[callback, callback2, callback3]} />
</Wrapper>
);
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(
<Wrapper initialIndex={1}>
<List callbacks={[callback, callback2, callback3]} />
</Wrapper>
);
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(<List callbacks={[callback, callback2, callback3]} />, {
wrapper: createWrapper(2),
});
expect(callback).not.toHaveBeenCalled();
expect(callback2).not.toHaveBeenCalled();
expect(callback3).not.toHaveBeenCalled();
rerender(<List callbacks={[callback, callback2]} />);
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(<List callbacks={[callback, callback2, callback3]} />, {
wrapper: createWrapper(0),
});
expect(callback).not.toHaveBeenCalled();
expect(callback2).not.toHaveBeenCalled();
expect(callback3).not.toHaveBeenCalled();
rerender(<List callbacks={[callback2, callback3]} />);
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(<List callbacks={[callback, callback2, callback3]} />, {
wrapper: createWrapper(2),
});
expect(callback).not.toHaveBeenCalled();
expect(callback2).not.toHaveBeenCalled();
expect(callback3).not.toHaveBeenCalled();
rerender(<List callbacks={[callback, callback2]} />);
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(<List callbacks={[callback, callback2, callback3]} />, {
wrapper: createWrapper(),
});
expect(callback).not.toHaveBeenCalled();
expect(callback2).not.toHaveBeenCalled();
expect(callback3).not.toHaveBeenCalled();
rerender(<List callbacks={[callback, callback2]} />);
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(<List callbacks={[callback]} />, {
wrapper: createWrapper(),
});
expect(callback).not.toHaveBeenCalled();
rerender(<List callbacks={[]} />);
expect(callback).not.toHaveBeenCalled();
});
});

View File

@@ -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<KeyboardIteratorContextType>({
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<Array<Callback>>([]);
const activeIndex = useRef<number>(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 <KeyboardIteratorContext.Provider value={value}>{children}</KeyboardIteratorContext.Provider>;
};
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 = <button ref={ref}>My Iteration Target</button>
*/
export function useKeyboardIteratorTarget(): React.RefCallback<HTMLElement> {
const ref = useRef<HTMLElement>();
const callback = useCallback(() => ref.current?.focus(), []);
const refCallback: React.RefCallback<HTMLElement> = 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 }) => (
<KeyboardIteratorContextProvider>{children}</KeyboardIteratorContextProvider>
);

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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> {
props={{
page,
search,
namespace
namespace,
}}
/>
{groups.map(group => {
return <RepositoryGroupEntry group={group} key={group.name} />;
})}
<KeyboardIterator>
{groups.map((group) => {
return <RepositoryGroupEntry group={group} key={group.name} />;
})}
</KeyboardIterator>
</div>
);
}

View File

@@ -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==