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:
sdarbinyan
2026-07-24 09:18:09 +04:00
parent a03260eccf
commit 565fd9b3a8
26 changed files with 157 additions and 25 deletions

View File

@@ -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>

View File

@@ -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; }

View File

@@ -60,8 +60,9 @@
}
@if (loading) {
<div class="skeleton-rows">
<div class="skeleton-rows" role="status" aria-live="polite" aria-busy="true">
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
<span class="sr-only">{{ 'common.loading' | translate }}</span>
</div>
} @else if (treeRows.length === 0) {
<app-empty-state [title]="'adminCategories.emptyTitle' | translate" [description]="'adminCategories.emptyDescription' | translate">
@@ -112,6 +113,12 @@
<app-category-health-widget [items]="healthItems(row.category)" [completionPercent]="health(row.category).completionPercent" [compact]="true" />
</div>
<div class="category-tree__actions">
<button type="button" class="category-tree__reorder-btn" [disabled]="!canMoveUp(row)" (click)="moveRowUp(row)" [attr.aria-label]="'adminCategories.moveUp' | translate">
<app-icon name="arrowUp" [size]="16" />
</button>
<button type="button" class="category-tree__reorder-btn" [disabled]="!canMoveDown(row)" (click)="moveRowDown(row)" [attr.aria-label]="'adminCategories.moveDown' | translate">
<app-icon name="arrowDown" [size]="16" />
</button>
@if (row.category.parentId) {
<app-button variant="ghost" size="sm" (click)="jumpToParent.emit(row.category.id)">{{ 'adminCategories.jumpToParent' | translate }}</app-button>
}
@@ -143,7 +150,7 @@
@for (category of flatCategories; track category.id) {
<tr [class.deleted]="category.deletedAt">
<td><input type="checkbox" [checked]="isSelected(category.id)" [attr.aria-label]="('adminCategories.selectRow' | translate) + ': ' + category.title" (change)="selectionChange.emit({ id: category.id, checked: $any($event.target).checked })" /></td>
<td>{{ category.icon }} {{ category.title }}</td>
<th scope="row">{{ category.icon }} {{ category.title }}</th>
@if (visibleColumns.includes('slug')) { <td>{{ category.slug }}</td> }
@if (visibleColumns.includes('items')) { <td>{{ category.itemsCount }}</td> }
@if (visibleColumns.includes('status')) {

View File

@@ -6,6 +6,11 @@ select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color
.actions { display: flex; gap: 10px; align-items: center; }
tr.deleted { opacity: 0.55; }
.skeleton-rows { display: grid; gap: 8px; }
// scope="row" on the title 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; }
@media (max-width: 640px) { .filters { flex-direction: column; align-items: stretch; } .actions { flex-direction: column; align-items: stretch; } }
.bulk-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
@@ -36,7 +41,21 @@ tr.deleted { opacity: 0.55; }
.category-tree__icon { font-size: var(--font-size-lg, 1rem); }
.category-tree__title { font-weight: var(--font-weight-semibold, 600); color: var(--text-primary); }
.category-tree__items { font-size: var(--font-size-xs, 0.75rem); color: var(--text-light); }
.category-tree__actions { display: flex; gap: 6px; flex-wrap: wrap; }
.category-tree__actions { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
.category-tree__reorder-btn {
all: unset;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: var(--radius-sm);
color: var(--text-secondary);
}
.category-tree__reorder-btn:hover:not(:disabled) { background: var(--bg-tertiary); }
.category-tree__reorder-btn:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 1px; }
.category-tree__reorder-btn:disabled { color: var(--text-light); cursor: not-allowed; opacity: 0.5; }
.category-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
.category-grid--compact { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }

View File

@@ -15,6 +15,7 @@ import { MediaPickerComponent } from '../../../../shared/media/media-picker/medi
import { MediaAsset } from '../../../../core/media/models/media-asset.model';
import { CategoryHealthWidgetComponent, CategoryHealthItem } from './category-health-widget/category-health-widget.component';
import { CategoriesDashboardComponent } from './categories-dashboard/categories-dashboard.component';
import { IconComponent } from '../../../../shared/ui/icon/icon.component';
export interface AdminCategoryTreeRow {
category: AdminCategory;
@@ -39,6 +40,7 @@ export interface AdminCategoryTreeRow {
MediaPickerComponent,
CategoryHealthWidgetComponent,
CategoriesDashboardComponent,
IconComponent,
],
templateUrl: './admin-categories-list.component.html',
styleUrls: ['./admin-categories-list.component.scss'],
@@ -94,6 +96,44 @@ export class AdminCategoriesListComponent {
return this.selectedIds.includes(id);
}
// Keyboard-accessible equivalent of the native HTML5 drag-and-drop reorder
// above (WCAG 2.1.1) - swaps a row with its adjacent sibling using the same
// `reorder` output the mouse path already emits, so no facade/business
// logic changes.
private siblingRows(row: AdminCategoryTreeRow): AdminCategoryTreeRow[] {
return this.treeRows.filter(candidate => candidate.category.parentId === row.category.parentId);
}
canMoveUp(row: AdminCategoryTreeRow): boolean {
const siblings = this.siblingRows(row);
return siblings.findIndex(candidate => candidate.category.id === row.category.id) > 0;
}
canMoveDown(row: AdminCategoryTreeRow): boolean {
const siblings = this.siblingRows(row);
const index = siblings.findIndex(candidate => candidate.category.id === row.category.id);
return index >= 0 && index < siblings.length - 1;
}
moveRowUp(row: AdminCategoryTreeRow): void {
const siblings = this.siblingRows(row);
const index = siblings.findIndex(candidate => candidate.category.id === row.category.id);
if (index > 0) {
this.reorder.emit({ id: row.category.id, targetId: siblings[index - 1].category.id });
}
}
moveRowDown(row: AdminCategoryTreeRow): void {
const siblings = this.siblingRows(row);
const index = siblings.findIndex(candidate => candidate.category.id === row.category.id);
if (index >= 0 && index < siblings.length - 1) {
// Emitted with roles reversed: reorder() always inserts `id` before
// `targetId`, so moving the *next* sibling in front of this row
// produces the same adjacent swap as "move this row down".
this.reorder.emit({ id: siblings[index + 1].category.id, targetId: row.category.id });
}
}
healthItems(category: AdminCategory): CategoryHealthItem[] {
const health = this.health(category);
return [

View File

@@ -4,8 +4,9 @@
</div>
@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,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
<span class="sr-only">{{ 'common.loading' | translate }}</span>
</div>
} @else if (facade.filteredCustomers().length === 0) {
<app-empty-state [title]="'adminCustomers.emptyTitle' | translate" [description]="'adminCustomers.emptyDescription' | translate" />
@@ -25,7 +26,7 @@
<tbody>
@for (customer of facade.filteredCustomers(); track customer.email) {
<tr>
<td>{{ customer.name }}</td>
<th scope="row">{{ customer.name }}</th>
<td>{{ customer.email }}</td>
<td>{{ customer.phone }}</td>
<td>

View File

@@ -1,3 +1,8 @@
.admin-customers-card { display: grid; gap: 16px; padding: 16px; border: 1px solid var(--border-color, #d3dad9); border-radius: var(--radius-md); background: var(--bg-primary, #fff); }
.toolbar { display: flex; }
.skeleton-rows { display: grid; gap: 8px; }
// scope="row" on the 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; }

View File

@@ -21,13 +21,13 @@
<tbody>
@for (report of facade.reports(); track report.id) {
<tr>
<td>
<th scope="row">
@if (canOpen(report.targetType, report.targetId)) {
<button type="button" class="link-button" (click)="openTarget(report.targetType, report.targetId)">{{ report.targetLabel }}</button>
} @else {
{{ report.targetLabel || ('adminModeration.notAvailable' | translate) }}
}
</td>
</th>
<td>{{ ('adminModeration.targetType.' + report.targetType) | translate }}</td>
<td>{{ report.reason }}</td>
<td><app-badge [variant]="report.status === 'open' ? 'warning' : report.status === 'resolved' ? 'success' : 'neutral'">{{ ('adminModeration.reportStatus.' + report.status) | translate }}</app-badge></td>

View File

@@ -4,3 +4,8 @@
.actions { display: flex; gap: 8px; }
.link-button { all: unset; cursor: pointer; color: var(--primary-color); font-weight: var(--font-weight-semibold, 600); }
.link-button:hover, .link-button:focus-visible { text-decoration: underline; }
// scope="row" on the report-target 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; }

View File

@@ -58,8 +58,9 @@
}
@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,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
<span class="sr-only">{{ 'common.loading' | translate }}</span>
</div>
} @else if (facade.reviews().length === 0) {
<app-empty-state [title]="'adminModeration.emptyTitle' | translate" [description]="'adminModeration.emptyDescription' | translate" />
@@ -84,7 +85,7 @@
@for (review of facade.reviews(); track review.id) {
<tr>
<td><input type="checkbox" [checked]="isSelected(review.id)" [attr.aria-label]="review.customerName" (change)="facade.toggleSelection(review.id, $any($event.target).checked)" /></td>
<td>{{ review.customerName }}</td>
<th scope="row">{{ review.customerName }}</th>
@if (isColumnVisible('product')) { <td>{{ review.productName }}</td> }
@if (isColumnVisible('rating')) { <td>{{ review.rating }}★</td> }
@if (isColumnVisible('status')) {

View File

@@ -29,4 +29,9 @@ select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color
.review-grid__product { margin: 0; font-size: var(--font-size-xs, 0.75rem); color: var(--text-tertiary, #9aa6a2); }
.review-grid__text { margin: 0; font-size: var(--font-size-sm, 0.8125rem); color: var(--text-secondary, #5f6e6a); overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
// scope="row" on the customer-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; }
@media (max-width: 640px) { .filters { flex-direction: column; align-items: stretch; } }

View File

@@ -46,7 +46,7 @@
<tbody>
@for (delivery of facade.webhooks(); track delivery.id) {
<tr>
<td>{{ delivery.endpoint }}</td>
<th scope="row">{{ delivery.endpoint }}</th>
<td>{{ delivery.event }}</td>
<td><app-badge [variant]="delivery.status === 'delivered' ? 'success' : delivery.status === 'failed' ? 'danger' : 'neutral'">{{ ('adminMonitoring.webhookStatus.' + delivery.status) | translate }}</app-badge></td>
<td>{{ delivery.timestamp | date:'short' }}</td>
@@ -67,10 +67,11 @@
</select>
</div>
@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.events().length === 0) {
<app-empty-state [title]="'adminMonitoring.eventsEmptyTitle' | translate" [description]="'adminMonitoring.eventsEmptyDescription' | translate" />

View File

@@ -8,3 +8,8 @@
select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color, #d3dad9); border-radius: 10px; }
@media (max-width: 700px) { .queue-grid { grid-template-columns: 1fr; } .filters { flex-direction: column; align-items: stretch; } }
.skeleton-rows { display: grid; gap: 8px; }
// scope="row" on the endpoint 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; }

View File

@@ -50,8 +50,9 @@
}
@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,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
<span class="sr-only">{{ 'common.loading' | translate }}</span>
</div>
} @else if (facade.orders().length === 0) {
<app-empty-state [title]="'adminOrders.emptyTitle' | translate" [description]="'adminOrders.emptyDescription' | translate" />
@@ -74,7 +75,7 @@
@for (order of facade.orders(); track order.id) {
<tr>
<td><input type="checkbox" [checked]="isSelected(order.id)" [attr.aria-label]="('adminOrders.selectRow' | translate) + ': ' + order.orderNumber" (change)="facade.toggleSelection(order.id, $any($event.target).checked)" /></td>
<td>{{ order.orderNumber }}</td>
<th scope="row">{{ order.orderNumber }}</th>
@if (isColumnVisible('customer')) { <td>{{ order.customer.name }}</td> }
@if (isColumnVisible('status')) { <td><app-badge variant="neutral">{{ ('adminOrders.status.' + order.status) | translate }}</app-badge></td> }
@if (isColumnVisible('payment')) { <td><app-badge [variant]="order.payment.status === 'paid' ? 'success' : order.payment.status === 'refund_requested' ? 'warning' : 'neutral'">{{ ('adminOrders.paymentStatus.' + order.payment.status) | translate }}</app-badge></td> }

View File

@@ -25,3 +25,8 @@ select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color
}
.density-compact td, .density-compact th { padding: 4px 8px; }
// scope="row" on the order-number 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; }

View File

@@ -72,10 +72,11 @@
}
@if (loading) {
<div class="skeleton-rows">
<div class="skeleton-rows" role="status" aria-live="polite" aria-busy="true">
@for (i of [1,2,3,4,5]; track i) {
<app-skeleton shape="rect" height="40px" />
}
<span class="sr-only">{{ 'common.loading' | translate }}</span>
</div>
} @else if (products.length === 0) {
<app-empty-state [title]="'adminProducts.emptyTitle' | translate" [description]="'adminProducts.emptyDescription' | translate">
@@ -103,7 +104,7 @@
@for (product of products; track product.id) {
<tr>
<td><input type="checkbox" [checked]="isSelected(product.id)" [attr.aria-label]="('adminProducts.selectRow' | translate) + ': ' + product.name" (change)="selectionChange.emit({ id: product.id, checked: $any($event.target).checked })" /></td>
<td>{{ product.name }}</td>
<th scope="row">{{ product.name }}</th>
@if (isColumnVisible('sku')) { <td>{{ product.sku }}</td> }
@if (isColumnVisible('brand')) { <td>{{ product.brand }}</td> }
@if (isColumnVisible('price')) { <td>{{ product.price }} {{ product.currency }}</td> }

View File

@@ -8,6 +8,11 @@ select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color
@media (max-width: 640px) { .filters { grid-template-columns: 1fr; } .actions { flex-direction: column; align-items: stretch; } }
.skeleton-rows { display: grid; gap: 8px; }
// scope="row" on the 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; }
.toolbar__view-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.view-toggle { display: flex; gap: 2px; }
.columns-panel { display: flex; flex-wrap: wrap; gap: 12px; }

View File

@@ -8,7 +8,7 @@
<nav
#drawer
class="admin-layout__sidebar"
[attr.aria-label]="'adminShell.nav.dashboard' | translate"
[attr.aria-label]="'adminShell.sidebarLabel' | translate"
>
<a class="admin-layout__brand" [routerLink]="['/', currentLang(), 'backoffice', 'dashboard']">
<span class="admin-layout__brand-mark" aria-hidden="true">M</span>

View File

@@ -18,8 +18,9 @@
</div>
@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,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
<span class="sr-only">{{ 'common.loading' | translate }}</span>
</div>
} @else if (facade.transactions().length === 0) {
<app-empty-state [title]="'adminTransactions.emptyTitle' | translate" [description]="'adminTransactions.emptyDescription' | translate" />
@@ -39,7 +40,7 @@
<tbody>
@for (tx of facade.transactions(); track tx.id) {
<tr>
<td>{{ tx.orderNumber }}</td>
<th scope="row">{{ tx.orderNumber }}</th>
<td>{{ ('adminTransactions.typeValue.' + tx.type) | translate }}</td>
<td>{{ tx.method }}</td>
<td><app-badge [variant]="tx.status === 'success' ? 'success' : tx.status === 'failed' ? 'danger' : 'neutral'">{{ ('adminTransactions.status.' + tx.status) | translate }}</app-badge></td>

View File

@@ -6,3 +6,8 @@ select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
.skeleton-rows { display: grid; gap: 8px; }
@media (max-width: 640px) { .filters { flex-direction: column; align-items: stretch; } }
// scope="row" on the order-number 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; }

View File

@@ -2,8 +2,9 @@
<div class="card">
<h2>{{ 'adminUsers.title' | 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="40px" /> }
<span class="sr-only">{{ 'common.loading' | translate }}</span>
</div>
} @else if (facade.users().length === 0) {
<app-empty-state [title]="'adminUsers.emptyTitle' | translate" [description]="'adminUsers.emptyDescription' | translate" />
@@ -22,7 +23,7 @@
<tbody>
@for (user of facade.users(); track user.id) {
<tr>
<td>{{ user.name }}<br /><small>{{ user.telegramUsername }}</small></td>
<th scope="row">{{ user.name }}<br /><small>{{ user.telegramUsername }}</small></th>
<td>{{ ('adminUsers.scopeValue.' + user.scope) | translate }}</td>
<td>
<select [attr.aria-label]="'adminUsers.role' | translate" [ngModel]="user.roleId" (ngModelChange)="facade.setRole(user.id, $event)">
@@ -75,7 +76,7 @@
<tbody>
@for (invite of facade.invitations(); track invite.id) {
<tr>
<td>{{ invite.email }}</td>
<th scope="row">{{ invite.email }}</th>
<td>{{ facade.roleName(invite.roleId) }}</td>
<td><app-badge variant="neutral">{{ ('adminUsers.invitationStatus.' + invite.status) | translate }}</app-badge></td>
<td>

View File

@@ -7,3 +7,8 @@ select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
.skeleton-rows { display: grid; gap: 8px; }
@media (max-width: 640px) { .invite-form { flex-direction: column; align-items: stretch; } }
// scope="row" on the name/email cells (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; }