Skip to content

fix(a11y): use figure/figcaption for media block captions#2717

Merged
nperez0111 merged 2 commits intomainfrom
feat/revisit-original-issue
May 7, 2026
Merged

fix(a11y): use figure/figcaption for media block captions#2717
nperez0111 merged 2 commits intomainfrom
feat/revisit-original-issue

Conversation

@nperez0111
Copy link
Copy Markdown
Contributor

@nperez0111 nperez0111 commented May 6, 2026

Summary

Render media blocks (image / video / audio / file) with <figure>+<figcaption> when a caption is set. Closes #2055, supersedes #2056.

Changes

  • createFileBlockWrapper.ts / FileBlockWrapper.tsx: wrapper element is <figure> when a caption is shown, <div> otherwise; caption uses <figcaption> instead of <p>.
  • Image/block.ts and React Image/block.tsx — alt text:
    • caption present → alt="" (figcaption is the accessible name; avoids double-announcement)
    • no caption, name present → alt={name}
    • neither → alt="" (decorative; no extra ARIA)
  • React Video/Audio previews: removed interim aria-describedby — figure/figcaption now provides the association.
  • Block.css: reset margin: 0 on .bn-file-block-content-wrapper so the figure version matches the previous div layout (figure default margin: 1em 40px was not covered by the existing .bn-default-styles p-reset).
  • Snapshots refreshed (core, react, server-util).

Notes

  • Parsing was already covered: Image/Video/Audio/File parsers handle <figure>+<figcaption> via parseFigureElement (the export path was already producing that structure).
  • Did not run a manual browser-side accessibility check — flagging explicitly.

Co-authored with @Ovgodd, whose original PR did the alt-text simplification preserved here.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • File and image blocks now render with proper semantic HTML structure, using native <figure> and <figcaption> elements when captions are present to improve accessibility and document semantics
    • Image alt-text generation enhanced to use block names when available, providing better context for screen readers and assistive technologies while avoiding redundant announcements

@vercel
Copy link
Copy Markdown

vercel Bot commented May 6, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
blocknote Ready Ready Preview May 7, 2026 5:15am
blocknote-website Ready Ready Preview May 7, 2026 5:15am

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 6, 2026

📝 Walkthrough

Walkthrough

The PR changes file and image block rendering to use semantic <figure>/<figcaption> when a caption and URL are present, switches file wrapper markup conditionally, updates image alt handling to use the block name or "" (not caption), and adds a small CSS reset for <figure> margins.

Changes

File & Image Accessibility (semantic figures + caption-aware alt text)

Layer / File(s) Summary
Data / Condition
packages/core/src/blocks/File/helpers/render/createFileBlockWrapper.ts, packages/react/src/blocks/File/helpers/render/FileBlockWrapper.tsx
Introduce useFigure determined by url and caption (and not showing loader) to decide semantic wrapper type.
Core Rendering
packages/core/src/blocks/File/helpers/render/createFileBlockWrapper.ts
Create wrapper as figure when useFigure true, otherwise div; caption element changed from <p> to <figcaption>.
React Component Wiring
packages/react/src/blocks/File/helpers/render/FileBlockWrapper.tsx
Introduce Wrapper variable selecting <figure> or <div>; preserve loader, preview, AddFileButton logic; render <figcaption> when caption present.
Alt Text Logic
packages/core/src/blocks/Image/block.ts, packages/react/src/blocks/Image/block.tsx
Compute alt from `block.props.name
Styling
packages/core/src/editor/Block.css
Add margin: 0 (commented) inside [data-file-block] .bn-file-block-content-wrapper to reset default <figure> margins when wrapper is a <figure>.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant FileBlock as File Block
    participant WrapperLogic as Wrapper Logic
    participant DOM

    User->>FileBlock: Add/view file with url & caption
    FileBlock->>WrapperLogic: Evaluate url, caption, showLoader
    alt url present && caption && not loading
        WrapperLogic->>DOM: Render <figure> wrapper
        FileBlock->>DOM: Render preview / loader inside <figure>
        FileBlock->>DOM: Render <figcaption> with caption
    else
        WrapperLogic->>DOM: Render <div> wrapper
        FileBlock->>DOM: Render preview / loader inside <div>
        FileBlock->>DOM: Render caption fallback if applicable
    end
    DOM-->>User: Display file block
Loading
sequenceDiagram
    participant User
    participant ImageBlock as Image Block
    participant AltLogic as Alt Logic
    participant DOM

    User->>ImageBlock: Render image (with/without caption)
    ImageBlock->>AltLogic: Compute alt = name || ""
    alt caption present
        AltLogic-->>ImageBlock: alt = ""
        ImageBlock->>DOM: Render <img alt=""> inside <figure>
        ImageBlock->>DOM: Render <figcaption> visible caption
    else no caption
        AltLogic-->>ImageBlock: alt = name or ""
        ImageBlock->>DOM: Render <img alt="name"> (or decorative)
    end
    DOM-->>User: Accessible output without duplicated announcements
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐰 I stitched a figure, neat and true,
A caption nestled, snug in view,
Alt stays quiet when the fig speaks loud,
Screen readers nod, no echoes proud,
Hopping on with semantic cheer.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: using semantic HTML figure/figcaption for media block captions to improve accessibility.
Description check ✅ Passed The description comprehensively covers summary, rationale, changes, and testing approach, though some template sections like formal checklist items are not explicitly ticked.
Linked Issues check ✅ Passed The PR successfully implements the WCAG-compliant semantic solution from issue #2055 by using figure/figcaption for captions and setting alt-text based on caption/name presence.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing semantic HTML for media block captions; Block.css margin reset is a necessary layout fix supporting the figure implementation.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/revisit-original-issue
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch feat/revisit-original-issue

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

nperez0111 added a commit that referenced this pull request May 6, 2026
Two CI fixes for #2717:

- Block.css: add `margin: 0` to .bn-file-block-content-wrapper so the
  wrapper looks identical whether it renders as <figure> (captioned)
  or <div> (uncaptioned). Browser default <figure> margin is `1em 40px`,
  whereas the previous <div>+<p class="bn-file-caption"> structure had
  the <p> margins reset by .bn-default-styles. Without this reset the
  captioned-image visual snapshot grew by ~50px.
- server-util/ServerBlockNoteEditor.test.ts.snap: refresh — these
  snapshots cover the full HTML/markdown round-trip and were missed
  in the previous snapshot pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 6, 2026

Open in StackBlitz

@blocknote/ariakit

npm i https://pkg.pr.new/@blocknote/ariakit@2717

@blocknote/code-block

npm i https://pkg.pr.new/@blocknote/code-block@2717

@blocknote/core

npm i https://pkg.pr.new/@blocknote/core@2717

@blocknote/mantine

npm i https://pkg.pr.new/@blocknote/mantine@2717

@blocknote/react

npm i https://pkg.pr.new/@blocknote/react@2717

@blocknote/server-util

npm i https://pkg.pr.new/@blocknote/server-util@2717

@blocknote/shadcn

npm i https://pkg.pr.new/@blocknote/shadcn@2717

@blocknote/xl-ai

npm i https://pkg.pr.new/@blocknote/xl-ai@2717

@blocknote/xl-docx-exporter

npm i https://pkg.pr.new/@blocknote/xl-docx-exporter@2717

@blocknote/xl-email-exporter

npm i https://pkg.pr.new/@blocknote/xl-email-exporter@2717

@blocknote/xl-multi-column

npm i https://pkg.pr.new/@blocknote/xl-multi-column@2717

@blocknote/xl-odt-exporter

npm i https://pkg.pr.new/@blocknote/xl-odt-exporter@2717

@blocknote/xl-pdf-exporter

npm i https://pkg.pr.new/@blocknote/xl-pdf-exporter@2717

commit: 28991e2

nperez0111 and others added 2 commits May 7, 2026 07:08
Closes #2055. Supersedes #2056.

When a file/image/video/audio block has a caption, render the wrapper
as <figure> with a <figcaption> instead of a <div>+<p>. This matches
the WCAG-recommended semantic for caption-content association and
removes the need for ad-hoc ARIA fallbacks.

Image alt text logic is also tightened:
- caption present -> alt="" (the figcaption is the accessible name;
  this avoids screen readers double-announcing the caption)
- no caption, name present -> alt={name}
- neither -> alt="" (decorative; aria-hidden was dropped because it
  would have removed unintentionally-unlabeled images from the
  accessibility tree entirely)

Co-Authored-By: Cyril G <c.gromoff@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two CI fixes for #2717:

- Block.css: add `margin: 0` to .bn-file-block-content-wrapper so the
  wrapper looks identical whether it renders as <figure> (captioned)
  or <div> (uncaptioned). Browser default <figure> margin is `1em 40px`,
  whereas the previous <div>+<p class="bn-file-caption"> structure had
  the <p> margins reset by .bn-default-styles. Without this reset the
  captioned-image visual snapshot grew by ~50px.
- server-util/ServerBlockNoteEditor.test.ts.snap: refresh — these
  snapshots cover the full HTML/markdown round-trip and were missed
  in the previous snapshot pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/core/src/blocks/Image/block.ts`:
- Around line 119-122: The alt text is set unconditionally which causes
duplicate announcements when a caption/figcaption exists; update the assignments
in imageRender (where image.alt is set) and in imageToExternalHTML (where the
<img> alt attribute is set) to use an empty string if block.props.caption (or
caption) is present — i.e., set alt = "" when block.props.caption is non-empty,
otherwise use block.props.name || ""; keep the existing conditional wrapping
with createFigureWithCaption intact.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8692e8f2-7aa6-4b5a-b03c-83ac1f19d0f9

📥 Commits

Reviewing files that changed from the base of the PR and between d520572 and 28991e2.

⛔ Files ignored due to path filters (28)
  • packages/server-util/src/context/__snapshots__/ServerBlockNoteEditor.test.ts.snap is excluded by !**/*.snap, !**/__snapshots__/**
  • tests/src/unit/core/clipboard/copy/__snapshots__/text/html/image.html is excluded by !**/__snapshots__/**
  • tests/src/unit/core/clipboard/copy/__snapshots__/text/html/nestedImage.html is excluded by !**/__snapshots__/**
  • tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/file/basic.html is excluded by !**/__snapshots__/**
  • tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/file/nested.html is excluded by !**/__snapshots__/**
  • tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/file/noName.html is excluded by !**/__snapshots__/**
  • tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/image.html is excluded by !**/__snapshots__/**
  • tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/image/basic.html is excluded by !**/__snapshots__/**
  • tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/image/nested.html is excluded by !**/__snapshots__/**
  • tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/image/noName.html is excluded by !**/__snapshots__/**
  • tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/image/noPreview.html is excluded by !**/__snapshots__/**
  • tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/image/urlOnly.html is excluded by !**/__snapshots__/**
  • tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/image/withCaption.html is excluded by !**/__snapshots__/**
  • tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/video/withCaption.html is excluded by !**/__snapshots__/**
  • tests/src/unit/core/formatConversion/export/__snapshots__/html/image.html is excluded by !**/__snapshots__/**
  • tests/src/unit/core/formatConversion/export/__snapshots__/html/image/nested.html is excluded by !**/__snapshots__/**
  • tests/src/unit/core/formatConversion/export/__snapshots__/html/image/noName.html is excluded by !**/__snapshots__/**
  • tests/src/unit/core/formatConversion/export/__snapshots__/html/image/urlOnly.html is excluded by !**/__snapshots__/**
  • tests/src/unit/core/formatConversion/export/__snapshots__/markdown/image.md is excluded by !**/__snapshots__/**
  • tests/src/unit/core/formatConversion/export/__snapshots__/markdown/image/urlOnly.md is excluded by !**/__snapshots__/**
  • tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/reactFile/basic.html is excluded by !**/__snapshots__/**
  • tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/reactFile/nested.html is excluded by !**/__snapshots__/**
  • tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/reactFile/noName.html is excluded by !**/__snapshots__/**
  • tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/reactImage/basic.html is excluded by !**/__snapshots__/**
  • tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/reactImage/nested.html is excluded by !**/__snapshots__/**
  • tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/reactImage/noName.html is excluded by !**/__snapshots__/**
  • tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/reactImage/noPreview.html is excluded by !**/__snapshots__/**
  • tests/src/unit/react/formatConversion/export/__snapshots__/html/reactImage/noName.html is excluded by !**/__snapshots__/**
📒 Files selected for processing (5)
  • packages/core/src/blocks/File/helpers/render/createFileBlockWrapper.ts
  • packages/core/src/blocks/Image/block.ts
  • packages/core/src/editor/Block.css
  • packages/react/src/blocks/File/helpers/render/FileBlockWrapper.tsx
  • packages/react/src/blocks/Image/block.tsx
✅ Files skipped from review due to trivial changes (1)
  • packages/core/src/editor/Block.css
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/react/src/blocks/File/helpers/render/FileBlockWrapper.tsx
  • packages/core/src/blocks/File/helpers/render/createFileBlockWrapper.ts
  • packages/react/src/blocks/Image/block.tsx

Comment on lines +119 to +122
// alt describes image content (per WCAG H86); figcaption (when present)
// is the contextual caption. Fall back to "" so unlabelled images are
// marked decorative rather than getting a noisy generic fallback.
image.alt = block.props.name || "";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

alt should be empty when a figcaption is present to avoid redundant AT announcements.

The comment correctly notes that a figcaption provides the contextual label, but the code doesn't act on it — image.alt is set to block.props.name regardless of whether a caption (and therefore a figcaption) is also being rendered.

When both block.props.name and block.props.caption are non-empty, screen readers will announce the alt text and the figcaption separately, producing duplicated output. The PR objectives explicitly specify "caption present → alt=""".

The same fix is needed at line 156 inside imageToExternalHTML, where the <img> is likewise assigned block.props.name || "" before being conditionally wrapped in createFigureWithCaption.

♿ Proposed fix (apply in both `imageRender` and `imageToExternalHTML`)
-    // alt describes image content (per WCAG H86); figcaption (when present)
-    // is the contextual caption. Fall back to "" so unlabelled images are
-    // marked decorative rather than getting a noisy generic fallback.
-    image.alt = block.props.name || "";
+    // When a figcaption is present it becomes the accessible name for the
+    // figure; keep img alt="" to avoid redundant AT announcements (WCAG H37).
+    // Without a caption, use the file name as the succinct image description,
+    // or "" to mark the image decorative if neither is provided.
+    image.alt = block.props.caption ? "" : block.props.name || "";
   image = document.createElement("img");
   image.src = block.props.url;
-  image.alt = block.props.name || "";
+  image.alt = block.props.caption ? "" : block.props.name || "";
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// alt describes image content (per WCAG H86); figcaption (when present)
// is the contextual caption. Fall back to "" so unlabelled images are
// marked decorative rather than getting a noisy generic fallback.
image.alt = block.props.name || "";
// When a figcaption is present it becomes the accessible name for the
// figure; keep img alt="" to avoid redundant AT announcements (WCAG H37).
// Without a caption, use the file name as the succinct image description,
// or "" to mark the image decorative if neither is provided.
image.alt = block.props.caption ? "" : block.props.name || "";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/blocks/Image/block.ts` around lines 119 - 122, The alt text
is set unconditionally which causes duplicate announcements when a
caption/figcaption exists; update the assignments in imageRender (where
image.alt is set) and in imageToExternalHTML (where the <img> alt attribute is
set) to use an empty string if block.props.caption (or caption) is present —
i.e., set alt = "" when block.props.caption is non-empty, otherwise use
block.props.name || ""; keep the existing conditional wrapping with
createFigureWithCaption intact.

@nperez0111 nperez0111 merged commit 9b441be into main May 7, 2026
23 checks passed
@nperez0111 nperez0111 deleted the feat/revisit-original-issue branch May 7, 2026 10:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Image caption isn't associated to the image in an accessible way

1 participant