0036. A media block may override an image item's alternative text, stored as the text and a decorative flag¶
Status: Accepted
Date: 2026-10-30
Context¶
0006 decided that the media block stores a UUID and a view mode, and that alternative text "is not here at all: alt text belongs to the media entity, where Drupal already put it". Changing it on the item fixes every document that uses the item. Some sites need a different alternative text for one use of an image than the item carries, or need to mark one use decorative, and work item #3627800 asks for both, following core's media embed.
Core 11.4.11 offers both in CKEditor 5. Its drupalMedia plugin draws a button labeled "Override media image alternative text" (core/modules/ckeditor5/js/ckeditor5_plugins/drupalMedia/src/mediaimagetextalternative/mediaimagetextalternativeui.js), enabled only when the item's metadata says it is an image: CKEditor5MediaController::mediaEntityMetadata() sends imageSourceMetadata, with the item's own alt, only when the media source field is an image field whose alt_field setting is on. The form has a text field labeled "Alternative text override", a "Decorative image" toggle, and the hint "Leave blank to use the default alternative text". It stores the answer as an alt attribute on <drupal-media>, where the two characters "" mean decorative, so that an empty value can still mean no override.
Core's MediaEmbed::process() (core/modules/media/src/Plugin/Filter/MediaEmbed.php) answers how an override reaches markup the filter does not own. It loads the item, takes its translation with getTranslationFromContext(), clones it, and applyPerEmbedMediaOverrides() sets the alt on the clone's image source field and on its thumbnail, since the image source copies the same alt into the thumbnail when the item is saved and a view mode may show either. It does so only when getMediaImageSourceField() finds an ImageItem source field with alt_field on, and does nothing for any other media type; it also overrides title when the field's title_field is on. The clone then goes to the view builder in the chosen view mode, and renderMedia() unsets the build's cache keys, so an overridden item is never render cached apart from the text holding it.
This module renders the item in MediaRenderer::build(), the lazy builder the page's placeholder calls and the preview route calls too (0006), and it already unsets the cache keys the same way. A lazy builder's arguments must be scalars or NULL.
A block's version is a contract between the PHP plugin and the client specification, not a fact stored in a document: BlockNote::getJSSettings() sends each enabled block's version as blockVersions, and buildClientSchema() in js/src/schema.tsx leaves a specification whose version disagrees out of the schema, so the author gets the read-only carrier instead of an editable block the server would refuse (docs/extending.md sections 1 and 6). 0018 raised the media block from 1 to 2 for textAlignment and did nothing else: a stored document names no block version, and the validator checks only the properties a stored block carries, so a document saved before the change rendered as left.
The preview route's trust boundary, as 0006 states it, is that nothing the request carries reaches the output except the choice of item and view mode. The block menu's contract (0014) is a list of menu items, each running a callback or opening a submenu, with no place for a text field; the block's Replace media already opens core's Media Library modal from both the toolbar and the menu (0019).
Decision¶
The block stores the override as two props. alt is a string, '' by default, meaning no override; decorative is a boolean, FALSE by default. Both are PropSpecs in DrupalMedia::getPropSchema() and propSchema members in modules/blocknote_media/js/block.tsx, and the block's version is raised from 2 to 3 on the attribute and in modules/blocknote_media/js/index.tsx. Core's "" value is not used: a stored document says what it means without a magic string. This supersedes the words of 0006's Decision that alt text "is not here at all". The item's own alternative text stays where 0006 put it, and stays what every use shows unless the use overrides it.
Decorative wins, and the save refuses the two together. Amended 2026-10-31. DrupalMedia::resolveAltOverride() is the one rule the page and the preview use: decorative: true renders alt="" whatever alt holds, an alt holding text renders that text, and anything else keeps the item's own. An alt holding only whitespace holds no text: the editor trims what it stores with JavaScript's trim(), and DrupalMedia::altText() reads a value made only of the characters trim() removes as no override, on the page, in the preview and in the two checks below, so that a value an import wrote that way means on the server what it means in the editor. DrupalMedia::validate() refuses a block with decorative: true and an alt holding text, as drupal_media_decorative_with_alt, with the fragment "cannot have alternative text when it is marked decorative" (0023), and an alt longer than 512 characters, the most core's image field stores for the item's own, as drupal_media_alt_too_long, with the fragment "has alternative text @actual characters long and this site stores at most @limit", worded like the server's other limits. Amended 2026-10-31. The two fragments are now sentences of their own, "it cannot have alternative text when it is marked decorative" and "its alternative text is @actual characters long and this site stores at most @limit", as 0023's fragment rule records. The editor never writes either: ticking Decorative image empties and disables the text field, saving it stores alt: '', and the text field takes at most 512 characters. The types of the two props are checked the way every declared prop's are. An override on an item that takes none is accepted and ignored, as core does: refusing it would mean loading the item on save, and a site builder may turn alt_field on or off on the item's type at any time.
MediaRenderer::build() applies the override the way core's media embed does. It takes a fourth argument, ?string $alt: NULL keeps the item's own, an empty string marks the image decorative, any other string replaces it. DrupalMedia::build() passes resolveAltOverride()'s answer. When MediaRenderer::altOverrideField() finds an ImageItem source field with alt_field on, the renderer clones the translated item, sets the text on that field and on thumbnail, and renders the clone, which is never saved; for any other item it renders the item as before. title is not overridden.
A stored document needs no migration. The props are optional on both sides: a block without them reads as alt: '' and decorative: false, which renders the item's own alternative text, as before. A client bundle at version 2 meeting the server at version 3 is refused and carried read-only, by the mechanism above, as 0018's change was.
The preview applies the same override and says whether the item takes one. The preview request carries alt when the block has an override and decorative=1 when it is decorative, resolved by the same rule. A response for an item that takes an override carries Drupal-Media-Image: 1, and Drupal-Media-Alt with the item's own alternative text, percent-encoded like the label, when it has any. Amended 2026-10-31. The text is withheld, and Drupal-Media-Alt-Withheld: 1 sent in its place, when the account may not view the field it is read from, whether or not the field holds any, and when its encoding is longer than 1024 bytes (MediaPreviewController::ALT_HEADER_LIMIT): a proxy in front of PHP reads a response's headers into a buffer of its own, 4 KB by default in nginx, and answers 502 when they do not fit, and the 512 Georgian letters core's image field holds come to 4,608 bytes encoded. The headers are read from the translation of the item the preview rendered, in the interface language. Amended 2026-11-02. The preview renders the item in the language the widget publishes for the document, and in the interface language only when the request names none the site knows (0006). The editor remembers all of this by uuid beside the item's media type (modules/blocknote_media/js/bundles.ts), and keys a shared preview by the override as well as the item and the view mode. Amended 2026-11-02. Whether the item takes an override and its own alternative text are remembered by the preview endpoint as well as the uuid, since the endpoint carries the language of the field's document and the text is that translation's, so two fields in different languages on one page each read their own; the media type stays keyed by uuid alone. This supersedes the sentence of 0006 that says nothing the request carries reaches the output except the choice of item and view mode: the alternative text reaches it too, only as the value of the image's alt attribute, which core's image formatter escapes, the same text the author would store and the page would print. Amended 2026-10-31. The cap is 1536 bytes, set together with the label's cap of 512 (0006): the two headers share the buffer, and together they leave half of it to the response's other headers, which come to about 450 bytes on a site that adds none of its own. 512 ASCII characters, the most the field stores, encode to 1536 bytes at most, so no text written in ASCII is withheld for its length, and the cap holds 170 Georgian or CJK characters where 1024 bytes held 113. The text is still withheld rather than shortened as the label is, because the form shows it as the text an override replaces, and a shortened one would misstate it.
The control is one form, reached from the formatting toolbar and the Alt+F10 block menu. Amended 2026-10-31. For an item the preview has said takes an override, and for a block that already holds one, so that it can be cleared while the preview has failed or has not answered, the toolbar draws Override media image alternative text, core's own label, between the alignment buttons and Toggle caption, and the block menu's Media group offers Alternative text in the same place. Both open the same form in a core modal dialog (modules/blocknote_media/js/alt-text.ts), titled like the button, with a text field labeled Alternative text override and described by the item's own alternative text, or without it when the preview withheld it or has not sent it, a Decorative image checkbox, and Save and Cancel. Enter in the text field saves and Escape cancels. Either way jQuery UI gives focus back to the element that had it when the form opened: a toolbar button, or, after the block menu, which closes before the form opens, the editor. The form gives focus to the editor itself only when, once the editor has drawn what was stored, that element has left the page, as a toolbar button does when a saved change draws the toolbar again, or focus was left nowhere. Escape stops at the dialog, because upstream's formatting toolbar closes on an Escape anywhere in the page and gives focus to the editor. Both routes open the form on the next task, because jQuery UI does not take focus back from anything that moves it while a modal is opening: the menu gives focus back to the editor once its item has run, and a click that takes focus into the toolbar sets off the focus trap in @blocknote/mantine's Toolbar, which moves focus to the toolbar's first button on a timer. Amended 2026-10-31. BlockNote 0.57.0's Toolbar holds no focus trap, so the toolbar's button opens the form at once, and only the block menu's entry waits for the next task. 0.57.0 also closes the formatting toolbar as soon as focus leaves the editor and the elements registered as its interface with BlockNoteEditor.registerPortalElement(), which took the button out of the page while the form was open and left focus on the editor after Escape. The form registers its own content before the dialog opens, since jQuery UI focuses its first field while opening, and the dialog's wrapper once it exists, and releases both as it closes, so the toolbar stays open behind the form and the button is still there to take focus back. Amended 2026-10-31. After the block menu, the element that had focus when the form opened is the editable itself, and jQuery UI focusing it by its own element let the browser put the caret at the start of the document, which ProseMirror read back as the selection when the author had clicked into the caption before opening the menu, so the caret left the media block for the first paragraph. The form now leaves the editable before the dialog opens, so that jQuery UI has nothing to give focus back to, and gives focus to the editor itself as the dialog closes, which puts back the selection the editor held. BlockControlsAccessibilityTest checks that the caret is still in the caption after each route.
Choosing an item clears the override. The Media Library's selection writes alt: '' and decorative: false with the new uuid, because an override describes the image it was written for.
Alternatives considered¶
One alt prop with core's "" for decorative. Rejected by the maintainer. It is what core stores, but a reader of the stored JSON, a migration or another module would have to know that two quote characters mean something other than the text they are.
Letting decorative carry alternative text along, kept for when the flag is cleared. Rejected. The page would show the image as decorative while the document held text nobody sees, and a reader of the document could not tell which the author meant.
Healing decorative with alt in normalizeBlock() instead of refusing it. Rejected. The normalizer heals what a change to the format's settings makes stale (0015); this combination is never produced by a settings change or by the editor, only by a hand-written payload, which the save refuses the way it refuses any other value the block does not accept.
A form in a popover on the toolbar, as upstream's own FileCaptionButton and FileRenameButton draw. Rejected. A popover hangs off its trigger, and the block menu's entry is a menu item with no trigger of its own on the toolbar, which is not shown at all for a caret in the caption. Reaching the same function from the menu would have meant a second form, or a form drawn inside the block's node view, where its text field would sit inside the editable. A core dialog is one form both routes open, and its keyboard behavior is core's.
Keeping the override when the item is replaced. Rejected. Text written to describe one image would silently describe another.
Refusing an alt that starts or ends with whitespace. Amended 2026-10-31. Rejected. A save would fail for padding no visitor sees, and the check would still have to use exactly the characters JavaScript's trim() removes, which are not PHP's, or it would refuse text the editor considers trimmed.
Sending a preview's own alternative text in its body, beside the rendered item. Amended 2026-10-31. Not taken. The body is the item's markup, inserted as it is, and has no place for a value that is not markup; a header the size of the text, with a cap, is the smaller change.
One budget for the label and the alternative text, the text taking whatever the label leaves. Amended 2026-10-31. Not taken. An item with a short name could send longer text, but whether the form shows an item's own text would then depend on the length of the item's name, which nobody editing the text would expect.
Overriding title as core does when title_field is on. Not taken. Nobody asked for it, and it is a third prop and another version for a field a site rarely shows.
Consequences¶
The block's contract version is 3. The document schema version is unchanged at 1, and every document saved before this change renders exactly as it did; its placeholders now carry a fourth, NULL, argument.
The override belongs to the document, so it is per translation of the host entity: each translation's document carries its own props, and the item is rendered in that translation's language with the override set over whatever alternative text that translation of the item carries.
An override stays in the document when the item's type stops taking one, because a site builder turned alt_field off, and has no effect until it is turned back on. Amended 2026-10-31. The editor still offers the control for it, because the block holds an override, so that the author can clear it.
Amended 2026-10-31. The block menu is built when Alt+F10 opens it (blockMenuGroups in docs/extending.md), so a menu opened before the preview has answered for an item lacks Alternative text until it is opened again, unless the block already holds an override. An open menu that followed the answer would need a way for a block to tell the menu to rebuild, which the contract does not have. Amended 2026-10-31. The contract has that way now, blockMenuSubscribe (0014): the media block subscribes an open menu to what the preview route has said about items and to which previews its editor has hidden (subscribeMediaBlockMenu() in modules/blocknote_media/js/blockmenu.ts), so Alternative text, and the view modes the item's type displays, join an open menu as soon as the preview answers.
The preview is fetched again whenever the override changes, and once per distinct override of an item on the page.
The dialog is built in the page's script, not by an AJAX request, and is removed when it closes, as core's AJAX dialogs are. It shares their flaw before Drupal 11.5: a dialog closed within 20 milliseconds of a window resize or scroll logs a jQuery UI error, because core's debounced resize runs on the removed dialog (#3472624).
The toolbar button carries Remix Icon's font-family glyph, upstream's own icon for Rename image, which edits an uploaded image's alternative text (0011); it is added to modules/blocknote_media/js/icons/remix/ under 0033.
The form shows the author the item's own alternative text. The preview is inert and hidden from assistive technology (0019), so without it an author using a screen reader would have no way to learn the text they are overriding. Amended 2026-10-31. An author the text is withheld from, or whose item's text is longer than a header carries, is told only that a blank field keeps it.
Amended 2026-10-31. DrupalMedia::validate()'s two refusals and every client string the form adds are new translatable strings; the button's label, the text field's label, Decorative image, Save and Cancel are core's own and already translated.
References¶
- Drupal core 11.4.11:
core/modules/media/src/Plugin/Filter/MediaEmbed.php(process(),applyPerEmbedMediaOverrides(),getMediaImageSourceField(),renderMedia()),core/modules/ckeditor5/src/Controller/CKEditor5MediaController.php::mediaEntityMetadata(),core/modules/ckeditor5/js/ckeditor5_plugins/drupalMedia/src/mediaimagetextalternative/,core/modules/media/tests/src/Kernel/MediaEmbedFilterTest.php::testOverridesAltAndTitle(),core/misc/dialog/dialog.js,core/misc/dialog/dialog.position.js. - This module:
modules/blocknote_media/src/Plugin/BlockNoteBlock/DrupalMedia.php,modules/blocknote_media/src/MediaRenderer.php,modules/blocknote_media/src/Controller/MediaPreviewController.php,modules/blocknote_media/js/block.tsx,alt-text.ts,toolbar.tsx,blockmenu.ts,bundles.ts,preview.ts,library.ts,index.tsx. - Tests:
tests/src/Kernel/MediaRenderTest.php,tests/src/Kernel/MediaBlockSettingsTest.php,tests/src/Functional/MediaRoutesTest.php,tests/src/Functional/MediaPreviewLanguageTest.php,tests/src/FunctionalJavascript/BlockControlsAccessibilityTest.php,modules/blocknote_media/js/__tests__/alt-text.test.tsand the other media specs. - Work item #3627800.
- 0006, 0011, 0014, 0015, 0018, 0019, 0023, 0033.