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:
sdarbinyan
2026-07-24 08:46:18 +04:00
parent 3253b297eb
commit fb1afb72d4
18 changed files with 169 additions and 24 deletions

View File

@@ -195,12 +195,12 @@
<!-- Payment Popup Modal -->
@if (showPaymentPopup()) {
<div class="payment-modal-overlay">
<div class="payment-modal">
<div class="payment-modal" #paymentModalPanel role="dialog" aria-modal="true" [attr.aria-label]="'cart.checkout' | translate" tabindex="-1">
<button class="close-modal-btn" (click)="closePaymentPopup()" [attr.aria-label]="'cart.close' | translate">
<app-icon name="x" [size]="20" />
</button>
@if (paymentStatus() === 'creating') {
<div class="payment-status-screen">
<div class="payment-status-screen" role="status" aria-live="polite">
<div class="spinner-large"></div>
<h2>{{ 'cart.creatingPayment' | translate }}</h2>
<p>{{ 'cart.waitFewSeconds' | translate }}</p>
@@ -208,7 +208,7 @@
}
@if (paymentStatus() === 'waiting') {
<div class="payment-active">
<div class="payment-active" role="status" aria-live="polite">
<h2>{{ selectedPaymentMethod() === 'card' ? ('cart.waitingPayment' | translate) : ('cart.scanQr' | translate) }}</h2>
@if (qrCodeUrl()) {
@@ -246,7 +246,7 @@
}
@if (paymentStatus() === 'error') {
<div class="payment-status-screen error">
<div class="payment-status-screen error" role="alert" aria-live="assertive">
<div class="error-icon" aria-hidden="true">!</div>
<h2>{{ 'cart.paymentError' | translate }}</h2>
<p>{{ 'cart.paymentErrorDesc' | translate }}</p>
@@ -260,14 +260,14 @@
}
@if (paymentStatus() === 'success') {
<div class="payment-status-screen success">
<div class="payment-status-screen success" role="status" aria-live="polite">
<div class="success-icon" aria-hidden="true"></div>
<h2>{{ 'cart.paymentSuccess' | translate }}</h2>
</div>
}
@if (paymentStatus() === 'timeout') {
<div class="payment-status-screen timeout">
<div class="payment-status-screen timeout" role="status" aria-live="polite">
<div class="timeout-icon" aria-hidden="true"></div>
<h2>{{ 'cart.paymentTimeout' | translate }}</h2>
<p>{{ 'cart.paymentTimeoutDesc' | translate }}</p>
@@ -278,7 +278,7 @@
@if (showBankPaymentPopup() && bankPaymentFrameUrl()) {
<div class="bank-payment-modal-overlay">
<div class="bank-payment-modal">
<div class="bank-payment-modal" #bankPaymentModalPanel role="dialog" aria-modal="true" [attr.aria-label]="'cart.payViaCard' | translate" tabindex="-1">
<button class="close-modal-btn" (click)="closeBankPaymentPopup()" [attr.aria-label]="'cart.close' | translate">
<app-icon name="x" [size]="20" />
</button>

View File

@@ -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 {

View File

@@ -52,12 +52,15 @@
.org-short {
font-size: var(--font-size-md, 0.9375rem);
color: var(--text-light);
/* --text-light fails WCAG AA 4.5:1 at this font size across every theme
(dexar 3.39:1, lavero/novo 2.54:1) — using --text-secondary instead,
which passes (4.55:1 / 7.56:1) with no visual-family change. */
color: var(--text-secondary);
}
.basis {
font-size: var(--font-size-base, 0.875rem);
color: var(--text-light);
color: var(--text-secondary);
margin-top: 0.5rem;
}