fix: WCAG 2.2.2 hero autoplay pause control, invisible keyboard-focusable cart button, literal hex token
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
- Hero widget autoplay had no pause control and ignored prefers-reduced-motion (WCAG 2.2.2 requires a way to pause auto-updating content lasting >5s). Added a pause/resume toggle button and skip autoplay entirely when the OS prefers reduced motion. - Cart's swipe-reveal delete-btn-mobile was reachable by Tab even while invisible (opacity: 0, only the touch-swipe gesture could reveal it) - a confusing, unusable focus stop for keyboard users. Now tabindex=-1 + aria-hidden until swiped. Keyboard users already had a full removal path via the always-visible header remove button; this just stops the redundant hidden button from being a dead tab stop. - stars.component.scss hardcoded #cdd6d5 for the unfilled-star color instead of the --border-color design token. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.star {
|
.star {
|
||||||
color: #cdd6d5;
|
color: var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.star.filled {
|
.star.filled {
|
||||||
|
|||||||
@@ -1129,6 +1129,8 @@ export const en: Translations = {
|
|||||||
nextProducts: 'Next products',
|
nextProducts: 'Next products',
|
||||||
previousSlide: 'Previous slide',
|
previousSlide: 'Previous slide',
|
||||||
nextSlide: 'Next slide',
|
nextSlide: 'Next slide',
|
||||||
|
pauseAutoplay: 'Pause slideshow',
|
||||||
|
resumeAutoplay: 'Resume slideshow',
|
||||||
closeDialog: 'Close dialog',
|
closeDialog: 'Close dialog',
|
||||||
dismiss: 'Dismiss',
|
dismiss: 'Dismiss',
|
||||||
qrCode: 'QR Code',
|
qrCode: 'QR Code',
|
||||||
|
|||||||
@@ -1129,6 +1129,8 @@ export const hy: Translations = {
|
|||||||
nextProducts: 'Հաջորդ ապրանքները',
|
nextProducts: 'Հաջորդ ապրանքները',
|
||||||
previousSlide: 'Նախորդ սլայդը',
|
previousSlide: 'Նախորդ սլայդը',
|
||||||
nextSlide: 'Հաջորդ սլայդը',
|
nextSlide: 'Հաջորդ սլայդը',
|
||||||
|
pauseAutoplay: 'Դադարեցնել սլայդշոուն',
|
||||||
|
resumeAutoplay: 'Վերսկսել սլայդշոուն',
|
||||||
closeDialog: 'Փակել պատուհանը',
|
closeDialog: 'Փակել պատուհանը',
|
||||||
dismiss: 'Փակել',
|
dismiss: 'Փակել',
|
||||||
qrCode: 'QR կոդ',
|
qrCode: 'QR կոդ',
|
||||||
|
|||||||
@@ -1129,6 +1129,8 @@ export const ru: Translations = {
|
|||||||
nextProducts: 'Следующие товары',
|
nextProducts: 'Следующие товары',
|
||||||
previousSlide: 'Предыдущий слайд',
|
previousSlide: 'Предыдущий слайд',
|
||||||
nextSlide: 'Следующий слайд',
|
nextSlide: 'Следующий слайд',
|
||||||
|
pauseAutoplay: 'Приостановить слайд-шоу',
|
||||||
|
resumeAutoplay: 'Возобновить слайд-шоу',
|
||||||
closeDialog: 'Закрыть диалог',
|
closeDialog: 'Закрыть диалог',
|
||||||
dismiss: 'Скрыть',
|
dismiss: 'Скрыть',
|
||||||
qrCode: 'QR-код',
|
qrCode: 'QR-код',
|
||||||
|
|||||||
@@ -1128,6 +1128,8 @@ export interface Translations {
|
|||||||
nextProducts: string;
|
nextProducts: string;
|
||||||
previousSlide: string;
|
previousSlide: string;
|
||||||
nextSlide: string;
|
nextSlide: string;
|
||||||
|
pauseAutoplay: string;
|
||||||
|
resumeAutoplay: string;
|
||||||
closeDialog: string;
|
closeDialog: string;
|
||||||
dismiss: string;
|
dismiss: string;
|
||||||
qrCode: string;
|
qrCode: string;
|
||||||
|
|||||||
@@ -98,7 +98,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="delete-btn-mobile" (click)="removeItem(item)" [attr.aria-label]="'cart.removeItem' | translate">
|
<button
|
||||||
|
class="delete-btn-mobile"
|
||||||
|
(click)="removeItem(item)"
|
||||||
|
[attr.aria-label]="'cart.removeItem' | translate"
|
||||||
|
[attr.tabindex]="swipedItemId() === item.itemID ? null : -1"
|
||||||
|
[attr.aria-hidden]="swipedItemId() === item.itemID ? null : 'true'"
|
||||||
|
>
|
||||||
<app-icon name="trash" [size]="20" />
|
<app-icon name="trash" [size]="20" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -61,6 +61,15 @@ const SWIPE_THRESHOLD_PX = 50;
|
|||||||
(click)="goTo($index)"
|
(click)="goTo($index)"
|
||||||
></button>
|
></button>
|
||||||
}
|
}
|
||||||
|
@if (data?.autoplay) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="hero-widget__pause"
|
||||||
|
[attr.aria-label]="(isPaused() ? 'common.resumeAutoplay' : 'common.pauseAutoplay') | translate"
|
||||||
|
[attr.aria-pressed]="isPaused()"
|
||||||
|
(click)="toggleAutoplay()"
|
||||||
|
>{{ isPaused() ? '▶' : '⏸' }}</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,6 +180,23 @@ const SWIPE_THRESHOLD_PX = 50;
|
|||||||
&:focus-visible { outline: 2px solid var(--primary-color, #497671); outline-offset: 2px; }
|
&:focus-visible { outline: 2px solid var(--primary-color, #497671); outline-offset: 2px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-widget__pause {
|
||||||
|
margin-left: var(--space-sm, 8px);
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--border-color, #d3dad9);
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:focus-visible { outline: 2px solid var(--primary-color, #497671); outline-offset: 2px; }
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes hero-widget-in {
|
@keyframes hero-widget-in {
|
||||||
from { opacity: 0; transform: translateY(12px); }
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
@@ -199,6 +225,7 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy {
|
|||||||
@Output() ctaClicked = new EventEmitter<void>();
|
@Output() ctaClicked = new EventEmitter<void>();
|
||||||
|
|
||||||
readonly activeIndex = signal(0);
|
readonly activeIndex = signal(0);
|
||||||
|
readonly isPaused = signal(false);
|
||||||
private readonly dataSignal = signal<HeroWidgetData | null>(null);
|
private readonly dataSignal = signal<HeroWidgetData | null>(null);
|
||||||
private autoplayHandle: ReturnType<typeof setInterval> | null = null;
|
private autoplayHandle: ReturnType<typeof setInterval> | null = null;
|
||||||
private swipeStartX: number | null = null;
|
private swipeStartX: number | null = null;
|
||||||
@@ -237,6 +264,7 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy {
|
|||||||
if (changes['data']) {
|
if (changes['data']) {
|
||||||
this.dataSignal.set(this.data);
|
this.dataSignal.set(this.data);
|
||||||
this.activeIndex.set(0);
|
this.activeIndex.set(0);
|
||||||
|
this.isPaused.set(false);
|
||||||
this.setupAutoplay();
|
this.setupAutoplay();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -289,10 +317,20 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy {
|
|||||||
this.ctaClicked.emit();
|
this.ctaClicked.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** WCAG 2.2.2: auto-updating content lasting >5s needs a way to pause it. */
|
||||||
|
toggleAutoplay(): void {
|
||||||
|
this.isPaused.update(paused => !paused);
|
||||||
|
this.setupAutoplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
private prefersReducedMotion(): boolean {
|
||||||
|
return typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
private setupAutoplay(): void {
|
private setupAutoplay(): void {
|
||||||
this.clearAutoplay();
|
this.clearAutoplay();
|
||||||
const slides = this.allSlides();
|
const slides = this.allSlides();
|
||||||
if (!this.data?.autoplay || slides.length <= 1) {
|
if (!this.data?.autoplay || slides.length <= 1 || this.isPaused() || this.prefersReducedMotion()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.autoplayHandle = setInterval(() => {
|
this.autoplayHandle = setInterval(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user