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

@@ -14,8 +14,9 @@
<router-outlet></router-outlet> <router-outlet></router-outlet>
<app-telegram-login mode="admin" /> <app-telegram-login mode="admin" />
} @else { } @else {
<a class="skip-link" href="#main-content">{{ 'app.skipToContent' | translate }}</a>
<app-header></app-header> <app-header></app-header>
<main class="main-content"> <main id="main-content" class="main-content" tabindex="-1">
@if (!isHomePage()) { @if (!isHomePage()) {
<app-back-button /> <app-back-button />
} }

View File

@@ -5,7 +5,7 @@
<label class="delivery-label" [for]="selectId">{{ 'cart.deliveryMethod' | translate }}</label> <label class="delivery-label" [for]="selectId">{{ 'cart.deliveryMethod' | translate }}</label>
<div class="delivery-control"> <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) { @if (required) {
<option value="">{{ 'cart.selectDelivery' | translate }}</option> <option value="">{{ 'cart.selectDelivery' | translate }}</option>
} }

View File

@@ -33,7 +33,7 @@
<div class="platform-search-wrapper"> <div class="platform-search-wrapper">
<div class="platform-search-box"> <div class="platform-search-box">
<app-icon name="search" [size]="20" class="platform-search-icon" /> <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>
</div> </div>
} }

View File

@@ -509,19 +509,26 @@
z-index: 999; z-index: 999;
box-sizing: border-box; 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; max-height: 0;
padding-top: 0; padding-top: 0;
padding-bottom: 0; padding-bottom: 0;
overflow: hidden; overflow: hidden;
opacity: 0; 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 { &.platform-mobile-menu-open {
max-height: calc(100dvh - 84px); max-height: calc(100dvh - 84px);
padding: 28px 20px 32px; padding: 28px 20px 32px;
opacity: 1; opacity: 1;
visibility: visible;
overflow-y: auto; overflow-y: auto;
transition: max-height 0.35s ease, opacity 0.25s ease, padding 0.35s ease, visibility 0s linear 0s;
} }
} }

View File

@@ -14,7 +14,11 @@
(blur)="onBlur()" (blur)="onBlur()"
name="query" name="query"
[placeholder]="'catalog.searchPlaceholder' | translate" [placeholder]="'catalog.searchPlaceholder' | translate"
role="combobox"
aria-autocomplete="list"
[attr.aria-expanded]="suggestions.length > 0" [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" [attr.aria-label]="'catalog.searchPlaceholder' | translate"
autocomplete="off" /> autocomplete="off" />
@@ -27,14 +31,17 @@
</form> </form>
@if (suggestions.length > 0) { @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> <strong>{{ 'catalog.suggestionsTitle' | translate }}</strong>
<div class="suggestion-list"> <div class="suggestion-list">
@for (item of suggestions; track item.id) { @for (item of suggestions; track item.id) {
<button <button
type="button" type="button"
[id]="'search-suggestion-' + $index"
class="suggestion-item" class="suggestion-item"
role="option"
[class.active]="$index === activeSuggestionIndex()" [class.active]="$index === activeSuggestionIndex()"
[attr.aria-selected]="$index === activeSuggestionIndex()"
[attr.aria-label]="suggestionLabel(item)" [attr.aria-label]="suggestionLabel(item)"
(click)="selectSuggestion(item)"> (click)="selectSuggestion(item)">
<span class="suggestion-icon" aria-hidden="true">{{ item.icon }}</span> <span class="suggestion-icon" aria-hidden="true">{{ item.icon }}</span>

View File

@@ -93,7 +93,7 @@
[activeKey]="activeTab()" [activeKey]="activeTab()"
(activeKeyChange)="activeTab.set($event)" /> (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()) { @switch (activeTab()) {
@case ('description') { @case ('description') {
<app-product-description [product]="currentProduct" [simpleDescription]="productDescription(currentProduct)" /> <app-product-description [product]="currentProduct" [simpleDescription]="productDescription(currentProduct)" />

View File

@@ -1,12 +1,14 @@
@if (tabs.length > 0) { @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) { @for (tab of tabs; track tab.key) {
<button <button
type="button" type="button"
[id]="'product-tab-' + tab.key"
class="tab-btn" class="tab-btn"
role="tab" role="tab"
[class.active]="activeKey === tab.key" [class.active]="activeKey === tab.key"
[attr.aria-selected]="activeKey === tab.key" [attr.aria-selected]="activeKey === tab.key"
aria-controls="product-tab-panel"
[disabled]="tab.enabled === false" [disabled]="tab.enabled === false"
(click)="selectTab(tab)"> (click)="selectTab(tab)">
<span>{{ tab.label }}</span> <span>{{ tab.label }}</span>

View File

@@ -5,19 +5,19 @@
<p class="success">{{ 'productDetails.reviewSuccess' | translate }}</p> <p class="success">{{ 'productDetails.reviewSuccess' | translate }}</p>
} }
<label>{{ 'productDetails.reviewRatingLabel' | translate }}</label> <label id="review-rating-label">{{ 'productDetails.reviewRatingLabel' | translate }}</label>
<app-star-selector [rating]="rating" (ratingChange)="updateRating($event)" /> <app-star-selector [rating]="rating" (ratingChange)="updateRating($event)" [attr.aria-describedby]="showRatingError ? 'review-rating-error' : null" />
@if (showRatingError) { @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> <label for="review-title">{{ 'productDetails.reviewTitleLabel' | translate }}</label>
<input id="review-title" name="title" [(ngModel)]="title" [placeholder]="'productDetails.reviewTitlePlaceholder' | translate" maxlength="120" (input)="hideSuccess()" /> <input id="review-title" name="title" [(ngModel)]="title" [placeholder]="'productDetails.reviewTitlePlaceholder' | translate" maxlength="120" (input)="hideSuccess()" />
<label for="review-text">{{ 'productDetails.reviewTextLabel' | translate }}</label> <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) { @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"> <label class="check">

View File

@@ -43,7 +43,9 @@ textarea {
.upload-placeholder { .upload-placeholder {
margin: 0; 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); font-size: var(--font-size-sm, 0.8125rem);
} }

View File

@@ -392,6 +392,7 @@ export const en: Translations = {
serverError: 'Could not connect to the server. Please check your internet connection.', serverError: 'Could not connect to the server. Please check your internet connection.',
retryConnection: 'Retry', retryConnection: 'Retry',
pageTitle: 'Marketplace of goods and services', pageTitle: 'Marketplace of goods and services',
skipToContent: 'Skip to main content',
}, },
platform: { platform: {
loadingPage: 'Loading platform page...', loadingPage: 'Loading platform page...',

View File

@@ -392,6 +392,7 @@ export const hy: Translations = {
serverError: 'Չհաջողվեց միանալ սերվերին։ Ստուգեք ինտերնետը։', serverError: 'Չհաջողվեց միանալ սերվերին։ Ստուգեք ինտերնետը։',
retryConnection: 'Փորձել կրկին', retryConnection: 'Փորձել կրկին',
pageTitle: 'Ապրանքների և ծառայությունների մարքեթփլեյս', pageTitle: 'Ապրանքների և ծառայությունների մարքեթփլեյս',
skipToContent: 'Անցնել հիմնական բովանդակությանը',
}, },
platform: { platform: {
loadingPage: 'Պլատֆորմի էջի բեռնում...', loadingPage: 'Պլատֆորմի էջի բեռնում...',

View File

@@ -392,6 +392,7 @@ export const ru: Translations = {
serverError: 'Не удалось подключиться к серверу. Проверьте подключение к интернету.', serverError: 'Не удалось подключиться к серверу. Проверьте подключение к интернету.',
retryConnection: 'Повторить попытку', retryConnection: 'Повторить попытку',
pageTitle: 'Маркетплейс товаров и услуг', pageTitle: 'Маркетплейс товаров и услуг',
skipToContent: 'Перейти к основному содержимому',
}, },
platform: { platform: {
loadingPage: 'Загрузка страницы платформы...', loadingPage: 'Загрузка страницы платформы...',

View File

@@ -390,6 +390,7 @@ export interface Translations {
serverError: string; serverError: string;
retryConnection: string; retryConnection: string;
pageTitle: string; pageTitle: string;
skipToContent: string;
}; };
platform: { platform: {
loadingPage: string; loadingPage: string;

View File

@@ -195,12 +195,12 @@
<!-- Payment Popup Modal --> <!-- Payment Popup Modal -->
@if (showPaymentPopup()) { @if (showPaymentPopup()) {
<div class="payment-modal-overlay"> <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"> <button class="close-modal-btn" (click)="closePaymentPopup()" [attr.aria-label]="'cart.close' | translate">
<app-icon name="x" [size]="20" /> <app-icon name="x" [size]="20" />
</button> </button>
@if (paymentStatus() === 'creating') { @if (paymentStatus() === 'creating') {
<div class="payment-status-screen"> <div class="payment-status-screen" role="status" aria-live="polite">
<div class="spinner-large"></div> <div class="spinner-large"></div>
<h2>{{ 'cart.creatingPayment' | translate }}</h2> <h2>{{ 'cart.creatingPayment' | translate }}</h2>
<p>{{ 'cart.waitFewSeconds' | translate }}</p> <p>{{ 'cart.waitFewSeconds' | translate }}</p>
@@ -208,7 +208,7 @@
} }
@if (paymentStatus() === 'waiting') { @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> <h2>{{ selectedPaymentMethod() === 'card' ? ('cart.waitingPayment' | translate) : ('cart.scanQr' | translate) }}</h2>
@if (qrCodeUrl()) { @if (qrCodeUrl()) {
@@ -246,7 +246,7 @@
} }
@if (paymentStatus() === 'error') { @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> <div class="error-icon" aria-hidden="true">!</div>
<h2>{{ 'cart.paymentError' | translate }}</h2> <h2>{{ 'cart.paymentError' | translate }}</h2>
<p>{{ 'cart.paymentErrorDesc' | translate }}</p> <p>{{ 'cart.paymentErrorDesc' | translate }}</p>
@@ -260,14 +260,14 @@
} }
@if (paymentStatus() === 'success') { @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> <div class="success-icon" aria-hidden="true"></div>
<h2>{{ 'cart.paymentSuccess' | translate }}</h2> <h2>{{ 'cart.paymentSuccess' | translate }}</h2>
</div> </div>
} }
@if (paymentStatus() === 'timeout') { @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> <div class="timeout-icon" aria-hidden="true"></div>
<h2>{{ 'cart.paymentTimeout' | translate }}</h2> <h2>{{ 'cart.paymentTimeout' | translate }}</h2>
<p>{{ 'cart.paymentTimeoutDesc' | translate }}</p> <p>{{ 'cart.paymentTimeoutDesc' | translate }}</p>
@@ -278,7 +278,7 @@
@if (showBankPaymentPopup() && bankPaymentFrameUrl()) { @if (showBankPaymentPopup() && bankPaymentFrameUrl()) {
<div class="bank-payment-modal-overlay"> <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"> <button class="close-modal-btn" (click)="closeBankPaymentPopup()" [attr.aria-label]="'cart.close' | translate">
<app-icon name="x" [size]="20" /> <app-icon name="x" [size]="20" />
</button> </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 { DecimalPipe } from '@angular/common';
import { Router, RouterLink } from '@angular/router'; import { Router, RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
@@ -22,6 +22,9 @@ import { TenantResolverService } from '../../core/config/tenant-resolver.service
type PaymentMethod = 'qr' | 'card'; 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({ @Component({
selector: 'app-cart', selector: 'app-cart',
imports: [DecimalPipe, RouterLink, FormsModule, DeliverySelectorComponent, TelegramLoginComponent, LangRoutePipe, TranslatePipe, IconComponent, EmptyStateComponent, ButtonComponent], 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 configService = inject(ConfigService);
private tenantResolver = inject(TenantResolverService); 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( constructor(
private cartService: CartService, private cartService: CartService,
private apiService: ApiService, private apiService: ApiService,
@@ -90,6 +100,78 @@ export class CartComponent implements OnDestroy {
this.totalWithDelivery = this.cartService.totalWithDelivery; this.totalWithDelivery = this.cartService.totalWithDelivery;
this.hasDeliveryPrice = this.cartService.hasDeliveryPrice; this.hasDeliveryPrice = this.cartService.hasDeliveryPrice;
this.allRequiredDeliveriesSelected = this.cartService.allRequiredDeliveriesSelected; 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 { requestLogin(): void {

View File

@@ -52,12 +52,15 @@
.org-short { .org-short {
font-size: var(--font-size-md, 0.9375rem); 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 { .basis {
font-size: var(--font-size-base, 0.875rem); font-size: var(--font-size-base, 0.875rem);
color: var(--text-light); color: var(--text-secondary);
margin-top: 0.5rem; margin-top: 0.5rem;
} }

View File

@@ -22,6 +22,9 @@ import { APP_ICONS, type AppIconName } from './icon-registry';
[color]="color()" [color]="color()"
[strokeWidth]="strokeWidth()" [strokeWidth]="strokeWidth()"
[title]="ariaLabel()" [title]="ariaLabel()"
[attr.role]="ariaLabel() ? 'img' : null"
[attr.aria-label]="ariaLabel()"
[attr.aria-hidden]="ariaLabel() ? null : 'true'"
></svg> ></svg>
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -203,6 +203,40 @@ a, button, input, textarea, select {
outline-offset: 2px; outline-offset: 2px;
} }
/* Screen-reader-only utility: visually hidden but announced by assistive tech */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Skip link (WCAG 2.4.1 Bypass Blocks): first focusable element on every
storefront page, visually hidden until it receives keyboard focus. */
.skip-link {
position: absolute;
top: -100px;
left: 8px;
z-index: 9999;
padding: 12px 20px;
background: var(--primary-color);
color: #fff;
border-radius: var(--radius-md);
text-decoration: none;
font-weight: 600;
transition: top 0.2s ease;
}
.skip-link:focus-visible,
.skip-link:focus {
top: 8px;
}
/* Spinner rotation for app-icon name="spinner" used as a loading indicator */ /* Spinner rotation for app-icon name="spinner" used as a loading indicator */
@keyframes app-icon-spin { @keyframes app-icon-spin {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }