@svadmin/ui 0.58.0 → 0.60.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"
@@ -41,10 +41,13 @@
41
41
  );
42
42
 
43
43
  const percentLabel = $derived(`${Math.abs(diff * 100).toFixed(1)}%`);
44
+ const anomalyAriaLabel = $derived(
45
+ `Anomaly: current ${formatter(value)}, baseline ${formatter(baseline)} (${diff > 0 ? '+' : '-'}${percentLabel})`
46
+ );
44
47
  </script>
45
48
 
46
49
  {#if !isAnomaly}
47
- <span class="text-muted-foreground text-sm font-medium {className}">
50
+ <span class="text-muted-foreground text-sm font-medium tabular-nums {className}">
48
51
  {formatter(value)}
49
52
  </span>
50
53
  {:else}
@@ -53,15 +56,16 @@
53
56
  variant="ghost"
54
57
  size="sm"
55
58
  class="p-0 h-auto hover:bg-transparent"
59
+ aria-label={anomalyAriaLabel}
56
60
  >
57
61
  <Badge
58
62
  variant={isGood ? 'default' : 'destructive'}
59
- class="flex items-center gap-1 font-mono text-xs px-1.5 py-0 {isGood ? 'bg-success hover:bg-success/90 text-success-foreground' : ''} {className}"
63
+ class="flex items-center gap-1 font-mono text-xs tabular-nums px-1.5 py-0 {isGood ? 'bg-success hover:bg-success/90 text-success-foreground' : ''} {className}"
60
64
  >
61
65
  {#if diff > 0}
62
- <TrendingUp class="h-3 w-3" />
66
+ <TrendingUp class="h-3 w-3" aria-hidden="true" />
63
67
  {:else}
64
- <TrendingDown class="h-3 w-3" />
68
+ <TrendingDown class="h-3 w-3" aria-hidden="true" />
65
69
  {/if}
66
70
  {formatter(value)}
67
71
  </Badge>
@@ -198,14 +198,14 @@
198
198
  tooltip={i18n.t('common.back')}
199
199
  onclick={() => guardNavigate(() => navigation.list(resourceName))}
200
200
  >
201
- <ArrowLeft class="h-5 w-5" />
201
+ <ArrowLeft class="h-5 w-5" aria-hidden="true" />
202
202
  </TooltipButton>
203
203
  <h1 class="text-xl font-semibold text-foreground">{pageTitle}</h1>
204
204
  {#if headerContent}
205
205
  {@render headerContent()}
206
206
  {/if}
207
207
  {#if form.isTainted()}
208
- <Badge variant="outline" class="border-warning/30 bg-warning/10 text-warning-foreground">{i18n.t('common.unsaved')}</Badge>
208
+ <Badge variant="outline" role="status" aria-live="polite" class="border-warning/30 bg-warning/10 text-warning-foreground">{i18n.t('common.unsaved')}</Badge>
209
209
  {/if}
210
210
  </div>
211
211
  {/if}
@@ -226,7 +226,7 @@
226
226
  {#if submitError}
227
227
  <div transition:slide={{ duration: 300, axis: 'y' }} class="svadmin-shake">
228
228
  <Alert.Root variant="destructive">
229
- <AlertCircle class="h-4 w-4" />
229
+ <AlertCircle class="h-4 w-4" aria-hidden="true" />
230
230
  <Alert.Description>{submitError}</Alert.Description>
231
231
  </Alert.Root>
232
232
  </div>
@@ -301,9 +301,9 @@
301
301
  {:else if !isReadonly}
302
302
  <Button type="submit" size={isCompact ? 'sm' : 'default'} disabled={form.submitting}>
303
303
  {#if form.submitting}
304
- <Loader2 class="h-4 w-4 animate-spin" data-icon="inline-start" />
304
+ <Loader2 class="h-4 w-4 animate-spin" data-icon="inline-start" aria-hidden="true" />
305
305
  {:else}
306
- <Save class="h-4 w-4" data-icon="inline-start" />
306
+ <Save class="h-4 w-4" data-icon="inline-start" aria-hidden="true" />
307
307
  {/if}
308
308
  {i18n.t('common.save')}
309
309
  </Button>
@@ -16,20 +16,20 @@
16
16
  }>();
17
17
  </script>
18
18
 
19
- <div class="flex items-center gap-2 text-sm text-muted-foreground">
19
+ <div class="flex items-center gap-2 text-sm text-muted-foreground" role="status" aria-live="polite">
20
20
  {#key status}
21
21
  <div class="flex items-center gap-2" in:fly={{ y: -8, duration: 200 }} out:fade={{ duration: 150 }}>
22
22
  {#if status === 'idle'}
23
- <Cloud class="h-4 w-4" />
23
+ <Cloud class="h-4 w-4" aria-hidden="true" />
24
24
  <span>{i18n.t('autoSave.idle')}</span>
25
25
  {:else if status === 'loading'}
26
- <Loader2 class="h-4 w-4 animate-spin text-primary" />
26
+ <Loader2 class="h-4 w-4 animate-spin text-primary" aria-hidden="true" />
27
27
  <span>{i18n.t('autoSave.saving')}</span>
28
28
  {:else if status === 'success'}
29
- <Check class="h-4 w-4 text-green-500" />
30
- <span class="text-green-500">{i18n.t('autoSave.saved')}</span>
29
+ <Check class="h-4 w-4 text-success" aria-hidden="true" />
30
+ <span class="text-success">{i18n.t('autoSave.saved')}</span>
31
31
  {:else if status === 'error'}
32
- <X class="h-4 w-4 text-destructive" />
32
+ <X class="h-4 w-4 text-destructive" aria-hidden="true" />
33
33
  <span class="text-destructive" title={error}>{i18n.t('autoSave.error')}</span>
34
34
  {/if}
35
35
  </div>
@@ -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();
@@ -1097,12 +1099,12 @@
1097
1099
  <!-- Selection Banner (Enterprise Batch Actions) -->
1098
1100
  {#if selectedCount > 0}
1099
1101
  <div
1100
- class="flex flex-col gap-3 border border-primary/20 bg-primary/10 px-3.5 py-2 rounded-lg text-sm text-foreground animate-in fade-in duration-200 sm:flex-row sm:items-center sm:justify-between"
1102
+ class="flex flex-col gap-3 border border-primary/20 bg-primary/10 px-3.5 py-2 rounded-lg text-sm text-foreground animate-in fade-in duration-200 motion-reduce:animate-none sm:flex-row sm:items-center sm:justify-between"
1101
1103
  aria-label={i18n.t("common.selectedCount", { count: selectedCount })}
1102
1104
  data-svadmin-batch-toolbar
1103
1105
  >
1104
1106
  <div class="flex min-w-0 items-center gap-2 text-sm">
1105
- <span class="font-medium text-foreground">{i18n.t("common.selectedCount", { count: selectedCount })}</span>
1107
+ <span class="font-medium text-foreground tabular-nums">{i18n.t("common.selectedCount", { count: selectedCount })}</span>
1106
1108
  {#if deleteManyMutation.isPending}
1107
1109
  <span class="text-muted-foreground" role="status">{i18n.t("common.processing")}</span>
1108
1110
  {/if}
@@ -1119,7 +1121,7 @@
1119
1121
  disabled={deleteManyMutation.isPending}
1120
1122
  onclick={confirmBatchDelete}
1121
1123
  >
1122
- <Trash2 class="h-3.5 w-3.5" data-icon="inline-start" /> {i18n.t("common.batchDelete", { count: selectedCount })}
1124
+ <Trash2 class="h-3.5 w-3.5" data-icon="inline-start" aria-hidden="true" /> {i18n.t("common.batchDelete", { count: selectedCount })}
1123
1125
  </Button>
1124
1126
  {/if}
1125
1127
  <Button
@@ -1141,7 +1143,7 @@
1141
1143
  <div class="flex flex-wrap items-center gap-2 flex-1 min-w-[200px]">
1142
1144
  {#if searchableFields.length > 0}
1143
1145
  <div class="relative max-w-sm flex-1 sm:min-w-[220px]">
1144
- <Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
1146
+ <Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
1145
1147
  <Input
1146
1148
  type="text"
1147
1149
  value={searchText}
@@ -1157,10 +1159,10 @@
1157
1159
  <Popover.Trigger>
1158
1160
  {#snippet child({ props })}
1159
1161
  <Button variant="outline" size="sm" class="h-9 px-3" {...props}>
1160
- <FilterIcon class="h-4 w-4" data-icon="inline-start" />
1162
+ <FilterIcon class="h-4 w-4" data-icon="inline-start" aria-hidden="true" />
1161
1163
  {i18n.t("common.filter")}
1162
1164
  {#if activeFilterCount > 0}
1163
- <Badge variant="secondary" class="ml-1 h-5 min-w-5 px-1">{activeFilterCount}</Badge>
1165
+ <Badge variant="secondary" class="ml-1 h-5 min-w-5 px-1 tabular-nums">{activeFilterCount}</Badge>
1164
1166
  {/if}
1165
1167
  </Button>
1166
1168
  {/snippet}
@@ -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}"
@@ -1213,7 +1215,7 @@
1213
1215
  <div class="flex items-center gap-1.5 shrink-0">
1214
1216
  {#if !showHeader && canExport}
1215
1217
  <TooltipButton tooltip={i18n.t("common.export")} variant="outline" size="sm" class="h-9 px-2.5" onclick={exportCSV}>
1216
- <Download class="h-4 w-4" />
1218
+ <Download class="h-4 w-4" aria-hidden="true" />
1217
1219
  </TooltipButton>
1218
1220
  {/if}
1219
1221
 
@@ -1222,7 +1224,7 @@
1222
1224
  <DropdownMenu.Trigger>
1223
1225
  {#snippet child({ props })}
1224
1226
  <TooltipButton tooltip={i18n.t("common.density")} variant="outline" size="sm" class="h-9 px-2.5" {...props}>
1225
- <Rows class="h-4 w-4" />
1227
+ <Rows class="h-4 w-4" aria-hidden="true" />
1226
1228
  </TooltipButton>
1227
1229
  {/snippet}
1228
1230
  </DropdownMenu.Trigger>
@@ -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}
@@ -1750,12 +1754,13 @@
1750
1754
  {#if totalPages > 0}
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
- <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"
1757
+ <span class="shrink-0 whitespace-nowrap tabular-nums">{i18n.t('common.total', { total: query.data?.total ?? 0 })}</span>
1758
+ <Select.Root
1759
+ aria-label={i18n.t('common.perPage')}
1760
+ class="h-8 w-[78px] shrink-0 tabular-nums"
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>
@@ -1782,6 +1787,7 @@
1782
1787
  <PaginationUI.Ellipsis />
1783
1788
  {:else}
1784
1789
  <PaginationUI.Link
1790
+ class="tabular-nums"
1785
1791
  isActive={page === currentPage}
1786
1792
  onclick={() => goToPage(page as number)}
1787
1793
  >
@@ -44,15 +44,15 @@
44
44
  {#if variant === 'floating'}
45
45
  <div
46
46
  class={cn(
47
- 'fixed bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-3 rounded-lg border border-border/80 bg-background/95 px-4 py-2.5 shadow-2xl backdrop-blur-md transition-all duration-200 animate-in fade-in-0 slide-in-from-bottom-4',
47
+ 'fixed bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-3 rounded-lg border border-border/80 bg-background/95 px-4 py-2.5 shadow-2xl backdrop-blur-md transition-all duration-200 animate-in fade-in-0 slide-in-from-bottom-4 motion-reduce:animate-none',
48
48
  className
49
49
  )}
50
50
  role="region"
51
51
  aria-label="Batch Actions"
52
52
  >
53
53
  <div class="flex items-center gap-2 pr-2 border-r border-border/60">
54
- <CheckCircle2 class="h-4 w-4 text-primary" />
55
- <span class="text-xs font-medium text-foreground">{displayText}</span>
54
+ <CheckCircle2 class="h-4 w-4 text-primary" aria-hidden="true" />
55
+ <span class="text-xs font-medium text-foreground tabular-nums">{displayText}</span>
56
56
  </div>
57
57
 
58
58
  {#if actions}
@@ -75,7 +75,7 @@
75
75
  onclick={onclear}
76
76
  aria-label={clearLabel}
77
77
  >
78
- <X class="h-3.5 w-3.5 mr-1" />
78
+ <X class="h-3.5 w-3.5 mr-1" aria-hidden="true" />
79
79
  {clearLabel}
80
80
  </Button>
81
81
  {/if}
@@ -91,14 +91,14 @@
91
91
  aria-label="Batch Actions"
92
92
  >
93
93
  <div class="flex items-center gap-2">
94
- <Badge variant="secondary" class="h-5 px-1.5 font-semibold text-[11px] bg-primary/15 text-primary border-primary/20">
94
+ <Badge variant="secondary" class="h-5 px-1.5 font-semibold text-[11px] tabular-nums bg-primary/15 text-primary border-primary/20">
95
95
  {selectedCount}
96
96
  </Badge>
97
- <span class="font-medium text-foreground">{displayText}</span>
97
+ <span class="font-medium text-foreground tabular-nums">{displayText}</span>
98
98
  {#if onclear}
99
99
  <button
100
100
  type="button"
101
- class="text-muted-foreground hover:text-foreground underline underline-offset-2 ml-1 text-xs cursor-pointer focus:outline-none"
101
+ class="text-muted-foreground hover:text-foreground underline underline-offset-2 ml-1 text-xs cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 rounded-xs"
102
102
  onclick={onclear}
103
103
  >
104
104
  {clearLabel}
@@ -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" />
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import * as AlertDialog from './ui/alert-dialog/index.js';
3
3
  import { Button } from './ui/button/index.js';
4
+ import { Loader2 } from '@lucide/svelte';
4
5
  import { useTranslation } from '@svadmin/core/i18n';
5
6
 
6
7
  const i18n = useTranslation();
@@ -61,6 +62,9 @@
61
62
  <AlertDialog.Action>
62
63
  {#snippet child({ props })}
63
64
  <Button variant={variantMap[variant as keyof typeof variantMap]} {...props} disabled={confirming} onclick={handleConfirm}>
65
+ {#if confirming}
66
+ <Loader2 class="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
67
+ {/if}
64
68
  {resolvedConfirmText}
65
69
  </Button>
66
70
  {/snippet}
@@ -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" />
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { useUpdate } from '@svadmin/core';
3
3
  import type { FieldDefinition } from '@svadmin/core';
4
+ import { Loader2 } from '@lucide/svelte';
4
5
 
5
6
  interface Props {
6
7
  resourceName: string;
@@ -19,6 +20,7 @@
19
20
  let saving = $state(false);
20
21
  let savePending = false;
21
22
  const displayValue = $derived(String(value ?? ''));
23
+ const fieldLabel = $derived(field.label || field.key);
22
24
 
23
25
  const { mutation } = useUpdate({ get resource() { return resourceName; } });
24
26
 
@@ -75,24 +77,32 @@
75
77
  </script>
76
78
 
77
79
  {#if editing}
78
- <input
79
- bind:this={inputRef}
80
- type={field.type === 'number' ? 'number' : 'text'}
81
- bind:value={editValue}
82
- onkeydown={handleKeydown}
83
- onblur={handleBlur}
84
- disabled={saving}
85
- class="h-7 w-full rounded border bg-background px-2 text-sm outline-none ring-1 ring-primary/50 focus:ring-2 focus:ring-primary transition-all"
86
- />
80
+ <div class="relative flex items-center w-full">
81
+ <input
82
+ bind:this={inputRef}
83
+ type={field.type === 'number' ? 'number' : 'text'}
84
+ bind:value={editValue}
85
+ onkeydown={handleKeydown}
86
+ onblur={handleBlur}
87
+ disabled={saving}
88
+ aria-label={fieldLabel}
89
+ class="h-7 w-full rounded border bg-background px-2 text-sm outline-none ring-1 ring-primary/50 focus:ring-2 focus:ring-primary transition-all disabled:opacity-60"
90
+ />
91
+ {#if saving}
92
+ <span class="absolute right-2 flex items-center pointer-events-none" role="status" aria-label="Saving...">
93
+ <Loader2 class="size-3.5 animate-spin text-muted-foreground" aria-hidden="true" />
94
+ </span>
95
+ {/if}
96
+ </div>
87
97
  {:else}
88
98
  <span
89
- class="inline-block cursor-pointer rounded px-1 py-0.5 -mx-1 hover:bg-accent/50 transition-colors"
99
+ class="inline-flex items-center cursor-pointer rounded px-1.5 py-0.5 -mx-1 text-sm text-foreground hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 transition-colors"
90
100
  ondblclick={startEdit}
91
- title="Double-click to edit"
101
+ title={`Double-click to edit ${fieldLabel}`}
92
102
  role="button"
93
103
  tabindex="0"
94
- onkeydown={(e) => { if (e.key === 'Enter' || e.key === 'F2') startEdit(); }}
104
+ onkeydown={(e) => { if (e.key === 'Enter' || e.key === 'F2') { e.preventDefault(); startEdit(); } }}
95
105
  >
96
- {value ?? '—'}
106
+ {value != null && value !== '' ? String(value) : '—'}
97
107
  </span>
98
108
  {/if}
@@ -95,25 +95,36 @@ ${scopedContext}`;
95
95
  }
96
96
  </script>
97
97
 
98
- <div class="rounded-xl border bg-card text-card-foreground shadow-sm flex flex-col {className}">
98
+ <div
99
+ class="rounded-xl border bg-card text-card-foreground shadow-sm flex flex-col {className}"
100
+ role="region"
101
+ aria-label={title}
102
+ >
99
103
  <div class="flex items-center justify-between p-4 border-b">
100
104
  <div class="flex items-center gap-2 font-medium">
101
- <Sparkles class="h-4 w-4 text-amber-500" />
105
+ <Sparkles class="h-4 w-4 text-amber-500" aria-hidden="true" />
102
106
  {title}
103
107
  </div>
104
- <Button variant="ghost" size="icon-sm" class="h-6 w-6" onclick={getInsights} disabled={isPredicting || !provider}>
105
- <RefreshCcw class="h-3 w-3 {isPredicting ? 'animate-spin' : ''}" />
108
+ <Button
109
+ variant="ghost"
110
+ size="icon-sm"
111
+ class="h-6 w-6"
112
+ onclick={getInsights}
113
+ disabled={isPredicting || !provider}
114
+ aria-label="Refresh AI Insights"
115
+ >
116
+ <RefreshCcw class="h-3 w-3 {isPredicting ? 'animate-spin' : ''}" aria-hidden="true" />
106
117
  </Button>
107
118
  </div>
108
119
 
109
- <div class="p-4 flex-1">
120
+ <div class="p-4 flex-1" role="status" aria-live="polite" aria-busy={isPredicting}>
110
121
  {#if !provider}
111
122
  <div class="text-sm text-muted-foreground italic flex items-center justify-center h-full">
112
123
  ChatProvider not configured.
113
124
  </div>
114
125
  {:else if isPredicting && !insightText}
115
126
  <div class="flex items-center justify-center gap-2 text-sm text-muted-foreground h-full py-4">
116
- <Loader2 class="h-4 w-4 animate-spin" /> Analyzing data...
127
+ <Loader2 class="h-4 w-4 animate-spin" aria-hidden="true" /> Analyzing data...
117
128
  </div>
118
129
  {:else if !insightText}
119
130
  <div class="text-sm text-muted-foreground italic flex items-center justify-center h-full">
@@ -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>
@@ -38,11 +38,17 @@
38
38
 
39
39
  {#key status}
40
40
  <div transition:fade={{ duration: 200 }}>
41
- <Badge variant={cfg.variant} class="gap-1.5 select-none" title={cfg.label}>
42
- <span class="relative flex h-2 w-2">
41
+ <Badge
42
+ variant={cfg.variant}
43
+ class="gap-1.5 select-none"
44
+ title={cfg.label}
45
+ role="status"
46
+ aria-label={`Connection status: ${cfg.label}${resource ? ` for ${resource}` : ''}`}
47
+ >
48
+ <span class="relative flex h-2 w-2" aria-hidden="true">
43
49
  <span class="{cfg.dotClass} rounded-full h-2 w-2"></span>
44
50
  {#if status === 'connected'}
45
- <span class="absolute inset-0 rounded-full {cfg.dotClass} opacity-40 animate-ping"></span>
51
+ <span class="absolute inset-0 rounded-full {cfg.dotClass} opacity-40 animate-ping motion-reduce:animate-none"></span>
46
52
  {/if}
47
53
  </span>
48
54
  <span class="text-[0.6875rem] font-semibold uppercase tracking-wide">{cfg.label}</span>
@@ -55,7 +61,7 @@
55
61
  <span class="text-[0.625rem] opacity-70">
56
62
  {lastEvent.type}
57
63
  {#if timeSinceEvent !== null && timeSinceEvent < 60}
58
- <span class="opacity-60">{timeSinceEvent}s ago</span>
64
+ <span class="opacity-60 tabular-nums">{timeSinceEvent}s ago</span>
59
65
  {/if}
60
66
  </span>
61
67
  {/if}
@@ -61,7 +61,7 @@
61
61
  data-svadmin-record-detail
62
62
  >
63
63
  {#if permissionPending || (canReadRecord && query.isLoading)}
64
- <div class="divide-y divide-border/60" aria-label={i18n.t('common.loading')}>
64
+ <div class="divide-y divide-border/60" role="status" aria-live="polite" aria-label={i18n.t('common.loading')} aria-busy="true">
65
65
  {#each showFields.slice(0, 6) as field (field.key)}
66
66
  <div class="grid gap-2 py-4 sm:grid-cols-[minmax(8rem,1fr)_2fr] sm:gap-6">
67
67
  <Skeleton class="h-4 w-24" />
@@ -104,7 +104,7 @@
104
104
  {#if canReadRecord && recordId != null && resource.canShow !== false}
105
105
  <CanAccess resource={resourceName} action="show" params={{ id: recordId }}>
106
106
  <Button variant="outline" onclick={() => navigateTo('show')}>
107
- <Eye class="h-4 w-4" data-icon="inline-start" />
107
+ <Eye class="h-4 w-4" data-icon="inline-start" aria-hidden="true" />
108
108
  {i18n.t('common.openFullDetail')}
109
109
  </Button>
110
110
  </CanAccess>
@@ -112,7 +112,7 @@
112
112
  {#if canReadRecord && recordId != null && resource.canEdit !== false}
113
113
  <CanAccess resource={resourceName} action="edit" params={{ id: recordId }}>
114
114
  <Button onclick={() => navigateTo('edit')}>
115
- <Pencil class="h-4 w-4" data-icon="inline-start" />
115
+ <Pencil class="h-4 w-4" data-icon="inline-start" aria-hidden="true" />
116
116
  {i18n.t('common.edit')}
117
117
  </Button>
118
118
  </CanAccess>
@@ -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>
@@ -46,15 +46,15 @@
46
46
  </div>
47
47
  {:else if state === 'error' || state === 'forbidden'}
48
48
  <Alert.Root variant={state === 'error' ? 'destructive' : 'warning'} class={className}>
49
- {#if state === 'error'}<AlertTriangle class="size-4" />{:else}<LockKeyhole class="size-4" />{/if}
49
+ {#if state === 'error'}<AlertTriangle class="size-4" aria-hidden="true" />{:else}<LockKeyhole class="size-4" aria-hidden="true" />{/if}
50
50
  <Alert.Title>{resolvedTitle}</Alert.Title>
51
51
  <Alert.Description>{resolvedDescription}</Alert.Description>
52
- {#if retry}<Button variant="outline" size="sm" class="mt-3" onclick={retry}><RefreshCw class="size-3.5" />{resolvedRetryLabel}</Button>{/if}
52
+ {#if retry}<Button variant="outline" size="sm" class="mt-3" onclick={retry}><RefreshCw class="size-3.5" aria-hidden="true" />{resolvedRetryLabel}</Button>{/if}
53
53
  {#if action}<div class="mt-3">{@render action()}</div>{/if}
54
54
  </Alert.Root>
55
55
  {:else}
56
- <div class={'flex flex-col items-center justify-center rounded-lg border border-dashed border-border bg-card px-6 py-8 text-center ' + className}>
57
- <Inbox class="size-8 text-muted-foreground" /><h3 class="mt-3 text-sm font-semibold text-foreground">{resolvedTitle}</h3><p class="mt-1 max-w-sm text-sm text-muted-foreground">{resolvedDescription}</p>
56
+ <div class={'flex flex-col items-center justify-center rounded-lg border border-dashed border-border bg-card px-6 py-8 text-center ' + className} role="status" aria-live="polite">
57
+ <Inbox class="size-8 text-muted-foreground" aria-hidden="true" /><h3 class="mt-3 text-sm font-semibold text-foreground">{resolvedTitle}</h3><p class="mt-1 max-w-sm text-sm text-muted-foreground">{resolvedDescription}</p>
58
58
  {#if action}<div class="mt-4">{@render action()}</div>{/if}
59
59
  </div>
60
60
  {/if}
@@ -47,11 +47,11 @@
47
47
  >
48
48
  <div class="flex min-w-0 flex-1 items-start gap-2.5">
49
49
  {#if tone === 'danger'}
50
- <CircleAlert class="mt-0.5 size-4 shrink-0" />
50
+ <CircleAlert class="mt-0.5 size-4 shrink-0" aria-hidden="true" />
51
51
  {:else if tone === 'warning'}
52
- <TriangleAlert class="mt-0.5 size-4 shrink-0" />
52
+ <TriangleAlert class="mt-0.5 size-4 shrink-0" aria-hidden="true" />
53
53
  {:else}
54
- <Info class="mt-0.5 size-4 shrink-0 text-muted-foreground" />
54
+ <Info class="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
55
55
  {/if}
56
56
  <p class="min-w-0 leading-5">{message}</p>
57
57
  </div>
@@ -46,7 +46,7 @@
46
46
  <div class="flex flex-col gap-2 sm:flex-row sm:items-center">
47
47
  {#if showSearch}
48
48
  <div class="relative min-w-0 flex-1">
49
- <Search class={cn('pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground', isCompact ? 'size-3.5' : 'size-4')} />
49
+ <Search class={cn('pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground', isCompact ? 'size-3.5' : 'size-4')} aria-hidden="true" />
50
50
  <Input
51
51
  bind:value={query}
52
52
  {placeholder}
@@ -62,7 +62,7 @@
62
62
  title={clearLabel}
63
63
  onclick={() => query = ''}
64
64
  >
65
- <X class="size-3.5" />
65
+ <X class="size-3.5" aria-hidden="true" />
66
66
  </Button>
67
67
  {/if}
68
68
  </div>
@@ -81,17 +81,17 @@
81
81
  aria-expanded={advancedOpen}
82
82
  aria-controls={advancedPanelId}
83
83
  >
84
- <SlidersHorizontal class="size-3.5" />
84
+ <SlidersHorizontal class="size-3.5" aria-hidden="true" />
85
85
  <span>{advancedLabel}</span>
86
86
  {#if activeFilterCount > 0}
87
- <Badge variant="secondary" class="ml-0.5 h-4 min-w-4 px-1 text-[10px] leading-none font-semibold">
87
+ <Badge variant="secondary" class="ml-0.5 h-4 min-w-4 px-1 text-[10px] leading-none font-semibold tabular-nums">
88
88
  {activeFilterCount}
89
89
  </Badge>
90
90
  {/if}
91
91
  {#if advancedOpen}
92
- <ChevronUp class="size-3 text-muted-foreground ml-0.5" />
92
+ <ChevronUp class="size-3 text-muted-foreground ml-0.5" aria-hidden="true" />
93
93
  {:else}
94
- <ChevronDown class="size-3 text-muted-foreground ml-0.5" />
94
+ <ChevronDown class="size-3 text-muted-foreground ml-0.5" aria-hidden="true" />
95
95
  {/if}
96
96
  </Button>
97
97
  {/if}
@@ -102,7 +102,7 @@
102
102
  {/if}
103
103
  </div>
104
104
  {#if advanced && advancedOpen}
105
- <div id={advancedPanelId} class="svadmin-filter-toolbar-advanced rounded-lg border border-border/60 bg-muted/20 p-3" aria-hidden="false">
105
+ <div id={advancedPanelId} role="region" aria-label={advancedLabel} class="svadmin-filter-toolbar-advanced rounded-lg border border-border/60 bg-muted/20 p-3" aria-hidden="false">
106
106
  {@render advanced()}
107
107
  </div>
108
108
  {/if}
@@ -30,15 +30,15 @@
30
30
  };
31
31
  </script>
32
32
 
33
- <div data-svadmin-metric-card class={'min-w-0 rounded-lg border border-border bg-card p-4 shadow-sm ' + className}>
33
+ <div data-svadmin-metric-card role="region" aria-label={label} class={'min-w-0 rounded-lg border border-border bg-card p-4 shadow-sm ' + className}>
34
34
  <div class="flex items-start justify-between gap-3">
35
35
  <p class="text-sm text-muted-foreground">{label}</p>
36
- {#if icon}<span class="text-muted-foreground">{@render icon()}</span>{/if}
36
+ {#if icon}<span class="text-muted-foreground" aria-hidden="true">{@render icon()}</span>{/if}
37
37
  </div>
38
38
  {#if loading}
39
39
  <Skeleton class="mt-3 h-8 w-24" />
40
40
  {:else}
41
- <p class="mt-2 truncate text-2xl font-semibold tracking-normal text-foreground">{value}</p>
42
- {#if detail || trend}<div class="mt-2 flex flex-wrap gap-2 text-xs">{#if trend}<span class={'font-medium ' + trendClass[trendTone]}>{trend}</span>{/if}{#if detail}<span class="text-muted-foreground">{detail}</span>{/if}</div>{/if}
41
+ <p class="mt-2 truncate text-2xl font-semibold tracking-normal text-foreground tabular-nums">{value}</p>
42
+ {#if detail || trend}<div class="mt-2 flex flex-wrap gap-2 text-xs">{#if trend}<span class={'font-medium tabular-nums ' + trendClass[trendTone]}>{trend}</span>{/if}{#if detail}<span class="text-muted-foreground">{detail}</span>{/if}</div>{/if}
43
43
  {/if}
44
44
  </div>
@@ -1,22 +1,76 @@
1
1
  <script lang="ts">
2
2
  import { Input } from '../ui/input/index.js';
3
- interface Props { value?: string[]; length?: number; disabled?: boolean; 'aria-label'?: string; class?: string; }
4
- let { value = $bindable<string[]>([]), length = 6, disabled = false, 'aria-label': ariaLabel = 'One-time password', class: className = '' }: Props = $props();
3
+ interface Props {
4
+ value?: string[];
5
+ length?: number;
6
+ disabled?: boolean;
7
+ 'aria-label'?: string;
8
+ class?: string;
9
+ }
10
+ let {
11
+ value = $bindable<string[]>([]),
12
+ length = 6,
13
+ disabled = false,
14
+ 'aria-label': ariaLabel = 'One-time password',
15
+ class: className = '',
16
+ }: Props = $props();
17
+
5
18
  const slots = $derived(Array.from({ length }, (_, index) => index));
19
+
6
20
  function updateDigit(event: Event, index: number) {
7
21
  const input = event.currentTarget as HTMLInputElement;
8
22
  const digit = input.value.replace(/\D/g, '').slice(-1);
9
23
  const next = [...value];
24
+ while (next.length < length) next.push('');
10
25
  next[index] = digit;
11
26
  value = next;
12
27
  if (digit && index < length - 1) {
13
- const sibling = input.parentElement?.querySelectorAll('input')[index + 1] as HTMLInputElement | undefined;
28
+ const inputs = input.parentElement?.querySelectorAll('input');
29
+ const sibling = inputs?.[index + 1] as HTMLInputElement | undefined;
14
30
  sibling?.focus();
15
31
  }
16
32
  }
33
+
34
+ function handleKeydown(event: KeyboardEvent, index: number) {
35
+ const input = event.currentTarget as HTMLInputElement;
36
+ if (event.key === 'Backspace' && !input.value && index > 0) {
37
+ const inputs = input.parentElement?.querySelectorAll('input');
38
+ const prev = inputs?.[index - 1] as HTMLInputElement | undefined;
39
+ prev?.focus();
40
+ }
41
+ }
42
+
43
+ function handlePaste(event: ClipboardEvent) {
44
+ event.preventDefault();
45
+ const pasted = event.clipboardData?.getData('text/plain') ?? '';
46
+ const digits = pasted.replace(/\D/g, '').slice(0, length).split('');
47
+ if (digits.length === 0) return;
48
+ const next = [...value];
49
+ while (next.length < length) next.push('');
50
+ digits.forEach((digit, i) => {
51
+ next[i] = digit;
52
+ });
53
+ value = next;
54
+ const targetIndex = Math.min(digits.length, length - 1);
55
+ const inputs = (event.currentTarget as HTMLElement).parentElement?.querySelectorAll('input');
56
+ inputs?.[targetIndex]?.focus();
57
+ }
17
58
  </script>
59
+
18
60
  <div class={'flex justify-center gap-2 ' + className} role="group" aria-label={ariaLabel}>
19
61
  {#each slots as index (index)}
20
- <Input class="size-12 text-center text-lg font-semibold" value={value[index] ?? ''} maxlength={1} inputmode="numeric" pattern="[0-9]*" aria-label={ariaLabel + ' digit ' + (index + 1)} {disabled} oninput={(event) => updateDigit(event, index)} />
62
+ <Input
63
+ class="size-12 text-center text-lg font-semibold tabular-nums"
64
+ value={value[index] ?? ''}
65
+ maxlength={1}
66
+ inputmode="numeric"
67
+ pattern="[0-9]*"
68
+ autocomplete="one-time-code"
69
+ aria-label={ariaLabel + ' digit ' + (index + 1)}
70
+ {disabled}
71
+ oninput={(event) => updateDigit(event, index)}
72
+ onkeydown={(event) => handleKeydown(event, index)}
73
+ onpaste={handlePaste}
74
+ />
21
75
  {/each}
22
76
  </div>
@@ -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.60.0",
4
4
  "description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
5
5
  "type": "module",
6
6
  "sideEffects": [