diff --git a/v3/cypress/support/elements/cfm.ts b/v3/cypress/support/elements/cfm.ts index bef22a3985..b8caa809c3 100644 --- a/v3/cypress/support/elements/cfm.ts +++ b/v3/cypress/support/elements/cfm.ts @@ -7,7 +7,7 @@ export const CfmElements = { cy.wait(1000) // Wait for the document to load }, openLocalDocWithUserEntry(filename: string) { - cy.get('#user-entry-drop-overlay').selectFile(filename, { action: 'drag-drop' }) + cy.get('#user-entry-drop-overlay').selectFile(filename, { action: 'drag-drop', force: true }) }, closeDocument(options?: IDocumentOptions) { this.getHamburgerMenuButton().click() diff --git a/v3/src/components/app.tsx b/v3/src/components/app.tsx index b3b5f731a2..6f59ee5136 100644 --- a/v3/src/components/app.tsx +++ b/v3/src/components/app.tsx @@ -76,6 +76,7 @@ export const App = observer(function App() { const {isOpen: isOpenUserEntry, onOpen: onOpenUserEntry, onClose: onCloseUserEntry} = useDisclosure({defaultIsOpen: true}) const [isDragOver, setIsDragOver] = useState(false) + const userEntryOverlayRef = useRef(null) const cfmRef = useRef(null) // Sync the attribute with the current locale @@ -248,12 +249,13 @@ export const App = observer(function App() { -
diff --git a/v3/src/components/menu-bar/user-entry-modal.scss b/v3/src/components/menu-bar/user-entry-modal.scss index 1d8f4f73a4..b7a77cff1e 100644 --- a/v3/src/components/menu-bar/user-entry-modal.scss +++ b/v3/src/components/menu-bar/user-entry-modal.scss @@ -4,19 +4,10 @@ align-items: center; justify-content: center; flex-direction: column; - position: fixed; height: 190px; width: 440px; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - z-index: 200; - border: 1px solid #aaa; + max-width: 440px; background-color: #fff; - - &:focus { - outline: none; - } .user-entry-modal-header { display: flex; @@ -25,10 +16,12 @@ width: 100%; height: 30px; background-color: #0b4e5d; + border-radius: var(--codap-radii-md) var(--codap-radii-md) 0 0; color: white; font-size: 12px; + font-weight: normal; text-transform: uppercase; - padding: 10px + padding: 10px; } .user-entry-modal-body { @@ -48,6 +41,10 @@ outline: 2px solid #0085f2; outline-offset: 2px; } + &:focus-visible { + outline: 3px solid #0957d0; + outline-offset: 2px; + } &:hover { background: #3c94a1 !important; color: white; diff --git a/v3/src/components/menu-bar/user-entry-modal.test.tsx b/v3/src/components/menu-bar/user-entry-modal.test.tsx new file mode 100644 index 0000000000..7fea8104a1 --- /dev/null +++ b/v3/src/components/menu-bar/user-entry-modal.test.tsx @@ -0,0 +1,94 @@ +/* eslint-disable testing-library/no-node-access */ +import { render, screen } from "@testing-library/react" +import { userEvent } from "@testing-library/user-event" +import { UserEntryModal } from "./user-entry-modal" + +// mock the CFM context so the component renders without a real CloudFileManager +jest.mock("../../hooks/use-cfm-context", () => ({ + useCfmContext: () => ({ client: { openFileDialog: jest.fn() } }) +})) + +describe("UserEntryModal", () => { + const mockOnClose = jest.fn() + + beforeEach(() => { + mockOnClose.mockClear() + }) + + it("renders nothing when closed", () => { + render() + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + }) + + it("renders a dialog with proper ARIA attributes when open", () => { + render() + const dialog = screen.getByRole("dialog") + expect(dialog).toBeInTheDocument() + + // Chakra's ModalHeader should wire up aria-labelledby automatically + const labelledBy = dialog.getAttribute("aria-labelledby") + expect(labelledBy).toBeTruthy() + const header = document.getElementById(labelledBy!) + expect(header).toBeInTheDocument() + expect(header?.textContent).toMatch(/what would you like to do/i) + + // Chakra's ModalBody should wire up aria-describedby automatically + const describedBy = dialog.getAttribute("aria-describedby") + expect(describedBy).toBeTruthy() + expect(document.getElementById(describedBy!)).toBeInTheDocument() + }) + + it("renders both action buttons", () => { + render() + expect(screen.getByText("Open Document or Browse Examples")).toBeInTheDocument() + expect(screen.getByText("Create New Document")).toBeInTheDocument() + }) + + it("sets initial focus on the default (Open Document) button", () => { + render() + expect(screen.getByText("Open Document or Browse Examples")).toHaveFocus() + }) + + it("traps focus within the modal", async () => { + const user = userEvent.setup() + render() + const openButton = screen.getByText("Open Document or Browse Examples") + const newButton = screen.getByText("Create New Document") + + // initial focus is on the Open Document button + expect(openButton).toHaveFocus() + + // Tab to the New Document button + await user.tab() + expect(newButton).toHaveFocus() + + // Tab should wrap back within the modal, not escape to elements outside + await user.tab() + const dialog = screen.getByRole("dialog") + expect(dialog.contains(document.activeElement)).toBe(true) + }) + + it("calls onClose when the New Document button is clicked", async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByText("Create New Document")) + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it("does not close when clicking outside the modal", async () => { + const user = userEvent.setup() + render() + const overlay = document.querySelector(".chakra-modal__overlay") + expect(overlay).toBeInTheDocument() + await user.click(overlay!) + expect(mockOnClose).not.toHaveBeenCalled() + }) + + it("calls onClose when Escape is pressed", async () => { + const user = userEvent.setup() + render() + await user.keyboard("{Escape}") + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) +}) +/* eslint-enable testing-library/no-node-access */ diff --git a/v3/src/components/menu-bar/user-entry-modal.tsx b/v3/src/components/menu-bar/user-entry-modal.tsx index 787bb1b0e4..bdf73e4c00 100644 --- a/v3/src/components/menu-bar/user-entry-modal.tsx +++ b/v3/src/components/menu-bar/user-entry-modal.tsx @@ -1,5 +1,5 @@ -import { Button } from "@chakra-ui/react" -import React, { useEffect, useRef } from "react" +import { Button, Modal, ModalBody, ModalContent, ModalHeader, ModalOverlay } from "@chakra-ui/react" +import { RefObject, useRef } from "react" import { useCfmContext } from "../../hooks/use-cfm-context" import { t } from "../../utilities/translation/translate" @@ -8,20 +8,12 @@ import "./user-entry-modal.scss" interface IProps { isOpen: boolean onClose: () => void + containerRef?: RefObject } -export const UserEntryModal = ({ isOpen, onClose }: IProps) => { +export const UserEntryModal = ({ isOpen, onClose, containerRef }: IProps) => { const cfm = useCfmContext() - const modalRef = useRef(null) - - const handleKeyDown = (event: React.KeyboardEvent) => { - if (event.key === "Escape") { - onClose() - } - if (event.key === "Enter") { - openDocument() - } - } + const defaultButtonRef = useRef(null) const openDocument = () => { cfm?.client.openFileDialog() @@ -41,29 +33,32 @@ export const UserEntryModal = ({ isOpen, onClose }: IProps) => { onClick: createNewDocument }] - useEffect(() => { - if (isOpen && modalRef.current) { - modalRef.current.focus() - } - }, [isOpen]) - return ( -
-
-
+ + + + {t("DG.main.userEntryView.title")} -
-
-
- { buttons.map((b, idx) => ( - - )) - } -
-
+ ))} + + + ) }