From 4b21643e49144bae9a3831d86022883f9c0af673 Mon Sep 17 00:00:00 2001 From: Suvi <81521009+susulone@users.noreply.github.com> Date: Mon, 19 Feb 2024 15:06:24 +0200 Subject: [PATCH] Modal focus trapping (#172) * 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 Co-authored-by: Katariina Ruotsalainen <64400810+bkruotsalainen@users.noreply.github.com> Co-authored-by: TTyni <90600923+TTyni@users.noreply.github.com> Co-authored-by: Harri Nieminen Co-authored-by: Harri Nieminen --- frontend/src/components/DeleteModal.tsx | 58 ++++++++++++++-- frontend/src/components/Modal.tsx | 61 +++++++++++++---- .../features/calendar/CalendarEventModal.tsx | 57 ++++++++++++---- .../features/calendar/DeleteEventModal.tsx | 9 +-- .../src/features/kanban/CreateLabelModal.tsx | 7 +- .../src/features/kanban/EditLabelModal.tsx | 1 + frontend/src/features/kanban/IconButton.tsx | 12 ++-- frontend/src/features/kanban/KanbanTask.tsx | 68 +++++++++++++++---- frontend/src/features/kanban/LabelModal.tsx | 40 ++++++----- frontend/src/features/kanban/SubModal.tsx | 59 ++++++++++++++-- .../src/features/kanban/TaskMemberItem.tsx | 2 +- .../src/features/kanban/TaskMembersModal.tsx | 4 +- .../features/project/ProjectMembersModal.tsx | 10 ++- 13 files changed, 298 insertions(+), 90 deletions(-) diff --git a/frontend/src/components/DeleteModal.tsx b/frontend/src/components/DeleteModal.tsx index a056c120..f9568f8c 100644 --- a/frontend/src/components/DeleteModal.tsx +++ b/frontend/src/components/DeleteModal.tsx @@ -18,15 +18,58 @@ export const DeleteModal: React.FunctionComponent = ({ 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 (
setConfirmDeleteEdit(!confirmDeleteEdit)} + onMouseDown={() => setConfirmDeleteEdit(!confirmDeleteEdit)} > -
e.stopPropagation()} + 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"} @@ -34,6 +77,7 @@ export const DeleteModal: React.FunctionComponent = ({ >
-
+
{deleteModalTitle && -

+

{deleteModalTitle}

} -

{deleteModalText}

+

{deleteModalText}

-
+
); }; diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx index 6765e9db..571d4560 100644 --- a/frontend/src/components/Modal.tsx +++ b/frontend/src/components/Modal.tsx @@ -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"; @@ -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); @@ -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 ( <> @@ -61,18 +91,19 @@ export const Modal = ({ {isModalOpen &&
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"}`}>
diff --git a/frontend/src/features/calendar/CalendarEventModal.tsx b/frontend/src/features/calendar/CalendarEventModal.tsx index fa02c529..2001f994 100644 --- a/frontend/src/features/calendar/CalendarEventModal.tsx +++ b/frontend/src/features/calendar/CalendarEventModal.tsx @@ -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 { @@ -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(""); + const calendarModalRef = useRef(null as HTMLDialogElement | null); const projectId = parseInt(useParams().projectId!); const { data: project } = useGetProjectQuery(projectId); @@ -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 ( <> @@ -165,16 +195,19 @@ const CalendarEventModal = ({ events, currentMonth, day, yevents }: Props) => { {isModalOpen &&
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"}`}>
diff --git a/frontend/src/features/calendar/DeleteEventModal.tsx b/frontend/src/features/calendar/DeleteEventModal.tsx index 205ee234..39c9db9b 100644 --- a/frontend/src/features/calendar/DeleteEventModal.tsx +++ b/frontend/src/features/calendar/DeleteEventModal.tsx @@ -45,15 +45,16 @@ export const DeleteEventModal = ({ deleteEvent, eventId }: Props) => { return ( <> - openModal()} - tabIndex={0} onKeyDown={(e) => { if (e.key !== "Enter") return; openModal(); }} - /> + > + + {isDeleteModalOpen && ( @@ -94,8 +95,8 @@ export const CreateLabelModal = ({
{labelColors.map((label) => ( -
setSelectedColor(label.color)}> diff --git a/frontend/src/features/kanban/IconButton.tsx b/frontend/src/features/kanban/IconButton.tsx index 09106318..5282562a 100644 --- a/frontend/src/features/kanban/IconButton.tsx +++ b/frontend/src/features/kanban/IconButton.tsx @@ -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": @@ -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"}`} > {getIconFromName(iconName)}

{btnText} diff --git a/frontend/src/features/kanban/KanbanTask.tsx b/frontend/src/features/kanban/KanbanTask.tsx index dc4a2c6d..86ceea48 100644 --- a/frontend/src/features/kanban/KanbanTask.tsx +++ b/frontend/src/features/kanban/KanbanTask.tsx @@ -1,5 +1,5 @@ // React -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; // Redux import { type Member, Project } from "../api/apiSlice"; @@ -92,6 +92,7 @@ export const KanbanTask = ({ const [isEditTitleSelected, setIsEditTitleSelected] = useState(false); const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false); const [title, setTitle] = useState(task.title); + const taskModalRef = useRef(null as HTMLDialogElement | null); const openModal = () => { setIsModalOpen(true); @@ -137,15 +138,48 @@ export const KanbanTask = ({ }; useEffect(() => { - const closeOnEscapePressed = (e: KeyboardEvent) => { - if (e.key === "Escape") { - closeModal(); - } - }; - document.addEventListener("keydown", closeOnEscapePressed); - return () => - document.removeEventListener("keydown", closeOnEscapePressed); - }, []); + if (isModalOpen) { + const taskModalElement = taskModalRef.current; + const focusableElements = taskModalElement!.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); + taskModalElement?.addEventListener("keydown", handleTabKeyPress); + return () => { + + document.removeEventListener("keydown", closeOnEscapePressed); + taskModalElement?.removeEventListener("keydown", handleTabKeyPress); + }; + } + }, [isModalOpen]); + + if (!task.labels) { + return null; + } if (!task.labels) { return null; @@ -228,14 +262,16 @@ export const KanbanTask = ({ {isModalOpen && (

e.stopPropagation()} - className={`max-h-screen min-w-[400px] fixed p-2 pb-4 flex flex-col inset-0 z-30 sm:justify-start items-left overflow-x-hidden overflow-y-auto outline-none sm:rounded focus:outline-none shadow transition-all + tabIndex={-1} + ref={taskModalRef} + onMouseDown={(e) => e.stopPropagation()} + className={`max-h-screen fixed p-2 pb-4 flex flex-col inset-0 z-30 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" @@ -245,6 +281,7 @@ export const KanbanTask = ({