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:
@@ -14,8 +14,9 @@
|
||||
<router-outlet></router-outlet>
|
||||
<app-telegram-login mode="admin" />
|
||||
} @else {
|
||||
<a class="skip-link" href="#main-content">{{ 'app.skipToContent' | translate }}</a>
|
||||
<app-header></app-header>
|
||||
<main class="main-content">
|
||||
<main id="main-content" class="main-content" tabindex="-1">
|
||||
@if (!isHomePage()) {
|
||||
<app-back-button />
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<label class="delivery-label" [for]="selectId">{{ 'cart.deliveryMethod' | translate }}</label>
|
||||
|
||||
<div class="delivery-control">
|
||||
<select [id]="selectId" [ngModel]="selectedKey" (ngModelChange)="onSelectionChange($event)">
|
||||
<select [id]="selectId" [ngModel]="selectedKey" (ngModelChange)="onSelectionChange($event)" [attr.aria-required]="required ? 'true' : null">
|
||||
@if (required) {
|
||||
<option value="">{{ 'cart.selectDelivery' | translate }}</option>
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<div class="platform-search-wrapper">
|
||||
<div class="platform-search-box">
|
||||
<app-icon name="search" [size]="20" class="platform-search-icon" />
|
||||
<input type="text" [placeholder]="'header.searchPlaceholder' | translate" class="platform-search-input" (click)="navigateToSearch()" readonly />
|
||||
<input type="text" [placeholder]="'header.searchPlaceholder' | translate" class="platform-search-input" [attr.aria-label]="'header.search' | translate" (click)="navigateToSearch()" (keydown.enter)="navigateToSearch()" readonly />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -509,19 +509,26 @@
|
||||
z-index: 999;
|
||||
box-sizing: border-box;
|
||||
|
||||
// Hide by default, animate in
|
||||
// Hide by default, animate in. `visibility: hidden` (with a matched
|
||||
// transition-delay so it only applies once the collapse finishes) keeps
|
||||
// the menu's links/buttons out of the Tab order and the accessibility
|
||||
// tree while closed — without it they stayed keyboard-focusable and
|
||||
// screen-reader-reachable despite being visually collapsed to 0 height.
|
||||
max-height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transition: max-height 0.35s ease, opacity 0.25s ease, padding 0.35s ease;
|
||||
visibility: hidden;
|
||||
transition: max-height 0.35s ease, opacity 0.25s ease, padding 0.35s ease, visibility 0s linear 0.35s;
|
||||
|
||||
&.platform-mobile-menu-open {
|
||||
max-height: calc(100dvh - 84px);
|
||||
padding: 28px 20px 32px;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
overflow-y: auto;
|
||||
transition: max-height 0.35s ease, opacity 0.25s ease, padding 0.35s ease, visibility 0s linear 0s;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,11 @@
|
||||
(blur)="onBlur()"
|
||||
name="query"
|
||||
[placeholder]="'catalog.searchPlaceholder' | translate"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
[attr.aria-expanded]="suggestions.length > 0"
|
||||
[attr.aria-controls]="suggestions.length > 0 ? 'search-suggestions-listbox' : null"
|
||||
[attr.aria-activedescendant]="activeSuggestionIndex() >= 0 ? 'search-suggestion-' + activeSuggestionIndex() : null"
|
||||
[attr.aria-label]="'catalog.searchPlaceholder' | translate"
|
||||
autocomplete="off" />
|
||||
|
||||
@@ -27,14 +31,17 @@
|
||||
</form>
|
||||
|
||||
@if (suggestions.length > 0) {
|
||||
<div class="suggestions" role="listbox" [attr.aria-label]="'catalog.suggestionsTitle' | translate">
|
||||
<div class="suggestions" id="search-suggestions-listbox" role="listbox" [attr.aria-label]="'catalog.suggestionsTitle' | translate">
|
||||
<strong>{{ 'catalog.suggestionsTitle' | translate }}</strong>
|
||||
<div class="suggestion-list">
|
||||
@for (item of suggestions; track item.id) {
|
||||
<button
|
||||
type="button"
|
||||
[id]="'search-suggestion-' + $index"
|
||||
class="suggestion-item"
|
||||
role="option"
|
||||
[class.active]="$index === activeSuggestionIndex()"
|
||||
[attr.aria-selected]="$index === activeSuggestionIndex()"
|
||||
[attr.aria-label]="suggestionLabel(item)"
|
||||
(click)="selectSuggestion(item)">
|
||||
<span class="suggestion-icon" aria-hidden="true">{{ item.icon }}</span>
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
[activeKey]="activeTab()"
|
||||
(activeKeyChange)="activeTab.set($event)" />
|
||||
|
||||
<div class="product-tab-content">
|
||||
<div class="product-tab-content" id="product-tab-panel" role="tabpanel" [attr.aria-labelledby]="'product-tab-' + activeTab()" tabindex="0">
|
||||
@switch (activeTab()) {
|
||||
@case ('description') {
|
||||
<app-product-description [product]="currentProduct" [simpleDescription]="productDescription(currentProduct)" />
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
@if (tabs.length > 0) {
|
||||
<nav class="product-tabs" [attr.aria-label]="'productDetails.tabsAria' | translate">
|
||||
<nav class="product-tabs" role="tablist" [attr.aria-label]="'productDetails.tabsAria' | translate">
|
||||
@for (tab of tabs; track tab.key) {
|
||||
<button
|
||||
type="button"
|
||||
[id]="'product-tab-' + tab.key"
|
||||
class="tab-btn"
|
||||
role="tab"
|
||||
[class.active]="activeKey === tab.key"
|
||||
[attr.aria-selected]="activeKey === tab.key"
|
||||
aria-controls="product-tab-panel"
|
||||
[disabled]="tab.enabled === false"
|
||||
(click)="selectTab(tab)">
|
||||
<span>{{ tab.label }}</span>
|
||||
|
||||
@@ -5,19 +5,19 @@
|
||||
<p class="success">{{ 'productDetails.reviewSuccess' | translate }}</p>
|
||||
}
|
||||
|
||||
<label>{{ 'productDetails.reviewRatingLabel' | translate }}</label>
|
||||
<app-star-selector [rating]="rating" (ratingChange)="updateRating($event)" />
|
||||
<label id="review-rating-label">{{ 'productDetails.reviewRatingLabel' | translate }}</label>
|
||||
<app-star-selector [rating]="rating" (ratingChange)="updateRating($event)" [attr.aria-describedby]="showRatingError ? 'review-rating-error' : null" />
|
||||
@if (showRatingError) {
|
||||
<p class="error">{{ 'productDetails.reviewRatingRequired' | translate }}</p>
|
||||
<p class="error" id="review-rating-error" role="alert">{{ 'productDetails.reviewRatingRequired' | translate }}</p>
|
||||
}
|
||||
|
||||
<label for="review-title">{{ 'productDetails.reviewTitleLabel' | translate }}</label>
|
||||
<input id="review-title" name="title" [(ngModel)]="title" [placeholder]="'productDetails.reviewTitlePlaceholder' | translate" maxlength="120" (input)="hideSuccess()" />
|
||||
|
||||
<label for="review-text">{{ 'productDetails.reviewTextLabel' | translate }}</label>
|
||||
<textarea id="review-text" name="text" [(ngModel)]="text" rows="4" [placeholder]="'productDetails.reviewTextPlaceholder' | translate" (input)="hideSuccess()"></textarea>
|
||||
<textarea id="review-text" name="text" [(ngModel)]="text" rows="4" [placeholder]="'productDetails.reviewTextPlaceholder' | translate" (input)="hideSuccess()" aria-required="true" [attr.aria-describedby]="showTextError ? 'review-text-error' : null" [attr.aria-invalid]="showTextError ? 'true' : null"></textarea>
|
||||
@if (showTextError) {
|
||||
<p class="error">{{ 'productDetails.reviewTextRequired' | translate }}</p>
|
||||
<p class="error" id="review-text-error" role="alert">{{ 'productDetails.reviewTextRequired' | translate }}</p>
|
||||
}
|
||||
|
||||
<label class="check">
|
||||
|
||||
@@ -43,7 +43,9 @@ textarea {
|
||||
|
||||
.upload-placeholder {
|
||||
margin: 0;
|
||||
color: var(--text-light);
|
||||
/* --text-light fails WCAG AA 4.5:1 at this font size (dexar 3.39:1,
|
||||
lavero/novo 2.54:1) — using --text-secondary, which passes. */
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm, 0.8125rem);
|
||||
}
|
||||
|
||||
|
||||
@@ -392,6 +392,7 @@ export const en: Translations = {
|
||||
serverError: 'Could not connect to the server. Please check your internet connection.',
|
||||
retryConnection: 'Retry',
|
||||
pageTitle: 'Marketplace of goods and services',
|
||||
skipToContent: 'Skip to main content',
|
||||
},
|
||||
platform: {
|
||||
loadingPage: 'Loading platform page...',
|
||||
|
||||
@@ -392,6 +392,7 @@ export const hy: Translations = {
|
||||
serverError: 'Չհաջողվեց միանալ սերվերին։ Ստուգեք ինտերնետը։',
|
||||
retryConnection: 'Փորձել կրկին',
|
||||
pageTitle: 'Ապրանքների և ծառայությունների մարքեթփլեյս',
|
||||
skipToContent: 'Անցնել հիմնական բովանդակությանը',
|
||||
},
|
||||
platform: {
|
||||
loadingPage: 'Պլատֆորմի էջի բեռնում...',
|
||||
|
||||
@@ -392,6 +392,7 @@ export const ru: Translations = {
|
||||
serverError: 'Не удалось подключиться к серверу. Проверьте подключение к интернету.',
|
||||
retryConnection: 'Повторить попытку',
|
||||
pageTitle: 'Маркетплейс товаров и услуг',
|
||||
skipToContent: 'Перейти к основному содержимому',
|
||||
},
|
||||
platform: {
|
||||
loadingPage: 'Загрузка страницы платформы...',
|
||||
|
||||
@@ -390,6 +390,7 @@ export interface Translations {
|
||||
serverError: string;
|
||||
retryConnection: string;
|
||||
pageTitle: string;
|
||||
skipToContent: string;
|
||||
};
|
||||
platform: {
|
||||
loadingPage: string;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ import { APP_ICONS, type AppIconName } from './icon-registry';
|
||||
[color]="color()"
|
||||
[strokeWidth]="strokeWidth()"
|
||||
[title]="ariaLabel()"
|
||||
[attr.role]="ariaLabel() ? 'img' : null"
|
||||
[attr.aria-label]="ariaLabel()"
|
||||
[attr.aria-hidden]="ariaLabel() ? null : 'true'"
|
||||
></svg>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
||||
Reference in New Issue
Block a user