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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user