Improve modal accessibility

Implement initial focus for modals. Change all modals including forms to put initial focus on the first input. When Enter is pressed on any input (CTRL + Enter for Textareas), the form is submitted if it is valid.

Co-authored-by: Sebastian Sdorra <sebastian.sdorra@cloudogu.com>
Co-authored-by: Eduard Heimbuch <eduard.heimbuch@cloudogu.com>
This commit is contained in:
Konstantin Schaper
2022-01-21 14:25:19 +01:00
committed by GitHub
parent d8fcb12402
commit d0cf976a54
37 changed files with 1848 additions and 570 deletions

View File

@@ -0,0 +1,2 @@
- type: added
description: initial focus, submission on pressing enter and fix trap focus for modals ([#1925](https://github.com/scm-manager/scm-manager/pull/1925))

View File

@@ -27,11 +27,14 @@ const fs = require("fs");
const MiniCssExtractPlugin = require("mini-css-extract-plugin"); const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const RemoveThemesPlugin = require("./RemoveThemesPlugin"); const RemoveThemesPlugin = require("./RemoveThemesPlugin");
const WorkerPlugin = require("worker-plugin"); const WorkerPlugin = require("worker-plugin");
const ReactDOM = require("react-dom");
const root = path.resolve(".."); const root = path.resolve("..");
const themedir = path.join(root, "ui-styles", "src"); const themedir = path.join(root, "ui-styles", "src");
ReactDOM.createPortal = node => node;
const themes = fs const themes = fs
.readdirSync(themedir) .readdirSync(themedir)
.map(filename => path.parse(filename)) .map(filename => path.parse(filename))
@@ -50,7 +53,7 @@ module.exports = {
// add our themes to webpack entry points // add our themes to webpack entry points
config.entry = { config.entry = {
main: config.entry, main: config.entry,
...themes, ...themes
}; };
// create separate css files for our themes // create separate css files for our themes

View File

@@ -92,7 +92,8 @@
"remark-parse": "^9.0.0", "remark-parse": "^9.0.0",
"remark-rehype": "^8.0.0", "remark-rehype": "^8.0.0",
"tabbable": "^5.2.1", "tabbable": "^5.2.1",
"unified": "^9.2.1" "unified": "^9.2.1",
"@headlessui/react": "^1.4.3"
}, },
"babel": { "babel": {
"presets": [ "presets": [

File diff suppressed because it is too large Load Diff

View File

@@ -40,6 +40,7 @@ export type ButtonProps = {
reducedMobile?: boolean; reducedMobile?: boolean;
children?: ReactNode; children?: ReactNode;
testId?: string; testId?: string;
ref?: React.ForwardedRef<HTMLButtonElement>;
}; };
type Props = ButtonProps & { type Props = ButtonProps & {
@@ -47,7 +48,11 @@ type Props = ButtonProps & {
color?: string; color?: string;
}; };
const Button: FC<Props> = ({ type InnerProps = Props & {
innerRef: React.Ref<HTMLButtonElement>;
};
const Button: FC<InnerProps> = ({
link, link,
className, className,
icon, icon,
@@ -61,7 +66,8 @@ const Button: FC<Props> = ({
loading, loading,
disabled, disabled,
action, action,
color = "default" color = "default",
innerRef
}) => { }) => {
const renderIcon = () => { const renderIcon = () => {
return <>{icon ? <Icon name={icon} color="inherit" className="is-medium pr-1" /> : null}</>; return <>{icon ? <Icon name={icon} color="inherit" className="is-medium pr-1" /> : null}</>;
@@ -109,6 +115,7 @@ const Button: FC<Props> = ({
disabled={disabled} disabled={disabled}
onClick={event => action && action(event)} onClick={event => action && action(event)}
className={classes} className={classes}
ref={innerRef}
{...createAttributesForTesting(testId)} {...createAttributesForTesting(testId)}
> >
{content} {content}
@@ -116,4 +123,4 @@ const Button: FC<Props> = ({
); );
}; };
export default Button; export default React.forwardRef<HTMLButtonElement, Props>((props, ref) => <Button {...props} innerRef={ref} />);

View File

@@ -21,112 +21,78 @@
* 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 from "react"; import React, { FC, useEffect, useState } from "react";
import { WithTranslation, withTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import InputField from "./InputField"; import InputField from "./InputField";
type State = { type BaseProps = {
password: string;
confirmedPassword: string;
passwordValid: boolean;
passwordConfirmationFailed: boolean;
};
type Props = WithTranslation & {
passwordChanged: (p1: string, p2: boolean) => void; passwordChanged: (p1: string, p2: boolean) => void;
passwordValidator?: (p: string) => boolean; passwordValidator?: (p: string) => boolean;
onReturnPressed?: () => void; onReturnPressed?: () => void;
}; };
class PasswordConfirmation extends React.Component<Props, State> { type InnerProps = BaseProps & {
constructor(props: Props) { innerRef: React.Ref<HTMLInputElement>;
super(props); };
this.state = {
password: "",
confirmedPassword: "",
passwordValid: true,
passwordConfirmationFailed: false,
};
}
componentDidMount() { const PasswordConfirmation: FC<InnerProps> = ({ passwordChanged, passwordValidator, onReturnPressed, innerRef }) => {
this.setState({ const [t] = useTranslation("commons");
password: "", const [password, setPassword] = useState("");
confirmedPassword: "", const [confirmedPassword, setConfirmedPassword] = useState("");
passwordValid: true, const [passwordValid, setPasswordValid] = useState(true);
passwordConfirmationFailed: false, const [passwordConfirmationFailed, setPasswordConfirmationFailed] = useState(false);
}); const isValid = passwordValid && !passwordConfirmationFailed;
}
render() { useEffect(() => passwordChanged(password, isValid), [password, isValid]);
const { t, onReturnPressed } = this.props;
return (
<div className="columns is-multiline">
<div className="column is-half">
<InputField
label={t("password.newPassword")}
type="password"
onChange={this.handlePasswordChange}
value={this.state.password ? this.state.password : ""}
validationError={!this.state.passwordValid}
errorMessage={t("password.passwordInvalid")}
/>
</div>
<div className="column is-half">
<InputField
label={t("password.confirmPassword")}
type="password"
onChange={this.handlePasswordValidationChange}
value={this.state ? this.state.confirmedPassword : ""}
validationError={this.state.passwordConfirmationFailed}
errorMessage={t("password.passwordConfirmFailed")}
onReturnPressed={onReturnPressed}
/>
</div>
</div>
);
}
validatePassword = (password: string) => { const validatePassword = (newPassword: string) => {
const { passwordValidator } = this.props;
if (passwordValidator) { if (passwordValidator) {
return passwordValidator(password); return passwordValidator(newPassword);
} }
return password.length >= 6 && password.length < 32; return newPassword.length >= 6 && newPassword.length < 32;
}; };
handlePasswordValidationChange = (confirmedPassword: string) => { const handlePasswordValidationChange = (newConfirmedPassword: string) => {
const passwordConfirmed = this.state.password === confirmedPassword; setConfirmedPassword(newConfirmedPassword);
setPasswordConfirmationFailed(password !== newConfirmedPassword);
this.setState(
{
confirmedPassword,
passwordConfirmationFailed: !passwordConfirmed,
},
this.propagateChange
);
}; };
handlePasswordChange = (password: string) => { const handlePasswordChange = (newPassword: string) => {
const passwordConfirmationFailed = password !== this.state.confirmedPassword; setPasswordConfirmationFailed(newPassword !== confirmedPassword);
setPassword(newPassword);
this.setState( setPasswordValid(validatePassword(newPassword));
{
passwordValid: this.validatePassword(password),
passwordConfirmationFailed,
password: password,
},
this.propagateChange
);
}; };
isValid = () => { return (
return this.state.passwordValid && !this.state.passwordConfirmationFailed; <div className="columns is-multiline">
}; <div className="column is-half">
<InputField
label={t("password.newPassword")}
type="password"
onChange={event => handlePasswordChange(event.target.value)}
value={password}
validationError={!passwordValid}
errorMessage={t("password.passwordInvalid")}
ref={innerRef}
onReturnPressed={onReturnPressed}
/>
</div>
<div className="column is-half">
<InputField
label={t("password.confirmPassword")}
type="password"
onChange={handlePasswordValidationChange}
value={confirmedPassword}
validationError={passwordConfirmationFailed}
errorMessage={t("password.passwordConfirmFailed")}
onReturnPressed={onReturnPressed}
/>
</div>
</div>
);
};
propagateChange = () => { export default React.forwardRef<HTMLInputElement, BaseProps>((props, ref) => (
this.props.passwordChanged(this.state.password, this.isValid()); <PasswordConfirmation {...props} innerRef={ref} />
}; ));
}
export default withTranslation("commons")(PasswordConfirmation);

View File

@@ -37,15 +37,27 @@ const buttons = [
{ {
className: "is-outlined", className: "is-outlined",
label: "Cancel", label: "Cancel",
onClick: () => null, onClick: () => null
}, },
{ {
label: "Submit", label: "Submit"
}
];
const buttonsWithAutofocus = [
{
className: "is-outlined",
label: "Cancel",
onClick: () => null
}, },
{
label: "I should be focused",
autofocus: true
}
]; ];
storiesOf("Modal/ConfirmAlert", module) storiesOf("Modal/ConfirmAlert", module)
.addDecorator((story) => <MemoryRouter initialEntries={["/"]}>{story()}</MemoryRouter>) .addDecorator(story => <MemoryRouter initialEntries={["/"]}>{story()}</MemoryRouter>)
.add("Default", () => <ConfirmAlert message={body} title={"Are you sure about that?"} buttons={buttons} />) .add("Default", () => <ConfirmAlert message={body} title={"Are you sure about that?"} buttons={buttons} />)
.add("WithButton", () => { .add("WithButton", () => {
const buttonClick = () => { const buttonClick = () => {
@@ -57,4 +69,7 @@ storiesOf("Modal/ConfirmAlert", module)
<div id="modalRoot" /> <div id="modalRoot" />
</> </>
); );
}); })
.add("Autofocus", () => (
<ConfirmAlert message={body} title={"Are you sure about that?"} buttons={buttonsWithAutofocus} />
));

View File

@@ -22,7 +22,7 @@
* SOFTWARE. * SOFTWARE.
*/ */
import * as React from "react"; import * as React from "react";
import { FC, useState } from "react"; import { FC, useRef, useState } from "react";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import Modal from "./Modal"; import Modal from "./Modal";
import classNames from "classnames"; import classNames from "classnames";
@@ -32,6 +32,7 @@ type Button = {
label: string; label: string;
isLoading?: boolean; isLoading?: boolean;
onClick?: () => void | null; onClick?: () => void | null;
autofocus?: boolean;
}; };
type Props = { type Props = {
@@ -43,6 +44,7 @@ type Props = {
export const ConfirmAlert: FC<Props> = ({ title, message, buttons, close }) => { export const ConfirmAlert: FC<Props> = ({ title, message, buttons, close }) => {
const [showModal, setShowModal] = useState(true); const [showModal, setShowModal] = useState(true);
const initialFocusButton = useRef<HTMLButtonElement>(null);
const onClose = () => { const onClose = () => {
if (typeof close === "function") { if (typeof close === "function") {
@@ -69,8 +71,9 @@ 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)} onKeyDown={e => e.key === "Enter" && handleClickButton(button)}
tabIndex={0} tabIndex={0}
ref={button.autofocus ? initialFocusButton : undefined}
> >
{button.label} {button.label}
</button> </button>
@@ -80,7 +83,14 @@ export const ConfirmAlert: FC<Props> = ({ title, message, buttons, close }) => {
); );
return ( return (
(showModal && <Modal title={title} closeFunction={onClose} body={body} active={true} footer={footer} />) || null <Modal
title={title}
closeFunction={onClose}
body={body}
active={showModal}
footer={footer}
initialFocusRef={initialFocusButton}
/>
); );
}; };

View File

@@ -22,13 +22,13 @@
* SOFTWARE. * SOFTWARE.
*/ */
import React, { FC, useEffect, useState } from "react"; import React, { FC, useEffect, useRef, useState } from "react";
import { Modal, InputField, Button, apiClient } from "@scm-manager/ui-components"; import { apiClient, Button, InputField, Modal } from "@scm-manager/ui-components";
import { WithTranslation, withTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Tag } from "@scm-manager/ui-types"; import { Tag } from "@scm-manager/ui-types";
import { isBranchValid } from "../validation"; import { isBranchValid } from "../validation";
type Props = WithTranslation & { type Props = {
existingTagsLink: string; existingTagsLink: string;
tagCreationLink: string; tagCreationLink: string;
onClose: () => void; onClose: () => void;
@@ -40,16 +40,18 @@ type Props = WithTranslation & {
/** /**
* @deprecated * @deprecated
*/ */
const CreateTagModal: FC<Props> = ({ t, onClose, tagCreationLink, existingTagsLink, onCreated, onError, revision }) => { const CreateTagModal: FC<Props> = ({ onClose, tagCreationLink, existingTagsLink, onCreated, onError, revision }) => {
const [t] = useTranslation("repos");
const [newTagName, setNewTagName] = useState(""); const [newTagName, setNewTagName] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [tagNames, setTagNames] = useState<string[]>([]); const [tagNames, setTagNames] = useState<string[]>([]);
const initialFocusRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
apiClient apiClient
.get(existingTagsLink) .get(existingTagsLink)
.then((response) => response.json()) .then(response => response.json())
.then((json) => setTagNames(json._embedded.tags.map((tag: Tag) => tag.name))); .then(json => setTagNames(json._embedded.tags.map((tag: Tag) => tag.name)));
}, [existingTagsLink]); }, [existingTagsLink]);
const createTag = () => { const createTag = () => {
@@ -57,7 +59,7 @@ const CreateTagModal: FC<Props> = ({ t, onClose, tagCreationLink, existingTagsLi
apiClient apiClient
.post(tagCreationLink, { .post(tagCreationLink, {
revision, revision,
name: newTagName, name: newTagName
}) })
.then(onCreated) .then(onCreated)
.catch(onError) .catch(onError)
@@ -83,10 +85,12 @@ const CreateTagModal: FC<Props> = ({ t, onClose, tagCreationLink, existingTagsLi
<InputField <InputField
name="name" name="name"
label={t("tags.create.form.field.name.label")} label={t("tags.create.form.field.name.label")}
onChange={(val) => setNewTagName(val)} onChange={e => setNewTagName(e.target.value)}
value={newTagName} value={newTagName}
validationError={!!validationError} validationError={!!validationError}
errorMessage={t(validationError)} errorMessage={t(validationError)}
onReturnPressed={() => !validationError && newTagName.length > 0 && createTag()}
ref={initialFocusRef}
/> />
<div className="mt-6">{t("tags.create.hint")}</div> <div className="mt-6">{t("tags.create.hint")}</div>
</> </>
@@ -105,8 +109,9 @@ const CreateTagModal: FC<Props> = ({ t, onClose, tagCreationLink, existingTagsLi
</> </>
} }
closeFunction={onClose} closeFunction={onClose}
initialFocusRef={initialFocusRef}
/> />
); );
}; };
export default withTranslation("repos")(CreateTagModal); export default CreateTagModal;

View File

@@ -22,7 +22,7 @@
* SOFTWARE. * SOFTWARE.
*/ */
import * as React from "react"; import * as React from "react";
import { FC, ReactNode } from "react"; import { FC, MutableRefObject, ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Modal } from "./Modal"; import { Modal } from "./Modal";
import Button from "../buttons/Button"; import Button from "../buttons/Button";
@@ -34,6 +34,7 @@ type Props = {
body: ReactNode; body: ReactNode;
active: boolean; active: boolean;
closeButtonLabel?: string; closeButtonLabel?: string;
initialFocusRef?: MutableRefObject<HTMLElement | null>;
}; };
const FullSizedModal = styled(Modal)` const FullSizedModal = styled(Modal)`
@@ -43,11 +44,22 @@ const FullSizedModal = styled(Modal)`
} }
`; `;
const FullscreenModal: FC<Props> = ({ title, closeFunction, body, active, closeButtonLabel }) => { const FullscreenModal: FC<Props> = ({ title, closeFunction, body, active, initialFocusRef, closeButtonLabel }) => {
const [t] = useTranslation("repos"); const [t] = useTranslation("repos");
const footer = <Button label={closeButtonLabel || t("diff.fullscreen.close")} action={closeFunction} color="secondary" />; const footer = (
<Button label={closeButtonLabel || t("diff.fullscreen.close")} action={closeFunction} color="secondary" />
);
return <FullSizedModal title={title} closeFunction={closeFunction} body={body} footer={footer} active={active} />; return (
<FullSizedModal
title={title}
closeFunction={closeFunction}
body={body}
footer={footer}
active={active}
initialFocusRef={initialFocusRef}
/>
);
}; };
export default FullscreenModal; export default FullscreenModal;

View File

@@ -24,13 +24,13 @@
import { storiesOf } from "@storybook/react"; import { storiesOf } from "@storybook/react";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import React, { useState, FC } from "react"; import React, { FC, useRef, useState } from "react";
import Modal from "./Modal"; import Modal from "./Modal";
import Checkbox from "../forms/Checkbox"; import Checkbox from "../forms/Checkbox";
import styled from "styled-components"; import styled from "styled-components";
import ExternalLink from "../navigation/ExternalLink"; import ExternalLink from "../navigation/ExternalLink";
import { Radio, Textarea, InputField } from "../forms"; import { InputField, Radio, Textarea } from "../forms";
import { ButtonGroup, Button } from "../buttons"; import { Button, ButtonGroup } from "../buttons";
import Notification from "../Notification"; import Notification from "../Notification";
import { Autocomplete } from "../index"; import { Autocomplete } from "../index";
import { SelectValue } from "@scm-manager/ui-types"; import { SelectValue } from "@scm-manager/ui-types";
@@ -52,9 +52,8 @@ const text = `Mind-paralyzing change needed improbability vortex machine sorts s
Kakrafoon humanoid undergarment ship powered by GPP-guided bowl of petunias nothing was frequently away incredibly Kakrafoon humanoid undergarment ship powered by GPP-guided bowl of petunias nothing was frequently away incredibly
ordinary mob.`; ordinary mob.`;
// eslint-disable-next-line @typescript-eslint/no-empty-function
const doNothing = () => { const doNothing = () => {
// nothing to do // Do nothing
}; };
const withFormElementsBody = ( const withFormElementsBody = (
<> <>
@@ -114,6 +113,21 @@ storiesOf("Modal/Modal", module)
footer={withFormElementsFooter} footer={withFormElementsFooter}
/> />
)) ))
.add("With initial input field focus", () => {
const ref = useRef<HTMLInputElement | null>(null);
return (
<Modal
closeFunction={doNothing}
active={true}
title={"Hitchhiker Modal"}
footer={withFormElementsFooter}
initialFocusRef={ref}
>
<InputField ref={ref} />
</Modal>
);
})
.add("With initial button focus", () => <RefModal />)
.add("With long tooltips", () => { .add("With long tooltips", () => {
return ( return (
<NonCloseableModal> <NonCloseableModal>
@@ -285,9 +299,20 @@ const NestedModal: FC = ({ children }) => {
const [showOuter, setShowOuter] = useState(true); const [showOuter, setShowOuter] = useState(true);
const [showInner, setShowInner] = useState(false); const [showInner, setShowInner] = useState(false);
const outerBody = ( const outerBody = (
<Button title="Open inner modal" className="button" action={() => setShowInner(true)}> <>
Open inner modal {showInner && (
</Button> <Modal
body={children}
closeFunction={() => setShowInner(!showInner)}
active={showInner}
title="Inner Hitchhiker Modal"
/>
)}
<Button title="Open inner modal" className="button" action={() => setShowInner(true)}>
Open inner modal
</Button>
</>
); );
return ( return (
@@ -301,14 +326,21 @@ const NestedModal: FC = ({ children }) => {
size="M" size="M"
/> />
)} )}
{showInner && (
<Modal
body={children}
closeFunction={() => setShowInner(!showInner)}
active={showInner}
title="Inner Hitchhiker Modal"
/>
)}
</> </>
); );
}; };
const RefModal = () => {
const ref = useRef<HTMLButtonElement>(null);
return (
<Modal
closeFunction={doNothing}
active={true}
title={"Hitchhiker Modal"}
footer={withFormElementsFooter}
initialFocusRef={ref}
>
<button ref={ref}>Hello</button>
</Modal>
);
};

View File

@@ -21,12 +21,10 @@
* 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, KeyboardEvent, useRef } from "react"; import React, { FC, MutableRefObject, useRef } from "react";
import classNames from "classnames"; import classNames from "classnames";
import usePortalRootElement from "../usePortalRootElement";
import ReactDOM from "react-dom";
import styled from "styled-components"; import styled from "styled-components";
import { useTrapFocus } from "../useTrapFocus"; import { Dialog } from "@headlessui/react";
type ModalSize = "S" | "M" | "L"; type ModalSize = "S" | "M" | "L";
@@ -35,13 +33,14 @@ const modalSizes: { [key in ModalSize]: number } = { S: 33, M: 50, L: 66 };
type Props = { type Props = {
title: string; title: string;
closeFunction: () => void; closeFunction: () => void;
body: any; body?: any;
footer?: any; footer?: any;
active: boolean; active: boolean;
className?: string; className?: string;
headColor?: string; headColor?: string;
headTextColor?: string; headTextColor?: string;
size?: ModalSize; size?: ModalSize;
initialFocusRef?: MutableRefObject<HTMLElement | null>;
overflowVisible?: boolean; overflowVisible?: boolean;
}; };
@@ -50,82 +49,67 @@ const SizedModal = styled.div<{ size?: ModalSize; overflow: string }>`
overflow: ${props => props.overflow}; overflow: ${props => props.overflow};
`; `;
const DivWithOptionalOverflow = styled.div<{ overflow: string; borderBottomRadius: string }>`
overflow: ${props => props.overflow};
border-bottom-left-radius: ${props => props.borderBottomRadius};
border-bottom-right-radius: ${props => props.borderBottomRadius};
`;
const SectionWithOptionalOverflow = styled.section<{ overflow: string; borderBottomRadius: string }>`
overflow: ${props => props.overflow};
border-bottom-left-radius: ${props => props.borderBottomRadius};
border-bottom-right-radius: ${props => props.borderBottomRadius};
`;
export const Modal: FC<Props> = ({ export const Modal: FC<Props> = ({
title, title,
closeFunction, closeFunction,
body, body,
children,
footer, footer,
active, active,
className, className,
headColor = "secondary-less", headColor = "secondary-less",
headTextColor = "secondary-most", headTextColor = "secondary-most",
size, size,
initialFocusRef,
overflowVisible overflowVisible
}) => { }) => {
const portalRootElement = usePortalRootElement("modalsRoot"); const closeButtonRef = useRef<HTMLButtonElement | null>(null);
const initialFocusRef = useRef(null);
const trapRef = useTrapFocus({
includeContainer: true,
initialFocus: initialFocusRef.current,
returnFocus: true,
updateNodes: false
});
if (!portalRootElement) {
return null;
}
const isActive = active ? "is-active" : null;
let showFooter = null; let showFooter = null;
if (footer) { if (footer) {
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 overflowAttribute = overflowVisible ? "visible" : "auto"; const overflowAttribute = overflowVisible ? "visible" : "auto";
const borderBottomRadiusAttribute = overflowVisible && !footer ? "inherit" : "unset"; const borderBottomRadiusAttribute = overflowVisible && !footer ? "inherit" : "unset";
const modalElement = ( return (
<DivWithOptionalOverflow <Dialog
className={classNames("modal", className, isActive)} open={active}
ref={trapRef} onClose={closeFunction}
onKeyDown={onKeyDown} className={classNames(
overflow={overflowAttribute} "modal",
borderBottomRadius={borderBottomRadiusAttribute} { "is-active": active },
`is-overflow-${overflowAttribute}`,
`is-border-bottom-radius-${borderBottomRadiusAttribute}`,
className
)}
initialFocus={initialFocusRef ?? closeButtonRef}
> >
<div className="modal-background" onClick={closeFunction} /> <Dialog.Overlay className="modal-background" />
<SizedModal className="modal-card" size={size} overflow={overflowAttribute}> <SizedModal className="modal-card" size={size} overflow={overflowAttribute}>
<header className={classNames("modal-card-head", `has-background-${headColor}`)}> <Dialog.Title as="header" className={classNames("modal-card-head", `has-background-${headColor}`)}>
<h2 className={`modal-card-title m-0 has-text-${headTextColor}`}>{title}</h2> <h2 className={`modal-card-title m-0 has-text-${headTextColor}`}>{title}</h2>
<button className="delete" aria-label="close" onClick={closeFunction} ref={initialFocusRef} autoFocus /> <button
</header> className="delete"
<SectionWithOptionalOverflow className="modal-card-body" overflow={overflowAttribute} borderBottomRadius={borderBottomRadiusAttribute}> aria-label="close"
{body} onClick={closeFunction}
</SectionWithOptionalOverflow> ref={!initialFocusRef ? closeButtonRef : undefined}
/>
</Dialog.Title>
<section
className={classNames(
"modal-card-body",
`is-overflow-${overflowAttribute}`,
`is-border-bottom-radius-${borderBottomRadiusAttribute}`
)}
>
{body || children}
</section>
{showFooter} {showFooter}
</SizedModal> </SizedModal>
</DivWithOptionalOverflow> </Dialog>
); );
return ReactDOM.createPortal(modalElement, portalRootElement);
}; };
export default Modal; export default Modal;

View File

@@ -1,163 +0,0 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { MutableRefObject, RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { FocusableElement, tabbable } from "tabbable";
type Node = HTMLDivElement | null;
interface UseTrapFocus {
includeContainer?: boolean;
initialFocus?: "container" | Node;
returnFocus?: boolean;
updateNodes?: boolean;
}
// Based on https://tobbelindstrom.com/blog/useTrapFocus/
export const useTrapFocus = (options?: UseTrapFocus): MutableRefObject<Node> => {
const node = useRef<Node>(null);
const { includeContainer, initialFocus, returnFocus, updateNodes } = useMemo<UseTrapFocus>(
() => ({
includeContainer: false,
initialFocus: null,
returnFocus: true,
updateNodes: false,
...options,
}),
[options]
);
const [tabbableNodes, setTabbableNodes] = useState<FocusableElement[]>([]);
const previousFocusedNode = useRef<Node>(document.activeElement as Node);
const setInitialFocus = useCallback(() => {
if (initialFocus === "container") {
node.current?.focus();
} else {
initialFocus?.focus();
}
}, [initialFocus]);
const updateTabbableNodes = useCallback(() => {
const { current } = node;
if (current) {
const getTabbableNodes = tabbable(current, { includeContainer });
setTabbableNodes(getTabbableNodes);
return getTabbableNodes;
}
return [];
}, [includeContainer]);
useEffect(() => {
updateTabbableNodes();
if (node.current) setInitialFocus();
}, [setInitialFocus, updateTabbableNodes]);
useEffect(() => {
return () => {
const { current } = previousFocusedNode;
if (current && returnFocus) current.focus();
};
}, [returnFocus]);
const handleKeydown = useCallback(
(event) => {
const { key, keyCode, shiftKey } = event;
let getTabbableNodes = tabbableNodes;
if (updateNodes) getTabbableNodes = updateTabbableNodes();
if ((key === "Tab" || keyCode === 9) && getTabbableNodes.length) {
const firstNode = getTabbableNodes[0];
const lastNode = getTabbableNodes[getTabbableNodes.length - 1];
const { activeElement } = document;
if (!getTabbableNodes.includes(activeElement as FocusableElement)) {
event.preventDefault();
shiftKey ? lastNode.focus() : firstNode.focus();
}
if (shiftKey && activeElement === firstNode) {
event.preventDefault();
lastNode.focus();
}
if (!shiftKey && activeElement === lastNode) {
event.preventDefault();
firstNode.focus();
}
}
},
[tabbableNodes, updateNodes, updateTabbableNodes]
);
useEventListener({
type: "keydown",
listener: handleKeydown,
});
return node;
};
interface UseEventListener {
type: keyof WindowEventMap;
listener: EventListener;
element?: RefObject<Element> | Document | Window | null;
options?: AddEventListenerOptions;
}
export const useEventListener = ({
type,
listener,
element = isSSR ? undefined : window,
options,
}: UseEventListener): void => {
const savedListener = useRef<EventListener>();
useEffect(() => {
savedListener.current = listener;
}, [listener]);
const handleEventListener = useCallback((event: Event) => {
savedListener.current?.(event);
}, []);
useEffect(() => {
const target = getRefElement(element);
target?.addEventListener(type, handleEventListener, options);
return () => target?.removeEventListener(type, handleEventListener);
}, [type, element, options, handleEventListener]);
};
const isSSR = !(typeof window !== "undefined" && window.document?.createElement);
const getRefElement = <T>(element?: RefObject<Element> | T): Element | T | undefined | null => {
if (element && "current" in element) {
return element.current;
}
return element;
};

View File

@@ -759,6 +759,24 @@ form .field:not(.is-grouped) {
} }
} }
.is-overflow-visible {
overflow: visible;
}
.is-overflow-auto {
overflow: auto;
}
.is-border-bottom-radius-inherit {
border-bottom-left-radius: inherit;
border-bottom-right-radius: inherit;
}
.is-border-bottom-radius-unset {
border-bottom-left-radius: unset;
border-bottom-right-radius: unset;
}
// radio // radio
//overwrite bulma's default margin //overwrite bulma's default margin
.radio + .radio { .radio + .radio {

View File

@@ -22,12 +22,13 @@
* SOFTWARE. * SOFTWARE.
*/ */
import * as React from "react"; import * as React from "react";
import { WithTranslation, withTranslation } from "react-i18next"; import { FC, useRef } from "react";
import { useTranslation } from "react-i18next";
import { PendingPlugins, PluginCollection } from "@scm-manager/ui-types"; import { PendingPlugins, PluginCollection } from "@scm-manager/ui-types";
import { Button, ButtonGroup, ErrorNotification, Modal } from "@scm-manager/ui-components"; import { Button, ButtonGroup, ErrorNotification, Modal } from "@scm-manager/ui-components";
import SuccessNotification from "./SuccessNotification"; import SuccessNotification from "./SuccessNotification";
type Props = WithTranslation & { type Props = {
onClose: () => void; onClose: () => void;
pendingPlugins?: PendingPlugins; pendingPlugins?: PendingPlugins;
installedPlugins?: PluginCollection; installedPlugins?: PluginCollection;
@@ -37,148 +38,131 @@ type Props = WithTranslation & {
loading: boolean; loading: boolean;
error?: Error | null; error?: Error | null;
success: boolean; success: boolean;
children?: React.Node;
}; };
class PluginActionModal extends React.Component<Props> { const PluginActionModal: FC<Props> = ({
renderNotifications = () => { error,
const { children, error, success } = this.props; success,
if (error) { children,
return <ErrorNotification error={error} />; installedPlugins,
} else if (success) { pendingPlugins,
return <SuccessNotification />; description,
} else { label,
return children; loading,
} onClose,
}; execute
}) => {
const [t] = useTranslation("admin");
const initialFocusRef = useRef<HTMLButtonElement>(null);
renderModalContent = () => { let notifications;
return ( if (error) {
<> notifications = <ErrorNotification error={error} />;
{this.renderUpdatable()} } else if (success) {
{this.renderInstallQueue()} notifications = <SuccessNotification />;
{this.renderUpdateQueue()} } else {
{this.renderUninstallQueue()} notifications = children;
</>
);
};
renderUpdatable = () => {
const { installedPlugins, t } = this.props;
return (
<>
{installedPlugins && installedPlugins._embedded && installedPlugins._embedded.plugins && (
<>
<strong>{t("plugins.modal.updateQueue")}</strong>
<ul>
{installedPlugins._embedded.plugins
.filter(plugin => plugin._links && plugin._links.update)
.map(plugin => (
<li key={plugin.name}>{plugin.name}</li>
))}
</ul>
</>
)}
</>
);
};
renderInstallQueue = () => {
const { pendingPlugins, t } = this.props;
return (
<>
{pendingPlugins && pendingPlugins._embedded && pendingPlugins._embedded.new.length > 0 && (
<>
<strong>{t("plugins.modal.installQueue")}</strong>
<ul>
{pendingPlugins._embedded.new.map(plugin => (
<li key={plugin.name}>{plugin.name}</li>
))}
</ul>
</>
)}
</>
);
};
renderUpdateQueue = () => {
const { pendingPlugins, t } = this.props;
return (
<>
{pendingPlugins && pendingPlugins._embedded && pendingPlugins._embedded.update.length > 0 && (
<>
<strong>{t("plugins.modal.updateQueue")}</strong>
<ul>
{pendingPlugins._embedded.update.map(plugin => (
<li key={plugin.name}>{plugin.name}</li>
))}
</ul>
</>
)}
</>
);
};
renderUninstallQueue = () => {
const { pendingPlugins, t } = this.props;
return (
<>
{pendingPlugins && pendingPlugins._embedded && pendingPlugins._embedded.uninstall.length > 0 && (
<>
<strong>{t("plugins.modal.uninstallQueue")}</strong>
<ul>
{pendingPlugins._embedded.uninstall.map(plugin => (
<li key={plugin.name}>{plugin.name}</li>
))}
</ul>
</>
)}
</>
);
};
renderBody = () => {
return (
<>
<div className="media">
<div className="content">
<p>{this.props.description}</p>
{this.renderModalContent()}
</div>
</div>
<div className="media">{this.renderNotifications()}</div>
</>
);
};
renderFooter = () => {
const { onClose, t, loading, error, success } = this.props;
return (
<ButtonGroup>
<Button
color="warning"
label={this.props.label}
loading={loading}
action={this.props.execute}
disabled={!!error || success}
/>
<Button label={t("plugins.modal.abort")} action={onClose} />
</ButtonGroup>
);
};
render() {
const { onClose } = this.props;
return (
<Modal
title={this.props.label}
closeFunction={onClose}
body={this.renderBody()}
footer={this.renderFooter()}
active={true}
/>
);
} }
}
export default withTranslation("admin")(PluginActionModal); const updatable = (
<>
{installedPlugins && installedPlugins._embedded && installedPlugins._embedded.plugins && (
<>
<strong>{t("plugins.modal.updateQueue")}</strong>
<ul>
{installedPlugins._embedded.plugins
.filter(plugin => plugin._links && plugin._links.update)
.map(plugin => (
<li key={plugin.name}>{plugin.name}</li>
))}
</ul>
</>
)}
</>
);
const installQueue = (
<>
{pendingPlugins && pendingPlugins._embedded && pendingPlugins._embedded.new.length > 0 && (
<>
<strong>{t("plugins.modal.installQueue")}</strong>
<ul>
{pendingPlugins._embedded.new.map(plugin => (
<li key={plugin.name}>{plugin.name}</li>
))}
</ul>
</>
)}
</>
);
const updateQueue = pendingPlugins && pendingPlugins._embedded && pendingPlugins._embedded.update.length > 0 && (
<>
<strong>{t("plugins.modal.updateQueue")}</strong>
<ul>
{pendingPlugins._embedded.update.map(plugin => (
<li key={plugin.name}>{plugin.name}</li>
))}
</ul>
</>
);
const uninstallQueue = pendingPlugins && pendingPlugins._embedded && pendingPlugins._embedded.uninstall.length > 0 && (
<>
<strong>{t("plugins.modal.uninstallQueue")}</strong>
<ul>
{pendingPlugins._embedded.uninstall.map(plugin => (
<li key={plugin.name}>{plugin.name}</li>
))}
</ul>
</>
);
const content = (
<>
{updatable}
{installQueue}
{updateQueue}
{uninstallQueue}
</>
);
const body = (
<>
<div className="media">
<div className="content">
<p>{description}</p>
{content}
</div>
</div>
<div className="media">{notifications}</div>
</>
);
const footer = (
<ButtonGroup>
<Button
color="warning"
label={label}
loading={loading}
action={execute}
disabled={!!error || success}
ref={initialFocusRef}
/>
<Button label={t("plugins.modal.abort")} action={onClose} />
</ButtonGroup>
);
return (
<Modal
title={label}
closeFunction={onClose}
body={body}
footer={footer}
active={true}
initialFocusRef={initialFocusRef}
/>
);
};
export default PluginActionModal;

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
import React, { FC, useEffect, useState } from "react"; import React, { FC, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import classNames from "classnames"; import classNames from "classnames";
import styled from "styled-components"; import styled from "styled-components";
@@ -66,6 +66,7 @@ const PluginModal: FC<Props> = ({ onClose, pluginAction, plugin }) => {
const error = installError || uninstallError || updateError || pluginCenterAuthInfoError; const error = installError || uninstallError || updateError || pluginCenterAuthInfoError;
const loading = isInstalling || isUninstalling || isUpdating || isLoadingPluginCenterAuthInfo; const loading = isInstalling || isUninstalling || isUpdating || isLoadingPluginCenterAuthInfo;
const isDone = isInstalled || isUninstalled || isUpdated; const isDone = isInstalled || isUninstalled || isUpdated;
const initialFocusRef = useRef<HTMLButtonElement>(null);
useEffect(() => { useEffect(() => {
if (isDone && !shouldRestart) { if (isDone && !shouldRestart) {
@@ -108,6 +109,7 @@ const PluginModal: FC<Props> = ({ onClose, pluginAction, plugin }) => {
action={handlePluginAction} action={handlePluginAction}
loading={loading} loading={loading}
disabled={!!error || isDone} disabled={!!error || isDone}
ref={initialFocusRef}
/> />
<Button label={t("plugins.modal.abort")} action={onClose} /> <Button label={t("plugins.modal.abort")} action={onClose} />
</ButtonGroup> </ButtonGroup>
@@ -253,6 +255,7 @@ const PluginModal: FC<Props> = ({ onClose, pluginAction, plugin }) => {
body={body} body={body}
footer={footer()} footer={footer()}
active={true} active={true}
initialFocusRef={initialFocusRef}
/> />
); );
}; };

View File

@@ -71,7 +71,8 @@ const DeleteRepositoryRole: FC<Props> = ({ confirmDialog = true, role }: Props)
}, },
{ {
label: t("repositoryRole.delete.confirmAlert.cancel"), label: t("repositoryRole.delete.confirmAlert.cancel"),
onClick: () => null onClick: () => null,
autofocus: true
} }
]} ]}
close={() => setShowConfirmAlert(false)} close={() => setShowConfirmAlert(false)}

View File

@@ -47,10 +47,9 @@ const DropDownMenu = styled.div<DropDownMenuProps>`
${props => ${props =>
props.mobilePosition === "right" && props.mobilePosition === "right" &&
css` css`
right: -1.5rem; right: -1.5rem;
left: auto; left: auto;
`}; `};
} }
@media screen and (max-width: ${devices.desktop.width - 1}px) { @media screen and (max-width: ${devices.desktop.width - 1}px) {
@@ -87,7 +86,7 @@ const DropDownMenu = styled.div<DropDownMenuProps>`
left: auto; left: auto;
right: 1.75rem; right: 1.75rem;
} }
`}; `};
} }
`; `;

View File

@@ -66,7 +66,8 @@ export const DeleteGroup: FC<Props> = ({ confirmDialog = true, group }) => {
}, },
{ {
label: t("deleteGroup.confirmAlert.cancel"), label: t("deleteGroup.confirmAlert.cancel"),
onClick: () => null onClick: () => null,
autofocus: true
} }
]} ]}
close={() => setShowConfirmAlert(false)} close={() => setShowConfirmAlert(false)}

View File

@@ -21,11 +21,11 @@
* 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 from "react"; import React, { FC } from "react";
import { WithTranslation, withTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Checkbox } from "@scm-manager/ui-components"; import { Checkbox } from "@scm-manager/ui-components";
type Props = WithTranslation & { type Props = {
name: string; name: string;
checked: boolean; checked: boolean;
onChange?: (value: boolean, name?: string) => void; onChange?: (value: boolean, name?: string) => void;
@@ -33,40 +33,53 @@ type Props = WithTranslation & {
role?: boolean; role?: boolean;
}; };
class PermissionCheckbox extends React.Component<Props> { type InnerProps = Props & {
render() { innerRef: React.Ref<HTMLInputElement>;
const { name, checked, onChange, disabled, role, t } = this.props; };
const key = name.split(":").join("."); const PermissionCheckbox: FC<InnerProps> = ({ name, checked, onChange, disabled, role, innerRef }) => {
const label = role const [t] = useTranslation("plugins");
? t("verbs.repository." + name + ".displayName") const key = name.split(":").join(".");
: this.translateOrDefault("permissions." + key + ".displayName", key);
const helpText = role
? t("verbs.repository." + name + ".description")
: this.translateOrDefault("permissions." + key + ".description", t("permissions.unknown"));
return ( const translateOrDefault = (key: string, defaultText: string) => {
<Checkbox const translation = t(key);
key={name}
name={name}
label={label}
helpText={helpText}
checked={checked}
onChange={onChange}
disabled={disabled}
testId={label}
/>
);
}
translateOrDefault = (key: string, defaultText: string) => {
const translation = this.props.t(key);
if (translation === key) { if (translation === key) {
return defaultText; return defaultText;
} else { } else {
return translation; return translation;
} }
}; };
}
export default withTranslation("plugins")(PermissionCheckbox); const label = role
? t("verbs.repository." + name + ".displayName")
: translateOrDefault("permissions." + key + ".displayName", key);
const helpText = role
? t("verbs.repository." + name + ".description")
: translateOrDefault("permissions." + key + ".description", t("permissions.unknown"));
const commonCheckboxProps = {
key: name,
name,
label,
helpText,
checked,
disabled,
testId: label
};
if (innerRef) {
return (
<Checkbox
{...commonCheckboxProps}
onChange={onChange ? event => onChange(event.target.checked, name) : undefined}
ref={innerRef}
/>
);
}
return <Checkbox {...commonCheckboxProps} onChange={onChange ? newValue => onChange(newValue, name) : undefined} />;
};
export default React.forwardRef<HTMLInputElement, Props>((props, ref) => (
<PermissionCheckbox {...props} innerRef={ref} />
));

View File

@@ -31,7 +31,9 @@ import { useTranslation } from "react-i18next";
const BranchCommitDateCommitter: FC<{ branch: Branch }> = ({ branch }) => { const BranchCommitDateCommitter: FC<{ branch: Branch }> = ({ branch }) => {
const [t] = useTranslation("repos"); const [t] = useTranslation("repos");
const committedAt = <DateFromNow className={classNames("is-size-7", "has-text-secondary")} date={branch.lastCommitDate} />; const committedAt = (
<DateFromNow className={classNames("is-size-7", "has-text-secondary")} date={branch.lastCommitDate} />
);
if (branch.lastCommitter?.name) { if (branch.lastCommitter?.name) {
return ( return (
<> <>

View File

@@ -82,7 +82,8 @@ const BranchTable: FC<Props> = ({ repository, baseUrl, branches, type, branchesD
}, },
{ {
label: t("branch.delete.confirmAlert.cancel"), label: t("branch.delete.confirmAlert.cancel"),
onClick: () => abortDelete() onClick: () => abortDelete(),
autofocus: true
} }
]} ]}
close={() => abortDelete()} close={() => abortDelete()}

View File

@@ -61,7 +61,8 @@ const DeleteBranch: FC<Props> = ({ repository, branch }: Props) => {
}, },
{ {
label: t("branch.delete.confirmAlert.cancel"), label: t("branch.delete.confirmAlert.cancel"),
onClick: () => null onClick: () => null,
autofocus: true
} }
]} ]}
close={() => setShowConfirmAlert(false)} close={() => setShowConfirmAlert(false)}

View File

@@ -23,11 +23,11 @@
*/ */
import React, { FC, useEffect, useState } from "react"; import React, { FC, useEffect, useState } from "react";
import { CUSTOM_NAMESPACE_STRATEGY, RepositoryCreation} from "@scm-manager/ui-types"; import { CUSTOM_NAMESPACE_STRATEGY, RepositoryCreation } from "@scm-manager/ui-types";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { InputField } from "@scm-manager/ui-components"; import { InputField } from "@scm-manager/ui-components";
import * as validator from "./form/repositoryValidation"; import * as validator from "./form/repositoryValidation";
import { useNamespaceStrategies} from "@scm-manager/ui-api"; import { useNamespaceStrategies } from "@scm-manager/ui-api";
import NamespaceInput from "./NamespaceInput"; import NamespaceInput from "./NamespaceInput";
type Props = { type Props = {

View File

@@ -22,7 +22,7 @@
* SOFTWARE. * SOFTWARE.
*/ */
import React, { FC, useEffect, useState } from "react"; import React, { FC, useEffect, useRef, useState } from "react";
import { Modal, InputField, Button, Loading, ErrorNotification } from "@scm-manager/ui-components"; import { Modal, InputField, Button, Loading, ErrorNotification } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Changeset, Repository } from "@scm-manager/ui-types"; import { Changeset, Repository } from "@scm-manager/ui-types";
@@ -43,13 +43,15 @@ const CreateTagModal: FC<Props> = ({ repository, changeset, onClose }) => {
); );
const [t] = useTranslation("repos"); const [t] = useTranslation("repos");
const [newTagName, setNewTagName] = useState(""); const [newTagName, setNewTagName] = useState("");
const initialFocusRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
if (createdTag) { if (createdTag) {
onClose(); onClose();
} }
}, [createdTag, onClose]); }, [createdTag, onClose]);
const tagNames = tags?._embedded.tags.map(tag => tag.name); const tagNames = tags?._embedded?.tags.map(tag => tag.name);
let validationError = ""; let validationError = "";
@@ -74,10 +76,12 @@ const CreateTagModal: FC<Props> = ({ repository, changeset, onClose }) => {
<InputField <InputField
name="name" name="name"
label={t("tags.create.form.field.name.label")} label={t("tags.create.form.field.name.label")}
onChange={val => setNewTagName(val)} onChange={event => setNewTagName(event.target.value)}
value={newTagName} value={newTagName}
validationError={!!validationError} validationError={!!validationError}
errorMessage={t(validationError)} errorMessage={t(validationError)}
ref={initialFocusRef}
onReturnPressed={() => !validationError && newTagName.length > 0 && create(newTagName)}
/> />
<div className="mt-5">{t("tags.create.hint")}</div> <div className="mt-5">{t("tags.create.hint")}</div>
</> </>
@@ -103,6 +107,7 @@ const CreateTagModal: FC<Props> = ({ repository, changeset, onClose }) => {
</> </>
} }
closeFunction={onClose} closeFunction={onClose}
initialFocusRef={initialFocusRef}
/> />
); );
}; };

View File

@@ -59,7 +59,8 @@ const ArchiveRepo: FC<Props> = ({ repository, confirmDialog = true }) => {
}, },
{ {
label: t("archiveRepo.confirmAlert.cancel"), label: t("archiveRepo.confirmAlert.cancel"),
onClick: () => null onClick: () => null,
autofocus: true
} }
]} ]}
close={() => setShowConfirmAlert(false)} close={() => setShowConfirmAlert(false)}

View File

@@ -71,7 +71,8 @@ const DeleteRepo: FC<Props> = ({ repository, confirmDialog = true }) => {
}, },
{ {
label: t("deleteRepo.confirmAlert.cancel"), label: t("deleteRepo.confirmAlert.cancel"),
onClick: () => null onClick: () => null,
autofocus: true
} }
]} ]}
close={() => setShowConfirmAlert(false)} close={() => setShowConfirmAlert(false)}

View File

@@ -22,7 +22,7 @@
* SOFTWARE. * SOFTWARE.
*/ */
import React, { FC, useState } from "react"; import React, { FC, useRef, useState } from "react";
import { Repository } from "@scm-manager/ui-types"; import { Repository } from "@scm-manager/ui-types";
import { Button, ButtonGroup, ErrorNotification, InputField, Level, Loading, Modal } from "@scm-manager/ui-components"; import { Button, ButtonGroup, ErrorNotification, InputField, Level, Loading, Modal } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -53,6 +53,7 @@ const RenameRepository: FC<Props> = ({ repository }) => {
error: namespaceStrategyLoadError, error: namespaceStrategyLoadError,
data: namespaceStrategies data: namespaceStrategies
} = useNamespaceStrategies(); } = useNamespaceStrategies();
const initialFocusRef = useRef<HTMLInputElement>(null);
if (isRenamed) { if (isRenamed) {
return <Redirect to={`/repo/${namespace}/${name}`} />; return <Redirect to={`/repo/${namespace}/${name}`} />;
@@ -91,7 +92,9 @@ const RenameRepository: FC<Props> = ({ repository }) => {
helpText={t("help.nameHelpText")} helpText={t("help.nameHelpText")}
validationError={nameValidationError} validationError={nameValidationError}
value={name} value={name}
onChange={handleNameChange} onChange={event => handleNameChange(event.target.value)}
onReturnPressed={() => isValid && renameRepository(namespace, name)}
ref={initialFocusRef}
/> />
<NamespaceInput <NamespaceInput
namespace={namespace} namespace={namespace}
@@ -130,6 +133,7 @@ const RenameRepository: FC<Props> = ({ repository }) => {
footer={footer} footer={footer}
body={modalBody} body={modalBody}
closeFunction={() => setShowModal(false)} closeFunction={() => setShowModal(false)}
initialFocusRef={initialFocusRef}
overflowVisible={true} overflowVisible={true}
/> />
); );

View File

@@ -56,7 +56,8 @@ const UnarchiveRepo: FC<Props> = ({ repository, confirmDialog = true }) => {
className: "is-outlined", className: "is-outlined",
label: t("unarchiveRepo.confirmAlert.submit"), label: t("unarchiveRepo.confirmAlert.submit"),
isLoading, isLoading,
onClick: () => unarchiveRepoCallback() onClick: () => unarchiveRepoCallback(),
autofocus: true
}, },
{ {
label: t("unarchiveRepo.confirmAlert.cancel"), label: t("unarchiveRepo.confirmAlert.cancel"),

View File

@@ -67,7 +67,8 @@ const DeletePermissionButton: FC<Props> = ({ namespaceOrRepository, permission,
}, },
{ {
label: t("permission.delete-permission-button.confirm-alert.cancel"), label: t("permission.delete-permission-button.confirm-alert.cancel"),
onClick: () => null onClick: () => null,
autofocus: true
} }
]} ]}
close={() => setShowConfirmAlert(false)} close={() => setShowConfirmAlert(false)}

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
import React, { FC, useEffect, useState } from "react"; import React, { FC, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button, ButtonGroup, Modal, SubmitButton } from "@scm-manager/ui-components"; import { Button, ButtonGroup, Modal, SubmitButton } from "@scm-manager/ui-components";
import PermissionCheckbox from "../../../permissions/components/PermissionCheckbox"; import PermissionCheckbox from "../../../permissions/components/PermissionCheckbox";
@@ -45,6 +45,8 @@ const createPreSelection = (availableVerbs: string[], selectedVerbs?: string[]):
const AdvancedPermissionsDialog: FC<Props> = ({ availableVerbs, selectedVerbs, readOnly, onSubmit, onClose }) => { const AdvancedPermissionsDialog: FC<Props> = ({ availableVerbs, selectedVerbs, readOnly, onSubmit, onClose }) => {
const [verbs, setVerbs] = useState<SelectedVerbs>({}); const [verbs, setVerbs] = useState<SelectedVerbs>({});
const [t] = useTranslation("repos"); const [t] = useTranslation("repos");
const initialFocusRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
setVerbs(createPreSelection(availableVerbs, selectedVerbs)); setVerbs(createPreSelection(availableVerbs, selectedVerbs));
}, [availableVerbs, selectedVerbs]); }, [availableVerbs, selectedVerbs]);
@@ -58,7 +60,7 @@ const AdvancedPermissionsDialog: FC<Props> = ({ availableVerbs, selectedVerbs, r
} }
}; };
const verbSelectBoxes = Object.entries(verbs).map(([name, checked]) => ( const verbSelectBoxes = Object.entries(verbs).map(([name, checked], index) => (
<PermissionCheckbox <PermissionCheckbox
key={name} key={name}
name={name} name={name}
@@ -66,6 +68,7 @@ const AdvancedPermissionsDialog: FC<Props> = ({ availableVerbs, selectedVerbs, r
onChange={handleChange} onChange={handleChange}
disabled={readOnly} disabled={readOnly}
role={true} role={true}
ref={index === 0 ? initialFocusRef : undefined}
/> />
)); ));
@@ -93,6 +96,7 @@ const AdvancedPermissionsDialog: FC<Props> = ({ availableVerbs, selectedVerbs, r
body={<>{verbSelectBoxes}</>} body={<>{verbSelectBoxes}</>}
footer={footer} footer={footer}
active={true} active={true}
initialFocusRef={initialFocusRef}
/> />
); );
}; };

View File

@@ -31,8 +31,8 @@ import { File, Link, Repository } from "@scm-manager/ui-types";
import { ErrorNotification, Loading, PdfViewer } from "@scm-manager/ui-components"; import { ErrorNotification, Loading, PdfViewer } from "@scm-manager/ui-components";
import SwitchableMarkdownViewer from "../components/content/SwitchableMarkdownViewer"; import SwitchableMarkdownViewer from "../components/content/SwitchableMarkdownViewer";
import styled from "styled-components"; import styled from "styled-components";
import { ContentType, useContentType } from "@scm-manager/ui-api"; import { useContentType } from "@scm-manager/ui-api";
import {determineSyntaxHighlightingLanguage} from "../utils/files"; import { determineSyntaxHighlightingLanguage } from "../utils/files";
const NoSpacingSyntaxHighlighterContainer = styled.div` const NoSpacingSyntaxHighlighterContainer = styled.div`
& pre { & pre {
@@ -47,8 +47,6 @@ type Props = {
revision: string; revision: string;
}; };
const SourcesView: FC<Props> = ({ file, repository, revision }) => { const SourcesView: FC<Props> = ({ file, repository, revision }) => {
const { data: contentTypeData, error, isLoading } = useContentType((file._links.self as Link).href); const { data: contentTypeData, error, isLoading } = useContentType((file._links.self as Link).href);

View File

@@ -81,7 +81,8 @@ const TagTable: FC<Props> = ({ repository, baseUrl, tags }) => {
}, },
{ {
label: t("tag.delete.confirmAlert.cancel"), label: t("tag.delete.confirmAlert.cancel"),
onClick: () => abortDelete() onClick: () => abortDelete(),
autofocus: true
} }
]} ]}
close={() => abortDelete()} close={() => abortDelete()}

View File

@@ -59,7 +59,8 @@ const DeleteTag: FC<Props> = ({ tag, repository }) => {
}, },
{ {
label: t("tag.delete.confirmAlert.cancel"), label: t("tag.delete.confirmAlert.cancel"),
onClick: () => null onClick: () => null,
autofocus: true
} }
]} ]}
close={() => setShowConfirmAlert(false)} close={() => setShowConfirmAlert(false)}

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
import React, { FC, useEffect, useState } from "react"; import React, { FC, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import classNames from "classnames"; import classNames from "classnames";
import { User } from "@scm-manager/ui-types"; import { User } from "@scm-manager/ui-types";
@@ -56,6 +56,7 @@ const UserConverter: FC<Props> = ({ user }) => {
} = useConvertToExternal(); } = useConvertToExternal();
const error = convertingToExternalError || convertingToInternalError || undefined; const error = convertingToExternalError || convertingToInternalError || undefined;
const isLoading = isConvertingToExternal || isConvertingToInternal; const isLoading = isConvertingToExternal || isConvertingToInternal;
const initialFocusRef = useRef<HTMLInputElement>(null);
useEffect(() => setShowPasswordModal(false), [user]); useEffect(() => setShowPasswordModal(false), [user]);
@@ -103,15 +104,12 @@ const UserConverter: FC<Props> = ({ user }) => {
} }
}; };
const passwordChangeField = (
<PasswordConfirmation passwordChanged={changePassword} onReturnPressed={onReturnPressed} />
);
const passwordModal = ( const passwordModal = (
<Modal <Modal
body={passwordChangeField}
closeFunction={() => setShowPasswordModal(false)} closeFunction={() => setShowPasswordModal(false)}
active={showPasswordModal} active={showPasswordModal}
title={t("userForm.modal.passwordRequired")} title={t("userForm.modal.passwordRequired")}
initialFocusRef={initialFocusRef}
footer={ footer={
<SubmitButton <SubmitButton
action={() => password && passwordValid && convertToInternal(user, password)} action={() => password && passwordValid && convertToInternal(user, password)}
@@ -121,7 +119,9 @@ const UserConverter: FC<Props> = ({ user }) => {
label={t("userForm.modal.convertToInternal")} label={t("userForm.modal.convertToInternal")}
/> />
} }
/> >
<PasswordConfirmation passwordChanged={changePassword} onReturnPressed={onReturnPressed} ref={initialFocusRef} />
</Modal>
); );
return ( return (

View File

@@ -66,7 +66,8 @@ const DeleteUser: FC<Props> = ({ confirmDialog = true, user }) => {
}, },
{ {
label: t("deleteUser.confirmAlert.cancel"), label: t("deleteUser.confirmAlert.cancel"),
onClick: () => null onClick: () => null,
autofocus: true
} }
]} ]}
close={() => setShowConfirmAlert(false)} close={() => setShowConfirmAlert(false)}

View File

@@ -1845,6 +1845,11 @@
resolved "https://registry.yarnpkg.com/@hapi/bourne/-/bourne-2.0.0.tgz#5bb2193eb685c0007540ca61d166d4e1edaf918d" resolved "https://registry.yarnpkg.com/@hapi/bourne/-/bourne-2.0.0.tgz#5bb2193eb685c0007540ca61d166d4e1edaf918d"
integrity sha512-WEezM1FWztfbzqIUbsDzFRVMxSoLy3HugVcux6KDDtTqzPsLE8NDRHfXvev66aH1i2oOKKar3/XDjbvh/OUBdg== integrity sha512-WEezM1FWztfbzqIUbsDzFRVMxSoLy3HugVcux6KDDtTqzPsLE8NDRHfXvev66aH1i2oOKKar3/XDjbvh/OUBdg==
"@headlessui/react@^1.4.3":
version "1.4.3"
resolved "https://registry.yarnpkg.com/@headlessui/react/-/react-1.4.3.tgz#f77c6bb5cb4a614a5d730fb880cab502d48abf37"
integrity sha512-n2IQkaaw0aAAlQS5MEXsM4uRK+w18CrM72EqnGRl/UBOQeQajad8oiKXR9Nk15jOzTFQjpxzrZMf1NxHidFBiw==
"@hypnosphi/create-react-context@^0.3.1": "@hypnosphi/create-react-context@^0.3.1":
version "0.3.1" version "0.3.1"
resolved "https://registry.yarnpkg.com/@hypnosphi/create-react-context/-/create-react-context-0.3.1.tgz#f8bfebdc7665f5d426cba3753e0e9c7d3154d7c6" resolved "https://registry.yarnpkg.com/@hypnosphi/create-react-context/-/create-react-context-0.3.1.tgz#f8bfebdc7665f5d426cba3753e0e9c7d3154d7c6"