From 723da5c28aead0957db7fc3d85a75dfa3cdbd6e4 Mon Sep 17 00:00:00 2001 From: Malik Glossop Date: Mon, 19 Jan 2026 13:53:08 +0100 Subject: [PATCH] Merge pull request #30490 from overleaf/mg-context-menu-a11y [web] Add a11y support for context menu GitOrigin-RevId: 3cbe66ee3ee8345dd0e89f89561928889832a50d --- .../components/editor-context-menu.tsx | 94 ++++----- .../source-editor/extensions/context-menu.ts | 84 +++++++- .../extensions/toolbar/commands.ts | 17 +- .../hooks/use-context-menu-items.tsx | 155 ++++++++------- .../components/types/dropdown-menu-props.ts | 2 + .../stylesheets/components/dropdown-menu.scss | 8 +- .../web/frontend/stylesheets/pages/all.scss | 1 - .../pages/editor/context-menu.scss | 55 ------ .../codemirror-editor-context-menu.spec.tsx | 187 +++++++++++++----- 9 files changed, 370 insertions(+), 233 deletions(-) delete mode 100644 services/web/frontend/stylesheets/pages/editor/context-menu.scss diff --git a/services/web/frontend/js/features/source-editor/components/editor-context-menu.tsx b/services/web/frontend/js/features/source-editor/components/editor-context-menu.tsx index 94d7c262b2..ccc061ec9e 100644 --- a/services/web/frontend/js/features/source-editor/components/editor-context-menu.tsx +++ b/services/web/frontend/js/features/source-editor/components/editor-context-menu.tsx @@ -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(null) - const menuItems = useContextMenuItems() + useEffect(() => { + menuRef.current?.focus() + }, []) return ( -
- {menuItems.map((menuItem, index) => ( - - {menuItem.separatorAbove && ( -
- )} - menuItem.handler()} - disabled={menuItem.disabled} - shortcut={menuItem.shortcut} - /> - - ))} -
+ + { + switch (event.code) { + case 'Escape': + case 'Tab': + event.preventDefault() + closeMenu() + break + } + }} + > + {menuItems.map((menuItem, index) => ( + + {menuItem.separatorAbove && } + + menuItem.handler()} + disabled={menuItem.disabled} + trailingIcon={ + menuItem.shortcut ? ( + {menuItem.shortcut} + ) : undefined + } + > + {menuItem.label} + + + + ))} + + ) }) -type ContextMenuItemProps = { - label: string - onClick: () => void - disabled?: boolean - shortcut?: string -} - -const ContextMenuItem: FC = ({ - label, - shortcut, - onClick, - disabled, -}) => ( - -) - export default EditorContextMenu diff --git a/services/web/frontend/js/features/source-editor/extensions/context-menu.ts b/services/web/frontend/js/features/source-editor/extensions/context-menu.ts index a4a6933551..a91cc72037 100644 --- a/services/web/frontend/js/features/source-editor/extensions/context-menu.ts +++ b/services/web/frontend/js/features/source-editor/extensions/context-menu.ts @@ -38,7 +38,7 @@ export const contextMenuStateField = StateField.define({ 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({ ], }) -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', }, }) diff --git a/services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts b/services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts index 0fbb21c643..e6ab083441 100644 --- a/services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts +++ b/services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts @@ -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 } diff --git a/services/web/frontend/js/features/source-editor/hooks/use-context-menu-items.tsx b/services/web/frontend/js/features/source-editor/hooks/use-context-menu-items.tsx index 83a7ffc447..cdb4923c63 100644 --- a/services/web/frontend/js/features/source-editor/hooks/use-context-menu-items.tsx +++ b/services/web/frontend/js/features/source-editor/hooks/use-context-menu-items.tsx @@ -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), + } } diff --git a/services/web/frontend/js/shared/components/types/dropdown-menu-props.ts b/services/web/frontend/js/shared/components/types/dropdown-menu-props.ts index 2664e8914f..8384788a7b 100644 --- a/services/web/frontend/js/shared/components/types/dropdown-menu-props.ts +++ b/services/web/frontend/js/shared/components/types/dropdown-menu-props.ts @@ -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<{ diff --git a/services/web/frontend/stylesheets/components/dropdown-menu.scss b/services/web/frontend/stylesheets/components/dropdown-menu.scss index 63aa9173c9..64ed25e585 100644 --- a/services/web/frontend/stylesheets/components/dropdown-menu.scss +++ b/services/web/frontend/stylesheets/components/dropdown-menu.scss @@ -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 { diff --git a/services/web/frontend/stylesheets/pages/all.scss b/services/web/frontend/stylesheets/pages/all.scss index 71f4d5ff13..cda46b222c 100644 --- a/services/web/frontend/stylesheets/pages/all.scss +++ b/services/web/frontend/stylesheets/pages/all.scss @@ -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'; diff --git a/services/web/frontend/stylesheets/pages/editor/context-menu.scss b/services/web/frontend/stylesheets/pages/editor/context-menu.scss deleted file mode 100644 index 53d0cd9ef0..0000000000 --- a/services/web/frontend/stylesheets/pages/editor/context-menu.scss +++ /dev/null @@ -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); -} diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-context-menu.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-context-menu.spec.tsx index 7acac8e90a..beec118334 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-context-menu.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-context-menu.spec.tsx @@ -102,16 +102,42 @@ describe('editor context menu', { scrollBehavior: false }, function () { ) - 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( + + + + + + ) + + 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( + + + + + + + ) + + // 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 () { { + 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 () { { + 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( + + + + + + ) + + 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 () { ) - 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') }) }) })