diff --git a/src/js/media/views/attachment.js b/src/js/media/views/attachment.js
index 152c150200a75..b30503486d7d4 100644
--- a/src/js/media/views/attachment.js
+++ b/src/js/media/views/attachment.js
@@ -17,7 +17,17 @@ 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.
+ *
+ * @since 7.2.0
+ *
+ * @return {string} Accessible name for the attachment.
+ */
+ getAriaLabel: function() {
var ariaLabel = this.model.get( 'title' );
if ( ! ariaLabel ) {
@@ -28,10 +38,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 +175,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;
},
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.' );
+ } );
+} )();