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.srcLarge}}',
imgWithCarousel: `
-
-
-

-
+
+
+
{{data.imageIndexString}}
+

+
+
-
+
{{#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');
+ });
+});