@svadmin/ui 0.58.0 → 0.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -111,6 +111,7 @@
111
111
 
112
112
  <Dialog.Dialog bind:open onOpenChange={(v: boolean) => !v && close()}>
113
113
  <Dialog.DialogContent class="overflow-hidden p-0 sm:max-w-[600px] border-none shadow-2xl">
114
+ <Dialog.DialogTitle class="sr-only">{i18n.t('common.actions')}</Dialog.DialogTitle>
114
115
  <Command.Root
115
116
  shouldFilter={!aiMode}
116
117
  class="flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground [&_[data-command-group-heading]]:text-muted-foreground [&_[data-command-group-heading]]:px-2 [&_[data-command-group-heading]]:py-1.5 [&_[data-command-group-heading]]:text-xs [&_[data-command-group-heading]]:font-semibold [&_[data-command-group-heading]]:uppercase [&_[data-command-group-heading]]:tracking-wider"
@@ -76,6 +76,7 @@
76
76
  import * as DropdownMenu from './ui/dropdown-menu/index.js';
77
77
  import * as PaginationUI from './ui/pagination/index.js';
78
78
  import * as ContextMenu from './ui/context-menu/index.js';
79
+ import * as Select from './ui/select/index.js';
79
80
  import {
80
81
  Plus, Pencil, Trash2,
81
82
  Search, Download, ChevronDown, ChevronUp, SlidersHorizontal, Filter as FilterIcon,
@@ -87,6 +88,7 @@
87
88
  import TooltipButton from './TooltipButton.svelte';
88
89
  import InlineEdit from './InlineEdit.svelte';
89
90
  import DraggableHeader from './DraggableHeader.svelte';
91
+ import DataState from './content/DataState.svelte';
90
92
  import type { Snippet } from 'svelte';
91
93
 
92
94
  const i18n = useTranslation();
@@ -1172,17 +1174,17 @@
1172
1174
  <div class="space-y-1">
1173
1175
  <label class="text-xs text-muted-foreground" for="filter-{field.key}">{field.label}</label>
1174
1176
  {#if field.type === "select" && field.options}
1175
- <select
1177
+ <Select.Root
1176
1178
  id="filter-{field.key}"
1177
- class="h-9 text-sm w-full font-normal flex w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
1179
+ class="h-9 text-sm"
1178
1180
  value={filterValues[field.key] ?? ""}
1179
- onchange={(e) => setFilterValue(field.key, e.currentTarget.value)}
1181
+ onchange={(e) => setFilterValue(field.key, (e.currentTarget as HTMLSelectElement).value)}
1180
1182
  >
1181
1183
  <option value="">{i18n.t("common.all")}</option>
1182
1184
  {#each field.options as opt, _i (_i)}
1183
1185
  <option value={opt.value}>{opt.label}</option>
1184
1186
  {/each}
1185
- </select>
1187
+ </Select.Root>
1186
1188
  {:else}
1187
1189
  <Input
1188
1190
  id="filter-{field.key}"
@@ -1275,12 +1277,12 @@
1275
1277
  </div>
1276
1278
  <div class="space-y-1">
1277
1279
  <label class="text-xs text-muted-foreground" for="saved-list-view">{i18n.t("common.currentView")}</label>
1278
- <select
1280
+ <Select.Root
1279
1281
  id="saved-list-view"
1280
- class="h-9 w-full rounded-md border border-input bg-background px-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
1282
+ class="h-9 w-full"
1281
1283
  value={activeSavedViewId ?? ""}
1282
1284
  onchange={(event) => {
1283
- const id = event.currentTarget.value;
1285
+ const id = (event.currentTarget as HTMLSelectElement).value;
1284
1286
  const view = savedViews.find((candidate) => candidate.id === id);
1285
1287
  if (view) applySavedView(view);
1286
1288
  else {
@@ -1293,7 +1295,7 @@
1293
1295
  {#each savedViews as view (view.id)}
1294
1296
  <option value={view.id}>{view.name}</option>
1295
1297
  {/each}
1296
- </select>
1298
+ </Select.Root>
1297
1299
  </div>
1298
1300
  <div class="flex gap-2">
1299
1301
  <Input
@@ -1409,9 +1411,11 @@
1409
1411
  {/each}
1410
1412
  </div>
1411
1413
  {:else if query.error}
1412
- <div class="flex h-64 items-center justify-center text-destructive text-sm">
1413
- {i18n.t('common.loadFailed', { message: (query.error as Error).message })}
1414
- </div>
1414
+ <DataState
1415
+ state="error"
1416
+ description={i18n.t('common.loadFailed', { message: (query.error as Error).message })}
1417
+ retry={() => listResult.refetch()}
1418
+ />
1415
1419
  {:else}
1416
1420
  <div in:fade={{ duration: 150 }}>
1417
1421
  <!-- Desktop Table (hidden on mobile) -->
@@ -1475,7 +1479,7 @@
1475
1479
  {/each}
1476
1480
  </Table.Header>
1477
1481
  <Table.Body>
1478
- {#each tableView.rows as row, _i (_i)}
1482
+ {#each tableView.rows as row (row.id)}
1479
1483
  {@const record = row.original}
1480
1484
  {@const id = record[primaryKey] as string | number}
1481
1485
  {@const visibleCells = row_getVisibleCells(row).filter((cell) => isColumnVisible(cell.column.id))}
@@ -1702,7 +1706,7 @@
1702
1706
  </div>
1703
1707
  <!-- Card fields -->
1704
1708
  <div class="space-y-2">
1705
- {#each visibleFields.filter((field) => isColumnVisible(field.key)).slice(0, 6) as field, _i (_i)}
1709
+ {#each visibleFields.filter((field) => isColumnVisible(field.key)).slice(0, 6) as field (field.key)}
1706
1710
  {@const value = record[field.key]}
1707
1711
  <div class="flex items-start justify-between gap-4">
1708
1712
  <span class="text-xs font-medium text-muted-foreground shrink-0">{field.label}</span>
@@ -1737,7 +1741,7 @@
1737
1741
  {#if emptyState}
1738
1742
  {@render emptyState()}
1739
1743
  {:else}
1740
- {i18n.t('common.noData')}
1744
+ <DataState state="empty" class="border-0 bg-transparent px-2 py-4" />
1741
1745
  {/if}
1742
1746
  </div>
1743
1747
  {/each}
@@ -1751,11 +1755,12 @@
1751
1755
  <div class="flex flex-col sm:flex-row items-center justify-between gap-3 px-1 py-2 text-sm text-muted-foreground">
1752
1756
  <div class="flex shrink-0 items-center gap-3 leading-8">
1753
1757
  <span class="shrink-0 whitespace-nowrap">{i18n.t('common.total', { total: query.data?.total ?? 0 })}</span>
1754
- <select
1755
- class="h-8 w-[70px] shrink-0 px-1 py-1 flex items-center justify-between rounded-md border border-input bg-background text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
1758
+ <Select.Root
1759
+ aria-label={i18n.t('common.perPage')}
1760
+ class="h-8 w-[78px] shrink-0"
1756
1761
  value={String(pagination.pageSize ?? 10)}
1757
1762
  onchange={(e) => {
1758
- const size = Number((e.target as HTMLSelectElement).value);
1763
+ const size = Number((e.currentTarget as HTMLSelectElement).value);
1759
1764
  if (!isNaN(size)) {
1760
1765
  markSavedViewDirty();
1761
1766
  pagination = { ...pagination, pageSize: size, current: 1 };
@@ -1766,7 +1771,7 @@
1766
1771
  <option value="20">20</option>
1767
1772
  <option value="50">50</option>
1768
1773
  <option value="100">100</option>
1769
- </select>
1774
+ </Select.Root>
1770
1775
  </div>
1771
1776
  <PaginationUI.Root>
1772
1777
  <PaginationUI.Content>
@@ -45,6 +45,7 @@
45
45
 
46
46
  <Dialog.Dialog bind:open>
47
47
  <Dialog.DialogContent class="overflow-hidden p-0 sm:max-w-[560px]">
48
+ <Dialog.DialogTitle class="sr-only">{i18n.t('common.search')}</Dialog.DialogTitle>
48
49
  <Command.Root class="flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground [&_[data-command-group-heading]]:text-muted-foreground [&_[data-command-group-heading]]:px-2 [&_[data-command-group-heading]]:py-1.5 [&_[data-command-group-heading]]:text-xs [&_[data-command-group-heading]]:font-semibold [&_[data-command-group-heading]]:uppercase [&_[data-command-group-heading]]:tracking-wider">
49
50
  <div class="flex items-center border-b px-3" data-cmdk-input-wrapper="">
50
51
  <Search class="mr-2 h-4 w-4 shrink-0 opacity-50" />
@@ -17,8 +17,10 @@
17
17
  </script>
18
18
 
19
19
  <div
20
- class="flex flex-col items-center justify-center rounded-lg bg-card/50 px-8 py-10 text-center shadow-sm"
21
- in:scale={{ start: 0.95, duration: 250, opacity: 0 }}
20
+ class="flex flex-col items-center justify-center rounded-lg bg-card/50 px-8 py-10 text-center shadow-xs"
21
+ in:scale={{ start: 0.95, duration: 200, opacity: 0 }}
22
+ role="status"
23
+ aria-live="polite"
22
24
  >
23
25
  <div class="h-20 w-20 rounded-full bg-muted flex items-center justify-center mb-4">
24
26
  <PackageOpen class="h-10 w-10 text-muted-foreground" />
@@ -170,7 +170,14 @@
170
170
  </div>
171
171
 
172
172
  <!-- Mobile sidebar via Sheet -->
173
- <Sheet.Root bind:open={mobileMenuOpen} side="left">
173
+ <Sheet.Root
174
+ bind:open={mobileMenuOpen}
175
+ side="left"
176
+ role="dialog"
177
+ aria-modal="true"
178
+ aria-labelledby={`${layoutScope}-mobile-navigation-title`}
179
+ >
180
+ <Sheet.Title id={`${layoutScope}-mobile-navigation-title`} class="sr-only">{title}</Sheet.Title>
174
181
  <div class="md:hidden">
175
182
  <Sidebar collapsed={false} {identity} {title} {menu} {routeMode} onToggle={() => { mobileMenuOpen = false; }} onLogout={handleLogout} />
176
183
  </div>
@@ -60,11 +60,13 @@
60
60
  <p class="text-xl font-mono font-semibold text-foreground tracking-tight">{value}</p>
61
61
  {#if trend}
62
62
  <span
63
- class="text-xs font-medium {trend.value >= 0 ? 'text-success' : 'text-destructive'}"
63
+ class="text-xs font-medium inline-flex items-center gap-0.5 {trend.value >= 0 ? 'text-success' : 'text-destructive'}"
64
+ aria-label="{trend.value >= 0 ? '上升' : '下降'} {Math.abs(trend.value)}%"
64
65
  >
65
- {trend.value >= 0 ? '↑' : '↓'}{Math.abs(trend.value)}%
66
+ <span aria-hidden="true">{trend.value >= 0 ? '↑' : '↓'}</span>
67
+ <span>{Math.abs(trend.value)}%</span>
66
68
  {#if trend.label}
67
- <span class="text-muted-foreground">{trend.label}</span>
69
+ <span class="text-muted-foreground ml-0.5">{trend.label}</span>
68
70
  {/if}
69
71
  </span>
70
72
  {/if}
@@ -227,10 +227,19 @@
227
227
  {/if}
228
228
  </Button>
229
229
 
230
- <Sheet.Root bind:open side="right" class="w-full max-w-6xl border-l border-border bg-card p-0" onClose={() => open = false}>
230
+ <Sheet.Root
231
+ bind:open
232
+ side="right"
233
+ class="w-full max-w-6xl border-l border-border bg-card p-0"
234
+ role="dialog"
235
+ aria-modal="true"
236
+ aria-labelledby="svadmin-task-queue-title"
237
+ onClose={() => open = false}
238
+ >
231
239
  <Sheet.Content class="h-full gap-0 p-0">
232
240
  <div class="flex h-full flex-col">
233
241
  <div class="border-b border-border bg-muted/30 px-4 py-3">
242
+ <Sheet.Title id="svadmin-task-queue-title" class="sr-only">{resolvedTitle}</Sheet.Title>
234
243
  <div class="flex flex-wrap items-center gap-2">
235
244
  <ListTodo class="h-5 w-5 text-muted-foreground" />
236
245
  <h2 class="text-sm font-semibold">{resolvedTitle}</h2>
@@ -32,7 +32,7 @@
32
32
  bind:this={ref}
33
33
  data-slot={dataSlot}
34
34
  class={cn(
35
- "dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-9 rounded-lg border bg-transparent px-3 py-1.5 text-base transition-colors file:h-7 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
35
+ "dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-9 rounded-lg border bg-transparent p-1 pr-3 text-xs md:text-sm text-muted-foreground transition-colors file:mr-3 file:inline-flex file:h-7 file:cursor-pointer file:items-center file:rounded-md file:border file:border-border/60 file:bg-muted file:px-2.5 file:text-xs file:font-medium file:text-foreground file:transition-colors hover:file:bg-muted/80 focus-visible:ring-3 aria-invalid:ring-3 placeholder:text-muted-foreground w-full min-w-0 outline-none cursor-pointer disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
36
36
  className
37
37
  )}
38
38
  type="file"
@@ -44,7 +44,7 @@
44
44
  bind:this={ref}
45
45
  data-slot={dataSlot}
46
46
  class={cn(
47
- "dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-9 rounded-lg border bg-transparent px-3 py-1.5 text-base transition-colors file:h-7 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
47
+ "dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-9 rounded-lg border bg-transparent px-3 py-1.5 text-base transition-colors focus-visible:ring-3 aria-invalid:ring-3 md:text-sm placeholder:text-muted-foreground w-full min-w-0 outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
48
48
  className
49
49
  )}
50
50
  {type}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/ui",
3
- "version": "0.58.0",
3
+ "version": "0.59.0",
4
4
  "description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
5
5
  "type": "module",
6
6
  "sideEffects": [