diff --git a/src/app/pages/cart/cart.component.ts b/src/app/pages/cart/cart.component.ts
index 4abeea9..ac81d70 100644
--- a/src/app/pages/cart/cart.component.ts
+++ b/src/app/pages/cart/cart.component.ts
@@ -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
;
+ @ViewChild('bankPaymentModalPanel') private bankPaymentModalPanel?: ElementRef;
+ 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(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(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 {
diff --git a/src/app/pages/legal/company-details/company-details.component.scss b/src/app/pages/legal/company-details/company-details.component.scss
index cff9d5f..8775e75 100644
--- a/src/app/pages/legal/company-details/company-details.component.scss
+++ b/src/app/pages/legal/company-details/company-details.component.scss
@@ -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;
}
diff --git a/src/app/shared/ui/icon/icon.component.ts b/src/app/shared/ui/icon/icon.component.ts
index 8baeab1..698ad6f 100644
--- a/src/app/shared/ui/icon/icon.component.ts
+++ b/src/app/shared/ui/icon/icon.component.ts
@@ -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'"
>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
diff --git a/src/styles.scss b/src/styles.scss
index 98430b0..3ec9d6c 100644
--- a/src/styles.scss
+++ b/src/styles.scss
@@ -203,6 +203,40 @@ a, button, input, textarea, select {
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 */
@keyframes app-icon-spin {
to { transform: rotate(360deg); }