Skip to content

Commit

Permalink
Modal focus trapping (#172)
Browse files Browse the repository at this point in the history
* Modal: remove unnecessary comments

* Modal: remove dialog from the tab index

* Modal: give all focusable children a tab index and make them loop

* DeleteModal: Remove dialog from the tab index. Give all focusable children a tab index and make them loop. Add event listener to be able to close modal with esc.

* DeleteModal: add aria label to close modal button

* SubModal: Remove dialog from the tab index. Give all focusable children a tab index and make them loop. Add event listener to be able to close modal with esc.

* SubModal: add aria labels for icon buttons

* SubModal: wrap closeAllModals with use callback hook

* Modal: remove aria modal

* CreateLabelModal: change import to import type

* changed modal to close on mouse dow

* Frontend: Kanban column polish (#167)

* Polish kanban column layout

* Fix button position

* Add padding to show full outline

* Change height to avoid column warping on drag

* Fix height

* Adjust task positions

* Add missing tabIndex and onKeyDown to TaskMemberItem (#168)

* Password update validation (#166)

* removed couple comments and added backend password update validation

* Type fix

* fixes

* KanbanTask: Remove dialog from the tab index. Give all focusable children a tab index and make them loop. Add event listener to be able to close modal with esc and add aria label for the close task button.

* CreateLabelModal: add autofocus for the input field

* EditLabelModal: add autofocus for the input field

* KanbanTask: change closing the task modal to onMouseDown

* SubModal: change closing the modal to onMouseDown

* IconButton: fix sizing issues when only icon is displayed

* LabelModal: make checkbox focusable and selectable with keyboard

* LabelModal: refactor code to be able to select the task by clicking the task name also

* SubModal: change min-w-[400px] to only apply with larger screens

* LabelModal: give label modal a max height and give overflow a scroll

* TaskMembersModal: give modal a max height and set overflow to scroll

* Modal: remove unnecessary styling from the dialog and change overflow to hidden

* ProjectMembersModal: give div a max height and set overflow to auto

* SubModal: remove unnecessary styling from the dialog

* DeleteEventModal: refactor button to enable better styling

* CalendarEventModal: Remove dialog from the tab index. Give all focusable children a tab index and make them loop. Add listener to be able to close modal with esc and add aria label for the close modal button.

* make suggested fixes to LabelModal and TaskMemberItem

* fix overflow issues in horizontal mobile screens

* fix overflow issues in horizontal mobile screens

* DeleteModal: fix issues on vertical mobile screen

* fix overflow issues in horizontal mobile screens (Modal)

* frontend: pass only required events to eventmodal

* frontend: calendar change from y.array to y.map

* frontend: increase event maxlength (#170)

* frontend: invalidate projects cache on userUpdate

* frontend: update backend url

* Frontend: Polish kanban task (#173)

* Polish kanban task

* Fix disappearing members

* Change member limits

* Scale kanban deadline buttons

* Add suggested fixes

* KanbanTask: Remove dialog from the tab index. Give all focusable children a tab index and make them loop. Add event listener to be able to close modal with esc and add aria label for the close task button.

* KanbanTask: fix deadline label styling

* Modal: change overflow-y-hidden to auto

* ProjectMembersModal: change max height

---------

Co-authored-by: Tyni <[email protected]>
Co-authored-by: Katariina Ruotsalainen <[email protected]>
Co-authored-by: TTyni <[email protected]>
Co-authored-by: Harri Nieminen <[email protected]>
Co-authored-by: Harri Nieminen <[email protected]>
  • Loading branch information
6 people authored Feb 19, 2024
1 parent 13e3e68 commit 4b21643
Show file tree
Hide file tree
Showing 13 changed files with 298 additions and 90 deletions.
58 changes: 51 additions & 7 deletions frontend/src/components/DeleteModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,36 +18,80 @@ export const DeleteModal: React.FunctionComponent<propTypes> = ({
deleteModalText,
}) => {
const screenDimensions = useScreenDimensions();
const modalRef = React.useRef(null as HTMLDialogElement | null);

React.useEffect(() => {
if (confirmDeleteEdit) {
const modalElement = modalRef.current;
const focusableElements = modalElement!.querySelectorAll(
"button, [tabindex]:not([tabindex=\"-1\"])"
);

const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];

const handleTabKeyPress = (event: KeyboardEvent) => {
if (event.key === "Tab") {
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault();
(lastElement as HTMLDialogElement).focus();
} else if (
!event.shiftKey &&
document.activeElement === lastElement
) {
event.preventDefault();
(firstElement as HTMLDialogElement).focus();
}
}
};

const closeOnEscapePressed = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setConfirmDeleteEdit(false);
}
};
document.addEventListener("keydown", closeOnEscapePressed);
modalElement?.addEventListener("keydown", handleTabKeyPress);
return () => {

document.removeEventListener("keydown", closeOnEscapePressed);
modalElement?.removeEventListener("keydown", handleTabKeyPress);
};
}
}, [confirmDeleteEdit, setConfirmDeleteEdit]);

return (
<div
className={`fixed z-50 flex inset-0 justify-center items-center transition-colors rounded ${
confirmDeleteEdit ? "visible bg-dark-blue-100/40" : "invisible"}`}
onClick={() => setConfirmDeleteEdit(!confirmDeleteEdit)}
onMouseDown={() => setConfirmDeleteEdit(!confirmDeleteEdit)}
>
<div
onClick={(e) => e.stopPropagation()}
<dialog
tabIndex={-1}
ref={modalRef}
onMouseDown={(e) => e.stopPropagation()}
className={`p-2 pb-4 flex flex-col inset-0 sm:justify-start items-left overflow-x-hidden overflow-y-auto outline-none rounded focus:outline-none shadow transition-all bg-grayscale-100 ${
confirmDeleteEdit ? "scale-100 opacity-100" : "scale-110 opacity-0"}
${screenDimensions.height < 400 ? "min-h-screen max-h-screen w-full" : "w-full h-full sm:h-fit sm:w-fit sm:max-w-2xl"}
}`}
>
<div className="w-full flex flex-col place-items-end">
<button
aria-label="Close modal"
onClick={() => setConfirmDeleteEdit(!confirmDeleteEdit)}
className="p-1 text-dark-font bg-grayscale-0 hover:bg-grayscale-0"
>
<X size={20} />
</button>
</div>

<main className="w-min mx-auto px-2">
<main className="w-full sm:w-min mx-auto px-2">
{deleteModalTitle &&
<p className="w-max px-2 mx-auto mb-2 heading-sm text-center">
<p className="w-full sm:w-max px-2 mx-auto mb-2 heading-sm text-center">
{deleteModalTitle}
</p>
}
<p className="w-11/12 mx-auto mb-6 body-text-md text-center">{deleteModalText}</p>
<p className="sm:w-11/12 mx-auto mb-6 body-text-md text-center">{deleteModalText}</p>

<section className="w-full min-w-max mx-auto grid grid-cols-2 gap-6">
<button
Expand All @@ -68,7 +112,7 @@ export const DeleteModal: React.FunctionComponent<propTypes> = ({
</section>
</main>

</div>
</dialog>
</div>
);
};
61 changes: 46 additions & 15 deletions frontend/src/components/Modal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type ReactElement, useState, createContext, useEffect } from "react";
import { type ReactElement, useState, createContext, useEffect, useRef } from "react";
import { X } from "react-feather";
import useScreenDimensions from "../utils/screenDimensions";

Expand Down Expand Up @@ -27,6 +27,7 @@ export const Modal = ({
}: ModalProps) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const screenDimensions = useScreenDimensions();
const modalRef = useRef(null as HTMLDialogElement | null);

const openModal = () => {
setIsModalOpen(true);
Expand All @@ -37,15 +38,44 @@ export const Modal = ({
};

useEffect(() => {
const closeOnEscapePressed = (e: KeyboardEvent) => {
if (e.key === "Escape") {
closeModal();
}
};
document.addEventListener("keydown", closeOnEscapePressed);
return () =>
document.removeEventListener("keydown", closeOnEscapePressed);
}, []);
if (isModalOpen) {
const modalElement = modalRef.current;
const focusableElements = modalElement!.querySelectorAll(
"button, input, select, textarea, [tabindex]:not([tabindex=\"-1\"])"
);

const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];

const handleTabKeyPress = (event: KeyboardEvent) => {
if (event.key === "Tab") {
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault();
(lastElement as HTMLDialogElement).focus();
} else if (
!event.shiftKey &&
document.activeElement === lastElement
) {
event.preventDefault();
(firstElement as HTMLDialogElement).focus();
}
}
};

const closeOnEscapePressed = (e: KeyboardEvent) => {
if (e.key === "Escape") {
closeModal();
}
};
document.addEventListener("keydown", closeOnEscapePressed);
modalElement?.addEventListener("keydown", handleTabKeyPress);
return () => {

document.removeEventListener("keydown", closeOnEscapePressed);
modalElement?.removeEventListener("keydown", handleTabKeyPress);
};
}
}, [isModalOpen]);

return (
<>
Expand All @@ -61,18 +91,19 @@ export const Modal = ({

{isModalOpen &&
<div
onClick={closeModal}
onMouseDown={closeModal}
className="fixed flex justify-center inset-0 z-30 items-center transition-colors bg-dark-blue-100/40"
>
<dialog
aria-modal="true"
onClick={(e) => e.stopPropagation()}
// The sizing of the modal (w, min-w and max-w) might need to be modified
className={`fixed p-2 pb-4 flex flex-col inset-0 z-30 max-h-screen sm:justify-start items-left overflow-x-hidden overflow-y-auto outline-none sm:rounded focus:outline-none shadow transition-all
tabIndex={-1}
ref={modalRef}
onMouseDown={(e) => e.stopPropagation()}
className={`fixed p-2 pb-4 flex flex-col inset-0 z-30 max-h-screen sm:justify-start items-left overflow-x-hidden overflow-y-auto sm:rounded shadow transition-all
${screenDimensions.height < 500 ? "min-h-screen w-full" : "w-full h-full sm:h-fit sm:w-fit sm:max-w-2xl"}`}>
<header className="w-full flex flex-col mb-2 place-items-end">
<button
onClick={closeModal}
aria-label="Close modal"
className="p-1 text-dark-font bg-grayscale-0 hover:bg-grayscale-0">
<X size={20}/>
</button>
Expand Down
57 changes: 45 additions & 12 deletions frontend/src/features/calendar/CalendarEventModal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect, useMemo, useRef } from "react";
import { useParams } from "react-router-dom";
import { nanoid } from "@reduxjs/toolkit";
import {
Expand Down Expand Up @@ -30,6 +30,7 @@ const CalendarEventModal = ({ events, currentMonth, day, yevents }: Props) => {
const [newDate, setNewDate] = useState(day);
const [newDateOnCreate, setNewDateOnCreate] = useState(day);
const [activeEdit, setActiveEdit] = useState<string>("");
const calendarModalRef = useRef(null as HTMLDialogElement | null);

const projectId = parseInt(useParams().projectId!);
const { data: project } = useGetProjectQuery(projectId);
Expand Down Expand Up @@ -114,15 +115,44 @@ const CalendarEventModal = ({ events, currentMonth, day, yevents }: Props) => {
};

useEffect(() => {
const closeOnEscapePressed = (e: KeyboardEvent) => {
if (e.key === "Escape") {
closeModal();
}
};
document.addEventListener("keydown", closeOnEscapePressed);
return () =>
document.removeEventListener("keydown", closeOnEscapePressed);
}, []);
if (isModalOpen) {
const calendarModalElement = calendarModalRef.current;
const focusableElements = calendarModalElement!.querySelectorAll(
"button, input, select, textarea, [tabindex]:not([tabindex=\"-1\"])"
);

const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];

const handleTabKeyPress = (event: KeyboardEvent) => {
if (event.key === "Tab") {
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault();
(lastElement as HTMLDialogElement).focus();
} else if (
!event.shiftKey &&
document.activeElement === lastElement
) {
event.preventDefault();
(firstElement as HTMLDialogElement).focus();
}
}
};

const closeOnEscapePressed = (e: KeyboardEvent) => {
if (e.key === "Escape") {
closeModal();
}
};
document.addEventListener("keydown", closeOnEscapePressed);
calendarModalElement?.addEventListener("keydown", handleTabKeyPress);
return () => {

document.removeEventListener("keydown", closeOnEscapePressed);
calendarModalElement?.removeEventListener("keydown", handleTabKeyPress);
};
}
}, [isModalOpen]);

return (
<>
Expand Down Expand Up @@ -165,16 +195,19 @@ const CalendarEventModal = ({ events, currentMonth, day, yevents }: Props) => {
</section>
{isModalOpen &&
<div
onClick={closeModal}
onMouseDown={closeModal}
className="fixed flex justify-center inset-0 z-30 items-center transition-colors bg-dark-blue-100/40">
<dialog
onClick={(e) => e.stopPropagation()}
tabIndex={-1}
ref={calendarModalRef}
onMouseDown={(e) => e.stopPropagation()}
className={`fixed p-2 pb-4 flex flex-col inset-0 z-30 max-h-screen sm:justify-start items-left overflow-x-hidden overflow-y-auto
outline-none sm:rounded focus:outline-none shadow transition-all
${screenDimensions.height < 500 ? "min-h-screen w-full" : "w-full h-full sm:h-fit sm:w-fit sm:max-w-2xl"}`}>
<header className="flex flex-col mb-2 place-items-end">
<button
onClick={closeModal}
aria-label="Close modal"
className="p-1 text-dark-font bg-grayscale-0 hover:bg-grayscale-0">
<X size={20} />
</button>
Expand Down
9 changes: 5 additions & 4 deletions frontend/src/features/calendar/DeleteEventModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,16 @@ export const DeleteEventModal = ({ deleteEvent, eventId }: Props) => {

return (
<>
<Trash2
className="grid cursor-pointer text-caution-100 focus:outline-none focus:ring focus:ring-dark-blue-50 rounded"
<button
className="grid p-1 cursor-pointer text-caution-100 bg-grayscale-0 hover:bg-grayscale-0 focus:outline-none focus:ring focus:ring-dark-blue-50 rounded"
onClick={() => openModal()}
tabIndex={0}
onKeyDown={(e) => {
if (e.key !== "Enter") return;
openModal();
}}
/>
>
<Trash2 />
</button>

{isDeleteModalOpen && (
<DeleteModal
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/features/kanban/CreateLabelModal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useContext, useState } from "react";
import { ColorModal } from "./ColorModal";
import { Labels } from "./Kanban";
import type { Labels } from "./Kanban";
import { FieldErrors, useForm } from "react-hook-form";
import { createLabelSchema } from "./labelValidation";
import { yupResolver } from "@hookform/resolvers/yup";
Expand Down Expand Up @@ -76,6 +76,7 @@ export const CreateLabelModal = ({
<input
type="text"
{...register("name")}
autoFocus
placeholder="Title for label"
className="block w-full body-text-sm py-1 px-2 mt-1.5 border-grayscale-300"
/>
Expand All @@ -94,8 +95,8 @@ export const CreateLabelModal = ({
</label>
<div className="grid grid-cols-3 gap-2 mt-1.5">
{labelColors.map((label) => (
<div key={label.id}
className={label.color === selectedColor ? "outline outline-grayscale-400 rounded" : ""}
<div key={label.id}
className={label.color === selectedColor ? "outline outline-grayscale-400 rounded" : ""}
onClick={() => setSelectedColor(label.color)}>
<ColorModal
setValue={setValue}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/features/kanban/EditLabelModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export const EditLabelModal = ({
<input
type="text"
{...register("name")}
autoFocus
placeholder={label.name}
className="block w-full body-text-sm py-1 px-2 mt-1.5 border-grayscale-300"
/>
Expand Down
12 changes: 7 additions & 5 deletions frontend/src/features/kanban/IconButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ interface IconButtonProps {
handleOnClick: () => void;
}

export const IconButton = ({iconName, btnText, btnType = "button", handleOnClick}: IconButtonProps) => {
export const IconButton = ({iconName, btnText = "", btnType = "button", handleOnClick}: IconButtonProps) => {
const getIconFromName = (iconName: IconType) => {
switch (iconName) {
case "Edit":
Expand Down Expand Up @@ -55,17 +55,19 @@ export const IconButton = ({iconName, btnText, btnType = "button", handleOnClick
type={btnType}
onClick={handleOnClick}
aria-label={getAriaLabelText(iconName)}
className={`w-full px-4 pt-2 pb-1 btn-text-xs ${
className={`btn-text-xs ${
iconName === "Save" && "bg-success-100 hover:bg-success-200"
} ${iconName === "Delete" && "bg-caution-100 hover:bg-caution-200"} ${
iconName === "none" ? "sm:text-center pb-1.5" : "sm:text-left"
} ${iconName === "Edit" && "bg-grayscale-0 hover:bg-grayscale-0"}`}
} ${iconName === "Edit" && "bg-grayscale-0 hover:bg-grayscale-0"} ${
btnText === "" ? "w-fit h-fit p-1 pb-0 place-items-center" : "w-full px-4 pt-2 pb-1"}`}
>
<span className="inline-flex">
{getIconFromName(iconName)}
<p
className={`ms-[6px] ${
iconName === "none" ? "visible" : "hidden"
className={`${
iconName === "none" ? "visible" : "hidden"} ${
btnText === "" ? "hidden" : "ms-[6px] visible"
} sm:inline-block`}
>
{btnText}
Expand Down
Loading

0 comments on commit 4b21643

Please sign in to comment.