Skip to content

Commit acf2d72

Browse files
authored
Merge pull request #919 from Shopify/feature/iframe-accessibility-title
🎯 Add `title` attribute to iframes for accessibility
2 parents d9f0831 + 7ebcdae commit acf2d72

File tree

7 files changed

+157
-1
lines changed

7 files changed

+157
-1
lines changed
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@shopify/buy-button-js": patch
3+
---
4+
5+
Add title attribute to iframes for improved accessibility
6+
7+
- Adds configurable `title` attribute to all iframe components for better screen reader support
8+
- Screen readers now announce meaningful descriptions instead of generic "frame" text
9+
- Each component type (product, cart, toggle, modal, productSet) has intelligent default titles
10+
- Titles can be customized via the component's text configuration
11+
- For toggle, modal, and productSet components, use the `iframeAccessibilityLabel` text property
12+
- For product and cart components, defaults to existing button/title text configurations

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
# Changelog
2+
### v3.0.6 (Unreleased)
3+
- Add title attribute to iframes for improved accessibility ([#919](https://github.com/Shopify/buy-button-js/pull/919))
4+
25
### v3.0.5 (July 2, 2025)
36
- Upgrade to v3.0.7 of `shopify-buy`
47

src/defaults/components.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,9 @@ const defaults = {
147147
},
148148
order: ['contents'],
149149
templates: modalTemplates,
150+
text: {
151+
iframeAccessibilityLabel: 'Product modal',
152+
},
150153
},
151154
productSet: {
152155
iframe: true,
@@ -173,6 +176,7 @@ const defaults = {
173176
},
174177
text: {
175178
nextPageButton: 'Next page',
179+
iframeAccessibilityLabel: 'Product collection',
176180
},
177181
},
178182
option: {
@@ -310,6 +314,7 @@ const defaults = {
310314
},
311315
text: {
312316
title: 'cart',
317+
iframeAccessibilityLabel: 'Cart toggle',
313318
},
314319
},
315320
window: {

src/iframe.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,12 @@ export default class iframe {
9797

9898
Object.keys(iframeAttrs).forEach((key) => this.el.setAttribute(key, iframeAttrs[key]));
9999
this.el.setAttribute('name', config.name);
100+
101+
// Add title attribute for accessibility
102+
if (config.title) {
103+
this.el.setAttribute('title', config.title);
104+
}
105+
100106
this.styleTag = null;
101107
}
102108

src/view.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,30 @@ export default class View {
2222
if (this.iframe || !this.component.options.iframe) {
2323
return Promise.resolve(this.iframe);
2424
}
25+
26+
// Determine title for iframe accessibility based on component type
27+
let iframeTitle = '';
28+
switch (this.component.typeKey) {
29+
case 'product':
30+
iframeTitle = (this.component.options.text && this.component.options.text.button) || 'Add to cart';
31+
break;
32+
case 'cart':
33+
iframeTitle = (this.component.options.text && this.component.options.text.title) || 'Cart';
34+
break;
35+
case 'toggle':
36+
iframeTitle = (this.component.options.text && this.component.options.text.iframeAccessibilityLabel) || 'Cart toggle';
37+
break;
38+
case 'modal':
39+
iframeTitle = (this.component.options.text && this.component.options.text.iframeAccessibilityLabel) || 'Product modal';
40+
break;
41+
case 'productSet':
42+
iframeTitle = (this.component.options.text && this.component.options.text.iframeAccessibilityLabel) || 'Product collection';
43+
break;
44+
default:
45+
iframeTitle = 'Shopify component';
46+
break;
47+
}
48+
2549
this.iframe = new Iframe(this.component.node, {
2650
classes: this.component.classes,
2751
customStyles: this.component.styles,
@@ -30,6 +54,7 @@ export default class View {
3054
googleFonts: this.component.googleFonts,
3155
name: this.component.name,
3256
width: this.component.options.layout === 'vertical' ? this.component.options.width : null,
57+
title: iframeTitle,
3358
});
3459
this.iframe.addClass(this.className);
3560
return this.iframe.load();

test/unit/iframe.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,9 +135,21 @@ describe('Iframe class', () => {
135135
});
136136

137137
it('sets element name to name in config', () => {
138+
iframe = new Iframe(parent, constructorConfig);
138139
assert.equal(iframe.el.getAttribute('name'), constructorConfig.name);
139140
});
140141

142+
it('sets title attribute when title is provided in config', () => {
143+
constructorConfig.title = 'Add to cart';
144+
iframe = new Iframe(parent, constructorConfig);
145+
assert.equal(iframe.el.getAttribute('title'), 'Add to cart');
146+
});
147+
148+
it('does not set title attribute when title is not provided in config', () => {
149+
iframe = new Iframe(parent, constructorConfig);
150+
assert.isNull(iframe.el.getAttribute('title'));
151+
});
152+
141153
it('sets styleTag to null', () => {
142154
iframe = new Iframe(parent, constructorConfig);
143155
assert.equal(iframe.styleTag = null);
@@ -508,4 +520,4 @@ describe('Iframe class', () => {
508520
});
509521
});
510522
});
511-
});
523+
});

test/unit/view.js

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,99 @@ describe('View class', () => {
9292
assert.calledOnce(loadStub);
9393
});
9494

95+
it('passes title to iframe constructor for product component', async () => {
96+
component.options.text = { button: 'ADD TO CART' };
97+
const iframeConstructorSpy = sinon.spy(Iframe);
98+
const originalIframe = Iframe;
99+
Iframe = iframeConstructorSpy;
100+
101+
await view.init();
102+
103+
assert.calledWith(iframeConstructorSpy, component.node, sinon.match({
104+
title: 'ADD TO CART'
105+
}));
106+
107+
Iframe = originalIframe;
108+
});
109+
110+
it('passes title to iframe constructor for cart component', async () => {
111+
component.typeKey = 'cart';
112+
component.options.text = { title: 'Cart' };
113+
const iframeConstructorSpy = sinon.spy(Iframe);
114+
const originalIframe = Iframe;
115+
Iframe = iframeConstructorSpy;
116+
117+
await view.init();
118+
119+
assert.calledWith(iframeConstructorSpy, component.node, sinon.match({
120+
title: 'Cart'
121+
}));
122+
123+
Iframe = originalIframe;
124+
});
125+
126+
it('passes title to iframe constructor for toggle component', async () => {
127+
component.typeKey = 'toggle';
128+
component.options.text = { iframeAccessibilityLabel: 'Cart toggle' };
129+
const iframeConstructorSpy = sinon.spy(Iframe);
130+
const originalIframe = Iframe;
131+
Iframe = iframeConstructorSpy;
132+
133+
await view.init();
134+
135+
assert.calledWith(iframeConstructorSpy, component.node, sinon.match({
136+
title: 'Cart toggle'
137+
}));
138+
139+
Iframe = originalIframe;
140+
});
141+
142+
it('passes title to iframe constructor for modal component', async () => {
143+
component.typeKey = 'modal';
144+
component.options.text = { iframeAccessibilityLabel: 'Product modal' };
145+
const iframeConstructorSpy = sinon.spy(Iframe);
146+
const originalIframe = Iframe;
147+
Iframe = iframeConstructorSpy;
148+
149+
await view.init();
150+
151+
assert.calledWith(iframeConstructorSpy, component.node, sinon.match({
152+
title: 'Product modal'
153+
}));
154+
155+
Iframe = originalIframe;
156+
});
157+
158+
it('passes title to iframe constructor for productSet component', async () => {
159+
component.typeKey = 'productSet';
160+
component.options.text = { iframeAccessibilityLabel: 'Product collection' };
161+
const iframeConstructorSpy = sinon.spy(Iframe);
162+
const originalIframe = Iframe;
163+
Iframe = iframeConstructorSpy;
164+
165+
await view.init();
166+
167+
assert.calledWith(iframeConstructorSpy, component.node, sinon.match({
168+
title: 'Product collection'
169+
}));
170+
171+
Iframe = originalIframe;
172+
});
173+
174+
it('passes default title when text is not provided', async () => {
175+
const iframeConstructorSpy = sinon.spy(Iframe);
176+
const originalIframe = Iframe;
177+
Iframe = iframeConstructorSpy;
178+
179+
await view.init();
180+
181+
assert.calledWith(iframeConstructorSpy, component.node, sinon.match({
182+
title: 'Add to cart'
183+
}));
184+
185+
Iframe = originalIframe;
186+
});
187+
95188
it('returns the response of iframe\'s load()', async () => {
96189
const response = await view.init();
97190
assert.equal(response, loadRes);

0 commit comments

Comments
 (0)