From 9725eefa1eef746de8490ccfcae2f00e68d2f611 Mon Sep 17 00:00:00 2001 From: Bruno Prieto Date: Sat, 27 Jun 2026 07:21:58 +0200 Subject: [PATCH 01/11] Extract shared toolbar focus helpers for tests --- test/browser/helpers/editor_handle.js | 4 ++++ test/browser/helpers/toolbar.js | 4 ++++ test/browser/tests/formatting/toolbar.test.js | 11 ++++------- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/test/browser/helpers/editor_handle.js b/test/browser/helpers/editor_handle.js index 36569b02f..f47fa8be7 100644 --- a/test/browser/helpers/editor_handle.js +++ b/test/browser/helpers/editor_handle.js @@ -36,6 +36,10 @@ export class EditorHandle { await this.content.focus() } + async isFocused() { + return this.content.evaluate((element) => document.activeElement === element) + } + async click() { await this.content.click() } diff --git a/test/browser/helpers/toolbar.js b/test/browser/helpers/toolbar.js index 1a71cfde3..956633f5c 100644 --- a/test/browser/helpers/toolbar.js +++ b/test/browser/helpers/toolbar.js @@ -1,5 +1,9 @@ export const HELLO_EVERYONE = "

Hello everyone

" +export function focusedName(page) { + return page.evaluate(() => document.activeElement?.name) +} + export async function openFormatDropdown(page) { await openToolbarDropdown(page, "format") } diff --git a/test/browser/tests/formatting/toolbar.test.js b/test/browser/tests/formatting/toolbar.test.js index d0577f44d..aeed838cb 100644 --- a/test/browser/tests/formatting/toolbar.test.js +++ b/test/browser/tests/formatting/toolbar.test.js @@ -1,7 +1,7 @@ import { test } from "../../test_helper.js" import { expect } from "@playwright/test" import { assertEditorHtml } from "../../helpers/assertions.js" -import { HELLO_EVERYONE } from "../../helpers/toolbar.js" +import { HELLO_EVERYONE, focusedName } from "../../helpers/toolbar.js" test.describe("Toolbar", () => { test.beforeEach(async ({ page }) => { @@ -53,16 +53,13 @@ test.describe("Toolbar", () => { const boldButton = page.locator("lexxy-toolbar button[name='bold']") await boldButton.focus() - const focusedName = () => - page.evaluate(() => document.activeElement?.getAttribute("name")) - - await expect.poll(focusedName).toBe("bold") + await expect.poll(() => focusedName(page)).toBe("bold") await page.keyboard.press("ArrowRight") - await expect.poll(focusedName).toBe("italic") + await expect.poll(() => focusedName(page)).toBe("italic") await page.keyboard.press("ArrowLeft") - await expect.poll(focusedName).toBe("bold") + await expect.poll(() => focusedName(page)).toBe("bold") }) test("undo and redo commands", async ({ page, editor }) => { From 8de233f046aaf4f81c9e7428d1de15a78f5f24f7 Mon Sep 17 00:00:00 2001 From: Bruno Prieto Date: Sat, 27 Jun 2026 07:26:43 +0200 Subject: [PATCH 02/11] Confine arrow navigation to the toolbar and its open menus Arrow keys move along the bar with left/right and through an open menu with all four arrows, wrapping and trapped so they never leak out. Escape returns focus to the trigger when the menu was opened from the keyboard, otherwise to the editor. --- src/elements/toolbar.js | 14 ++- src/elements/toolbar_dropdown.js | 19 ++- src/helpers/accessibility_helper.js | 52 +++++++-- .../toolbar_keyboard_navigation.test.js | 109 ++++++++++++++++++ 4 files changed, 174 insertions(+), 20 deletions(-) create mode 100644 test/browser/tests/formatting/toolbar_keyboard_navigation.test.js diff --git a/src/elements/toolbar.js b/src/elements/toolbar.js index 8f3ca160b..50608f401 100644 --- a/src/elements/toolbar.js +++ b/src/elements/toolbar.js @@ -181,8 +181,8 @@ export class LexicalToolbarElement extends HTMLElement { } #handleEditorFocus = () => { - const firstVisible = this.#buttons.find(isActiveAndVisible) - if (firstVisible) firstVisible.tabIndex = 0 + const firstVisibleButton = this.#toolbarButtons.find(isActiveAndVisible) + if (firstVisibleButton) firstVisibleButton.tabIndex = 0 } #handleEditorBlur = () => { @@ -190,11 +190,11 @@ export class LexicalToolbarElement extends HTMLElement { } #handleKeydown = (event) => { - handleRollingTabIndex(this.#buttons, event) + handleRollingTabIndex(this.#toolbarButtons, event) } #resetTabIndexValues() { - this.#buttons.forEach((button) => { + this.#toolbarButtons.forEach((button) => { button.tabIndex = -1 }) } @@ -369,8 +369,10 @@ export class LexicalToolbarElement extends HTMLElement { return Array.from(this.querySelectorAll(":scope > button:not([data-prevent-overflow])")) } - get #buttons() { - return Array.from(this.querySelectorAll(":scope button")) + get #toolbarButtons() { + return Array.from(this.querySelectorAll(":scope button")).filter((button) => { + return !button.closest("[data-dropdown-panel]") + }) } get #toolbarItems() { diff --git a/src/elements/toolbar_dropdown.js b/src/elements/toolbar_dropdown.js index 0855b7ebf..0df0c8baf 100644 --- a/src/elements/toolbar_dropdown.js +++ b/src/elements/toolbar_dropdown.js @@ -1,8 +1,10 @@ import { nextFrame } from "../helpers/timing_helper" import { ListenerBin, registerEventListener } from "../helpers/listener_helper" +import { handleRollingTabIndex, isKeyboardActivation } from "../helpers/accessibility_helper" export class ToolbarDropdown extends HTMLElement { #listeners = new ListenerBin() + #shouldReturnFocusToTrigger = false connectedCallback() { this.#onToolbarEditor(() => { @@ -75,15 +77,20 @@ export class ToolbarDropdown extends HTMLElement { ) } - #handleTriggerClick = () => { + #handleTriggerClick = (event) => { if (this.isOpen) { this.close({ focusEditor: false }) } else { + this.#shouldReturnFocusToTrigger = this.#isOpenedFromToolbar(event) this.toolbar?.closeDropdowns({ except: this }) this.open() } } + #isOpenedFromToolbar(event) { + return isKeyboardActivation(event) && this.toolbar?.contains(document.activeElement) + } + async #onToolbarEditor(callback) { if (!this.toolbar) return @@ -94,10 +101,18 @@ export class ToolbarDropdown extends HTMLElement { #handleKeyDown = (event) => { if (event.key === "Escape") { event.stopPropagation() - this.close() + this.close({ focusEditor: !this.#shouldReturnFocusToTrigger }) + if (this.#shouldReturnFocusToTrigger) this.trigger?.focus() + } else if (this.#isNavigatingMenu(event)) { + event.stopPropagation() + handleRollingTabIndex(this.#buttons, event, { orientation: "both", wrap: true }) } } + #isNavigatingMenu(event) { + return this.panel.role === "menu" && this.panel.contains(event.target) + } + async #focusFirstInteractive() { this.#interactiveElements[0]?.focus() await this.#resetTabIndexValues() diff --git a/src/helpers/accessibility_helper.js b/src/helpers/accessibility_helper.js index 1d5a9f29b..e352622bb 100644 --- a/src/helpers/accessibility_helper.js +++ b/src/helpers/accessibility_helper.js @@ -1,10 +1,15 @@ import { isActiveAndVisible } from "./html_helper" -export function handleRollingTabIndex(elements, event) { +// Keyboard and programmatic clicks carry a synthetic pointer. +export function isKeyboardActivation(event) { + return event instanceof PointerEvent && event.pointerId === -1 +} + +export function handleRollingTabIndex(elements, event, { orientation = "horizontal", wrap = false } = {}) { const previousActiveElement = document.activeElement if (elements.includes(previousActiveElement)) { - const finder = new NextElementFinder(elements, event.key) + const finder = new NextElementFinder(elements, event.key, { orientation, wrap }) if (finder.selectNext(previousActiveElement)) { event.preventDefault() @@ -13,9 +18,11 @@ export function handleRollingTabIndex(elements, event) { } class NextElementFinder { - constructor(elements, key) { + constructor(elements, key, { orientation, wrap }) { this.elements = elements this.key = key + this.orientation = orientation + this.wrap = wrap } selectNext(fromElement) { @@ -32,23 +39,36 @@ class NextElementFinder { } #findNextElement(fromElement) { - switch (this.key) { - case "ArrowRight": - case "ArrowDown": + switch (this.#directionFor(this.key)) { + case "next": return this.#findNextSibling(fromElement) - case "ArrowLeft": - case "ArrowUp": + case "previous": return this.#findPreviousSibling(fromElement) - case "Home": + case "first": return this.#findFirst() - case "End": + case "last": return this.#findLast() } } + #directionFor(key) { + if (key === "Home") return "first" + if (key === "End") return "last" + if (this.#nextKeys.includes(key)) return "next" + if (this.#previousKeys.includes(key)) return "previous" + } + + get #nextKeys() { + return this.orientation === "both" ? [ "ArrowRight", "ArrowDown" ] : [ "ArrowRight" ] + } + + get #previousKeys() { + return this.orientation === "both" ? [ "ArrowLeft", "ArrowUp" ] : [ "ArrowLeft" ] + } + #findFirst(elements = this.elements) { return elements.find(isActiveAndVisible) } @@ -59,12 +79,20 @@ class NextElementFinder { #findNextSibling(element) { const afterElements = this.elements.slice(this.#indexOf(element) + 1) - return this.#findFirst(afterElements) + return this.#findFirst(afterElements) ?? this.#wrapToFirst() } #findPreviousSibling(element) { const beforeElements = this.elements.slice(0, this.#indexOf(element)) - return this.#findLast(beforeElements) + return this.#findLast(beforeElements) ?? this.#wrapToLast() + } + + #wrapToFirst() { + return this.wrap ? this.#findFirst() : undefined + } + + #wrapToLast() { + return this.wrap ? this.#findLast() : undefined } #indexOf(element) { diff --git a/test/browser/tests/formatting/toolbar_keyboard_navigation.test.js b/test/browser/tests/formatting/toolbar_keyboard_navigation.test.js new file mode 100644 index 000000000..13c52ca93 --- /dev/null +++ b/test/browser/tests/formatting/toolbar_keyboard_navigation.test.js @@ -0,0 +1,109 @@ +import { test } from "../../test_helper.js" +import { expect } from "@playwright/test" +import { HELLO_EVERYONE, focusedName } from "../../helpers/toolbar.js" + +test.describe("Toolbar keyboard navigation", () => { + test.beforeEach(async ({ page, editor }) => { + await page.goto("/") + await page.waitForSelector("lexxy-editor[connected]") + await page.waitForSelector("lexxy-toolbar[connected]") + await editor.setValue(HELLO_EVERYONE) + }) + + test("the toolbar moves with left and right, not up and down", async ({ page }) => { + await page.locator("lexxy-toolbar button[name='bold']").focus() + await expect.poll(() => focusedName(page)).toBe("bold") + + await page.keyboard.press("ArrowDown") + await expect.poll(() => focusedName(page)).toBe("bold") + + await page.keyboard.press("ArrowRight") + await expect.poll(() => focusedName(page)).toBe("italic") + + await page.keyboard.press("ArrowUp") + await expect.poll(() => focusedName(page)).toBe("italic") + + await page.keyboard.press("ArrowLeft") + await expect.poll(() => focusedName(page)).toBe("bold") + }) + + test("arrow keys keep moving along the toolbar when focus is on a menu dropdown trigger", async ({ page }) => { + const formatTrigger = page.locator("lexxy-toolbar button[name='format']") + await formatTrigger.focus() + await expect.poll(() => focusedName(page)).toBe("format") + + await page.keyboard.press("ArrowRight") + await expect.poll(() => focusedName(page)).toBe("highlight") + + await page.keyboard.press("ArrowLeft") + await expect.poll(() => focusedName(page)).toBe("format") + }) + + test("an open menu traps arrow navigation and wraps around", async ({ page }) => { + await page.locator("lexxy-toolbar button[name='format']").click() + await expect.poll(() => focusedName(page)).toBe("paragraph") + + // Wraps to the last item instead of leaking to the toolbar + await page.keyboard.press("ArrowUp") + await expect.poll(() => focusedName(page)).toBe("clear-formatting") + + // And back to the first + await page.keyboard.press("ArrowDown") + await expect.poll(() => focusedName(page)).toBe("paragraph") + + await page.keyboard.press("ArrowDown") + await expect.poll(() => focusedName(page)).toBe("heading-large") + }) + + test("the color grid moves with all four arrows", async ({ page }) => { + await page.locator("lexxy-toolbar button[name='highlight']").click() + + const focusedColorIndex = () => + page.evaluate(() => { + const buttons = [ ...document.querySelectorAll("lexxy-highlight-dropdown [data-dropdown-panel] button") ] + return buttons.indexOf(document.activeElement) + }) + + await expect.poll(focusedColorIndex).toBe(0) + + await page.keyboard.press("ArrowRight") + await expect.poll(focusedColorIndex).toBe(1) + + await page.keyboard.press("ArrowLeft") + await expect.poll(focusedColorIndex).toBe(0) + + // Down also moves linearly, not by column + await page.keyboard.press("ArrowDown") + await expect.poll(focusedColorIndex).toBe(1) + + await page.keyboard.press("ArrowUp") + await expect.poll(focusedColorIndex).toBe(0) + }) + + test("Escape returns focus to the trigger when the menu was opened from the toolbar", async ({ page }) => { + const formatTrigger = page.locator("lexxy-toolbar button[name='format']") + await formatTrigger.focus() + await formatTrigger.press("Enter") + await expect.poll(() => focusedName(page)).toBe("paragraph") + + await page.keyboard.press("Escape") + await expect.poll(() => focusedName(page)).toBe("format") + }) + + test("Escape returns focus to the editor when the dropdown was opened from the editor", async ({ page, editor }) => { + await editor.focus() + await page.keyboard.press("Control+k") + await expect(page.locator("lexxy-link-dropdown input[type='url']")).toBeFocused() + + await page.keyboard.press("Escape") + await expect.poll(() => editor.isFocused()).toBe(true) + }) + + test("Escape returns focus to the editor when the menu was opened with the mouse", async ({ page, editor }) => { + await page.locator("lexxy-toolbar button[name='format']").click() + await expect.poll(() => focusedName(page)).toBe("paragraph") + + await page.keyboard.press("Escape") + await expect.poll(() => editor.isFocused()).toBe(true) + }) +}) From fb9ed5043a7687fb911387eaf2ce6560aff3770d Mon Sep 17 00:00:00 2001 From: Bruno Prieto Date: Sat, 27 Jun 2026 07:29:24 +0200 Subject: [PATCH 03/11] Make toolbar command focus and disabled state accessible Disabled buttons use aria-disabled instead of the native attribute, so the keyboard reaches them and focus never falls to the body when a button disables itself. A command activated from inside a dropdown returns focus to the editor instead of being stranded on the hidden panel. --- src/elements/toolbar.js | 31 +++++----- .../tests/attachments/attachments.test.js | 2 +- test/browser/tests/formatting/toolbar.test.js | 4 +- .../toolbar_keyboard_navigation.test.js | 57 +++++++++++++++++++ 4 files changed, 75 insertions(+), 19 deletions(-) diff --git a/src/elements/toolbar.js b/src/elements/toolbar.js index 50608f401..29430d6f2 100644 --- a/src/elements/toolbar.js +++ b/src/elements/toolbar.js @@ -8,7 +8,7 @@ import { } from "lexical" import { getNonce } from "../helpers/csp_helper" import { ListenerBin, registerEventListener } from "../helpers/listener_helper" -import { handleRollingTabIndex } from "../helpers/accessibility_helper" +import { handleRollingTabIndex, isKeyboardActivation } from "../helpers/accessibility_helper" import ToolbarIcons from "./toolbar_icons" import { generateDomId, isActiveAndVisible } from "../helpers/html_helper" @@ -134,14 +134,21 @@ export class LexicalToolbarElement extends HTMLElement { } } - #dispatchButtonCommand(event, { dataset: { command, payload } }) { - const isKeyboard = event instanceof PointerEvent && event.pointerId === -1 + #dispatchButtonCommand(event, button) { + if (button.ariaDisabled === "true") return + + const { command, payload } = button.dataset + const shouldKeepToolbarFocus = isKeyboardActivation(event) && !this.#belongsToDropdown(button) this.editor.update(() => { this.editor.dispatchCommand(command, payload) - }, { tag: isKeyboard ? SKIP_DOM_SELECTION_TAG : undefined }) + }, { tag: shouldKeepToolbarFocus ? SKIP_DOM_SELECTION_TAG : undefined }) + + if (!shouldKeepToolbarFocus) this.editor.focus() + } - if (!isKeyboard) this.editor.focus() + #belongsToDropdown(button) { + return button.closest("[data-dropdown-panel]") != null } #bindHotkeys() { @@ -260,15 +267,7 @@ export class LexicalToolbarElement extends HTMLElement { #setButtonDisabled(name, isDisabled) { const button = this.querySelector(`[name="${name}"]`) - if (button) { - if (button.disabled !== isDisabled) { - button.disabled = isDisabled - } - const next = isDisabled.toString() - if (button.getAttribute("aria-disabled") !== next) { - button.setAttribute("aria-disabled", next) - } - } + if (button) button.ariaDisabled = isDisabled } #refreshOverflow() { @@ -471,11 +470,11 @@ export class LexicalToolbarElement extends HTMLElement { ${ToolbarIcons.hr} - - diff --git a/test/browser/tests/attachments/attachments.test.js b/test/browser/tests/attachments/attachments.test.js index 70746c68c..48e1a010d 100644 --- a/test/browser/tests/attachments/attachments.test.js +++ b/test/browser/tests/attachments/attachments.test.js @@ -282,7 +282,7 @@ test.describe("Attachments", () => { // Undo until the undo button is disabled — no stale upload node should remain const undoButton = page.getByRole("button", { name: "Undo" }) - while (await undoButton.evaluate((el) => !el.disabled)) { + while (await undoButton.evaluate((element) => element.ariaDisabled !== "true")) { await undoButton.click() await editor.flush() } diff --git a/test/browser/tests/formatting/toolbar.test.js b/test/browser/tests/formatting/toolbar.test.js index aeed838cb..fc736acfa 100644 --- a/test/browser/tests/formatting/toolbar.test.js +++ b/test/browser/tests/formatting/toolbar.test.js @@ -68,7 +68,7 @@ test.describe("Toolbar", () => { // Undo until the undo button is disabled (editor is back to initial state) const undoButton = page.getByRole("button", { name: "Undo" }) - while (await undoButton.evaluate((el) => !el.disabled)) { + while (await undoButton.evaluate((element) => element.ariaDisabled !== "true")) { await undoButton.click() await editor.flush() } @@ -76,7 +76,7 @@ test.describe("Toolbar", () => { // Redo until the redo button is disabled const redoButton = page.getByRole("button", { name: "Redo" }) - while (await redoButton.evaluate((el) => !el.disabled)) { + while (await redoButton.evaluate((element) => element.ariaDisabled !== "true")) { await redoButton.click() await editor.flush() } diff --git a/test/browser/tests/formatting/toolbar_keyboard_navigation.test.js b/test/browser/tests/formatting/toolbar_keyboard_navigation.test.js index 13c52ca93..bedaa95a9 100644 --- a/test/browser/tests/formatting/toolbar_keyboard_navigation.test.js +++ b/test/browser/tests/formatting/toolbar_keyboard_navigation.test.js @@ -106,4 +106,61 @@ test.describe("Toolbar keyboard navigation", () => { await page.keyboard.press("Escape") await expect.poll(() => editor.isFocused()).toBe(true) }) + + test("applying a menu command with the keyboard returns focus to the editor", async ({ page, editor }) => { + await editor.send("Hello World") + + const formatTrigger = page.locator("lexxy-toolbar button[name='format']") + await formatTrigger.focus() + await formatTrigger.press("Enter") + await expect.poll(() => focusedName(page)).toBe("paragraph") + + await page.keyboard.press("Enter") + await expect.poll(() => editor.isFocused()).toBe(true) + }) + + test("applying a command from the overflow menu returns focus to the editor", async ({ page, editor }) => { + await page.setViewportSize({ width: 300, height: 600 }) + await page.waitForSelector("lexxy-toolbar[overflowing]") + await editor.send("Hello World") + + await page.locator("lexxy-toolbar .lexxy-editor__toolbar-overflow button[data-dropdown-trigger]").click() + const undo = page.locator(".lexxy-editor__toolbar-overflow-menu button[name='undo']") + await undo.focus() + await expect.poll(() => focusedName(page)).toBe("undo") + + await page.keyboard.press("Enter") + await expect.poll(() => editor.isFocused()).toBe(true) + }) + + test("arrow navigation stops on disabled buttons so they can be announced", async ({ page, editor }) => { + await editor.send("Hello World") + + const redo = page.locator("lexxy-toolbar button[name='redo']") + await expect(redo).toHaveAttribute("aria-disabled", "true") + + await page.locator("lexxy-toolbar button[name='undo']").focus() + await expect.poll(() => focusedName(page)).toBe("undo") + + await page.keyboard.press("ArrowRight") + await expect.poll(() => focusedName(page)).toBe("redo") + }) + + test("focus stays on the button when its own action disables it", async ({ page, editor }) => { + await editor.send("Hello World") + + const undo = page.locator("lexxy-toolbar button[name='undo']") + await undo.focus() + await expect.poll(() => focusedName(page)).toBe("undo") + + // Focus must stay on the button as it disables itself, not fall to the body + let guard = 0 + while ((await undo.evaluate((element) => element.ariaDisabled)) !== "true" && guard++ < 20) { + await page.keyboard.press("Enter") + await editor.flush() + } + + await expect(undo).toHaveAttribute("aria-disabled", "true") + await expect.poll(() => focusedName(page)).toBe("undo") + }) }) From 21fde49f595de8cf881ddd4f24d0ef54c26f73e3 Mon Sep 17 00:00:00 2001 From: Bruno Prieto Date: Sat, 27 Jun 2026 07:29:56 +0200 Subject: [PATCH 04/11] Set toolbar ARIA state through reflection properties --- src/elements/dropdown/highlight.js | 6 +----- src/elements/toolbar.js | 7 +------ src/elements/toolbar_dropdown.js | 4 ++-- 3 files changed, 4 insertions(+), 13 deletions(-) diff --git a/src/elements/dropdown/highlight.js b/src/elements/dropdown/highlight.js index 15e1026a1..65c2f50a4 100644 --- a/src/elements/dropdown/highlight.js +++ b/src/elements/dropdown/highlight.js @@ -79,11 +79,7 @@ export class HighlightDropdown extends ToolbarDropdown { const backgroundColor = $getSelectionStyleValueForProperty(selection, "background-color", NO_STYLE) this.#colorButtons.forEach(button => { - const matchesSelection = button.dataset.value === textColor || button.dataset.value === backgroundColor - const next = matchesSelection.toString() - if (button.getAttribute("aria-pressed") !== next) { - button.setAttribute("aria-pressed", next) - } + button.ariaPressed = button.dataset.value === textColor || button.dataset.value === backgroundColor }) const hasHighlight = textColor !== NO_STYLE || backgroundColor !== NO_STYLE diff --git a/src/elements/toolbar.js b/src/elements/toolbar.js index 29430d6f2..2681794fb 100644 --- a/src/elements/toolbar.js +++ b/src/elements/toolbar.js @@ -257,12 +257,7 @@ export class LexicalToolbarElement extends HTMLElement { #setButtonPressed(name, isPressed) { const button = this.querySelector(`[name="${name}"]`) - if (button) { - const next = isPressed.toString() - if (button.getAttribute("aria-pressed") !== next) { - button.setAttribute("aria-pressed", next) - } - } + if (button) button.ariaPressed = isPressed } #setButtonDisabled(name, isDisabled) { diff --git a/src/elements/toolbar_dropdown.js b/src/elements/toolbar_dropdown.js index 0df0c8baf..257f79b88 100644 --- a/src/elements/toolbar_dropdown.js +++ b/src/elements/toolbar_dropdown.js @@ -55,7 +55,7 @@ export class ToolbarDropdown extends HTMLElement { open() { if (this.isOpen) return - this.trigger.setAttribute("aria-expanded", "true") + this.trigger.ariaExpanded = true this.panel.hidden = false this.onOpen() this.#focusFirstInteractive() @@ -65,7 +65,7 @@ export class ToolbarDropdown extends HTMLElement { if (focusEditor) this.editor?.focus() if (this.isClosed) return - this.trigger.setAttribute("aria-expanded", "false") + this.trigger.ariaExpanded = false this.panel.hidden = true this.onClose() } From 8669c543a9b8bd845a08bb90fd3a6c7fca3b1ea1 Mon Sep 17 00:00:00 2001 From: Bruno Prieto Date: Sat, 27 Jun 2026 07:41:47 +0200 Subject: [PATCH 05/11] Mark active menu options with aria-checked instead of aria-pressed aria-pressed is not valid on role="menuitem", so screen readers ignored the active state of the format and color menus. Block formats become menuitemradio and colors menuitemcheckbox, both exposing their state through aria-checked. The highlight CSS follows the new attribute. --- app/assets/stylesheets/lexxy-editor.css | 4 ++-- src/elements/dropdown/heading.js | 6 +++--- src/elements/dropdown/highlight.js | 4 ++-- src/elements/toolbar.js | 9 +++++++-- .../tests/formatting/color_highlighter.test.js | 14 ++++++++++++++ .../tests/formatting/heading_format.test.js | 9 +++++---- 6 files changed, 33 insertions(+), 13 deletions(-) diff --git a/app/assets/stylesheets/lexxy-editor.css b/app/assets/stylesheets/lexxy-editor.css index 6456ffd27..59b3925c7 100644 --- a/app/assets/stylesheets/lexxy-editor.css +++ b/app/assets/stylesheets/lexxy-editor.css @@ -636,7 +636,7 @@ padding: 1ch; position: relative; - &[aria-pressed="true"] { + &[aria-checked="true"] { background-color: var(--lexxy-color-selected); &:hover { @@ -796,7 +796,7 @@ position: absolute; } - &[aria-pressed="true"] { + &[aria-checked="true"] { background-color: transparent; box-shadow: 0 0 0 2px currentColor inset; diff --git a/src/elements/dropdown/heading.js b/src/elements/dropdown/heading.js index ea4bfa37f..e84f153b2 100644 --- a/src/elements/dropdown/heading.js +++ b/src/elements/dropdown/heading.js @@ -64,8 +64,8 @@ export class HeadingDropdown extends HTMLElement { updateActiveHeading(tag) { this.#headingButtons.forEach(button => { const next = (button.dataset.heading === tag).toString() - if (button.getAttribute("aria-pressed") !== next) { - button.setAttribute("aria-pressed", next) + if (button.getAttribute("aria-checked") !== next) { + button.setAttribute("aria-checked", next) } }) } @@ -110,7 +110,7 @@ export class HeadingDropdown extends HTMLElement { button.classList.add("lexxy-heading-button") button.name = name button.title = label - button.setAttribute("role", "menuitem") + button.setAttribute("role", "menuitemradio") button.innerHTML = `${icon} ${label}` return button } diff --git a/src/elements/dropdown/highlight.js b/src/elements/dropdown/highlight.js index 65c2f50a4..5b03028ca 100644 --- a/src/elements/dropdown/highlight.js +++ b/src/elements/dropdown/highlight.js @@ -55,7 +55,7 @@ export class HighlightDropdown extends ToolbarDropdown { style: `${attribute}: ${value}`, class: "lexxy-editor__toolbar-button lexxy-highlight-button", name: `${attribute}-${index}`, - role: "menuitem" + role: "menuitemcheckbox" }) } @@ -79,7 +79,7 @@ export class HighlightDropdown extends ToolbarDropdown { const backgroundColor = $getSelectionStyleValueForProperty(selection, "background-color", NO_STYLE) this.#colorButtons.forEach(button => { - button.ariaPressed = button.dataset.value === textColor || button.dataset.value === backgroundColor + button.ariaChecked = button.dataset.value === textColor || button.dataset.value === backgroundColor }) const hasHighlight = textColor !== NO_STYLE || backgroundColor !== NO_STYLE diff --git a/src/elements/toolbar.js b/src/elements/toolbar.js index 2681794fb..b6299da54 100644 --- a/src/elements/toolbar.js +++ b/src/elements/toolbar.js @@ -238,7 +238,7 @@ export class LexicalToolbarElement extends HTMLElement { this.#setButtonPressed("underline", isUnderline) this.#setButtonPressed("format", isInHeading) - this.#setButtonPressed("paragraph", !isInHeading) + this.#setButtonChecked("paragraph", !isInHeading) this.querySelector("lexxy-heading-dropdown") ?.updateActiveHeading(headingTag) @@ -260,6 +260,11 @@ export class LexicalToolbarElement extends HTMLElement { if (button) button.ariaPressed = isPressed } + #setButtonChecked(name, isChecked) { + const button = this.querySelector(`[name="${name}"]`) + if (button) button.ariaChecked = isChecked + } + #setButtonDisabled(name, isDisabled) { const button = this.querySelector(`[name="${name}"]`) if (button) button.ariaDisabled = isDisabled @@ -406,7 +411,7 @@ export class LexicalToolbarElement extends HTMLElement { ${ToolbarIcons.heading}