diff --git a/src/extensions/attachments_extension.js b/src/extensions/attachments_extension.js index a24962367..1f9f8dab4 100644 --- a/src/extensions/attachments_extension.js +++ b/src/extensions/attachments_extension.js @@ -1,8 +1,8 @@ -import { $getSelection, $isDecoratorNode, $isParagraphNode, $splitNode, COMMAND_PRIORITY_NORMAL, DELETE_CHARACTER_COMMAND, defineExtension } from "lexical" +import { $getSelection, $isDecoratorNode, $isNodeSelection, $isParagraphNode, $splitNode, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_NORMAL, DELETE_CHARACTER_COMMAND, KEY_TAB_COMMAND, defineExtension } from "lexical" import { mergeRegister } from "@lexical/utils" import { $findOrCreateGalleryForImage, $isImageGalleryNode, ImageGalleryNode } from "../nodes/image_gallery_node" -import { ActionTextAttachmentNode } from "../nodes/action_text_attachment_node" +import { $isActionTextAttachmentNode, ActionTextAttachmentNode } from "../nodes/action_text_attachment_node" import { ActionTextAttachmentUploadNode } from "../nodes/action_text_attachment_upload_node.js" import { AttachmentDragAndDrop } from "../editor/attachments/drag_and_drop" @@ -39,6 +39,7 @@ export class AttachmentsExtension extends LexxyExtension { return mergeRegister( editor.registerNodeTransform(ActionTextAttachmentNode, $extractAttachmentFromParagraph), editor.registerCommand(DELETE_CHARACTER_COMMAND, $collapseIntoGallery, COMMAND_PRIORITY_NORMAL), + editor.registerCommand(KEY_TAB_COMMAND, $focusCaptionFromSelectedAttachment, COMMAND_PRIORITY_HIGH), editor.registerMutationListener(ActionTextAttachmentUploadNode, this.#handleUploadMutations.bind(this)), () => dragAndDrop.destroy() ) @@ -138,6 +139,27 @@ function $collapseAtGalleryEdge(anchor, backwards) { } } +// Tab from a selected attachment moves focus into its caption textarea. The +// textarea carries tabIndex -1 so it is no longer reachable by walking the +// document with Tab; this is the deliberate way back in. Shift+Tab is left +// alone so it still steps backwards out of the editor. +function $focusCaptionFromSelectedAttachment(event) { + if (event.shiftKey) return false + + const selection = $getSelection() + if (!$isNodeSelection(selection)) return false + + const nodes = selection.getNodes() + const node = nodes.length === 1 ? nodes[0] : null + + if ($isActionTextAttachmentNode(node) && node.focusCaption()) { + event.preventDefault() + return true + } else { + return false + } +} + // Manual selection handling to prevent Lexical merging the gallery with a
and unwrapping it function $moveSelectionBeforeGallery(anchor) { const previousNode = anchor.getNode().getPreviousSibling() diff --git a/src/nodes/action_text_attachment_node.js b/src/nodes/action_text_attachment_node.js index d4f3f422c..99b91265f 100644 --- a/src/nodes/action_text_attachment_node.js +++ b/src/nodes/action_text_attachment_node.js @@ -1,6 +1,7 @@ import Lexxy from "../config/lexxy" -import { $getEditor, $getNearestRootOrShadowRoot, DecoratorNode, HISTORY_MERGE_TAG } from "lexical" +import { $getEditor, $getNearestRootOrShadowRoot, $setSelection, DecoratorNode, HISTORY_MERGE_TAG } from "lexical" import { createAttachmentFigure, createElement, isPreviewableImage } from "../helpers/html_helper" +import { $createNodeSelectionWith } from "../helpers/lexical_helper" import { bytesToHumanSize, extractFileName } from "../helpers/storage_helper" import { parseBoolean } from "../helpers/string_helper" import { REWRITE_HISTORY_COMMAND } from "../extensions/rewritable_history_extension" @@ -217,6 +218,15 @@ export class ActionTextAttachmentNode extends DecoratorNode { return this.contentType.startsWith("video/") } + // The caption textarea is deliberately out of the tab order, so this is the + // only way in from the keyboard. Returns false when there is no caption to + // focus, which lets the Tab handler fall through to default behaviour. + focusCaption() { + const textarea = this.editor.getElementByKey(this.getKey())?.querySelector("figcaption textarea") + textarea?.focus() + return textarea != null + } + #createDOMForPendingPreview() { const figure = this.createAttachmentFigure(false) figure.appendChild(this.#createDOMForFile()) @@ -411,6 +421,8 @@ export class ActionTextAttachmentNode extends DecoratorNode { const input = createElement("textarea", { value: this.caption, placeholder: this.fileName, + ariaLabel: this.isVideo ? "Video caption" : "Image caption", + tabIndex: -1, rows: "1" }) @@ -448,6 +460,20 @@ export class ActionTextAttachmentNode extends DecoratorNode { }, { tag: HISTORY_MERGE_TAG }) + } else if (event.key === "Escape") { + // Leave the caption without moving the caret past the attachment: hand + // focus back to the editor and reselect the attachment the caption + // belongs to, so Tab can step back in. + event.preventDefault() + event.target.blur() + + this.editor.getRootElement()?.focus({ preventScroll: true }) + + this.editor.update(() => { + $setSelection($createNodeSelectionWith(this)) + }, { + tag: HISTORY_MERGE_TAG + }) } // Stop all keydown events from bubbling to the Lexical root element. diff --git a/test/browser/tests/attachments/attachment_caption_focus.test.js b/test/browser/tests/attachments/attachment_caption_focus.test.js new file mode 100644 index 000000000..6f1d290e8 --- /dev/null +++ b/test/browser/tests/attachments/attachment_caption_focus.test.js @@ -0,0 +1,64 @@ +import { test } from "../../test_helper.js" +import { expect } from "@playwright/test" +import { mockActiveStorageUploads } from "../../helpers/active_storage_mock.js" + +test.describe("Attachment caption focus", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/attachments.html") + await page.waitForSelector("lexxy-editor[connected]") + await page.waitForSelector("lexxy-toolbar[connected]") + await mockActiveStorageUploads(page) + }) + + test("Tab past an attachment does not fall into its caption", async ({ page, editor }) => { + await editor.uploadFile("test/fixtures/files/example.png") + await expect(captionOf(page)).toBeVisible({ timeout: 10_000 }) + + // The reported bug: caret in ordinary text, nothing selected, and Tab lands + // in an image caption instead of moving on. + await editor.focus() + await page.keyboard.press("Home") + await page.keyboard.type("Hello") + await expect(page.locator("figure.attachment.node--selected")).toHaveCount(0) + + await page.keyboard.press("Tab") + + await expect(captionOf(page)).not.toBeFocused() + }) + + test("Tab from a selected attachment focuses the caption textarea", async ({ page, editor }) => { + await editor.uploadFile("test/fixtures/files/example.png") + + const figure = page.locator("figure.attachment") + await expect(figure).toBeVisible({ timeout: 10_000 }) + await selectAttachment(figure) + + await page.keyboard.press("Tab") + + await expect(figure.locator("figcaption textarea")).toBeFocused() + }) + + test("Escape from caption restores attachment selection and editor focus", async ({ page, editor }) => { + await editor.uploadFile("test/fixtures/files/example.png") + + const caption = captionOf(page) + await expect(caption).toBeVisible({ timeout: 10_000 }) + + await caption.click() + await caption.pressSequentially("Hello") + await caption.press("Escape") + + await expect(page.locator("figure.attachment.node--selected")).toHaveCount(1) + await expect(editor.content).toBeFocused() + }) +}) + +function captionOf(page) { + return page.locator("figure.attachment figcaption textarea") +} + +async function selectAttachment(figure) { + await figure.locator("img[src*='/blobs/']").waitFor() + await figure.locator("img").click() + await expect(figure).toHaveClass(/node--selected/) +}