Explorar el Código

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

[web] Add a11y support for context menu

GitOrigin-RevId: 3cbe66ee3ee8345dd0e89f89561928889832a50d
Malik Glossop hace 6 meses
padre
commit
723da5c28a

+ 50 - 44
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<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

+ 79 - 5
services/web/frontend/js/features/source-editor/extensions/context-menu.ts

@@ -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',
   },
 })

+ 12 - 5
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
 }

+ 87 - 68
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),
+  }
 }

+ 2 - 0
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<{

+ 7 - 1
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 {

+ 0 - 1
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';

+ 0 - 55
services/web/frontend/stylesheets/pages/editor/context-menu.scss

@@ -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);
-}

+ 133 - 54
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 () {
       </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')
     })
   })
 })