@h-ai/ui 0.1.0-alpha.23 → 0.1.0-alpha.24

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.
package/README.md CHANGED
@@ -288,7 +288,7 @@ components/
288
288
  >
289
289
  > `pagination` 配置分页栏(始终显示):`showSizeChanger`、`pageSizeOptions`、`showJumper`、`showTotal` 默认开启。
290
290
  >
291
- > `density` 配置列表密度:`'normal'`(默认)或 `'compact'`(紧凑)。
291
+ > `density` 配置列表密度:`'normal'`(默认)或 `'compact'`(紧凑)。它只影响列表行、行内操作、分页与创建/编辑/详情表单元素,不影响页头与筛选栏。
292
292
 
293
293
  ## 使用示例
294
294
 
@@ -18,7 +18,7 @@
18
18
  }: CardProps = $props()
19
19
 
20
20
  const paddingMap = {
21
- 'none': '',
21
+ 'none': 'p-0',
22
22
  'xs': 'p-2',
23
23
  'sm': 'p-3',
24
24
  'md': 'p-4',
@@ -57,6 +57,28 @@
57
57
 
58
58
  type Column = { key: keyof T | string, label: string, width?: string, align?: 'left' | 'center' | 'right', render?: (item: T) => string }
59
59
 
60
+ const tableClass = $derived(
61
+ density === 'compact'
62
+ ? 'table table-sm w-full text-[12px] [&_thead_th]:px-3 [&_thead_th]:py-2 [&_tbody_td]:px-3 [&_tbody_td]:py-2'
63
+ : 'table w-full text-[13px] [&_thead_th]:px-4 [&_thead_th]:py-2.5 [&_tbody_td]:px-4 [&_tbody_td]:py-3',
64
+ )
65
+
66
+ const headerRowClass = $derived(
67
+ density === 'compact'
68
+ ? 'text-[11px] text-base-content/55 border-b border-base-content/6'
69
+ : 'text-[12px] text-base-content/55 border-b border-base-content/6',
70
+ )
71
+
72
+ const rowClass = $derived(
73
+ hoverable
74
+ ? 'border-b border-base-content/5 transition-colors hover:bg-base-content/3 last:border-b-0'
75
+ : 'border-b border-base-content/5 last:border-b-0',
76
+ )
77
+
78
+ const actionColumnClass = $derived(density === 'compact' ? 'w-20 text-center font-medium' : 'w-24 text-center font-medium')
79
+ const actionWrapClass = $derived(density === 'compact' ? 'flex items-center justify-center gap-0.5' : 'flex items-center justify-center gap-1')
80
+ const placeholderCellClass = $derived(density === 'compact' ? 'text-center py-6 text-base-content/40' : 'text-center py-8 text-base-content/40')
81
+
60
82
  function getValue(item: T, col: Column): string {
61
83
  if (col.render) {
62
84
  return col.render(item)
@@ -82,9 +104,9 @@
82
104
  </script>
83
105
 
84
106
  <div class='overflow-x-auto {className}'>
85
- <table class='table w-full {density === `compact` ? `table-sm text-xs` : `text-[13px]`}' class:table-zebra={striped}>
107
+ <table class={tableClass} class:table-zebra={striped}>
86
108
  <thead>
87
- <tr class='text-xs text-base-content/50 border-b border-base-content/6'>
109
+ <tr class={headerRowClass}>
88
110
  {#each columns as col (String(col.key))}
89
111
  <th
90
112
  style={col.width ? `width: ${col.width}` : ''}
@@ -94,20 +116,20 @@
94
116
  </th>
95
117
  {/each}
96
118
  {#if actions}
97
- <th class='w-24 text-center font-medium'>{uiM('data_table_actions')}</th>
119
+ <th class={actionColumnClass}>{uiM('data_table_actions')}</th>
98
120
  {/if}
99
121
  </tr>
100
122
  </thead>
101
123
  <tbody>
102
124
  {#if loading}
103
125
  <tr>
104
- <td colspan={columns.length + (actions ? 1 : 0)} class='text-center py-8'>
126
+ <td colspan={columns.length + (actions ? 1 : 0)} class={placeholderCellClass}>
105
127
  <span class='loading loading-spinner loading-sm'></span>
106
128
  </td>
107
129
  </tr>
108
130
  {:else if data.length === 0}
109
131
  <tr>
110
- <td colspan={columns.length + (actions ? 1 : 0)} class='text-center py-8 text-base-content/40'>
132
+ <td colspan={columns.length + (actions ? 1 : 0)} class={placeholderCellClass}>
111
133
  {#if empty}
112
134
  {@render empty()}
113
135
  {:else}
@@ -117,13 +139,13 @@
117
139
  </tr>
118
140
  {:else}
119
141
  {#each data as item (item[keyField])}
120
- <tr class={hoverable ? 'hover:bg-base-content/3 transition-colors' : ''}>
142
+ <tr class={rowClass}>
121
143
  {#each columns as col (String(col.key))}
122
144
  <td class={getAlignClass(col.align)}>{getValue(item, col)}</td>
123
145
  {/each}
124
146
  {#if actions}
125
147
  <td>
126
- <div class='flex items-center justify-center gap-1'>
148
+ <div class={actionWrapClass}>
127
149
  {@render actions(item)}
128
150
  </div>
129
151
  </td>
@@ -87,6 +87,18 @@
87
87
  ),
88
88
  )
89
89
 
90
+ const controlSize = $derived(size === 'xs' ? 'xs' : 'sm')
91
+ const jumperControlSize = 'xs'
92
+ const wrapperClass = $derived(size === 'xs' ? 'flex flex-wrap items-center justify-center gap-1.5 text-xs' : 'flex flex-wrap items-center justify-center gap-2.5 text-sm')
93
+ const totalClass = $derived(size === 'xs' ? 'text-xs text-base-content/70' : 'text-sm text-base-content/70')
94
+ const sizeChangerClass = $derived(size === 'xs' ? 'w-22 shrink-0' : 'w-24 shrink-0')
95
+ const jumperWrapClass = $derived(size === 'xs' ? 'flex min-w-fit items-center gap-1.5 whitespace-nowrap' : 'flex min-w-fit items-center gap-1.5 whitespace-nowrap')
96
+ const jumperInputClass = $derived(
97
+ size === 'xs'
98
+ ? 'w-[2.25rem] shrink-0 [&>.fieldset]:m-0 [&>.fieldset]:min-w-0'
99
+ : 'w-[2.5rem] shrink-0 [&>.fieldset]:m-0 [&>.fieldset]:min-w-0',
100
+ )
101
+
90
102
  function goToPage(p: number) {
91
103
  if (p >= 1 && p <= totalPages && p !== page) {
92
104
  page = p
@@ -112,17 +124,17 @@
112
124
  }
113
125
  </script>
114
126
 
115
- <div class='flex flex-wrap items-center gap-4'>
127
+ <div class={wrapperClass}>
116
128
  {#if showTotal}
117
- <span class='text-sm text-base-content/70'>
129
+ <span class={totalClass}>
118
130
  {(labels.total ?? uiM('pagination_total')).replace('{count}', String(total))}
119
131
  </span>
120
132
  {/if}
121
133
 
122
134
  {#if showSizeChanger}
123
- <div class='w-28 shrink-0'>
135
+ <div class={sizeChangerClass}>
124
136
  <Select
125
- size='sm'
137
+ size={controlSize}
126
138
  value={String(pageSize)}
127
139
  onchange={handlePageSizeChange}
128
140
  >
@@ -167,18 +179,20 @@
167
179
  </div>
168
180
 
169
181
  {#if showJumper}
170
- <div class='flex min-w-fit items-center gap-2 whitespace-nowrap'>
171
- <span class='shrink-0 text-sm'>{labels.jumpTo ?? uiM('pagination_jump_to')}</span>
172
- <Input
173
- type='number'
174
- size='xs'
175
- class='w-14 shrink-0'
176
- min={1}
177
- max={totalPages}
178
- bind:value={jumperValue}
179
- onkeydown={(e: KeyboardEvent & { currentTarget: HTMLInputElement }) => e.key === 'Enter' && handleJump()}
180
- />
181
- <span class='shrink-0 text-sm'>{labels.page ?? uiM('pagination_page')}</span>
182
+ <div class={jumperWrapClass}>
183
+ <span class={totalClass}>{labels.jumpTo ?? uiM('pagination_jump_to')}</span>
184
+ <div class={jumperInputClass}>
185
+ <Input
186
+ type='number'
187
+ size={jumperControlSize}
188
+ class='h-7 w-full rounded-md [&_input]:px-1.5 [&_input]:text-center [&_input]:text-[12px]'
189
+ min={1}
190
+ max={totalPages}
191
+ bind:value={jumperValue}
192
+ onkeydown={(e: KeyboardEvent & { currentTarget: HTMLInputElement }) => e.key === 'Enter' && handleJump()}
193
+ />
194
+ </div>
195
+ <span class={totalClass}>{labels.page ?? uiM('pagination_page')}</span>
182
196
  </div>
183
197
  {/if}
184
198
  </div>
@@ -49,6 +49,24 @@
49
49
  : 'h-10',
50
50
  )
51
51
 
52
+ const inputSpacingClass = $derived(
53
+ size === 'xs'
54
+ ? 'px-2.5 text-xs'
55
+ : size === 'sm'
56
+ ? 'px-3 text-sm'
57
+ : size === 'lg'
58
+ ? 'px-4 text-base'
59
+ : size === 'xl'
60
+ ? 'px-4 text-lg'
61
+ : 'px-3 text-sm',
62
+ )
63
+
64
+ const inputTypeClass = $derived(
65
+ type === 'number'
66
+ ? '[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none'
67
+ : '',
68
+ )
69
+
52
70
  const containerClass = $derived(
53
71
  cn(
54
72
  'flex items-center w-full rounded-lg border bg-base-100',
@@ -114,7 +132,11 @@
114
132
  {step}
115
133
  inputmode={inputmode}
116
134
  autocomplete={autocomplete as HTMLInputElement['autocomplete']}
117
- class='flex-1 h-full px-3 bg-transparent border-none outline-none text-sm placeholder:text-base-content/35'
135
+ class={cn(
136
+ 'flex-1 h-full bg-transparent border-none outline-none placeholder:text-base-content/35',
137
+ inputSpacingClass,
138
+ inputTypeClass,
139
+ )}
118
140
  bind:value
119
141
  oninput={handleInput}
120
142
  onchange={handleChange}
@@ -10,7 +10,7 @@
10
10
  -->
11
11
  <script lang='ts' generics="T = string">
12
12
  import type { SelectProps } from '../../types.js'
13
- import { cn, getInputSizeClass } from '../../utils.js'
13
+ import { cn, getSizeClass } from '../../utils.js'
14
14
 
15
15
  let {
16
16
  value = $bindable<T>(),
@@ -32,7 +32,7 @@
32
32
  const selectClass = $derived(
33
33
  cn(
34
34
  'select w-full',
35
- getInputSizeClass(size),
35
+ getSizeClass(size, 'input'),
36
36
  error && 'select-error',
37
37
  className,
38
38
  ),
@@ -0,0 +1,196 @@
1
+ <!--
2
+ @component CrudDetailDrawer
3
+ CRUD 详情组件(只读展示),支持抽屉(drawer)与弹窗(modal)两种展示形式
4
+
5
+ 使用 Svelte 5 Runes ($props, $derived)
6
+ 使用 compounds 组件:Drawer, Modal, Badge
7
+ -->
8
+ <script lang='ts'>
9
+ import type { Snippet } from 'svelte'
10
+ import type { Size } from '../../../types.js'
11
+ import type { CrudFormVariant } from './crud-types.js'
12
+ import { uiM } from '../../../messages.js'
13
+ import Drawer from '../../compounds/Drawer.svelte'
14
+ import Modal from '../../compounds/Modal.svelte'
15
+ import Badge from '../../primitives/Badge.svelte'
16
+ import Button from '../../primitives/Button.svelte'
17
+
18
+ type FieldDef = {
19
+ id: string
20
+ label: string | (() => string)
21
+ type: string
22
+ inDetail?: boolean
23
+ options?: Array<{ label: string, value: string | number | boolean }> | (() => Array<{ label: string, value: string | number | boolean }>)
24
+ render?: (value: unknown, item: Record<string, unknown>) => string
25
+ order?: number
26
+ }
27
+
28
+ let {
29
+ open = $bindable(false),
30
+ item = null,
31
+ fields = [],
32
+ title = '',
33
+ variant = 'drawer' as CrudFormVariant,
34
+ size = '2xl' as Size,
35
+ drawerWidth,
36
+ modalSize = '2xl' as Size | 'full',
37
+ modalWidth,
38
+ modalHeight,
39
+ canEdit = false,
40
+ onedit,
41
+ onclose,
42
+ detailExtra,
43
+ }: {
44
+ open?: boolean
45
+ item?: Record<string, unknown> | null
46
+ fields?: FieldDef[]
47
+ title?: string
48
+ variant?: CrudFormVariant
49
+ size?: Size
50
+ drawerWidth?: string
51
+ modalSize?: Size | 'full'
52
+ modalWidth?: string
53
+ modalHeight?: string
54
+ canEdit?: boolean
55
+ onedit?: () => void
56
+ onclose?: () => void
57
+ detailExtra?: Snippet<[Record<string, unknown>]>
58
+ } = $props()
59
+
60
+ function resolveText(text: string | (() => string)): string {
61
+ return typeof text === 'function' ? text() : text
62
+ }
63
+
64
+ function resolveOptions(options?: FieldDef['options']): Array<{ label: string, value: string | number | boolean }> {
65
+ if (!options)
66
+ return []
67
+ return typeof options === 'function' ? options() : options
68
+ }
69
+
70
+ function formatValue(field: FieldDef, value: unknown): string {
71
+ if (value === null || value === undefined)
72
+ return uiM('crud_detail_empty')
73
+
74
+ if (field.render && item) {
75
+ return field.render(value, item)
76
+ }
77
+
78
+ // 选项类型:将 value 映射为 label
79
+ if (field.type === 'select' || field.type === 'radio') {
80
+ const opts = resolveOptions(field.options)
81
+ const found = opts.find(o => String(o.value) === String(value))
82
+ return found?.label ?? String(value)
83
+ }
84
+
85
+ // 多选类型
86
+ if (field.type === 'multi-select' && Array.isArray(value)) {
87
+ const opts = resolveOptions(field.options)
88
+ return value.map((v) => {
89
+ const found = opts.find(o => String(o.value) === String(v))
90
+ return found?.label ?? String(v)
91
+ }).join(', ')
92
+ }
93
+
94
+ // 布尔
95
+ if (field.type === 'boolean' || field.type === 'checkbox') {
96
+ return value ? '✓' : '✗'
97
+ }
98
+
99
+ // 日期/时间
100
+ if (field.type === 'date' && (typeof value === 'string' || typeof value === 'number' || value instanceof Date)) {
101
+ return new Date(value as string | number).toLocaleDateString()
102
+ }
103
+ if (field.type === 'datetime' && (typeof value === 'string' || typeof value === 'number' || value instanceof Date)) {
104
+ return new Date(value as string | number).toLocaleString()
105
+ }
106
+
107
+ return String(value)
108
+ }
109
+
110
+ const detailFields = $derived(
111
+ [...fields]
112
+ .filter(f => f.inDetail !== false)
113
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
114
+ )
115
+
116
+ function handleClose() {
117
+ open = false
118
+ onclose?.()
119
+ }
120
+ </script>
121
+
122
+ {#snippet detailBody()}
123
+ {#if item}
124
+ <div class='space-y-4'>
125
+ {#each detailFields as field (field.id)}
126
+ {@const value = item[field.id]}
127
+ <div>
128
+ <div class='text-xs font-medium text-base-content/50 mb-1'>
129
+ {resolveText(field.label)}
130
+ </div>
131
+ <div class='text-sm text-base-content'>
132
+ {#if field.type === 'multi-select' && Array.isArray(value) && value.length > 0}
133
+ <div class='flex flex-wrap gap-1'>
134
+ {#each value as v, index (`${String(v)}:${index}`)}
135
+ {@const opts = resolveOptions(field.options)}
136
+ {@const found = opts.find(o => String(o.value) === String(v))}
137
+ <Badge variant='ghost' size='sm'>{found?.label ?? String(v)}</Badge>
138
+ {/each}
139
+ </div>
140
+ {:else}
141
+ {formatValue(field, value)}
142
+ {/if}
143
+ </div>
144
+ </div>
145
+ {/each}
146
+
147
+ {#if detailExtra && item}
148
+ <div class='border-t border-base-content/5 pt-4'>
149
+ {@render detailExtra(item)}
150
+ </div>
151
+ {/if}
152
+ </div>
153
+ {/if}
154
+ {/snippet}
155
+
156
+ {#snippet footerActions()}
157
+ {#if canEdit}
158
+ <Button variant='primary' onclick={onedit}>
159
+ <span class='icon-[tabler--edit] size-4 mr-1'></span>
160
+ {uiM('crud_edit')}
161
+ </Button>
162
+ {/if}
163
+ <Button variant='ghost' onclick={handleClose}>
164
+ {uiM('crud_close')}
165
+ </Button>
166
+ {/snippet}
167
+
168
+ {#if variant === 'modal'}
169
+ <Modal
170
+ bind:open
171
+ {title}
172
+ size={modalSize}
173
+ width={modalWidth}
174
+ height={modalHeight}
175
+ onclose={handleClose}
176
+ >
177
+ {@render detailBody()}
178
+
179
+ {#snippet footer()}
180
+ {@render footerActions()}
181
+ {/snippet}
182
+ </Modal>
183
+ {:else}
184
+ <Drawer bind:open {title} position='right' {size} width={drawerWidth} onclose={handleClose}>
185
+ {#if item}
186
+ <div class='pb-20'>
187
+ {@render detailBody()}
188
+ </div>
189
+
190
+ <!-- 底部操作栏 -->
191
+ <div class='absolute bottom-0 left-0 right-0 p-4 bg-base-200 border-t border-base-content/10 flex justify-end gap-2'>
192
+ {@render footerActions()}
193
+ </div>
194
+ {/if}
195
+ </Drawer>
196
+ {/if}
@@ -0,0 +1,44 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { Size } from '../../../types.js';
3
+ import type { CrudFormVariant } from './crud-types.js';
4
+ type FieldDef = {
5
+ id: string;
6
+ label: string | (() => string);
7
+ type: string;
8
+ inDetail?: boolean;
9
+ options?: Array<{
10
+ label: string;
11
+ value: string | number | boolean;
12
+ }> | (() => Array<{
13
+ label: string;
14
+ value: string | number | boolean;
15
+ }>);
16
+ render?: (value: unknown, item: Record<string, unknown>) => string;
17
+ order?: number;
18
+ };
19
+ type $$ComponentProps = {
20
+ open?: boolean;
21
+ item?: Record<string, unknown> | null;
22
+ fields?: FieldDef[];
23
+ title?: string;
24
+ variant?: CrudFormVariant;
25
+ size?: Size;
26
+ drawerWidth?: string;
27
+ modalSize?: Size | 'full';
28
+ modalWidth?: string;
29
+ modalHeight?: string;
30
+ canEdit?: boolean;
31
+ onedit?: () => void;
32
+ onclose?: () => void;
33
+ detailExtra?: Snippet<[Record<string, unknown>]>;
34
+ };
35
+ /**
36
+ * CrudDetailDrawer
37
+ * CRUD 详情组件(只读展示),支持抽屉(drawer)与弹窗(modal)两种展示形式
38
+ *
39
+ * 使用 Svelte 5 Runes ($props, $derived)
40
+ * 使用 compounds 组件:Drawer, Modal, Badge
41
+ */
42
+ declare const CrudDetailDrawer: import("svelte").Component<$$ComponentProps, {}, "open">;
43
+ type CrudDetailDrawer = ReturnType<typeof CrudDetailDrawer>;
44
+ export default CrudDetailDrawer;
@@ -8,7 +8,7 @@
8
8
  <script lang='ts'>
9
9
  import type { Snippet } from 'svelte'
10
10
  import type { Size } from '../../../types.js'
11
- import type { CrudFormVariant } from './crud-types.js'
11
+ import type { CrudDensity, CrudFormVariant } from './crud-types.js'
12
12
  import { uiM } from '../../../messages.js'
13
13
  import Drawer from '../../compounds/Drawer.svelte'
14
14
  import Modal from '../../compounds/Modal.svelte'
@@ -29,6 +29,7 @@
29
29
  open = $bindable(false),
30
30
  item = null,
31
31
  fields = [],
32
+ density = 'normal' as CrudDensity,
32
33
  title = '',
33
34
  variant = 'drawer' as CrudFormVariant,
34
35
  size = '2xl' as Size,
@@ -44,6 +45,7 @@
44
45
  open?: boolean
45
46
  item?: Record<string, unknown> | null
46
47
  fields?: FieldDef[]
48
+ density?: CrudDensity
47
49
  title?: string
48
50
  variant?: CrudFormVariant
49
51
  size?: Size
@@ -109,6 +111,16 @@
109
111
  .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
110
112
  )
111
113
 
114
+ const isCompact = $derived(density === 'compact')
115
+ const contentClass = $derived(isCompact ? 'space-y-3' : 'space-y-4')
116
+ const labelClass = $derived(isCompact ? 'text-[11px] font-medium text-base-content/50 mb-1' : 'text-xs font-medium text-base-content/50 mb-1')
117
+ const valueClass = $derived(isCompact ? 'text-[13px] text-base-content' : 'text-sm text-base-content')
118
+ const badgeSize = $derived(isCompact ? 'xs' : 'sm')
119
+ const detailExtraClass = $derived(isCompact ? 'border-t border-base-content/5 pt-3' : 'border-t border-base-content/5 pt-4')
120
+ const iconClass = $derived(isCompact ? 'size-3.5 mr-1' : 'size-4 mr-1')
121
+ const drawerBodyClass = $derived(isCompact ? 'pb-16' : 'pb-20')
122
+ const drawerFooterClass = $derived(isCompact ? 'absolute bottom-0 left-0 right-0 p-3 bg-base-200 border-t border-base-content/10 flex justify-end gap-2' : 'absolute bottom-0 left-0 right-0 p-4 bg-base-200 border-t border-base-content/10 flex justify-end gap-2')
123
+
112
124
  function handleClose() {
113
125
  open = false
114
126
  onclose?.()
@@ -117,20 +129,20 @@
117
129
 
118
130
  {#snippet detailBody()}
119
131
  {#if item}
120
- <div class='space-y-4'>
132
+ <div class={contentClass}>
121
133
  {#each detailFields as field (field.id)}
122
134
  {@const value = item[field.id]}
123
135
  <div>
124
- <div class='text-xs font-medium text-base-content/50 mb-1'>
136
+ <div class={labelClass}>
125
137
  {resolveText(field.label)}
126
138
  </div>
127
- <div class='text-sm text-base-content'>
139
+ <div class={valueClass}>
128
140
  {#if field.type === 'multi-select' && Array.isArray(value) && value.length > 0}
129
141
  <div class='flex flex-wrap gap-1'>
130
142
  {#each value as v, index (`${String(v)}:${index}`)}
131
143
  {@const opts = resolveOptions(field.options)}
132
144
  {@const found = opts.find(o => String(o.value) === String(v))}
133
- <Badge variant='ghost' size='sm'>{found?.label ?? String(v)}</Badge>
145
+ <Badge variant='ghost' size={badgeSize}>{found?.label ?? String(v)}</Badge>
134
146
  {/each}
135
147
  </div>
136
148
  {:else}
@@ -141,7 +153,7 @@
141
153
  {/each}
142
154
 
143
155
  {#if detailExtra && item}
144
- <div class='border-t border-base-content/5 pt-4'>
156
+ <div class={detailExtraClass}>
145
157
  {@render detailExtra(item)}
146
158
  </div>
147
159
  {/if}
@@ -151,12 +163,12 @@
151
163
 
152
164
  {#snippet footerActions()}
153
165
  {#if canEdit}
154
- <Button variant='primary' onclick={onedit}>
155
- <span class='icon-[tabler--edit] size-4 mr-1'></span>
166
+ <Button variant='primary' size='sm' onclick={onedit}>
167
+ <span class='icon-[tabler--edit] {iconClass}'></span>
156
168
  {uiM('crud_edit')}
157
169
  </Button>
158
170
  {/if}
159
- <Button variant='ghost' onclick={handleClose}>
171
+ <Button variant='ghost' size='sm' onclick={handleClose}>
160
172
  {uiM('crud_close')}
161
173
  </Button>
162
174
  {/snippet}
@@ -179,12 +191,12 @@
179
191
  {:else}
180
192
  <Drawer bind:open {title} position='right' {size} width={drawerWidth} onclose={handleClose}>
181
193
  {#if item}
182
- <div class='pb-20'>
194
+ <div class={drawerBodyClass}>
183
195
  {@render detailBody()}
184
196
  </div>
185
197
 
186
198
  <!-- 底部操作栏 -->
187
- <div class='absolute bottom-0 left-0 right-0 p-4 bg-base-200 border-t border-base-content/10 flex justify-end gap-2'>
199
+ <div class={drawerFooterClass}>
188
200
  {@render footerActions()}
189
201
  </div>
190
202
  {/if}
@@ -1,6 +1,6 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { Size } from '../../../types.js';
3
- import type { CrudFormVariant } from './crud-types.js';
3
+ import type { CrudDensity, CrudFormVariant } from './crud-types.js';
4
4
  type FieldDef = {
5
5
  id: string;
6
6
  label: string | (() => string);
@@ -20,6 +20,7 @@ type $$ComponentProps = {
20
20
  open?: boolean;
21
21
  item?: Record<string, unknown> | null;
22
22
  fields?: FieldDef[];
23
+ density?: CrudDensity;
23
24
  title?: string;
24
25
  variant?: CrudFormVariant;
25
26
  size?: Size;
@@ -8,7 +8,7 @@
8
8
  <script lang='ts'>
9
9
  import type { Snippet } from 'svelte'
10
10
  import type { Size } from '../../../types.js'
11
- import type { CrudFormVariant } from './crud-types.js'
11
+ import type { CrudDensity, CrudFormVariant } from './crud-types.js'
12
12
  import { uiM } from '../../../messages.js'
13
13
  import Drawer from '../../compounds/Drawer.svelte'
14
14
  import FormField from '../../compounds/FormField.svelte'
@@ -35,6 +35,7 @@
35
35
  mode = 'create' as 'create' | 'edit',
36
36
  fields = [],
37
37
  formData = $bindable<Record<string, unknown>>({}),
38
+ density = 'normal' as CrudDensity,
38
39
  title = '',
39
40
  variant = 'drawer' as CrudFormVariant,
40
41
  size = '2xl' as Size,
@@ -53,6 +54,7 @@
53
54
  mode?: 'create' | 'edit'
54
55
  fields?: FieldDef[]
55
56
  formData?: Record<string, unknown>
57
+ density?: CrudDensity
56
58
  title?: string
57
59
  variant?: CrudFormVariant
58
60
  size?: Size
@@ -82,6 +84,16 @@
82
84
  [...fields].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
83
85
  )
84
86
 
87
+ const isCompact = $derived(density === 'compact')
88
+ const controlSize = $derived(isCompact ? 'xs' : 'sm')
89
+ const formClass = $derived(isCompact ? 'space-y-3' : 'space-y-4')
90
+ const extraClass = $derived(isCompact ? 'border-t border-base-content/5 pt-3' : 'border-t border-base-content/5 pt-4')
91
+ const multiSelectWrapClass = $derived(isCompact ? 'flex flex-wrap gap-2 p-2.5 bg-base-200 rounded-lg max-h-48 overflow-y-auto' : 'flex flex-wrap gap-3 p-3 bg-base-200 rounded-lg max-h-48 overflow-y-auto')
92
+ const multiSelectLabelClass = $derived(isCompact ? 'inline-flex items-center gap-1.5 cursor-pointer' : 'inline-flex items-center gap-2 cursor-pointer')
93
+ const multiSelectTextClass = $derived(isCompact ? 'text-xs text-base-content' : 'text-sm text-base-content')
94
+ const drawerFormClass = $derived(isCompact ? 'space-y-3 pb-16' : 'space-y-4 pb-20')
95
+ const drawerFooterClass = $derived(isCompact ? 'absolute bottom-0 left-0 right-0 p-3 bg-base-200 border-t border-base-content/10 flex justify-end gap-2' : 'absolute bottom-0 left-0 right-0 p-4 bg-base-200 border-t border-base-content/10 flex justify-end gap-2')
96
+
85
97
  function handleClose() {
86
98
  open = false
87
99
  onclose?.()
@@ -139,6 +151,7 @@
139
151
  <FormField label={resolveText(field.label)} required={isRequired}>
140
152
  <Textarea
141
153
  id={field.id}
154
+ size={controlSize}
142
155
  value={String(fieldValue ?? '')}
143
156
  {placeholder}
144
157
  disabled={submitting || isReadonly}
@@ -152,6 +165,7 @@
152
165
  <FormField label={resolveText(field.label)} required={isRequired}>
153
166
  <Select
154
167
  id={field.id}
168
+ size={controlSize}
155
169
  value={String(fieldValue ?? '')}
156
170
  disabled={submitting || isReadonly}
157
171
  onchange={value => updateField(field.id, value)}
@@ -167,20 +181,20 @@
167
181
  {@const opts = resolveOptions(field.options)}
168
182
  {@const selectedValues = ((fieldValue ?? []) as Array<string | number | boolean>).map(String)}
169
183
  <FormField label={resolveText(field.label)} required={isRequired}>
170
- <div class='flex flex-wrap gap-3 p-3 bg-base-200 rounded-lg max-h-48 overflow-y-auto'>
184
+ <div class={multiSelectWrapClass}>
171
185
  {#each opts as opt (String(opt.value))}
172
- <label class='inline-flex items-center gap-2 cursor-pointer'>
186
+ <label class={multiSelectLabelClass}>
173
187
  <Checkbox
174
- size='sm'
188
+ size={controlSize}
175
189
  checked={selectedValues.includes(String(opt.value))}
176
190
  onchange={() => toggleMultiSelect(field.id, opt.value)}
177
191
  disabled={submitting || isReadonly}
178
192
  />
179
- <span class='text-sm text-base-content'>{opt.label}</span>
193
+ <span class={multiSelectTextClass}>{opt.label}</span>
180
194
  </label>
181
195
  {/each}
182
196
  {#if opts.length === 0}
183
- <span class='text-sm text-base-content/60'>{uiM('crud_no_data')}</span>
197
+ <span class={multiSelectTextClass}>{uiM('crud_no_data')}</span>
184
198
  {/if}
185
199
  </div>
186
200
  </FormField>
@@ -188,6 +202,7 @@
188
202
  {:else if field.type === 'boolean' || field.type === 'checkbox'}
189
203
  <FormField label={resolveText(field.label)}>
190
204
  <Checkbox
205
+ size={controlSize}
191
206
  checked={Boolean(fieldValue)}
192
207
  onchange={(checked: boolean) => updateField(field.id, checked)}
193
208
  disabled={submitting || isReadonly}
@@ -199,6 +214,7 @@
199
214
  <Input
200
215
  id={field.id}
201
216
  type={getInputType(field.type)}
217
+ size={controlSize}
202
218
  value={String(fieldValue ?? '')}
203
219
  {placeholder}
204
220
  disabled={submitting || isReadonly}
@@ -215,17 +231,17 @@
215
231
  {/each}
216
232
 
217
233
  {#if editFormExtra}
218
- <div class='border-t border-base-content/5 pt-4'>
234
+ <div class={extraClass}>
219
235
  {@render editFormExtra(editingItem, mode)}
220
236
  </div>
221
237
  {/if}
222
238
  {/snippet}
223
239
 
224
240
  {#snippet footerActions()}
225
- <Button variant='ghost' onclick={handleClose} disabled={submitting}>
241
+ <Button variant='ghost' size='sm' onclick={handleClose} disabled={submitting}>
226
242
  {uiM('crud_cancel')}
227
243
  </Button>
228
- <Button variant='primary' onclick={handleSubmit} disabled={submitting}>
244
+ <Button variant='primary' size='sm' onclick={handleSubmit} disabled={submitting}>
229
245
  {#if submitting}
230
246
  <span class='loading loading-spinner loading-xs mr-2'></span>
231
247
  {/if}
@@ -243,7 +259,7 @@
243
259
  closeOnBackdrop={false}
244
260
  onclose={handleClose}
245
261
  >
246
- <form onsubmit={handleSubmit} class='space-y-4'>
262
+ <form onsubmit={handleSubmit} class={formClass}>
247
263
  {@render formFields()}
248
264
  </form>
249
265
 
@@ -253,12 +269,12 @@
253
269
  </Modal>
254
270
  {:else}
255
271
  <Drawer bind:open {title} position='right' {size} width={drawerWidth} onclose={handleClose} closeOnBackdrop={false}>
256
- <form onsubmit={handleSubmit} class='space-y-4 pb-20'>
272
+ <form onsubmit={handleSubmit} class={drawerFormClass}>
257
273
  {@render formFields()}
258
274
  </form>
259
275
 
260
276
  <!-- 底部操作栏 -->
261
- <div class='absolute bottom-0 left-0 right-0 p-4 bg-base-200 border-t border-base-content/10 flex justify-end gap-2'>
277
+ <div class={drawerFooterClass}>
262
278
  {@render footerActions()}
263
279
  </div>
264
280
  </Drawer>
@@ -1,6 +1,6 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { Size } from '../../../types.js';
3
- import type { CrudFormVariant } from './crud-types.js';
3
+ import type { CrudDensity, CrudFormVariant } from './crud-types.js';
4
4
  type FieldDef = {
5
5
  id: string;
6
6
  label: string | (() => string);
@@ -28,6 +28,7 @@ type $$ComponentProps = {
28
28
  mode?: 'create' | 'edit';
29
29
  fields?: FieldDef[];
30
30
  formData?: Record<string, unknown>;
31
+ density?: CrudDensity;
31
32
  title?: string;
32
33
  variant?: CrudFormVariant;
33
34
  size?: Size;
@@ -57,15 +57,16 @@
57
57
  }
58
58
  </script>
59
59
 
60
- <div class='p-4'>
61
- <div class='flex flex-col sm:flex-row gap-3'>
60
+ <div class='grid gap-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center'>
61
+ <div class='flex min-w-0 flex-wrap items-center gap-3 [&_.fieldset]:m-0 [&_.fieldset]:min-w-0'>
62
62
  {#if searchable}
63
- <div class='flex-1 relative'>
64
- <span class='icon-[tabler--search] size-4.5 absolute left-3 top-1/2 -translate-y-1/2 text-base-content/35 z-10'></span>
63
+ <div class='relative min-w-[15rem] flex-[1_1_20rem]'>
64
+ <span class='icon-[tabler--search] pointer-events-none absolute left-3.5 top-1/2 z-10 size-4 -translate-y-1/2 text-base-content/35'></span>
65
65
  <Input
66
66
  type='text'
67
+ size='sm'
67
68
  placeholder={searchPlaceholder || uiM('crud_search_placeholder')}
68
- class='pl-10'
69
+ class='pl-10 shadow-none'
69
70
  bind:value={searchValue}
70
71
  oninput={handleSearchInput}
71
72
  autocomplete='off'
@@ -76,21 +77,24 @@
76
77
  {#each filterFields as field (field.id)}
77
78
  {@const opts = resolveOptions(field.options)}
78
79
  {#if opts.length > 0}
79
- <Select
80
- class='min-w-32'
81
- value={String(filterValues[field.id] ?? '')}
82
- onchange={value => handleFilterChange(field.id, value)}
83
- >
84
- <option value="">{uiM('crud_filter_all')}</option>
85
- {#each opts as opt (String(opt.value))}
86
- <option value={String(opt.value)}>{opt.label}</option>
87
- {/each}
88
- </Select>
80
+ <div class='w-full sm:w-[11rem] sm:flex-none'>
81
+ <Select
82
+ size='sm'
83
+ class='border-base-content/10 bg-base-100 shadow-none'
84
+ value={String(filterValues[field.id] ?? '')}
85
+ onchange={value => handleFilterChange(field.id, value)}
86
+ >
87
+ <option value="">{uiM('crud_filter_all')}</option>
88
+ {#each opts as opt (String(opt.value))}
89
+ <option value={String(opt.value)}>{opt.label}</option>
90
+ {/each}
91
+ </Select>
92
+ </div>
89
93
  {/if}
90
94
  {/each}
95
+ </div>
91
96
 
92
- <div class='text-sm text-base-content/50 self-center whitespace-nowrap'>
93
- {uiM('crud_total', { count: total })}
94
- </div>
97
+ <div class='inline-flex items-center justify-self-start rounded-full border border-base-content/8 bg-base-200/60 px-3 py-1 text-xs font-medium text-base-content/60 md:justify-self-end'>
98
+ {uiM('crud_total', { count: total })}
95
99
  </div>
96
100
  </div>
@@ -137,6 +137,14 @@
137
137
  const canUpdate = $derived(permissions.update !== false && Boolean(crud.api.update))
138
138
  const canDelete = $derived(permissions.delete !== false && Boolean(crud.api.remove))
139
139
  const searchable = $derived(crud.resource.searchable !== false)
140
+ const isCompact = $derived(density === 'compact')
141
+ const pageGapClass = 'space-y-5'
142
+ const actionButtonSize = $derived(isCompact ? 'xs' : 'sm')
143
+ const actionIconClass = $derived(isCompact ? 'size-3.5' : 'size-4')
144
+ const paginationSize = $derived(isCompact ? 'xs' : 'sm')
145
+ const filterCardClass = 'overflow-hidden rounded-[1.25rem] border-base-content/7 shadow-sm'
146
+ const listCardClass = 'overflow-hidden rounded-[1.25rem] border-base-content/7 shadow-sm'
147
+ const paginationBarClass = 'flex justify-center border-t border-base-content/5 px-4 py-2'
140
148
 
141
149
  // 表单展示配置(抽屉 / 弹窗)
142
150
  const formVariant = $derived(form.variant ?? 'drawer')
@@ -395,7 +403,7 @@
395
403
  )
396
404
  </script>
397
405
 
398
- <div class='space-y-4 {className}'>
406
+ <div class='{pageGapClass} {className}'>
399
407
  <!-- 页面标题 -->
400
408
  <PageHeader title={resourceLabel}>
401
409
  {#snippet actions()}
@@ -403,8 +411,8 @@
403
411
  {@render headerActions()}
404
412
  {/if}
405
413
  {#if canCreate}
406
- <Button variant='primary' onclick={openCreate}>
407
- <span class='icon-[tabler--plus] size-4.5 mr-1'></span>
414
+ <Button variant='primary' size='sm' onclick={openCreate} class='shadow-sm'>
415
+ <span class='icon-[tabler--plus] size-4.5 mr-1.5'></span>
408
416
  {uiM('crud_create')}
409
417
  </Button>
410
418
  {/if}
@@ -413,7 +421,7 @@
413
421
 
414
422
  <!-- 搜索 + 过滤栏 -->
415
423
  {#if searchable || filterFields.length > 0}
416
- <Card>
424
+ <Card padding='sm' class={filterCardClass}>
417
425
  <CrudFilterBar
418
426
  {searchable}
419
427
  searchPlaceholder={searchPlaceholderText}
@@ -428,7 +436,7 @@
428
436
  {/if}
429
437
 
430
438
  <!-- 数据列表 -->
431
- <Card>
439
+ <Card padding='none' class={listCardClass}>
432
440
  <DataTable
433
441
  data={data.items}
434
442
  columns={dtColumns}
@@ -440,32 +448,32 @@
440
448
  {#if rowClickDetail}
441
449
  <IconButton
442
450
  variant='ghost'
443
- size='sm'
451
+ size={actionButtonSize}
444
452
  ariaLabel={uiM('crud_detail')}
445
453
  onclick={() => openDetail(item)}
446
454
  >
447
- <span class='icon-[tabler--eye] size-4'></span>
455
+ <span class='icon-[tabler--eye] {actionIconClass}'></span>
448
456
  </IconButton>
449
457
  {/if}
450
458
  {#if canUpdate}
451
459
  <IconButton
452
460
  variant='ghost'
453
- size='sm'
461
+ size={actionButtonSize}
454
462
  ariaLabel={uiM('crud_edit')}
455
463
  onclick={() => openEdit(item)}
456
464
  >
457
- <span class='icon-[tabler--edit] size-4'></span>
465
+ <span class='icon-[tabler--edit] {actionIconClass}'></span>
458
466
  </IconButton>
459
467
  {/if}
460
468
  {#if canDelete}
461
469
  <IconButton
462
470
  variant='ghost'
463
- size='sm'
471
+ size={actionButtonSize}
464
472
  ariaLabel={uiM('crud_delete')}
465
473
  onclick={() => requestDelete(item)}
466
474
  class='hover:text-error'
467
475
  >
468
- <span class='icon-[tabler--trash] size-4'></span>
476
+ <span class='icon-[tabler--trash] {actionIconClass}'></span>
469
477
  </IconButton>
470
478
  {/if}
471
479
  {#if listItemActions}
@@ -475,12 +483,12 @@
475
483
  </DataTable>
476
484
 
477
485
  <!-- 分页栏:始终显示,支持每页条数选择与跳页 -->
478
- <div class='flex justify-center p-4 border-t border-base-content/5'>
486
+ <div class={paginationBarClass}>
479
487
  <Pagination
480
488
  page={data.page}
481
489
  total={data.total}
482
490
  pageSize={data.pageSize}
483
- size='sm'
491
+ size={paginationSize}
484
492
  showTotal={paginationShowTotal}
485
493
  showJumper={paginationShowJumper}
486
494
  showSizeChanger={paginationShowSizeChanger}
@@ -497,6 +505,7 @@
497
505
  open={detailOpen}
498
506
  item={selectedItem}
499
507
  fields={detailFields}
508
+ {density}
500
509
  title={panelTitle}
501
510
  variant={formVariant}
502
511
  size={formDrawerSize}
@@ -515,6 +524,7 @@
515
524
  open={editOpen}
516
525
  mode={panelMode === 'create' ? 'create' : 'edit'}
517
526
  fields={panelMode === 'create' ? createFields : editFields}
527
+ {density}
518
528
  bind:formData
519
529
  title={panelTitle}
520
530
  variant={formVariant}
@@ -142,7 +142,7 @@ export interface CrudPageProps<T = Record<string, unknown>> {
142
142
  form?: CrudFormConfig;
143
143
  /** 分页栏展示配置(每页条数选择、跳页、总数) */
144
144
  pagination?: CrudPaginationConfig;
145
- /** 列表显示密度:'normal'(默认)或 'compact'(紧凑) */
145
+ /** 列表显示密度:影响列表行、行内操作、分页与详情/编辑表单;不影响页头与筛选栏 */
146
146
  density?: CrudDensity;
147
147
  /** 列表行点击是否打开详情(默认 true) */
148
148
  rowClickDetail?: boolean;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@h-ai/ui",
3
3
  "type": "module",
4
- "version": "0.1.0-alpha.23",
4
+ "version": "0.1.0-alpha.24",
5
5
  "description": "Hai Framework UI component library for Svelte 5 Runes.",
6
6
  "license": "Apache-2.0",
7
7
  "svelte": "./dist/index.js",
@@ -45,7 +45,7 @@
45
45
  "mermaid": "^11.12.0",
46
46
  "shiki": "^4.0.2",
47
47
  "tailwind-merge": "^3.5.0",
48
- "@h-ai/core": "0.1.0-alpha.23"
48
+ "@h-ai/core": "0.1.0-alpha.24"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@sveltejs/package": "^2.5.7",