From face4409b9015d0e3393b975f19f13bf455aa5f6 Mon Sep 17 00:00:00 2001 From: SornapudiSuresh Date: Fri, 14 Aug 2026 17:37:47 +0530 Subject: [PATCH 1/3] Media: Refresh attachment tile aria-label when model data loads. Backbone only evaluates attributes() when the element is created, so tiles built for id-only models kept a stale uploading/(no title) label after fetch. Re-apply the accessible name in render() so the label catches up. --- src/js/media/views/attachment.js | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/js/media/views/attachment.js b/src/js/media/views/attachment.js index 152c150200a75..4b73ce68b13a9 100644 --- a/src/js/media/views/attachment.js +++ b/src/js/media/views/attachment.js @@ -17,7 +17,15 @@ Attachment = View.extend(/** @lends wp.media.view.Attachment.prototype */{ className: 'attachment', template: wp.template('attachment'), - attributes: function() { + /** + * Compute the accessible name for an attachment tile. + * + * Factored out of attributes() so render() can refresh the label when a + * model finishes loading after the view was created. See #65852. + * + * @return {string} Accessible name for the attachment. + */ + getAriaLabel: function() { var ariaLabel = this.model.get( 'title' ); if ( ! ariaLabel ) { @@ -28,10 +36,14 @@ Attachment = View.extend(/** @lends wp.media.view.Attachment.prototype */{ } } + return ariaLabel; + }, + + attributes: function() { return { 'tabIndex': 0, 'role': 'checkbox', - 'aria-label': ariaLabel, + 'aria-label': this.getAriaLabel(), 'aria-checked': false, 'data-id': this.model.get( 'id' ) }; @@ -161,6 +173,17 @@ Attachment = View.extend(/** @lends wp.media.view.Attachment.prototype */{ this.views.render(); + /* + * Backbone only evaluates attributes() when the element is created. + * When a tile is built for an id-only model that is still fetching, + * the aria-label is computed from empty data and never refreshes. + * Re-apply it here once the model has data. Skip subclasses that + * reset attributes (e.g. Attachment.Details). See #65852, #47458. + */ + if ( undefined !== _.result( this, 'attributes' )['aria-label'] ) { + this.$el.attr( 'aria-label', this.getAriaLabel() ); + } + return this; }, From ba6ce175ffbc4b1fe1bcc38a9267cf3fdadf9411 Mon Sep 17 00:00:00 2001 From: Andrea Fercia Date: Sat, 10 Oct 2026 14:59:20 +0200 Subject: [PATCH 2/3] Add since tag. --- src/js/media/views/attachment.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/js/media/views/attachment.js b/src/js/media/views/attachment.js index 4b73ce68b13a9..b30503486d7d4 100644 --- a/src/js/media/views/attachment.js +++ b/src/js/media/views/attachment.js @@ -23,6 +23,8 @@ Attachment = View.extend(/** @lends wp.media.view.Attachment.prototype */{ * Factored out of attributes() so render() can refresh the label when a * model finishes loading after the view was created. See #65852. * + * @since 7.2.0 + * * @return {string} Accessible name for the attachment. */ getAriaLabel: function() { From e36cc2840f6e14389e730d6251215ae7869c04c9 Mon Sep 17 00:00:00 2001 From: Andrea Fercia Date: Sat, 10 Oct 2026 15:53:12 +0200 Subject: [PATCH 3/3] Add QUnit tests. --- tests/qunit/index.html | 2 + .../js/media/test-attachment-view.js | 107 ++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 tests/qunit/wp-includes/js/media/test-attachment-view.js diff --git a/tests/qunit/index.html b/tests/qunit/index.html index be99fe0fe5924..5054ff954518d 100644 --- a/tests/qunit/index.html +++ b/tests/qunit/index.html @@ -53,6 +53,7 @@ + @@ -200,6 +201,7 @@ + ' ) + .appendTo( 'body' ); + }, + afterEach: function() { + $template.remove(); + } + } ); + + /** + * Creates an attachment view for a new model, with a minimal controller. + * + * @since 7.2.0 + * + * @param {Object} attributes Attributes for the attachment model. + * @param {Function} [ViewClass] View class to instantiate. Defaults to wp.media.view.Attachment. + * @param {wp.media.model.Selection} [selection] Selection the view should reflect. + * @return {wp.media.view.Attachment} The created view. + */ + function createView( attributes, ViewClass, selection ) { + var controller = { + state: function() { + return { get: function() {} }; + }, + isModeActive: function() { + return false; + }, + trigger: function() {}, + states: new wp.media.model.Attachments() + }, + model = new wp.media.model.Attachment( attributes ); + + ViewClass = ViewClass || wp.media.view.Attachment; + + return new ViewClass( { controller: controller, model: model, selection: selection } ); + } + + /* + * Trac #65852: Backbone evaluates attributes() only when the element is + * created, so a view built for an id-only model kept a stale aria-label + * after the model received its data. + */ + QUnit.test( 'aria-label is refreshed when the model title loads (Trac #65852)', function( assert ) { + var view = createView( { id: 123 } ); + + assert.strictEqual( view.el.getAttribute( 'aria-label' ), '(no title)', 'Initial label is the no-title fallback.' ); + + view.model.set( { title: 'Sunset' } ); + + assert.strictEqual( view.el.getAttribute( 'aria-label' ), 'Sunset', 'Label is updated to the loaded title.' ); + } ); + + QUnit.test( 'aria-label stays "uploading…" until the upload completes (Trac #65852)', function( assert ) { + var view = createView( { uploading: true, filename: 'sunset.jpg', percent: 0 } ); + + assert.strictEqual( view.el.getAttribute( 'aria-label' ), 'uploading…', 'Initial label is the uploading label.' ); + + view.model.set( { percent: 50 } ); + + assert.strictEqual( view.el.getAttribute( 'aria-label' ), 'uploading…', 'Label is unchanged while progress is reported.' ); + + view.model.set( { uploading: false, title: 'Sunset' } ); + + assert.strictEqual( view.el.getAttribute( 'aria-label' ), 'Sunset', 'Label is updated once the upload completes.' ); + } ); + + QUnit.test( 'untitled attachments keep the no-title label (Trac #65852)', function( assert ) { + var view = createView( { id: 123, title: '' } ); + + view.model.set( { caption: 'A caption' } ); + + assert.strictEqual( view.el.getAttribute( 'aria-label' ), '(no title)', 'Label remains the no-title fallback.' ); + } ); + + QUnit.test( 'aria-checked is not reset by re-rendering (Trac #65852)', function( assert ) { + var selection = new wp.media.model.Selection(), + view = createView( { id: 123 }, null, selection ); + + selection.add( view.model ); + view.render(); + + assert.strictEqual( view.el.getAttribute( 'aria-checked' ), 'true', 'The selected tile is checked.' ); + + view.model.set( { title: 'Sunset' } ); + + assert.strictEqual( view.el.getAttribute( 'aria-checked' ), 'true', 'Selection state is preserved after the label refresh.' ); + } ); + + QUnit.test( 'Attachment.Details does not receive an aria-label (Trac #65852)', function( assert ) { + var view = createView( { id: 123 }, wp.media.view.Attachment.Details ); + + view.model.set( { title: 'Sunset' } ); + + assert.notOk( view.el.hasAttribute( 'aria-label' ), 'No aria-label is added.' ); + assert.notOk( view.el.hasAttribute( 'role' ), 'No role is added.' ); + } ); +} )();