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", "react-test-renderer": "^17.0.1",
"sass-loader": "^12.3.0", "sass-loader": "^12.3.0",
"storybook-addon-i18next": "^1.3.0", "storybook-addon-i18next": "^1.3.0",
"tabbable": "^5.2.1",
"storybook-addon-themes": "^6.1.0", "storybook-addon-themes": "^6.1.0",
"to-camel-case": "^1.0.0", "to-camel-case": "^1.0.0",
"webpack": "^5.61.0", "webpack": "^5.61.0",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -29,6 +29,7 @@ type Props = {
location: TooltipLocation; location: TooltipLocation;
multiline?: boolean; multiline?: boolean;
children: ReactNode; children: ReactNode;
id?: string;
}; };
export type TooltipLocation = "bottom" | "right" | "top" | "left"; export type TooltipLocation = "bottom" | "right" | "top" | "left";
@@ -39,7 +40,7 @@ class Tooltip extends React.Component<Props> {
}; };
render() { render() {
const { className, message, location, multiline, children } = this.props; const { className, message, location, multiline, children, id } = this.props;
let classes = `tooltip has-tooltip-${location}`; let classes = `tooltip has-tooltip-${location}`;
if (multiline) { if (multiline) {
classes += " has-tooltip-multiline"; classes += " has-tooltip-multiline";
@@ -49,7 +50,7 @@ class Tooltip extends React.Component<Props> {
} }
return ( return (
<span className={classes} data-tooltip={message}> <span className={classes} data-tooltip={message} aria-label={message} id={id}>
{children} {children}
</span> </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 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
import React, { FC, MouseEvent, ReactNode } from "react"; import React, { FC, MouseEvent, ReactNode, KeyboardEvent } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import Icon from "../Icon"; import Icon from "../Icon";
@@ -32,7 +32,7 @@ export type ButtonProps = {
title?: string; title?: string;
loading?: boolean; loading?: boolean;
disabled?: boolean; disabled?: boolean;
action?: (event: MouseEvent) => void; action?: (event: MouseEvent | KeyboardEvent) => void;
link?: string; link?: string;
className?: string; className?: string;
icon?: 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 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
import React, { MouseEvent } from "react"; import React, { MouseEvent, KeyboardEvent } from "react";
import { DeleteButton } from "."; import { DeleteButton } from ".";
import classNames from "classnames"; import classNames from "classnames";
@@ -41,7 +41,7 @@ class RemoveEntryOfTableButton extends React.Component<Props, State> {
<div className={classNames("is-pulled-right")}> <div className={classNames("is-pulled-right")}>
<DeleteButton <DeleteButton
label={label} label={label}
action={(event: MouseEvent) => { action={(event: MouseEvent | KeyboardEvent) => {
event.preventDefault(); event.preventDefault();
removeEntry(entryname); removeEntry(entryname);
}} }}

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
import React, { MouseEvent } from "react"; import React, { MouseEvent, KeyboardEvent } from "react";
import Button, { ButtonProps } from "./Button"; import Button, { ButtonProps } from "./Button";
type SubmitButtonProps = ButtonProps & { type SubmitButtonProps = ButtonProps & {
@@ -41,7 +41,7 @@ class SubmitButton extends React.Component<SubmitButtonProps> {
type="submit" type="submit"
color="primary" color="primary"
{...this.props} {...this.props}
action={(event: MouseEvent) => { action={(event: MouseEvent | KeyboardEvent) => {
if (action) { if (action) {
action(event); 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 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
import React, { FC, useState, MouseEvent } from "react"; import React, { FC, useState, MouseEvent, KeyboardEvent } from "react";
import styled from "styled-components"; import styled from "styled-components";
import Level from "../layout/Level"; import Level from "../layout/Level";
import AddButton from "../buttons/AddButton"; import AddButton from "../buttons/AddButton";
@@ -56,7 +56,7 @@ const AddEntryToTableField: FC<Props> = ({
setEntryToAdd(entryName); setEntryToAdd(entryName);
}; };
const addButtonClicked = (event: MouseEvent) => { const addButtonClicked = (event: MouseEvent | KeyboardEvent) => {
event.preventDefault(); event.preventDefault();
appendEntry(); appendEntry();
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -85,6 +85,7 @@ export { regExpPattern as changesetShortLinkRegex } from "./markdown/remarkChang
export * from "./markdown/PluginApi"; export * from "./markdown/PluginApi";
export * from "./devices"; export * from "./devices";
export { default as copyToClipboard } from "./CopyToClipboard"; export { default as copyToClipboard } from "./CopyToClipboard";
export { createA11yId } from "./createA11yId";
export { default as comparators } from "./comparators"; 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 { Link } from "react-router-dom";
import classNames from "classnames"; import classNames from "classnames";
import styled from "styled-components"; import styled from "styled-components";
import { useTranslation } from "react-i18next";
const StyledGroupEntry = styled.div` const StyledGroupEntry = styled.div`
max-height: calc(90px - 1.5rem); max-height: calc(90px - 1.5rem);
@@ -76,12 +77,18 @@ type Props = {
description?: string | ReactNode; description?: string | ReactNode;
contentRight?: ReactNode; contentRight?: ReactNode;
link: string; 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 ( return (
<div className="is-relative"> <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 <StyledGroupEntry
className={classNames("is-flex", "is-justify-content-space-between", "is-align-items-center", "p-2")} className={classNames("is-flex", "is-justify-content-space-between", "is-align-items-center", "p-2")}
title={title} 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" : "")} className={classNames("button", "is-info", button.className, button.isLoading ? "is-loading" : "")}
key={index} key={index}
onClick={() => handleClickButton(button)} onClick={() => handleClickButton(button)}
onKeyDown={(e) => e.key === "Enter" && handleClickButton(button)}
tabIndex={0}
> >
{button.label} {button.label}
</button> </button>

View File

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

View File

@@ -154,6 +154,7 @@ const RepositoryEntry: FC<Props> = ({ repository, baseDate }) => {
description={repository.description} description={repository.description}
contentRight={actions} contentRight={actions}
link={repositoryLink} 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", "filterRepositories": "Repositories filtern",
"allNamespaces": "Alle Namespaces", "allNamespaces": "Alle Namespaces",
"clone": "Clone/Checkout", "clone": "Clone/Checkout",
"contact": "E-Mail senden an {{contact}}" "contact": "E-Mail senden an {{contact}}",
"ariaLabel": "Repository {{name}}"
}, },
"create": { "create": {
"title": "Repository hinzufügen", "title": "Repository hinzufügen",

View File

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

View File

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

View File

@@ -22,12 +22,12 @@
* SOFTWARE. * SOFTWARE.
*/ */
import React, { FC } from "react"; import React, { FC } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components"; import styled from "styled-components";
import { Link, Plugin } from "@scm-manager/ui-types"; import { Link, Plugin } from "@scm-manager/ui-types";
import { CardColumn, Icon } from "@scm-manager/ui-components"; import { CardColumn, Icon } from "@scm-manager/ui-components";
import PluginAvatar from "./PluginAvatar";
import { PluginAction, PluginModalContent } from "../containers/PluginsOverview"; import { PluginAction, PluginModalContent } from "../containers/PluginsOverview";
import { useTranslation } from "react-i18next";
import PluginAvatar from "./PluginAvatar";
type Props = { type Props = {
plugin: Plugin; 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", className: "level-item mb-0 p-2 is-clickable",
}))` }))`
border: 1px solid #cdcdcd; // $dark-25 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 PluginEntry: FC<Props> = ({ plugin, openModal }) => {
const [t] = useTranslation("admin"); const [t] = useTranslation("admin");
const isInstallable = plugin._links.install && (plugin._links.install as Link).href; const isInstallable = plugin._links.install && (plugin._links.install as Link).href;
@@ -81,22 +89,22 @@ const PluginEntry: FC<Props> = ({ plugin, openModal }) => {
const actionBar = () => ( const actionBar = () => (
<ActionbarWrapper className="is-flex"> <ActionbarWrapper className="is-flex">
{isCloudoguPlugin && ( {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" /> <Icon title={t("plugins.modal.cloudoguInstall")} name="link" color="success-dark" />
</IconWrapper> </IconWrapper>
)} )}
{isInstallable && ( {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" /> <Icon title={t("plugins.modal.install")} name="download" color="info" />
</IconWrapper> </IconWrapper>
)} )}
{isUninstallable && ( {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" /> <Icon title={t("plugins.modal.uninstall")} name="trash" color="info" />
</IconWrapper> </IconWrapper>
)} )}
{isUpdatable && ( {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" /> <Icon title={t("plugins.modal.update")} name="sync-alt" color="info" />
</IconWrapper> </IconWrapper>
)} )}

View File

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

View File

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

View File

@@ -42,7 +42,7 @@ const BranchRow: FC<Props> = ({ baseUrl, branch, onDelete }) => {
let deleteButton; let deleteButton;
if ((branch?._links?.delete as Link)?.href) { if ((branch?._links?.delete as Link)?.href) {
deleteButton = ( 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")} /> <Icon name="trash" className="fas " title={t("branch.delete.button")} />
</span> </span>
); );

View File

@@ -46,7 +46,7 @@ const BranchesOverview: FC<Props> = ({ repository, baseUrl }) => {
return <Loading />; return <Loading />;
} }
const branches = data._embedded.branches || []; const branches = data?._embedded?.branches || [];
if (branches.length === 0) { if (branches.length === 0) {
return <Notification type="info">{t("branches.overview.noBranches")}</Notification>; 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 FileSearchButton: FC<Props> = ({ baseUrl, revision }) => {
const [t] = useTranslation("repos"); const [t] = useTranslation("repos");
return ( 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" /> <SearchIcon title={t("fileSearch.button.title")} name="search" color="inherit" />
</Link> </Link>
); );

View File

@@ -91,6 +91,7 @@ const FileSearch: FC<Props> = ({ repository, baseUrl, branches, selectedBranch }
}; };
const contentBaseUrl = `${baseUrl}/sources/${revision}/`; const contentBaseUrl = `${baseUrl}/sources/${revision}/`;
const id = useA11yId("file-search");
return ( return (
<> <>
@@ -121,8 +122,9 @@ const FileSearch: FC<Props> = ({ repository, baseUrl, branches, selectedBranch }
value={query} value={query}
filter={search} filter={search}
autoFocus={true} 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> </div>
<ErrorNotification error={error} /> <ErrorNotification error={error} />
{isLoading ? <Loading /> : <FileSearchResults contentBaseUrl={contentBaseUrl} query={query} paths={result} />} {isLoading ? <Loading /> : <FileSearchResults contentBaseUrl={contentBaseUrl} query={query} paths={result} />}

View File

@@ -78,7 +78,13 @@ const DeletePermissionButton: FC<Props> = ({ namespaceOrRepository, permission,
return ( return (
<> <>
<ErrorNotification error={error} /> <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 FileIcon: FC<Props> = ({ file }) => {
const [t] = useTranslation("repos"); const [t] = useTranslation("repos");
if (file.subRepository) { 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) { } 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; export default FileIcon;

View File

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

View File

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

View File

@@ -25,7 +25,7 @@ import React, { FC } from "react";
import { Link as RouterLink } from "react-router-dom"; import { Link as RouterLink } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import classNames from "classnames"; 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"; import { DateFromNow, Icon } from "@scm-manager/ui-components";
type Props = { type Props = {
@@ -41,7 +41,7 @@ const TagRow: FC<Props> = ({ tag, baseUrl, onDelete }) => {
let deleteButton; let deleteButton;
if ((tag?._links?.delete as Link)?.href) { if ((tag?._links?.delete as Link)?.href) {
deleteButton = ( 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")} /> <Icon name="trash" className="fas" title={t("tag.delete.button")} />
</span> </span>
); );

View File

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

View File

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

View File

@@ -19714,6 +19714,11 @@ systemjs@0.21.6:
resolved "https://registry.yarnpkg.com/systemjs/-/systemjs-0.21.6.tgz#9d15e79d9f60abbac23f0d179f887ec01f260a1b" resolved "https://registry.yarnpkg.com/systemjs/-/systemjs-0.21.6.tgz#9d15e79d9f60abbac23f0d179f887ec01f260a1b"
integrity sha512-R+5S9eV9vcQgWOoS4D87joZ4xkFJHb19ZsyKY07D1+VBDE9bwYcU+KXE0r5XlDA8mFoJGyuWDbfrNoh90JsA8g== 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: table@^6.0.4:
version "6.7.0" version "6.7.0"
resolved "https://registry.yarnpkg.com/table/-/table-6.7.0.tgz#26274751f0ee099c547f6cb91d3eff0d61d155b2" resolved "https://registry.yarnpkg.com/table/-/table-6.7.0.tgz#26274751f0ee099c547f6cb91d3eff0d61d155b2"