@h-ai/ui 0.1.0-alpha.40 → 0.1.0-alpha.42

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.
@@ -0,0 +1,691 @@
1
+ <!--
2
+ @component TableToolbar
3
+ 通用表格工具栏,支持搜索、筛选弹层、排序弹层、视图切换、主操作按钮和自定义按钮插槽。
4
+
5
+ @prop {boolean} searchable - 是否显示搜索框
6
+ @prop {string} searchPlaceholder - 搜索框占位文案
7
+ @prop {string} searchValue - 搜索框当前值(bindable)
8
+ @prop {string} searchLabel - 搜索框 aria-label
9
+ @prop {FilterFieldDef[]} filterFields - 筛选字段配置
10
+ @prop {Record<string, unknown>} filterValues - 当前筛选值(bindable)
11
+ @prop {Array<{key: string, label: string}>} sortableColumns - 可排序列
12
+ @prop {string} sortBy - 当前排序字段
13
+ @prop {'asc' | 'desc'} sortDirection - 当前排序方向
14
+ @prop {'table' | 'card'} viewMode - 当前视图模式
15
+ @prop {boolean} showViewSwitch - 是否显示视图切换
16
+ @prop {string} primaryActionLabel - 主操作按钮文案
17
+ @prop {Snippet} leading - 工具栏左侧内容插槽,可用于页面标题或说明标签
18
+ @prop {Snippet} children - 自定义按钮插槽
19
+ @prop {Function} onsearch - 搜索回调
20
+ @prop {Function} onfilterchange - 筛选变化回调
21
+ @prop {Function} onclearfilters - 清空筛选回调
22
+ @prop {Function} onsort - 排序回调
23
+ @prop {Function} onclearsort - 清空排序回调
24
+ @prop {Function} onviewmodechange - 视图切换回调
25
+ @prop {Function} onPrimaryAction - 主操作回调
26
+
27
+ @example
28
+ <TableToolbar
29
+ searchable
30
+ searchPlaceholder="搜索..."
31
+ bind:searchValue
32
+ filterFields={[
33
+ { id: 'status', label: '状态', type: 'enum', options: [{ label: '启用', value: 'active' }] }
34
+ ]}
35
+ bind:filterValues
36
+ sortableColumns={[{ key: 'name', label: '名称' }]}
37
+ sortBy="name"
38
+ sortDirection="asc"
39
+ onsearch={(v) => handleSearch(v)}
40
+ onfilterchange={(f) => handleFilter(f)}
41
+ onsort={(key, dir) => handleSort(key, dir)}
42
+ >
43
+ <Button size="sm" onclick={handleExport}>导出</Button>
44
+ </TableToolbar>
45
+ -->
46
+ <script lang='ts'>
47
+ import type { Snippet } from 'svelte'
48
+ import type { DataAttributes } from '../../types.js'
49
+ import { onMount } from 'svelte'
50
+ import { uiM } from '../../messages.js'
51
+ import { getDataAttributes } from '../../utils.js'
52
+
53
+ /** 工具栏筛选字段定义。 */
54
+ type FilterFieldDef = {
55
+ /** 筛选字段 key,同时作为回调中的属性名。 */
56
+ id: string
57
+ /** 用户可见字段名。 */
58
+ label: string | (() => string)
59
+ /** 控件类型;date-range 会渲染起止日期输入。 */
60
+ type: string
61
+ /** 控件占位文案。 */
62
+ placeholder?: string | (() => string)
63
+ /** 日期范围起始值 key。 */
64
+ startKey?: string
65
+ /** 日期范围结束值 key。 */
66
+ endKey?: string
67
+ /** 数值控件最小值。 */
68
+ min?: number
69
+ /** 数值控件最大值。 */
70
+ max?: number
71
+ /** 数值控件步长。 */
72
+ step?: number
73
+ /** 选择类控件选项。 */
74
+ options?: Array<{ label: string, value: string | number | boolean }> | (() => Array<{ label: string, value: string | number | boolean }>)
75
+ }
76
+
77
+ /** TableToolbar 对外入参。 */
78
+ type TableToolbarProps = {
79
+ /** 是否显示搜索框,默认 true。 */
80
+ searchable?: boolean
81
+ /** 搜索框占位文案。 */
82
+ searchPlaceholder?: string
83
+ /** 搜索框当前值,可通过 bind:searchValue 受控。 */
84
+ searchValue?: string
85
+ /** 搜索框无障碍标签。 */
86
+ searchLabel?: string
87
+ /** 可用筛选字段。 */
88
+ filterFields?: FilterFieldDef[]
89
+ /** 当前筛选值,可通过 bind:filterValues 受控。 */
90
+ filterValues?: Record<string, unknown>
91
+ /** 可用排序列。 */
92
+ sortableColumns?: Array<{ key: string, label: string }>
93
+ /** 当前排序字段,可通过 bind:sortBy 受控。 */
94
+ sortBy?: string
95
+ /** 当前排序方向。 */
96
+ sortDirection?: 'asc' | 'desc'
97
+ /** 当前视图模式,可通过 bind:viewMode 受控。 */
98
+ viewMode?: 'table' | 'card'
99
+ /** 是否显示表格/卡片切换。 */
100
+ showViewSwitch?: boolean
101
+ /** 主操作按钮文案;为空时不显示。 */
102
+ primaryActionLabel?: string
103
+ /** 工具栏左侧内容插槽,可放置页面标题、统计信息等非操作内容。 */
104
+ leading?: Snippet
105
+ /** 工具栏右侧自定义操作插槽。 */
106
+ children?: Snippet
107
+ /** 搜索提交回调。 */
108
+ onsearch?: (search: string) => void
109
+ /** 筛选值变化回调。 */
110
+ onfilterchange?: (filters: Record<string, unknown>) => void
111
+ /** 清空筛选回调。 */
112
+ onclearfilters?: () => void
113
+ /** 排序变化回调。 */
114
+ onsort?: (key: string, direction: 'asc' | 'desc') => void
115
+ /** 清空排序回调。 */
116
+ onclearsort?: () => void
117
+ /** 视图变化回调。 */
118
+ onviewmodechange?: (mode: 'table' | 'card') => void
119
+ /** 主操作点击回调。 */
120
+ onPrimaryAction?: () => void
121
+ /** 根节点自定义 class。 */
122
+ class?: string
123
+ } & DataAttributes
124
+
125
+ let {
126
+ searchable = true,
127
+ searchPlaceholder = '',
128
+ searchValue = $bindable(''),
129
+ searchLabel = '',
130
+ filterFields = [],
131
+ filterValues = $bindable({}),
132
+ sortableColumns = [],
133
+ sortBy = $bindable(''),
134
+ sortDirection = $bindable('desc'),
135
+ viewMode = $bindable('table'),
136
+ showViewSwitch = false,
137
+ primaryActionLabel = '',
138
+ leading,
139
+ children,
140
+ onsearch,
141
+ onfilterchange,
142
+ onclearfilters,
143
+ onsort,
144
+ onclearsort,
145
+ onviewmodechange,
146
+ onPrimaryAction,
147
+ class: className = '',
148
+ ...restProps
149
+ }: TableToolbarProps = $props()
150
+
151
+ /** 透传到根节点的 data-* 属性。 */
152
+ const dataAttributes = $derived(getDataAttributes(restProps))
153
+
154
+ // ─── 弹层状态 ───
155
+ /** 筛选弹层是否打开。 */
156
+ let filterOpen = $state(false)
157
+ /** 排序弹层是否打开。 */
158
+ let sortOpen = $state(false)
159
+ /** 用于检测外部点击的根节点。 */
160
+ let rootElement = $state<HTMLElement | null>(null)
161
+
162
+ // ─── 日期范围本地输入 ───
163
+ /** 日期范围起始输入框草稿。 */
164
+ const dateStartInputs = $state<Record<string, string>>({})
165
+ /** 日期范围结束输入框草稿。 */
166
+ const dateEndInputs = $state<Record<string, string>>({})
167
+
168
+ // ─── 派生状态 ───
169
+ /** 是否存在可用筛选字段。 */
170
+ const hasFilters = $derived(filterFields.length > 0)
171
+ /** 是否存在可用排序列。 */
172
+ const hasSortableColumns = $derived(sortableColumns.length > 0)
173
+
174
+ /** 已生效筛选条件数量,用于按钮角标。 */
175
+ const activeFilterCount = $derived.by(() =>
176
+ filterFields.filter((field) => {
177
+ if (isDateRangeField(field)) {
178
+ const startKey = field.startKey ?? `${field.id}Start`
179
+ const endKey = field.endKey ?? `${field.id}End`
180
+ return Boolean(getFilterValue(startKey) && getFilterValue(endKey))
181
+ }
182
+ return hasMeaningfulFilterValue(filterValues[field.id])
183
+ }).length,
184
+ )
185
+
186
+ /** 当前排序字段的显示文案。 */
187
+ const activeSortLabel = $derived(
188
+ sortableColumns.find(col => col.key === sortBy)?.label ?? '',
189
+ )
190
+
191
+ // ─── 同步日期范围本地状态 ───
192
+ $effect(() => {
193
+ for (const field of filterFields) {
194
+ if (!isDateRangeField(field))
195
+ continue
196
+ const startKey = field.startKey ?? `${field.id}Start`
197
+ const endKey = field.endKey ?? `${field.id}End`
198
+ if (!(startKey in dateStartInputs)) {
199
+ dateStartInputs[startKey] = String(filterValues[startKey] ?? '')
200
+ }
201
+ if (!(endKey in dateEndInputs)) {
202
+ dateEndInputs[endKey] = String(filterValues[endKey] ?? '')
203
+ }
204
+ }
205
+ })
206
+
207
+ // ─── 工具函数 ───
208
+
209
+ /** 解析静态或动态文案。 */
210
+ function resolveText(text: string | (() => string) | undefined): string {
211
+ if (!text)
212
+ return ''
213
+ return typeof text === 'function' ? text() : text
214
+ }
215
+
216
+ /** 判断字段是否使用日期范围控件。 */
217
+ function isDateRangeField(field: FilterFieldDef): boolean {
218
+ const t = field.type.trim().toLowerCase()
219
+ return t === 'date-range' || t === 'daterange'
220
+ }
221
+
222
+ /** 判断字段是否使用布尔筛选;空值选项统一表示“不限制”。 */
223
+ function isBooleanField(field: FilterFieldDef): boolean {
224
+ const t = field.type.trim().toLowerCase()
225
+ return t === 'boolean' || t === 'switch' || t === 'toggle'
226
+ }
227
+
228
+ /** 判断字段是否使用日期输入控件。 */
229
+ function isDateField(field: FilterFieldDef): boolean {
230
+ return new Set(['date', 'datetime-local', 'datetime', 'month', 'week']).has(field.type.trim().toLowerCase())
231
+ }
232
+
233
+ /** 判断字段是否使用数值输入控件。 */
234
+ function isNumberField(field: FilterFieldDef): boolean {
235
+ return new Set(['number', 'integer']).has(field.type.trim().toLowerCase())
236
+ }
237
+
238
+ /** 把任意筛选值归一化为控件使用的字符串。 */
239
+ function getFilterValue(key: string): string {
240
+ return String(filterValues[key] ?? '')
241
+ }
242
+
243
+ /** 判断筛选值是否真正生效,避免 false 或 0 被误判为空。 */
244
+ function hasMeaningfulFilterValue(value: unknown): boolean {
245
+ return value !== undefined && value !== null && String(value) !== ''
246
+ }
247
+
248
+ /** 解析静态或动态选项。 */
249
+ function resolveOptions(options?: FilterFieldDef['options']): Array<{ label: string, value: string | number | boolean }> {
250
+ if (!options)
251
+ return []
252
+ return typeof options === 'function' ? options() : options
253
+ }
254
+
255
+ // ─── 弹层控制 ───
256
+
257
+ /** 切换筛选弹层,并关闭排序弹层。 */
258
+ function toggleFilterPopover(): void {
259
+ filterOpen = !filterOpen
260
+ if (filterOpen)
261
+ sortOpen = false
262
+ }
263
+
264
+ /** 切换排序弹层,并关闭筛选弹层。 */
265
+ function toggleSortPopover(): void {
266
+ sortOpen = !sortOpen
267
+ if (sortOpen)
268
+ filterOpen = false
269
+ }
270
+
271
+ // ─── 筛选操作 ───
272
+
273
+ /** 更新单个筛选值并通知父组件。 */
274
+ function handleFilterChange(fieldId: string, value: string): void {
275
+ /** 保留其它筛选字段,避免单项更新覆盖并行条件。 */
276
+ const next = { ...filterValues, [fieldId]: value }
277
+ filterValues = next
278
+ onfilterchange?.(next)
279
+ }
280
+
281
+ /** 更新日期范围;只有起止完整且合法时才提交筛选。 */
282
+ function handleDateRangeChange(startKey: string, endKey: string, field: 'start' | 'end', value: string): void {
283
+ if (field === 'start') {
284
+ dateStartInputs[startKey] = value
285
+ }
286
+ else {
287
+ dateEndInputs[endKey] = value
288
+ }
289
+
290
+ /** 本次变更后的起始日期。 */
291
+ const startVal = field === 'start' ? value : dateStartInputs[startKey]
292
+ /** 本次变更后的结束日期。 */
293
+ const endVal = field === 'end' ? value : dateEndInputs[endKey]
294
+
295
+ if (!startVal || !endVal) {
296
+ /** 单边日期不构成有效范围,因此同步清空父级两个值。 */
297
+ const next = { ...filterValues }
298
+ next[startKey] = ''
299
+ next[endKey] = ''
300
+ filterValues = next
301
+ onfilterchange?.(next)
302
+ return
303
+ }
304
+
305
+ if (startVal > endVal) {
306
+ /** 非法范围不能提交给业务查询。 */
307
+ const next = { ...filterValues }
308
+ next[startKey] = ''
309
+ next[endKey] = ''
310
+ filterValues = next
311
+ onfilterchange?.(next)
312
+ return
313
+ }
314
+
315
+ /** 已校验的完整日期范围。 */
316
+ const next = { ...filterValues, [startKey]: startVal, [endKey]: endVal }
317
+ filterValues = next
318
+ onfilterchange?.(next)
319
+ }
320
+
321
+ /** 清空父组件筛选状态和日期范围草稿。 */
322
+ function handleClearFilters(): void {
323
+ // 清空日期范围本地状态
324
+ for (const field of filterFields) {
325
+ if (!isDateRangeField(field))
326
+ continue
327
+ const startKey = field.startKey ?? `${field.id}Start`
328
+ const endKey = field.endKey ?? `${field.id}End`
329
+ dateStartInputs[startKey] = ''
330
+ dateEndInputs[endKey] = ''
331
+ }
332
+ filterValues = {}
333
+ onclearfilters?.()
334
+ }
335
+
336
+ // ─── 排序操作 ───
337
+
338
+ /** 选择排序字段;选择默认项时清空排序。 */
339
+ function handleSortFieldChange(event: Event): void {
340
+ /** 下拉框选中的排序字段。 */
341
+ const nextSortBy = (event.currentTarget as HTMLSelectElement).value
342
+ if (!nextSortBy) {
343
+ sortBy = ''
344
+ sortDirection = 'desc'
345
+ onclearsort?.()
346
+ return
347
+ }
348
+ sortBy = nextSortBy
349
+ onsort?.(nextSortBy, sortDirection)
350
+ }
351
+
352
+ /** 更新排序方向;未选字段时使用第一列。 */
353
+ function updateSortDirection(direction: 'asc' | 'desc'): void {
354
+ /** 未显式选择字段时,方向按钮作用于第一列。 */
355
+ const nextSortBy = sortBy || sortableColumns[0]?.key
356
+ if (!nextSortBy)
357
+ return
358
+ sortBy = nextSortBy
359
+ sortDirection = direction
360
+ onsort?.(nextSortBy, direction)
361
+ }
362
+
363
+ // ─── 点击外部关闭弹层 ───
364
+
365
+ onMount(() => {
366
+ /** 点击组件外部时关闭所有弹层。 */
367
+ const handlePointerDown = (event: PointerEvent) => {
368
+ if (rootElement && !rootElement.contains(event.target as Node)) {
369
+ filterOpen = false
370
+ sortOpen = false
371
+ }
372
+ }
373
+
374
+ /** Escape 键关闭当前弹层。 */
375
+ const handleKeyDown = (event: KeyboardEvent) => {
376
+ if (event.key === 'Escape') {
377
+ filterOpen = false
378
+ sortOpen = false
379
+ }
380
+ }
381
+
382
+ document.addEventListener('pointerdown', handlePointerDown)
383
+ document.addEventListener('keydown', handleKeyDown)
384
+
385
+ return () => {
386
+ document.removeEventListener('pointerdown', handlePointerDown)
387
+ document.removeEventListener('keydown', handleKeyDown)
388
+ }
389
+ })
390
+ </script>
391
+
392
+ <div {...dataAttributes} bind:this={rootElement} class='flex items-center justify-between gap-3 flex-wrap {className}'>
393
+ <!-- 左侧信息区:标题、说明等不参与表格操作的内容。 -->
394
+ {#if leading}
395
+ <div class='min-w-0 flex-1'>
396
+ {@render leading()}
397
+ </div>
398
+ {/if}
399
+
400
+ <!-- 右侧操作区:搜索、筛选、排序及自定义操作始终靠右对齐。 -->
401
+ <div class='flex min-w-0 flex-1 items-center justify-end gap-2 flex-wrap'>
402
+ <!-- 搜索框 -->
403
+ {#if searchable}
404
+ <div class='relative flex-1 min-w-[11rem] max-w-[15rem]'>
405
+ <span class='icon-[tabler--search] pointer-events-none absolute left-3 top-1/2 z-10 size-4 -translate-y-1/2 text-base-content/35'></span>
406
+ <input
407
+ type='search'
408
+ bind:value={searchValue}
409
+ aria-label={searchLabel || uiM('toolbar_search')}
410
+ class='input input-sm w-full pl-9 bg-base-100 border-base-content/15'
411
+ placeholder={searchPlaceholder || uiM('crud_search_placeholder')}
412
+ onkeydown={(e) => {
413
+ if (e.key === 'Enter') {
414
+ e.preventDefault()
415
+ onsearch?.(searchValue)
416
+ }
417
+ }}
418
+ />
419
+ </div>
420
+ {/if}
421
+
422
+ <!-- 筛选弹层按钮 -->
423
+ {#if hasFilters}
424
+ <div class='relative'>
425
+ <button
426
+ type='button'
427
+ aria-expanded={filterOpen}
428
+ aria-haspopup='dialog'
429
+ aria-label={uiM('toolbar_filter')}
430
+ class='btn btn-sm btn-square border-base-content/15 bg-base-100 {activeFilterCount > 0 ? 'border-primary/50 text-primary bg-primary/5' : ''}'
431
+ title={uiM('toolbar_filter')}
432
+ onclick={toggleFilterPopover}
433
+ >
434
+ <span class='icon-[tabler--filter] size-4'></span>
435
+ {#if activeFilterCount > 0}
436
+ <span class='absolute -top-1.5 -right-1.5 inline-flex items-center justify-center min-w-[1.15rem] h-[1.15rem] rounded-full bg-primary text-white text-[10px] font-bold leading-none px-0.5'>
437
+ {activeFilterCount}
438
+ </span>
439
+ {/if}
440
+ </button>
441
+
442
+ {#if filterOpen}
443
+ <div
444
+ role='dialog'
445
+ aria-label={uiM('toolbar_filter_conditions')}
446
+ class='absolute top-full right-0 mt-2 z-50 w-[min(22rem,calc(100vw-2rem))] rounded-xl border border-base-content/10 bg-base-100 p-4 shadow-lg'
447
+ >
448
+ <div class='flex items-center justify-between mb-3'>
449
+ <h3 class='text-sm font-semibold text-base-content'>{uiM('toolbar_filter')}</h3>
450
+ <button
451
+ type='button'
452
+ class='btn btn-xs btn-ghost text-primary'
453
+ disabled={activeFilterCount === 0}
454
+ onclick={handleClearFilters}
455
+ >
456
+ {uiM('toolbar_filter_reset')}
457
+ </button>
458
+ </div>
459
+
460
+ <div class='flex flex-col gap-3'>
461
+ {#each filterFields as field (field.id)}
462
+ {@const fieldLabel = resolveText(field.label)}
463
+ <div class='flex flex-col gap-1.5'>
464
+ <span class='text-xs text-base-content/55 font-medium'>{fieldLabel}</span>
465
+
466
+ {#if isDateRangeField(field)}
467
+ {@const startKey = field.startKey ?? `${field.id}Start`}
468
+ {@const endKey = field.endKey ?? `${field.id}End`}
469
+ <div class='grid grid-cols-[1fr_auto_1fr] items-center gap-1.5'>
470
+ <div class='relative'>
471
+ <input
472
+ type='date'
473
+ class='input input-sm w-full bg-base-100 border-base-content/15 cursor-pointer'
474
+ aria-label={uiM('crud_filter_from')}
475
+ max={dateEndInputs[endKey] || undefined}
476
+ value={dateStartInputs[startKey]}
477
+ oninput={e => handleDateRangeChange(startKey, endKey, 'start', e.currentTarget.value)}
478
+ />
479
+ </div>
480
+ <span class='text-xs text-base-content/40'>~</span>
481
+ <div class='relative'>
482
+ <input
483
+ type='date'
484
+ class='input input-sm w-full bg-base-100 border-base-content/15 cursor-pointer'
485
+ aria-label={uiM('crud_filter_to')}
486
+ min={dateStartInputs[startKey] || undefined}
487
+ value={dateEndInputs[endKey]}
488
+ oninput={e => handleDateRangeChange(startKey, endKey, 'end', e.currentTarget.value)}
489
+ />
490
+ </div>
491
+ </div>
492
+ {:else if isBooleanField(field)}
493
+ <select
494
+ class='select select-sm w-full bg-base-100 border-base-content/15'
495
+ aria-label={fieldLabel}
496
+ value={getFilterValue(field.id)}
497
+ onchange={e => handleFilterChange(field.id, e.currentTarget.value)}
498
+ >
499
+ <option value=''>{uiM('toolbar_filter_all')}</option>
500
+ <option value='true'>{uiM('crud_filter_yes')}</option>
501
+ <option value='false'>{uiM('crud_filter_no')}</option>
502
+ </select>
503
+ {:else if isDateField(field)}
504
+ <input
505
+ type='date'
506
+ class='input input-sm w-full bg-base-100 border-base-content/15'
507
+ aria-label={fieldLabel}
508
+ value={getFilterValue(field.id)}
509
+ onchange={e => handleFilterChange(field.id, e.currentTarget.value)}
510
+ />
511
+ {:else if isNumberField(field)}
512
+ <input
513
+ type='number'
514
+ class='input input-sm w-full bg-base-100 border-base-content/15'
515
+ aria-label={fieldLabel}
516
+ min={field.min}
517
+ max={field.max}
518
+ step={field.step}
519
+ placeholder={resolveText(field.placeholder) || fieldLabel}
520
+ value={getFilterValue(field.id)}
521
+ onchange={e => handleFilterChange(field.id, e.currentTarget.value)}
522
+ />
523
+ {:else}
524
+ {@const opts = resolveOptions(field.options)}
525
+ {#if opts.length > 0}
526
+ <select
527
+ class='select select-sm w-full bg-base-100 border-base-content/15'
528
+ aria-label={fieldLabel}
529
+ value={getFilterValue(field.id)}
530
+ onchange={e => handleFilterChange(field.id, e.currentTarget.value)}
531
+ >
532
+ <option value=''>{uiM('toolbar_filter_all')}</option>
533
+ {#each opts as option (option.value)}
534
+ <option value={String(option.value)}>{option.label}</option>
535
+ {/each}
536
+ </select>
537
+ {:else}
538
+ <input
539
+ type='text'
540
+ class='input input-sm w-full bg-base-100 border-base-content/15'
541
+ aria-label={fieldLabel}
542
+ placeholder={resolveText(field.placeholder) || fieldLabel}
543
+ value={getFilterValue(field.id)}
544
+ onchange={e => handleFilterChange(field.id, e.currentTarget.value)}
545
+ />
546
+ {/if}
547
+ {/if}
548
+ </div>
549
+ {/each}
550
+ </div>
551
+ </div>
552
+ {/if}
553
+ </div>
554
+ {/if}
555
+
556
+ <!-- 排序弹层按钮 -->
557
+ {#if hasSortableColumns}
558
+ <div class='relative'>
559
+ <button
560
+ type='button'
561
+ aria-expanded={sortOpen}
562
+ aria-haspopup='dialog'
563
+ aria-label={uiM('toolbar_sort')}
564
+ class='btn btn-sm btn-square border-base-content/15 bg-base-100 {sortBy ? 'border-primary/50 text-primary bg-primary/5' : ''}'
565
+ title={uiM('toolbar_sort')}
566
+ onclick={toggleSortPopover}
567
+ >
568
+ <span class='icon-[tabler--arrows-sort] size-4'></span>
569
+ </button>
570
+
571
+ {#if sortOpen}
572
+ <div
573
+ role='dialog'
574
+ aria-label={uiM('toolbar_sort_conditions')}
575
+ class='absolute top-full right-0 mt-2 z-50 w-[min(18rem,calc(100vw-2rem))] rounded-xl border border-base-content/10 bg-base-100 p-4 shadow-lg'
576
+ >
577
+ <div class='flex items-center justify-between mb-3'>
578
+ <h3 class='text-sm font-semibold text-base-content'>{uiM('toolbar_sort')}</h3>
579
+ <button
580
+ type='button'
581
+ class='btn btn-xs btn-ghost text-primary'
582
+ disabled={!sortBy}
583
+ onclick={() => {
584
+ sortBy = ''
585
+ sortDirection = 'desc'
586
+ onclearsort?.()
587
+ }}
588
+ >
589
+ {uiM('toolbar_sort_default')}
590
+ </button>
591
+ </div>
592
+
593
+ <div class='flex flex-col gap-3'>
594
+ <!-- 排序字段 -->
595
+ <div class='flex flex-col gap-1.5'>
596
+ <span class='text-xs text-base-content/55 font-medium'>{uiM('toolbar_sort_field')}</span>
597
+ <select
598
+ class='select select-sm w-full bg-base-100 border-base-content/15'
599
+ aria-label={uiM('toolbar_sort_field')}
600
+ value={sortBy}
601
+ onchange={handleSortFieldChange}
602
+ >
603
+ <option value=''>{uiM('toolbar_sort_default_order')}</option>
604
+ {#each sortableColumns as column (column.key)}
605
+ <option value={column.key}>{column.label}</option>
606
+ {/each}
607
+ </select>
608
+ </div>
609
+
610
+ <!-- 排序方向 -->
611
+ <div class='flex flex-col gap-1.5'>
612
+ <span class='text-xs text-base-content/55 font-medium'>{uiM('toolbar_sort_direction')}</span>
613
+ <div class='grid grid-cols-2 gap-1' role='group' aria-label={uiM('toolbar_sort_direction')}>
614
+ <button
615
+ type='button'
616
+ class='btn btn-sm {sortDirection === 'desc' ? 'btn-primary' : 'btn-ghost border-base-content/15'}'
617
+ onclick={() => updateSortDirection('desc')}
618
+ >
619
+ <span class='icon-[tabler--sort-descending] size-4'></span>
620
+ {uiM('toolbar_sort_desc')}
621
+ </button>
622
+ <button
623
+ type='button'
624
+ class='btn btn-sm {sortDirection === 'asc' ? 'btn-primary' : 'btn-ghost border-base-content/15'}'
625
+ onclick={() => updateSortDirection('asc')}
626
+ >
627
+ <span class='icon-[tabler--sort-ascending] size-4'></span>
628
+ {uiM('toolbar_sort_asc')}
629
+ </button>
630
+ </div>
631
+ </div>
632
+
633
+ <!-- 排序摘要 -->
634
+ {#if activeSortLabel}
635
+ <p class='text-xs text-base-content/50'>
636
+ {uiM('toolbar_sort_summary', {
637
+ label: activeSortLabel,
638
+ direction: sortDirection === 'desc' ? uiM('toolbar_sort_desc') : uiM('toolbar_sort_asc'),
639
+ })}
640
+ </p>
641
+ {/if}
642
+ </div>
643
+ </div>
644
+ {/if}
645
+ </div>
646
+ {/if}
647
+
648
+ <!-- 视图切换 -->
649
+ {#if showViewSwitch}
650
+ <div class='join' role='group' aria-label={uiM('toolbar_view_switch')}>
651
+ <button
652
+ type='button'
653
+ class='btn btn-sm join-item border-base-content/15 bg-base-100 {viewMode === 'table' ? 'text-primary bg-primary/5' : ''}'
654
+ aria-label={uiM('toolbar_view_table')}
655
+ title={uiM('toolbar_view_table')}
656
+ onclick={() => {
657
+ viewMode = 'table'
658
+ onviewmodechange?.('table')
659
+ }}
660
+ >
661
+ <span class='icon-[tabler--table] size-4'></span>
662
+ </button>
663
+ <button
664
+ type='button'
665
+ class='btn btn-sm join-item border-base-content/15 bg-base-100 {viewMode === 'card' ? 'text-primary bg-primary/5' : ''}'
666
+ aria-label={uiM('toolbar_view_card')}
667
+ title={uiM('toolbar_view_card')}
668
+ onclick={() => {
669
+ viewMode = 'card'
670
+ onviewmodechange?.('card')
671
+ }}
672
+ >
673
+ <span class='icon-[tabler--layout-grid] size-4'></span>
674
+ </button>
675
+ </div>
676
+ {/if}
677
+
678
+ <!-- 自定义按钮插槽 -->
679
+ {#if children}
680
+ {@render children()}
681
+ {/if}
682
+
683
+ <!-- 主操作按钮 -->
684
+ {#if primaryActionLabel && onPrimaryAction}
685
+ <button type='button' class='btn btn-sm btn-primary shadow-sm' onclick={onPrimaryAction}>
686
+ <span class='icon-[tabler--plus] size-4'></span>
687
+ {primaryActionLabel}
688
+ </button>
689
+ {/if}
690
+ </div>
691
+ </div>