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; }
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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)); }
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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> }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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> }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user