diff --git a/.github/workflows/a11y.yml b/.github/workflows/a11y.yml new file mode 100644 index 0000000000..1cae184a9d --- /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 Playwright 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 playwright test --config a11y/playwright.config.ts + + - 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/browser/ + 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.1 setup --ci + pnpm dlx @guidepup/setup@0.29.1 install + + - name: Record runner version + run: sw_vers + + # Invoke the runner directly so a cached task can never skip VoiceOver. + # The Playwright config restricts screen reader automation to one worker. + - 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/voiceover/ + 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/package.json b/package.json index 5494ca2b3d..01440acf86 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,8 @@ "e2e": "bash tests/docker-run.sh -e CI=1 -- --run", "e2e:updateSnaps": "bash tests/docker-run.sh -e CI=1 -- --run --update=true", "e2e:report": "serve -l 4173 tests/playwright-report", + "a11y": "node tests/a11y/run.mjs", + "a11y:screen-reader": "node tests/a11y/run.mjs --screen-reader", "lint": "vp lint --type-aware", "typecheck": "tsc --noEmit -p tsconfig.json", "postpublish": "rm -rf packages/core/README.md && rm -rf packages/react/README.md", diff --git a/packages/core/src/blocks/ListItem/CheckListItem/block.a11y.spec.ts b/packages/core/src/blocks/ListItem/CheckListItem/block.a11y.spec.ts new file mode 100644 index 0000000000..9a54a29082 --- /dev/null +++ b/packages/core/src/blocks/ListItem/CheckListItem/block.a11y.spec.ts @@ -0,0 +1,12 @@ +import { test } from "../../../../../../tests/a11y/test.js"; + +test.fixme("check list item: checkbox is keyboard accessible while the editor has focus", async () => { + // TODO: The keyboard interaction for controls within blocks is still TBD. + // Once defined, verify reaching the checkbox while focus is within the + // editor, toggling it on/off without changing neighbouring items, and + // returning to editing. Outside the editor, skip it in page tab navigation. + // Capture the focus indicator and announcements of its name, role and state. + // A direct toggle shortcut alone would not require a full browser test; + // this placeholder is for the eventual keyboard focus/navigation behavior, + // which will also apply to other interactive blocks such as toggle headings. +}); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 9f067133c8..05abab19e9 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -22,5 +22,6 @@ "skipLibCheck": true, "noErrorTruncation": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.a11y.spec.ts"] } diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts index 4c449800c9..8e09c370be 100644 --- a/packages/core/vite.config.ts +++ b/packages/core/vite.config.ts @@ -25,7 +25,11 @@ 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.spec.ts", + ], }, plugins: [webpackStats()], build: { diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts new file mode 100644 index 0000000000..abb47b4a1a --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts @@ -0,0 +1,225 @@ +import type { PartialBlock } from "@blocknote/core"; +import { + expect, + test, + type Page, + type ScreenReaderPlaywright, +} from "../../../../../tests/a11y/test.js"; +import { + openEditor, + checkAxe, + pressKey, + readSelection, + saveTranscript, + selectToLineEndKey, +} from "../../../../../tests/a11y/helpers.js"; + +// This vertical slice covers controls shown for a basic text selection only. +// TODO: Cover context-specific toolbar items with axe, keyboard interactions, +// focus screenshots and screen-reader announcements: +// - File/media blocks: caption, replace, rename, delete, download and preview. +// - Table cell selections: merge cells. +// - Editors with commenting/collaboration configured: AddCommentButton and +// AddTiptapCommentButton, including their resulting comment UI. +// The 12-button assertion below describes this fixture, not the entire toolbar. +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" }, +]; + +test.afterEach(async ({ screenReader }) => { + await saveTranscript(screenReader); +}); + +// Each test supplies its document before this keyboard-only toolbar setup. +// Setup stops before activation so each test owns its action and assertions. +async function setupToolbar( + page: Page, + screenReader: ScreenReaderPlaywright | undefined, + control: string, +) { + await pressKey(page, "Tab", screenReader); + await expect(page.locator(".bn-editor")).toBeFocused(); + await pressKey(page, "ArrowDown", screenReader); + await pressKey(page, selectToLineEndKey, screenReader); + await expect + .poll(async () => (await readSelection(page)).text) + .toBe("Format this text"); + const toolbar = page.getByRole("toolbar"); + await expect(toolbar).toBeVisible(); + await checkAxe(page); + await expect(toolbar.getByRole("button")).toHaveCount(12); + + const button = toolbar.getByRole("button", { name: control, exact: true }); + await expect(button).toBeEnabled(); + // Bound traversal to report unreachable controls without looping forever. + for (let step = 0; step < 15; step++) { + if ( + await button.evaluate((element) => element === document.activeElement) + ) { + break; + } + await pressKey(page, "Tab", screenReader); + } + await expect(button, `${control} is reachable with Tab`).toBeFocused(); + if (test.info().project.name === "chromium-linux") { + // Compare the toolbar while the control still has keyboard focus, before + // Enter can move focus back to the editor. Native screen-reader runs keep + // their diagnostic screenshots but do not write platform-specific baselines. + await expect(toolbar).toHaveScreenshot( + `focus-${control.toLowerCase().replaceAll(" ", "-")}.png`, + { animations: "disabled" }, + ); + } + return { toolbar, button }; +} + +test("formatting toolbar: Paragraph", async ({ page, screenReader }) => { + await openEditor(page, toolbarDocument, screenReader); + const { toolbar } = await setupToolbar(page, screenReader, "Paragraph"); + await pressKey(page, "Enter", screenReader); + const menu = page.getByRole("menu"); + await expect(menu).toBeVisible(); + await pressKey(page, "ArrowDown", screenReader); + await pressKey(page, "Home", screenReader); + await expect(menu.getByRole("menuitem").first()).toBeFocused(); + if (test.info().project.name === "chromium-linux") { + await expect(menu).toHaveScreenshot("focus-block-type-menu-item.png", { + animations: "disabled", + }); + } + await pressKey(page, "Escape", screenReader); + await expect(toolbar).toHaveCount(0); + await expect(page.locator(".bn-editor")).toBeFocused(); +}); + +test("formatting toolbar: Bold", async ({ page, screenReader }) => { + await openEditor(page, toolbarDocument, screenReader); + const { button } = await setupToolbar(page, screenReader, "Bold"); + await pressKey(page, "Enter", screenReader); + await expect(button).toHaveAttribute("aria-pressed", "true"); +}); + +test("formatting toolbar: Italic", async ({ page, screenReader }) => { + await openEditor(page, toolbarDocument, screenReader); + const { button } = await setupToolbar(page, screenReader, "Italic"); + await pressKey(page, "Enter", screenReader); + await expect(button).toHaveAttribute("aria-pressed", "true"); +}); + +test("formatting toolbar: Underline", async ({ page, screenReader }) => { + await openEditor(page, toolbarDocument, screenReader); + const { button } = await setupToolbar(page, screenReader, "Underline"); + await pressKey(page, "Enter", screenReader); + await expect(button).toHaveAttribute("aria-pressed", "true"); +}); + +test("formatting toolbar: Strike", async ({ page, screenReader }) => { + await openEditor(page, toolbarDocument, screenReader); + const { button } = await setupToolbar(page, screenReader, "Strike"); + await pressKey(page, "Enter", screenReader); + await expect(button).toHaveAttribute("aria-pressed", "true"); +}); + +test("formatting toolbar: Align text left", async ({ page, screenReader }) => { + await openEditor(page, toolbarDocument, screenReader); + const { button } = await setupToolbar(page, screenReader, "Align text left"); + await pressKey(page, "Enter", screenReader); + await expect(button).toHaveAttribute("aria-pressed", "true"); +}); + +test("formatting toolbar: Align text center", async ({ + page, + screenReader, +}) => { + await openEditor(page, toolbarDocument, screenReader); + const { button } = await setupToolbar( + page, + screenReader, + "Align text center", + ); + await pressKey(page, "Enter", screenReader); + await expect(button).toHaveAttribute("aria-pressed", "true"); +}); + +test("formatting toolbar: Align text right", async ({ page, screenReader }) => { + await openEditor(page, toolbarDocument, screenReader); + const { button } = await setupToolbar(page, screenReader, "Align text right"); + await pressKey(page, "Enter", screenReader); + await expect(button).toHaveAttribute("aria-pressed", "true"); +}); + +test("formatting toolbar: Colors", async ({ page, screenReader }) => { + await openEditor(page, toolbarDocument, screenReader); + const { toolbar } = await setupToolbar(page, screenReader, "Colors"); + await pressKey(page, "Enter", screenReader); + await expect(page.getByRole("menu")).toBeVisible(); + await pressKey(page, "Escape", screenReader); + await expect(toolbar).toHaveCount(0); + await expect(page.locator(".bn-editor")).toBeFocused(); +}); + +test("formatting toolbar: Nest block", async ({ page, screenReader }) => { + await openEditor( + page, + [ + { id: "preceding", type: "paragraph", content: "Preceding paragraph" }, + { id: "format", type: "paragraph", content: "Format this text" }, + ], + screenReader, + ); + await setupToolbar(page, screenReader, "Nest block"); + await pressKey(page, "Enter", screenReader); + await expect( + page.locator( + '[data-node-type="blockContainer"][data-id="preceding"] [data-node-type="blockContainer"][data-id="format"]', + ), + ).toHaveCount(1); + await expect( + page.locator('[data-node-type="blockContainer"][data-id="format"]'), + ).toHaveCount(1); +}); + +test("formatting toolbar: Unnest block", async ({ page, screenReader }) => { + await openEditor( + page, + [ + { + id: "preceding", + type: "paragraph", + content: "Preceding paragraph", + children: [ + { id: "format", type: "paragraph", content: "Format this text" }, + ], + }, + ], + screenReader, + ); + await setupToolbar(page, screenReader, "Unnest block"); + await pressKey(page, "Enter", screenReader); + await expect( + page.locator( + '[data-node-type="blockContainer"][data-id="preceding"] [data-node-type="blockContainer"][data-id="format"]', + ), + ).toHaveCount(0); + await expect( + page.locator('[data-node-type="blockContainer"][data-id="format"]'), + ).toHaveCount(1); +}); + +test("formatting toolbar: Create link", async ({ page, screenReader }) => { + await openEditor(page, toolbarDocument, screenReader); + await setupToolbar(page, screenReader, "Create link"); + await pressKey(page, "Enter", screenReader); + const popover = page.locator(".bn-form-popover"); + await expect(popover.getByPlaceholder("Edit URL")).toBeFocused(); + if (test.info().project.name === "chromium-linux") { + // TODO: The focused URL input currently has no distinct focus outline. + // This records its appearance; it does not establish sufficient focus styling. + await expect(popover).toHaveScreenshot("focus-create-link-url.png", { + animations: "disabled", + }); + } + await pressKey(page, "Escape", screenReader); +}); diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-align-text-center-transcript-voiceover-darwin.json b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-align-text-center-transcript-voiceover-darwin.json new file mode 100644 index 0000000000..95d8485be7 --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-align-text-center-transcript-voiceover-darwin.json @@ -0,0 +1,15 @@ +[ + "list box pop-up text entry area Preceding paragraph\nFormat this text\nFollowing paragraph Insertion at beginning of text.", + "Format this text", + "Format this text selected", + "Add block button main", + "BlockNote accessibility tests web content", + "Paragraph menu pop-up button main", + "Bold toggle button", + "Italic Italic\n⌘+I toggle button", + "Underline toggle button", + "Strike toggle button", + "Align text left Align text left selected toggle button", + "Align text center toggle button", + "Format this text selected" +] \ No newline at end of file diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-align-text-left-transcript-voiceover-darwin.json b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-align-text-left-transcript-voiceover-darwin.json new file mode 100644 index 0000000000..e0eed26d93 --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-align-text-left-transcript-voiceover-darwin.json @@ -0,0 +1,13 @@ +[ + "list box pop-up text entry area Preceding paragraph\nFormat this text\nFollowing paragraph Insertion at beginning of text. main", + "Format this text", + "Format this text selected", + "Add block button main", + "Paragraph menu pop-up button main", + "Bold toggle button", + "Italic toggle button", + "Underline toggle button", + "Strike toggle button", + "Align text left selected toggle button", + "Format this text selected" +] \ No newline at end of file diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-align-text-right-transcript-voiceover-darwin.json b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-align-text-right-transcript-voiceover-darwin.json new file mode 100644 index 0000000000..a58055f58c --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-align-text-right-transcript-voiceover-darwin.json @@ -0,0 +1,16 @@ +[ + "list box pop-up text entry area Preceding paragraph\nFormat this text\nFollowing paragraph Insertion at beginning of text.", + "Format this text", + "Format this text selected", + "Add block button main", + "BlockNote accessibility tests web content", + "Paragraph menu pop-up button main", + "Bold toggle button", + "Italic toggle button", + "Underline toggle button", + "Strike toggle button", + "Align text left selected toggle button", + "Align text center Align text center toggle button", + "Align text right toggle button", + "Format this text selected" +] \ No newline at end of file diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-bold-transcript-voiceover-darwin.json b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-bold-transcript-voiceover-darwin.json new file mode 100644 index 0000000000..01234b0e12 --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-bold-transcript-voiceover-darwin.json @@ -0,0 +1,10 @@ +[ + "list box pop-up text entry area Preceding paragraph\nFormat this text\nFollowing paragraph Insertion at beginning of text.", + "Format this text", + "Format this text selected", + "Add block button main", + "BlockNote accessibility tests web content", + "Paragraph menu pop-up button main", + "Bold toggle button", + "Format this text selected" +] \ No newline at end of file diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-colors-transcript-voiceover-darwin.json b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-colors-transcript-voiceover-darwin.json new file mode 100644 index 0000000000..3e7e971ab3 --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-colors-transcript-voiceover-darwin.json @@ -0,0 +1,18 @@ +[ + "list box pop-up text entry area Preceding paragraph\nFormat this text\nFollowing paragraph Insertion at beginning of text.", + "Format this text", + "Format this text selected", + "Add block button main", + "BlockNote accessibility tests web content", + "Paragraph menu pop-up button main", + "Bold toggle button", + "Italic toggle button", + "Underline Underline\n⌘+U toggle button", + "Strike toggle button", + "Align text left selected toggle button", + "Align text center toggle button", + "Align text right toggle button", + "Colors menu pop-up button", + "menu Colors", + "Format this text selected" +] \ No newline at end of file diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-create-link-transcript-voiceover-darwin.json b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-create-link-transcript-voiceover-darwin.json new file mode 100644 index 0000000000..903eae0841 --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-create-link-transcript-voiceover-darwin.json @@ -0,0 +1,20 @@ +[ + "list box pop-up text entry area Preceding paragraph\nFormat this text\nFollowing paragraph Insertion at beginning of text.", + "Format this text", + "Format this text selected", + "Add block button main", + "BlockNote accessibility tests web content", + "Paragraph menu pop-up button main", + "Bold toggle button", + "Italic toggle button", + "Underline toggle button", + "Strike toggle button", + "Align text left selected toggle button", + "Align text center toggle button", + "Align text right toggle button", + "Colors menu pop-up button", + "Nest block button", + "Create link dialogue pop-up button", + "Create link Edit URL web dialog edit text blank", + "Format this text selected" +] \ No newline at end of file diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-italic-transcript-voiceover-darwin.json b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-italic-transcript-voiceover-darwin.json new file mode 100644 index 0000000000..368d955569 --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-italic-transcript-voiceover-darwin.json @@ -0,0 +1,11 @@ +[ + "list box pop-up text entry area Preceding paragraph\nFormat this text\nFollowing paragraph Insertion at beginning of text.", + "Format this text", + "Format this text selected", + "Add block button main", + "BlockNote accessibility tests web content", + "Paragraph menu pop-up button main", + "Bold toggle button", + "Italic toggle button", + "Format this text selected" +] \ No newline at end of file diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-nest-block-transcript-voiceover-darwin.json b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-nest-block-transcript-voiceover-darwin.json new file mode 100644 index 0000000000..470ac1c1bc --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-nest-block-transcript-voiceover-darwin.json @@ -0,0 +1,18 @@ +[ + "list box pop-up text entry area Preceding paragraph\nFormat this text Insertion at beginning of text.", + "Format this text", + "Format this text selected", + "Add block button main", + "BlockNote accessibility tests web content", + "Paragraph menu pop-up button main", + "Bold toggle button", + "Italic toggle button", + "Underline toggle button", + "Strike toggle button", + "Align text left Align text left selected toggle button", + "Align text center toggle button", + "Align text right toggle button", + "Colors menu pop-up button", + "Nest block button", + "Format this text selected" +] \ No newline at end of file diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-paragraph-transcript-voiceover-darwin.json b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-paragraph-transcript-voiceover-darwin.json new file mode 100644 index 0000000000..fa2b69d425 --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-paragraph-transcript-voiceover-darwin.json @@ -0,0 +1,12 @@ +[ + "list box pop-up text entry area Preceding paragraph\nFormat this text\nFollowing paragraph Insertion at beginning of text.", + "Format this text", + "Format this text selected", + "Add block button main", + "BlockNote accessibility tests web content", + "Paragraph menu pop-up button main", + "menu Paragraph", + "Paragraph selected menu item, group (1 of 15) menu Paragraph", + "", + "Format this text selected" +] \ No newline at end of file diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-strike-transcript-voiceover-darwin.json b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-strike-transcript-voiceover-darwin.json new file mode 100644 index 0000000000..5836c335f2 --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-strike-transcript-voiceover-darwin.json @@ -0,0 +1,13 @@ +[ + "list box pop-up text entry area Preceding paragraph\nFormat this text\nFollowing paragraph Insertion at beginning of text.", + "Format this text", + "Format this text selected", + "Add block button main", + "BlockNote accessibility tests web content", + "Paragraph menu pop-up button main", + "Bold toggle button", + "Italic toggle button", + "Underline Underline\n⌘+U toggle button", + "Strike toggle button", + "Format this text selected" +] \ No newline at end of file diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-underline-transcript-voiceover-darwin.json b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-underline-transcript-voiceover-darwin.json new file mode 100644 index 0000000000..997209a6c0 --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-underline-transcript-voiceover-darwin.json @@ -0,0 +1,12 @@ +[ + "list box pop-up text entry area Preceding paragraph\nFormat this text\nFollowing paragraph Insertion at beginning of text.", + "Format this text", + "Format this text selected", + "Add block button main", + "BlockNote accessibility tests web content", + "Paragraph menu pop-up button main", + "Bold Bold\n⌘+B toggle button", + "Italic toggle button", + "Underline toggle button", + "Format this text selected" +] \ No newline at end of file diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-unnest-block-transcript-voiceover-darwin.json b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-unnest-block-transcript-voiceover-darwin.json new file mode 100644 index 0000000000..754ee5322a --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/screen-reader/formatting-toolbar-unnest-block-transcript-voiceover-darwin.json @@ -0,0 +1,18 @@ +[ + "list box pop-up text entry area Preceding paragraph\nFormat this text Insertion at beginning of text.", + "Format this text", + "Format this text selected", + "Add block button main", + "BlockNote accessibility tests web content", + "Paragraph menu pop-up button main", + "Bold toggle button", + "Italic toggle button", + "Underline toggle button", + "Strike toggle button", + "Align text left Align text left selected toggle button", + "Align text center toggle button", + "Align text right toggle button", + "Colors menu pop-up button", + "Unnest block button", + "Format this text selected" +] \ No newline at end of file diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-align-text-center-chromium-linux-linux.png b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-align-text-center-chromium-linux-linux.png new file mode 100644 index 0000000000..250aa44310 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-align-text-center-chromium-linux-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-align-text-left-chromium-linux-linux.png b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-align-text-left-chromium-linux-linux.png new file mode 100644 index 0000000000..f5a88a7db2 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-align-text-left-chromium-linux-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-align-text-right-chromium-linux-linux.png b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-align-text-right-chromium-linux-linux.png new file mode 100644 index 0000000000..d053216b9e Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-align-text-right-chromium-linux-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-block-type-menu-item-chromium-linux-linux.png b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-block-type-menu-item-chromium-linux-linux.png new file mode 100644 index 0000000000..06c3019d26 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-block-type-menu-item-chromium-linux-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-bold-chromium-linux-linux.png b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-bold-chromium-linux-linux.png new file mode 100644 index 0000000000..3f70c9087e Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-bold-chromium-linux-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-colors-chromium-linux-linux.png b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-colors-chromium-linux-linux.png new file mode 100644 index 0000000000..e7a7a5ae91 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-colors-chromium-linux-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-create-link-chromium-linux-linux.png b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-create-link-chromium-linux-linux.png new file mode 100644 index 0000000000..38d06e4922 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-create-link-chromium-linux-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-create-link-url-chromium-linux-linux.png b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-create-link-url-chromium-linux-linux.png new file mode 100644 index 0000000000..49b4e574a2 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-create-link-url-chromium-linux-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-italic-chromium-linux-linux.png b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-italic-chromium-linux-linux.png new file mode 100644 index 0000000000..06ff7e619c Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-italic-chromium-linux-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-nest-block-chromium-linux-linux.png b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-nest-block-chromium-linux-linux.png new file mode 100644 index 0000000000..20c9ee5498 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-nest-block-chromium-linux-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-paragraph-chromium-linux-linux.png b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-paragraph-chromium-linux-linux.png new file mode 100644 index 0000000000..91aa591396 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-paragraph-chromium-linux-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-strike-chromium-linux-linux.png b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-strike-chromium-linux-linux.png new file mode 100644 index 0000000000..72622fb019 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-strike-chromium-linux-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-underline-chromium-linux-linux.png b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-underline-chromium-linux-linux.png new file mode 100644 index 0000000000..55bcfee364 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-underline-chromium-linux-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-unnest-block-chromium-linux-linux.png b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-unnest-block-chromium-linux-linux.png new file mode 100644 index 0000000000..c439998864 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/FormattingToolbar.a11y.spec.ts-snapshots/visual/focus-unnest-block-chromium-linux-linux.png differ diff --git a/packages/react/src/editor/BlockNoteView.a11y.spec.ts b/packages/react/src/editor/BlockNoteView.a11y.spec.ts new file mode 100644 index 0000000000..28cd22b0c2 --- /dev/null +++ b/packages/react/src/editor/BlockNoteView.a11y.spec.ts @@ -0,0 +1,159 @@ +import { defaultSchemaDocument } from "../../../../tests/a11y/documents.js"; +import { expect, test } from "../../../../tests/a11y/test.js"; +import { + openEditor, + checkAxe, + pressKey, + readSelection, + saveTranscript, +} from "../../../../tests/a11y/helpers.js"; + +test.afterEach(async ({ screenReader }) => { + await saveTranscript(screenReader); +}); + +test("default schema: axe and arrow-key document navigation", async ({ + page, + screenReader, +}) => { + await openEditor(page, defaultSchemaDocument, screenReader); + await checkAxe(page); + await pressKey(page, "Tab", screenReader); + await expect(page.locator(".bn-editor")).toBeFocused(); + + // 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 && (await readSelection(page)).blockId !== "end"; + step++ + ) { + const { tableCell } = await readSelection(page); + 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( + page, + tableCell && visitedTableCells.size < 4 ? "ArrowRight" : "ArrowDown", + screenReader, + ); + } + expect([...visitedTableCells].sort(), "Every table cell is visited").toEqual([ + "0,0", + "0,1", + "1,0", + "1,1", + ]); + expect( + (await readSelection(page)).blockId, + "Arrow keys reach the final block", + ).toBe("end"); + + for ( + let step = 0; + step < 120 && (await readSelection(page)).blockId !== "start"; + step++ + ) { + await pressKey(page, "ArrowUp", screenReader); + } + expect( + (await readSelection(page)).blockId, + "ArrowUp returns to the first block", + ).toBe("start"); +}); + +test.fixme("keyboard selection includes an entire block", async () => { + // TODO: Whole-block keyboard selection is not currently available. + // Once implemented, select a block using only the keyboard and assert the + // block selection (not just its text), leaving neighbouring blocks unselected. + // Verify the visible selection and screen-reader announcement. The keyboard + // interaction remains unspecified until the product behavior is designed. +}); + +test.fixme("keyboard selection includes the entire editor", async () => { + // TODO: Whole-editor keyboard selection is not currently available. + // Once implemented, select all editor content using only the keyboard, + // including non-text blocks, without selecting surrounding page controls. + // Verify the selection boundaries, visible selection and announcement. + // Do not assume a shortcut before the product interaction is defined. +}); + +test.fixme("keyboard focus can enter and leave the editor in both directions", async () => { + // TODO: There is currently no easy keyboard escape from the editor. + // Once the interaction is designed, navigate from Before editor into the + // editor (including interactive blocks), escape to After editor, and repeat + // in reverse. Assert focus at each boundary and capture its visible indicator. + // Do not choose an escape shortcut here before the product behavior exists. +}); + +// Screenshots need human review for clipping/overlap before accepting a +// baseline; matching an existing image alone does not prove accessibility. +test.describe("text spacing and resizing", () => { + test.skip( + ({ headless }) => !headless, + "Visual baselines are generated and compared only in Linux/Docker.", + ); + + test("default schema remains readable with text spacing overrides", async ({ + page, + }) => { + await openEditor(page, defaultSchemaDocument); + // WCAG 1.4.12: override spacing without changing any other style property. + // https://www.w3.org/WAI/WCAG22/Understanding/text-spacing.html + await page.addStyleTag({ + content: ` + .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 expect(page).toHaveScreenshot("default-schema-text-spacing.png", { + fullPage: true, + animations: "disabled", + // Native media controls can vary by a few pixels between runs. + maxDiffPixels: 10, + }); + }); + + test("default schema remains readable at 200% text size", async ({ + page, + }) => { + await openEditor(page, defaultSchemaDocument); + const editor = page.locator(".bn-editor"); + const paragraph = editor.locator("p").first(); + const fontSize = await paragraph.evaluate((element) => + Number.parseFloat(getComputedStyle(element).fontSize), + ); + + // WCAG 1.4.4: resize text to 200%, without scaling its containers/images. + // Read every original size first so inherited sizes aren't doubled twice. + // https://www.w3.org/WAI/WCAG22/Understanding/resize-text.html + await editor.evaluate((element) => { + const sizes = [element, ...element.querySelectorAll("*")] + .filter((node): node is HTMLElement => node instanceof HTMLElement) + .map((node) => ({ + node, + size: Number.parseFloat(getComputedStyle(node).fontSize), + })); + for (const { node, size } of sizes) { + node.style.setProperty("font-size", `${size * 2}px`, "important"); + } + }); + await expect(paragraph).toHaveCSS("font-size", `${fontSize * 2}px`); + await expect(page).toHaveScreenshot( + "default-schema-text-size-200-percent.png", + { + fullPage: true, + animations: "disabled", + // Native media controls can vary by a few pixels between runs. + maxDiffPixels: 10, + }, + ); + }); +}); diff --git a/packages/react/src/editor/BlockNoteView.a11y.spec.ts-snapshots/screen-reader/default-schema-axe-and-arrow-key-document-navigation-transcript-voiceover-darwin.json b/packages/react/src/editor/BlockNoteView.a11y.spec.ts-snapshots/screen-reader/default-schema-axe-and-arrow-key-document-navigation-transcript-voiceover-darwin.json new file mode 100644 index 0000000000..0e0ba3a6ce --- /dev/null +++ b/packages/react/src/editor/BlockNoteView.a11y.spec.ts-snapshots/screen-reader/default-schema-axe-and-arrow-key-document-navigation-transcript-voiceover-darwin.json @@ -0,0 +1,80 @@ +[ + "Now in tab 🎭 Playwright: BlockNote accessibility tests BlockNote accessibility tests web content", + "Plain text. BlockNote documentation bold. italic. underline. strike. code. textColor. backgroundColor.", + "", + "Plain text. BlockNote documentation bold. italic. underline. strike. code. textColor. backgroundColor.", + "", + "Plain text. BlockNote documentation bold. italic. underline. strike. code. textColor. backgroundColor.", + "", + "Plain text. BlockNote documentation bold. italic. underline. strike. code. textColor. backgroundColor.", + "", + "Plain text. BlockNote documentation bold. italic. underline. strike. code. textColor. backgroundColor.", + "", + "Plain text. BlockNote documentation bold. italic. underline. strike. code. textColor. backgroundColor.", + "", + "Plain text. BlockNote documentation bold. italic. underline. strike. code. textColor. backgroundColor.", + "", + "Plain text. BlockNote documentation bold. italic. underline. strike. code. textColor. backgroundColor.", + "", + "Plain text. BlockNote documentation bold. italic. underline. strike. code. textColor. backgroundColor.", + "", + "Plain text. BlockNote documentation bold. italic. underline. strike. code. textColor. backgroundColor.", + "", + "Plain text. BlockNote documentation bold. italic. underline. strike. code. textColor. backgroundColor.", + "", + "Plain text. BlockNote documentation bold. italic. underline. strike. code. textColor. backgroundColor.", + "", + "Plain text. BlockNote documentation bold. italic. underline. strike. code. textColor. backgroundColor.", + "", + "Plain text. BlockNote documentation bold. italic. underline. strike. code. textColor. backgroundColor.", + "", + "Plain text. BlockNote documentation bold. italic. underline. strike. code. textColor. backgroundColor.", + "", + "Plain text. BlockNote documentation bold. italic. underline. strike. code. textColor. backgroundColor.", + "", + "Plain text. BlockNote documentation bold. italic. underline. strike. code. textColor. backgroundColor.", + "", + "Plain text. BlockNote documentation bold. italic. underline. strike. code. textColor. backgroundColor.", + "", + "Plain text. BlockNote documentation bold. italic. underline. strike. code. textColor. backgroundColor.", + "", + "Plain text. BlockNote documentation bold. italic. underline. strike. code. textColor. backgroundColor.", + "", + "Plain text. BlockNote documentation bold. italic. underline. strike. code. textColor. backgroundColor.", + "", + "Plain text. BlockNote documentation bold. italic. underline. strike. code. textColor. backgroundColor.", + "", + "You are currently on a text area.", + "", + "You are currently on a text area.", + "", + "You are currently on a text area.", + "", + "You are currently on a text area.", + "", + "You are currently on a text area.", + "", + "You are currently on a text area.", + "", + "You are currently on a text area.", + "", + "You are currently on a text area.", + "", + "You are currently on a text area.", + "", + "You are currently on a text area.", + "", + "You are currently on a text area.", + "", + "You are currently on a text area.", + "", + "You are currently on a text area.", + "", + "You are currently on a text area.", + "", + "You are currently on a text area.", + "", + "You are currently on a text area.", + "", + "You are currently on a text area." +] \ No newline at end of file diff --git a/packages/react/src/editor/BlockNoteView.a11y.spec.ts-snapshots/visual/default-schema-text-size-200-percent-chromium-linux-linux.png b/packages/react/src/editor/BlockNoteView.a11y.spec.ts-snapshots/visual/default-schema-text-size-200-percent-chromium-linux-linux.png new file mode 100644 index 0000000000..cc2238b607 Binary files /dev/null and b/packages/react/src/editor/BlockNoteView.a11y.spec.ts-snapshots/visual/default-schema-text-size-200-percent-chromium-linux-linux.png differ diff --git a/packages/react/src/editor/BlockNoteView.a11y.spec.ts-snapshots/visual/default-schema-text-spacing-chromium-linux-linux.png b/packages/react/src/editor/BlockNoteView.a11y.spec.ts-snapshots/visual/default-schema-text-spacing-chromium-linux-linux.png new file mode 100644 index 0000000000..1c610aec04 Binary files /dev/null and b/packages/react/src/editor/BlockNoteView.a11y.spec.ts-snapshots/visual/default-schema-text-spacing-chromium-linux-linux.png differ diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json index 34ea71f87f..45fbec2713 100644 --- a/packages/react/tsconfig.json +++ b/packages/react/tsconfig.json @@ -22,6 +22,7 @@ "emitDeclarationOnly": true }, "include": ["src"], + "exclude": ["src/**/*.a11y.spec.ts"], "references": [ { "path": "../core" diff --git a/packages/react/vite.config.ts b/packages/react/vite.config.ts index 0d0bd6893c..2871b030e0 100644 --- a/packages/react/vite.config.ts +++ b/packages/react/vite.config.ts @@ -27,7 +27,11 @@ export default defineConfig( environment: "jsdom", setupFiles: ["./vitestSetup.ts"], // Browser tests run in the tests package's Docker browser suite. - exclude: [...configDefaults.exclude, "**/*.browser.test.*"], + exclude: [ + ...configDefaults.exclude, + "**/*.browser.test.*", + "**/*.a11y.spec.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 822bd884f9..2e2743ec0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6540,6 +6540,15 @@ importers: specifier: 3.2.0 version: 3.2.0 devDependencies: + '@guidepup/playwright': + specifier: ^0.19.1 + version: 0.19.1(@guidepup/guidepup@0.35.0)(@playwright/test@1.60.0) + '@guidepup/guidepup': + specifier: ^0.35.0 + version: 0.35.0 + '@axe-core/playwright': + specifier: ^4.13.0 + version: 4.13.0(playwright-core@1.60.0) '@blocknote/ariakit': specifier: workspace:^ version: link:../packages/ariakit @@ -6892,6 +6901,11 @@ packages: resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} engines: {node: '>=18.0.0'} + '@axe-core/playwright@4.13.0': + resolution: {integrity: sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==} + peerDependencies: + playwright-core: '>= 1.0.0' + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -7628,6 +7642,15 @@ packages: tailwindcss: optional: true + '@guidepup/guidepup@0.35.0': + resolution: {integrity: sha512-US64a++RucCtLfoq/DqnjzaHYpKzERVYmhtsWViz38MyklICICDpb50AKLn9sbxUwTceK2rS0fjDy7fYpWK/QA==} + + '@guidepup/playwright@0.19.1': + resolution: {integrity: sha512-d0FMH6LFbRbV71l2NGzv2gqV3vQbHDfTVctXsKm6d1ZahpS2/Ozq1WCEoZ7QTAR6iuNzwy03tZfvyijGH+zUDw==} + peerDependencies: + '@guidepup/guidepup': '>=0.33.0' + '@playwright/test': ^1.57.0 + '@handlewithcare/prosemirror-inputrules@0.1.4': resolution: {integrity: sha512-GMqlBeG2MKM+tXEFd2N+wIv5z4VvJTg8JtfJUrdjvFq2W6v+AW8oTgiWyFw8L3iEQwvtQcVJxU873iB0LXUNNw==} peerDependencies: @@ -11999,6 +12022,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==} @@ -15069,6 +15096,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==} @@ -16688,6 +16719,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==} @@ -17376,6 +17411,11 @@ snapshots: '@aws/lambda-invoke-store@0.2.4': {} + '@axe-core/playwright@4.13.0(playwright-core@1.60.0)': + dependencies: + axe-core: 4.13.0 + playwright-core: 1.60.0 + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -18040,6 +18080,18 @@ 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 + + '@guidepup/playwright@0.19.1(@guidepup/guidepup@0.35.0)(@playwright/test@1.60.0)': + dependencies: + '@guidepup/guidepup': 0.35.0 + '@playwright/test': 1.60.0 + '@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 @@ -22464,6 +22516,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 @@ -25904,6 +25958,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 @@ -27904,6 +27963,8 @@ snapshots: xml@1.0.1: {} + xmlbuilder@15.1.1: {} + xmlchars@2.2.0: {} xtend@4.0.2: {} diff --git a/tests/a11y/app.tsx b/tests/a11y/app.tsx new file mode 100644 index 0000000000..503a1d2e9b --- /dev/null +++ b/tests/a11y/app.tsx @@ -0,0 +1,22 @@ +import "@blocknote/core/fonts/inter.css"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { useCreateBlockNote } from "@blocknote/react"; +import { createRoot } from "react-dom/client"; + +function App() { + const editor = useCreateBlockNote({ + initialContent: window.a11yInitialContent, + }); + + return ( +
+

BlockNote accessibility fixture

+ + + +
+ ); +} + +createRoot(document.getElementById("root")!).render(); diff --git a/tests/a11y/documents.ts b/tests/a11y/documents.ts new file mode 100644 index 0000000000..ef0e3d1e32 --- /dev/null +++ b/tests/a11y/documents.ts @@ -0,0 +1,126 @@ +import type { + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, + PartialBlock, + PartialInlineContentElement, + Styles, +} from "@blocknote/core"; + +// Keep each style on its own span: code intentionally excludes other marks. +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 +>; + +// Exhaustive by schema key, so adding a default block/style/inline type makes +// this fixture fail type checking until there is an example for it. +const blockExamples = { + paragraph: { + type: "paragraph", + content: [ + ...Object.values(inlineExamples), + ...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.ts b/tests/a11y/helpers.ts new file mode 100644 index 0000000000..f25905f259 --- /dev/null +++ b/tests/a11y/helpers.ts @@ -0,0 +1,145 @@ +import type { PartialBlock } from "@blocknote/core"; +import AxeBuilder from "@axe-core/playwright"; +import { + test, + expect, + type Page, + type ScreenReaderPlaywright, +} from "./test.js"; + +export const selectToLineEndKey = + process.platform === "darwin" ? "Meta+Shift+ArrowRight" : "Shift+End"; + +type FocusEvent = { tag: string; label: string | null; text: string | null }; +declare global { + interface Window { + a11yFocusEvents: FocusEvent[]; + a11yInitialContent?: PartialBlock[]; + } +} + +export async function openEditor( + page: Page, + initialContent: PartialBlock[], + screenReader?: ScreenReaderPlaywright, +) { + await page.addInitScript((content) => { + window.a11yInitialContent = content; + window.a11yFocusEvents = []; + document.addEventListener("focusin", (event) => { + if (event.target instanceof Element) { + window.a11yFocusEvents.push({ + tag: event.target.tagName, + label: event.target.getAttribute("aria-label"), + text: event.target.textContent?.slice(0, 100) ?? null, + }); + } + }); + }, initialContent); + await page.goto("/"); + await expect(page.locator(".bn-editor")).toBeVisible(); + await page.evaluate(() => document.fonts.ready.then(() => undefined)); + if (screenReader) { + await screenReader.navigateToWebContent(); + await screenReader.clearSpokenPhraseLog(); + } + // Set only the starting point; navigation within the editor uses key presses. + await page + .getByRole("button", { name: "Before editor", exact: true }) + .focus(); +} + +export async function checkAxe(page: Page) { + const results = await new AxeBuilder({ page }).analyze(); + await test.info().attach("axe.json", { + body: JSON.stringify(results, null, 2), + contentType: "application/json", + }); + // Continue collecting interaction evidence even if existing violations fail. + expect.soft(results.violations, "axe violations").toEqual([]); +} + +export async function readSelection(page: Page) { + return page.evaluate(() => { + const selection = window.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() ?? "", + }; + }); +} + +// Each key produces a report step, interaction details, and a screenshot if +// focus changed. With GuidePup, the same step also captures its announcement. +export async function pressKey( + page: Page, + key: string, + screenReader?: ScreenReaderPlaywright, +) { + await test.step(`Press ${key}`, async () => { + let speech: string | undefined; + if (screenReader) { + speech = (await screenReader.capture(() => page.keyboard.press(key))) + .spokenPhrase; + } else { + await page.keyboard.press(key); + } + const focus = await page.evaluate(() => window.a11yFocusEvents.splice(0)); + const info = test.info(); + const name = `${info.attachments.length}-${key.replace(/[^a-z0-9]+/gi, "-")}`; + await info.attach(`${name}.json`, { + body: JSON.stringify( + { key, focus, selection: await readSelection(page), speech }, + null, + 2, + ), + contentType: "application/json", + }); + if (focus.length) { + // This captures the resulting state; the JSON also records transient focus. + await info.attach(`${name}-focus.png`, { + body: await page.screenshot({ animations: "disabled" }), + contentType: "image/png", + }); + } + }); +} + +// Called explicitly from afterEach, before GuidePup stops its screen reader. +export async function saveTranscript(screenReader?: ScreenReaderPlaywright) { + if (!screenReader) { + return; + } + const transcript = await screenReader.spokenPhraseLog(); + await test.info().attach("screen-reader-transcript.json", { + body: JSON.stringify( + { reader: screenReader.name, version: screenReader.version, transcript }, + null, + 2, + ), + contentType: "application/json", + }); + expect( + transcript.length, + "The screen reader must produce a transcript", + ).toBeGreaterThan(0); + // Preserve every phrase and its order: no normalization or partial matching. + // Each test gets its own snapshot; Playwright separates projects by reader. + const name = test + .info() + .title.replace(/[^a-z0-9]+/gi, "-") + .toLowerCase(); + expect(JSON.stringify(transcript, null, 2)).toMatchSnapshot( + `${name}-transcript.json`, + ); +} diff --git a/tests/a11y/index.html b/tests/a11y/index.html new file mode 100644 index 0000000000..2cf3f668ef --- /dev/null +++ b/tests/a11y/index.html @@ -0,0 +1,12 @@ + + + + + + BlockNote accessibility tests + + +
+ + + 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/playwright.config.ts b/tests/a11y/playwright.config.ts new file mode 100644 index 0000000000..c1315751e8 --- /dev/null +++ b/tests/a11y/playwright.config.ts @@ -0,0 +1,81 @@ +// Discovers colocated tests and starts the fixture app; one worker owns OS focus. +import { defineConfig } from "@playwright/test"; +import { fileURLToPath } from "node:url"; + +const reader = process.env.BLOCKNOTE_SCREEN_READER; +if ( + !reader && + process.platform !== "linux" && + !process.argv.includes("--list") +) { + throw new Error( + "Run browser-only accessibility tests in Docker with vp run a11y.", + ); +} +if (reader !== undefined && reader !== "voiceover" && reader !== "nvda") { + throw new Error( + "BLOCKNOTE_SCREEN_READER must be voiceover or nvda (or unset for Docker browser tests).", + ); +} +if ( + (reader === "voiceover" && process.platform !== "darwin") || + (reader === "nvda" && process.platform !== "win32") +) { + throw new Error("VoiceOver requires macOS; NVDA requires Windows."); +} + +export default defineConfig({ + testDir: "../../packages", + testMatch: "**/*.a11y.spec.ts", + timeout: reader ? 300_000 : 120_000, + snapshotPathTemplate: + "{testDir}/{testFilePath}-snapshots/screen-reader/{arg}-{projectName}-{platform}{ext}", + expect: { + timeout: 10_000, + toHaveScreenshot: { + pathTemplate: + "{testDir}/{testFilePath}-snapshots/visual/{arg}-{projectName}-{platform}{ext}", + }, + }, + workers: 1, + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: 0, + // Baselines are created/changed only with an explicit --update-snapshots run. + updateSnapshots: "none", + outputDir: `../test-results/a11y/${reader ?? "browser"}`, + reporter: [ + ["list"], + [ + "html", + { + outputFolder: `../playwright-report/a11y/${reader ?? "browser"}`, + open: "never", + }, + ], + ], + use: { + baseURL: "http://127.0.0.1:5190", + viewport: { width: 1280, height: 900 }, + locale: "en-US", + colorScheme: "light", + headless: !reader, + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + projects: reader + ? [ + { + name: reader, + use: { browserName: reader === "voiceover" ? "webkit" : "chromium" }, + }, + ] + : [{ name: "chromium-linux", use: { browserName: "chromium" } }], + webServer: { + command: "pnpm exec vp dev --config a11y/vite.config.ts", + cwd: fileURLToPath(new URL("..", import.meta.url)), + url: "http://127.0.0.1:5190", + reuseExistingServer: false, + timeout: 120_000, + }, +}); 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..fa02ce903d --- /dev/null +++ b/tests/a11y/run.mjs @@ -0,0 +1,61 @@ +// 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/@playwright/test/cli.js", import.meta.url), + ), + "test", + "--config", + "a11y/playwright.config.ts", + ...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/playwright", + "--", + "test", + "--config", + "a11y/playwright.config.ts", + ...args, + ], + { cwd: root, stdio: "inherit" }, + ); +} +if (result.error) { + throw result.error; +} +process.exit(result.status ?? 1); diff --git a/tests/a11y/test.ts b/tests/a11y/test.ts new file mode 100644 index 0000000000..6e57488d25 --- /dev/null +++ b/tests/a11y/test.ts @@ -0,0 +1,17 @@ +import { test as playwrightTest } from "@playwright/test"; +import type { ScreenReaderPlaywright } from "@guidepup/playwright"; + +export { expect } from "@playwright/test"; +export type { Page } from "@playwright/test"; +export type { ScreenReaderPlaywright } from "@guidepup/playwright"; + +// GuidePup owns screen-reader startup/cleanup. The browser-only runner supplies +// undefined for that one fixture so both runners can execute the same tests. +// Import GuidePup only in native mode: importing it on Linux throws. +export const test = process.env.BLOCKNOTE_SCREEN_READER + ? (await import("@guidepup/playwright")).screenReaderTest + : playwrightTest.extend<{ screenReader: ScreenReaderPlaywright | undefined }>( + { + screenReader: undefined, + }, + ); diff --git a/tests/a11y/tsconfig.json b/tests/a11y/tsconfig.json new file mode 100644 index 0000000000..c2d2b7f33f --- /dev/null +++ b/tests/a11y/tsconfig.json @@ -0,0 +1,19 @@ +{ + "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"] + }, + "include": [ + "./**/*.ts", + "./**/*.tsx", + "../../packages/*/src/**/*.a11y.spec.ts" + ] +} diff --git a/tests/a11y/vite.config.ts b/tests/a11y/vite.config.ts new file mode 100644 index 0000000000..6520ed2b95 --- /dev/null +++ b/tests/a11y/vite.config.ts @@ -0,0 +1,22 @@ +// Serves the React fixture against workspace source, without package builds. +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + root: fileURLToPath(new URL(".", import.meta.url)), + resolve: { + alias: { + "@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"], + }, + server: { host: "127.0.0.1", port: 5190, strictPort: true }, +}); diff --git a/tests/package.json b/tests/package.json index 651ded4f48..e840719997 100644 --- a/tests/package.json +++ b/tests/package.json @@ -37,7 +37,10 @@ "rimraf": "^5.0.10", "vite-plus": "catalog:", "vitest-browser-react": "^2.2.0", - "@blocknote/xl-typst-exporter": "workspace:^" + "@blocknote/xl-typst-exporter": "workspace:^", + "@axe-core/playwright": "^4.13.0", + "@guidepup/playwright": "^0.19.1", + "@guidepup/guidepup": "^0.35.0" }, "dependencies": { "get-port-please": "3.2.0", diff --git a/vite.config.ts b/vite.config.ts index 922250d223..0be5cc7fb9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -84,6 +84,22 @@ export default defineConfig({ "import/no-cycle": "off", }, overrides: [ + { + 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/**",