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.' ); + } ); +} )();