diff --git a/app/code/Magento/Swatches/view/frontend/templates/product/layered/renderer.phtml b/app/code/Magento/Swatches/view/frontend/templates/product/layered/renderer.phtml index 19b4002efb2a71289961b139cf41517c0c78e8ef..c8842b5b5be3a31f206e4cd9d98e0938a5342c2c 100644 --- a/app/code/Magento/Swatches/view/frontend/templates/product/layered/renderer.phtml +++ b/app/code/Magento/Swatches/view/frontend/templates/product/layered/renderer.phtml @@ -10,15 +10,19 @@ ?> <?php $swatchData = $block->getSwatchData(); ?> <div class="swatch-attribute swatch-layered <?php /* @escapeNotVerified */ echo $swatchData['attribute_code'] ?>" - attribute-code="<?php /* @escapeNotVerified */ echo $swatchData['attribute_code'] ?>" attribute-id="<?php /* @escapeNotVerified */ echo $swatchData['attribute_id'] ?>"> + attribute-code="<?php /* @escapeNotVerified */ echo $swatchData['attribute_code'] ?>" + attribute-id="<?php /* @escapeNotVerified */ echo $swatchData['attribute_id'] ?>"> <div class="swatch-attribute-options clearfix"> <?php foreach ($swatchData['options'] as $option => $label): ?> - <a href="<?php /* @escapeNotVerified */ echo $label['link'] ?>" class="swatch-option-link-layered"> + <a href="<?php /* @escapeNotVerified */ echo $label['link'] ?>" + aria-label="<?php /* @escapeNotVerified */ echo $label['label'] ?>" + class="swatch-option-link-layered"> <?php if (isset($swatchData['swatches'][$option]['type'])) { ?> <?php switch ($swatchData['swatches'][$option]['type']) { case '3': ?> <div class="swatch-option <?php /* @escapeNotVerified */ echo $label['custom_style'] ?>" + tabindex="-1" option-type="3" option-id="<?php /* @escapeNotVerified */ echo $option ?>" option-label="<?php /* @escapeNotVerified */ echo $label['label'] ?>" @@ -33,6 +37,7 @@ <?php $swatchImagePath = $block->getSwatchPath('swatch_image', $swatchData['swatches'][$option]['value']); ?> <div class="swatch-option image <?php /* @escapeNotVerified */ echo $label['custom_style'] ?>" + tabindex="-1" option-type="2" option-id="<?php /* @escapeNotVerified */ echo $option ?>" option-label="<?php /* @escapeNotVerified */ echo $label['label'] ?>" @@ -43,6 +48,7 @@ case '1': ?> <div class="swatch-option color <?php /* @escapeNotVerified */ echo $label['custom_style'] ?>" + tabindex="-1" option-type="1" option-id="<?php /* @escapeNotVerified */ echo $option ?>" option-label="<?php /* @escapeNotVerified */ echo $label['label'] ?>" @@ -54,6 +60,7 @@ default: ?> <div class="swatch-option text <?php /* @escapeNotVerified */ echo $label['custom_style'] ?>" + tabindex="-1" option-type="0" option-id="<?php /* @escapeNotVerified */ echo $option ?>" option-label="<?php /* @escapeNotVerified */ echo $label['label'] ?>" diff --git a/app/code/Magento/Swatches/view/frontend/web/css/swatches.css b/app/code/Magento/Swatches/view/frontend/web/css/swatches.css index 8204fc7fc8f630f64ace7caa37567550354f62cf..3899a663788b0f308ce5bf4c7b0f8da4389c4245 100644 --- a/app/code/Magento/Swatches/view/frontend/web/css/swatches.css +++ b/app/code/Magento/Swatches/view/frontend/web/css/swatches.css @@ -210,6 +210,10 @@ padding: 0 !important; } +.swatch-option-link-layered:focus > div { + box-shadow: 0 0 3px 1px #68a8e0; +} + .swatch-option-tooltip-layered { width: 140px; position: absolute; @@ -276,3 +280,9 @@ .swatch-option-loading { content: url("../images/loader-2.gif"); } + +.swatch-input { + left: -1000px; + position: absolute; + visibility: hidden; +} diff --git a/app/code/Magento/Swatches/view/frontend/web/js/swatch-renderer.js b/app/code/Magento/Swatches/view/frontend/web/js/swatch-renderer.js index dfd4b2bb2da0ffcc19286dafd125be59d980f804..4e79c42dd6adbf4eb1f079f569ac3aeb77799241 100644 --- a/app/code/Magento/Swatches/view/frontend/web/js/swatch-renderer.js +++ b/app/code/Magento/Swatches/view/frontend/web/js/swatch-renderer.js @@ -6,11 +6,57 @@ define([ 'jquery', 'underscore', + 'mage/smart-keyboard-handler', 'jquery/ui', - 'jquery/jquery.parsequery' -], function ($, _) { + 'jquery/jquery.parsequery', + 'mage/validation/validation' +], function ($, _, keyboardHandler) { 'use strict'; + /** + * Extend form validation to support swatch accessibility + */ + $.widget('mage.validation', $.mage.validation, { + /** + * Handle form with swatches validation. Focus on first invalid swatch block. + * + * @param {jQuery.Event} event + * @param {Object} validation + */ + listenFormValidateHandler: function (event, validation) { + var swatchWrapper, firstActive, swatches, swatch, successList, errorList, firstSwatch; + + this._superApply(arguments); + + swatchWrapper = '.swatch-attribute-options'; + swatches = $(event.target).find(swatchWrapper); + + if (!swatches.length) { + return; + } + + swatch = '.swatch-attribute'; + firstActive = $(validation.errorList[0].element || []); + successList = validation.successList; + errorList = validation.errorList; + firstSwatch = $(firstActive).parent(swatch).find(swatchWrapper); + + keyboardHandler.focus(swatches); + + $.each(successList, function (index, item) { + $(item).parent(swatch).find(swatchWrapper).attr('aria-invalid', false); + }); + + $.each(errorList, function (index, item) { + $(item.element).parent(swatch).find(swatchWrapper).attr('aria-invalid', true); + }); + + if (firstSwatch.length) { + $(firstSwatch).focus(); + } + } + }); + /** * Render tooltips by attributes (only to up). * Required element attributes: @@ -117,6 +163,7 @@ define([ $element.hide(); clearTimeout(timer); }); + $(document).on('tap', function () { $element.hide(); clearTimeout(timer); @@ -169,6 +216,9 @@ define([ //selector of product images gallery wrapper mediaGallerySelector: '[data-gallery-role=gallery-placeholder]', + // selector of category product tile wrapper + selectorProductTile: '.product-item', + // number of controls to show (false or zero = show all) numberToShow: false, @@ -178,6 +228,9 @@ define([ // enable label for control enableControlLabel: true, + // control label id + controlLabelId: '', + // text for more button moreButtonText: 'More', @@ -191,7 +244,10 @@ define([ mediaGalleryInitial: [{}], // - onlyMainImg: false + onlyMainImg: false, + + // whether swatches are rendered in product list or on product page + inProductList: false }, /** @@ -246,7 +302,9 @@ define([ 'img': $main.find('.product-image-photo').attr('src') }]; } - this.productForm = this.element.parents(this.options.selectorProduct).find('form:first'); + + this.productForm = this.element.parents(this.options.selectorProductTile).find('form:first'); + this.inProductList = this.productForm.length > 0; }, /** @@ -264,9 +322,11 @@ define([ $.each(this.options.jsonConfig.attributes, function () { var item = this, - options = $widget._RenderSwatchOptions(item), + controlLabelId = 'option-label-' + item.code + '-' + item.id, + options = $widget._RenderSwatchOptions(item, controlLabelId), select = $widget._RenderSwatchSelect(item, chooseText), input = $widget._RenderFormInput(item), + listLabel = '', label = ''; // Show only swatch controls @@ -276,22 +336,32 @@ define([ if ($widget.options.enableControlLabel) { label += - '<span class="' + classes.attributeLabelClass + '">' + item.label + '</span>' + + '<span id="' + controlLabelId + '" class="' + classes.attributeLabelClass + '">' + + item.label + + '</span>' + '<span class="' + classes.attributeSelectedOptionLabelClass + '"></span>'; } - if ($widget.productForm) { + if ($widget.inProductList) { $widget.productForm.append(input); input = ''; + listLabel = 'aria-label="' + item.label + '"'; + } else { + listLabel = 'aria-labelledby="' + controlLabelId + '"'; } // Create new control container.append( - '<div class="' + classes.attributeClass + ' ' + item.code + - '" attribute-code="' + item.code + - '" attribute-id="' + item.id + '">' + - label + - '<div class="' + classes.attributeOptionsWrapper + ' clearfix">' + + '<div class="' + classes.attributeClass + ' ' + item.code + '" ' + + 'attribute-code="' + item.code + '" ' + + 'attribute-id="' + item.id + '">' + + label + + '<div aria-activedescendant="" ' + + 'tabindex="0" ' + + 'aria-invalid="false" ' + + 'aria-required="true" ' + + 'role="listbox" ' + listLabel + + 'class="' + classes.attributeOptionsWrapper + ' clearfix">' + options + select + '</div>' + input + '</div>' @@ -336,10 +406,11 @@ define([ * Render swatch options by part of config * * @param {Object} config + * @param {String} controlId * @returns {String} * @private */ - _RenderSwatchOptions: function (config) { + _RenderSwatchOptions: function (config, controlId) { var optionConfig = this.options.jsonSwatchConfig[config.id], optionClass = this.options.classes.optionClass, moreLimit = parseInt(this.options.numberToShow, 10), @@ -375,11 +446,17 @@ define([ thumb = optionConfig[id].hasOwnProperty('thumb') ? optionConfig[id].thumb : ''; label = this.label ? this.label : ''; attr = + ' id="' + controlId + '-item-' + id + '"' + + ' aria-checked="false"' + + ' aria-describedby="' + controlId + '"' + + ' tabindex="0"' + ' option-type="' + type + '"' + ' option-id="' + id + '"' + ' option-label="' + label + '"' + + ' aria-label="' + label + '"' + ' option-tooltip-thumb="' + thumb + '"' + - ' option-tooltip-value="' + value + '"'; + ' option-tooltip-value="' + value + '"' + + ' role="option"'; if (!this.hasOwnProperty('products') || this.products.length <= 0) { attr += ' option-empty="true"'; @@ -392,19 +469,19 @@ define([ } else if (type === 1) { // Color html += '<div class="' + optionClass + ' color" ' + attr + - '" style="background: ' + value + + ' style="background: ' + value + ' no-repeat center; background-size: initial;">' + '' + '</div>'; } else if (type === 2) { // Image html += '<div class="' + optionClass + ' image" ' + attr + - '" style="background: url(' + value + ') no-repeat center; background-size: initial;">' + '' + + ' style="background: url(' + value + ') no-repeat center; background-size: initial;">' + '' + '</div>'; } else if (type === 3) { // Clear html += '<div class="' + optionClass + '" ' + attr + '></div>'; } else { - // Defaualt + // Default html += '<div class="' + optionClass + '" ' + attr + '>' + label + '</div>'; } }); @@ -460,10 +537,9 @@ define([ 'type="text" ' + 'value="" ' + 'data-selector="super_attribute[' + config.id + ']" ' + - 'data-validate="{required:true}" ' + + 'data-validate="{required: true}" ' + 'aria-required="true" ' + - 'aria-invalid="true" ' + - 'style="visibility: hidden; position:absolute; left:-1000px">'; + 'aria-invalid="false">'; }, /** @@ -472,22 +548,39 @@ define([ * @private */ _EventListener: function () { + var $widget = this, + options = this.options.classes, + target; - var $widget = this; - - $widget.element.on('click', '.' + this.options.classes.optionClass, function () { + $widget.element.on('click', '.' + options.optionClass, function () { return $widget._OnClick($(this), $widget); }); - $widget.element.on('change', '.' + this.options.classes.selectClass, function () { + $widget.element.on('change', '.' + options.selectClass, function () { return $widget._OnChange($(this), $widget); }); - $widget.element.on('click', '.' + this.options.classes.moreButton, function (e) { + $widget.element.on('click', '.' + options.moreButton, function (e) { e.preventDefault(); return $widget._OnMoreClick($(this)); }); + + $widget.element.on('keydown', function (e) { + if (e.which === 13) { + target = $(e.target); + + if (target.is('.' + options.optionClass)) { + return $widget._OnClick(target, $widget); + } else if (target.is('.' + options.selectClass)) { + return $widget._OnChange(target, $widget); + } else if (target.is('.' + options.moreButton)) { + e.preventDefault(); + + return $widget._OnMoreClick(target); + } + } + }); }, /** @@ -498,13 +591,17 @@ define([ * @private */ _OnClick: function ($this, $widget) { - var $parent = $this.parents('.' + $widget.options.classes.attributeClass), + $wrapper = $this.parents('.' + $widget.options.classes.attributeOptionsWrapper), $label = $parent.find('.' + $widget.options.classes.attributeSelectedOptionLabelClass), attributeId = $parent.attr('attribute-id'), + $input = $parent.find('.' + $widget.options.classes.attributeInput); + + if ($widget.inProductList) { $input = $widget.productForm.find( '.' + $widget.options.classes.attributeInput + '[name="super_attribute[' + attributeId + ']"]' ); + } if ($this.hasClass('disabled')) { return; @@ -514,11 +611,13 @@ define([ $parent.removeAttr('option-selected').find('.selected').removeClass('selected'); $input.val(''); $label.text(''); + $this.attr('aria-checked', false); } else { $parent.attr('option-selected', $this.attr('option-id')).find('.selected').removeClass('selected'); $label.text($this.attr('option-label')); $input.val($this.attr('option-id')); $this.addClass('selected'); + $widget._toggleCheckedAttributes($this, $wrapper); } $widget._Rebuild(); @@ -533,6 +632,19 @@ define([ $input.trigger('change'); }, + /** + * Toggle accessibility attributes + * + * @param {Object} $this + * @param {Object} $wrapper + * @private + */ + _toggleCheckedAttributes: function ($this, $wrapper) { + $wrapper.attr('aria-activedescendant', $this.attr('id')) + .find('.' + this.options.classes.optionClass).attr('aria-checked', false); + $this.attr('aria-checked', true); + }, + /** * Event for select * @@ -543,9 +655,13 @@ define([ _OnChange: function ($this, $widget) { var $parent = $this.parents('.' + $widget.options.classes.attributeClass), attributeId = $parent.attr('attribute-id'), + $input = $parent.find('.' + $widget.options.classes.attributeInput); + + if ($widget.productForm.length > 0) { $input = $widget.productForm.find( '.' + $widget.options.classes.attributeInput + '[name="super_attribute[' + attributeId + ']"]' ); + } if ($this.val() > 0) { $parent.attr('option-selected', $this.val()); @@ -687,7 +803,6 @@ define([ 'prices': $widget._getPrices(result, $productPrice.priceBox('option').prices) } ); - }, /** diff --git a/lib/web/mage/smart-keyboard-handler.js b/lib/web/mage/smart-keyboard-handler.js index 859ab69372e09515f967a10a42c421ea7b750341..b6a65f2ee5d43b43ae91722b43507c39febd1941 100644 --- a/lib/web/mage/smart-keyboard-handler.js +++ b/lib/web/mage/smart-keyboard-handler.js @@ -16,7 +16,8 @@ define([ CODE_TAB = 9; return { - apply: smartKeyboardFocus + apply: smartKeyboardFocus, + focus: handleFocus }; /** @@ -49,22 +50,39 @@ define([ * Handle logic, when onTabKeyPress fired at first. * Then it changes state. */ - function onFocusInHandler () { + function onFocusInHandler() { focusState = true; - $('body').addClass(tabFocusClass) + body.addClass(tabFocusClass) .off('focusin.keyboardHandler', onFocusInHandler); } /** * Handle logic to remove state after onTabKeyPress to normal. - * @param {Event} event */ - function onClickHandler(event) { - focusState = false; - $('body').removeClass(tabFocusClass) + function onClickHandler() { + focusState = false; + body.removeClass(tabFocusClass) .off('click', onClickHandler); } + + /** + * Attach smart focus on specific element. + * @param {jQuery} element + */ + function handleFocus(element) { + element.on('focusin.emulateTabFocus', function () { + focusState = true; + body.addClass(tabFocusClass); + element.off(); + }); + + element.on('focusout.emulateTabFocus', function () { + focusState = false; + body.removeClass(tabFocusClass); + element.off(); + }); + } } return new KeyboardHandler; -}); \ No newline at end of file +}); diff --git a/lib/web/mage/validation.js b/lib/web/mage/validation.js index 6c8deba8164112a31939b11d1ed79b5c23dd7bc0..df2cd0288648e4a33b6e724c4141baf31d3a4d17 100644 --- a/lib/web/mage/validation.js +++ b/lib/web/mage/validation.js @@ -1543,6 +1543,7 @@ this.validate.resetForm(); } }, + /** * Validation creation * @protected @@ -1559,36 +1560,46 @@ this._listenFormValidate(); }, + /** * Validation listening * @protected */ _listenFormValidate: function () { - $('form').on('invalid-form.validate', function (event, validation) { - var firstActive = $(validation.errorList[0].element || []), - lastActive = $(validation.findLastActive() || validation.errorList.length && validation.errorList[0].element || []); - - if (lastActive.is(':hidden')) { - var parent = lastActive.parent(); - var windowHeight = $(window).height(); - $('html, body').animate({ - scrollTop: parent.offset().top - windowHeight / 2 - }); - } - - // ARIA (removing aria attributes if success) - var successList = validation.successList; - if (successList.length) { - $.each(successList, function () { - $(this) - .removeAttr('aria-describedby') - .removeAttr('aria-invalid'); - }) - } - if (firstActive.length) { - firstActive.focus(); - } - }); + $('form').on('invalid-form.validate', this.listenFormValidateHandler); + }, + + /** + * Handle form validation. Focus on first invalid form field. + * + * @param {jQuery.Event} event + * @param {Object} validation + */ + listenFormValidateHandler: function (event, validation) { + var firstActive = $(validation.errorList[0].element || []), + lastActive = $(validation.findLastActive() || validation.errorList.length && validation.errorList[0].element || []); + + if (lastActive.is(':hidden')) { + var parent = lastActive.parent(); + var windowHeight = $(window).height(); + $('html, body').animate({ + scrollTop: parent.offset().top - windowHeight / 2 + }); + } + + // ARIA (removing aria attributes if success) + var successList = validation.successList; + if (successList.length) { + $.each(successList, function () { + $(this) + .removeAttr('aria-describedby') + .removeAttr('aria-invalid'); + }); + } + + if (firstActive.length) { + firstActive.focus(); + } } });