fix(backoffice): WCAG 2.1 AA accessibility fixes
RC A11Y-01 audit pass, Backoffice (admin/*) area. Builds on 712a7b4/63c9cee's
composition pass (scope="col", skeletons, empty-states) without redoing that
work.
- Sidebar nav landmark: admin-layout's <nav aria-label> reused the
"Dashboard" nav-item translation key as its landmark label, misleadingly
announcing the whole sidebar as "Dashboard" - added a dedicated
adminShell.sidebarLabel key ("Admin sidebar navigation") in en/ru/hy.
Skip link, #admin-content main landmark, Escape handling, and mobile-drawer
focus management were already correct - verified, not touched.
- Categories tree drag-and-drop keyboard fallback (WCAG 2.1.1): the category
tree's native HTML5 DnD (draggable/dragstart/drop) reorders siblings with
no keyboard equivalent - existing arrow-key tree navigation only expands/
collapses/selects, never reorders. Added per-row move-up/move-down icon
buttons (disabled at sibling boundaries), reusing the existing `reorder`
output so the facade's reorder logic is untouched; new
adminCategories.moveUp/moveDown keys in en/ru/hy.
- Screen-reader loading announcements: skeleton-row loading states across
Products, Categories, Customers, Orders, Transactions, Users, Reviews,
Monitoring (webhooks/events), and Analytics (summary cards + top products)
were purely visual (app-skeleton is aria-hidden by design) with no
accessible "loading" text, unlike the storefront/product-details pattern -
added role="status"/aria-live="polite"/aria-busy + sr-only text using the
existing common.loading key.
- Table row headers: added scope="row" to the primary identifying cell
(product/category/customer name, order number, transaction order number,
user name, review customer, report target, top-products/low-stock product
name, webhook endpoint) on 9 tables that only had scope="col". Added
matching `tbody th[scope='row'] { font-weight/color/text-align/
vertical-align }` + last-row border resets in each component's own scss so
the semantic change doesn't alter visuals (the shared app-table stylesheet
styles all <th> as bold/muted by default).
Verified via `git show --stat` of fb1afb7/a03260e and `docs/UI-COMPOSITION-
REVIEW.md`'s Backoffice sections first, per instructions - confirmed
scope="col" coverage already complete, all admin modals already route
through the shared app-dialog (focus-trap/Escape/return-focus already
correct, nothing to fix), and the bare-<select> filters still carry
aria-label per the accepted Sprint 28 decision (not re-migrated to
app-select).
Flagged, not fixed:
- No toast/notification system exists anywhere in this codebase (product/
category save and delete call the gateway with no success/error UI at
all, not even a subscribe error handler) - there is nothing to wire
aria-live onto without adding a new UI mechanism, which is out of scope
for an a11y-only pass. A prerequisite feature-level fix, not an a11y
regression.
- Dashboard's per-card metric/status-row/timeline skeletons (dashboard-
metric, dashboard-status-row, dashboard-timeline) were left without
aria-live wiring - wrapping each of the ~10 simultaneous mini-widgets in
its own live region would fire a burst of redundant announcements; needs
a single page-level "loading dashboard" region instead, a larger change
than this surgical pass.
- Monitoring's events table and the notifications dropdown (role="menu"
with a static empty-state message, aria-haspopup="true") were left as-is -
matches the same partial-widget-pattern precedent already accepted for
locale-tabs/product-tabs in the storefront and builder passes.
- Analytics `lowStockProducts` table's missing loading-skeleton branch
(already flagged, not fixed, in the RC-Visual-02 pass) - untouched again
here for the same reason.
Verified: npx tsc --noEmit clean; npm run build green (only the pre-existing
768.57 kB vs 700 kB initial-bundle budget warning, unrelated to this pass).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,10 +29,11 @@
|
||||
@if (activeTab() === 'overview') {
|
||||
<div class="panel" role="tabpanel" id="panel-overview" aria-labelledby="tab-overview">
|
||||
@if (facade.loading()) {
|
||||
<div class="summary-grid">
|
||||
<div class="summary-grid" role="status" aria-live="polite" aria-busy="true">
|
||||
@for (i of [1,2,3,4,5,6]; track i) {
|
||||
<app-skeleton shape="rect" height="64px" />
|
||||
}
|
||||
<span class="sr-only">{{ 'common.loading' | translate }}</span>
|
||||
</div>
|
||||
} @else if (facade.summary(); as summary) {
|
||||
<div class="summary-grid">
|
||||
@@ -58,8 +59,9 @@
|
||||
<div class="card">
|
||||
<h2>{{ 'adminAnalytics.topProducts' | translate }}</h2>
|
||||
@if (facade.loading()) {
|
||||
<div class="skeleton-rows">
|
||||
<div class="skeleton-rows" role="status" aria-live="polite" aria-busy="true">
|
||||
@for (i of [1,2,3]; track i) { <app-skeleton shape="rect" height="36px" /> }
|
||||
<span class="sr-only">{{ 'common.loading' | translate }}</span>
|
||||
</div>
|
||||
} @else if (facade.topProducts().length === 0) {
|
||||
<app-empty-state [title]="'adminAnalytics.topProductsEmptyTitle' | translate" [description]="'adminAnalytics.topProductsEmptyDescription' | translate" />
|
||||
@@ -68,7 +70,7 @@
|
||||
<thead><tr><th scope="col">{{ 'adminProducts.name' | translate }}</th><th scope="col">{{ 'adminAnalytics.quantitySold' | translate }}</th><th scope="col">{{ 'adminAnalytics.revenue' | translate }}</th></tr></thead>
|
||||
<tbody>
|
||||
@for (product of facade.topProducts(); track product.productId) {
|
||||
<tr><td>{{ product.name }}</td><td>{{ product.quantity }}</td><td>{{ product.revenue }}</td></tr>
|
||||
<tr><th scope="row">{{ product.name }}</th><td>{{ product.quantity }}</td><td>{{ product.revenue }}</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</app-table>
|
||||
@@ -85,7 +87,7 @@
|
||||
<tbody>
|
||||
@for (product of facade.lowStockProducts(); track product.productId) {
|
||||
<tr>
|
||||
<td>{{ product.name }}</td>
|
||||
<th scope="row">{{ product.name }}</th>
|
||||
<td>{{ product.quantity }}</td>
|
||||
<td><app-badge [variant]="product.stockStatus === 'out_of_stock' ? 'danger' : 'warning'">{{ ('adminProducts.' + (product.stockStatus === 'out_of_stock' ? 'outOfStock' : 'lowStock')) | translate }}</app-badge></td>
|
||||
</tr>
|
||||
|
||||
@@ -55,3 +55,8 @@
|
||||
.summary-grid { grid-template-columns: 1fr; }
|
||||
.tabs { overflow-x: auto; flex-wrap: nowrap; }
|
||||
}
|
||||
|
||||
// scope="row" on the product-name cell (a11y row header) must render
|
||||
// identically to a plain <td> - only the semantics change, not the visuals.
|
||||
tbody th[scope='row'] { font-weight: inherit; color: inherit; text-align: left; vertical-align: middle; }
|
||||
tbody tr:last-child th[scope='row'] { border-bottom: none; }
|
||||
|
||||
Reference in New Issue
Block a user