Repository navigation
feat(ui,paragraph): ARIA semantics + accessibility e2e suites - #187
Merged
Merged
Conversation
…eadless Introduce `@editorjs/editorjs`, a batteries-included entry point that composes `@editorjs/core` with the default DOM adapter, collaboration, tools, plugins, and UI, auto-initializes in its constructor, and exposes an `isReady` promise. It honors a v2-style `config.tools` map merged over the defaults with override-by-name. To support it, `Core` becomes a headless engine: - Remove the hardcoded default tool/plugin/infra `use()` calls from the constructor; drop the corresponding dependencies. - `initialize()` now fails loudly: it throws when no rendering adapter is registered or the `defaultBlock` tool is missing, and re-throws init failures instead of swallowing them. `ShortcutsPlugin` stays in `@editorjs/core` (no longer auto-registered); its relocation is handled by a separate change. Free the `@editorjs/editorjs` package name by aliasing the published v2 dependency as `editorjs-v2` (`npm:@editorjs/editorjs@…`) across core, sdk, ui, and tools/paragraph. The playground now consumes `@editorjs/editorjs` instead of wiring `Core` and UI by hand. Specs updated via OpenSpec change `add-editorjs-bundle-package` (archived). Co-Authored-By: Claude Opus 4.8 <[email protected]>
…gration test Core no longer depends on `@editorjs/dom-adapters`, so its dist is not built in CI when checking `@editorjs/core`. The leftover `jest.unstable_mockModule` call still resolved the specifier, failing the suite (and the Stryker dry run) with "Cannot find module '@editorjs/dom-adapters'". Co-Authored-By: Claude Opus 4.8 <[email protected]>
Runs lint, build and unit tests for the new bundle package on pull requests and merge groups, mirroring the other package workflows. Mutation tests are disabled since the package has no Stryker config yet. Co-Authored-By: Claude Opus 4.8 <[email protected]>
`tsc --build` only produces output for TypeScript project references, so `@editorjs/ui` — which is bundled by vite — had no `dist/ui.js` after building `@editorjs/editorjs` from a clean checkout. Jest then failed to resolve `@editorjs/ui` in the bundle test. Build dependencies first via `yarn workspaces foreach`, mirroring how the playground package builds its own dependency graph. Co-Authored-By: Claude Opus 4.8 <[email protected]>
- Core.initialize() no longer logs to console.error before re-throwing (dead try/catch removed) - BlockRenderer/BlockManager import editorjs-v2 types with `import type` instead of pulling it in as a runtime dependency - Playground attaches a .catch() to editor.isReady instead of discarding the promise, avoiding an unhandled rejection - mergeTools() now throws when a config.tools key doesn't match the tool's static name, since Core looks up tools by name, not map key - editorjs-bundle spec: replaced TBD Purpose and documented the new fail-loud mismatch scenario Co-Authored-By: Claude Sonnet 5 <[email protected]>
…hange Adds a Playwright e2e setup for @editorjs/editorjs (Vite-served fixture mounting the real bundle from src/, no build step) covering paragraph rendering, typing, and inline-toolbar bold. Documents a gotcha where locator.press()/selectText() desync Editor.js's native caret tracking. Writing these tests surfaced that @editorjs/ui and the paragraph tool emit no ARIA roles/names anywhere, forcing CSS-class selectors instead of getByRole/getByLabel. Proposes openspec change add-editor-aria-semantics to close that gap. Co-Authored-By: Claude Sonnet 5 <[email protected]>
Contributor
Unit Tests
Mutation Tests
|
ilyamore88
reviewed
Jul 25, 2026
ilyamore88
reviewed
Jul 25, 2026
Comment on lines
+17
to
+22
| projects: [ | ||
| { | ||
| name: 'chromium', | ||
| use: { ...devices['Desktop Chrome'] }, | ||
| }, | ||
| ], |
Member
There was a problem hiding this comment.
Can you include Safari as well?
Member
Author
There was a problem hiding this comment.
Done — added a webkit project alongside chromium. Installed WebKit locally and confirmed all 3 tests pass on both browsers (6/6 total).
…ebkit e2e target Replace role="textbox" on the blocks holder with role="group" to avoid nesting a textbox inside another textbox — contenteditable elements implicitly carry role="textbox" via HTML-AAM regardless of explicit role, so putting it on the holder too would be an ARIA anti-pattern. Instead, assign role="textbox" + aria-multiline="true" + aria-label to each block's own contenteditable element (starting with Paragraph). Update proposal, design decisions, specs (ui + tools), and task checklist to reflect the revised approach. Also adds webkit (Desktop Safari) as a second Playwright project alongside chromium in packages/editorjs/playwright.config.ts.
The editor emitted zero roles or accessible names, which made it unusable with a screen reader and forced the e2e suite onto CSS-class selectors. - BlocksUI: role="group" on the holder, so the block textboxes are not nested inside a textbox the holder would otherwise imply - Paragraph: role="textbox", aria-multiline, aria-label, and the configured placeholder as aria-placeholder - ToolbarUI: role="toolbar" with a roving tabindex, named plus button, aria-haspopup/aria-expanded, and focus moved to the button on open so Safari restores it there rather than to <body> - InlineToolbarUI: named toolbar, aria-activedescendant mirrored from ui-kit's highlight and made resolvable with aria-owns, plus a live region announcing the toolbar once per open - Popover item roles and names come from @editorjs/ui-kit 2.0.0, fixed upstream rather than worked around from here Covered by aria.spec.ts, axe.spec.ts and a VoiceOver suite driving a real screen reader (voiceover.spec.ts, macOS-only, excluded from test:e2e). Note: @editorjs/ui-kit 2.0.0 is not published yet, so the lockfile has no entry for it and `yarn install` fails. The local `link:` resolution the work was developed against has been removed deliberately — see tasks.md 5a.2/5a.3. Co-Authored-By: Claude Opus 5 <[email protected]>
The ARIA semantics the UI now depends on are enforced only by Playwright — nothing in the type surface catches a dropped role or accessible name, so until now a regression could land unnoticed. Adds an `include-e2e` input to the reusable package-check workflow and an `e2e-tests` composite action that installs chromium and webkit (cached by lockfile hash) and runs `test:e2e`. Kept as its own job: it needs browsers and takes minutes, and its failures read differently from a unit test's. `voiceover.spec.ts` stays excluded — screen readers cannot run headless. Co-Authored-By: Claude Opus 5 <[email protected]>
gohabereg
marked this pull request as draft
July 29, 2026 18:02
main landed the squashed bundle package (#183) plus work this branch predates, so the overlap had to be reconciled by hand: - ShortcutsPlugin: kept main's extraction into @editorjs/shortcuts-plugin — the bundle depends on it, registers it as a default plugin, and core no longer ships it. Added to editorjs's package.json, tsconfigs and index.ts alongside ClipboardPlugin. - ToolConfig now comes from @editorjs/sdk (#188) instead of editorjs-v2; dropped the alias import in sdk entities and paragraph while keeping this branch's ARIA changes in paragraph. - Core.initialize resolves UndoRedoManager after plugins/tools (main's ordering) and registers PluginRegistry. - packages/ui: took main's chokidar dev script and dependency reshuffle, kept this branch's @editorjs/ui-kit ^2.0.0 pin. - openspec: took main's archived bundle change and updated core/editorjs-bundle specs; this branch's add-editor-aria-semantics change is untouched. - Playwright/e2e tooling, .gitignore, eslint config and the e2e CI job are kept from this branch. yarn.lock was regenerated from main's and still has no @editorjs/ui-kit entry: 2.0.0 is not published yet, so `yarn install` fails exactly as it did on this branch before the merge. Verified with the workspace's tsc/jest directly — core (179), editorjs (8) pass; core, paragraph, shortcuts-plugin and editorjs typecheck; packages/ui's three errors are the unpublished ui-kit 2.0.0 APIs. Co-Authored-By: Claude Opus 5 <[email protected]>
Reversean
requested changes
Sep 21, 2026
…cape Escape closes the popover inside ui-kit, without going through #hide(), so #isVisible stayed true and every later appearance was taken for a reselection while already open: the toolbar was never announced again. Listen for PopoverEvent.Closed to reset it. The listener is removed before the popover is destroyed, since destroy() emits Closed as well and a rebuild would otherwise re-announce the toolbar on every selection change. Co-Authored-By: Claude Opus 5 <[email protected]>
With only the list reporter nothing was written to playwright-report/, so the upload step had nothing to upload and the traces recorded on a retry were lost with the runner. Co-Authored-By: Claude Opus 5 <[email protected]>
Hashing yarn.lock dropped the ~400MB browser cache on any unrelated dependency bump. Each Playwright release pins its own browser builds, so the installed version is all the key needs. Also bump upload-artifact to v7, in line with the other actions. Co-Authored-By: Claude Opus 5 <[email protected]>
@editorjs/ui-kit 2.0.0 depends on @editorjs/dom ^1.2.0, while the workspace packages still asked for ^1.1.0. Yarn will not re-resolve an already-locked entry, so the two ranges resolved separately and node_modules ended up with two copies of @editorjs/dom - including a nested one under @editorjs/caret, which pins ^1.1.0 transitively. Two copies split module state and break any identity check across the boundary. Widen the workspace ranges to ^1.2.0 and run `yarn dedupe @editorjs/dom` so both ranges collapse onto a single 1.2.0 resolution. The lockfile also gains the @editorjs/ui-kit 2.0.0 resolution it was missing. Co-Authored-By: Claude Opus 5 <[email protected]>
`#popoverElement` was only ever written: assigned from `getElement()`, used as the receiver for the id and the append, then nulled in the teardown. A local covers all three, and dropping the field removes a second piece of state that had to be kept in step with `#popover` for no one's benefit. `editor.spec.ts` was the only spec reimplementing the mount-and-wait sequence and the triple-click selection instead of importing `mountDocument` and `selectParagraphText`. The readiness signal it waited on lives in the fixture, so a change there would have fixed the three specs using the helpers and left this one timing out. Co-Authored-By: Claude Opus 5 <[email protected]>
5a.3 was still written as the blocking, not-yet-done publish. @editorjs/ui-kit 2.0.0 is published, the lockfile resolves it, and the suite was re-run against the published package: build, 973 unit tests, 80 e2e on both engines, lint. The task now also records the duplicate @editorjs/dom the regeneration surfaced and the dedupe that resolved it, since @editorjs/caret still pins the old range. The verification summary listed the same publish as an outstanding blocker and its verdict turned on it; both now say otherwise. Case 18's comment credited the CSS class with removing filtered items from the accessibility tree. ui-kit sets `hidden` alongside the class and that is what does it - as the spec and aria.spec.ts both already said. Co-Authored-By: Claude Opus 5 <[email protected]>
10.5b, 10.7b and 10.8 were records of pre-existing bugs the change deliberately did not fix, not work items. Every archived change here has zero unchecked tasks, so as encoded they would have blocked archiving and reported 69/72. The findings themselves are preserved outside the repo. What referenced them by number - the proposal, the design note, 10.1c, 10.7a and the verification summary - now describes the bug instead, so nothing points at a task that is gone. Co-Authored-By: Claude Opus 5 <[email protected]>
gohabereg
marked this pull request as ready for review
September 29, 2026 21:42
neSpecc
approved these changes
Oct 8, 2026
| * Identifies the block *type*, so it reads identically on every paragraph in a document — see | ||
| * the Open Questions in `openspec/changes/add-editor-aria-semantics/design.md` for whether that | ||
| * should become per-instance. Named here rather than inline as the seam localisation would | ||
| * attach to, mirroring `packages/ui/src/messages.ts`; i18n itself is out of scope for now |
Contributor
There was a problem hiding this comment.
Suggested change
| * attach to, mirroring `packages/ui/src/messages.ts`; i18n itself is out of scope for now | |
| * attach to, mirroring `packages/ui/src/messages.ts` |
| await expect(paragraph).toHaveText('Hello world!'); | ||
| }); | ||
|
|
||
| test('bolds selected text via the inline toolbar', async ({ page }) => { |
Contributor
There was a problem hiding this comment.
this could be moved to a separate file, like inline-toolbar.test.ts
| * | ||
| * Identifies the block *type*, so it reads identically on every paragraph in a document — see | ||
| * the Open Questions in `openspec/changes/add-editor-aria-semantics/design.md` for whether that | ||
| * should become per-instance. Named here rather than inline as the seam localisation would |
Contributor
There was a problem hiding this comment.
it is not clear for me how aria-label is related to i18n?
Reversean
approved these changes
Oct 8, 2026
Member
Author
|
/archive |
Contributor
|
✅ Archived |
gohabereg
added a commit
that referenced
this pull request
Oct 9, 2026
The e2e/ARIA branch landed as #187, which satisfies this change's first precondition and invalidates the second. Its ARIA requirements turned out to live beside `Floating toolbar` rather than inside it, so the delta was not about to revert them -- but it did duplicate one of their scenarios and put the settings button's ARIA assertions in the wrong requirement. The delta now modifies all three merged requirements, each header matching verbatim so the archive fold still applies: behaviour stays in `Floating toolbar`, accessible names in `Accessible floating toolbar`, and roving tabindex plus `aria-expanded` in `The block actions toolbar is a single tab stop`. Drops the duplicated tab-stop scenario and adds the block-settings counterpart of the toolbox's "keys from inside the popover are left alone". Also resolves the "block actions" collision #187 made concrete. `main` now ships that phrase as the toolbar container's accessible name, asserted in two e2e suites and carried in a requirement title; the move/delete package was only spec text. It becomes `@editorjs/default-block-settings-plugin` (`default-block-settings`), which is what it actually is. Co-Authored-By: Claude Opus 5 <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
@editorjs/uiand the paragraph tool emitted zerorole/aria-*attributes. Acontenteditable block editor with no ARIA semantics is unusable with a screen reader, and it also
forced this package's e2e suite onto brittle CSS-class selectors instead of
getByRole.What changes
Editor semantics
BlocksUI—role="group"on the blocks holder. It iscontenteditable, so HTML-AAM wouldotherwise imply
role="textbox"on it and nest a textbox inside the textbox each block exposes.Paragraph—role="textbox",aria-multiline,aria-label, and the configured placeholderexposed as
aria-placeholder(a contenteditable has no nativeplaceholder).ToolbarUI—role="toolbar"with a real roving tabindex, named plus button,aria-haspopup/aria-expanded, and focus moved onto the button when it opens the toolbox.InlineToolbarUI— named toolbar, plusaria-activedescendantmirrored from ui-kit's highlightand made resolvable with
aria-owns, and a live region announcing the toolbar once per open.@editorjs/ui-kit2.0.0 — fixed upstream rather than byreaching into its rendered DOM from here (which its own
innerHTMLreplacement would wipe).Testing
e2e/tests/aria.spec.ts— roles, names and relationships, chromium + webkit.e2e/tests/axe.spec.ts—@axe-core/playwrightaudits over four states, including thenavigated toolbar, where
aria-owns/aria-activedescendantexist.e2e/tests/voiceover.spec.ts— 18 cases driving a real VoiceOver instance via Guidepup.macOS-only,
workers: 1, headed; run withyarn test:e2e:voiceover, excluded fromtest:e2e.@editorjs/uithrough itsdist, so an unbuilt change was previously invisible to the suitein both directions.
include-e2einput + ane2e-testscomposite action installingchromium and webkit). This closes the known gap flagged in the previous version of this PR.
Bugs found along the way
aria-activedescendantpointed outside the focused subtree. The toolbar is a sibling ofthe blocks holder, so the reference was invalid and silently dropped by assistive tech. It
passed the whole suite because the test compared the attribute to the button's
id— a matchthat holds whether or not the reference resolves. Now asserted over the resolved relationship.
<body>when the toolbox closed, on Escape and after inserting ablock alike — Safari does not focus a
<button>on click, so the popover captureddocument.bodyas its restore target. Only the webkit target caught it.ArrowLeft/ArrowRightwith any modifier and calledpreventDefault(), but VO+Right/VO+Leftarrive as
Control+Option+arrow. Seven of seventeen VoiceOver cases failed at once, havingpassed 16/17 on the same code the run before. The accessibility tree was correct throughout —
the keyboard was not, which no attribute assertion can see.
Dependencies
@editorjs/ui-kit2.0.0 is published, and the lockfile resolves it — the blocker this PR carriedis gone. Regenerating the lockfile surfaced one thing worth knowing: ui-kit 2.0.0 depends on
@editorjs/dom^1.2.0while the workspace packages still asked for^1.1.0, and Yarn will notre-resolve an already-locked entry, so
node_modulesended up with two copies of@editorjs/dom— one nested under
@editorjs/caret, which pins^1.1.0transitively. The workspace ranges arewidened to
^1.2.0andyarn dedupe @editorjs/domcollapses both onto a single 1.2.0 resolution.@editorjs/caretis still published against the old range, so a fresh install of ui-kit 2.0.0elsewhere gets the duplicate until that is widened upstream.
Known limitations
Two are pinned with
test.fail()rather than skipped, so fixing them trips the test instead ofleaving it quietly asserting the bug:
focus: trueon insert does not move the caret into the new block.Neither is this change's to fix. A third, also out of scope, is left as a
@todoon#onSelectionChanged: a tool throwing fromgetToolbarConfigrejects the whole build and therejection goes nowhere — no toolbar, nothing logged.
Verification
Run against the published
@editorjs/ui-kit2.0.0, not a local build of it:yarn build— clean.yarn test— 973 passing, 0 failing, across 10 workspaces. The pre-existingts-jestcrashin
packages/sdkthat the previous revision of this PR noted is gone: it was caused by ui-kitshipping
dist/e2e/**/*.d.tsimporting from@playwright/test, fixed at source before 2.0.0.yarn test:e2e— 80 passed on chromium + webkit. That count includes the threetest.fail()expectations for the two limitations above, which Playwright reports as passing while they fail.
yarn lint— clean across all workspaces.openspec validate add-editor-aria-semantics— valid, 69/69 tasks.The VoiceOver suite is macOS-only and not run by CI here; wiring it into the merge queue is
stacked in #190.
🤖 Generated with Claude Code