mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-10 23:45:44 +01:00
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:
committed by
GitHub
parent
d8fcb12402
commit
d0cf976a54
2
gradle/changelog/modal-focus.yaml
Normal file
2
gradle/changelog/modal-focus.yaml
Normal 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))
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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} />);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
));
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ type DropDownMenuProps = {
|
|||||||
|
|
||||||
const DropDownMenu = styled.div<DropDownMenuProps>`
|
const DropDownMenu = styled.div<DropDownMenuProps>`
|
||||||
min-width: 35rem;
|
min-width: 35rem;
|
||||||
|
|
||||||
@media screen and (max-width: ${devices.desktop.width}px) {
|
@media screen and (max-width: ${devices.desktop.width}px) {
|
||||||
min-width: 30rem;
|
min-width: 30rem;
|
||||||
}
|
}
|
||||||
@@ -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) {
|
||||||
@@ -79,7 +78,7 @@ const DropDownMenu = styled.div<DropDownMenuProps>`
|
|||||||
@media screen and (min-width: ${devices.desktop.width}px) {
|
@media screen and (min-width: ${devices.desktop.width}px) {
|
||||||
right: 1.3rem;
|
right: 1.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
${props =>
|
${props =>
|
||||||
props.mobilePosition === "right" &&
|
props.mobilePosition === "right" &&
|
||||||
css`
|
css`
|
||||||
@@ -87,7 +86,7 @@ const DropDownMenu = styled.div<DropDownMenuProps>`
|
|||||||
left: auto;
|
left: auto;
|
||||||
right: 1.75rem;
|
right: 1.75rem;
|
||||||
}
|
}
|
||||||
`};
|
`};
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
));
|
||||||
|
|||||||
@@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user