diff --git a/src/components/product.js b/src/components/product.js index 3cdd4b39..63e63de3 100644 --- a/src/components/product.js +++ b/src/components/product.js @@ -7,6 +7,7 @@ import formatMoney from '../utils/money'; import normalizeConfig from '../utils/normalize-config'; import browserFeatures from '../utils/detect-features'; import getUnitPriceBaseUnit from '../utils/unit-price'; +import parseTemplateString from '../utils/template-string'; import ProductView from '../views/product'; import ProductUpdater from '../updaters/product'; @@ -262,6 +263,7 @@ export default class Product extends Component { formattedUnitPriceBaseUnit: this.formattedUnitPriceBaseUnit, carouselIndex: 0, carouselImages: this.carouselImages, + imageIndexString: this.imageIndexString, }); } @@ -272,7 +274,7 @@ export default class Product extends Component { src: image.src, carouselSrc: this.props.client.image.helpers.imageForSize(image, {maxWidth: 100, maxHeight: 100}), isSelected: image.id === this.currentImage.id, - altText: this.imageAltText(image.altText), + ariaLabel: this.carouselImageLabel(image.altText), }; }); } @@ -361,7 +363,7 @@ export default class Product extends Component { [`click ${this.selectors.product.quantityIncrement}`]: this.onQuantityIncrement.bind(this, 1), [`click ${this.selectors.product.quantityDecrement}`]: this.onQuantityIncrement.bind(this, -1), [`blur ${this.selectors.product.quantityInput}`]: this.onQuantityBlur.bind(this), - [`click ${this.selectors.product.carouselItem}`]: this.onCarouselItemClick.bind(this), + [`click ${this.selectors.product.carouselItemLink}`]: this.onCarouselItemClick.bind(this), [`click ${this.selectors.product.carouselNext}`]: this.onCarouselChange.bind(this, 1), [`click ${this.selectors.product.carouselPrevious}`]: this.onCarouselChange.bind(this, -1), }, this.options.DOMEvents); @@ -847,6 +849,11 @@ export default class Product extends Component { return altText || this.model.title; } + carouselImageLabel(altText) { + const imageAlt = this.imageAltText(altText); + return `${this.options.text.carouselImageLinkAccessibilityLabel} ${imageAlt}`; + } + get priceAccessibilityLabel() { return this.hasCompareAtPrice ? this.options.text.salePriceAccessibilityLabel : this.options.text.regularPriceAccessibilityLabel; } @@ -858,4 +865,15 @@ export default class Product extends Component { get hasCompareAtPrice() { return Boolean(this.selectedVariant && this.selectedVariant.compareAtPriceV2); } + + get imageIndexString() { + const imageList = this.model.images; + const total = imageList.length; + + const currentImage = imageList.find((image) => { + return image.id === this.currentImage.id; + }); + const index = imageList.indexOf(currentImage) + 1; + return parseTemplateString(this.options.text.carouselImageIndexLabel, {index, total}); + } } diff --git a/src/defaults/components.js b/src/defaults/components.js index df58ebf2..5974d61a 100644 --- a/src/defaults/components.js +++ b/src/defaults/components.js @@ -48,10 +48,15 @@ const defaults = { img: 'shopify-buy__product__variant-img', imgWrapper: 'shopify-buy__product-img-wrapper', carousel: 'shopify-buy__carousel', - carouselNext: 'carousel-button--next', - carouselPrevious: 'carousel-button--previous', + carouselImgWrapper: 'shopify-buy__carousel-img-wrapper', + carouselButtons: 'shopify-buy__carousel__buttons', + carouselButton: 'shopify-buy__carousel__button', + carouselNext: 'shopify-buy__carousel__button--next', + carouselPrevious: 'shopify-buy__carousel__button--previous', + carouselButtonIcon: 'shopify-buy__carousel__button__icon', carouselItem: 'shopify-buy__carousel-item', carouselItemSelected: 'shopify-buy__carousel-item--selected', + carouselItemLink: 'shopify-buy__carousel-item__link', blockButton: 'shopify-buy__btn--parent', button: 'shopify-buy__btn', buttonWrapper: 'shopify-buy__btn-wrapper', @@ -84,6 +89,12 @@ const defaults = { unitPriceAccessibilitySeparator: 'per', regularPriceAccessibilityLabel: 'Regular price', salePriceAccessibilityLabel: 'Sale price', + carouselImageLinkAccessibilityLabel: 'Load image into gallery viewer.', + carouselAriaRoleDescription: 'Carousel', + carouselAriaLabel: 'Image gallery', + carouselPreviousImage: 'Previous image', + carouselNextImage: 'Next image', + carouselImageIndexLabel: 'Image ${index} of ${total}', }, }, modalProduct: { diff --git a/src/styles/embeds/sass/components/modal.css b/src/styles/embeds/sass/components/modal.css index caa06862..162d0cf3 100644 --- a/src/styles/embeds/sass/components/modal.css +++ b/src/styles/embeds/sass/components/modal.css @@ -155,10 +155,6 @@ padding-right: calc(var(--gutter) * 2); } - .shopify-buy__product__variant-img { - margin: 0 auto; - } - .shopify-buy__btn--close { top: -60px; color: color-mod(var(--color-white) lightness(+10%)); diff --git a/src/styles/embeds/sass/components/product.css b/src/styles/embeds/sass/components/product.css index e2fcd021..97b245b5 100644 --- a/src/styles/embeds/sass/components/product.css +++ b/src/styles/embeds/sass/components/product.css @@ -298,17 +298,20 @@ width: calc(16.666% - var(--gutter)); margin-left: var(--gutter); display: inline-block; - vertical-align: middle; - cursor: pointer; - position: relative; - background-size: cover; - background-position: center; - padding: 0; - border: none; &:nth-child(n+7) { margin-top: var(--gutter); } +} + +.shopify-buy__carousel-item__link { + display: block; + background-size: cover; + background-position: center; + cursor: pointer; + position: relative; + padding: 0; + border: none; &:before { content: ""; @@ -317,15 +320,16 @@ } } -.main-image-wrapper { +.shopify-buy__carousel-img-wrapper { position: relative; } -.carousel-button { - position: absolute; +.shopify-buy__carousel__buttons { + text-align: center; +} + +.shopify-buy__carousel__button { width: 75px; - top: 0; - height: 100%; border: none; font-size: 0; background-color: transparent; @@ -335,23 +339,21 @@ &:hover, &:focus { opacity: 0.9; - outline: none; } } -.carousel-button-arrow { - width: 20px; - display: inline-block; - margin-left: 25px; +.shopify-buy__carousel__button--next { + margin-left: 10px; } -.carousel-button--previous { - left: 0; - transform: rotate(180deg); +.shopify-buy__carousel__button--previous { + margin-right: 10px; } -.carousel-button--next { - right: 0; +.shopify-buy__carousel__button__icon { + width: 20px; + display: inline-block; + fill: currentColor; } .shopify-buy__carousel-item--selected { diff --git a/src/templates/product.js b/src/templates/product.js index 50cbc58b..7f9ad4e6 100644 --- a/src/templates/product.js +++ b/src/templates/product.js @@ -14,22 +14,27 @@ const buttonTemplate = '
{{data.currentImage.altText}}
{{/data.currentImage.srcLarge}}', imgWithCarousel: `
-
- - {{data.currentImage.altText}} - +
+
+ {{data.imageIndexString}} + {{data.currentImage.altText}} +
+
+ + +
-
+
    {{#data.carouselImages}} - +
  • + +
  • {{/data.carouselImages}} -
+
`, title: '

{{data.title}}

', variantTitle: '{{#data.hasVariants}}

{{data.selectedVariant.title}}

{{/data.hasVariants}}', diff --git a/src/utils/template-string.js b/src/utils/template-string.js new file mode 100644 index 00000000..cfa257e3 --- /dev/null +++ b/src/utils/template-string.js @@ -0,0 +1,9 @@ + +function parseTemplateString(string, replacements) { + return string.replace(/\${.+?}/g, (match) => { + const key = match.substr(2, match.length - 3).trim(); + return replacements[key]; + }); +} + +export default parseTemplateString; \ No newline at end of file diff --git a/test/fixtures/product-fixture.js b/test/fixtures/product-fixture.js index 5d306752..c92184aa 100644 --- a/test/fixtures/product-fixture.js +++ b/test/fixtures/product-fixture.js @@ -6,18 +6,22 @@ const testProduct = { { id: '1', src: 'https://cdn.shopify.com/s/files/1/0014/8583/2214/products/image-one.jpg', + altText: 'image one alt text', }, { id: '2', src: 'https://cdn.shopify.com/s/files/1/0014/8583/2214/products/image-two.jpeg', + altText: 'image two alt text', }, { id: '3', src: 'https://cdn.shopify.com/s/files/1/0014/8583/2214/products/image-three.jpg', + altText: 'image three alt text', }, { id: '4', src: 'https://cdn.shopify.com/s/files/1/0014/8583/2214/products/image-four.jpeg', + altText: 'image four alt text', }, ], options: [ diff --git a/test/test.js b/test/test.js index 32f2c49a..19c8fa22 100644 --- a/test/test.js +++ b/test/test.js @@ -26,6 +26,7 @@ import './unit/normalize-config'; import './unit/detect-features'; import './unit/unit-price'; import './unit/focus'; +import './unit/template-string'; window.chai = chai; window.sinon = sinon; diff --git a/test/unit/product/product-component.js b/test/unit/product/product-component.js index c4e711ee..2e721e50 100644 --- a/test/unit/product/product-component.js +++ b/test/unit/product/product-component.js @@ -13,6 +13,7 @@ import * as normalizeConfig from '../../../src/utils/normalize-config'; import * as formatMoney from '../../../src/utils/money'; import * as browserFeatures from '../../../src/utils/detect-features'; import * as getUnitPriceBaseUnit from '../../../src/utils/unit-price'; +import * as parseTemplateString from '../../../src/utils/template-string'; const rootImageURI = 'https://cdn.shopify.com/s/files/1/0014/8583/2214/products/'; @@ -809,6 +810,7 @@ describe('Product Component class', () => { const expectedImage = { id: '1', src: `${rootImageURI}image-one.jpg`, + altText: 'image one alt text', }; product.onCarouselItemClick(event, target); @@ -1103,6 +1105,7 @@ describe('Product Component class', () => { const expectedImage = { id: '1', src: `${rootImageURI}image-one.jpg`, + altText: 'image one alt text', }; product.updateVariant('Size', 'large'); assert.deepEqual(product.selectedImage, expectedImage); @@ -1168,11 +1171,30 @@ describe('Product Component class', () => { it('returns the passed in image alt text if it is valid', () => { assert.equal(product.imageAltText('test alt'), 'test alt'); - }) + }); it('returns the image title when alt text passed in is null', () => { assert.equal(product.imageAltText(null), product.model.title); - }) + }); + }); + + describe('carouselImageLabel()', () => { + beforeEach(async () => { + await product.init(testProductCopy); + }); + + it('returns a string containing the carousel image link accessibility label and image alt text', () => { + const mockImageAltText = 'image alt text'; + const altText = 'alt text'; + const imageAltTextStub = sinon.stub(product, 'imageAltText').returns(mockImageAltText); + const label = product.carouselImageLabel(altText); + + assert.calledOnce(imageAltTextStub); + assert.calledWith(imageAltTextStub, altText); + assert.equal(label, `${product.options.text.carouselImageLinkAccessibilityLabel} ${mockImageAltText}`); + + imageAltTextStub.restore(); + }); }); describe('getters', () => { @@ -1633,17 +1655,24 @@ describe('Product Component class', () => { it('returns an object with carouselImages', () => { assert.deepEqual(viewData.carouselImages, product.carouselImages); }); + + it('returns an object with imageIndexString', () => { + assert.equal(viewData.imageIndexString, product.imageIndexString); + }); }); describe('carouselImages', () => { let imageForSizeStub; let carouselImages; + let carouselImageLabelStub; + const mockImageLabel = 'mockImageLabel'; beforeEach(async () => { await product.init(testProductCopy); imageForSizeStub = sinon.stub(product.props.client.image.helpers, 'imageForSize').callsFake((image, dimensions) => { return dimensions; }); + carouselImageLabelStub = sinon.stub(product, 'carouselImageLabel').returns(mockImageLabel); product = Object.defineProperty(product, 'currentImage', { value: testProductCopy.images[0], }); @@ -1652,9 +1681,10 @@ describe('Product Component class', () => { afterEach(() => { imageForSizeStub.restore(); + carouselImageLabelStub.restore(); }); - it('returns an array of objects holding the id and src of each item in the model', () => { + it('returns an array of objects holding the id and of each item in the model', () => { assert.equal(carouselImages[0].id, testProductCopy.images[0].id); assert.equal(carouselImages[0].src, testProductCopy.images[0].src); @@ -1691,6 +1721,22 @@ describe('Product Component class', () => { assert.isFalse(carouselImages[2].isSelected); assert.isFalse(carouselImages[3].isSelected); }); + + it('returns an array of objects containing an aria label with the image alt text', () => { + assert.callCount(carouselImageLabelStub, testProductCopy.images.length); + + assert.calledWith(carouselImageLabelStub.getCall(0), testProductCopy.images[0].altText); + assert.deepEqual(carouselImages[0].ariaLabel, mockImageLabel); + + assert.calledWith(carouselImageLabelStub.getCall(1), testProductCopy.images[1].altText); + assert.deepEqual(carouselImages[1].ariaLabel, mockImageLabel); + + assert.calledWith(carouselImageLabelStub.getCall(2), testProductCopy.images[2].altText); + assert.deepEqual(carouselImages[2].ariaLabel, mockImageLabel); + + assert.calledWith(carouselImageLabelStub.getCall(3), testProductCopy.images[3].altText); + assert.deepEqual(carouselImages[3].ariaLabel, mockImageLabel); + }); }); describe('buttonClass', () => { @@ -2133,7 +2179,7 @@ describe('Product Component class', () => { it('binds onCarouselItemClick to carouselItem click', () => { const onCarouselItemClickStub = sinon.stub(product, 'onCarouselItemClick'); - product.DOMEvents[`click ${product.selectors.product.carouselItem}`](); + product.DOMEvents[`click ${product.selectors.product.carouselItemLink}`](); assert.calledOnce(onCarouselItemClickStub); onCarouselItemClickStub.restore(); }); @@ -2563,6 +2609,28 @@ describe('Product Component class', () => { assert.equal(product.hasCompareAtPrice, true); }); }); + + describe('imageIndexString', () => { + it('returns the parsed carousel image index label with index and total replacements', () => { + const mockTemplateString = 'mock template string'; + const parseTemplateStringStub = sinon.stub(parseTemplateString, 'default').returns(mockTemplateString); + const selectedIndex = 2; + product.model.images = testProductCopy.images; + product = Object.defineProperty(product, 'currentImage', { + value: testProductCopy.images[selectedIndex], + }); + const imageIndexString = product.imageIndexString; + + assert.calledOnce(parseTemplateStringStub); + assert.calledWith(parseTemplateStringStub, product.options.text.carouselImageIndexLabel, { + index: selectedIndex + 1, + total: testProductCopy.images.length, + }); + assert.equal(imageIndexString, mockTemplateString); + + parseTemplateStringStub.restore(); + }); + }); }); }); }); diff --git a/test/unit/template-string.js b/test/unit/template-string.js new file mode 100644 index 00000000..05020699 --- /dev/null +++ b/test/unit/template-string.js @@ -0,0 +1,14 @@ +import { assert } from 'chai'; +import parseTemplateString from '../../src/utils/template-string'; + +describe('parseTemplateString', () => { + it('returns the string if no placeholders are found', () => { + const string = 'string without templates'; + assert.equal(parseTemplateString(string), string); + }); + + it('returns a string with the placeholders replaced', () => { + const string = 'string with ${placeholder} templates'; + assert.equal(parseTemplateString(string, {placeholder: 'awesome'}), 'string with awesome templates'); + }); +});