fix(storefront): WCAG 2.1 AA accessibility fixes
RC A11Y-01 audit pass, storefront + shared app-shell chrome only. Builds on RC-Visual-02/RC-Premium-01/RC STORE-01 without redoing that work. - Skip link: added first-focusable "skip to main content" link (app.html, styles.scss .skip-link/.sr-only), targeting new #main-content landmark. New app.skipToContent i18n key in en/ru/hy. - Header: mobile menu items stayed keyboard-focusable and screen-reader reachable while visually collapsed (max-height:0 with no visibility toggle) - fixed with visibility:hidden + matched transition-delay. Desktop search input (readonly, click-to-navigate) had no keyboard activation - added aria-label + (keydown.enter). - Cart payment/bank-payment modals: custom (non-app-dialog) UI had no focus trap, no Escape handling, and never returned focus to the triggering element - ported app-dialog's confirmed-correct focus-trap/Escape/return-focus pattern directly onto cart.component.ts. Added role="dialog"/aria-modal/aria-label to both panels and role="status"|"alert"/aria-live to every payment-status screen so screen readers announce state changes (creating/waiting/success/ error/timeout). - Search combobox: suggestion listbox had no role="combobox" wiring on the input and suggestion buttons weren't role="option" - added aria-autocomplete, aria-controls, aria-activedescendant, aria-selected so the existing arrow-key navigation is announced to screen readers. - Product tabs: tablist/tab pattern was incomplete (no role="tablist", no tabpanel) - added role="tablist" + ids to product-tabs.component, role="tabpanel"/aria-labelledby to the content panel in product-details-container. - Review form: rating/text validation errors weren't associated with their controls (no aria-describedby, no role="alert") - fixed; added aria-required to the review textarea. - delivery-selector: added aria-required to the delivery <select> when a selection is mandatory. - Shared app-icon component: doc comment claimed "decorative by default (aria-hidden)" but no aria-hidden was ever applied - fixed to actually set aria-hidden="true" when undecorated, and role="img"/aria-label when ariaLabel is passed. Shared component, affects every icon-only usage app-wide, no visual change. - Color contrast: --text-light fails WCAG AA 4.5:1 for normal text in every theme (dexar 3.39:1, lavero/novo 2.54:1 against white). The two in-scope usages (company-details org-short/basis, review-form upload-placeholder) switched to --text-secondary (4.55:1-7.56:1, passes), same visual family, no layout change. Flagged, not fixed (design-system decisions, not polish): - --border-color fails WCAG 1.4.11 3:1 for UI-component boundaries in every theme (dexar 1.42:1, lavero/novo 1.24:1 vs white) - pervasive token used by hundreds of borders app-wide; needs theme-owner sign-off. - --success-color/--warning-color/--error-color/--info-color used as plain text-on-white in several places (product-information, question-card, review-form, compare-page) fail 4.5:1 (2.15-3.76:1) - genuine brand semantic colors, changing them to pass would visibly shift the palette; needs a deliberate token decision. - Header mobile-menu max-height/padding transition (pre-existing, unrelated to this fix) flagged by design lint as layout-thrashing; left as-is per the "no layout/business-logic changes" constraint. Verified: npx tsc --noEmit clean; npm run build green (only the pre-existing bundle-budget warning, unrelated to this pass). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Component, ChangeDetectionStrategy, signal, OnDestroy, inject } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy, signal, OnDestroy, inject, ElementRef, ViewChild, HostListener, effect } from '@angular/core';
|
||||
import { DecimalPipe } from '@angular/common';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -22,6 +22,9 @@ import { TenantResolverService } from '../../core/config/tenant-resolver.service
|
||||
|
||||
type PaymentMethod = 'qr' | 'card';
|
||||
|
||||
const MODAL_FOCUSABLE_SELECTOR =
|
||||
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), iframe, [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
@Component({
|
||||
selector: 'app-cart',
|
||||
imports: [DecimalPipe, RouterLink, FormsModule, DeliverySelectorComponent, TelegramLoginComponent, LangRoutePipe, TranslatePipe, IconComponent, EmptyStateComponent, ButtonComponent],
|
||||
@@ -76,6 +79,13 @@ export class CartComponent implements OnDestroy {
|
||||
private configService = inject(ConfigService);
|
||||
private tenantResolver = inject(TenantResolverService);
|
||||
|
||||
// Focus management for the custom payment/bank-payment modals (not
|
||||
// app-dialog — they own a real multi-step state machine). Mirrors
|
||||
// app-dialog's confirmed-correct focus-trap/Escape/return-focus pattern.
|
||||
@ViewChild('paymentModalPanel') private paymentModalPanel?: ElementRef<HTMLElement>;
|
||||
@ViewChild('bankPaymentModalPanel') private bankPaymentModalPanel?: ElementRef<HTMLElement>;
|
||||
private previouslyFocusedBeforeModal: HTMLElement | null = null;
|
||||
|
||||
constructor(
|
||||
private cartService: CartService,
|
||||
private apiService: ApiService,
|
||||
@@ -90,6 +100,78 @@ export class CartComponent implements OnDestroy {
|
||||
this.totalWithDelivery = this.cartService.totalWithDelivery;
|
||||
this.hasDeliveryPrice = this.cartService.hasDeliveryPrice;
|
||||
this.allRequiredDeliveriesSelected = this.cartService.allRequiredDeliveriesSelected;
|
||||
|
||||
effect(() => {
|
||||
const isOpen = this.showPaymentPopup();
|
||||
if (isOpen) {
|
||||
this.previouslyFocusedBeforeModal ??= document.activeElement as HTMLElement | null;
|
||||
queueMicrotask(() => this.focusActiveModalPanel());
|
||||
} else if (this.previouslyFocusedBeforeModal) {
|
||||
this.previouslyFocusedBeforeModal.focus();
|
||||
this.previouslyFocusedBeforeModal = null;
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (this.showBankPaymentPopup()) {
|
||||
queueMicrotask(() => this.focusActiveModalPanel());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
protected handleModalKeydown(event: KeyboardEvent): void {
|
||||
if (!this.showPaymentPopup()) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
if (this.showBankPaymentPopup()) {
|
||||
this.closeBankPaymentPopup();
|
||||
} else {
|
||||
this.closePaymentPopup();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Tab') {
|
||||
this.trapModalFocus(event);
|
||||
}
|
||||
}
|
||||
|
||||
private activeModalPanel(): HTMLElement | undefined {
|
||||
return this.showBankPaymentPopup()
|
||||
? this.bankPaymentModalPanel?.nativeElement
|
||||
: this.paymentModalPanel?.nativeElement;
|
||||
}
|
||||
|
||||
private focusActiveModalPanel(): void {
|
||||
const panel = this.activeModalPanel();
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
const focusable = panel.querySelectorAll<HTMLElement>(MODAL_FOCUSABLE_SELECTOR);
|
||||
(focusable[0] ?? panel).focus();
|
||||
}
|
||||
|
||||
private trapModalFocus(event: KeyboardEvent): void {
|
||||
const panel = this.activeModalPanel();
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
const focusable = Array.from(panel.querySelectorAll<HTMLElement>(MODAL_FOCUSABLE_SELECTOR));
|
||||
if (focusable.length === 0) {
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = document.activeElement;
|
||||
|
||||
if (event.shiftKey && active === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && active === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
requestLogin(): void {
|
||||
|
||||
Reference in New Issue
Block a user