Skip to content

Commit db35bc1

Browse files
authored
Merge pull request #758 from tblivet/feat/EAA-3
[ACCESSIBILITY - PART 3] Product page improvements
2 parents afcc9e2 + 5a6607e commit db35bc1

17 files changed

Lines changed: 260 additions & 92 deletions

File tree

modules/ps_sharebuttons/views/templates/hook/ps_sharebuttons.tpl

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@
1010
<ul class="ps-sharebuttons__list">
1111
{foreach from=$social_share_links item='social_share_link'}
1212
<li class="{$social_share_link.class}">
13-
<a href="{$social_share_link.url}" class="ps-sharebuttons__link" title="{$social_share_link.label}" target="_blank" rel="noopener noreferrer">
13+
<a href="{$social_share_link.url}" class="ps-sharebuttons__link" target="_blank" rel="noopener noreferrer"
14+
{if $social_share_link.class}
15+
aria-label="{l s='Share on %social_share_link_label%' sprintf=['%social_share_link_label%' => $social_share_link.class] d='Shop.Theme.Actions'}"
16+
{/if}
17+
>
1418
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
1519
{if $social_share_link.class == 'facebook'}
1620
<path d="M16 8.049c0-4.446-3.582-8.05-8-8.05C3.58 0-.002 3.603-.002 8.05c0 4.017 2.926 7.347 6.75 7.951v-5.625h-2.03V8.05H6.75V6.275c0-2.017 1.195-3.131 3.022-3.131.876 0 1.791.157 1.791.157v1.98h-1.009c-.993 0-1.303.621-1.303 1.258v1.51h2.218l-.354 2.326H9.25V16c3.824-.604 6.75-3.934 6.75-7.951"/>

src/js/accessibility/product.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* For the full copyright and license information, please view the LICENSE
3+
* file that was distributed with this source code.
4+
*/
5+
6+
import {Carousel} from 'bootstrap';
7+
import SelectorsMap from '../constants/selectors-map';
8+
9+
export default () => {
10+
const {prestashop, Theme: {events}} = window;
11+
12+
// Product images sync on open/close modal
13+
document.addEventListener('show.bs.modal', (event) => {
14+
const modal = event.target as HTMLElement;
15+
16+
if (!modal.matches(SelectorsMap.product.productImagesModal)) return;
17+
18+
const modalCarouselElement = modal.querySelector(SelectorsMap.product.productImagesModalCarousel);
19+
const mainCarouselElement = document.querySelector(SelectorsMap.product.carousel);
20+
21+
if (!modalCarouselElement || !mainCarouselElement) return;
22+
23+
const modalCarousel = Carousel.getOrCreateInstance(modalCarouselElement as HTMLElement);
24+
const activeIndex = getActiveSlideIndex(mainCarouselElement as HTMLElement);
25+
26+
if (activeIndex !== -1) {
27+
modalCarousel.to(activeIndex);
28+
}
29+
});
30+
31+
document.addEventListener('hide.bs.modal', (event) => {
32+
const modal = event.target as HTMLElement;
33+
34+
if (!modal.matches(SelectorsMap.product.productImagesModal)) return;
35+
36+
const modalCarouselElement = modal.querySelector(SelectorsMap.product.productImagesModalCarousel);
37+
const mainCarouselElement = document.querySelector(SelectorsMap.product.carousel);
38+
39+
if (!modalCarouselElement || !mainCarouselElement) return;
40+
41+
const mainCarousel = Carousel.getOrCreateInstance(mainCarouselElement as HTMLElement);
42+
const activeIndex = getActiveSlideIndex(modalCarouselElement as HTMLElement);
43+
44+
if (activeIndex !== -1) {
45+
mainCarousel.to(activeIndex);
46+
}
47+
});
48+
49+
const getActiveSlideIndex = (carouselEl: HTMLElement): number => {
50+
const items = carouselEl.querySelectorAll('.carousel-item');
51+
52+
return Array.from(items).findIndex((item) => item.classList.contains('active'));
53+
};
54+
55+
// Stores the element to refocus, per context ("main" or "quickview")
56+
const focusMap = new Map<'quickview' | 'main', { id: string }>();
57+
58+
// Save the element before the update
59+
prestashop.on(events.updateProduct, ({event}: { event: Event }) => {
60+
const target = event?.target as HTMLElement;
61+
62+
if (!target || !target.id) return;
63+
64+
const isInQuickView = !!target.closest(SelectorsMap.quickviewModal);
65+
const context: 'quickview' | 'main' = isInQuickView ? 'quickview' : 'main';
66+
67+
focusMap.set(context, {id: target.id});
68+
});
69+
70+
// Restore focus after update
71+
prestashop.on(events.updatedProduct, () => {
72+
focusMap.forEach((focusData, context) => {
73+
let container: HTMLElement | null = null;
74+
75+
if (context === 'quickview') {
76+
container = document.querySelector(SelectorsMap.quickviewModal);
77+
} else {
78+
container = document.querySelector(SelectorsMap.product.container);
79+
}
80+
81+
if (!container) return;
82+
83+
const elementToFocus = container.querySelector<HTMLElement>(`#${focusData.id}`);
84+
85+
if (elementToFocus) {
86+
elementToFocus.focus();
87+
88+
// Emit event to when the focus is restored
89+
prestashop.emit(events.combinationFocusRestored, {
90+
context,
91+
elementId: focusData.id,
92+
});
93+
}
94+
95+
focusMap.delete(context);
96+
});
97+
});
98+
99+
// Product availability messages announcement
100+
prestashop.on(events.combinationFocusRestored, ({context}: { context: 'quickview' | 'main' }) => {
101+
let container: HTMLElement | null = null;
102+
103+
// Get the container of the context
104+
if (context === 'quickview') {
105+
container = document.querySelector(SelectorsMap.quickviewModal);
106+
} else {
107+
container = document.querySelector(SelectorsMap.product.container);
108+
}
109+
110+
if (!container) return;
111+
112+
const productAvailabilityElement = container.querySelector(SelectorsMap.product.productAvailability);
113+
114+
if (!productAvailabilityElement) return;
115+
116+
// Delay to ensure focus announcement finishes first
117+
setTimeout(() => {
118+
productAvailabilityElement.setAttribute('aria-live', 'polite');
119+
}, 250);
120+
});
121+
};

src/js/constants/events-map.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ export default {
1515
updatedProduct: 'updatedProduct',
1616
updateFacets: 'updateFacets',
1717
updatedDeliveryForm: 'updatedDeliveryForm',
18+
combinationFocusRestored: 'combinationFocusRestored',
1819
};

src/js/constants/selectors-map.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,10 @@ const selectorsMap = {
179179
carousel: '.js-product-carousel',
180180
miniature: '.js-product-miniature',
181181
thumbnail: '.js-thumb-container',
182+
productImagesModal: '[data-ps-ref="product-images-modal"]',
183+
productImagesModalCarousel: '[data-ps-ref="product-images-modal-carousel"]',
182184
activeThumbail: (id: number): string => `.js-thumb-container:nth-child(${id + 1})`,
185+
productAvailability: '[data-ps-ref="product-availability"]',
183186
},
184187
order: {
185188
returnForm: '.js-order-return-form',

src/js/theme.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import initDesktopMenu from './modules/ps_mainmenu';
2929
import initFormValidation from './form-validation';
3030
import initCategoryTree from './modules/ps_categorytree';
3131
import initScrollPaddingTop from './helpers/scrollPadding';
32+
import initProductAccessibility from './accessibility/product';
3233

3334
initEmitter();
3435

@@ -55,6 +56,8 @@ document.addEventListener('DOMContentLoaded', () => {
5556
initCategoryTree();
5657
initScrollPaddingTop();
5758
initBlockCart();
59+
// Accessibility
60+
initProductAccessibility();
5861

5962
prestashop.on(events.responsiveUpdate, () => {
6063
initSearchbar();

src/scss/prestashop/pages/_product.scss

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,20 @@ $component-name: product;
1616
}
1717

1818
&__thumbnail {
19+
padding: 0;
20+
background-color: transparent;
21+
border: none;
22+
border-radius: var(--bs-border-radius);
23+
1924
&.active .#{$component-name}__thumbnail-image {
20-
outline: 2px solid var(--bs-primary);
25+
outline: 0.125rem solid var(--bs-primary);
26+
outline-offset: -0.125rem;
2127
}
2228

2329
&-image {
2430
border-radius: var(--bs-border-radius);
25-
outline: 2px solid transparent;
31+
outline: 0.125rem solid transparent;
32+
outline-offset: -0.125rem;
2633
}
2734
}
2835

templates/_partials/notifications.tpl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444

4545
{if $notifications.info}
4646
{block name='notifications_info'}
47-
<article class="alert alert-info" role="alert" data-alert="info">
47+
<article class="alert alert-info" role="status" data-alert="info">
4848
<ul class="mb-0">
4949
{foreach $notifications.info as $notif}
5050
<li>{$notif nofilter}</li>

templates/catalog/_partials/miniatures/product-pack.tpl

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@
44
*}
55
{block name='pack_miniature_item'}
66
<article class="product-pack__item">
7-
<a href="{$product.url}" title="{$product.name}" class="product-pack__link">
8-
<div class="product-pack__image-wrapper">
7+
<a href="{$product.url}"
8+
class="product-pack__link"
9+
aria-labelledby="pack-product-{$product.id_product}"
10+
>
11+
<span class="product-pack__image-wrapper">
912
{if !empty($product.default_image)}
1013
<picture>
1114
{if isset($product.default_image.bySize.default_xs.sources.avif)}
@@ -69,21 +72,25 @@
6972
>
7073
</picture>
7174
{/if}
72-
</div>
75+
</span>
7376

74-
<p class="product-pack__name">
77+
<span class="product-pack__name">
7578
{$product.name}
76-
</p>
79+
</span>
7780

7881
{if $showPackProductsPrice}
79-
<p class="product-pack__price">
82+
<span class="product-pack__price">
8083
{$product.price}
81-
</p>
84+
</span>
8285
{/if}
8386

84-
<p class="product-pack__quantity">
87+
<span class="product-pack__quantity">
8588
x{$product.pack_quantity}
86-
</p>
89+
</span>
90+
91+
<span id="pack-product-{$product.id_product}" class="visually-hidden">
92+
{l s='View product %product_name%, part of the pack.' sprintf=['%product_name%' => $product.name] d='Shop.Theme.Catalog'} {l s='Quantity inside the pack: %quantity%.' sprintf=['%quantity%' => $product.pack_quantity] d='Shop.Theme.Catalog'} {if $showPackProductsPrice}{l s='Price: %price%.' sprintf=['%price%' => $product.price] d='Shop.Theme.Catalog'}{/if}
93+
</span>
8794
</a>
8895
</article>
8996
{/block}

templates/catalog/_partials/product-add-to-cart.tpl

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,15 @@
2727
{/if}
2828

2929
{** And render the availability message with icon *}
30-
<div class="product__availability-status {$availability_class}" role="alert">
30+
<div class="product__availability-status {$availability_class}" aria-live="off" data-ps-ref="product-availability">
3131
<i class="product__availability-icon material-icons rtl-no-flip">&#x{$availability_icon};</i>
3232

3333
<div class="product__availability-messages">
34+
<span class="visually-hidden">{l s='Product availability:' d='Shop.Theme.Global'}</span>
3435
<span>{$product.availability_message}</span>
3536

3637
{if !empty($product.availability_submessage)}
37-
<br>
38-
<small>{$product.availability_submessage}</small>
38+
<small class="d-block">{$product.availability_submessage}</small>
3939
{/if}
4040
</div>
4141
</div>
@@ -79,9 +79,12 @@
7979
data-button-action="add-to-cart"
8080
type="submit"
8181
{if !$product.add_to_cart_url}
82+
aria-disabled="true"
8283
disabled
8384
{/if}
8485
data-ps-ref="add-to-cart"
86+
aria-label="{l s='Add to cart %product_name%' sprintf=['%product_name%' => $product.name] d='Shop.Theme.Actions'}"
87+
title="{l s='Add to cart %product_name%' sprintf=['%product_name%' => $product.name] d='Shop.Theme.Actions'}"
8588
>
8689
<i class="material-icons" aria-hidden="true">&#xE547;</i>
8790
{l s='Add to cart' d='Shop.Theme.Actions'}

templates/catalog/_partials/product-cover-thumbnails.tpl

Lines changed: 31 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,29 +8,13 @@
88
<div
99
id="product-images-{$product.id}"
1010
class="product__carousel carousel slide js-product-carousel"
11-
data-bs-ride="carousel"
1211
>
13-
<div class="carousel-inner">
14-
{include file='catalog/_partials/product-flags.tpl'}
15-
16-
{if $product.images|@count > 1}
17-
<button class="carousel-control-prev outline outline--rounded" type="button" data-bs-target="#product-images-{$product.id}" data-bs-slide="prev">
18-
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
19-
<span class="visually-hidden">{l s='Previous image' d='Shop.Theme.Global'}</span>
20-
</button>
21-
22-
<button class="carousel-control-next outline outline--rounded" type="button" data-bs-target="#product-images-{$product.id}" data-bs-slide="next">
23-
<span class="carousel-control-next-icon" aria-hidden="true"></span>
24-
<span class="visually-hidden">{l s='Next image' d='Shop.Theme.Global'}</span>
25-
</button>
26-
{/if}
12+
{include file='catalog/_partials/product-flags.tpl'}
2713

14+
<div class="carousel-inner">
2815
{block name='product_cover'}
2916
{foreach from=$product.images item=image key=key name=productImages}
30-
<div class="carousel-item{if $image.id_image == $product.default_image.id_image} active{/if}"
31-
data-bs-target="#product-images-modal-{$product.id}"
32-
data-bs-slide-to="{$key}"
33-
>
17+
<div class="carousel-item{if $image.id_image == $product.default_image.id_image} active{/if}">
3418
<picture>
3519
{if isset($image.bySize.default_xl.sources.avif)}
3620
<source
@@ -67,28 +51,42 @@
6751
data-full-size-image-url="{$image.bySize.home_default.url}"
6852
>
6953
</picture>
70-
71-
<div class="product__zoom btn btn-tertiary btn-square-icon" data-bs-toggle="modal" data-bs-target="#product-modal">
72-
<i class="material-icons">&#xE8B6;</i>
73-
</div>
7454
</div>
7555
{/foreach}
7656
{/block}
7757
</div>
58+
59+
{if $product.images|@count > 1}
60+
<button class="carousel-control-prev outline outline--rounded" type="button" data-bs-target="#product-images-{$product.id}" data-bs-slide="prev">
61+
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
62+
<span class="visually-hidden">{l s='Previous image' d='Shop.Theme.Global'}</span>
63+
</button>
64+
65+
<button class="carousel-control-next outline outline--rounded" type="button" data-bs-target="#product-images-{$product.id}" data-bs-slide="next">
66+
<span class="carousel-control-next-icon" aria-hidden="true"></span>
67+
<span class="visually-hidden">{l s='Next image' d='Shop.Theme.Global'}</span>
68+
</button>
69+
{/if}
70+
71+
{block name='product_images_modal_button'}
72+
<button class="product__zoom btn btn-tertiary outline outline--rounded btn-square-icon" data-bs-toggle="modal" data-bs-target="#product-modal" aria-label="{l s='Open zoomed product image gallery' d='Shop.Theme.Global'}" title="{l s='Open zoomed product image gallery' d='Shop.Theme.Global'}">
73+
<i class="material-icons">&#xE8B6;</i>
74+
</button>
75+
{/block}
7876
</div>
7977

8078
{block name='product_images'}
8179
<div class="product__thumbnails">
8280
<ul class="product__thumbnails-list">
83-
{foreach from=$product.images item=image key=key}
84-
<li
85-
class="product__thumbnail js-thumb-container{if $image.id_image == $product.default_image.id_image} active{/if}"
81+
{foreach from=$product.images item=image key=key name=productThumbnails}
82+
<button
83+
class="product__thumbnail focus-ring js-thumb-container{if $image.id_image == $product.default_image.id_image} active{/if}"
8684
data-bs-target="#product-images-{$product.id}"
8785
data-bs-slide-to="{$key}"
8886
{if $image.id_image == $product.default_image.id_image}
8987
aria-current="true"
9088
{/if}
91-
aria-label="{l s='Product image %number%' d='Shop.Theme.Catalog' sprintf=['%number%' => $key]}"
89+
aria-label="{l s='Slide to product image %number%' d='Shop.Theme.Catalog' sprintf=['%number%' => $key + 1]}"
9290
>
9391
<picture>
9492
{if isset($image.bySize.default_xs.sources.avif)}
@@ -110,7 +108,7 @@
110108
{/if}
111109

112110
<img
113-
class="product__thumbnail-image img-fluid js-thumb{if $image.id_image == $product.default_image.id_image} js-thumb-selected{/if}"
111+
class="product__thumbnail-image outline outline--rounded img-fluid js-thumb{if $image.id_image == $product.default_image.id_image} js-thumb-selected{/if}"
114112
srcset="
115113
{$image.bySize.default_xs.url},
116114
{$image.bySize.default_xl.url} 2x"
@@ -121,7 +119,7 @@
121119
title="{$image.legend}"
122120
>
123121
</picture>
124-
</li>
122+
</button>
125123
{/foreach}
126124
</ul>
127125
</div>
@@ -165,8 +163,8 @@
165163
>
166164
</picture>
167165
{/if}
168-
</div>
169166

170-
{block name='product_images_modal'}
171-
{include file='catalog/_partials/product-images-modal.tpl'}
172-
{/block}
167+
{block name='product_images_modal'}
168+
{include file='catalog/_partials/product-images-modal.tpl'}
169+
{/block}
170+
</div>

0 commit comments

Comments
 (0)