diff --git a/.github/workflows/a11y.yml b/.github/workflows/a11y.yml new file mode 100644 index 0000000000..22ab4853ac --- /dev/null +++ b/.github/workflows/a11y.yml @@ -0,0 +1,103 @@ +name: Accessibility + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +concurrency: + group: a11y-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + browser: + name: Accessibility - browser + runs-on: ubuntu-latest + timeout-minutes: 30 + # Match the local Docker environment and the installed Playwright version. + container: + image: mcr.microsoft.com/playwright:v1.60.0-noble + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: voidzero-dev/setup-vp@3754dd7dbdb32bd8f6d28b6043de13ad3a75f21f # v1.21.1 + with: + node-version-file: ".node-version" + cache: true + + - name: Install dependencies + run: vp install --frozen-lockfile + + # Already inside Docker; call Vitest directly to avoid nested Docker + # and task caching. Source aliases mean no package build is needed. + - name: Run axe, keyboard and visual checks + working-directory: tests + run: pnpm exec vp test --config a11y/vite.config.ts --run + + - name: Upload browser report and diagnostics + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: a11y-browser + path: | + tests/playwright-report/a11y/browser/ + tests/test-results/a11y/ + retention-days: 7 + + voiceover: + name: Accessibility - VoiceOver + # Pin the OS major version; hosted images still receive patch updates. + # The initial transcripts came from macOS 27. Review any CI differences + # against these baselines rather than automatically updating snapshots. + runs-on: macos-26 + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: voidzero-dev/setup-vp@3754dd7dbdb32bd8f6d28b6043de13ad3a75f21f # v1.21.1 + with: + node-version-file: ".node-version" + cache: true + + - name: Install dependencies + run: vp install --frozen-lockfile + + - name: Install WebKit + working-directory: tests + run: pnpm exec playwright install webkit + + - name: Configure VoiceOver + working-directory: tests + run: | + pnpm dlx @guidepup/setup@0.29.3 setup --ci + pnpm dlx @guidepup/setup@0.29.3 install + + - name: Record runner version + run: sw_vers + + # Invoke the runner directly so a cached task can never skip VoiceOver. + # The Vitest config disables parallel test files for screen reader automation. + - name: Run screen reader checks + run: pnpm exec node tests/a11y/run.mjs --screen-reader + + - name: Upload VoiceOver report and diagnostics + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: a11y-voiceover + path: | + tests/playwright-report/a11y/voiceover/ + tests/test-results/a11y/ + retention-days: 7 diff --git a/a11y-study/outline-of-concerns.md b/a11y-study/outline-of-concerns.md new file mode 100644 index 0000000000..024b4a8a91 --- /dev/null +++ b/a11y-study/outline-of-concerns.md @@ -0,0 +1,61 @@ +This document covers the main areas that are most relevant to the editor regarding accessibility. These areas are derived from: + +- The list of issues found by the Docs team in an accessibility audit (marked with the a11y tag on GitHub). +- Scanning through the WCAG 2.0–2.2 guidelines and identifying likely problem areas. +- Long-standing UX issues that relate to accessibility but haven't yet been formally documented. + +# Legibility + +Sight-impaired users need to be able to read text in the editor body as well as in interactive elements like menus and toolbars. The following concerns are most relevant to BlockNote for accessibility. + +## Contrast + +WCAG sets guidelines for [minimum](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum) and [enhanced](https://www.w3.org/WAI/WCAG22/Understanding/contrast-enhanced) contrast that text has to have against its background to be legible. + +## Text Spacing & Resizing + +Vision-impaired users have tools available, which make text on a page easier to read by applying additional CSS styles. There are WCAG guidelines that state the upper limits of how the text's [size](https://www.w3.org/WAI/WCAG21/Understanding/resize-text.html) and [spacing](https://www.w3.org/WAI/WCAG21/Understanding/text-spacing.html) may be increased, without causing content to clip. + +# Keyboard & Focus Handling + +Users with impaired motor control will use a keyboard or other device that relies solely on buttons to navigate a page. When it comes to BlockNote, there are a few specific cases +that we need to consider. + +## Editor Focus + +Currently, the editor can be pretty annoying when moving focus around a page. Once focus lands on the editor, it immediately snaps to the editor's selection. This is fine for when the user wants to focus the editor, but less pretty annoying when moving around the page and having to focus the editor due to tab order. Since it has its own tab handling, trying to move focus outside it can be frustrating. + +## Block Selection + +There is a distinction between selecting all content within a block and selecting the entire block, especially for things like backspace handling. This distinction is currently unclear to the user, selected blocks are not always clearly highlighted, and it's only possible to select an entire block with text content using Cmd+Click. + +## Interactive Block Elements + +Some blocks contain interactive elements within them, e.g., checkboxes in check list items. These are not accessible while the editor is focused and are otherwise, but their tab order is not at all intuitive. + +## UI Elements + +Some work has been put into making the formatting and link toolbars accessible. Suggestion menus are also keyboard-accessible. For other UI elements, keyboard navigation is either completely broken (e.g., side menu) or not considered (e.g., file panel). + +## Hover Controls + +Any controls that are exposed by hovering an element with the mouse cursor or focusing it must be accessible with the keyboard, as per this [WCAG guideline](https://www.w3.org/WAI/WCAG22/Understanding/content-on-hover-or-focus.html). + +# Screen Reader Announcements + +When screen readers announce content within the editor, they must also announce additional semantic information that would be useful when viewing and editing a document. + +## Screen Reader Focus + +Screen readers have their own keyboard navigation and target handling separate from the browser. This means that while an element is focused in the browser, the user can still move the screen reader to target other elements on the page. However, moving around the editor using the keyboard moves the screen reader target with it, so generally everything should work out-of-the-box in our case. + +## Markup + +The editor contains a lot of markup that is currently not announced by screen readers but is required for the user to understand the document structure. This is generally fixed using ARIA attributes and includes things like: + +- Block type +- Block nesting +- Block children +- Block colors +- Table row/column information +- ... diff --git a/a11y-study/testing-requirements.md b/a11y-study/testing-requirements.md new file mode 100644 index 0000000000..194fbf6e13 --- /dev/null +++ b/a11y-study/testing-requirements.md @@ -0,0 +1,38 @@ +This document covers what testing infrastructure must be in place to ensure that BlockNote has a good level of accessibility. This means coverage of all areas discussed in Outline of Concerns. + +# Legibility + +## Contrast + +Contrast requirements can be checked using static DOM analysis tools like [axe-core](https://github.com/dequelabs/axe-core/tree/develop). A rule like [color-contrast](https://dequeuniversity.com/rules/axe/4.13/color-contrast?application=RuleDescription) calculates contrast between the CSS text and background colors for each element. It cannot check contrast for image, gradient, or other non-single-color backgrounds, but these aren't used in BlockNote, so it doesn't matter in our case. Visual regression testing is not necessary for checking contrast. + +## Text Spacing & Resizing + +The WCAG guidelines require that text spacing & sizing must be modifiable, and this is done in accessibility tools by overwriting CSS. Testing this is twofold. First, static DOM analysis using [axe-core](https://github.com/dequelabs/axe-core/tree/develop) ensures that no inline styles prevent CSS rules from applying. Visual regression testing is then used to capture screenshots and verify that content is not clipped, which is already part of our existing test infrastructure. + +# Keyboard & Focus Handling + +We already have keyboard handling as part of our existing tests. This is done in two ways: + +1. Simulating key presses using synthetic events or directly calling their related handlers in a jsdom environment. +2. Driving the keyboard to dispatch real events in a real browser environment. + +For accessibility purposes, we should be using a real browser environment. Simulated key presses in a jsdom environment are not guaranteed to behave the same as a real browser, while calling the handlers is not functionally different to having a unit test for that handler. We should refactor our existing jsdom tests to either unit or browser tests. + +The scope of keyboard handling should include: + +- Keyboard navigation through a page with interactive elements, including a BlockNote editor. +- Selections within an editor at different levels: + - Selecting content within blocks. + - Selecting entire blocks. + - Selecting the entire editor. +- Focusing all interactive elements within default blocks, including media controls. +- Focusing/triggering all interactive elements in the BlockNote UI, including those gated behind hovering/focusing another element. + +Visual regression testing should also be used to ensure the focused elements are visually distinct, e.g., using a focus ring. + +# Screen Reader Announcements + +Static DOM analysis tools like [axe-core](https://github.com/dequelabs/axe-core/tree/develop) can catch a large chunk of missing screen reader announcements through the presence of things like ARIA attributes. However, they are geared more towards static websites, and we are in a fairly unique position where the user is editing content as well as viewing it, and the content itself has significantly more markup than the average website. For example, our schema allows for block indentation. It's important that the indentation level of a block is communicated by a screen reader, but it's not something that static DOM analysis tools can catch. + +Therefore, we should also incorporate automated screen reader testing, which lets you capture the output of a screen reader as a page is being navigated. This lets us ensure that BlockNote-specific semantics are captured by a screen reader. While screen readers have their own keyboard navigation and targeting, we don't need to test this. When navigating through the editor using a keyboard, the screen reader target will move with the selection. Advanced screen reader navigation, like [VoiceOver's rotor](https://support.apple.com/en-euro/guide/voiceover/mchlp2719/mac), is reliant on proper DOM element semantics and ARIA attributes, so it's tested implicitly by static DOM analysis tools. diff --git a/a11y-study/tooling-and-organization.md b/a11y-study/tooling-and-organization.md new file mode 100644 index 0000000000..5ff8bb4a07 --- /dev/null +++ b/a11y-study/tooling-and-organization.md @@ -0,0 +1,53 @@ +This document goes over the tooling we need based onTesting Requirements and discusses how accessibility tests should be organized. + +# Tooling Overview + +This section goes over all the relevant tooling for accessibility testing, both manual and automated. + +## Linting + +[oxlint](https://oxc.rs/docs/guide/usage/linter.html) includes a ruleset for accessibility linting - [jsx-a11y](https://oxc.rs/docs/guide/usage/linter/rules.html?sort=source&dir=asc&scope=jsx_a11y). This is useful for catching any obvious issues like missing labels and positive tab indices before running any automated tests. + +## Static DOM Analysis + +[axe-core](https://github.com/dequelabs/axe-core) is the gold standard for basic accessibility testing. It scans the DOM of a page and checks for issues similar issues to the aforementioned linter. The draw is that it has a [browser extension](https://chromewebstore.google.com/detail/axe-devtools-web-accessib/lhdoppojpmngadmnindnejefpokejbdd), and more importantly, has a [Playwright integration](https://playwright.dev/docs/accessibility-testing) which makes it much easier to slot in to our existing testing infrastructure, and scan different UI menus/toolbars with it. + +## Visual Regression Testing + +We can already do visual regression testing using Playwright. + +## Screen Reader Automated Testing + +This is something that has only come around in recent years, but it's pretty self explanatory. You can programmatically control a screen reader and transcribe its output into snapshots that you compare other test runs against. + +In our case, we mostly just care about the transcriptions as the screen reader will follow the selection when navigating the editor using a keyboard. + +The utility of these tests really is in looking at a transcription, comparing it to the editor state and seeing if there's any useful information that isn't being conveyed, so we can make further improvements. The "north star" is that the transcription of a screen reader provides all the necessary information for someone to be able to recreate the document, as well as keyboard inputs made by the user, without any errors. + +[GuidePup](https://www.guidepup.dev/) is a library for automated screen reader testing that fits quite well into our existing stack as it integrates with Playwright. It supports [VoiceOver](https://support.apple.com/en-euro/guide/voiceover/welcome/mac) on macOS, [NVDA](https://www.nvaccess.org/) on Windows, and a virtual screen reader for use outside a browser environment. Other popular solutions include [BrowserStack](https://www.browserstack.com/docs/app-accessibility/screen-reader-automation) and [Assistiv Labs](https://assistivlabs.com/articles/automating-screen-readers-for-accessibility-testing), but these are entire platforms that we probably don't want to deal with as part of our CI. The [AT Driver](https://github.com/w3c/at-driver) is also relevant here, but is still WIP. + +## Built-In Tools + +Chrome has an accessibility tree viewer which is quite useful for getting item ordering with tab & screen reader navigation. + +[VoiceOver](https://support.apple.com/en-euro/guide/voiceover/welcome/mac) is the built-in macOS/iOS screen reader and is helpful for manual testing. It's useful to get comfortable using it to put yourself in the user's shoes and spot issues. Windows has [NVDA](https://www.nvaccess.org/) and Android has [TalkBack](https://support.google.com/accessibility/android/answer/6283677?hl=en) as equivalents, but I haven't tried them yet. + +# Automated Testing Organization + +Based on the need 3 types of automated tests to cover accessibility: + +1. Static DOM analysis +2. Visual regression snapshotting +3. Screen reader + +TODO + +## Out of Scope + +### Drag & Drop + +There is a WCAG [guideline on drag & drop](https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements.html) which states that actions performed by drag & drop must have an alternative way of triggering them using regular clicks. We do technically have a way of doing this with keyboard shortcuts to move blocks up/down, but this doesn't fit the guideline as it uses keyboard shortcuts, not clicks. I'm not sure yet what the best UX pattern to solve this issue would be yet and think this requires a separate look. + +### Announcements + +TODO diff --git a/packages/core/src/blocks/Heading/a11y/block.a11y.test.ts b/packages/core/src/blocks/Heading/a11y/block.a11y.test.ts new file mode 100644 index 0000000000..cd3b64ec02 --- /dev/null +++ b/packages/core/src/blocks/Heading/a11y/block.a11y.test.ts @@ -0,0 +1,65 @@ +import { expect, test } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; +import { openEditor, pressKey } from "@root/tests/a11y/helpers.js"; + +// Toggle headings represent interactive blocks in this accessibility vertical slice. +// Keyboard access to toggle controls from within an editable block is not implemented. +test.skip("editable toggle heading: toggle is keyboard accessible while the editor has focus", async () => { + // TODO: The keyboard interaction for controls within an editable editor is TBD. + // Reach the toggle while the editor has focus, expand/collapse its children, + // and return to editing without changing the document content. + // In editable mode, skip the toggle in page tab navigation outside the editor; + // in read-only mode it must remain a regular tab stop (tested below). + // Capture its focus indicator and announcements of its name, role and expanded state. + // A direct toggle shortcut alone would not require a full browser test; + // this placeholder covers the eventual keyboard focus/navigation behavior. +}); + +test("read-only toggle heading: Tab reaches the toggle and Enter expands/collapses it", async () => { + const id = "read-only-toggle-heading"; + // Reset only this fixture's persisted expansion state before each run. + localStorage.removeItem(`toggle-${id}`); + await openEditor( + [ + { + id, + type: "heading", + props: { level: 2, isToggleable: true }, + content: "Details", + children: [{ type: "paragraph", content: "Nested read-only content" }], + }, + ], + { editable: false }, + ); + const toggle = page.getByRole("button", { + name: "Expand or collapse", + exact: true, + }); + const content = page.getByText("Nested read-only content", { exact: true }); + await expect.element(content).not.toBeVisible(); + await expect.element(toggle).toHaveAttribute("aria-expanded", "false"); + + // Start at Before editor and reach the toggle through the page's tab order. + // Do not focus the button directly: that would hide a keyboard trap. + for ( + let step = 0; + step < 3 && document.activeElement !== toggle.element(); + step++ + ) { + await pressKey("Tab"); + } + await expect.element(toggle).toHaveFocus(); + await pressKey("Enter"); + await expect.element(content).toBeVisible(); + await expect.element(toggle).toHaveAttribute("aria-expanded", "true"); + await expect.element(toggle).toHaveFocus(); + await pressKey("Enter"); + await expect.element(content).not.toBeVisible(); + await expect.element(toggle).toHaveAttribute("aria-expanded", "false"); + await expect.element(toggle).toHaveFocus(); + await pressKey("Tab"); + await expect + .element(page.getByRole("button", { name: "After editor", exact: true })) + .toHaveFocus(); + // TODO: Capture its focus indicator and screen-reader name/expanded state. +}); diff --git a/packages/core/src/blocks/ToggleWrapper/createToggleWrapper.ts b/packages/core/src/blocks/ToggleWrapper/createToggleWrapper.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 9f067133c8..c7cb169d8a 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -22,5 +22,10 @@ "skipLibCheck": true, "noErrorTruncation": true }, - "include": ["src"] + "include": ["src"], + "exclude": [ + "src/**/*.a11y.test.ts", + "src/**/*.screen-reader.test.ts", + "src/**/a11y/**" + ] } diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts index f654e8e899..c1709752d7 100644 --- a/packages/core/vite.config.ts +++ b/packages/core/vite.config.ts @@ -27,7 +27,13 @@ export default defineConfig({ setupFiles: ["./vitestSetup.ts"], // `.browser.test` files need a real browser; the tests package's browser // suite runs them. - exclude: [...configDefaults.exclude, "**/*.browser.test.*"], + exclude: [ + ...configDefaults.exclude, + "**/*.browser.test.*", + "**/*.a11y.test.ts", + "**/*.axe.test.ts", + "**/*.screen-reader.test.ts", + ], }, plugins: [webpackStats()], build: { diff --git a/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts b/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts new file mode 100644 index 0000000000..fab3102c6f --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts @@ -0,0 +1,203 @@ +import { toolbarDocument, focusToolbarControl } from "./helpers.js"; +import { expect, test } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; +import { openEditor, pressKey } from "@root/tests/a11y/helpers.js"; + +async function setupToolbar(control: string) { + const result = await focusToolbarControl(control); + await expect + .element(result.toolbar) + .toMatchScreenshot( + `focus-${control.toLowerCase().replaceAll(" ", "-")}.png`, + {}, + ); + return result; +} + +test("formatting toolbar: Block type select", async () => { + await openEditor(toolbarDocument); + await setupToolbar("Paragraph"); + await pressKey("Enter"); + const menu = page.getByRole("menu"); + await expect.element(menu).toBeVisible(); + await pressKey("ArrowDown"); + await expect.element(menu.getByRole("menuitem").first()).toHaveFocus(); + await expect + .element(menu) + .toMatchScreenshot("focus-block-type-menu-item.png", {}); + await pressKey("ArrowDown"); + await expect + .element(menu.getByRole("menuitem", { name: "Heading 1", exact: true })) + .toHaveFocus(); + await pressKey("Enter"); + await expect + .element( + page.getByRole("heading", { + level: 1, + name: "Format this text", + exact: true, + }), + ) + .toHaveTextContent("Format this text"); + await expect.element(menu).not.toBeInTheDocument(); + await expect.element(page.getByRole("textbox")).toHaveFocus(); +}); + +test("formatting toolbar: Bold", async () => { + await openEditor(toolbarDocument); + const { button } = await setupToolbar("Bold"); + await pressKey("Enter"); + await expect.element(button).toHaveAttribute("aria-pressed", "true"); +}); + +test("formatting toolbar: Italic", async () => { + await openEditor(toolbarDocument); + const { button } = await setupToolbar("Italic"); + await pressKey("Enter"); + await expect.element(button).toHaveAttribute("aria-pressed", "true"); +}); + +test("formatting toolbar: Underline", async () => { + await openEditor(toolbarDocument); + const { button } = await setupToolbar("Underline"); + await pressKey("Enter"); + await expect.element(button).toHaveAttribute("aria-pressed", "true"); +}); + +test("formatting toolbar: Strike", async () => { + await openEditor(toolbarDocument); + const { button } = await setupToolbar("Strike"); + await pressKey("Enter"); + await expect.element(button).toHaveAttribute("aria-pressed", "true"); +}); + +test("formatting toolbar: Align text left", async () => { + await openEditor(toolbarDocument); + const { button } = await setupToolbar("Align text left"); + await pressKey("Enter"); + await expect.element(button).toHaveAttribute("aria-pressed", "true"); +}); + +test("formatting toolbar: Align text center", async () => { + await openEditor(toolbarDocument); + const { button } = await setupToolbar("Align text center"); + await pressKey("Enter"); + await expect.element(button).toHaveAttribute("aria-pressed", "true"); +}); + +test("formatting toolbar: Align text right", async () => { + await openEditor(toolbarDocument); + const { button } = await setupToolbar("Align text right"); + await pressKey("Enter"); + await expect.element(button).toHaveAttribute("aria-pressed", "true"); +}); + +// Choose gray, verify it is applied to the selected text, and check that +// focus returns to the editor. +test("formatting toolbar: Colors", async () => { + await openEditor(toolbarDocument); + await setupToolbar("Colors"); + await pressKey("Enter"); + await expect.element(page.getByRole("menu")).toBeVisible(); + await pressKey("ArrowDown"); + await pressKey("ArrowDown"); + await expect + .element( + document.querySelector('[data-test="text-color-gray"]'), + ) + .toHaveFocus(); + await pressKey("Enter"); + await expect + .element( + document.querySelector( + '[data-id="format"] [data-style-type="textColor"][data-value="gray"]', + ), + ) + .toHaveTextContent("Format this text"); + await expect.element(page.getByRole("menu")).not.toBeInTheDocument(); + await expect.element(page.getByRole("textbox")).toHaveFocus(); +}); + +test("formatting toolbar: Nest block", async () => { + await openEditor([ + { id: "preceding", type: "paragraph", content: "Preceding paragraph" }, + { id: "format", type: "paragraph", content: "Format this text" }, + ]); + await setupToolbar("Nest block"); + await pressKey("Enter"); + await expect + .element( + document.querySelector( + '[data-node-type="blockContainer"][data-id="preceding"] [data-node-type="blockContainer"][data-id="format"]', + ), + ) + .toBeInTheDocument(); + await expect + .element( + document.querySelector( + '[data-node-type="blockContainer"][data-id="format"]', + ), + ) + .toBeInTheDocument(); +}); + +test("formatting toolbar: Unnest block", async () => { + await openEditor([ + { + id: "preceding", + type: "paragraph", + content: "Preceding paragraph", + children: [ + { id: "format", type: "paragraph", content: "Format this text" }, + ], + }, + ]); + await setupToolbar("Unnest block"); + await pressKey("Enter"); + await expect + .element( + document.querySelector( + '[data-node-type="blockContainer"][data-id="preceding"] [data-node-type="blockContainer"][data-id="format"]', + ), + ) + .not.toBeInTheDocument(); + await expect + .element( + document.querySelector( + '[data-node-type="blockContainer"][data-id="format"]', + ), + ) + .toBeInTheDocument(); +}); + +// The focused URL input lacks a distinct focus outline; the visual accessibility requirement is not met. +test.skip("formatting toolbar: Create link", async () => { + await openEditor(toolbarDocument); + await setupToolbar("Create link"); + await pressKey("Enter"); + const popover = page.elementLocator( + document.querySelector(".bn-form-popover")!, + ); + await expect.element(popover.getByPlaceholder("Edit URL")).toHaveFocus(); + // TODO: The focused URL input currently has no distinct focus outline. + await expect + .element(popover) + .toMatchScreenshot("focus-create-link-url.png", {}); + await userEvent.keyboard("https://www.blocknotejs.org/"); + await pressKey("Enter"); + await expect + .element(document.querySelector('[data-id="format"] a')) + .toHaveAttribute("href", "https://www.blocknotejs.org/"); + await expect.element(page.getByRole("textbox")).toHaveFocus(); +}); + +// File/media blocks cannot be selected with Tab to expose their formatting toolbar actions. +test.skip("read-only formatting toolbar: Tab selects a file block and reaches its actions", async () => { + // TODO: Open an editor with editable: false and a populated image/file block. + // Select the block with Tab, verify its formatting toolbar opens, then Tab + // to the read-only action (e.g. Download file) and activate it with Enter. + // Verify the resulting file action, focus indicator, and continued Tab + // navigation to After editor. Editing actions must not be offered. + // This is blocked by file/media blocks not being selectable with Tab. + // Keep the setup keyboard-only rather than forcing selection via an API. +}); diff --git a/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-align-text-center-chromium-linux.png b/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-align-text-center-chromium-linux.png new file mode 100644 index 0000000000..250aa44310 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-align-text-center-chromium-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-align-text-left-chromium-linux.png b/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-align-text-left-chromium-linux.png new file mode 100644 index 0000000000..f5a88a7db2 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-align-text-left-chromium-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-align-text-right-chromium-linux.png b/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-align-text-right-chromium-linux.png new file mode 100644 index 0000000000..d053216b9e Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-align-text-right-chromium-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-block-type-menu-item-chromium-linux.png b/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-block-type-menu-item-chromium-linux.png new file mode 100644 index 0000000000..06c3019d26 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-block-type-menu-item-chromium-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-bold-chromium-linux.png b/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-bold-chromium-linux.png new file mode 100644 index 0000000000..3f70c9087e Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-bold-chromium-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-colors-chromium-linux.png b/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-colors-chromium-linux.png new file mode 100644 index 0000000000..e7a7a5ae91 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-colors-chromium-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-create-link-chromium-linux.png b/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-create-link-chromium-linux.png new file mode 100644 index 0000000000..38d06e4922 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-create-link-chromium-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-italic-chromium-linux.png b/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-italic-chromium-linux.png new file mode 100644 index 0000000000..06ff7e619c Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-italic-chromium-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-nest-block-chromium-linux.png b/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-nest-block-chromium-linux.png new file mode 100644 index 0000000000..20c9ee5498 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-nest-block-chromium-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-paragraph-chromium-linux.png b/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-paragraph-chromium-linux.png new file mode 100644 index 0000000000..91aa591396 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-paragraph-chromium-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-strike-chromium-linux.png b/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-strike-chromium-linux.png new file mode 100644 index 0000000000..72622fb019 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-strike-chromium-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-underline-chromium-linux.png b/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-underline-chromium-linux.png new file mode 100644 index 0000000000..55bcfee364 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-underline-chromium-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-unnest-block-chromium-linux.png b/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-unnest-block-chromium-linux.png new file mode 100644 index 0000000000..c439998864 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.a11y.test.ts-snapshots/visual/focus-unnest-block-chromium-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.axe.test.ts b/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.axe.test.ts new file mode 100644 index 0000000000..2b85bb6b41 --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.axe.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; +import { toolbarDocument, focusToolbarControl } from "./helpers.js"; +import { openEditor, checkAxe, pressKey } from "@root/tests/a11y/helpers.js"; + +test("formatting toolbar: axe", async ({ annotate }) => { + await openEditor(toolbarDocument); + await focusToolbarControl("Paragraph"); + await checkAxe(annotate, '[role="toolbar"]'); +}); + +// Mantine inserts a focusable div inside the menu, triggering axe aria-required-children violations. +test.skip("formatting toolbar: block type menu axe", async ({ annotate }) => { + await openEditor(toolbarDocument); + await focusToolbarControl("Paragraph"); + await pressKey("Enter"); + await expect.element(page.getByRole("menu")).toBeVisible(); + await checkAxe(annotate, '[role="menu"]'); +}); + +// Mantine inserts a focusable div inside the menu, triggering axe aria-required-children violations. +test.skip("formatting toolbar: colors menu axe", async ({ annotate }) => { + await openEditor(toolbarDocument); + await focusToolbarControl("Colors"); + await pressKey("Enter"); + await expect.element(page.getByRole("menu")).toBeVisible(); + await checkAxe(annotate, '[role="menu"]'); +}); + +test("formatting toolbar: link popover axe", async ({ annotate }) => { + await openEditor(toolbarDocument); + await focusToolbarControl("Create link"); + await pressKey("Enter"); + await expect + .element(document.querySelector(".bn-form-popover")) + .toBeVisible(); + await checkAxe(annotate, ".bn-form-popover"); +}); diff --git a/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.screen-reader.test.ts b/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.screen-reader.test.ts new file mode 100644 index 0000000000..402eb3826d --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/a11y/FormattingToolbar.screen-reader.test.ts @@ -0,0 +1,26 @@ +import { afterEach, expect, test } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; +import { + openEditor, + pressKey, + saveTranscript, +} from "@root/tests/a11y/helpers.js"; +import { toolbarDocument, focusToolbarControl } from "./helpers.js"; + +afterEach(saveTranscript); + +// Record selection → toolbar → block-type menu → Escape → editor. +// Individual control actions and focus rings are covered by the browser tests. +// Screen reader announcements do not clearly communicate entering the toolbar. +test.skip("formatting toolbar: Paragraph", async () => { + await openEditor(toolbarDocument); + const { toolbar } = await focusToolbarControl("Paragraph"); + await pressKey("Enter"); + const menu = page.getByRole("menu"); + await expect.element(menu).toBeVisible(); + await pressKey("ArrowDown"); + await expect.element(menu.getByRole("menuitem").first()).toHaveFocus(); + await pressKey("Escape"); + await expect.element(toolbar).not.toBeInTheDocument(); + await expect.element(page.getByRole("textbox")).toHaveFocus(); +}); diff --git a/packages/react/src/components/FormattingToolbar/a11y/helpers.ts b/packages/react/src/components/FormattingToolbar/a11y/helpers.ts new file mode 100644 index 0000000000..52ca58d3d8 --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/a11y/helpers.ts @@ -0,0 +1,44 @@ +import type { PartialBlock } from "@blocknote/core"; +import { expect } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; +import { + pressKey, + readSelection, + selectToLineEndKey, +} from "@root/tests/a11y/helpers.js"; + +// The middle paragraph is selected; its neighbours provide navigation context. +export const toolbarDocument: PartialBlock[] = [ + { id: "preceding", type: "paragraph", content: "Preceding paragraph" }, + { id: "format", type: "paragraph", content: "Format this text" }, + { id: "following", type: "paragraph", content: "Following paragraph" }, +]; + +// Enter the editor from "Before editor", select its second line, then Tab to +// the requested control. Each test supplies its own document before calling this. +// Shared by browser assertions and the native screen reader journey. +export async function focusToolbarControl(control: string) { + await pressKey("Tab"); + await expect.element(page.getByRole("textbox")).toHaveFocus(); + // Move from the first line to the target and select to its end (OS-specific key). + await pressKey("ArrowDown"); + await pressKey(selectToLineEndKey); + await expect.poll(async () => readSelection().text).toBe("Format this text"); + const toolbar = page.getByRole("toolbar"); + await expect.element(toolbar).toBeVisible(); + await expect.poll(() => toolbar.getByRole("button").all().length).toBe(12); + + const button = toolbar.getByRole("button", { name: control, exact: true }); + await expect.element(button).toBeEnabled(); + // Bound traversal to report unreachable controls without looping forever. + for (let step = 0; step < 15; step++) { + if (button.element() === document.activeElement) { + break; + } + await pressKey("Tab"); + } + await expect + .element(button, { message: `${control} is reachable with Tab` }) + .toHaveFocus(); + return { toolbar, button }; +} diff --git a/packages/react/src/editor/a11y/BlockNoteView.a11y.test.ts b/packages/react/src/editor/a11y/BlockNoteView.a11y.test.ts new file mode 100644 index 0000000000..a73f67b38b --- /dev/null +++ b/packages/react/src/editor/a11y/BlockNoteView.a11y.test.ts @@ -0,0 +1,135 @@ +import { + applyStyles, + navigateDefaultSchemaDocument, + resizeText, +} from "./helpers.js"; +import { defaultSchemaDocument } from "@root/tests/a11y/documents.js"; +import { expect, test } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; +import { screenshotFull } from "@root/tests/src/utils/screenshotFull.js"; +import { openEditor, pressKey } from "@root/tests/a11y/helpers.js"; + +test("default schema: arrow-key document navigation", async () => { + await openEditor(defaultSchemaDocument); + await navigateDefaultSchemaDocument(); +}); + +// The editor has no separate focused state without an editing selection; Escape blurs to the body. +test.skip("editor can receive focus without selecting content", async () => { + // TODO: Focus the editor as a page element without an editing selection. + // Tab/arrows should navigate the page; Enter should enter editing. + // Escape currently blurs to body, which does not preserve this entry point. +}); + +// Whole-block selection requires Cmd+Click; no keyboard interaction is implemented. +test.skip("keyboard selection extends to the entire block", async () => { + // TODO: Extend a text selection to its whole block, leaving neighbours out. + // Whole-block selection currently requires Cmd+Click; keyboard UX is TBD. +}); + +// The keyboard whole-block selection needed to capture its visual indicator is not implemented. +test.skip("whole-block selection is visible", async () => { + // TODO: Once keyboard block selection exists, snapshot its visual indicator. + // TODO: Text selection, whole-document selection, and non-inline blocks. +}); + +test("Escape allows Tab to leave the editor", async () => { + await openEditor([{ type: "paragraph", content: "Editable text" }]); + await pressKey("Tab"); + await expect.element(page.getByRole("textbox")).toHaveFocus(); + await pressKey("Escape"); + await expect.element(document.body).toHaveFocus(); + await pressKey("Tab"); + await expect + .element(page.getByRole("button", { name: "After editor", exact: true })) + .toHaveFocus(); + // Revisit when the editor can retain focus without an editing selection. +}); + +async function waitForMedia() { + // Wait for current-frame media data before the tests take screenshots. + for (const media of document.querySelectorAll("audio, video")) { + if (media instanceof HTMLMediaElement) { + media.preload = "auto"; + media.load(); + await expect.poll(() => media.readyState).toBeGreaterThanOrEqual(2); + } + } +} + +test.describe("text spacing and resizing", () => { + test("default schema remains readable with text spacing overrides", async () => { + await openEditor(defaultSchemaDocument); + await waitForMedia(); + // WCAG 1.4.12: override spacing without changing any other style property. + // https://www.w3.org/WAI/WCAG22/Understanding/text-spacing.html + applyStyles(` + .bn-editor, .bn-editor * { + line-height: 1.5 !important; + letter-spacing: 0.12em !important; + word-spacing: 0.16em !important; + } + .bn-editor p { margin-block-end: 2em !important; } + `); + await screenshotFull(document.body, "default-schema-text-spacing.png", { + comparatorOptions: { allowedMismatchedPixels: 10 }, + }); + }); + + test("default schema remains readable at 200% text size", async () => { + await openEditor(defaultSchemaDocument); + await waitForMedia(); + resizeText(2); + await screenshotFull( + document.body, + "default-schema-text-size-200-percent.png", + { + comparatorOptions: { allowedMismatchedPixels: 10 }, + }, + ); + }); +}); + +test("read-only editor: Tab reaches inline links and leaves the editor", async () => { + await openEditor( + [ + { + type: "paragraph", + content: [ + { + type: "link", + href: "https://www.blocknotejs.org/", + content: "Documentation", + }, + ], + }, + ], + { editable: false }, + ); + const link = page.getByRole("link", { name: "Documentation", exact: true }); + // The editor itself may be a tab stop; it must not trap focus before the link. + for ( + let step = 0; + step < 3 && document.activeElement !== link.element(); + step++ + ) { + await pressKey("Tab"); + } + await expect.element(link).toHaveFocus(); + await pressKey("Tab"); + await expect + .element(page.getByRole("button", { name: "After editor", exact: true })) + .toHaveFocus(); + await pressKey("Shift+Tab"); + await expect.element(link).toHaveFocus(); +}); + +// Read-only file/media blocks cannot be selected through the page tab order. +test.skip("read-only editor: Tab selects blocks that expose a formatting toolbar", async () => { + // TODO: Open a read-only document containing an image/file block between + // ordinary paragraphs. Tab from Before editor to select that block, assert + // the block selection and visible indicator, and continue to After editor. + // Repeat with Shift+Tab. Static paragraphs should not introduce tab stops. + // Blocks currently cannot be selected through the page's tab order; do not + // work around this by clicking or setting the selection through editor APIs. +}); diff --git a/packages/react/src/editor/a11y/BlockNoteView.a11y.test.ts-snapshots/visual/default-schema-text-size-200-percent-chromium-linux.png b/packages/react/src/editor/a11y/BlockNoteView.a11y.test.ts-snapshots/visual/default-schema-text-size-200-percent-chromium-linux.png new file mode 100644 index 0000000000..502575500f Binary files /dev/null and b/packages/react/src/editor/a11y/BlockNoteView.a11y.test.ts-snapshots/visual/default-schema-text-size-200-percent-chromium-linux.png differ diff --git a/packages/react/src/editor/a11y/BlockNoteView.a11y.test.ts-snapshots/visual/default-schema-text-spacing-chromium-linux.png b/packages/react/src/editor/a11y/BlockNoteView.a11y.test.ts-snapshots/visual/default-schema-text-spacing-chromium-linux.png new file mode 100644 index 0000000000..c15e55638f Binary files /dev/null and b/packages/react/src/editor/a11y/BlockNoteView.a11y.test.ts-snapshots/visual/default-schema-text-spacing-chromium-linux.png differ diff --git a/packages/react/src/editor/a11y/BlockNoteView.axe.test.ts b/packages/react/src/editor/a11y/BlockNoteView.axe.test.ts new file mode 100644 index 0000000000..80845e2bfd --- /dev/null +++ b/packages/react/src/editor/a11y/BlockNoteView.axe.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; +import { + openEditor, + checkAxe, + pressKey, + readSelection, +} from "@root/tests/a11y/helpers.js"; +import { + blocksDocument, + inlineContentDocument, + tablesDocument, +} from "./axe-documents.js"; + +for (const theme of ["light", "dark"] as const) { + // Axe reports an unnamed editor textbox, unlabeled checklist checkboxes, and insufficient color contrast. + test.skip(`default schema blocks: axe (${theme})`, async ({ annotate }) => { + for (const block of blocksDocument) { + if ( + block.id?.startsWith("axe-heading-") || + block.id?.startsWith("axe-toggleListItem-") + ) { + localStorage.setItem( + `toggle-${block.id}`, + String(block.id.includes("-true-")), + ); + } + } + await openEditor(blocksDocument, { theme }); + await pressKey("Tab"); + await expect.poll(() => readSelection().blockId).toBe("axe-placeholder"); + await expect.element(page.getByRole("textbox")).toHaveFocus(); + await checkAxe(annotate); + }); + + // Axe reports an unnamed editor textbox and insufficient color contrast. + test.skip(`default inline content and colors: axe (${theme})`, async ({ + annotate, + }) => { + await openEditor(inlineContentDocument, { theme }); + await checkAxe(annotate); + }); + + // Axe reports an unnamed editor textbox, insufficient contrast, and an undersized "After editor" fixture button. + test.skip(`tables: axe (${theme})`, async ({ annotate }) => { + await openEditor(tablesDocument, { theme }); + const header = page + .getByText("Quarter totals", { exact: true }) + .element() + .closest("th"); + expect(header?.getAttribute("colspan")).toBe("2"); + const mergedCell = page + .getByText("Combined total", { exact: true }) + .element() + .closest("td"); + expect(mergedCell?.getAttribute("colspan")).toBe("2"); + expect(mergedCell?.getAttribute("rowspan")).toBe("2"); + await checkAxe(annotate); + }); +} diff --git a/packages/react/src/editor/a11y/BlockNoteView.screen-reader.test.ts b/packages/react/src/editor/a11y/BlockNoteView.screen-reader.test.ts new file mode 100644 index 0000000000..1d3fa59518 --- /dev/null +++ b/packages/react/src/editor/a11y/BlockNoteView.screen-reader.test.ts @@ -0,0 +1,19 @@ +import { afterEach, test } from "vite-plus/test"; +import { defaultSchemaDocument } from "@root/tests/a11y/documents.js"; +import { openEditor, saveTranscript } from "@root/tests/a11y/helpers.js"; +import { navigateDefaultSchemaDocument } from "./helpers.js"; + +afterEach(saveTranscript); + +// Navigation announcements omit block types, document structure, and a lot of other markup. +test.skip("default schema: arrow-key document navigation", async () => { + await openEditor(defaultSchemaDocument); + await navigateDefaultSchemaDocument(); +}); + +// Keyboard whole-block selection is not implemented, so its announcements cannot be tested yet. +test.skip("whole-block selection is announced", async () => { + // TODO: Once keyboard block selection exists, verify its type and scope are + // communicated, and review the transcript before accepting a baseline. + // TODO: Text selection, whole-document selection, and non-inline blocks. +}); diff --git a/packages/react/src/editor/a11y/axe-documents.ts b/packages/react/src/editor/a11y/axe-documents.ts new file mode 100644 index 0000000000..e03b0755aa --- /dev/null +++ b/packages/react/src/editor/a11y/axe-documents.ts @@ -0,0 +1,239 @@ +import { + COLORS_DEFAULT, + defaultBlockSpecs, + type PartialBlock, + type PartialTableCell, + type DefaultInlineContentSchema, + type DefaultStyleSchema, + type PropSchema, +} from "@blocknote/core"; +import { blockExamples } from "@root/tests/a11y/documents.js"; + +const colors = ["default", ...Object.keys(COLORS_DEFAULT)]; + +// Cover each finite prop value, rather than the Cartesian product of all block +// props. Open-ended strings and numbers use representative values. +function propValues( + name: string, + spec: PropSchema[string], + baseValue: string | number | boolean | undefined, +) { + if (spec.values) { + return spec.values; + } + if (typeof spec.default === "boolean") { + return [false, true]; + } + switch (name) { + case "textColor": + case "backgroundColor": + return colors; + case "name": + case "caption": + return [ + "", + typeof baseValue === "string" && baseValue !== "" + ? baseValue + : "Example text", + ]; + case "url": + return [""]; // The populated URL is supplied by blockExamples. + case "previewWidth": + return [undefined, 100, 300]; + case "start": + return [undefined, 1, 3]; + case "language": + return ["text", "javascript"]; + default: + throw new Error(`Add axe fixture values for block prop ${name}`); + } +} + +// The first block exposing a prop covers its variants for all block types. +const coveredBlockProps = new Set(); + +export const blocksDocument: PartialBlock[] = [ + { id: "axe-placeholder", type: "paragraph", content: "" }, + ...Object.values(blockExamples).flatMap((example): PartialBlock[] => { + const block: PartialBlock = example; + const props: Record = + block.props ?? {}; + return [ + { ...block, id: `axe-${example.type}-default` }, + ...Object.entries( + defaultBlockSpecs[example.type].config.propSchema, + ).flatMap(([name, spec]) => { + if (coveredBlockProps.has(name)) { + return []; + } + coveredBlockProps.add(name); + const baseValue = props[name] ?? spec.default; + return propValues(name, spec, baseValue).flatMap((value, index) => { + if ((value ?? spec.default) === baseValue) { + return []; + } + return [ + { + ...block, + id: `axe-${example.type}-${name}-${index}`, + props: { ...block.props, [name]: value }, + }, + ]; + }); + }), + ]; + }), + { + id: "axe-nested", + type: "paragraph", + content: "Parent paragraph", + children: [ + { + id: "axe-nested-bullet", + type: "bulletListItem", + content: "Nested bullet", + children: [ + { + id: "axe-nested-numbered", + type: "numberedListItem", + content: "Nested numbered item", + children: [ + { + id: "axe-nested-task", + type: "checkListItem", + content: "Nested task", + }, + ], + }, + ], + }, + ], + }, + ...(["heading", "toggleListItem"] as const).flatMap((type): PartialBlock[] => + [false, true].flatMap((expanded) => + [false, true].map((hasChildren): PartialBlock => ({ + id: `axe-${type}-${expanded}-${hasChildren}`, + ...(type === "heading" + ? { type, props: { isToggleable: true } } + : { type }), + content: `${type} ${expanded ? "expanded" : "collapsed"} ${hasChildren ? "with children" : "empty"}`, + children: hasChildren + ? [ + { + id: `axe-${type}-${expanded}-${hasChildren}-child`, + type: "paragraph", + content: "Toggle child", + }, + ] + : [], + })), + ), + ), +]; + +export const inlineContentDocument: PartialBlock[] = colors.flatMap( + (textColor) => + colors.map((backgroundColor) => ({ + id: `axe-inline-${textColor}-${backgroundColor}`, + type: "paragraph", + content: [ + { + type: "text", + text: `${textColor} on ${backgroundColor}. `, + styles: { textColor, backgroundColor }, + }, + { + type: "link", + href: "https://www.blocknotejs.org/", + content: [ + { + type: "text", + text: "Linked text", + styles: { textColor, backgroundColor }, + }, + ], + }, + ], + })), +); + +type Cell = PartialTableCell; +function cell(content: string, props: Cell["props"] = {}): Cell { + return { type: "tableCell", content, props }; +} + +export const tablesDocument: PartialBlock[] = [ + ...( + [ + { headerRows: 1 }, + { headerCols: 1 }, + { headerRows: 1, headerCols: 1 }, + ] as const + ).map((headers, index): PartialBlock => ({ + id: `axe-table-headers-${index}`, + type: "table", + content: { + type: "tableContent", + ...headers, + rows: [ + { cells: ["Region", "Sales", "Costs"] }, + { cells: ["North", "100", "50"] }, + { cells: ["South", "200", "80"] }, + ], + }, + })), + { + id: "axe-table-merged", + type: "table", + content: { + type: "tableContent", + headerRows: 1, + headerCols: 1, + rows: [ + { + cells: [ + cell("Region"), + cell("Quarter totals", { colspan: 2 }), + cell("Notes"), + ], + }, + { + cells: [ + cell("North", { rowspan: 2 }), + cell("Combined total", { colspan: 2, rowspan: 2 }), + cell("First period"), + ], + }, + { cells: [cell("Second period")] }, + { + cells: [ + cell("South"), + cell("100"), + cell("200"), + cell("Final period"), + ], + }, + ], + }, + }, + // Cell props use a different rendering path from inline marks. Exercise that + // path without repeating the full inline foreground/background matrix. + { + id: "axe-table-colors", + type: "table", + content: { + type: "tableContent", + headerRows: 1, + rows: [ + { cells: ["Color", "Cell text", "Cell background"] }, + ...colors.map((color) => ({ + cells: [ + cell(color), + cell("Colored text", { textColor: color }), + cell("Highlighted cell", { backgroundColor: color }), + ], + })), + ], + }, + }, +]; diff --git a/packages/react/src/editor/a11y/helpers.ts b/packages/react/src/editor/a11y/helpers.ts new file mode 100644 index 0000000000..8920857e8e --- /dev/null +++ b/packages/react/src/editor/a11y/helpers.ts @@ -0,0 +1,71 @@ +import { expect } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; +import { pressKey, readSelection } from "@root/tests/a11y/helpers.js"; + +// Shared by browser keyboard checks and the native transcript test. +export async function navigateDefaultSchemaDocument() { + await pressKey("Tab"); + await expect.element(page.getByRole("textbox")).toHaveFocus(); + + // Bound the traversal so a keyboard trap produces a useful failure instead + // of an infinite loop. Selection endpoints prove the arrows actually moved. + const visitedTableCells = new Set(); + for (let step = 0; step < 120 && readSelection().blockId !== "end"; step++) { + const { tableCell } = readSelection(); + if (tableCell) { + visitedTableCells.add(`${tableCell.row},${tableCell.column}`); + } + // ArrowDown alone skips columns. Move through cell text and across each + // row with ArrowRight, then resume ArrowDown from the final cell. + await pressKey( + tableCell && visitedTableCells.size < 4 ? "ArrowRight" : "ArrowDown", + ); + } + expect([...visitedTableCells].sort(), "Every table cell is visited").toEqual([ + "0,0", + "0,1", + "1,0", + "1,1", + ]); + expect(readSelection().blockId, "Arrow keys reach the final block").toBe( + "end", + ); + + for ( + let step = 0; + step < 120 && readSelection().blockId !== "start"; + step++ + ) { + await pressKey("ArrowUp"); + } + expect(readSelection().blockId, "ArrowUp returns to the first block").toBe( + "start", + ); +} + +export function applyStyles(css: string) { + const style = document.createElement("style"); + style.textContent = css; + page.getByRole("main").element().append(style); +} + +// Scaling text takes some logic as the scaling has to be applied to each font +// size, not just the base editor font size. +export function resizeText(scale: number) { + function rulesFor(element: Element, selector: string): string { + const rule = + element instanceof HTMLElement + ? `${selector} { font-size: ${Number.parseFloat(getComputedStyle(element).fontSize) * scale}px !important; }` + : ""; + return [ + rule, + ...Array.from(element.children, (child, index) => + rulesFor(child, `${selector} > :nth-child(${index + 1})`), + ), + ].join("\n"); + } + + // Read all sizes before applying overrides, so inherited sizes scale once. + // A stylesheet avoids ProseMirror reconciling mutations to editable DOM. + applyStyles(rulesFor(page.getByRole("textbox").element(), ".bn-editor")); +} diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json index 34ea71f87f..02c741dcb1 100644 --- a/packages/react/tsconfig.json +++ b/packages/react/tsconfig.json @@ -22,6 +22,11 @@ "emitDeclarationOnly": true }, "include": ["src"], + "exclude": [ + "src/**/*.a11y.test.ts", + "src/**/*.screen-reader.test.ts", + "src/**/a11y/**" + ], "references": [ { "path": "../core" diff --git a/packages/react/vite.config.ts b/packages/react/vite.config.ts index 7f9b24019c..f84c98629b 100644 --- a/packages/react/vite.config.ts +++ b/packages/react/vite.config.ts @@ -30,7 +30,13 @@ export default defineConfig( setupFiles: ["./vitestSetup.ts"], // `.browser.test` files need a real browser; the tests package's // browser suite runs them. - exclude: [...configDefaults.exclude, "**/*.browser.test.*"], + exclude: [ + ...configDefaults.exclude, + "**/*.browser.test.*", + "**/*.a11y.test.ts", + "**/*.axe.test.ts", + "**/*.screen-reader.test.ts", + ], }, plugins: [react(), webpackStats()], // used so that vitest resolves the core package from the sources instead of the built version diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c6ffd4454..8900ba6725 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6753,6 +6753,9 @@ importers: '@blocknote/xl-typst-exporter': specifier: workspace:^ version: link:../packages/xl-typst-exporter + '@guidepup/guidepup': + specifier: ^0.35.0 + version: 0.35.0 '@playwright/test': specifier: 1.60.0 version: 1.60.0 @@ -6795,6 +6798,9 @@ importers: '@y/y': specifier: 14.0.0-rc.26 version: 14.0.0-rc.26 + axe-core: + specifier: 4.13.0 + version: 4.13.0 htmlfy: specifier: ^0.6.7 version: 0.6.7 @@ -7838,6 +7844,9 @@ packages: tailwindcss: optional: true + '@guidepup/guidepup@0.35.0': + resolution: {integrity: sha512-US64a++RucCtLfoq/DqnjzaHYpKzERVYmhtsWViz38MyklICICDpb50AKLn9sbxUwTceK2rS0fjDy7fYpWK/QA==} + '@handlewithcare/prosemirror-inputrules@0.1.4': resolution: {integrity: sha512-GMqlBeG2MKM+tXEFd2N+wIv5z4VvJTg8JtfJUrdjvFq2W6v+AW8oTgiWyFw8L3iEQwvtQcVJxU873iB0LXUNNw==} peerDependencies: @@ -12234,6 +12243,10 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + axe-core@4.13.0: + resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} + engines: {node: '>=4'} + axios@1.15.0: resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==} @@ -15291,6 +15304,10 @@ packages: engines: {node: '>=18'} hasBin: true + plist@4.0.0: + resolution: {integrity: sha512-4dOqNo0Y2NpfSf9q4+zr4bh7pzNWeckIam34Z0KYJhg8qtNNfh59VbD+Yna5SjwcxawVvLKx5w5FtuCijpEF4Q==} + engines: {node: '>=18'} + png-js@2.0.0: resolution: {integrity: sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==} @@ -16910,6 +16927,10 @@ packages: xml@1.0.1: resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==} + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} @@ -18281,6 +18302,13 @@ snapshots: next: 16.3.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(@types/node@25.9.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) tailwindcss: 4.2.2 + '@guidepup/guidepup@0.35.0': + dependencies: + debug: 4.4.3 + plist: 4.0.0 + transitivePeerDependencies: + - supports-color + '@handlewithcare/prosemirror-inputrules@0.1.4(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)': dependencies: prosemirror-history: 1.5.0 @@ -22706,6 +22734,8 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + axe-core@4.13.0: {} + axios@1.15.0: dependencies: follow-redirects: 1.16.0 @@ -26133,6 +26163,11 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + plist@4.0.0: + dependencies: + '@xmldom/xmldom': 0.9.10 + xmlbuilder: 15.1.1 + png-js@2.0.0: dependencies: fflate: 0.8.3 @@ -28133,6 +28168,8 @@ snapshots: xml@1.0.1: {} + xmlbuilder@15.1.1: {} + xmlchars@2.2.0: {} xtend@4.0.2: {} diff --git a/tests/a11y/commands.ts b/tests/a11y/commands.ts new file mode 100644 index 0000000000..f18d57ef10 --- /dev/null +++ b/tests/a11y/commands.ts @@ -0,0 +1,51 @@ +import type { BrowserCommand } from "vite-plus/test/node"; +import type { Page, Frame } from "@playwright/test"; + +declare module "vite-plus/test/node" { + interface BrowserCommandContext { + page: Page; + frame(): Promise; + } +} + +// Import only on native runs: GuidePup cannot initialize on Linux. +async function reader() { + return (await import("@guidepup/guidepup")).screenReader; +} + +export const startReader: BrowserCommand<[]> = async (context) => { + const guidepup = await import("@guidepup/guidepup"); + await guidepup.screenReader.start(); + if (process.platform === "darwin") { + await guidepup.macOSActivate("Playwright"); + } else { + await guidepup.windowsActivate("chrome.exe", "Google Chrome for Testing"); + } + await context.page.bringToFront(); + const frame = await context.frame(); + await frame + .getByRole("button", { name: "Before editor", exact: true }) + .focus(); + if (process.platform === "darwin") { + await guidepup.voiceOver.perform( + guidepup.voiceOver.keyboardCommands.moveCursorToKeyboardFocus, + ); + } + await guidepup.screenReader.clearSpokenPhraseLog(); +}; + +export const pressKey: BrowserCommand<[key: string]> = async ( + { page }, + key, +) => { + if (process.env.BLOCKNOTE_SCREEN_READER) { + return (await (await reader()).capture(() => page.keyboard.press(key))) + .spokenPhrase; + } + await page.keyboard.press(key); +}; + +export const readTranscript: BrowserCommand<[]> = async () => + (await reader()).spokenPhraseLog(); +export const stopReader: BrowserCommand<[]> = async () => + (await reader()).stop(); diff --git a/tests/a11y/documents.ts b/tests/a11y/documents.ts new file mode 100644 index 0000000000..186e5b0284 --- /dev/null +++ b/tests/a11y/documents.ts @@ -0,0 +1,124 @@ +import type { + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, + PartialBlock, + PartialInlineContentElement, + Styles, +} from "@blocknote/core"; + +const styleExamples = { + bold: { bold: true }, + italic: { italic: true }, + underline: { underline: true }, + strike: { strike: true }, + code: { code: true }, + textColor: { textColor: "blue" }, + backgroundColor: { backgroundColor: "yellow" }, +} satisfies { + [K in keyof DefaultStyleSchema]: Required< + Pick, K> + >; +}; + +const inlineExamples = { + text: { type: "text", text: "Plain text. ", styles: {} }, + link: { + type: "link", + href: "https://www.blocknotejs.org/", + content: "BlockNote documentation", + }, +} satisfies Record< + keyof DefaultInlineContentSchema, + PartialInlineContentElement +>; + +export const blockExamples = { + paragraph: { + type: "paragraph", + content: [ + ...Object.values(inlineExamples), + // Use separate text spans because code excludes other formatting marks. + ...Object.entries(styleExamples).map(([name, styles]) => ({ + type: "text" as const, + text: ` ${name}.`, + styles, + })), + ], + }, + heading: { + type: "heading", + props: { level: 1 }, + content: "Heading level one", + }, + bulletListItem: { type: "bulletListItem", content: "Bullet list item" }, + numberedListItem: { type: "numberedListItem", content: "Numbered list item" }, + checkListItem: { type: "checkListItem", content: "Unchecked task" }, + toggleListItem: { + type: "toggleListItem", + content: "Toggle list item", + children: [{ type: "paragraph", content: "Toggle child" }], + }, + quote: { type: "quote", content: "Quoted text" }, + codeBlock: { type: "codeBlock", content: "const answer = 42;" }, + divider: { type: "divider" }, + table: { + type: "table", + content: { + type: "tableContent", + headerRows: 1, + rows: [ + { cells: ["Name", "Description"] }, + { cells: ["Example", "Table cell"] }, + ], + }, + }, + image: { + type: "image", + props: { + url: "/image.svg", + name: "Example image", + caption: "Blue square", + previewWidth: 100, + }, + }, + audio: { + type: "audio", + props: { url: "/tone.wav", name: "tone.wav", caption: "A short test tone" }, + }, + video: { + type: "video", + props: { + url: "/video.webm", + name: "video.webm", + caption: "A flower moving in the breeze", + previewWidth: 200, + }, + }, + file: { type: "file", props: { url: "/example.txt", name: "example.txt" } }, +} satisfies { [K in keyof DefaultBlockSchema]: PartialBlock & { type: K } }; + +export const defaultSchemaDocument: PartialBlock[] = [ + { id: "start", type: "paragraph", content: "Start of document" }, + ...Object.entries(blockExamples).map(([id, block]) => ({ ...block, id })), + ...[2, 3, 4, 5, 6].map((level) => ({ + id: `heading-${level}`, + type: "heading" as const, + props: { level }, + content: `Heading level ${level}`, + })), + { + id: "toggle-heading", + type: "heading", + props: { level: 2, isToggleable: true }, + content: "Toggle heading", + children: [{ type: "paragraph", content: "Heading child" }], + }, + { + id: "checked", + type: "checkListItem", + props: { checked: true }, + content: "Completed task", + }, + { id: "end", type: "paragraph", content: "End of document" }, +]; diff --git a/tests/a11y/helpers.tsx b/tests/a11y/helpers.tsx new file mode 100644 index 0000000000..18ec817990 --- /dev/null +++ b/tests/a11y/helpers.tsx @@ -0,0 +1,128 @@ +import type { PartialBlock } from "@blocknote/core"; +import "@blocknote/core/fonts/inter.css"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { useCreateBlockNote } from "@blocknote/react"; +import { render } from "vitest-browser-react"; +import axe from "axe-core"; +import { expect, type TestContext } from "vite-plus/test"; +import { commands, page, server } from "vite-plus/test/browser"; +import "./style.css"; + +export const screenReader = + server.config.name === "voiceover" || server.config.name === "nvda"; +export const selectToLineEndKey = + server.platform === "darwin" ? "Meta+Shift+ArrowRight" : "Shift+End"; + +// Vite+ does not re-export the augmentable BrowserCommands interface. Keep the +// typed RPC boundary here, as the existing browser test utilities do. +const browserCommands = commands as typeof commands & { + startReader(): Promise; + pressKey(key: string): Promise; + readTranscript(): Promise; + stopReader(): Promise; +}; + +function EditorFixture({ + initialContent, + editable, + theme, +}: { + initialContent: PartialBlock[]; + editable: boolean; + theme: "light" | "dark"; +}) { + const editor = useCreateBlockNote({ initialContent }); + return ( +
+

BlockNote accessibility fixture

+ + + +
+ ); +} + +export async function openEditor( + initialContent: PartialBlock[], + { + editable = true, + theme = "light", + }: { editable?: boolean; theme?: "light" | "dark" } = {}, +) { + await page.viewport(1280, 900); + await render( + , + ); + await expect.element(page.getByRole("textbox")).toBeVisible(); + await expect + .element(page.getByRole("textbox")) + .toHaveAttribute("contenteditable", String(editable)); + await document.fonts.ready; + if (screenReader) { + await browserCommands.startReader(); + } else { + page + .getByRole("button", { name: "Before editor", exact: true }) + .element() + .focus(); + } +} + +export async function checkAxe( + annotate: TestContext["annotate"], + scope?: string, +) { + const results = await axe.run(scope ? { include: [scope] } : document.body, { + rules: { "target-size": { enabled: true } }, + }); + for (const result of results.incomplete) { + for (const node of result.nodes) { + await annotate( + `axe manual review: ${result.id} — ${result.help}\nTarget: ${JSON.stringify(node.target)}\n${node.failureSummary ?? ""}\n${result.helpUrl}`, + "warning", + ); + } + } + expect(results.violations, "axe violations").toEqual([]); +} + +export function readSelection() { + const selection = document.getSelection(); + const node = selection?.focusNode; + const element = node instanceof Element ? node : node?.parentElement; + const cell = element?.closest("td, th"); + const row = cell?.parentElement; + return { + tableCell: + cell instanceof HTMLTableCellElement && row instanceof HTMLTableRowElement + ? { row: row.rowIndex, column: cell.cellIndex } + : null, + blockId: element?.closest("[data-id]")?.getAttribute("data-id") ?? null, + offset: selection?.focusOffset ?? 0, + text: selection?.toString() ?? "", + }; +} + +export async function pressKey(key: string) { + return browserCommands.pressKey(key); +} + +// Register with afterEach in screen-reader tests. Stop the reader even when +// reading or comparing the transcript fails. +export async function saveTranscript() { + if (!screenReader) { + throw new Error("saveTranscript requires a screen-reader test run."); + } + try { + const transcript = await browserCommands.readTranscript(); + expect(transcript.length).toBeGreaterThan(0); + expect(transcript).toMatchSnapshot(); + } finally { + await browserCommands.stopReader(); + } +} diff --git a/tests/a11y/package.json b/tests/a11y/package.json new file mode 100644 index 0000000000..e986b24bba --- /dev/null +++ b/tests/a11y/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/tests/a11y/public/example.txt b/tests/a11y/public/example.txt new file mode 100644 index 0000000000..72d985baa0 --- /dev/null +++ b/tests/a11y/public/example.txt @@ -0,0 +1 @@ +BlockNote accessibility test file. diff --git a/tests/a11y/public/image.svg b/tests/a11y/public/image.svg new file mode 100644 index 0000000000..a4e84046ae --- /dev/null +++ b/tests/a11y/public/image.svg @@ -0,0 +1 @@ + diff --git a/tests/a11y/public/tone.wav b/tests/a11y/public/tone.wav new file mode 100644 index 0000000000..77c826aa53 Binary files /dev/null and b/tests/a11y/public/tone.wav differ diff --git a/tests/a11y/public/video.webm b/tests/a11y/public/video.webm new file mode 100644 index 0000000000..5b8edf7d83 Binary files /dev/null and b/tests/a11y/public/video.webm differ diff --git a/tests/a11y/run.mjs b/tests/a11y/run.mjs new file mode 100644 index 0000000000..70c7298263 --- /dev/null +++ b/tests/a11y/run.mjs @@ -0,0 +1,63 @@ +// Browser checks run in Docker; --screen-reader runs natively on macOS/Windows. +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { mkdirSync } from "node:fs"; + +const root = fileURLToPath(new URL("../../", import.meta.url)); +const args = process.argv.slice(2); +const native = args[0] === "--screen-reader"; +if (native) { + args.shift(); +} +let result; +if (native) { + const reader = { darwin: "voiceover", win32: "nvda" }[process.platform]; + if (!reader) { + throw new Error("Screen reader tests require macOS or Windows."); + } + result = spawnSync( + process.execPath, + [ + fileURLToPath( + new URL("../node_modules/vite-plus/bin/vp", import.meta.url), + ), + "test", + "--config", + "a11y/vite.config.ts", + "--run", + ...args, + ], + { + cwd: fileURLToPath(new URL("..", import.meta.url)), + env: { ...process.env, BLOCKNOTE_SCREEN_READER: reader }, + stdio: "inherit", + }, + ); +} else { + mkdirSync(`${root}tests/test-results`, { recursive: true }); + result = spawnSync( + "bash", + [ + "tests/docker-run.sh", + "-e", + "CI=1", + "-v", + `${root}tests/a11y:/work/tests/a11y`, + "-v", + `${root}tests/test-results:/work/tests/test-results`, + "--entrypoint", + "/work/tests/node_modules/.bin/vp", + "--", + "test", + "--config", + "a11y/vite.config.ts", + "--run", + ...args, + ], + { cwd: root, stdio: "inherit" }, + ); +} +if (result.error) { + throw result.error; +} +process.exit(result.status ?? 1); diff --git a/tests/a11y/style.css b/tests/a11y/style.css new file mode 100644 index 0000000000..d4f0672560 --- /dev/null +++ b/tests/a11y/style.css @@ -0,0 +1,9 @@ +/* Test final UI states, including editor content and portalled menus, without + transient animation styles affecting axe scans or visual snapshots. */ +*, +*::before, +*::after { + animation: none !important; + transition: none !important; + scroll-behavior: auto !important; +} diff --git a/tests/a11y/tsconfig.json b/tests/a11y/tsconfig.json new file mode 100644 index 0000000000..8b1507e069 --- /dev/null +++ b/tests/a11y/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext", "DOM"], + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "types": ["node", "vite-plus/client"], + "paths": { + "@root/*": ["../../*"] + } + }, + "include": [ + "./**/*.ts", + "./**/*.tsx", + "../../packages/*/src/**/*.a11y.test.ts", + "../../packages/*/src/**/*.axe.test.ts", + "../../packages/*/src/**/*.screen-reader.test.ts", + "../../packages/*/src/**/a11y/*.ts" + ] +} diff --git a/tests/a11y/vite.config.ts b/tests/a11y/vite.config.ts new file mode 100644 index 0000000000..fd0e8eb02c --- /dev/null +++ b/tests/a11y/vite.config.ts @@ -0,0 +1,104 @@ +import { fileURLToPath } from "node:url"; +import { resolve } from "node:path"; +import { defineConfig } from "vite-plus"; +import { playwright } from "vite-plus/test/browser/providers/playwright"; +import { + startReader, + pressKey, + readTranscript, + stopReader, +} from "./commands.js"; + +const reader = process.env.BLOCKNOTE_SCREEN_READER; +if (reader && reader !== "voiceover" && reader !== "nvda") { + throw new Error("Unknown screen reader"); +} +if ( + (reader === "voiceover" && process.platform !== "darwin") || + (reader === "nvda" && process.platform !== "win32") +) { + throw new Error("VoiceOver requires macOS; NVDA requires Windows."); +} +if (!reader && process.platform !== "linux") { + throw new Error("Run browser-only a11y tests in Docker with vp run a11y."); +} + +export default defineConfig({ + publicDir: fileURLToPath(new URL("./public", import.meta.url)), + resolve: { + alias: { + "@root": fileURLToPath(new URL("../../", import.meta.url)), + "@blocknote/core": fileURLToPath( + new URL("../../packages/core/src", import.meta.url), + ), + "@blocknote/react": fileURLToPath( + new URL("../../packages/react/src", import.meta.url), + ), + "@blocknote/mantine": fileURLToPath( + new URL("../../packages/mantine/src", import.meta.url), + ), + }, + dedupe: ["react", "react-dom"], + }, + test: { + name: "a11y", + include: [ + reader + ? "../packages/*/src/**/*.screen-reader.test.ts" + : "../packages/*/src/**/*.{a11y,axe}.test.ts", + ], + testTimeout: reader ? 300_000 : 120_000, + hookTimeout: 30_000, + fileParallelism: false, + retry: 0, + // Missing baselines fail unless explicitly generated with --update. + update: false, + reporters: ["verbose", "html"], + outputFile: { + html: `playwright-report/a11y/${reader ?? "browser"}/index.html`, + }, + attachmentsDir: `test-results/a11y/${reader ?? "browser"}`, + resolveSnapshotPath: (path, extension) => + `${path}-snapshots/screen-reader/${reader ?? "browser"}${extension}`, + browser: { + enabled: true, + headless: !reader, + screenshotFailures: false, + locators: { testIdAttribute: "data-id" }, + provider: playwright({ + contextOptions: { + viewport: { width: 1280, height: 900 }, + locale: "en-US", + colorScheme: "light", + }, + }), + instances: [ + { + browser: reader === "voiceover" ? "webkit" : "chromium", + name: reader ?? "a11y-browser", + }, + ], + commands: { startReader, pressKey, readTranscript, stopReader }, + expect: { + toMatchScreenshot: { + comparatorName: "pixelmatch", + comparatorOptions: { allowedMismatchedPixels: 10 }, + resolveScreenshotPath: ({ + root, + testFileDirectory, + testFileName, + arg, + ext, + browserName, + platform, + }) => + resolve( + root, + testFileDirectory, + `${testFileName}-snapshots/visual/${arg}-${browserName}-${platform}${ext}`, + ), + }, + }, + }, + }, +}); diff --git a/tests/package.json b/tests/package.json index 0f85a565e5..80f28c019a 100644 --- a/tests/package.json +++ b/tests/package.json @@ -20,6 +20,7 @@ "@blocknote/xl-multi-column": "workspace:^", "@blocknote/xl-pdf-exporter": "workspace:^", "@blocknote/xl-typst-exporter": "workspace:^", + "@guidepup/guidepup": "^0.35.0", "@playwright/test": "1.60.0", "@shikijs/core": "^4.4.3", "@shikijs/engine-javascript": "^4.4.3", @@ -34,6 +35,7 @@ "@vitest/ui": "4.1.5", "@y/protocols": "^1.0.6-rc.1", "@y/y": "^14.0.0-rc.26", + "axe-core": "4.13.0", "htmlfy": "^0.6.7", "pdfjs-dist": "^4.10.38", "react": "^19.2.5", diff --git a/tsconfig.json b/tsconfig.json index 4546397b50..1149d1f950 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "files": [], "references": [ + { "path": "./tests/a11y" }, { "path": "./packages/core" }, { "path": "./packages/code-block" }, { "path": "./packages/react" }, diff --git a/vite.config.ts b/vite.config.ts index 0bebdd50e4..fbfeb68a63 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,6 +14,16 @@ export default defineConfig({ // its tsc step reads `core`'s `.d.ts` files. cache: { scripts: true }, tasks: { + // Accessibility checks interact with Docker or a native screen reader; + // replaying cached output must never stand in for running those checks. + a11y: { + command: "node tests/a11y/run.mjs", + cache: false, + }, + "a11y:screen-reader": { + command: "node tests/a11y/run.mjs --screen-reader", + cache: false, + }, "copy-package-readmes": { command: "node scripts/copy-package-readmes.mjs", // These copies are outputs, not inputs: automatic tracking of the @@ -91,6 +101,40 @@ export default defineConfig({ "import/no-cycle": "off", }, overrides: [ + { + files: [ + "**/a11y/**", + "**/*.a11y.test.ts", + "**/*.axe.test.ts", + "**/*.screen-reader.test.ts", + ], + rules: { + "no-restricted-properties": [ + "error", + { + property: "getByTestId", + message: + "Use accessible locators such as getByRole with a name or getByLabel in accessibility tests.", + }, + ], + }, + }, + { + files: ["tests/a11y/**"], + rules: { + // The nested package.json selects ESM; dependencies belong to tests. + "import-eslint/no-extraneous-dependencies": [ + "error", + { + packageDir: "./tests", + devDependencies: true, + peerDependencies: true, + optionalDependencies: false, + bundledDependencies: false, + }, + ], + }, + }, { files: [ "**/scripts/**",