Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion v3/cypress/support/elements/cfm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 3 additions & 1 deletion v3/src/components/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(null)
const cfmRef = useRef<CloudFileManager | null>(null)

// Sync the <html lang> attribute with the current locale
Expand Down Expand Up @@ -248,12 +249,13 @@ export const App = observer(function App() {
</If>
</div>
<If condition={isOpenUserEntry}>
<div id={`${kUserEntryDropOverlay}`}
<div id={`${kUserEntryDropOverlay}`} ref={userEntryOverlayRef}
className={clsx({ "show-highlight": isOpenUserEntry && isDragOver, beta: isBeta() })}
>
<UserEntryModal
isOpen={isOpenUserEntry}
onClose={onCloseUserEntry}
containerRef={userEntryOverlayRef}
/>
</div>
</If>
Expand Down
19 changes: 8 additions & 11 deletions v3/src/components/menu-bar/user-entry-modal.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand All @@ -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;
Expand Down
94 changes: 94 additions & 0 deletions v3/src/components/menu-bar/user-entry-modal.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<UserEntryModal isOpen={false} onClose={mockOnClose} />)
expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
})

it("renders a dialog with proper ARIA attributes when open", () => {
render(<UserEntryModal isOpen={true} onClose={mockOnClose} />)
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(<UserEntryModal isOpen={true} onClose={mockOnClose} />)
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(<UserEntryModal isOpen={true} onClose={mockOnClose} />)
expect(screen.getByText("Open Document or Browse Examples")).toHaveFocus()
})

it("traps focus within the modal", async () => {
const user = userEvent.setup()
render(<UserEntryModal isOpen={true} onClose={mockOnClose} />)
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(<UserEntryModal isOpen={true} onClose={mockOnClose} />)
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(<UserEntryModal isOpen={true} onClose={mockOnClose} />)
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(<UserEntryModal isOpen={true} onClose={mockOnClose} />)
await user.keyboard("{Escape}")
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
})
/* eslint-enable testing-library/no-node-access */
61 changes: 28 additions & 33 deletions v3/src/components/menu-bar/user-entry-modal.tsx
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -8,20 +8,12 @@ import "./user-entry-modal.scss"
interface IProps {
isOpen: boolean
onClose: () => void
containerRef?: RefObject<HTMLElement>
}

export const UserEntryModal = ({ isOpen, onClose }: IProps) => {
export const UserEntryModal = ({ isOpen, onClose, containerRef }: IProps) => {
const cfm = useCfmContext()
const modalRef = useRef<HTMLDivElement>(null)

const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Escape") {
onClose()
}
if (event.key === "Enter") {
openDocument()
}
}
const defaultButtonRef = useRef<HTMLButtonElement>(null)

const openDocument = () => {
cfm?.client.openFileDialog()
Expand All @@ -41,29 +33,32 @@ export const UserEntryModal = ({ isOpen, onClose }: IProps) => {
onClick: createNewDocument
}]

useEffect(() => {
if (isOpen && modalRef.current) {
modalRef.current.focus()
}
}, [isOpen])

return (
<div ref={modalRef} tabIndex={-1} className="user-entry-modal-container" onKeyDown={handleKeyDown}>
<div className="user-entry-modal-header">
<div className="user-entry-modal-title">
<Modal isOpen={isOpen} onClose={onClose} initialFocusRef={defaultButtonRef} isCentered
closeOnOverlayClick={false}
portalProps={containerRef ? { containerRef } : undefined}
>
<ModalOverlay />
<ModalContent className="user-entry-modal-container">
<ModalHeader className="user-entry-modal-header">
{t("DG.main.userEntryView.title")}
</div>
</div>
<div className="user-entry-modal-body">
{ buttons.map((b, idx) => (
<Button key={`${b.label}-${idx}`} size="md" ml="15"
className={`user-entry-button ${b.default ? "default" : ""}`}
onClick={b.onClick} data-testid={`${b.label}-button`}>
</ModalHeader>
<ModalBody className="user-entry-modal-body">
{buttons.map((b, idx) => (
<Button
key={`${b.label}-${idx}`}
ref={b.default ? defaultButtonRef : undefined}
size="md"
ml="15"
className={`user-entry-button ${b.default ? "default" : ""}`}
onClick={b.onClick}
data-testid={`${b.label}-button`}
>
{b.label}
</Button>
))
}
</div>
</div>
))}
</ModalBody>
</ModalContent>
</Modal>
)
}