mirror of
https://github.com/scm-manager/scm-manager.git
synced 2026-02-01 04:09:08 +01:00
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:
4
gradle/changelog/keyboard-access.yaml
Normal file
4
gradle/changelog/keyboard-access.yaml
Normal 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))
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -75,6 +75,7 @@ const CardColumn: FC<Props> = ({
|
||||
e.preventDefault();
|
||||
action();
|
||||
}}
|
||||
tabIndex={0}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
27
scm-ui/ui-components/src/createA11yId.tsx
Normal file
27
scm-ui/ui-components/src/createA11yId.tsx
Normal 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++;
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -154,6 +154,7 @@ const RepositoryEntry: FC<Props> = ({ repository, baseDate }) => {
|
||||
description={repository.description}
|
||||
contentRight={actions}
|
||||
link={repositoryLink}
|
||||
ariaLabel={repository.name}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
163
scm-ui/ui-components/src/useTrapFocus.ts
Normal file
163
scm-ui/ui-components/src/useTrapFocus.ts
Normal 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;
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user