mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-11 16:05: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
@@ -37,15 +37,27 @@ const buttons = [
|
||||
{
|
||||
className: "is-outlined",
|
||||
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)
|
||||
.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("WithButton", () => {
|
||||
const buttonClick = () => {
|
||||
@@ -57,4 +69,7 @@ storiesOf("Modal/ConfirmAlert", module)
|
||||
<div id="modalRoot" />
|
||||
</>
|
||||
);
|
||||
});
|
||||
})
|
||||
.add("Autofocus", () => (
|
||||
<ConfirmAlert message={body} title={"Are you sure about that?"} buttons={buttonsWithAutofocus} />
|
||||
));
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* SOFTWARE.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { FC, useState } from "react";
|
||||
import { FC, useRef, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import Modal from "./Modal";
|
||||
import classNames from "classnames";
|
||||
@@ -32,6 +32,7 @@ type Button = {
|
||||
label: string;
|
||||
isLoading?: boolean;
|
||||
onClick?: () => void | null;
|
||||
autofocus?: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -43,6 +44,7 @@ type Props = {
|
||||
|
||||
export const ConfirmAlert: FC<Props> = ({ title, message, buttons, close }) => {
|
||||
const [showModal, setShowModal] = useState(true);
|
||||
const initialFocusButton = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const onClose = () => {
|
||||
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" : "")}
|
||||
key={index}
|
||||
onClick={() => handleClickButton(button)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleClickButton(button)}
|
||||
onKeyDown={e => e.key === "Enter" && handleClickButton(button)}
|
||||
tabIndex={0}
|
||||
ref={button.autofocus ? initialFocusButton : undefined}
|
||||
>
|
||||
{button.label}
|
||||
</button>
|
||||
@@ -80,7 +83,14 @@ export const ConfirmAlert: FC<Props> = ({ title, message, buttons, close }) => {
|
||||
);
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
import React, { FC, useEffect, useState } from "react";
|
||||
import { Modal, InputField, Button, apiClient } from "@scm-manager/ui-components";
|
||||
import { WithTranslation, withTranslation } from "react-i18next";
|
||||
import React, { FC, useEffect, useRef, useState } from "react";
|
||||
import { apiClient, Button, InputField, Modal } from "@scm-manager/ui-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tag } from "@scm-manager/ui-types";
|
||||
import { isBranchValid } from "../validation";
|
||||
|
||||
type Props = WithTranslation & {
|
||||
type Props = {
|
||||
existingTagsLink: string;
|
||||
tagCreationLink: string;
|
||||
onClose: () => void;
|
||||
@@ -40,16 +40,18 @@ type Props = WithTranslation & {
|
||||
/**
|
||||
* @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 [loading, setLoading] = useState(false);
|
||||
const [tagNames, setTagNames] = useState<string[]>([]);
|
||||
const initialFocusRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiClient
|
||||
.get(existingTagsLink)
|
||||
.then((response) => response.json())
|
||||
.then((json) => setTagNames(json._embedded.tags.map((tag: Tag) => tag.name)));
|
||||
.then(response => response.json())
|
||||
.then(json => setTagNames(json._embedded.tags.map((tag: Tag) => tag.name)));
|
||||
}, [existingTagsLink]);
|
||||
|
||||
const createTag = () => {
|
||||
@@ -57,7 +59,7 @@ const CreateTagModal: FC<Props> = ({ t, onClose, tagCreationLink, existingTagsLi
|
||||
apiClient
|
||||
.post(tagCreationLink, {
|
||||
revision,
|
||||
name: newTagName,
|
||||
name: newTagName
|
||||
})
|
||||
.then(onCreated)
|
||||
.catch(onError)
|
||||
@@ -83,10 +85,12 @@ const CreateTagModal: FC<Props> = ({ t, onClose, tagCreationLink, existingTagsLi
|
||||
<InputField
|
||||
name="name"
|
||||
label={t("tags.create.form.field.name.label")}
|
||||
onChange={(val) => setNewTagName(val)}
|
||||
onChange={e => setNewTagName(e.target.value)}
|
||||
value={newTagName}
|
||||
validationError={!!validationError}
|
||||
errorMessage={t(validationError)}
|
||||
onReturnPressed={() => !validationError && newTagName.length > 0 && createTag()}
|
||||
ref={initialFocusRef}
|
||||
/>
|
||||
<div className="mt-6">{t("tags.create.hint")}</div>
|
||||
</>
|
||||
@@ -105,8 +109,9 @@ const CreateTagModal: FC<Props> = ({ t, onClose, tagCreationLink, existingTagsLi
|
||||
</>
|
||||
}
|
||||
closeFunction={onClose}
|
||||
initialFocusRef={initialFocusRef}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default withTranslation("repos")(CreateTagModal);
|
||||
export default CreateTagModal;
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* SOFTWARE.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { FC, ReactNode } from "react";
|
||||
import { FC, MutableRefObject, ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Modal } from "./Modal";
|
||||
import Button from "../buttons/Button";
|
||||
@@ -34,6 +34,7 @@ type Props = {
|
||||
body: ReactNode;
|
||||
active: boolean;
|
||||
closeButtonLabel?: string;
|
||||
initialFocusRef?: MutableRefObject<HTMLElement | null>;
|
||||
};
|
||||
|
||||
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 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;
|
||||
|
||||
@@ -24,13 +24,13 @@
|
||||
|
||||
import { storiesOf } from "@storybook/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import React, { useState, FC } from "react";
|
||||
import React, { FC, useRef, useState } from "react";
|
||||
import Modal from "./Modal";
|
||||
import Checkbox from "../forms/Checkbox";
|
||||
import styled from "styled-components";
|
||||
import ExternalLink from "../navigation/ExternalLink";
|
||||
import { Radio, Textarea, InputField } from "../forms";
|
||||
import { ButtonGroup, Button } from "../buttons";
|
||||
import { InputField, Radio, Textarea } from "../forms";
|
||||
import { Button, ButtonGroup } from "../buttons";
|
||||
import Notification from "../Notification";
|
||||
import { Autocomplete } from "../index";
|
||||
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
|
||||
ordinary mob.`;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
const doNothing = () => {
|
||||
// nothing to do
|
||||
// Do nothing
|
||||
};
|
||||
const withFormElementsBody = (
|
||||
<>
|
||||
@@ -114,6 +113,21 @@ storiesOf("Modal/Modal", module)
|
||||
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", () => {
|
||||
return (
|
||||
<NonCloseableModal>
|
||||
@@ -285,9 +299,20 @@ const NestedModal: FC = ({ children }) => {
|
||||
const [showOuter, setShowOuter] = useState(true);
|
||||
const [showInner, setShowInner] = useState(false);
|
||||
const outerBody = (
|
||||
<Button title="Open inner modal" className="button" action={() => setShowInner(true)}>
|
||||
Open inner modal
|
||||
</Button>
|
||||
<>
|
||||
{showInner && (
|
||||
<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 (
|
||||
@@ -301,14 +326,21 @@ const NestedModal: FC = ({ children }) => {
|
||||
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
|
||||
* SOFTWARE.
|
||||
*/
|
||||
import React, { FC, KeyboardEvent, useRef } from "react";
|
||||
import React, { FC, MutableRefObject, useRef } from "react";
|
||||
import classNames from "classnames";
|
||||
import usePortalRootElement from "../usePortalRootElement";
|
||||
import ReactDOM from "react-dom";
|
||||
import styled from "styled-components";
|
||||
import { useTrapFocus } from "../useTrapFocus";
|
||||
import { Dialog } from "@headlessui/react";
|
||||
|
||||
type ModalSize = "S" | "M" | "L";
|
||||
|
||||
@@ -35,13 +33,14 @@ const modalSizes: { [key in ModalSize]: number } = { S: 33, M: 50, L: 66 };
|
||||
type Props = {
|
||||
title: string;
|
||||
closeFunction: () => void;
|
||||
body: any;
|
||||
body?: any;
|
||||
footer?: any;
|
||||
active: boolean;
|
||||
className?: string;
|
||||
headColor?: string;
|
||||
headTextColor?: string;
|
||||
size?: ModalSize;
|
||||
initialFocusRef?: MutableRefObject<HTMLElement | null>;
|
||||
overflowVisible?: boolean;
|
||||
};
|
||||
|
||||
@@ -50,82 +49,67 @@ const SizedModal = styled.div<{ size?: ModalSize; overflow: string }>`
|
||||
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> = ({
|
||||
title,
|
||||
closeFunction,
|
||||
body,
|
||||
children,
|
||||
footer,
|
||||
active,
|
||||
className,
|
||||
headColor = "secondary-less",
|
||||
headTextColor = "secondary-most",
|
||||
size,
|
||||
initialFocusRef,
|
||||
overflowVisible
|
||||
}) => {
|
||||
const portalRootElement = usePortalRootElement("modalsRoot");
|
||||
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;
|
||||
|
||||
const closeButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
let showFooter = null;
|
||||
|
||||
if (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 borderBottomRadiusAttribute = overflowVisible && !footer ? "inherit" : "unset";
|
||||
|
||||
const modalElement = (
|
||||
<DivWithOptionalOverflow
|
||||
className={classNames("modal", className, isActive)}
|
||||
ref={trapRef}
|
||||
onKeyDown={onKeyDown}
|
||||
overflow={overflowAttribute}
|
||||
borderBottomRadius={borderBottomRadiusAttribute}
|
||||
return (
|
||||
<Dialog
|
||||
open={active}
|
||||
onClose={closeFunction}
|
||||
className={classNames(
|
||||
"modal",
|
||||
{ "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}>
|
||||
<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>
|
||||
<button className="delete" aria-label="close" onClick={closeFunction} ref={initialFocusRef} autoFocus />
|
||||
</header>
|
||||
<SectionWithOptionalOverflow className="modal-card-body" overflow={overflowAttribute} borderBottomRadius={borderBottomRadiusAttribute}>
|
||||
{body}
|
||||
</SectionWithOptionalOverflow>
|
||||
<button
|
||||
className="delete"
|
||||
aria-label="close"
|
||||
onClick={closeFunction}
|
||||
ref={!initialFocusRef ? closeButtonRef : undefined}
|
||||
/>
|
||||
</Dialog.Title>
|
||||
<section
|
||||
className={classNames(
|
||||
"modal-card-body",
|
||||
`is-overflow-${overflowAttribute}`,
|
||||
`is-border-bottom-radius-${borderBottomRadiusAttribute}`
|
||||
)}
|
||||
>
|
||||
{body || children}
|
||||
</section>
|
||||
{showFooter}
|
||||
</SizedModal>
|
||||
</DivWithOptionalOverflow>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
return ReactDOM.createPortal(modalElement, portalRootElement);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
|
||||
Reference in New Issue
Block a user