mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-13 17:05:43 +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",
|
"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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ const CardColumn: FC<Props> = ({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
action();
|
action();
|
||||||
}}
|
}}
|
||||||
|
tabIndex={0}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
* 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();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
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",
|
"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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
const a11yId = createA11yId("theme");
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={theme}
|
key={theme}
|
||||||
onClick={() => setValue("theme", theme, { shouldDirty: true })}
|
onClick={() => setValue("theme", theme, { shouldDirty: true })}
|
||||||
className="card ml-1 mb-5 control columns is-vcentered has-cursor-pointer"
|
className="card ml-1 mb-5 control columns is-vcentered has-cursor-pointer"
|
||||||
>
|
>
|
||||||
<RadioColumn className="column">
|
<RadioColumn className="column">
|
||||||
<Radio {...register("theme")} value={theme} disabled={isLoading} />
|
<Radio {...register("theme")} value={theme} disabled={isLoading} ariaLabelledby={a11yId} />
|
||||||
</RadioColumn>
|
</RadioColumn>
|
||||||
<div className="column content">
|
<div id={a11yId} className="column content">
|
||||||
<h3>{t(`profile.theme.${theme}.displayName`)}</h3>
|
<h3>{t(`profile.theme.${theme}.displayName`)}</h3>
|
||||||
<p>{t(`profile.theme.${theme}.description`)}</p>
|
<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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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} />}
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user