Improve accessibility (#1956)

Fixes several accessibility issues:

- Provide a style for empty table column headers
- Add aria references and aria-labels
- Remove aria references if the referenced element is not rendered
This commit is contained in:
Matthias Thieroff
2022-02-17 14:00:16 +01:00
committed by GitHub
parent 9fa0396167
commit 1fe7b0a01e
12 changed files with 61 additions and 33 deletions

View File

@@ -0,0 +1,2 @@
- type: fixed
description: Improve accessibility ([#1956](https://github.com/scm-manager/scm-manager/pull/1956))

View File

@@ -37,6 +37,7 @@ type Props = {
label?: string; label?: string;
testId?: string; testId?: string;
searchPlaceholder?: string; searchPlaceholder?: string;
groupAriaLabelledby?: string;
}; };
const createAbsoluteLink = (url: string) => { const createAbsoluteLink = (url: string) => {
@@ -52,7 +53,8 @@ const OverviewPageActions: FC<Props> = ({
groupSelected, groupSelected,
label, label,
testId, testId,
searchPlaceholder searchPlaceholder,
groupAriaLabelledby
}) => { }) => {
const history = useHistory(); const history = useHistory();
const location = useLocation(); const location = useLocation();
@@ -62,6 +64,7 @@ const OverviewPageActions: FC<Props> = ({
const groupSelector = groups && ( const groupSelector = groups && (
<div className="column is-flex"> <div className="column is-flex">
<Select <Select
ariaLabelledby={groupAriaLabelledby}
className="is-fullwidth" className="is-fullwidth"
options={groups.map(g => ({ value: g, label: g }))} options={groups.map(g => ({ value: g, label: g }))}
value={currentGroup} value={currentGroup}

View File

@@ -24,6 +24,7 @@
import React, { FC } from "react"; import React, { FC } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { Color, Size } from "./styleConstants"; import { Color, Size } from "./styleConstants";
import { useTranslation } from "react-i18next";
type Props = { type Props = {
className?: string; className?: string;
@@ -51,8 +52,10 @@ const Tag: FC<Props> = ({
title, title,
onClick, onClick,
onRemove, onRemove,
children, children
}) => { }) => {
const [t] = useTranslation("commons");
let showIcon = null; let showIcon = null;
if (icon) { if (icon) {
showIcon = ( showIcon = (
@@ -64,7 +67,7 @@ const Tag: FC<Props> = ({
} }
let showDelete = null; let showDelete = null;
if (onRemove) { if (onRemove) {
showDelete = <button className="tag is-delete" onClick={onRemove} />; showDelete = <button className="tag is-delete" onClick={onRemove} aria-label={t("tag.delete")} />;
} }
return ( return (
@@ -78,7 +81,7 @@ const Tag: FC<Props> = ({
{ {
"is-outlined": outlined, "is-outlined": outlined,
"is-rounded": rounded, "is-rounded": rounded,
"is-clickable": onClick, "is-clickable": onClick
}, },
size === "small" && smallClassNames size === "small" && smallClassNames
)} )}

View File

@@ -34,6 +34,7 @@ exports[`Storyshots BranchSelector Default 1`] = `
className="control select is-fullwidth" className="control select is-fullwidth"
> >
<select <select
aria-labelledby="branch-select_0"
disabled={false} disabled={false}
onBlur={[Function]} onBlur={[Function]}
onChange={[Function]} onChange={[Function]}
@@ -3048,7 +3049,6 @@ exports[`Storyshots Forms/Radio Default 1`] = `
className="radio mr-2" className="radio mr-2"
> >
<input <input
aria-describedby="radio_56"
aria-labelledby="radio_55" aria-labelledby="radio_55"
checked={false} checked={false}
onBlur={[Function]} onBlur={[Function]}
@@ -3070,7 +3070,6 @@ exports[`Storyshots Forms/Radio Default 1`] = `
className="radio mr-2" className="radio mr-2"
> >
<input <input
aria-describedby="radio_58"
aria-labelledby="radio_57" aria-labelledby="radio_57"
checked={true} checked={true}
onBlur={[Function]} onBlur={[Function]}
@@ -3100,7 +3099,6 @@ exports[`Storyshots Forms/Radio Disabled 1`] = `
disabled={true} disabled={true}
> >
<input <input
aria-describedby="radio_60"
aria-labelledby="radio_59" aria-labelledby="radio_59"
checked={true} checked={true}
disabled={true} disabled={true}
@@ -3128,7 +3126,6 @@ Array [
className="radio mr-2" className="radio mr-2"
> >
<input <input
aria-describedby="radio_68"
aria-labelledby="radio_67" aria-labelledby="radio_67"
checked={false} checked={false}
onBlur={[Function]} onBlur={[Function]}
@@ -3160,7 +3157,6 @@ exports[`Storyshots Forms/Radio ReactHookForm 1`] = `
className="radio mr-2" className="radio mr-2"
> >
<input <input
aria-describedby="radio_70"
aria-labelledby="radio_69" aria-labelledby="radio_69"
defaultChecked={true} defaultChecked={true}
name="rememberMe" name="rememberMe"
@@ -3184,7 +3180,6 @@ exports[`Storyshots Forms/Radio ReactHookForm 1`] = `
className="radio mr-2" className="radio mr-2"
> >
<input <input
aria-describedby="radio_72"
aria-labelledby="radio_71" aria-labelledby="radio_71"
name="rememberMe" name="rememberMe"
onBlur={[Function]} onBlur={[Function]}
@@ -3208,7 +3203,6 @@ exports[`Storyshots Forms/Radio ReactHookForm 1`] = `
className="radio mr-2 ml-2" className="radio mr-2 ml-2"
> >
<input <input
aria-describedby="radio_74"
aria-labelledby="radio_73" aria-labelledby="radio_73"
name="scramblePassword" name="scramblePassword"
onBlur={[Function]} onBlur={[Function]}
@@ -3232,7 +3226,6 @@ exports[`Storyshots Forms/Radio ReactHookForm 1`] = `
disabled={true} disabled={true}
> >
<input <input
aria-describedby="radio_76"
aria-labelledby="radio_75" aria-labelledby="radio_75"
disabled={true} disabled={true}
name="disabled" name="disabled"
@@ -3256,7 +3249,6 @@ exports[`Storyshots Forms/Radio ReactHookForm 1`] = `
className="radio mr-2" className="radio mr-2"
> >
<input <input
aria-describedby="radio_78"
aria-labelledby="radio_77" aria-labelledby="radio_77"
name="readonly" name="readonly"
onBlur={[Function]} onBlur={[Function]}
@@ -3298,7 +3290,6 @@ Array [
className="radio mr-2" className="radio mr-2"
> >
<input <input
aria-describedby="radio_66"
aria-labelledby="radio_65" aria-labelledby="radio_65"
checked={false} checked={false}
onBlur={[Function]} onBlur={[Function]}

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, { ChangeEvent, FC, FocusEvent } from "react"; import React, { ChangeEvent, FC, FocusEvent, useMemo } 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";
@@ -47,8 +47,8 @@ const InnerRadio: FC<FieldProps<BaseProps, HTMLInputElement, boolean>> = ({
ariaLabelledby, ariaLabelledby,
...props ...props
}) => { }) => {
const id = ariaLabelledby || createA11yId("radio"); const id = useMemo(() => ariaLabelledby || createA11yId("radio"), [ariaLabelledby]);
const helpId = createA11yId("radio"); const helpId = useMemo(() => createA11yId("radio"), []);
const renderHelp = () => { const renderHelp = () => {
const helpText = props.helpText; const helpText = props.helpText;
@@ -77,7 +77,7 @@ const InnerRadio: FC<FieldProps<BaseProps, HTMLInputElement, boolean>> = ({
} }
}; };
const labelElement = props.label ? (<span id={id}>{props.label}</span>) : null; 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}>
@@ -98,7 +98,7 @@ const InnerRadio: FC<FieldProps<BaseProps, HTMLInputElement, boolean>> = ({
ref={props.innerRef} ref={props.innerRef}
defaultChecked={defaultChecked} defaultChecked={defaultChecked}
aria-labelledby={id} aria-labelledby={id}
aria-describedby={helpId} aria-describedby={props.helpText ? helpId : undefined}
/>{" "} />{" "}
{labelElement} {labelElement}
{renderHelp()} {renderHelp()}

View File

@@ -124,7 +124,7 @@ const InnerSelect: FC<FieldProps<BaseProps, HTMLSelectElement, string>> = ({
onChange={handleInput} onChange={handleInput}
onBlur={handleBlur} onBlur={handleBlur}
disabled={disabled} disabled={disabled}
aria-labelledby={label ? a11yId : undefined} aria-labelledby={ariaLabelledby || (label ? a11yId : undefined)}
aria-describedby={helpText ? helpId : undefined} aria-describedby={helpText ? helpId : undefined}
{...createAttributesForTesting(testId)} {...createAttributesForTesting(testId)}
> >

View File

@@ -511,6 +511,14 @@ ul.is-separated {
&.is-darker { &.is-darker {
background-color: #e1e1e1; background-color: #e1e1e1;
} }
// Explicitly "remove" styles from td element to use it as an empty table column header, which is necessary for
// a11y because an empty th element is not allowed.
&.has-no-style {
background-color: transparent;
&.is-darker {
background-color: transparent;
}
}
} }
a { a {
color: $blue; color: $blue;
@@ -528,6 +536,13 @@ ul.is-separated {
&.is-darker { &.is-darker {
background-color: whitesmoke; background-color: whitesmoke;
} }
// Explicitly "remove" styles from td element to use it as an empty table column header, which is necessary for
// a11y because an empty th element is not allowed.
&.has-no-style {
padding: 0;
background-color: transparent;
border: none;
}
} }
&.is-hoverable tbody tr:not(.is-selected):hover { &.is-hoverable tbody tr:not(.is-selected):hover {
background-color: whitesmoke; background-color: whitesmoke;

View File

@@ -246,5 +246,8 @@
}, },
"pdfViewer": { "pdfViewer": {
"error": "Das Dokument konnte nicht angezeigt werden. Es kann <1>hier</1> heruntergeladen werden." "error": "Das Dokument konnte nicht angezeigt werden. Es kann <1>hier</1> heruntergeladen werden."
},
"tag": {
"delete": "Löschen"
} }
} }

View File

@@ -60,6 +60,7 @@
"invalidNamespace": "Keine Repositories gefunden. Möglicherweise existiert der ausgewählte Namespace nicht.", "invalidNamespace": "Keine Repositories gefunden. Möglicherweise existiert der ausgewählte Namespace nicht.",
"createButton": "Repository hinzufügen", "createButton": "Repository hinzufügen",
"filterRepositories": "Repositories filtern", "filterRepositories": "Repositories filtern",
"filterByNamespace": "Nach Namespace 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}}",

View File

@@ -247,5 +247,8 @@
}, },
"pdfViewer": { "pdfViewer": {
"error": "Failed to display the document. Please download it from <1>here</1>." "error": "Failed to display the document. Please download it from <1>here</1>."
},
"tag": {
"delete": "Delete"
} }
} }

View File

@@ -60,6 +60,7 @@
"invalidNamespace": "No repositories found. It's likely that the selected namespace does not exist.", "invalidNamespace": "No repositories found. It's likely that the selected namespace does not exist.",
"createButton": "Add Repository", "createButton": "Add Repository",
"filterRepositories": "Filter repositories", "filterRepositories": "Filter repositories",
"filterByNamespace": "Filter by namespace",
"allNamespaces": "All namespaces", "allNamespaces": "All namespaces",
"clone": "Clone/Checkout", "clone": "Clone/Checkout",
"contact": "Send mail to {{contact}}", "contact": "Send mail to {{contact}}",

View File

@@ -191,19 +191,25 @@ const Overview: FC = () => {
</div> </div>
<PageActions> <PageActions>
{showActions ? ( {showActions ? (
<OverviewPageActions <>
showCreateButton={showCreateButton} <label id="select-namespace" hidden>
currentGroup={ {t("overview.filterByNamespace")}
namespace && namespaces?._embedded.namespaces.some(n => n.namespace === namespace) ? namespace : "" </label>
} <OverviewPageActions
groups={namespacesToRender} showCreateButton={showCreateButton}
groupSelected={namespaceSelected} currentGroup={
link={namespace ? `repos/${namespace}` : "repos"} namespace && namespaces?._embedded.namespaces.some(n => n.namespace === namespace) ? namespace : ""
createLink="/repos/create/" }
label={t("overview.createButton")} groups={namespacesToRender}
testId="repository-overview" groupSelected={namespaceSelected}
searchPlaceholder={t("overview.filterRepositories")} groupAriaLabelledby="select-namespace"
/> link={namespace ? `repos/${namespace}` : "repos"}
createLink="/repos/create/"
label={t("overview.createButton")}
testId="repository-overview"
searchPlaceholder={t("overview.filterRepositories")}
/>
</>
) : null} ) : null}
</PageActions> </PageActions>
</Page> </Page>