Merge pull request #30490 from overleaf/mg-context-menu-a11y

[web] Add a11y support for context menu

GitOrigin-RevId: 3cbe66ee3ee8345dd0e89f89561928889832a50d
This commit is contained in:
Malik Glossop
2026-01-19 13:53:08 +01:00
committed by Copybot
parent 3e9d3f4d9c
commit 723da5c28a
9 changed files with 370 additions and 233 deletions

View File

@@ -1,7 +1,12 @@
import { FC, Fragment, memo } from 'react'
import { FC, Fragment, memo, useEffect, useRef } from 'react'
import ReactDOM from 'react-dom'
import { useTranslation } from 'react-i18next'
import { getTooltip } from '@codemirror/view'
import {
Dropdown,
DropdownMenu,
DropdownItem,
DropdownDivider,
} from '@/shared/components/dropdown/dropdown-menu'
import {
useCodeMirrorStateContext,
useCodeMirrorViewContext,
@@ -9,6 +14,7 @@ import {
import { contextMenuStateField } from '../extensions/context-menu'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import { useContextMenuItems } from '../hooks/use-context-menu-items'
import DropdownListItem from '@/shared/components/dropdown/dropdown-list-item'
const EditorContextMenu: FC = () => {
const state = useCodeMirrorStateContext()
@@ -29,52 +35,52 @@ const EditorContextMenu: FC = () => {
}
const EditorContextMenuContent: FC = memo(function EditorContextMenuContent() {
const { t } = useTranslation()
const { menuItems, closeMenu, onToggle } = useContextMenuItems()
const menuRef = useRef<any>(null)
const menuItems = useContextMenuItems()
useEffect(() => {
menuRef.current?.focus()
}, [])
return (
<div className="editor-context-menu" role="menu" aria-label={t('menu')}>
{menuItems.map((menuItem, index) => (
<Fragment key={index}>
{menuItem.separatorAbove && (
<div className="editor-context-menu-separator" />
)}
<ContextMenuItem
label={menuItem.label}
onClick={() => menuItem.handler()}
disabled={menuItem.disabled}
shortcut={menuItem.shortcut}
/>
</Fragment>
))}
</div>
<Dropdown show onToggle={onToggle}>
<DropdownMenu
ref={menuRef}
show
tabIndex={0}
className="dropdown-menu-unpositioned"
onKeyDown={event => {
switch (event.code) {
case 'Escape':
case 'Tab':
event.preventDefault()
closeMenu()
break
}
}}
>
{menuItems.map((menuItem, index) => (
<Fragment key={index}>
{menuItem.separatorAbove && <DropdownDivider />}
<DropdownListItem>
<DropdownItem
as="button"
onClick={() => menuItem.handler()}
disabled={menuItem.disabled}
trailingIcon={
menuItem.shortcut ? (
<span>{menuItem.shortcut}</span>
) : undefined
}
>
{menuItem.label}
</DropdownItem>
</DropdownListItem>
</Fragment>
))}
</DropdownMenu>
</Dropdown>
)
})
type ContextMenuItemProps = {
label: string
onClick: () => void
disabled?: boolean
shortcut?: string
}
const ContextMenuItem: FC<ContextMenuItemProps> = ({
label,
shortcut,
onClick,
disabled,
}) => (
<button
type="button"
role="menuitem"
className="editor-context-menu-item"
onClick={onClick}
disabled={disabled}
>
<span className="editor-context-menu-item-label">{label}</span>
<span className="editor-context-menu-item-shortcut">{shortcut ?? ''}</span>
</button>
)
export default EditorContextMenu

View File

@@ -38,7 +38,7 @@ export const contextMenuStateField = StateField.define<ContextMenuState>({
if (effect.is(openContextMenuEffect)) {
const { pos, x, y } = effect.value
return {
tooltip: buildContextMenuTooltip(pos),
tooltip: buildContextMenuTooltip(pos, { x, y }),
mousePosition: { x, y },
}
}
@@ -61,20 +61,71 @@ export const contextMenuStateField = StateField.define<ContextMenuState>({
],
})
function buildContextMenuTooltip(pos: number): Tooltip {
function buildContextMenuTooltip(
pos: number,
mousePosition: { x: number; y: number }
): Tooltip {
return {
pos,
above: false,
strictSide: false,
arrow: false,
create: createTooltipView,
create: () => createTooltipView(mousePosition),
}
}
const createTooltipView = (): TooltipView => {
const createTooltipView = (mousePosition: {
x: number
y: number
}): TooltipView => {
const dom = document.createElement('div')
dom.className = 'editor-context-menu-container'
return { dom, overlap: true, offset: { x: 0, y: 0 } }
// Watch for size changes and reposition accordingly
const resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => positionMenu(dom, mousePosition))
})
resizeObserver.observe(dom)
return {
dom,
overlap: true,
offset: { x: 0, y: 0 },
destroy() {
resizeObserver.disconnect()
},
}
}
function positionMenu(
dom: HTMLElement,
mousePosition: { x: number; y: number }
) {
const bounds = dom.getBoundingClientRect()
// Wait for menu to render
if (bounds.width === 0 || bounds.height === 0) {
return
}
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const y = mousePosition.y
// Adjust horizontal position if menu would overflow right edge
let left = mousePosition.x
if (mousePosition.x + bounds.width > viewportWidth) {
left = viewportWidth - bounds.width
}
dom.style.setProperty('--context-menu-left', `${left}px`)
const spaceBelow = viewportHeight - y
let top = y
if (bounds.height > spaceBelow) {
// Show above if menu won't fit below
top = y - bounds.height
}
dom.style.setProperty('--context-menu-top', `${top}px`)
}
function isPositionInsideSelection(pos: number, from: number, to: number) {
@@ -138,6 +189,21 @@ function openContextMenuAtPosition(
})
}
function openContextMenuAtSelection(view: EditorView): boolean {
const { main } = view.state.selection
const pos = main.head
const coords = view.coordsAtPos(pos)
if (!coords) {
return false
}
// Keep the current selection; actions should apply to it
const selection = view.state.selection
openContextMenuAtPosition(view, pos, selection, coords.left, coords.top)
return true
}
function isClickOnGutter(target: HTMLElement): boolean {
return !!target.closest('.cm-gutters')
}
@@ -246,6 +312,11 @@ const contextMenuKeymap = (): Extension =>
return false
},
},
{
key: 'Shift-F10',
// Accessibility standard shortcut to open context menu
run: view => openContextMenuAtSelection(view),
},
])
)
@@ -265,5 +336,8 @@ const contextMenuContainerTheme = EditorView.baseTheme({
backgroundColor: 'transparent',
border: 'none',
zIndex: 100,
position: 'fixed !important',
top: 'var(--context-menu-top) !important',
left: 'var(--context-menu-left) !important',
},
})

View File

@@ -168,12 +168,19 @@ export const addComment = () => {
}
export const deleteSelection: Command = view => {
const { from, to } = view.state.selection.main
if (from === to) return false
if (view.state.selection.ranges.every(range => range.empty)) return false
view.dispatch({
changes: { from, to, insert: '' },
selection: { anchor: from },
const transaction = view.state.changeByRange(range => {
if (range.empty) {
return { changes: [], range }
}
return {
changes: { from: range.from, to: range.to, insert: '' },
range: EditorSelection.cursor(range.from),
}
})
view.dispatch(transaction)
return true
}

View File

@@ -4,12 +4,13 @@ import {
} from '@/features/source-editor/components/codemirror-context'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
import { useProjectContext } from '@/shared/context/project-context'
import useSynctex from '@/features/pdf-preview/hooks/use-synctex'
import { useDetachCompileContext } from '@/shared/context/detach-compile-context'
import { useLayoutContext } from '@/shared/context/layout-context'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import { useTranslation } from 'react-i18next'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef } from 'react'
import {
formatShortcut,
useCommandRegistry,
@@ -35,20 +36,33 @@ export const useContextMenuItems = () => {
const visualPreviewEnabled = useFeatureFlag('visual-preview')
const { t } = useTranslation()
const { shortcuts } = useCommandRegistry()
const { features } = useProjectContext()
const requestedPdfSyncRef = useRef(false)
const closeMenu = useCallback(() => {
view.dispatch({ effects: closeContextMenuEffect.of(null) })
}, [view])
const [pendingClose, setPendingClose] = useState(false)
// Handle closing the menu when it loses focus, e.g. click outside the editor
const onToggle = (show: boolean) => {
if (!show) {
// Skip closing if a sync to PDF is in flight
if (requestedPdfSyncRef.current) {
return
}
closeMenu()
}
}
// Wait for syncToPdf to finish before closing the menu
useEffect(() => {
if (pendingClose && !syncToPdfInFlight) {
if (requestedPdfSyncRef.current && !syncToPdfInFlight) {
closeMenu()
setPendingClose(false)
// Clear the synchronous flag when the close completes
requestedPdfSyncRef.current = false
}
}, [pendingClose, syncToPdfInFlight, closeMenu])
}, [syncToPdfInFlight, closeMenu])
const hasSelection = !state.selection.main.empty
const canEdit = permissions.write || permissions.trackedWrite
@@ -94,8 +108,8 @@ export const useContextMenuItems = () => {
// Sync-to-PDF is special: it needs to wait for async completion before closing
const handleSyncToPdf = useCallback(() => {
requestedPdfSyncRef.current = true
syncToPdf()
setPendingClose(true)
view.focus()
}, [syncToPdf, view])
@@ -107,66 +121,71 @@ export const useContextMenuItems = () => {
[shortcuts]
)
return [
{
label: t('cut'),
handler: handleCut,
disabled: false,
show: canEdit,
shortcut: getShortcut('cut'),
},
{
label: t('copy'),
handler: handleCopy,
disabled: false,
show: true,
shortcut: getShortcut('copy'),
},
{
label: t('paste'),
handler: handlePaste,
disabled: false,
show: canEdit,
shortcut: getShortcut('paste'),
},
{
label: inVisualMode
? t('paste_without_formatting')
: t('paste_with_formatting'),
handler: handlePasteSpecial,
disabled: false,
show: canEdit,
shortcut: inVisualMode ? getShortcut('paste-special') : undefined,
},
{
label: t('delete'),
handler: handleDelete,
disabled: !hasSelection,
show: canEdit,
shortcut: undefined,
},
{
label: t('jump_to_location_in_pdf'),
handler: handleSyncToPdf,
disabled: syncToPdfInFlight,
separatorAbove: true,
show: jumpToLocationInPdfEnabled,
shortcut: undefined,
},
{
label: wantTrackChanges ? t('back_to_editing') : t('suggest_edits'),
handler: handleToggleTrackChanges,
disabled: false,
separatorAbove: true,
show: canEdit,
shortcut: getShortcut('toggle-track-changes'),
},
{
label: t('comment'),
handler: handleComment,
disabled: !hasSelection,
show: permissions.comment,
shortcut: getShortcut('insert-comment'),
},
].filter(item => item.show)
return {
closeMenu,
onToggle,
menuItems: [
{
label: t('cut'),
handler: handleCut,
disabled: false,
show: canEdit,
shortcut: getShortcut('cut'),
},
{
label: t('copy'),
handler: handleCopy,
disabled: false,
show: true,
shortcut: getShortcut('copy'),
},
{
label: t('paste'),
handler: handlePaste,
disabled: false,
show: canEdit,
shortcut: getShortcut('paste'),
},
{
label: inVisualMode
? t('paste_without_formatting')
: t('paste_with_formatting'),
handler: handlePasteSpecial,
disabled: false,
show: canEdit,
shortcut: inVisualMode ? getShortcut('paste-special') : undefined,
},
{
label: t('delete'),
handler: handleDelete,
disabled: !hasSelection,
show: canEdit,
shortcut: undefined,
},
{
label: t('jump_to_location_in_pdf'),
handler: handleSyncToPdf,
disabled: syncToPdfInFlight,
separatorAbove: true,
show: jumpToLocationInPdfEnabled,
shortcut: undefined,
},
{
label: wantTrackChanges ? t('back_to_editing') : t('suggest_edits'),
handler: handleToggleTrackChanges,
// disable for now, future work opens upgrade modal
disabled: !features.trackChanges,
separatorAbove: true,
show: canEdit,
shortcut: getShortcut('toggle-track-changes'),
},
{
label: t('comment'),
handler: handleComment,
disabled: !hasSelection,
show: permissions.comment,
shortcut: getShortcut('insert-comment'),
},
].filter(item => item.show),
}
}

View File

@@ -73,6 +73,8 @@ export type DropdownMenuProps = PropsWithChildren<{
id?: string
renderOnMount?: boolean
popperConfig?: BS5DropdownMenuProps['popperConfig']
tabIndex?: number
onKeyDown?: (event: React.KeyboardEvent) => void
}>
export type DropdownDividerProps = PropsWithChildren<{

View File

@@ -38,7 +38,9 @@
@include theme('default') {
.ide-redesign-main,
.project-ds-nav-page {
.project-ds-nav-page,
// Codemirror tooltips are rendered outside of the main app container
.cm-tooltip .dropdown-menu {
@include dark-dropdown-menu;
}
}
@@ -80,6 +82,10 @@ $dropdown-item-min-height: 36px;
z-index: unset;
display: block;
float: unset;
&:focus {
outline: none;
}
}
.dropdown-menu {

View File

@@ -31,7 +31,6 @@
@import 'editor/share';
@import 'editor/tags-input';
@import 'editor/review-panel';
@import 'editor/context-menu';
@import 'editor/table-generator-column-width-modal';
@import 'editor/math-preview';
@import 'editor/references-search';

View File

@@ -1,55 +0,0 @@
.editor-context-menu {
display: flex;
flex-direction: column;
min-width: 180px;
border-radius: var(--border-radius-base);
padding: var(--spacing-02);
gap: var(--spacing-01);
box-shadow: 0 2px 4px 0 #1e253029;
border: 1px solid var(--border-divider);
background-color: var(--bg-light-primary);
}
.editor-context-menu-item {
display: flex;
align-items: center;
gap: var(--spacing-04);
padding: var(--spacing-02) var(--spacing-04);
border: none;
border-radius: var(--border-radius-base);
background-color: transparent;
color: var(--content-primary);
cursor: pointer;
text-align: left;
font-size: var(--font-size-02);
line-height: var(--line-height-02);
&:hover:not(:disabled) {
background-color: var(--bg-light-secondary);
}
&:disabled {
color: var(--content-disabled);
cursor: not-allowed;
}
&-label {
flex: 1;
}
&-shortcut {
min-width: var(--spacing-10);
text-align: right;
color: var(--content-secondary);
.editor-context-menu-item:disabled & {
color: var(--content-disabled);
}
}
}
.editor-context-menu-separator {
height: 1px;
margin: var(--spacing-01) var(--spacing-04);
background-color: var(--border-divider);
}

View File

@@ -102,16 +102,42 @@ describe('editor context menu', { scrollBehavior: false }, function () {
</TestContainer>
)
cy.get('.editor-context-menu').should('not.exist')
cy.findByRole('menu').should('not.exist')
cy.get('.cm-line').eq(10).rightclick()
cy.get('.editor-context-menu').should('be.visible')
cy.findByRole('menu').should('be.visible')
cy.get('body').type('{esc}')
cy.get('.editor-context-menu').should('not.exist')
cy.findByRole('menu').should('not.exist')
})
it('should close when clicking elsewhere', function () {
it('should open on Shift+F10', { retries: 1 }, function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-line').eq(8).click()
cy.findByRole('menu').should('not.exist')
cy.get('.cm-line').eq(8).trigger('keydown', {
key: 'F10',
code: 'F10',
shiftKey: true,
bubbles: true,
cancelable: true,
force: true,
})
cy.findByRole('menu').should('be.visible')
})
it('should close when clicking elsewhere in the editor', function () {
const scope = mockScope()
cy.mount(
@@ -123,10 +149,30 @@ describe('editor context menu', { scrollBehavior: false }, function () {
)
cy.get('.cm-line').eq(10).rightclick()
cy.get('.editor-context-menu').should('be.visible')
cy.findByRole('menu').should('be.visible')
cy.get('.cm-line').eq(5).click()
cy.get('.editor-context-menu').should('not.exist')
cy.findByRole('menu').should('not.exist')
})
it('should should close when clicking outside the editor', function () {
const scope = mockScope()
const outsideEditorButtonName = 'Recompile'
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<button>{outsideEditorButtonName}</button>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// Open context menu
cy.get('.cm-line').eq(10).rightclick()
cy.findByRole('menu').should('be.visible')
cy.findByRole('button', { name: outsideEditorButtonName }).click()
cy.findByRole('menu').should('not.exist')
})
describe('when nothing is selected', function () {
@@ -143,17 +189,25 @@ describe('editor context menu', { scrollBehavior: false }, function () {
cy.get('.cm-line').eq(10).rightclick()
cy.get('.editor-context-menu').within(() => {
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', { name: /cut/i }).should('be.enabled')
cy.findByRole('menuitem', { name: /copy/i }).should('be.enabled')
cy.findByRole('menuitem', { name: /delete/i }).should('be.disabled')
cy.findByRole('menuitem', { name: /comment/i }).should('be.disabled')
cy.findByRole('menuitem', { name: pasteLabelMatcher }).should(
'be.enabled'
)
cy.findByRole('menuitem', {
name: /paste with formatting/i,
}).should('be.enabled')
cy.findByRole('menuitem', { name: /delete/i }).should(
'have.attr',
'aria-disabled',
'true'
)
cy.findByRole('menuitem', { name: /comment/i }).should(
'have.attr',
'aria-disabled',
'true'
)
cy.findByRole('menuitem', { name: /suggest edits/i }).should(
'be.enabled'
)
@@ -185,9 +239,9 @@ describe('editor context menu', { scrollBehavior: false }, function () {
cy.get('@line').rightclick()
cy.get('.cm-selectionBackground').should('exist')
cy.get('.editor-context-menu').should('be.visible')
cy.findByRole('menu').should('be.visible')
cy.get('.editor-context-menu').within(() => {
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', { name: /cut/i }).should('be.enabled')
cy.findByRole('menuitem', { name: /copy/i }).should('be.enabled')
cy.findByRole('menuitem', { name: pasteLabelMatcher }).should(
@@ -228,11 +282,11 @@ describe('editor context menu', { scrollBehavior: false }, function () {
cy.get('@line').rightclick()
cy.get('.editor-context-menu').within(() => {
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', { name: /copy/i }).click()
})
cy.get('.editor-context-menu').should('not.exist')
cy.findByRole('menu').should('not.exist')
})
it('should cut and paste text', function () {
@@ -259,11 +313,11 @@ describe('editor context menu', { scrollBehavior: false }, function () {
// Cut "world"
cy.get('@line').rightclick()
cy.get('.editor-context-menu').within(() => {
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', { name: /cut/i }).click()
})
cy.get('.editor-context-menu').should('not.exist')
cy.findByRole('menu').should('not.exist')
cy.get('@line').should('contain', 'hello ')
cy.get('@line').should('not.contain', 'world')
@@ -272,11 +326,11 @@ describe('editor context menu', { scrollBehavior: false }, function () {
cy.get('@line').rightclick(0, 0)
// Paste "world" at the beginning
cy.get('.editor-context-menu').within(() => {
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', { name: pasteLabelMatcher }).click()
})
cy.get('.editor-context-menu').should('not.exist')
cy.findByRole('menu').should('not.exist')
cy.get('@line').should('contain', 'worldhello')
})
@@ -302,11 +356,11 @@ describe('editor context menu', { scrollBehavior: false }, function () {
cy.get('@line').rightclick()
cy.get('.editor-context-menu').within(() => {
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', { name: /delete/i }).click()
})
cy.get('.editor-context-menu').should('not.exist')
cy.findByRole('menu').should('not.exist')
cy.get('@line').should('contain', 'hello ')
cy.get('@line').should('not.contain', 'world')
})
@@ -337,6 +391,7 @@ describe('editor context menu', { scrollBehavior: false }, function () {
<TestContainer>
<EditorProviders
scope={scope}
projectFeatures={{ trackChanges: true }}
providers={{
EditorPropertiesProvider: makeEditorPropertiesProvider({
wantTrackChanges: false,
@@ -350,7 +405,7 @@ describe('editor context menu', { scrollBehavior: false }, function () {
cy.get('.cm-line').eq(10).rightclick()
cy.get('.editor-context-menu').within(() => {
cy.findByRole('menu').within(() => {
// Verify we're showing the edit mode label
cy.findByRole('menuitem', { name: /suggest edits/i }).should(
'be.visible'
@@ -361,7 +416,7 @@ describe('editor context menu', { scrollBehavior: false }, function () {
cy.findByRole('menuitem', { name: /suggest edits/i }).click()
})
cy.get('.editor-context-menu').should('not.exist')
cy.findByRole('menu').should('not.exist')
// Verify the toggle event was dispatched
cy.get('@toggleTrackChanges').should('have.been.calledOnce')
@@ -374,6 +429,7 @@ describe('editor context menu', { scrollBehavior: false }, function () {
<TestContainer>
<EditorProviders
scope={scope}
projectFeatures={{ trackChanges: true }}
providers={{
EditorPropertiesProvider: makeEditorPropertiesProvider({
wantTrackChanges: true,
@@ -387,7 +443,7 @@ describe('editor context menu', { scrollBehavior: false }, function () {
cy.get('.cm-line').eq(10).rightclick()
cy.get('.editor-context-menu').within(() => {
cy.findByRole('menu').within(() => {
// Verify we're showing the review mode label
cy.findByRole('menuitem', { name: /back to editing/i }).should(
'be.visible'
@@ -398,11 +454,36 @@ describe('editor context menu', { scrollBehavior: false }, function () {
cy.findByRole('menuitem', { name: /back to editing/i }).click()
})
cy.get('.editor-context-menu').should('not.exist')
cy.findByRole('menu').should('not.exist')
// Verify the toggle event was dispatched
cy.get('@toggleTrackChanges').should('have.been.calledOnce')
})
it('should disable suggest edits when project does not support track changes', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders
scope={scope}
projectFeatures={{ trackChanges: false }}
>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-line').eq(10).rightclick()
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', { name: /suggest edits/i }).should(
'have.attr',
'aria-disabled',
'true'
)
})
})
})
describe('when feature flag is disabled', function () {
@@ -422,7 +503,7 @@ describe('editor context menu', { scrollBehavior: false }, function () {
)
cy.get('.cm-line').eq(10).rightclick()
cy.get('.editor-context-menu').should('not.exist')
cy.findByRole('menu').should('not.exist')
})
})
@@ -460,9 +541,9 @@ describe('editor context menu', { scrollBehavior: false }, function () {
cy.get('@line').rightclick()
cy.get('.editor-context-menu').should('be.visible')
cy.findByRole('menu').should('be.visible')
cy.get('.editor-context-menu').within(() => {
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', { name: /cut/i }).should('not.exist')
cy.findByRole('menuitem', { name: /copy/i }).should('be.enabled')
cy.findByRole('menuitem', { name: pasteLabelMatcher }).should(
@@ -515,9 +596,9 @@ describe('editor context menu', { scrollBehavior: false }, function () {
cy.get('@line').rightclick()
cy.get('.editor-context-menu').should('be.visible')
cy.findByRole('menu').should('be.visible')
cy.get('.editor-context-menu').within(() => {
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', { name: /copy/i }).should('be.enabled')
cy.findByRole('menuitem', { name: /comment/i }).should('not.exist')
})
@@ -554,10 +635,10 @@ describe('editor context menu', { scrollBehavior: false }, function () {
// Right-click to open context menu
cy.get('.cm-line').eq(10).rightclick()
cy.get('.editor-context-menu').should('be.visible')
cy.findByRole('menu').should('be.visible')
// Click paste button
cy.get('.editor-context-menu').within(() => {
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', { name: pasteLabelMatcher }).click()
})
@@ -565,7 +646,7 @@ describe('editor context menu', { scrollBehavior: false }, function () {
cy.findByText('Upload from computer').should('be.visible')
// Context menu should close
cy.get('.editor-context-menu').should('not.exist')
cy.findByRole('menu').should('not.exist')
}
)
})
@@ -612,13 +693,13 @@ describe('editor context menu', { scrollBehavior: false }, function () {
)
cy.get('.cm-line').eq(10).rightclick()
cy.get('.editor-context-menu').within(() => {
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', {
name: /paste with formatting/i,
}).click()
})
cy.get('.editor-context-menu').should('not.exist')
cy.findByRole('menu').should('not.exist')
cy.get('.cm-line').should($lines => {
const text = $lines.text()
@@ -642,11 +723,11 @@ describe('editor context menu', { scrollBehavior: false }, function () {
)
cy.get('.cm-line').eq(10).rightclick()
cy.get('.editor-context-menu').within(() => {
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', { name: pasteLabelMatcher }).click()
})
cy.get('.editor-context-menu').should('not.exist')
cy.findByRole('menu').should('not.exist')
cy.get('.cm-line').should($lines => {
const text = $lines.text()
@@ -688,13 +769,11 @@ describe('editor context menu', { scrollBehavior: false }, function () {
cy.get('.cm-line').eq(10).rightclick()
cy.get('.editor-context-menu').within(() => {
cy.findByRole('menuitem', {
name: /jump to location in pdf/i,
}).click()
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', { name: /jump to location in pdf/i }).click()
})
cy.get('.editor-context-menu').should('not.exist')
cy.findByRole('menu').should('not.exist')
// Verify the sync API was called and returned expected response
cy.wait('@syncToPdfRequest').then(interception => {
@@ -721,10 +800,10 @@ describe('editor context menu', { scrollBehavior: false }, function () {
cy.get('.cm-line').eq(10).rightclick()
cy.get('.editor-context-menu').within(() => {
cy.findByRole('menuitem', {
name: /jump to location in pdf/i,
}).should('not.exist')
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', { name: /jump to location in pdf/i }).should(
'not.exist'
)
})
})
})
@@ -744,7 +823,7 @@ describe('editor context menu', { scrollBehavior: false }, function () {
</TestContainer>
)
cy.get('.editor-context-menu').should('not.exist')
cy.findByRole('menu').should('not.exist')
cy.get('.cm-line').eq(editorLine).as('targetLine')
cy.get('@targetLine').click()
@@ -756,7 +835,7 @@ describe('editor context menu', { scrollBehavior: false }, function () {
cy.get('.cm-gutterElement').eq(gutterLineIndex).rightclick()
cy.get('.cm-selectionBackground').should('exist')
cy.get('.editor-context-menu').should('be.visible')
cy.findByRole('menu').should('be.visible')
})
it('should work with cut/copy/delete operations on gutter-selected line', function () {
@@ -783,9 +862,9 @@ describe('editor context menu', { scrollBehavior: false }, function () {
cy.get('.cm-gutterElement').eq(gutterLineIndex).rightclick()
cy.get('.cm-selectionBackground').should('exist')
cy.get('.editor-context-menu').should('be.visible')
cy.findByRole('menu').should('be.visible')
cy.get('.editor-context-menu').within(() => {
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', { name: /cut/i }).should('be.enabled')
cy.findByRole('menuitem', { name: /copy/i }).should('be.enabled')
cy.findByRole('menuitem', { name: pasteLabelMatcher }).should(
@@ -800,7 +879,7 @@ describe('editor context menu', { scrollBehavior: false }, function () {
cy.findByRole('menuitem', { name: /copy/i }).click()
})
cy.get('.editor-context-menu').should('not.exist')
cy.findByRole('menu').should('not.exist')
cy.get('@writeText').should('have.been.calledOnce')
cy.get('@writeText').should(
@@ -823,10 +902,10 @@ describe('editor context menu', { scrollBehavior: false }, function () {
)
cy.get('.cm-gutterElement').eq(5).rightclick()
cy.get('.editor-context-menu').should('be.visible')
cy.findByRole('menu').should('be.visible')
cy.get('.cm-line').eq(10).click()
cy.get('.editor-context-menu').should('not.exist')
cy.findByRole('menu').should('not.exist')
})
it('should close menu on Escape after gutter right-click', function () {
@@ -841,11 +920,11 @@ describe('editor context menu', { scrollBehavior: false }, function () {
)
cy.get('.cm-gutterElement').eq(5).rightclick()
cy.get('.editor-context-menu').should('be.visible')
cy.findByRole('menu').should('be.visible')
cy.get('.cm-content').focus()
cy.get('body').type('{esc}')
cy.get('.editor-context-menu').should('not.exist')
cy.findByRole('menu').should('not.exist')
})
it('should not show context menu on gutter when feature flag is disabled', function () {
@@ -864,7 +943,7 @@ describe('editor context menu', { scrollBehavior: false }, function () {
)
cy.get('.cm-gutterElement').eq(5).rightclick()
cy.get('.editor-context-menu').should('not.exist')
cy.findByRole('menu').should('not.exist')
})
})
})