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-20 09:06:32 +00:00
committed by Copybot
parent 3e9d3f4d9c
commit 723da5c28a
9 changed files with 370 additions and 233 deletions
@@ -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',
},
})
@@ -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
}