diff --git a/.claude/skills/playwright-cli/SKILL.md b/.claude/skills/playwright-cli/SKILL.md index fe30992ad9..8bfa4bafd8 100644 --- a/.claude/skills/playwright-cli/SKILL.md +++ b/.claude/skills/playwright-cli/SKILL.md @@ -6,6 +6,10 @@ allowed-tools: Bash(playwright-cli:*) Bash(npx:*) Bash(npm:*) # Browser Automation with playwright-cli +## BlockNote test conventions + +When authoring or changing tests in this repository, follow [testing-skill](../testing-skill/SKILL.md). Colocated component/accessibility tests use Vitest browser mode, one `*.test.ts` file with separate describe groups, and the shared Docker e2e runner. Read its [accessibility reference](../testing-skill/references/accessibility-tests.md) for assertion scopes, navigation helpers, and native-only screen-reader tests. These repository conventions take precedence over the generic Playwright test-generation examples below and in the references: do not introduce a standalone Playwright runner, one `.spec.ts` per scenario, or native browser-suite runs. + ## Quick start ```bash diff --git a/.claude/skills/playwright-cli/references/spec-driven-testing.md b/.claude/skills/playwright-cli/references/spec-driven-testing.md index 70f05135eb..f07d45f90f 100644 --- a/.claude/skills/playwright-cli/references/spec-driven-testing.md +++ b/.claude/skills/playwright-cli/references/spec-driven-testing.md @@ -1,5 +1,7 @@ # Spec-driven testing (plan → generate → heal) +For BlockNote repository tests, use [testing-skill](../../testing-skill/SKILL.md) and its [accessibility conventions](../../testing-skill/references/accessibility-tests.md). The generic runner setup and one-test-per-file instructions here do not apply to BlockNote's Vitest browser suite. + End-to-end workflow for authoring and maintaining Playwright tests using `playwright-cli`. The three sections below can be used independently: - **Planning** — explore the app, produce a spec file describing what to test. diff --git a/.claude/skills/testing-skill/SKILL.md b/.claude/skills/testing-skill/SKILL.md index 7efa847d7f..1808299462 100644 --- a/.claude/skills/testing-skill/SKILL.md +++ b/.claude/skills/testing-skill/SKILL.md @@ -1,6 +1,6 @@ --- name: testing-skill -description: Instructions for writing, running, and updating unit/end-to-end tests. Should be used when prompted specifically to add tests for a given feature, bug, or regression. +description: Instructions for writing, running, and updating unit, end-to-end, and colocated accessibility tests. Use when adding or changing tests for a feature, bug, or regression. --- # Testing @@ -52,6 +52,12 @@ The device-only behaviors above, checked on real hardware before a release: `tests/src/end-to-end`: Tests that need a real browser and span multiple packages go here — chiefly tests which interact with the editor UI or simulate user interaction, but also browser integration tests that exercise complete flows without interaction (e.g. exporting a full document, static rendering). New subdirectories can be added if the functionality being tested is not covered by any of the existing ones. Important note about existing E2E tests - many are written poorly and should only loosely be used as reference. We want to avoid abstraction layers and `waitForTimeout` as much as possible. +### Colocated Component and Accessibility Tests + +Component accessibility coverage is colocated with the component/block, but runs in the existing Docker e2e suite. Use one `*.test.ts` file with separate `test.describe("behavior")`, `"keyboard"`, `"ARIA"`, `"axe"`, and, where needed, `"visual"` groups. Native screen-reader edge cases belong in an optional `*.screen-reader.test.ts` file and run locally only, outside regular e2e/CI. + +When adding or changing this coverage, read [Accessibility test structure and helpers](references/accessibility-tests.md). It defines each group's assertions, fixture/helper conventions, discovery configuration, and run commands. Use the colocated FormattingToolbar, BlockNoteView, and Heading tests as the concrete examples; do not create per-component `a11y/` directories or separate files for each assertion type. + ## When & How to Add Tests Placement and proof obligations are governed by the ladder above: pick the rung red-first, and verify every new test fails without the code change it covers (for edge cases and new features too — existing tests continuing to pass is exactly the situation that demands new red-first ones). @@ -76,6 +82,8 @@ bash tests/docker-run.sh -e CI=1 -- --run [filters] **Never run the browser suite natively** (`vp test -c vite.config.browser.ts` outside the container) — not even "just one file". Two failure modes, both silent: the screenshot matcher _seeds_ a new `-darwin`/`-win32` baseline for every screenshot test that has none (passing without comparing anything, and littering the tree with hundreds of unvetted PNGs — these are gitignored as a backstop, and only `-linux` baselines are tracked), and several suites genuinely behave differently outside Linux (e.g. caret placement is font-metric-dependent, so platform-shared JSON snapshots mismatch). If Docker isn't running, notify the user to launch it instead of falling back to a native run. +Native screen-reader tests use the separate local runner described in the accessibility reference; the Docker-only rule above applies to the regular browser suite. + A specific test file may be targeted by appending (part of) its name as a filter. A single browser may be targeted with `--project "e2e (chromium)"`. Individual tests in a file may be disabled using `skip`, i.e. `test.skip("Test name", ...)` (remember to revert this once all tests pass). Screenshot baselines can be regenerated with `--update=true` (as `e2e:updateSnaps` does). Always attach the value: vitest declares the flag as `-u, --update [type]` (it also accepts `new`/`all`/`none`), so a bare `-u` before a filter swallows the filter as its value and silently runs the **whole** suite in update mode. `--update=true` cannot, so filters stay filters in any position. Note that `-u` only rewrites baselines whose comparison **fails** — a small intended change (e.g. a short text edit) that fits inside the suite's 2% pixel tolerance leaves the baseline stale while the test passes. To force a fresh capture, delete the baseline file first. Baselines are per-browser (`--linux.png`); after regenerating, always inspect the images before committing them. diff --git a/.claude/skills/testing-skill/references/accessibility-tests.md b/.claude/skills/testing-skill/references/accessibility-tests.md new file mode 100644 index 0000000000..799d3fcf88 --- /dev/null +++ b/.claude/skills/testing-skill/references/accessibility-tests.md @@ -0,0 +1,92 @@ +# Colocated component and accessibility tests + +All paths below are relative to the repository root. These are browser integration tests, even though their filenames end in `.test.ts`. They use the existing e2e configuration and CI jobs, not a separate accessibility browser project or workflow. + +## Files and fixtures + +Place files directly beside the component or block: + +```text +FormattingToolbar.tsx +FormattingToolbar.test.ts +FormattingToolbar.screen-reader.test.ts # optional, native/local only +testHelpers.ts # shared navigation, when needed +__snapshots__/ # ARIA snapshots +__screenshots__/ # browser-specific visual baselines +``` + +Examples to read before extending an area: + +- `packages/react/src/components/FormattingToolbar/FormattingToolbar.test.ts` +- `packages/react/src/editor/BlockNoteView.test.ts` +- `packages/core/src/blocks/Heading/block.test.ts` + +Keep the browser scenarios in one file, grouped by assertion purpose. Do not split them into `.keyboard.test.ts`, `.aria.test.ts`, `.axe.test.ts`, or `.visual.test.ts` files or put them in a component-local `a11y/` directory. Only add groups relevant to the component. + +Declare document fixtures in their consuming test files, with names ending in `Document` and typed as `PartialBlock[]`. Prefer plain data; avoid fixture-selection factories and separate `fixtures.ts` files. If both browser and screen-reader tests need a small fixture, declare it in each file rather than importing one test file from another. The common schema examples in `tests/a11y/documents.ts` remain shared infrastructure for broad editor coverage. + +Write explicit named tests for each toolbar item. Share repeated test logic through named functions near the start of the file or describe block, rather than registering all item tests in a loop. Use specific names such as `toolbarButton`, `toolbarMenu`, and `toolbarItem`, rather than `control`. + +## Describe blocks and assertion scope + +The same keyboard actions may appear in several groups. Each group checks a different outcome; avoid adding every kind of assertion to every test. + +| Group | Actions and assertions | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `behavior` | Navigate using the keyboard and activate with Enter. Assert the component's functional effect. For document edits, subscribe with `editor.onChange(vi.fn())` after navigation and before activation, register the unsubscribe with `onTestFinished`, and assert the callback ran. View-only changes may need another observable effect: toggle-heading tests check persisted expansion state. Keep detailed editor-command unit tests colocated with those commands rather than duplicating them here. | +| `keyboard` | Assert the relevant focus transitions explicitly with `toHaveFocus()`: entering the editor, reaching the toolbar/menu/item, and returning to the editor or leaving it. Assert visibility and, for document traversal, selection/block/table-cell changes using `readSelection()`. Check the visual focus indicator as well; DOM focus alone does not prove that focus is visible. | +| `ARIA` | Follow the keyboard interaction and use `checkAriaSnapshot(locator, name)` before and after activation to check names, roles, pressed/expanded state, etc. Scope item tests to the item: adding another toolbar button must not invalidate Bold's snapshots. Use a separate whole-toolbar tree snapshot to cover membership and overall structure. If activation changes the item's accessible name, locate its resulting name explicitly. | +| `axe` | Open the relevant UI state and call `checkAxe(annotate, scope)`. Scan a toolbar, menu, or popover independently where appropriate; use a whole-document scan for broad schema coverage. The helper asserts no violations and annotates incomplete results for manual review. Axe scans do not replace keyboard navigation or focus-indicator checks. | +| `visual` | Cover visual accessibility scenarios such as text-spacing overrides and 200% text resizing. Use `screenshotFull` for tall documents, wait for fonts/media, and mask nondeterministic video frames where needed. These checks concern layout/readability rather than individual button activation. | + +On `a11y-testing`, keyboard focus appearance is currently checked with item-level screenshots. FormattingToolbar's `checkScreenshotWithPadding` captures the item plus 2 px on each side so its focus ring is not clipped; avoid whole-toolbar screenshots for individual buttons. Keep visual-indicator assertions separate from navigation's `toHaveFocus()` assertions. Follow the checked-out branch's implementation when changing this mechanism; do not assume newer helpers from another branch exist. + +## Helpers and synchronization + +Import the common harness from `@root/tests/a11y/helpers.js`: + +- `openEditor(document, { editable, theme, onReady }?)` mounts the editor and surrounding "Before editor" / "After editor" buttons, waits for readiness, and returns the editor. Normal browser runs start focused on "Before editor"; native screen-reader runs start the reader. +- `pressKey(key)` sends real keyboard input through the browser provider and waits for pending editor focus reconciliation before the next key. Use `selectToLineEndKey` for platform-appropriate selection. +- `readSelection()` exposes block ID, table-cell coordinates, offset, and selected text for navigation assertions. +- `checkAriaSnapshot(locator, name)` captures the scoped browser accessibility tree into Vitest snapshots. +- `checkAxe(annotate, scope?)` runs axe with optional CSS scope and reports violations/incomplete results. +- `saveTranscript()` captures a native reader transcript and stops the reader in `finally`; register it with `afterEach` in screen-reader files. + +Keep reusable multi-step navigation in the colocated `testHelpers.ts`, such as `focusToolbarItem`, `focusToggleHeading`, or `navigateDefaultSchemaDocument`. Keep behavior assertions out of those action helpers. The keyboard group's corresponding `...WithAssertions` function follows the same navigation steps, adding focus/visibility/selection assertions. Inline trivial one- or two-line wrappers around `pressKey`, storage resets, or element lookup. Avoid generic setup helpers that mix fixture choice, navigation, and spying. + +Navigate to a requested item by advancing with keyboard input until that item has focus, with the test timeout bounding failure. Do not maintain a duplicate list of enabled toolbar buttons or calculate a fixed number of Tab presses. Use accessible locators (`getByRole` with a name, `getByLabel`) where available. + +Wait on observable readiness with retrying assertions, not arbitrary sleeps. This also applies outside the keyboard group when needed to drive the next action reliably. For example, Mantine asynchronously focuses a `[data-autofocus]` placeholder after opening a menu: wait for menu visibility, then assert that placeholder has focus before sending ArrowDown. Visibility alone is not enough. Prefer these explicit assertions to `vi.waitUntil` polling for focus. Use live locators for asynchronously mounted elements; passing the result of an early `document.querySelector()` to an assertion can permanently capture `null`. + +## Native screen-reader edge cases + +Use a separate `*.screen-reader.test.ts` only for actual reader announcements/navigation that ARIA snapshots cannot establish. Reuse keyboard navigation helpers, call `openEditor`, and register `afterEach(saveTranscript)`. Avoid duplicating every browser scenario. Transcripts are reader-specific; review changes rather than accepting them blindly. + +These tests run locally with VoiceOver on macOS or NVDA on Windows and require the installed reader and GuidePup permissions. They must stay excluded from regular e2e, node tests, and CI. `tests/a11y/vite.config.ts` is for this native runner only. + +## Discovery and commands + +The colocated `.test.ts` browser files are explicitly registered. When adding an area, update: + +- `tests/vite.config.browser.ts`: include its browser test in the shared e2e suite; preserve the screen-reader exclusions, including desktop instance exclusions. +- The package's `vite.config.ts`: exclude the browser test from node/jsdom unit runs. +- The package's `tsconfig.json` and `tests/a11y/tsconfig.json`: keep test-only files/helpers out of production declarations and include them in the accessibility type-check project. +- Root `vite.config.ts`: extend the accessible-locator lint override for the new test files where necessary. +- `tests/a11y/run.mjs`: add the area's browser test to the default filters for the `a11y` shortcut. + +Run from the repository root: + +```bash +# Existing e2e suite, always Docker; narrow by file, group, and/or browser. +vp run e2e FormattingToolbar.test.ts +vp run e2e FormattingToolbar.test.ts -t keyboard --project "e2e (webkit)" --retry=0 +vp run a11y + +# Update only the selected suite, then review and rerun without updating. +vp run e2e:updateSnaps FormattingToolbar.test.ts + +# Native/local only, never part of regular e2e or CI. +vp run a11y:screen-reader FormattingToolbar.screen-reader.test.ts +``` + +Keep known functionality gaps skipped with a brief comment explaining the actual failure. Use `test.skipIf(server.browser === "webkit")`, for example, when only that browser is affected, preserving other browsers' coverage. Do not skip timing failures or increase retries to hide them: fix readiness/locator handling and verify repeated runs with retries disabled. Do not refresh snapshots to accept an unexplained regression. 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/__snapshots__/block.test.ts.snap b/packages/core/src/blocks/Heading/__snapshots__/block.test.ts.snap new file mode 100644 index 0000000000..9a2a845526 --- /dev/null +++ b/packages/core/src/blocks/Heading/__snapshots__/block.test.ts.snap @@ -0,0 +1,20 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ARIA > read-only toggle heading: Tab reaches the toggle and Enter expands/collapses it > collapsed 1`] = ` +"- textbox: + - button "Expand or collapse" + - heading "Details" [level=2]" +`; + +exports[`ARIA > read-only toggle heading: Tab reaches the toggle and Enter expands/collapses it > collapsed again 1`] = ` +"- textbox: + - button "Expand or collapse" + - heading "Details" [level=2]" +`; + +exports[`ARIA > read-only toggle heading: Tab reaches the toggle and Enter expands/collapses it > expanded 1`] = ` +"- textbox: + - button "Expand or collapse" [expanded] + - heading "Details" [level=2] + - paragraph: Nested read-only content" +`; diff --git a/packages/core/src/blocks/Heading/block.test.ts b/packages/core/src/blocks/Heading/block.test.ts new file mode 100644 index 0000000000..92c92ea562 --- /dev/null +++ b/packages/core/src/blocks/Heading/block.test.ts @@ -0,0 +1,100 @@ +import { type PartialBlock } from "@blocknote/core"; +import { + openEditor, + checkAriaSnapshot, + pressKey, +} from "@root/tests/a11y/helpers.js"; +import { expect, test, vi, afterEach } from "vite-plus/test"; +import { focusToggleHeading } from "./testHelpers.js"; +import { page } from "vite-plus/test/browser"; + +const toggleHeadingDocument: PartialBlock[] = [ + { + id: "read-only-toggle-heading", + type: "heading", + props: { level: 2, isToggleable: true }, + content: "Details", + children: [{ type: "paragraph", content: "Nested read-only content" }], + }, +]; + +test.describe("behavior", () => { + afterEach(() => vi.restoreAllMocks()); + + test("read-only toggle heading: Tab reaches the toggle and Enter expands/collapses it", async () => { + localStorage.removeItem("toggle-read-only-toggle-heading"); + await openEditor(toggleHeadingDocument, { editable: false }); + const setItem = vi.spyOn(Storage.prototype, "setItem"); + await focusToggleHeading(); + await pressKey("Enter"); + expect(setItem).toHaveBeenCalledExactlyOnceWith( + "toggle-read-only-toggle-heading", + "true", + ); + setItem.mockClear(); + await pressKey("Enter"); + expect(setItem).toHaveBeenCalledExactlyOnceWith( + "toggle-read-only-toggle-heading", + "false", + ); + }); +}); + +test.describe("keyboard", () => { + // Same navigation as focusToggleHeading, with focus and visibility assertions. + async function focusToggleHeadingWithAssertions() { + const toggleButton = page.getByRole("button", { + name: "Expand or collapse", + exact: true, + }); + await expect.element(toggleButton).toBeVisible(); + + while (document.activeElement !== toggleButton.element()) { + await pressKey("Tab"); + } + await expect.element(toggleButton).toHaveFocus(); + + return toggleButton; + } + // 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. + }); + + // The toggle heading focus indicator is not visually correct yet. + // TODO: Fix the focus appearance and regenerate the visual baseline before enabling. + test.skip("read-only toggle heading: Tab reaches the toggle and Enter expands/collapses it", async () => { + localStorage.removeItem("toggle-read-only-toggle-heading"); + await openEditor(toggleHeadingDocument, { editable: false }); + const toggleButton = await focusToggleHeadingWithAssertions(); + await expect + .element(page.getByRole("textbox")) + .toMatchScreenshot("toggle-heading-focused.png", {}); + await pressKey("Enter"); + await expect.element(toggleButton).toHaveFocus(); + await pressKey("Enter"); + await expect.element(toggleButton).toHaveFocus(); + }); +}); + +test.describe("ARIA", () => { + test("read-only toggle heading: Tab reaches the toggle and Enter expands/collapses it", async () => { + localStorage.removeItem("toggle-read-only-toggle-heading"); + await openEditor(toggleHeadingDocument, { editable: false }); + await focusToggleHeading(); + const editor = page.getByRole("textbox"); + await checkAriaSnapshot(editor, "collapsed"); + await pressKey("Enter"); + await checkAriaSnapshot(editor, "expanded"); + await pressKey("Enter"); + await checkAriaSnapshot(editor, "collapsed again"); + }); +}); diff --git a/packages/core/src/blocks/Heading/testHelpers.ts b/packages/core/src/blocks/Heading/testHelpers.ts new file mode 100644 index 0000000000..62800b9ffd --- /dev/null +++ b/packages/core/src/blocks/Heading/testHelpers.ts @@ -0,0 +1,16 @@ +import { page } from "vite-plus/test/browser"; +import { pressKey } from "@root/tests/a11y/helpers.js"; + +// Start from "Before editor" after openEditor(). Keep these navigation steps +// identical to focusToggleHeadingWithAssertions in the keyboard suite. +export async function focusToggleHeading() { + const toggleButton = page.getByRole("button", { + name: "Expand or collapse", + exact: true, + }); + while (document.activeElement !== toggleButton.element()) { + await pressKey("Tab"); + } + + return toggleButton; +} 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 a7f9fb44ba..1d0b6597ee 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/**/*.screen-reader.test.ts", + "src/blocks/Heading/block.test.ts", + "src/blocks/Heading/testHelpers.ts" + ] } diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts index 3610ce8929..1159aa540f 100644 --- a/packages/core/vite.config.ts +++ b/packages/core/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.*", + "./src/blocks/Heading/block.test.ts", + "**/*.axe.test.ts", + "**/*.screen-reader.test.ts", + ], }, plugins: [webpackStats()], build: { diff --git a/packages/mantine/src/toolbar/ToolbarSelect.tsx b/packages/mantine/src/toolbar/ToolbarSelect.tsx index 138bfc2654..0b7c5bb79a 100644 --- a/packages/mantine/src/toolbar/ToolbarSelect.tsx +++ b/packages/mantine/src/toolbar/ToolbarSelect.tsx @@ -39,6 +39,9 @@ export const ToolbarSelect = forwardRef< exitDuration: 0, }} disabled={isDisabled} + // Item actions restore editor focus. Mantine's deferred return to the + // trigger would steal it back after the action has finished. + returnFocus={false} // Do not move focus to the dropdown when requested (mobile), as it blurs // the editor's contentEditable and dismisses the on-screen keyboard. trapFocus={preventFocusOnOpen ? false : undefined} diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbar.screen-reader.test.ts b/packages/react/src/components/FormattingToolbar/FormattingToolbar.screen-reader.test.ts new file mode 100644 index 0000000000..52e3d6713d --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/FormattingToolbar.screen-reader.test.ts @@ -0,0 +1,29 @@ +import type { PartialBlock } from "@blocknote/core"; +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 { focusToolbarItem } from "./testHelpers.js"; + +// The middle paragraph is selected; its neighbours provide navigation context. +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" }, +]; + +afterEach(saveTranscript); + +// Record selection → toolbar → Escape → editor. +// Individual toolbar item actions and focus rings are covered by the browser tests. +// Screen reader announcements do not clearly communicate entering the toolbar. +test.skip("formatting toolbar: open, focus, and close", async () => { + await openEditor(toolbarDocument); + const { toolbar } = await focusToolbarItem("Paragraph"); + await pressKey("Escape"); + await expect.element(toolbar).not.toBeInTheDocument(); + await expect.element(page.getByRole("textbox")).toHaveFocus(); +}); diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbar.test.ts b/packages/react/src/components/FormattingToolbar/FormattingToolbar.test.ts new file mode 100644 index 0000000000..77f04ebb5b --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/FormattingToolbar.test.ts @@ -0,0 +1,451 @@ +import { type PartialBlock } from "@blocknote/core"; +import { expect, test, vi } from "vite-plus/test"; +import { + openEditor, + pressKey, + checkAriaSnapshot, + checkAxe, + selectToLineEndKey, +} from "@root/tests/a11y/helpers.js"; +import { focusToolbarItem, checkScreenshotWithPadding } from "./testHelpers.js"; +import { page } from "vite-plus/test/browser"; + +// The middle paragraph is selected; its neighbours provide navigation context. +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" }, +]; + +const nestedToolbarDocument: PartialBlock[] = [ + { + id: "preceding", + type: "paragraph", + content: "Preceding paragraph", + children: [ + { id: "format", type: "paragraph", content: "Format this text" }, + ], + }, +]; + +// Left alignment must change the selected paragraph rather than reapply its default. +const centeredToolbarDocument: PartialBlock[] = [ + { id: "preceding", type: "paragraph", content: "Preceding paragraph" }, + { + id: "format", + type: "paragraph", + props: { textAlignment: "center" }, + content: "Format this text", + }, + { id: "following", type: "paragraph", content: "Following paragraph" }, +]; + +test.describe("behavior", () => { + test("formatting toolbar: Bold", async ({ onTestFinished }) => { + const editor = await openEditor(toolbarDocument); + await focusToolbarItem("Bold"); + const onChange = vi.fn(); + onTestFinished(editor.onChange(onChange)); + await pressKey("Enter"); + expect(onChange).toHaveBeenCalled(); + }); + + test("formatting toolbar: Italic", async ({ onTestFinished }) => { + const editor = await openEditor(toolbarDocument); + await focusToolbarItem("Italic"); + const onChange = vi.fn(); + onTestFinished(editor.onChange(onChange)); + await pressKey("Enter"); + expect(onChange).toHaveBeenCalled(); + }); + + test("formatting toolbar: Underline", async ({ onTestFinished }) => { + const editor = await openEditor(toolbarDocument); + await focusToolbarItem("Underline"); + const onChange = vi.fn(); + onTestFinished(editor.onChange(onChange)); + await pressKey("Enter"); + expect(onChange).toHaveBeenCalled(); + }); + + test("formatting toolbar: Strike", async ({ onTestFinished }) => { + const editor = await openEditor(toolbarDocument); + await focusToolbarItem("Strike"); + const onChange = vi.fn(); + onTestFinished(editor.onChange(onChange)); + await pressKey("Enter"); + expect(onChange).toHaveBeenCalled(); + }); + + test("formatting toolbar: Align text left", async ({ onTestFinished }) => { + const editor = await openEditor(centeredToolbarDocument); + await focusToolbarItem("Align text left"); + const onChange = vi.fn(); + onTestFinished(editor.onChange(onChange)); + await pressKey("Enter"); + expect(onChange).toHaveBeenCalled(); + }); + + test("formatting toolbar: Align text center", async ({ onTestFinished }) => { + const editor = await openEditor(toolbarDocument); + await focusToolbarItem("Align text center"); + const onChange = vi.fn(); + onTestFinished(editor.onChange(onChange)); + await pressKey("Enter"); + expect(onChange).toHaveBeenCalled(); + }); + + test("formatting toolbar: Align text right", async ({ onTestFinished }) => { + const editor = await openEditor(toolbarDocument); + await focusToolbarItem("Align text right"); + const onChange = vi.fn(); + onTestFinished(editor.onChange(onChange)); + await pressKey("Enter"); + expect(onChange).toHaveBeenCalled(); + }); + + test("formatting toolbar: Colors", async ({ onTestFinished }) => { + const editor = await openEditor(toolbarDocument); + await focusToolbarItem("Colors"); + await pressKey("Enter"); + const menu = page.getByRole("menu"); + await expect.element(menu).toBeVisible(); + // Mantine initially focuses a placeholder before the first menu item. + await expect + .element(menu.element().querySelector("[data-autofocus]")) + .toHaveFocus(); + await pressKey("ArrowDown"); + await pressKey("ArrowDown"); + const onChange = vi.fn(); + onTestFinished(editor.onChange(onChange)); + await pressKey("Enter"); + expect(onChange).toHaveBeenCalled(); + }); + + test("formatting toolbar: Nest block", async ({ onTestFinished }) => { + const editor = await openEditor(toolbarDocument); + await focusToolbarItem("Nest block"); + const onChange = vi.fn(); + onTestFinished(editor.onChange(onChange)); + await pressKey("Enter"); + expect(onChange).toHaveBeenCalled(); + }); + + test("formatting toolbar: Unnest block", async ({ onTestFinished }) => { + const editor = await openEditor(nestedToolbarDocument); + await focusToolbarItem("Unnest block"); + const onChange = vi.fn(); + onTestFinished(editor.onChange(onChange)); + await pressKey("Enter"); + expect(onChange).toHaveBeenCalled(); + }); + + test("formatting toolbar: Paragraph", async ({ onTestFinished }) => { + const editor = await openEditor(toolbarDocument); + await focusToolbarItem("Paragraph"); + await pressKey("Enter"); + const menu = page.getByRole("menu"); + await expect.element(menu).toBeVisible(); + // Mantine initially focuses a placeholder before the first menu item. + await expect + .element(menu.element().querySelector("[data-autofocus]")) + .toHaveFocus(); + await pressKey("ArrowDown"); + await pressKey("ArrowDown"); + const onChange = vi.fn(); + onTestFinished(editor.onChange(onChange)); + await pressKey("Enter"); + expect(onChange).toHaveBeenCalled(); + }); +}); + +test.describe("keyboard", () => { + // Same navigation as the shared focusToolbarItem, with focus assertions. + async function focusToolbarItemWithAssertions(toolbarItem: string) { + await pressKey("Tab"); + const editor = page.getByRole("textbox"); + await expect.element(editor).toHaveFocus(); + await pressKey("ArrowDown"); + await pressKey(selectToLineEndKey); + const toolbar = page.getByRole("toolbar"); + await expect.element(toolbar).toBeVisible(); + await pressKey("Tab"); + await expect + .poll(() => toolbar.element().contains(document.activeElement)) + .toBe(true); + const target = toolbar.getByRole("button", { + name: toolbarItem, + exact: true, + }); + while (document.activeElement !== target.element()) { + await pressKey("Tab"); + } + await expect.element(target).toHaveFocus(); + return { toolbar, button: target }; + } + + async function checkToolbarItemFocus(toolbarItem: string) { + const { button } = await focusToolbarItemWithAssertions(toolbarItem); + await checkScreenshotWithPadding( + button, + `focus-${toolbarItem.toLowerCase().replaceAll(" ", "-")}.png`, + ); + } + + async function checkToolbarButtonKeyboard(toolbarButton: string) { + await checkToolbarItemFocus(toolbarButton); + await pressKey("Enter"); + await expect.element(page.getByRole("textbox")).toHaveFocus(); + } + + async function checkToolbarMenuKeyboard( + toolbarMenu: string, + item: () => ReturnType, + screenshot?: string, + ) { + await checkToolbarItemFocus(toolbarMenu); + await pressKey("Enter"); + const menu = page.getByRole("menu"); + await expect.element(menu).toBeVisible(); + // Mantine initially focuses a placeholder before the first menu item. + await expect + .element(menu.element().querySelector("[data-autofocus]")) + .toHaveFocus(); + await pressKey("ArrowDown"); + await expect.element(menu.getByRole("menuitem").first()).toHaveFocus(); + if (screenshot) { + await checkScreenshotWithPadding(menu, screenshot); + } + await pressKey("ArrowDown"); + await expect.element(item()).toHaveFocus(); + await pressKey("Enter"); + await expect.element(page.getByRole("textbox")).toHaveFocus(); + } + + async function checkToolbarPopoverKeyboard( + toolbarPopover: string, + input: ReturnType, + ) { + await checkToolbarItemFocus(toolbarPopover); + await pressKey("Enter"); + await expect.element(input).toHaveFocus(); + await pressKey("Escape"); + await expect.element(input).not.toBeInTheDocument(); + await expect.element(page.getByRole("textbox")).toHaveFocus(); + } + + test("formatting toolbar: Paragraph", async () => { + await openEditor(toolbarDocument); + await checkToolbarMenuKeyboard( + "Paragraph", + () => page.getByRole("menuitem", { name: "Heading 1", exact: true }), + "focus-block-type-menu-item.png", + ); + }); + + test("formatting toolbar: Bold", async () => { + await openEditor(toolbarDocument); + await checkToolbarButtonKeyboard("Bold"); + }); + + test("formatting toolbar: Italic", async () => { + await openEditor(toolbarDocument); + await checkToolbarButtonKeyboard("Italic"); + }); + + test("formatting toolbar: Underline", async () => { + await openEditor(toolbarDocument); + await checkToolbarButtonKeyboard("Underline"); + }); + + test("formatting toolbar: Strike", async () => { + await openEditor(toolbarDocument); + await checkToolbarButtonKeyboard("Strike"); + }); + + test("formatting toolbar: Align text left", async () => { + await openEditor(toolbarDocument); + await checkToolbarButtonKeyboard("Align text left"); + }); + + test("formatting toolbar: Align text center", async () => { + await openEditor(toolbarDocument); + await checkToolbarButtonKeyboard("Align text center"); + }); + + test("formatting toolbar: Align text right", async () => { + await openEditor(toolbarDocument); + await checkToolbarButtonKeyboard("Align text right"); + }); + + test("formatting toolbar: Colors", async () => { + await openEditor(toolbarDocument); + await checkToolbarMenuKeyboard("Colors", () => + page.elementLocator( + document.querySelector('[data-test="text-color-gray"]')!, + ), + ); + }); + + test("formatting toolbar: Nest block", async () => { + await openEditor(toolbarDocument); + await checkToolbarButtonKeyboard("Nest block"); + }); + + test("formatting toolbar: Unnest block", async () => { + await openEditor(nestedToolbarDocument); + await checkToolbarButtonKeyboard("Unnest block"); + }); + + test("formatting toolbar: Create link", async () => { + await openEditor(toolbarDocument); + await checkToolbarPopoverKeyboard( + "Create link", + page.getByPlaceholder("Edit URL"), + ); + }); + + test("formatting toolbar: Escape dismisses block type select and returns to the editor", async () => { + await openEditor(toolbarDocument); + const { toolbar } = await focusToolbarItemWithAssertions("Paragraph"); + await pressKey("Enter"); + const menu = page.getByRole("menu"); + await expect.element(menu).toBeVisible(); + // Mantine initially focuses a placeholder before the first menu item. + await expect + .element(menu.element().querySelector("[data-autofocus]")) + .toHaveFocus(); + await pressKey("ArrowDown"); + await expect.element(menu.getByRole("menuitem").first()).toHaveFocus(); + await pressKey("Escape"); + await expect.element(menu).not.toBeInTheDocument(); + await expect.element(toolbar).not.toBeInTheDocument(); + await expect.element(page.getByRole("textbox")).toHaveFocus(); + }); +}); + +test.describe("ARIA", () => { + async function checkToolbarButtonAria(toolbarButton: string) { + const { button } = await focusToolbarItem(toolbarButton); + await checkAriaSnapshot(button, "before activation"); + await pressKey("Enter"); + await checkAriaSnapshot(button, "after activation"); + } + + async function checkToolbarMenuAria( + toolbarMenu: string, + activatedName: string, + ) { + const { toolbar, button } = await focusToolbarItem(toolbarMenu); + await checkAriaSnapshot(button, "before activation"); + await pressKey("Enter"); + const menu = page.getByRole("menu"); + await expect.element(menu).toBeVisible(); + // Mantine initially focuses a placeholder before the first menu item. + await expect + .element(menu.element().querySelector("[data-autofocus]")) + .toHaveFocus(); + await pressKey("ArrowDown"); + await pressKey("ArrowDown"); + await pressKey("Enter"); + await checkAriaSnapshot( + toolbar.getByRole("button", { name: activatedName, exact: true }), + "after activation", + ); + } + + test("formatting toolbar: complete accessibility tree", async () => { + await openEditor(toolbarDocument); + const { toolbar } = await focusToolbarItem("Paragraph"); + await checkAriaSnapshot(toolbar, "toolbar"); + }); + + test("formatting toolbar: Paragraph", async () => { + await openEditor(toolbarDocument); + await checkToolbarMenuAria("Paragraph", "Heading 1"); + }); + + test("formatting toolbar: Bold", async () => { + await openEditor(toolbarDocument); + await checkToolbarButtonAria("Bold"); + }); + + test("formatting toolbar: Italic", async () => { + await openEditor(toolbarDocument); + await checkToolbarButtonAria("Italic"); + }); + + test("formatting toolbar: Underline", async () => { + await openEditor(toolbarDocument); + await checkToolbarButtonAria("Underline"); + }); + + test("formatting toolbar: Strike", async () => { + await openEditor(toolbarDocument); + await checkToolbarButtonAria("Strike"); + }); + + test("formatting toolbar: Align text left", async () => { + await openEditor(toolbarDocument); + await checkToolbarButtonAria("Align text left"); + }); + + test("formatting toolbar: Align text center", async () => { + await openEditor(toolbarDocument); + await checkToolbarButtonAria("Align text center"); + }); + + test("formatting toolbar: Align text right", async () => { + await openEditor(toolbarDocument); + await checkToolbarButtonAria("Align text right"); + }); + + test("formatting toolbar: Colors", async () => { + await openEditor(toolbarDocument); + await checkToolbarMenuAria("Colors", "Colors"); + }); + + test("formatting toolbar: Nest block", async () => { + await openEditor(toolbarDocument); + await checkToolbarButtonAria("Nest block"); + }); + + test("formatting toolbar: Unnest block", async () => { + await openEditor(nestedToolbarDocument); + await checkToolbarButtonAria("Unnest block"); + }); +}); + +test.describe("axe", () => { + test("formatting toolbar: axe", async ({ annotate }) => { + await openEditor(toolbarDocument); + await focusToolbarItem("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 focusToolbarItem("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 focusToolbarItem("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 focusToolbarItem("Create link"); + await pressKey("Enter"); + await expect.element(page.getByPlaceholder("Edit URL")).toBeVisible(); + await checkAxe(annotate, ".bn-form-popover"); + }); +}); diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-align-text-center-chromium-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-align-text-center-chromium-linux.png new file mode 100644 index 0000000000..2bed7d7088 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-align-text-center-chromium-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-align-text-center-firefox-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-align-text-center-firefox-linux.png new file mode 100644 index 0000000000..a1f35d3d3b Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-align-text-center-firefox-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-align-text-center-webkit-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-align-text-center-webkit-linux.png new file mode 100644 index 0000000000..ce24a90740 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-align-text-center-webkit-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-align-text-left-chromium-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-align-text-left-chromium-linux.png new file mode 100644 index 0000000000..a8ad6bdcc9 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-align-text-left-chromium-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-align-text-left-firefox-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-align-text-left-firefox-linux.png new file mode 100644 index 0000000000..84128f5e1d Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-align-text-left-firefox-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-align-text-left-webkit-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-align-text-left-webkit-linux.png new file mode 100644 index 0000000000..d5fdd970f2 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-align-text-left-webkit-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-align-text-right-chromium-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-align-text-right-chromium-linux.png new file mode 100644 index 0000000000..4f34125031 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-align-text-right-chromium-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-align-text-right-firefox-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-align-text-right-firefox-linux.png new file mode 100644 index 0000000000..d2ae9c80ff Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-align-text-right-firefox-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-align-text-right-webkit-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-align-text-right-webkit-linux.png new file mode 100644 index 0000000000..4fc011dd65 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-align-text-right-webkit-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-block-type-menu-item-chromium-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-block-type-menu-item-chromium-linux.png new file mode 100644 index 0000000000..2a4d470dd4 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-block-type-menu-item-chromium-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-block-type-menu-item-firefox-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-block-type-menu-item-firefox-linux.png new file mode 100644 index 0000000000..6efc935b87 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-block-type-menu-item-firefox-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-block-type-menu-item-webkit-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-block-type-menu-item-webkit-linux.png new file mode 100644 index 0000000000..e1ffdcb383 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-block-type-menu-item-webkit-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-bold-chromium-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-bold-chromium-linux.png new file mode 100644 index 0000000000..863de6f289 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-bold-chromium-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-bold-firefox-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-bold-firefox-linux.png new file mode 100644 index 0000000000..41f065d000 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-bold-firefox-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-bold-webkit-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-bold-webkit-linux.png new file mode 100644 index 0000000000..1663f88585 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-bold-webkit-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-colors-chromium-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-colors-chromium-linux.png new file mode 100644 index 0000000000..77adf981a9 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-colors-chromium-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-colors-firefox-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-colors-firefox-linux.png new file mode 100644 index 0000000000..b5e35a1c40 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-colors-firefox-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-colors-webkit-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-colors-webkit-linux.png new file mode 100644 index 0000000000..4087acee8b Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-colors-webkit-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-create-link-chromium-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-create-link-chromium-linux.png new file mode 100644 index 0000000000..936d027320 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-create-link-chromium-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-create-link-firefox-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-create-link-firefox-linux.png new file mode 100644 index 0000000000..4425604118 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-create-link-firefox-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-create-link-webkit-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-create-link-webkit-linux.png new file mode 100644 index 0000000000..f4aaf94b88 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-create-link-webkit-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-italic-chromium-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-italic-chromium-linux.png new file mode 100644 index 0000000000..17965efd9a Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-italic-chromium-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-italic-firefox-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-italic-firefox-linux.png new file mode 100644 index 0000000000..ca2852cd83 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-italic-firefox-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-italic-webkit-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-italic-webkit-linux.png new file mode 100644 index 0000000000..f7e67a814c Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-italic-webkit-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-nest-block-chromium-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-nest-block-chromium-linux.png new file mode 100644 index 0000000000..25d24f8b2d Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-nest-block-chromium-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-nest-block-firefox-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-nest-block-firefox-linux.png new file mode 100644 index 0000000000..b4a58c6086 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-nest-block-firefox-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-nest-block-webkit-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-nest-block-webkit-linux.png new file mode 100644 index 0000000000..9ef5f09b58 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-nest-block-webkit-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-paragraph-chromium-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-paragraph-chromium-linux.png new file mode 100644 index 0000000000..a0d70f9e0a Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-paragraph-chromium-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-paragraph-firefox-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-paragraph-firefox-linux.png new file mode 100644 index 0000000000..98161a386c Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-paragraph-firefox-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-paragraph-webkit-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-paragraph-webkit-linux.png new file mode 100644 index 0000000000..125be8d4fd Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-paragraph-webkit-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-strike-chromium-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-strike-chromium-linux.png new file mode 100644 index 0000000000..4f061217a0 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-strike-chromium-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-strike-firefox-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-strike-firefox-linux.png new file mode 100644 index 0000000000..1d6a3927ab Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-strike-firefox-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-strike-webkit-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-strike-webkit-linux.png new file mode 100644 index 0000000000..f8dbc1e3c7 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-strike-webkit-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-underline-chromium-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-underline-chromium-linux.png new file mode 100644 index 0000000000..53ea7a986f Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-underline-chromium-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-underline-firefox-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-underline-firefox-linux.png new file mode 100644 index 0000000000..ef4e8b0ca9 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-underline-firefox-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-underline-webkit-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-underline-webkit-linux.png new file mode 100644 index 0000000000..e98ec507aa Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-underline-webkit-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-unnest-block-chromium-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-unnest-block-chromium-linux.png new file mode 100644 index 0000000000..b3dda38230 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-unnest-block-chromium-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-unnest-block-firefox-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-unnest-block-firefox-linux.png new file mode 100644 index 0000000000..243195046e Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-unnest-block-firefox-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-unnest-block-webkit-linux.png b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-unnest-block-webkit-linux.png new file mode 100644 index 0000000000..1d973920b7 Binary files /dev/null and b/packages/react/src/components/FormattingToolbar/__screenshots__/FormattingToolbar.test.ts/focus-unnest-block-webkit-linux.png differ diff --git a/packages/react/src/components/FormattingToolbar/__snapshots__/FormattingToolbar.test.ts.snap b/packages/react/src/components/FormattingToolbar/__snapshots__/FormattingToolbar.test.ts.snap new file mode 100644 index 0000000000..d1a95798a1 --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/__snapshots__/FormattingToolbar.test.ts.snap @@ -0,0 +1,135 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ARIA > formatting toolbar: Align text center > after activation 1`] = ` +"- button "Align text center" [pressed]: + - img" +`; + +exports[`ARIA > formatting toolbar: Align text center > before activation 1`] = ` +"- button "Align text center": + - img" +`; + +exports[`ARIA > formatting toolbar: Align text left > after activation 1`] = ` +"- button "Align text left" [pressed]: + - img" +`; + +exports[`ARIA > formatting toolbar: Align text left > before activation 1`] = ` +"- button "Align text left" [pressed]: + - img" +`; + +exports[`ARIA > formatting toolbar: Align text right > after activation 1`] = ` +"- button "Align text right" [pressed]: + - img" +`; + +exports[`ARIA > formatting toolbar: Align text right > before activation 1`] = ` +"- button "Align text right": + - img" +`; + +exports[`ARIA > formatting toolbar: Bold > after activation 1`] = ` +"- button "Bold" [pressed]: + - img" +`; + +exports[`ARIA > formatting toolbar: Bold > before activation 1`] = ` +"- button "Bold": + - img" +`; + +exports[`ARIA > formatting toolbar: Colors > after activation 1`] = `"- button "Colors": A"`; + +exports[`ARIA > formatting toolbar: Colors > before activation 1`] = `"- button "Colors": A"`; + +exports[`ARIA > formatting toolbar: Italic > after activation 1`] = ` +"- button "Italic" [pressed]: + - img" +`; + +exports[`ARIA > formatting toolbar: Italic > before activation 1`] = ` +"- button "Italic": + - img" +`; + +exports[`ARIA > formatting toolbar: Nest block > after activation 1`] = ` +"- button "Nest block" [disabled]: + - img" +`; + +exports[`ARIA > formatting toolbar: Nest block > before activation 1`] = ` +"- button "Nest block": + - img" +`; + +exports[`ARIA > formatting toolbar: Paragraph > after activation 1`] = ` +"- button "Heading 1": + - img + - text: Heading 1" +`; + +exports[`ARIA > formatting toolbar: Paragraph > before activation 1`] = ` +"- button "Paragraph": + - img + - text: Paragraph" +`; + +exports[`ARIA > formatting toolbar: Strike > after activation 1`] = ` +"- button "Strike" [pressed]: + - img" +`; + +exports[`ARIA > formatting toolbar: Strike > before activation 1`] = ` +"- button "Strike": + - img" +`; + +exports[`ARIA > formatting toolbar: Underline > after activation 1`] = ` +"- button "Underline" [pressed]: + - img" +`; + +exports[`ARIA > formatting toolbar: Underline > before activation 1`] = ` +"- button "Underline": + - img" +`; + +exports[`ARIA > formatting toolbar: Unnest block > after activation 1`] = ` +"- button "Unnest block" [disabled]: + - img" +`; + +exports[`ARIA > formatting toolbar: Unnest block > before activation 1`] = ` +"- button "Unnest block": + - img" +`; + +exports[`ARIA > formatting toolbar: complete accessibility tree > toolbar 1`] = ` +"- toolbar: + - button "Paragraph": + - img + - text: Paragraph + - button "Bold": + - img + - button "Italic": + - img + - button "Underline": + - img + - button "Strike": + - img + - button "Align text left" [pressed]: + - img + - button "Align text center": + - img + - button "Align text right": + - img + - button "Colors": A + - button "Nest block": + - img + - button "Unnest block" [disabled]: + - img + - button "Create link": + - img" +`; diff --git a/packages/react/src/components/FormattingToolbar/testHelpers.ts b/packages/react/src/components/FormattingToolbar/testHelpers.ts new file mode 100644 index 0000000000..85e8e86d12 --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/testHelpers.ts @@ -0,0 +1,46 @@ +import { expect } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; +import { pressKey, selectToLineEndKey } from "@root/tests/a11y/helpers.js"; + +// Start from "Before editor" after openEditor(). Keep the navigation steps +// identical to focusToolbarItemWithAssertions in the keyboard suite. +export async function focusToolbarItem(toolbarItem: string) { + await pressKey("Tab"); + await pressKey("ArrowDown"); + await pressKey(selectToLineEndKey); + const toolbar = page.getByRole("toolbar"); + await pressKey("Tab"); + const target = toolbar.getByRole("button", { + name: toolbarItem, + exact: true, + }); + while (document.activeElement !== target.element()) { + await pressKey("Tab"); + } + return { toolbar, button: target }; +} + +// Capture the surrounding pixels without changing the item's layout or focus. +export async function checkScreenshotWithPadding( + target: ReturnType, + name: string, +) { + const padding = 2; + const bounds = target.element().getBoundingClientRect(); + const capture = document.createElement("div"); + capture.inert = true; + Object.assign(capture.style, { + position: "absolute", + pointerEvents: "none", + left: `${bounds.left + window.scrollX - padding}px`, + top: `${bounds.top + window.scrollY - padding}px`, + width: `${bounds.width + padding * 2}px`, + height: `${bounds.height + padding * 2}px`, + }); + document.body.append(capture); + try { + await expect.element(capture).toMatchScreenshot(name, {}); + } finally { + capture.remove(); + } +} diff --git a/packages/react/src/editor/BlockNoteView.screen-reader.test.ts b/packages/react/src/editor/BlockNoteView.screen-reader.test.ts new file mode 100644 index 0000000000..61244d885b --- /dev/null +++ b/packages/react/src/editor/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 "./testHelpers.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/BlockNoteView.test.ts b/packages/react/src/editor/BlockNoteView.test.ts new file mode 100644 index 0000000000..d8ededa298 --- /dev/null +++ b/packages/react/src/editor/BlockNoteView.test.ts @@ -0,0 +1,532 @@ +import { + COLORS_DEFAULT, + defaultBlockSpecs, + type PartialBlock, + type PartialTableCell, + type DefaultInlineContentSchema, + type DefaultStyleSchema, + type PropSchema, +} from "@blocknote/core"; +import { + navigateDefaultSchemaDocument, + focusReadOnlyLink, +} from "./testHelpers.js"; +import { + defaultSchemaDocument, + blockExamples, +} from "@root/tests/a11y/documents.js"; +import { expect, test } from "vite-plus/test"; +import { page, server } from "vite-plus/test/browser"; +import { + openEditor, + readSelection, + checkAriaSnapshot, + checkAxe, + pressKey, +} from "@root/tests/a11y/helpers.js"; +import { screenshotFull } from "@root/tests/src/utils/screenshotFull.js"; + +const readOnlyLinkDocument: PartialBlock[] = [ + { + type: "paragraph", + content: [ + { + type: "link", + href: "https://www.blocknotejs.org/", + content: "Documentation", + }, + ], + }, +]; + +const editorEscapeDocument: PartialBlock[] = [ + { type: "paragraph", content: "Editable text" }, +]; + +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(); + +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", + }, + ] + : [], + })), + ), + ), +]; + +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 }; +} + +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 }), + ], + })), + ], + }, + }, +]; + +test.describe("keyboard", () => { + // Same navigation as navigateDefaultSchemaDocument, with selection assertions. + async function navigateDefaultSchemaDocumentWithAssertions() { + await pressKey("Tab"); + await expect.element(page.getByRole("textbox")).toHaveFocus(); + + // Follow the document until its endpoint; an unreachable endpoint times out. + const visitedTableCells = new Set(); + while (readSelection().blockId !== "end") { + 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. + if (tableCell && visitedTableCells.size < 4) { + await pressKey("ArrowRight"); + } else { + await pressKey("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", + ); + while (readSelection().blockId !== "start") { + await pressKey("ArrowUp"); + } + expect(readSelection().blockId, "ArrowUp returns to the first block").toBe( + "start", + ); + } + + async function focusReadOnlyLinkWithAssertions() { + const link = page.getByRole("link", { name: "Documentation", exact: true }); + await expect.element(link).toBeVisible(); + while (document.activeElement !== link.element()) { + await pressKey("Tab"); + } + await expect.element(link).toHaveFocus(); + await expect + .element(link) + .toMatchScreenshot("read-only-link-focused.png", {}); + return link; + } + + test("default schema: arrow-key document navigation", async () => { + await openEditor(defaultSchemaDocument); + await navigateDefaultSchemaDocumentWithAssertions(); + }); + + // 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. + }); + + // TODO: WebKit resets the tab position after Escape blurs the editor, so Tab + // focuses "Before editor" instead of "After editor". + test.skipIf(server.browser === "webkit")( + "Escape allows Tab to leave the editor", + async () => { + await openEditor(editorEscapeDocument); + 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. + }, + ); + + // Focused inline links do not yet have a suitable visual focus indicator. + // TODO: Fix the focus appearance and regenerate the visual baseline before enabling. + test.skip("read-only editor: Tab reaches inline links and leaves the editor", async () => { + await openEditor(readOnlyLinkDocument, { editable: false }); + const link = await focusReadOnlyLinkWithAssertions(); + 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. + }); +}); + +test.describe("ARIA", () => { + test("default schema: arrow-key document navigation", async () => { + await openEditor(defaultSchemaDocument); + await navigateDefaultSchemaDocument(); + await checkAriaSnapshot(page.getByRole("textbox"), "default schema"); + }); + + test("read-only editor: Tab reaches inline links and leaves the editor", async () => { + await openEditor(readOnlyLinkDocument, { editable: false }); + await focusReadOnlyLink(); + await checkAriaSnapshot(page.getByRole("textbox"), "read-only editor"); + }); +}); + +test.describe("axe", () => { + 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); + }); + } +}); + +test.describe("visual", () => { + 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. + 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")); + } + + 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 }, + // Video decoding differs across platforms; keep captions and layout in scope. + screenshotOptions: { + mask: [page.elementLocator(document.querySelector("video")!)], + }, + }); + }); + + 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 }, + screenshotOptions: { + mask: [page.elementLocator(document.querySelector("video")!)], + }, + }, + ); + }); + }); +}); diff --git a/packages/react/src/editor/__screenshots__/BlockNoteView.test.ts/default-schema-text-size-200-percent-chromium-linux.png b/packages/react/src/editor/__screenshots__/BlockNoteView.test.ts/default-schema-text-size-200-percent-chromium-linux.png new file mode 100644 index 0000000000..f8e7420c3f Binary files /dev/null and b/packages/react/src/editor/__screenshots__/BlockNoteView.test.ts/default-schema-text-size-200-percent-chromium-linux.png differ diff --git a/packages/react/src/editor/__screenshots__/BlockNoteView.test.ts/default-schema-text-size-200-percent-firefox-linux.png b/packages/react/src/editor/__screenshots__/BlockNoteView.test.ts/default-schema-text-size-200-percent-firefox-linux.png new file mode 100644 index 0000000000..23dfc09b3b Binary files /dev/null and b/packages/react/src/editor/__screenshots__/BlockNoteView.test.ts/default-schema-text-size-200-percent-firefox-linux.png differ diff --git a/packages/react/src/editor/__screenshots__/BlockNoteView.test.ts/default-schema-text-size-200-percent-webkit-linux.png b/packages/react/src/editor/__screenshots__/BlockNoteView.test.ts/default-schema-text-size-200-percent-webkit-linux.png new file mode 100644 index 0000000000..195e0b19c8 Binary files /dev/null and b/packages/react/src/editor/__screenshots__/BlockNoteView.test.ts/default-schema-text-size-200-percent-webkit-linux.png differ diff --git a/packages/react/src/editor/__screenshots__/BlockNoteView.test.ts/default-schema-text-spacing-chromium-linux.png b/packages/react/src/editor/__screenshots__/BlockNoteView.test.ts/default-schema-text-spacing-chromium-linux.png new file mode 100644 index 0000000000..b3e735c0d7 Binary files /dev/null and b/packages/react/src/editor/__screenshots__/BlockNoteView.test.ts/default-schema-text-spacing-chromium-linux.png differ diff --git a/packages/react/src/editor/__screenshots__/BlockNoteView.test.ts/default-schema-text-spacing-firefox-linux.png b/packages/react/src/editor/__screenshots__/BlockNoteView.test.ts/default-schema-text-spacing-firefox-linux.png new file mode 100644 index 0000000000..e694db87bf Binary files /dev/null and b/packages/react/src/editor/__screenshots__/BlockNoteView.test.ts/default-schema-text-spacing-firefox-linux.png differ diff --git a/packages/react/src/editor/__screenshots__/BlockNoteView.test.ts/default-schema-text-spacing-webkit-linux.png b/packages/react/src/editor/__screenshots__/BlockNoteView.test.ts/default-schema-text-spacing-webkit-linux.png new file mode 100644 index 0000000000..9935b577a6 Binary files /dev/null and b/packages/react/src/editor/__screenshots__/BlockNoteView.test.ts/default-schema-text-spacing-webkit-linux.png differ diff --git a/packages/react/src/editor/__snapshots__/BlockNoteView.test.ts.snap b/packages/react/src/editor/__snapshots__/BlockNoteView.test.ts.snap new file mode 100644 index 0000000000..c8eb319a18 --- /dev/null +++ b/packages/react/src/editor/__snapshots__/BlockNoteView.test.ts.snap @@ -0,0 +1,63 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ARIA > default schema: arrow-key document navigation > default schema 1`] = ` +"- textbox: + - paragraph: Start of document + - paragraph: + - text: Plain text. + - link "BlockNote documentation": + - /url: https://www.blocknotejs.org/ + - strong: bold. + - emphasis: italic. + - text: underline. strike. + - code: code. + - text: textColor. backgroundColor. + - heading "Heading level one" [level=1] + - text: • + - paragraph: Bullet list item + - text: "1." + - paragraph: Numbered list item + - checkbox + - paragraph: Unchecked task + - button "Expand or collapse" + - paragraph: Toggle list item + - blockquote: Quoted text + - code: const answer = 42; + - separator + - table: + - rowgroup: + - row "Name Description": + - columnheader "Name": + - paragraph: Name + - columnheader "Description": + - paragraph: Description + - row "Example Table cell": + - cell "Example": + - paragraph: Example + - cell "Table cell": + - paragraph: Table cell + - figure "Blue square": + - img "Example image" + - text: Blue square + - figure "A short test tone" + - figure "A flower moving in the breeze" + - img + - paragraph: example.txt + - heading "Heading level 2" [level=2] + - heading "Heading level 3" [level=3] + - heading "Heading level 4" [level=4] + - heading "Heading level 5" [level=5] + - heading "Heading level 6" [level=6] + - button "Expand or collapse" + - heading "Toggle heading" [level=2] + - checkbox [checked] + - paragraph: Completed task + - paragraph: End of document" +`; + +exports[`ARIA > read-only editor: Tab reaches inline links and leaves the editor > read-only editor 1`] = ` +"- textbox: + - paragraph: + - link "Documentation": + - /url: https://www.blocknotejs.org/" +`; diff --git a/packages/react/src/editor/testHelpers.ts b/packages/react/src/editor/testHelpers.ts new file mode 100644 index 0000000000..369646ca9d --- /dev/null +++ b/packages/react/src/editor/testHelpers.ts @@ -0,0 +1,35 @@ +import { page } from "vite-plus/test/browser"; +import { pressKey, readSelection } from "@root/tests/a11y/helpers.js"; + +// Keep navigation identical to navigateDefaultSchemaDocumentWithAssertions. +export async function navigateDefaultSchemaDocument() { + await pressKey("Tab"); + + // Follow the document until its endpoint; an unreachable endpoint times out. + const visitedTableCells = new Set(); + while (readSelection().blockId !== "end") { + 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. + if (tableCell && visitedTableCells.size < 4) { + await pressKey("ArrowRight"); + } else { + await pressKey("ArrowDown"); + } + } + while (readSelection().blockId !== "start") { + await pressKey("ArrowUp"); + } +} + +// The page tab order can include the read-only editor before its inline link. +export async function focusReadOnlyLink() { + const link = page.getByRole("link", { name: "Documentation", exact: true }); + while (document.activeElement !== link.element()) { + await pressKey("Tab"); + } + return link; +} diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json index 795b193d05..ad71b1a160 100644 --- a/packages/react/tsconfig.json +++ b/packages/react/tsconfig.json @@ -22,6 +22,13 @@ "emitDeclarationOnly": true }, "include": ["src"], + "exclude": [ + "src/**/*.screen-reader.test.ts", + "src/editor/BlockNoteView.test.ts", + "src/editor/testHelpers.ts", + "src/components/FormattingToolbar/FormattingToolbar.test.ts", + "src/components/FormattingToolbar/testHelpers.ts" + ], "references": [ { "path": "../core" diff --git a/packages/react/vite.config.ts b/packages/react/vite.config.ts index 337545305b..b5682b3945 100644 --- a/packages/react/vite.config.ts +++ b/packages/react/vite.config.ts @@ -34,7 +34,14 @@ 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.*", + "./src/editor/BlockNoteView.test.ts", + "./src/components/FormattingToolbar/FormattingToolbar.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 1df42384e4..4f107a35cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6716,6 +6716,9 @@ importers: '@blocknote/xl-typst-exporter': specifier: workspace:^ version: link:../packages/xl-typst-exporter + '@guidepup/guidepup': + specifier: ^0.35.0 + version: 0.35.0 '@opentelemetry/api': specifier: 1.10.0 version: 1.10.0 @@ -6761,6 +6764,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 @@ -7972,6 +7978,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: @@ -11958,6 +11967,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==} @@ -14852,6 +14865,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==} @@ -16390,6 +16407,10 @@ packages: resolution: {integrity: sha512-TxyRxk9sTOUg3glxSIY6f0nfuqRll2OEF8TspLgh5mZkLuBgheCn3zClcDSGJ58TvNmiwyCCuat4UajPud/5Og==} engines: {node: '>= 16'} + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} @@ -17948,6 +17969,13 @@ snapshots: next: 16.3.0(@babel/core@7.29.7)(@opentelemetry/api@1.10.0)(@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.1 @@ -21890,6 +21918,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 @@ -25118,6 +25148,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 @@ -27021,6 +27056,8 @@ snapshots: xml-parser-xo@4.1.5: {} + 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..f3cb724626 --- /dev/null +++ b/tests/a11y/commands.ts @@ -0,0 +1,56 @@ +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(); + +export const readAriaSnapshot: BrowserCommand<[selector: string]> = async ( + context, + selector, +) => (await context.frame()).locator(selector).ariaSnapshot(); 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..a02b058279 --- /dev/null +++ b/tests/a11y/helpers.tsx @@ -0,0 +1,159 @@ +import type { BlockNoteEditor, 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 { useEffect } from "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 { waitForEditorFocus } from "./setup.js"; +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 & { + readAriaSnapshot(selector: string): Promise; + startReader(): Promise; + pressKey(key: string): Promise; + readTranscript(): Promise; + stopReader(): Promise; +}; + +function EditorFixture({ + initialContent, + editable, + theme, + onReady, +}: { + initialContent: PartialBlock[]; + editable: boolean; + theme: "light" | "dark"; + onReady?: (editor: BlockNoteEditor) => void; +}) { + const editor = useCreateBlockNote({ initialContent }); + useEffect(() => { + onReady?.(editor); + }, [editor, onReady]); + return ( +
+

BlockNote accessibility fixture

+ + + +
+ ); +} + +export async function openEditor( + initialContent: PartialBlock[], + { + editable = true, + theme = "light", + onReady, + }: { + editable?: boolean; + theme?: "light" | "dark"; + onReady?: (editor: BlockNoteEditor) => void; + } = {}, +) { + let editor!: BlockNoteEditor; + await page.viewport(1280, 900); + await render( + { + editor = value; + onReady?.(value); + }} + />, + ); + 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(); + } + return editor; +} + +export async function pressKey(key: string) { + await waitForEditorFocus(); + return browserCommands.pressKey(key); +} + +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 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([]); +} + +// Use Playwright's semantic snapshot through the provider, and Vitest's +// snapshot storage so these baselines follow the same update policy as tests. +export async function checkAriaSnapshot( + locator: ReturnType, + name: string, +) { + expect( + await browserCommands.readAriaSnapshot(locator.selector), + ).toMatchSnapshot(name); +} + +// 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/run.mjs b/tests/a11y/run.mjs new file mode 100644 index 0000000000..4cfd9e5657 --- /dev/null +++ b/tests/a11y/run.mjs @@ -0,0 +1,59 @@ +// Browser checks run in Docker; --screen-reader runs natively on macOS/Windows. +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +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 { + result = spawnSync( + "bash", + [ + "tests/docker-run.sh", + "-e", + "CI=1", + "--", + "--run", + ...(args[0] && !args[0].startsWith("-") + ? args + : [ + "/Heading/block.test.ts", + "/editor/BlockNoteView.test.ts", + "/FormattingToolbar/FormattingToolbar.test.ts", + ...args, + ]), + ], + { cwd: root, stdio: "inherit" }, + ); +} +if (result.error) { + throw result.error; +} +process.exit(result.status ?? 1); diff --git a/tests/a11y/setup.ts b/tests/a11y/setup.ts new file mode 100644 index 0000000000..fedbe15a1c --- /dev/null +++ b/tests/a11y/setup.ts @@ -0,0 +1,32 @@ +import { afterEach, beforeEach } from "vite-plus/test"; + +let pendingEditorFocus = Promise.resolve(); + +function onEditorFocus(event: FocusEvent) { + if ( + event.target instanceof HTMLElement && + event.target.matches('.bn-editor[contenteditable="true"]') + ) { + // ProseMirror schedules selection reconciliation 20 ms after focus. + // This bubbling listener runs after its focus handler has scheduled that work. + pendingEditorFocus = new Promise((resolve) => setTimeout(resolve, 20)); + } +} + +export async function waitForEditorFocus() { + let pending; + do { + pending = pendingEditorFocus; + await pending; + } while (pending !== pendingEditorFocus); +} + +beforeEach(() => { + document.addEventListener("focusin", onEditorFocus); +}); + +afterEach(async () => { + document.removeEventListener("focusin", onEditorFocus); + await waitForEditorFocus(); + pendingEditorFocus = Promise.resolve(); +}); 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..89149b4149 --- /dev/null +++ b/tests/a11y/tsconfig.json @@ -0,0 +1,28 @@ +{ + "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/core/src/blocks/Heading/block.test.ts", + "../../packages/react/src/editor/BlockNoteView.test.ts", + "../../packages/react/src/components/FormattingToolbar/FormattingToolbar.test.ts", + "../../packages/*/src/**/*.screen-reader.test.ts", + "../../packages/core/src/blocks/Heading/testHelpers.ts", + "../../packages/react/src/editor/testHelpers.ts", + "../../packages/react/src/components/FormattingToolbar/testHelpers.ts" + ] +} diff --git a/tests/a11y/vite.config.ts b/tests/a11y/vite.config.ts new file mode 100644 index 0000000000..05e5f6674d --- /dev/null +++ b/tests/a11y/vite.config.ts @@ -0,0 +1,85 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite-plus"; +import { playwright } from "vite-plus/test/browser/providers/playwright"; +import { + readAriaSnapshot, + startReader, + pressKey, + readTranscript, + stopReader, +} from "./commands.js"; + +const reader = process.env.BLOCKNOTE_SCREEN_READER; +if (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."); +} + +export default defineConfig({ + root: fileURLToPath(new URL("../", import.meta.url)), + 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", + setupFiles: [fileURLToPath(new URL("./setup.ts", import.meta.url))], + include: ["../packages/*/src/**/*.screen-reader.test.ts"], + testTimeout: 300_000, + hookTimeout: 30_000, + fileParallelism: false, + retry: 0, + // Missing baselines fail unless explicitly generated with --update. + update: false, + reporters: [ + "verbose", + ["html", { outputDir: `playwright-report/a11y/${reader}` }], + ], + attachmentsDir: `.vitest/attachments/a11y/${reader}`, + resolveSnapshotPath: (path, extension) => + `${path}-snapshots/screen-reader/${reader}${extension}`, + browser: { + enabled: true, + headless: false, + 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, + }, + ], + commands: { + readAriaSnapshot, + startReader, + pressKey, + readTranscript, + stopReader, + }, + }, + }, +}); diff --git a/tests/docker-run.sh b/tests/docker-run.sh index 5001a82f4b..d9074de5ac 100755 --- a/tests/docker-run.sh +++ b/tests/docker-run.sh @@ -85,6 +85,8 @@ done # The test files and browser config (callers iterate on these too). mounts+=( -v "$PWD/tests/src:/work/tests/src" + -v "$PWD/tests/a11y:/work/tests/a11y" + -v "$PWD/tests/public:/work/tests/public" -v "$PWD/tests/vite.config.browser.ts:/work/tests/vite.config.browser.ts" -v "$PWD/tests/vitestSetup.browser.ts:/work/tests/vitestSetup.browser.ts" ) diff --git a/tests/package.json b/tests/package.json index ed28d1f8a2..26df4820f9 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", "@opentelemetry/api": "1.10.0", "@playwright/test": "1.60.0", "@shikijs/core": "^4.4.3", @@ -35,6 +36,7 @@ "@vitest/ui": "5.0.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/tests/public/example.txt b/tests/public/example.txt new file mode 100644 index 0000000000..72d985baa0 --- /dev/null +++ b/tests/public/example.txt @@ -0,0 +1 @@ +BlockNote accessibility test file. diff --git a/tests/public/image.svg b/tests/public/image.svg new file mode 100644 index 0000000000..a4e84046ae --- /dev/null +++ b/tests/public/image.svg @@ -0,0 +1 @@ + diff --git a/tests/public/tone.wav b/tests/public/tone.wav new file mode 100644 index 0000000000..77c826aa53 Binary files /dev/null and b/tests/public/tone.wav differ diff --git a/tests/public/video.webm b/tests/public/video.webm new file mode 100644 index 0000000000..5b8edf7d83 Binary files /dev/null and b/tests/public/video.webm differ diff --git a/tests/vite.config.browser.ts b/tests/vite.config.browser.ts index ac2b636bd7..66ad0e5888 100644 --- a/tests/vite.config.browser.ts +++ b/tests/vite.config.browser.ts @@ -3,6 +3,7 @@ import * as fs from "fs"; import * as path from "path"; import { configDefaults, defineConfig, type UserConfig } from "vite-plus"; import { playwright } from "vite-plus/test/browser/providers/playwright"; +import { readAriaSnapshot, pressKey } from "./a11y/commands.js"; import { positionalMouse } from "./src/utils/positionalMouse.js"; import { imeComposition } from "./src/utils/imeComposition.js"; @@ -20,6 +21,7 @@ const DESKTOP_EXCLUDE = [ ...configDefaults.exclude, ...CI_EXCLUDE, "**/end-to-end/mobile/**", + "../packages/*/src/**/*.screen-reader.test.ts", ]; import { restoreTouchEmulation } from "./src/utils/restoreTouchEmulation.js"; @@ -90,6 +92,7 @@ export default defineConfig( // src/examples.d.ts. alias: { ...blockNoteSrcAliases, + "@root": path.resolve(__dirname, ".."), // The shared test-utils package lives at the repo root (not under // packages/), so it isn't picked up by the packages scan above. // All consumers - test code and example apps alike - import it via @@ -107,15 +110,23 @@ export default defineConfig( // Besides the end-to-end tests, this suite also runs the packages' // `.browser.test` files: unit tests for browser-only implementations // (canvas rasterization, diagram rendering), colocated with the code - // they test but needing a real browser. The packages' own (node) + // they test but needing a real browser, plus colocated accessibility + // tests. Native screen-reader tests use their own runner. The packages' own (node) // vitest configs exclude them. include: [ "./src/end-to-end/**/*.test.tsx", "../packages/*/src/**/*.browser.test.{ts,tsx}", + "../packages/core/src/blocks/Heading/block.test.ts", + "../packages/react/src/editor/BlockNoteView.test.ts", + "../packages/react/src/components/FormattingToolbar/FormattingToolbar.test.ts", + ], + exclude: [ + ...configDefaults.exclude, + ...CI_EXCLUDE, + "../packages/*/src/**/*.screen-reader.test.ts", ], - exclude: [...configDefaults.exclude, ...CI_EXCLUDE], - setupFiles: ["./vitestSetup.browser.ts"], + setupFiles: ["./vitestSetup.browser.ts", "./a11y/setup.ts"], // Running three browsers concurrently inside one Docker container already // saturates CPU; layering per-browser file parallelism on top causes // 15s test timeouts and even Firefox WS-connect failures. One worker per @@ -165,7 +176,13 @@ export default defineConfig( // still show in the HTML report (errors + stack traces don't depend // on these shots), so disable them. See `e2e:report` to view. screenshotFailures: false, - commands: { positionalMouse, imeComposition, restoreTouchEmulation }, + commands: { + positionalMouse, + imeComposition, + restoreTouchEmulation, + readAriaSnapshot, + pressKey, + }, instances: [ { browser: "chromium", 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 adbc33b4ef..490dfdc9c4 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 @@ -95,6 +105,44 @@ export default defineConfig({ "import/no-cycle": "off", }, overrides: [ + { + files: [ + "**/a11y/**", + "packages/core/src/blocks/Heading/block.test.ts", + "packages/react/src/editor/BlockNoteView.test.ts", + "packages/react/src/components/FormattingToolbar/FormattingToolbar.test.ts", + "packages/{core,react}/src/**/testHelpers.ts", + "**/*.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/**",