Skip to content
170 changes: 169 additions & 1 deletion src/components/contextmenu/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,179 @@ export default function Contextmenu(content, options) {
show();
}

/**
* Whether a direct child of the menu is an enabled, actionable row.
*
* Separators (`<hr>`) and rows marked `disabled`/`separator` are excluded,
* so keyboard users can neither focus them nor trigger actions the menu
* presents as unavailable. Without this, Enter/Space would dispatch a click
* on a disabled row, and a programmatic click bypasses the CSS
* `pointer-events: none` that normally blocks it.
* @param {Element|null|undefined} $item
* @returns {boolean}
*/
function isActionableItem($item) {
if (!$item || $item.tagName === "HR") return false;
if ($item.classList.contains("disabled")) return false;
if ($item.classList.contains("separator")) return false;
return true;
}

/**
* All enabled, actionable rows in the menu, in visual order.
* @returns {HTMLElement[]}
*/
function getActionableItems() {
if ($el.classList.contains("disabled")) return [];
return [...$el.children].filter(isActionableItem);
}

function addTabindex() {
/**@type {Array<HTMLLIElement>} */
const children = [...$el.children];
for (let $el of children) $el.tabIndex = "0";
for (let $child of children) {
if (isActionableItem($child)) {
$child.tabIndex = "0";
} else {
$child.removeAttribute("tabindex");
}
}
}

/**
* Returns the currently focused actionable menu row, or null if focus is
* on a disabled row, a nested control, or outside the menu.
* @returns {HTMLElement|null}
*/
function getFocusedItem() {
const $active = document.activeElement;
if (!$active || $active.parentElement !== $el) return null;
return isActionableItem($active) ? $active : null;
}

/**
* Moves focus to the next/previous actionable menu row, wrapping around at
* the ends. `direction` is +1 for next, -1 for previous.
* @param {number} direction
*/
function moveFocus(direction) {
const items = getActionableItems();
if (!items.length) return;

const currentIndex = items.indexOf(document.activeElement);
let nextIndex;
if (currentIndex === -1) {
nextIndex = direction > 0 ? 0 : items.length - 1;
} else {
nextIndex = (currentIndex + direction + items.length) % items.length;
}
items[nextIndex]?.focus();
Comment thread
TonyGeez marked this conversation as resolved.
}

/**
* Turns a keyboard activation (Enter/Space on a focused menu row) into a
* real click event, so both selection patterns this component supports
* handle it identically to a pointer click:
* - the `items`/`onselect` array form, whose routing lives in the
* `onclick` handler above
* - a consumer's own click listener attached directly to $el, as used by
* menus built with the `innerHTML` option
*
* The dispatched event is marked with `keyboardActivated` so consumers that
* filter out synthetic `detail === 0` ghost clicks (e.g. to ignore the click
* that follows a touch-based long press) can still recognize and allow this
* one through.
*
* Disabled rows and separators are ignored, so they can never be activated
* from the keyboard.
* @param {HTMLElement|null} $item
*/

function activateItem($item) {
if ($el.classList.contains("disabled")) return;
if (!$item || $item.parentElement !== $el || !isActionableItem($item))
return;
const clickEvent = new MouseEvent("click", {
bubbles: true,
cancelable: true,
view: window,
});
Object.defineProperty(clickEvent, "keyboardActivated", {
value: true,
});
$item.dispatchEvent(clickEvent);
}

/**
* Whether a keydown event should be handled by the menu's keyboard
* navigation. Keys originating from interactive descendants (e.g. the file
* menu's read-only checkbox) are left alone so those controls keep their
* native Space/Enter behavior.
* @param {KeyboardEvent} e
* @returns {boolean}
*/
function isMenuKeyEvent(e) {
const $target = e.target;
if ($target === $el) return true;
return (
$target instanceof Element &&
$target.parentElement === $el &&
isActionableItem($target)
);
}

/**
* Keyboard support for the menu: Enter/Space activates the focused row,
* Up/Down arrows move focus between actionable rows (wrapping at the ends),
* Home/End jump to the first/last actionable row, and Escape closes the menu
* and returns focus to the toggler.
*
* Events originating from nested interactive controls are ignored (except
* Escape), so those controls are never prevented from handling their own
* keys.
* @param {KeyboardEvent} e
*/
function onMenuKeydown(e) {
if (e.key === "Escape" || e.key === "Esc") {
e.preventDefault();
hide();
options.toggler?.focus?.();
return;
}

if (!isMenuKeyEvent(e)) return;

switch (e.key) {
case "Enter":
case " ":
case "Spacebar":
e.preventDefault();
Comment thread
TonyGeez marked this conversation as resolved.
activateItem(getFocusedItem());
break;
case "ArrowDown":
case "Down":
e.preventDefault();
moveFocus(1);
break;
case "ArrowUp":
case "Up":
e.preventDefault();
moveFocus(-1);
break;
case "Home":
e.preventDefault();
getActionableItems()[0]?.focus();
break;
case "End": {
const items = getActionableItems();
items[items.length - 1]?.focus();
break;
}
}
}

function destroy() {
$el.removeEventListener("keydown", onMenuKeydown);
$el.remove();
$mask.remove();
options.toggler?.removeEventListener("click", toggle);
Expand All @@ -134,6 +300,8 @@ export default function Contextmenu(content, options) {
options.toggler.addEventListener("click", toggle);
}

$el.addEventListener("keydown", onMenuKeydown);

$el.hide = hide;
$el.show = show;
$el.destroy = destroy;
Expand Down
53 changes: 53 additions & 0 deletions src/handlers/editorFileTab.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import { focusEditorIfEditable } from "cm/editorReadOnly";
import openTabContextMenu from "handlers/tabContextMenu";
import config from "lib/config";
import settings from "lib/settings";
import { animate } from "motion";

const opts = { passive: false };

/**
* How far the pointer must travel before a long press counts as a drag
* instead of a tab context menu request.
*/
const DRAG_MENU_SLOP = 8;

/**
* Clone of tab being dragged
* @type {HTMLDivElement}
Expand Down Expand Up @@ -78,6 +85,23 @@ let prevScrollLeft = 0;
let initialNextSibling = null;
let didReorder = false;
let dragSessionId = 0;
/**
* Whether the pointer moved far enough during this drag session to count as a
* real drag. When the drag session ends without any drag, the tab context
* menu is shown instead.
* @type {boolean}
*/
let didDrag = false;
/**
* Pointer position where the current drag session started.
* @type {number}
*/
let dragOriginX = 0;
/**
* Pointer position where the current drag session started.
* @type {number}
*/
let dragOriginY = 0;

const MIN_SCROLL_SPEED = 2;
const MAX_SCROLL_SPEED = 14;
Expand All @@ -96,6 +120,10 @@ export default function startDrag(e) {
const { clientX, clientY } = getClientPos(e);
const { editor, activeFile } = editorManager;

dragOriginX = clientX;
dragOriginY = clientY;
didDrag = false;

if (activeFile.focusedBefore) {
focusEditorIfEditable(editor);
}
Expand Down Expand Up @@ -177,6 +205,14 @@ function onDrag(e) {

const { clientX, clientY } = getClientPos(e);

if (
!didDrag &&
(Math.abs(clientX - dragOriginX) > DRAG_MENU_SLOP ||
Math.abs(clientY - dragOriginY) > DRAG_MENU_SLOP)
) {
didDrag = true;
}

tabLeft = clientX - offsetX;
tabTop = clientY - offsetY;

Expand Down Expand Up @@ -206,6 +242,20 @@ function onDrag(e) {
function releaseDrag(e) {
const { clientX, clientY } = getClientPos(e);

// A long press (or right click) that ends without moving the pointer is a
// request for the tab context menu, not a drag.
const openContextMenu =
!didDrag &&
!!draggedFile &&
(e.type === "mouseup" || e.type === "touchend");

if (openContextMenu) {
const file = draggedFile;
finishDrag(false);
openTabContextMenu(file);
return;
}

/**@type {HTMLDivElement} target tab */
const $target = document.elementFromPoint(clientX, clientY);
const isPathDropTarget = isFilePathDropTarget($target);
Expand Down Expand Up @@ -311,6 +361,9 @@ function cleanupDrag(state = getCurrentDragState()) {
allowPaneTransfer = true;
initialNextSibling = null;
didReorder = false;
didDrag = false;
dragOriginX = 0;
dragOriginY = 0;
}

function preventDefaultScroll() {
Expand Down
Loading