Feature/fix tabulator stops (#1831)

Add tab stops to action to increase accessibility of SCM-Manager with keyboard only usage. Also add a focus trap for modals to ensure the actions inside the modal can be used without losing the focus.

Co-authored-by: René Pfeuffer <rene.pfeuffer@cloudogu.com>
This commit is contained in:
Eduard Heimbuch
2021-11-16 11:35:58 +01:00
committed by GitHub
parent 0530e3864f
commit dc5f7d0f23
47 changed files with 1380 additions and 118 deletions

View File

@@ -0,0 +1,4 @@
- type: changed
description: Improve keyboard access by adding tab stops ([#1831](https://github.com/scm-manager/scm-manager/pull/1831))
- type: changed
description: Improve aria lables for better screen reader support ([#1831](https://github.com/scm-manager/scm-manager/pull/1831))

View File

@@ -60,6 +60,7 @@
"react-test-renderer": "^17.0.1",
"sass-loader": "^12.3.0",
"storybook-addon-i18next": "^1.3.0",
"tabbable": "^5.2.1",
"storybook-addon-themes": "^6.1.0",
"to-camel-case": "^1.0.0",
"webpack": "^5.61.0",

View File

@@ -81,6 +81,7 @@ class Autocomplete extends React.Component<Props, State> {
creatable,
className,
} = this.props;
return (
<div className={classNames("field", className)}>
<LabelWithHelpIcon label={label} helpText={helpText} />
@@ -104,6 +105,7 @@ class Autocomplete extends React.Component<Props, State> {
},
});
}}
aria-label={helpText || label}
/>
) : (
<Async
@@ -114,6 +116,7 @@ class Autocomplete extends React.Component<Props, State> {
placeholder={placeholder}
loadingMessage={() => loadingMessage}
noOptionsMessage={() => noOptionsMessage}
aria-label={helpText || label}
/>
)}
</div>

View File

@@ -26,6 +26,7 @@ import classNames from "classnames";
import styled from "styled-components";
import { Branch } from "@scm-manager/ui-types";
import { Select } from "./forms";
import { createA11yId } from "./createA11yId";
type Props = {
branches: Branch[];
@@ -45,11 +46,15 @@ const MinWidthControl = styled.div`
`;
const BranchSelector: FC<Props> = ({ branches, onSelectBranch, selectedBranch, label, disabled }) => {
const a11yId = createA11yId("branch-select");
if (branches) {
return (
<div className={classNames("field", "is-horizontal")}>
<ZeroflexFieldLabel className={classNames("field-label", "is-normal")}>
<label className={classNames("label", "is-size-6")}>{label}</label>
<label className={classNames("label", "is-size-6")} id={a11yId}>
{label}
</label>
</ZeroflexFieldLabel>
<div className="field-body">
<div className={classNames("field", "is-narrow", "mb-0")}>
@@ -61,6 +66,7 @@ const BranchSelector: FC<Props> = ({ branches, onSelectBranch, selectedBranch, l
disabled={!!disabled}
value={selectedBranch}
addValueToOptions={true}
ariaLabelledby={a11yId}
/>
</MinWidthControl>
</div>

View File

@@ -23,11 +23,11 @@
*/
import React, { FC, useState } from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation, Link } from "react-router-dom";
import { Link, useHistory, useLocation } from "react-router-dom";
import classNames from "classnames";
import styled from "styled-components";
import { urls } from "@scm-manager/ui-api";
import { Branch, Repository, File } from "@scm-manager/ui-types";
import { Branch, File, Repository } from "@scm-manager/ui-types";
import { binder, ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions";
import Icon from "./Icon";
import Tooltip from "./Tooltip";
@@ -68,20 +68,25 @@ const BreadcrumbNav = styled.nav`
width: 100%;
/* move slash to end */
li + li::before {
content: none;
}
li:not(:last-child)::after {
color: #b5b5b5; //$breadcrumb-item-separator-color
content: "\\0002f";
}
li:first-child {
margin-left: 0.75rem;
}
/* sizing of each item */
li {
max-width: 375px;
a {
display: initial;
}
@@ -94,6 +99,7 @@ const HomeIcon = styled(Icon)`
const ActionBar = styled.div`
/* ensure space between action bar items */
& > * {
/*
* We have to use important, because plugins could use field or control classes like the editor-plugin does.
@@ -117,7 +123,7 @@ const Breadcrumb: FC<Props> = ({
baseUrl,
sources,
permalink,
preButtons,
preButtons
}) => {
const location = useLocation();
const history = useHistory();
@@ -189,13 +195,13 @@ const Breadcrumb: FC<Props> = ({
{prefixButtons}
<ul>
<li>
<Link to={homeUrl}>
<Link to={homeUrl} aria-label={t("breadcrumb.home")}>
<HomeIcon title={t("breadcrumb.home")} name="home" color="inherit" />
</Link>
</li>
{pathSection()}
</ul>
<PermaLinkWrapper className="ml-1">
<PermaLinkWrapper className="ml-1" tabIndex={0} onKeyDown={e => e.key === "Enter" && copySource()}>
{copying ? (
<Icon name="spinner fa-spin" alt={t("breadcrumb.loading")} />
) : (
@@ -214,7 +220,7 @@ const Breadcrumb: FC<Props> = ({
branch: branch ? branch : defaultBranch,
path,
sources,
repository,
repository
};
const renderExtensionPoints = () => {

View File

@@ -75,6 +75,7 @@ const CardColumn: FC<Props> = ({
e.preventDefault();
action();
}}
tabIndex={0}
/>
);
}

View File

@@ -31,16 +31,18 @@ type Props = {
message: string;
multiline?: boolean;
className?: string;
id?: string;
};
const AbsolutePositionTooltip = styled(Tooltip)`
position: absolute;
`;
const Help: FC<Props> = ({ message, multiline, className }) => (
const Help: FC<Props> = ({ message, multiline, className, id }) => (
<AbsolutePositionTooltip
className={classNames("is-inline-block", "pl-1", multiline ? "has-tooltip-multiline" : undefined, className)}
message={message}
id={id}
>
<HelpIcon />
</AbsolutePositionTooltip>

View File

@@ -29,6 +29,7 @@ type Props = {
location: TooltipLocation;
multiline?: boolean;
children: ReactNode;
id?: string;
};
export type TooltipLocation = "bottom" | "right" | "top" | "left";
@@ -39,7 +40,7 @@ class Tooltip extends React.Component<Props> {
};
render() {
const { className, message, location, multiline, children } = this.props;
const { className, message, location, multiline, children, id } = this.props;
let classes = `tooltip has-tooltip-${location}`;
if (multiline) {
classes += " has-tooltip-multiline";
@@ -49,7 +50,7 @@ class Tooltip extends React.Component<Props> {
}
return (
<span className={classes} data-tooltip={message}>
<span className={classes} data-tooltip={message} aria-label={message} id={id}>
{children}
</span>
);

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC, MouseEvent, ReactNode } from "react";
import React, { FC, MouseEvent, ReactNode, KeyboardEvent } from "react";
import classNames from "classnames";
import { Link } from "react-router-dom";
import Icon from "../Icon";
@@ -32,7 +32,7 @@ export type ButtonProps = {
title?: string;
loading?: boolean;
disabled?: boolean;
action?: (event: MouseEvent) => void;
action?: (event: MouseEvent | KeyboardEvent) => void;
link?: string;
className?: string;
icon?: string;

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { MouseEvent } from "react";
import React, { MouseEvent, KeyboardEvent } from "react";
import { DeleteButton } from ".";
import classNames from "classnames";
@@ -41,7 +41,7 @@ class RemoveEntryOfTableButton extends React.Component<Props, State> {
<div className={classNames("is-pulled-right")}>
<DeleteButton
label={label}
action={(event: MouseEvent) => {
action={(event: MouseEvent | KeyboardEvent) => {
event.preventDefault();
removeEntry(entryname);
}}

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { MouseEvent } from "react";
import React, { MouseEvent, KeyboardEvent } from "react";
import Button, { ButtonProps } from "./Button";
type SubmitButtonProps = ButtonProps & {
@@ -41,7 +41,7 @@ class SubmitButton extends React.Component<SubmitButtonProps> {
type="submit"
color="primary"
{...this.props}
action={(event: MouseEvent) => {
action={(event: MouseEvent | KeyboardEvent) => {
if (action) {
action(event);
}

View File

@@ -0,0 +1,27 @@
/*
* 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.
*/
let counter = 0;
export const createA11yId = (prefix: string) => prefix + "_" + counter++;

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC, useState, MouseEvent } from "react";
import React, { FC, useState, MouseEvent, KeyboardEvent } from "react";
import styled from "styled-components";
import Level from "../layout/Level";
import AddButton from "../buttons/AddButton";
@@ -56,7 +56,7 @@ const AddEntryToTableField: FC<Props> = ({
setEntryToAdd(entryName);
};
const addButtonClicked = (event: MouseEvent) => {
const addButtonClicked = (event: MouseEvent | KeyboardEvent) => {
event.preventDefault();
appendEntry();
};

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC, useState, MouseEvent } from "react";
import React, { FC, useState, MouseEvent, KeyboardEvent } from "react";
import styled from "styled-components";
import { SelectValue } from "@scm-manager/ui-types";
import Level from "../layout/Level";
@@ -61,7 +61,7 @@ const AutocompleteAddEntryToTableField: FC<Props> = ({
setSelectedValue(selection);
};
const addButtonClicked = (event: MouseEvent) => {
const addButtonClicked = (event: MouseEvent | KeyboardEvent) => {
event.preventDefault();
appendEntry();
};

View File

@@ -27,6 +27,7 @@ import LabelWithHelpIcon from "./LabelWithHelpIcon";
import useInnerRef from "./useInnerRef";
import { createFormFieldWrapper, FieldProps, FieldType, isLegacy, isUsingRef } from "./FormFieldTypes";
import classNames from "classnames";
import { createA11yId } from "../createA11yId";
export interface CheckboxElement extends HTMLElement {
value: boolean;
@@ -83,17 +84,20 @@ const InnerCheckbox: FC<FieldProps<BaseProps, HTMLInputElement, boolean>> = ({
}
};
const id = createA11yId("checkbox");
const helpId = createA11yId("checkbox");
const renderHelp = () => {
const { title, helpText } = props;
if (helpText && !title) {
return <Help message={helpText} />;
return <Help message={helpText} id={helpId} />;
}
};
const renderLabelWithHelp = () => {
const { title, helpText } = props;
if (title) {
return <LabelWithHelpIcon label={title} helpText={helpText} />;
return <LabelWithHelpIcon label={title} helpText={helpText} id={id} helpId={helpId} />;
}
};
return (
@@ -116,6 +120,8 @@ const InnerCheckbox: FC<FieldProps<BaseProps, HTMLInputElement, boolean>> = ({
checked={props.checked}
disabled={disabled}
readOnly={readOnly}
aria-labelledby={id}
aria-describedby={helpId}
{...createAttributesForTesting(testId)}
/>{" "}
{label}

View File

@@ -26,6 +26,7 @@ import { useTranslation } from "react-i18next";
import classNames from "classnames";
import { createAttributesForTesting } from "../devBuild";
import LabelWithHelpIcon from "./LabelWithHelpIcon";
import { createA11yId } from "../createA11yId";
type Props = {
name?: string;
@@ -73,9 +74,11 @@ const FileInput: FC<Props> = ({
}
};
const id = createA11yId("file-input");
return (
<div className={classNames("field", className)}>
<LabelWithHelpIcon label={label} helpText={helpText} />
<LabelWithHelpIcon label={label} helpText={helpText} id={id} />
<div className="file is-info has-name is-fullwidth">
<label className="file-label">
<input
@@ -87,6 +90,7 @@ const FileInput: FC<Props> = ({
disabled={disabled}
onChange={handleChange}
onBlur={handleBlur}
aria-describedby={id}
{...createAttributesForTesting(testId)}
/>
<span className="file-cta">

View File

@@ -34,13 +34,14 @@ type Props = {
placeholder?: string;
autoFocus?: boolean;
className?: string;
id?: string;
};
const FixedHeightInput = styled.input`
height: 2.5rem;
`;
const FilterInput: FC<Props> = ({ filter, value, testId, placeholder, autoFocus, className }) => {
const FilterInput: FC<Props> = ({ filter, value, testId, placeholder, autoFocus, className, id }) => {
const [stateValue, setStateValue] = useState(value || "");
const [timeoutId, setTimeoutId] = useState<ReturnType<typeof setTimeout>>();
const [t] = useTranslation("commons");
@@ -79,6 +80,7 @@ const FilterInput: FC<Props> = ({ filter, value, testId, placeholder, autoFocus,
value={stateValue}
onChange={(event) => setStateValue(event.target.value)}
autoFocus={autoFocus || false}
aria-describedby={id}
/>
<span className="icon is-small is-left">
<i className="fas fa-filter" />

View File

@@ -27,6 +27,7 @@ import LabelWithHelpIcon from "./LabelWithHelpIcon";
import { createAttributesForTesting } from "../devBuild";
import useAutofocus from "./useAutofocus";
import { createFormFieldWrapper, FieldProps, FieldType, isLegacy, isUsingRef } from "./FormFieldTypes";
import { createA11yId } from "../createA11yId";
type BaseProps = {
label?: string;
@@ -102,11 +103,16 @@ export const InnerInputField: FC<FieldProps<BaseProps, HTMLInputElement, string>
} else if (informationMessage) {
helper = <p className="help is-info">{informationMessage}</p>;
}
const id = createA11yId("input");
const helpId = createA11yId("input");
return (
<fieldset className={classNames("field", className)} disabled={readOnly}>
<LabelWithHelpIcon label={label} helpText={helpText} />
<LabelWithHelpIcon label={label} helpText={helpText} id={id} helpId={helpId} />
<div className="control">
<input
aria-labelledby={id}
aria-describedby={helpId}
ref={field}
name={name}
className={classNames("input", errorView)}

View File

@@ -27,24 +27,26 @@ import Help from "../Help";
type Props = {
label?: string;
helpText?: string;
id?: string;
helpId?: string;
};
class LabelWithHelpIcon extends React.Component<Props> {
renderHelp() {
const { helpText } = this.props;
const { helpText, helpId } = this.props;
if (helpText) {
return <Help message={helpText} />;
return <Help message={helpText} id={helpId} />;
}
}
render() {
const { label } = this.props;
const { label, id } = this.props;
if (label) {
const help = this.renderHelp();
return (
<label className="label">
{label} {help}
<span id={id}>{label}</span> {help}
</label>
);
}

View File

@@ -25,6 +25,7 @@ import React, { ChangeEvent, FC, FocusEvent } from "react";
import classNames from "classnames";
import { Help } from "../index";
import { createFormFieldWrapper, FieldProps, FieldType, isLegacy, isUsingRef } from "./FormFieldTypes";
import { createA11yId } from "../createA11yId";
type BaseProps = {
label?: string;
@@ -36,18 +37,23 @@ type BaseProps = {
defaultChecked?: boolean;
className?: string;
readOnly?: boolean;
ariaLabelledby?: string;
};
const InnerRadio: FC<FieldProps<BaseProps, HTMLInputElement, boolean>> = ({
name,
defaultChecked,
readOnly,
ariaLabelledby,
...props
}) => {
const id = ariaLabelledby || createA11yId("radio");
const helpId = createA11yId("radio");
const renderHelp = () => {
const helpText = props.helpText;
if (helpText) {
return <Help message={helpText} />;
return <Help message={helpText} id={helpId} />;
}
};
@@ -71,6 +77,8 @@ const InnerRadio: FC<FieldProps<BaseProps, HTMLInputElement, boolean>> = ({
}
};
const labelElement = props.label ? (<span id={id}>{props.label}</span>) : null;
return (
<fieldset className="is-inline-block" disabled={readOnly}>
{/*
@@ -89,8 +97,10 @@ const InnerRadio: FC<FieldProps<BaseProps, HTMLInputElement, boolean>> = ({
disabled={props.disabled}
ref={props.innerRef}
defaultChecked={defaultChecked}
aria-labelledby={id}
aria-describedby={helpId}
/>{" "}
{props.label}
{labelElement}
{renderHelp()}
</label>
</fieldset>

View File

@@ -27,6 +27,7 @@ import LabelWithHelpIcon from "./LabelWithHelpIcon";
import { createAttributesForTesting } from "../devBuild";
import useInnerRef from "./useInnerRef";
import { createFormFieldWrapper, FieldProps, FieldType, isLegacy, isUsingRef } from "./FormFieldTypes";
import { createA11yId } from "../createA11yId";
export type SelectItem = {
value: string;
@@ -46,6 +47,7 @@ type BaseProps = {
readOnly?: boolean;
className?: string;
addValueToOptions?: boolean;
ariaLabelledby?: string;
};
const InnerSelect: FC<FieldProps<BaseProps, HTMLSelectElement, string>> = ({
@@ -61,6 +63,7 @@ const InnerSelect: FC<FieldProps<BaseProps, HTMLSelectElement, string>> = ({
className,
options,
addValueToOptions,
ariaLabelledby,
...props
}) => {
const field = useInnerRef(props.innerRef);
@@ -106,10 +109,12 @@ const InnerSelect: FC<FieldProps<BaseProps, HTMLSelectElement, string>> = ({
}, [field, value, name]);
const loadingClass = loading ? "is-loading" : "";
const a11yId = ariaLabelledby || createA11yId("select");
const helpId = createA11yId("select");
return (
<fieldset className="field" disabled={readOnly}>
<LabelWithHelpIcon label={label} helpText={helpText} />
<LabelWithHelpIcon label={label} helpText={helpText} id={a11yId} helpId={helpId} />
<div className={classNames("control select", loadingClass, className)}>
<select
name={name}
@@ -119,6 +124,8 @@ const InnerSelect: FC<FieldProps<BaseProps, HTMLSelectElement, string>> = ({
onChange={handleInput}
onBlur={handleBlur}
disabled={disabled}
aria-labelledby={a11yId}
aria-describedby={helpId}
{...createAttributesForTesting(testId)}
>
{opts.map((opt) => {

View File

@@ -26,6 +26,7 @@ import LabelWithHelpIcon from "./LabelWithHelpIcon";
import useAutofocus from "./useAutofocus";
import classNames from "classnames";
import { createFormFieldWrapper, FieldProps, FieldType, isLegacy, isUsingRef } from "./FormFieldTypes";
import { createA11yId } from "../createA11yId";
type BaseProps = {
name?: string;
@@ -102,9 +103,12 @@ const InnerTextarea: FC<FieldProps<BaseProps, HTMLTextAreaElement, string>> = ({
helper = <p className="help is-info">{informationMessage}</p>;
}
const id = createA11yId("textarea");
const helpId = createA11yId("textarea");
return (
<fieldset className="field" disabled={readOnly}>
<LabelWithHelpIcon label={label} helpText={helpText} />
<LabelWithHelpIcon label={label} helpText={helpText} id={id} helpId={helpId} />
<div className="control">
<textarea
className={classNames("textarea", errorView)}
@@ -117,6 +121,8 @@ const InnerTextarea: FC<FieldProps<BaseProps, HTMLTextAreaElement, string>> = ({
disabled={disabled}
onKeyDown={onKeyDown}
defaultValue={defaultValue}
aria-labelledby={id}
aria-describedby={helpId}
/>
</div>
{helper}

View File

@@ -85,6 +85,7 @@ export { regExpPattern as changesetShortLinkRegex } from "./markdown/remarkChang
export * from "./markdown/PluginApi";
export * from "./devices";
export { default as copyToClipboard } from "./CopyToClipboard";
export { createA11yId } from "./createA11yId";
export { default as comparators } from "./comparators";

View File

@@ -25,6 +25,7 @@ import React, { FC, ReactNode } from "react";
import { Link } from "react-router-dom";
import classNames from "classnames";
import styled from "styled-components";
import { useTranslation } from "react-i18next";
const StyledGroupEntry = styled.div`
max-height: calc(90px - 1.5rem);
@@ -76,12 +77,18 @@ type Props = {
description?: string | ReactNode;
contentRight?: ReactNode;
link: string;
ariaLabel?: string;
};
const GroupEntry: FC<Props> = ({ link, avatar, title, name, description, contentRight }) => {
const GroupEntry: FC<Props> = ({ link, avatar, title, name, description, contentRight, ariaLabel }) => {
const [t] = useTranslation("repos");
return (
<div className="is-relative">
<OverlayLink to={link} className="has-hover-background-blue" />
<OverlayLink
to={link}
className="has-hover-background-blue"
aria-label={t("overview.ariaLabel", { name: ariaLabel })}
/>
<StyledGroupEntry
className={classNames("is-flex", "is-justify-content-space-between", "is-align-items-center", "p-2")}
title={title}

View File

@@ -69,6 +69,8 @@ export const ConfirmAlert: FC<Props> = ({ title, message, buttons, close }) => {
className={classNames("button", "is-info", button.className, button.isLoading ? "is-loading" : "")}
key={index}
onClick={() => handleClickButton(button)}
onKeyDown={(e) => e.key === "Enter" && handleClickButton(button)}
tabIndex={0}
>
{button.label}
</button>

View File

@@ -21,11 +21,12 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC } from "react";
import React, { FC, KeyboardEvent, useRef } from "react";
import classNames from "classnames";
import usePortalRootElement from "../usePortalRootElement";
import ReactDOM from "react-dom";
import styled from "styled-components";
import { useTrapFocus } from "../useTrapFocus";
type ModalSize = "S" | "M" | "L";
@@ -59,6 +60,13 @@ export const Modal: FC<Props> = ({
size,
}) => {
const portalRootElement = usePortalRootElement("modalsRoot");
const initialFocusRef = useRef(null);
const trapRef = useTrapFocus({
includeContainer: true,
initialFocus: initialFocusRef.current,
returnFocus: true,
updateNodes: false,
});
if (!portalRootElement) {
return null;
@@ -71,13 +79,19 @@ export const Modal: FC<Props> = ({
showFooter = <footer className="modal-card-foot">{footer}</footer>;
}
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (closeFunction && "Escape" === event.key) {
closeFunction();
}
};
const modalElement = (
<div className={classNames("modal", className, isActive)}>
<div className={classNames("modal", className, isActive)} ref={trapRef} onKeyDown={onKeyDown}>
<div className="modal-background" onClick={closeFunction} />
<SizedModal className="modal-card" size={size}>
<header className={classNames("modal-card-head", `has-background-${headColor}`)}>
<p className={`modal-card-title m-0 has-text-${headTextColor}`}>{title}</p>
<button className="delete" aria-label="close" onClick={closeFunction} />
<button className="delete" aria-label="close" onClick={closeFunction} ref={initialFocusRef} autoFocus />
</header>
<section className="modal-card-body">{body}</section>
{showFooter}

View File

@@ -154,6 +154,7 @@ const RepositoryEntry: FC<Props> = ({ repository, baseDate }) => {
description={repository.description}
contentRight={actions}
link={repositoryLink}
ariaLabel={repository.name}
/>
</>
);

View File

@@ -0,0 +1,163 @@
/*
* 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 { MutableRefObject, RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { FocusableElement, tabbable } from "tabbable";
type Node = HTMLDivElement | null;
interface UseTrapFocus {
includeContainer?: boolean;
initialFocus?: "container" | Node;
returnFocus?: boolean;
updateNodes?: boolean;
}
// Based on https://tobbelindstrom.com/blog/useTrapFocus/
export const useTrapFocus = (options?: UseTrapFocus): MutableRefObject<Node> => {
const node = useRef<Node>(null);
const { includeContainer, initialFocus, returnFocus, updateNodes } = useMemo<UseTrapFocus>(
() => ({
includeContainer: false,
initialFocus: null,
returnFocus: true,
updateNodes: false,
...options,
}),
[options]
);
const [tabbableNodes, setTabbableNodes] = useState<FocusableElement[]>([]);
const previousFocusedNode = useRef<Node>(document.activeElement as Node);
const setInitialFocus = useCallback(() => {
if (initialFocus === "container") {
node.current?.focus();
} else {
initialFocus?.focus();
}
}, [initialFocus]);
const updateTabbableNodes = useCallback(() => {
const { current } = node;
if (current) {
const getTabbableNodes = tabbable(current, { includeContainer });
setTabbableNodes(getTabbableNodes);
return getTabbableNodes;
}
return [];
}, [includeContainer]);
useEffect(() => {
updateTabbableNodes();
if (node.current) setInitialFocus();
}, [setInitialFocus, updateTabbableNodes]);
useEffect(() => {
return () => {
const { current } = previousFocusedNode;
if (current && returnFocus) current.focus();
};
}, [returnFocus]);
const handleKeydown = useCallback(
(event) => {
const { key, keyCode, shiftKey } = event;
let getTabbableNodes = tabbableNodes;
if (updateNodes) getTabbableNodes = updateTabbableNodes();
if ((key === "Tab" || keyCode === 9) && getTabbableNodes.length) {
const firstNode = getTabbableNodes[0];
const lastNode = getTabbableNodes[getTabbableNodes.length - 1];
const { activeElement } = document;
if (!getTabbableNodes.includes(activeElement as FocusableElement)) {
event.preventDefault();
shiftKey ? lastNode.focus() : firstNode.focus();
}
if (shiftKey && activeElement === firstNode) {
event.preventDefault();
lastNode.focus();
}
if (!shiftKey && activeElement === lastNode) {
event.preventDefault();
firstNode.focus();
}
}
},
[tabbableNodes, updateNodes, updateTabbableNodes]
);
useEventListener({
type: "keydown",
listener: handleKeydown,
});
return node;
};
interface UseEventListener {
type: keyof WindowEventMap;
listener: EventListener;
element?: RefObject<Element> | Document | Window | null;
options?: AddEventListenerOptions;
}
export const useEventListener = ({
type,
listener,
element = isSSR ? undefined : window,
options,
}: UseEventListener): void => {
const savedListener = useRef<EventListener>();
useEffect(() => {
savedListener.current = listener;
}, [listener]);
const handleEventListener = useCallback((event: Event) => {
savedListener.current?.(event);
}, []);
useEffect(() => {
const target = getRefElement(element);
target?.addEventListener(type, handleEventListener, options);
return () => target?.removeEventListener(type, handleEventListener);
}, [type, element, options, handleEventListener]);
};
const isSSR = !(typeof window !== "undefined" && window.document?.createElement);
const getRefElement = <T>(element?: RefObject<Element> | T): Element | T | undefined | null => {
if (element && "current" in element) {
return element.current;
}
return element;
};

View File

@@ -62,7 +62,8 @@
"filterRepositories": "Repositories filtern",
"allNamespaces": "Alle Namespaces",
"clone": "Clone/Checkout",
"contact": "E-Mail senden an {{contact}}"
"contact": "E-Mail senden an {{contact}}",
"ariaLabel": "Repository {{name}}"
},
"create": {
"title": "Repository hinzufügen",

View File

@@ -62,7 +62,8 @@
"filterRepositories": "Filter repositories",
"allNamespaces": "All namespaces",
"clone": "Clone/Checkout",
"contact": "Send mail to {{contact}}"
"contact": "Send mail to {{contact}}",
"ariaLabel": "Repository {{name}}"
},
"create": {
"title": "Add Repository",

View File

@@ -243,6 +243,7 @@ const GeneralSettings: FC<Props> = ({
buttonLabel={t("general-settings.emergencyContacts.addButton")}
loadSuggestions={userSuggestions}
placeholder={t("general-settings.emergencyContacts.autocompletePlaceholder")}
helpText={t("general-settings.emergencyContacts.helpText")}
/>
</div>
</div>

View File

@@ -22,12 +22,12 @@
* SOFTWARE.
*/
import React, { FC } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { Link, Plugin } from "@scm-manager/ui-types";
import { CardColumn, Icon } from "@scm-manager/ui-components";
import PluginAvatar from "./PluginAvatar";
import { PluginAction, PluginModalContent } from "../containers/PluginsOverview";
import { useTranslation } from "react-i18next";
import PluginAvatar from "./PluginAvatar";
type Props = {
plugin: Plugin;
@@ -40,7 +40,7 @@ const ActionbarWrapper = styled.div`
}
`;
const IconWrapper = styled.span.attrs((props) => ({
const IconWrapperStyle = styled.span.attrs((props) => ({
className: "level-item mb-0 p-2 is-clickable",
}))`
border: 1px solid #cdcdcd; // $dark-25
@@ -51,6 +51,14 @@ const IconWrapper = styled.span.attrs((props) => ({
}
`;
const IconWrapper: FC<{ action: () => void }> = ({ action, children }) => {
return (
<IconWrapperStyle onClick={action} onKeyDown={(e) => e.key === "Enter" && action()} tabIndex={0}>
{children}
</IconWrapperStyle>
);
};
const PluginEntry: FC<Props> = ({ plugin, openModal }) => {
const [t] = useTranslation("admin");
const isInstallable = plugin._links.install && (plugin._links.install as Link).href;
@@ -81,22 +89,22 @@ const PluginEntry: FC<Props> = ({ plugin, openModal }) => {
const actionBar = () => (
<ActionbarWrapper className="is-flex">
{isCloudoguPlugin && (
<IconWrapper onClick={() => openModal({ plugin, action: PluginAction.CLOUDOGU })}>
<IconWrapper action={() => openModal({ plugin, action: PluginAction.CLOUDOGU })}>
<Icon title={t("plugins.modal.cloudoguInstall")} name="link" color="success-dark" />
</IconWrapper>
)}
{isInstallable && (
<IconWrapper onClick={() => openModal({ plugin, action: PluginAction.INSTALL })}>
<IconWrapper action={() => openModal({ plugin, action: PluginAction.INSTALL })}>
<Icon title={t("plugins.modal.install")} name="download" color="info" />
</IconWrapper>
)}
{isUninstallable && (
<IconWrapper onClick={() => openModal({ plugin, action: PluginAction.UNINSTALL })}>
<IconWrapper action={() => openModal({ plugin, action: PluginAction.UNINSTALL })}>
<Icon title={t("plugins.modal.uninstall")} name="trash" color="info" />
</IconWrapper>
)}
{isUpdatable && (
<IconWrapper onClick={() => openModal({ plugin, action: PluginAction.UPDATE })}>
<IconWrapper action={() => openModal({ plugin, action: PluginAction.UPDATE })}>
<Icon title={t("plugins.modal.update")} name="sync-alt" color="info" />
</IconWrapper>
)}

View File

@@ -23,7 +23,7 @@
*/
import React, { FC, useState } from "react";
import { Radio, SubmitButton, Subtitle } from "@scm-manager/ui-components";
import { createA11yId, Radio, SubmitButton, Subtitle } from "@scm-manager/ui-components";
import { useForm } from "react-hook-form";
import styled from "styled-components";
import { useTranslation } from "react-i18next";
@@ -60,12 +60,12 @@ const Theme: FC = () => {
register,
setValue,
handleSubmit,
formState: { isDirty },
formState: { isDirty }
} = useForm<ThemeForm>({
mode: "onChange",
defaultValues: {
theme,
},
theme
}
});
const [t] = useTranslation("commons");
@@ -77,21 +77,24 @@ const Theme: FC = () => {
<>
<Subtitle>{t("profile.theme.subtitle")}</Subtitle>
<form onSubmit={handleSubmit(onSubmit)}>
{themes.map((theme) => (
<div
key={theme}
onClick={() => setValue("theme", theme, { shouldDirty: true })}
className="card ml-1 mb-5 control columns is-vcentered has-cursor-pointer"
>
<RadioColumn className="column">
<Radio {...register("theme")} value={theme} disabled={isLoading} />
</RadioColumn>
<div className="column content">
<h3>{t(`profile.theme.${theme}.displayName`)}</h3>
<p>{t(`profile.theme.${theme}.description`)}</p>
{themes.map(theme => {
const a11yId = createA11yId("theme");
return (
<div
key={theme}
onClick={() => setValue("theme", theme, { shouldDirty: true })}
className="card ml-1 mb-5 control columns is-vcentered has-cursor-pointer"
>
<RadioColumn className="column">
<Radio {...register("theme")} value={theme} disabled={isLoading} ariaLabelledby={a11yId} />
</RadioColumn>
<div id={a11yId} className="column content">
<h3>{t(`profile.theme.${theme}.displayName`)}</h3>
<p>{t(`profile.theme.${theme}.description`)}</p>
</div>
</div>
</div>
))}
);
})}
<SubmitButton label={t("profile.theme.submit")} loading={isLoading} disabled={!isDirty} />
</form>
</>

View File

@@ -48,7 +48,7 @@ class Details extends React.Component<Props> {
<tr>
<th>{t("group.external")}</th>
<td>
<Checkbox checked={group.external} />
<Checkbox checked={group.external} readOnly={true} />
</td>
</tr>
<tr>

View File

@@ -42,7 +42,7 @@ const BranchRow: FC<Props> = ({ baseUrl, branch, onDelete }) => {
let deleteButton;
if ((branch?._links?.delete as Link)?.href) {
deleteButton = (
<span className="icon is-small is-hovered" onClick={() => onDelete(branch)}>
<span className="icon is-small is-hovered" onClick={() => onDelete(branch)} onKeyDown={(e) => e.key === "Enter" && onDelete(branch)} tabIndex={0}>
<Icon name="trash" className="fas " title={t("branch.delete.button")} />
</span>
);

View File

@@ -46,7 +46,7 @@ const BranchesOverview: FC<Props> = ({ repository, baseUrl }) => {
return <Loading />;
}
const branches = data._embedded.branches || [];
const branches = data?._embedded?.branches || [];
if (branches.length === 0) {
return <Notification type="info">{t("branches.overview.noBranches")}</Notification>;

View File

@@ -39,7 +39,7 @@ const SearchIcon = styled(Icon)`
const FileSearchButton: FC<Props> = ({ baseUrl, revision }) => {
const [t] = useTranslation("repos");
return (
<Link to={`${baseUrl}/search/${encodeURIComponent(revision)}`}>
<Link to={`${baseUrl}/search/${encodeURIComponent(revision)}`} aria-label={t("fileSearch.button.title")}>
<SearchIcon title={t("fileSearch.button.title")} name="search" color="inherit" />
</Link>
);

View File

@@ -91,6 +91,7 @@ const FileSearch: FC<Props> = ({ repository, baseUrl, branches, selectedBranch }
};
const contentBaseUrl = `${baseUrl}/sources/${revision}/`;
const id = useA11yId("file-search");
return (
<>
@@ -121,8 +122,9 @@ const FileSearch: FC<Props> = ({ repository, baseUrl, branches, selectedBranch }
value={query}
filter={search}
autoFocus={true}
id={id}
/>
<Help className="ml-3" message={t("fileSearch.input.help")} />
<Help className="ml-3" message={t("fileSearch.input.help")} id={id} />
</div>
<ErrorNotification error={error} />
{isLoading ? <Loading /> : <FileSearchResults contentBaseUrl={contentBaseUrl} query={query} paths={result} />}

View File

@@ -78,7 +78,13 @@ const DeletePermissionButton: FC<Props> = ({ namespaceOrRepository, permission,
return (
<>
<ErrorNotification error={error} />
<Icon name="trash" onClick={action} />
<Icon
name="trash"
onClick={action}
onEnter={action}
tabIndex={0}
title={t("permission.delete-permission-button.label")}
/>
</>
);
};

View File

@@ -33,11 +33,13 @@ type Props = {
const FileIcon: FC<Props> = ({ file }) => {
const [t] = useTranslation("repos");
if (file.subRepository) {
return <Icon title={t("sources.fileTree.subRepository")} iconStyle="far" name="folder" color="inherit" />;
return (
<Icon title={t("sources.fileTree.subRepository")} iconStyle="far" name="folder" color="inherit" tabIndex={-1} />
);
} else if (file.directory) {
return <Icon title={t("sources.fileTree.folder")} name="folder" color="inherit" />;
return <Icon title={t("sources.fileTree.folder")} name="folder" color="inherit" tabIndex={-1} />;
}
return <Icon title={t("sources.fileTree.file")} name="file" color="inherit" />;
return <Icon title={t("sources.fileTree.file")} name="file" color="inherit" tabIndex={-1} />;
};
export default FileIcon;

View File

@@ -57,7 +57,7 @@ const ExtensionTd = styled.td`
class FileTreeLeaf extends React.Component<Props> {
createFileIcon = (file: File) => {
return (
<FileLink baseUrl={this.props.baseUrl} file={file}>
<FileLink baseUrl={this.props.baseUrl} file={file} tabIndex={-1}>
<FileIcon file={file} />
</FileLink>
);
@@ -65,7 +65,7 @@ class FileTreeLeaf extends React.Component<Props> {
createFileName = (file: File) => {
return (
<FileLink baseUrl={this.props.baseUrl} file={file}>
<FileLink baseUrl={this.props.baseUrl} file={file} tabIndex={0}>
{file.name}
</FileLink>
);

View File

@@ -31,6 +31,7 @@ type Props = {
baseUrl: string;
file: File;
children: ReactNode;
tabIndex?: number;
};
const isLocalRepository = (repositoryUrl: string) => {
@@ -74,7 +75,7 @@ export const createFolderLink = (base: string, file: File) => {
return link;
};
const FileLink: FC<Props> = ({ baseUrl, file, children }) => {
const FileLink: FC<Props> = ({ baseUrl, file, children, tabIndex }) => {
const [t] = useTranslation("repos");
if (file?.subRepository?.repositoryUrl) {
// file link represents a subRepository
@@ -87,13 +88,21 @@ const FileLink: FC<Props> = ({ baseUrl, file, children }) => {
if (file.subRepository.revision && isLocalRepository(link)) {
link += "/code/sources/" + file.subRepository.revision;
}
return <a href={link}>{children}</a>;
return (
<a href={link} tabIndex={tabIndex}>
{children}
</a>
);
} else if (link.startsWith("ssh://") && isLocalRepository(link)) {
link = createRelativeLink(link);
if (file.subRepository.revision) {
link += "/code/sources/" + file.subRepository.revision;
}
return <Link to={link}>{children}</Link>;
return (
<Link to={link} tabIndex={tabIndex}>
{children}
</Link>
);
} else {
// subRepository url cannot be linked
return (
@@ -104,7 +113,11 @@ const FileLink: FC<Props> = ({ baseUrl, file, children }) => {
}
}
// normal file or folder
return <Link to={createFolderLink(baseUrl, file)}>{children}</Link>;
return (
<Link to={createFolderLink(baseUrl, file)} tabIndex={tabIndex}>
{children}
</Link>
);
};
export default FileLink;

View File

@@ -25,7 +25,7 @@ import React, { FC } from "react";
import { Link as RouterLink } from "react-router-dom";
import { useTranslation } from "react-i18next";
import classNames from "classnames";
import { Link, Tag } from "@scm-manager/ui-types";
import { Tag, Link } from "@scm-manager/ui-types";
import { DateFromNow, Icon } from "@scm-manager/ui-components";
type Props = {
@@ -41,7 +41,7 @@ const TagRow: FC<Props> = ({ tag, baseUrl, onDelete }) => {
let deleteButton;
if ((tag?._links?.delete as Link)?.href) {
deleteButton = (
<span className="icon is-small" onClick={() => onDelete(tag)}>
<span className="icon is-small" onClick={() => onDelete(tag)} onKeyDown={(e) => e.key === "Enter" && onDelete(tag)} tabIndex={0}>
<Icon name="trash" className="fas" title={t("tag.delete.button")} />
</span>
);

View File

@@ -47,7 +47,7 @@ const TagsOverview: FC<Props> = ({ repository, baseUrl }) => {
return <Loading />;
}
const tags = data?._embedded.tags || [];
const tags = data?._embedded?.tags || [];
orderTags(tags);
return (

View File

@@ -53,13 +53,13 @@ class Details extends React.Component<Props> {
<tr>
<th>{t("user.active")}</th>
<td>
<Checkbox checked={user.active} />
<Checkbox checked={user.active} readOnly={true} />
</td>
</tr>
<tr>
<th>{t("user.externalFlag")}</th>
<td>
<Checkbox checked={!!user.external} />
<Checkbox checked={user.external} readOnly={true} />
</td>
</tr>
<tr>

View File

@@ -19714,6 +19714,11 @@ systemjs@0.21.6:
resolved "https://registry.yarnpkg.com/systemjs/-/systemjs-0.21.6.tgz#9d15e79d9f60abbac23f0d179f887ec01f260a1b"
integrity sha512-R+5S9eV9vcQgWOoS4D87joZ4xkFJHb19ZsyKY07D1+VBDE9bwYcU+KXE0r5XlDA8mFoJGyuWDbfrNoh90JsA8g==
tabbable@^5.2.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-5.2.1.tgz#e3fda7367ddbb172dcda9f871c0fdb36d1c4cd9c"
integrity sha512-40pEZ2mhjaZzK0BnI+QGNjJO8UYx9pP5v7BGe17SORTO0OEuuaAwQTkAp8whcZvqon44wKFOikD+Al11K3JICQ==
table@^6.0.4:
version "6.7.0"
resolved "https://registry.yarnpkg.com/table/-/table-6.7.0.tgz#26274751f0ee099c547f6cb91d3eff0d61d155b2"