@h-ai/ui 0.1.0-alpha.22 → 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.
Files changed (28) hide show
  1. package/README.md +39 -5
  2. package/auto-import.d.ts +2 -2
  3. package/auto-import.js +2 -2
  4. package/dist/components/compounds/Card.svelte +1 -1
  5. package/dist/components/compounds/DataTable.svelte +32 -7
  6. package/dist/components/compounds/DataTable.svelte.d.ts +2 -0
  7. package/dist/components/compounds/Drawer.svelte +7 -2
  8. package/dist/components/compounds/Modal.svelte +2 -0
  9. package/dist/components/compounds/Pagination.svelte +57 -16
  10. package/dist/components/primitives/Input.svelte +23 -1
  11. package/dist/components/primitives/Select.svelte +2 -2
  12. package/dist/components/scenes/crud/CrudDetailDrawer.svelte +59 -18
  13. package/dist/components/scenes/crud/CrudDetailDrawer.svelte.d.ts +8 -2
  14. package/dist/components/scenes/crud/CrudDetailPanel.svelte +204 -0
  15. package/dist/components/scenes/crud/CrudDetailPanel.svelte.d.ts +45 -0
  16. package/dist/components/scenes/crud/CrudEditPanel.svelte +281 -0
  17. package/dist/components/scenes/crud/{CrudEditDrawer.svelte.d.ts → CrudEditPanel.svelte.d.ts} +13 -6
  18. package/dist/components/scenes/crud/CrudFilterBar.svelte +22 -18
  19. package/dist/components/scenes/crud/CrudPage.svelte +105 -60
  20. package/dist/components/scenes/crud/CrudPage.svelte.d.ts +6 -4
  21. package/dist/components/scenes/crud/crud-types.d.ts +36 -2
  22. package/dist/components/scenes/crud/index.d.ts +3 -3
  23. package/dist/components/scenes/crud/index.js +3 -3
  24. package/dist/messages/en-US.json +3 -2
  25. package/dist/messages/zh-CN.json +3 -2
  26. package/dist/types.d.ts +10 -0
  27. package/package.json +2 -2
  28. package/dist/components/scenes/crud/CrudEditDrawer.svelte +0 -225
@@ -0,0 +1,204 @@
1
+ <!--
2
+ @component CrudDetailPanel
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 { CrudDensity, 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
+ density = 'normal' as CrudDensity,
33
+ title = '',
34
+ variant = 'drawer' as CrudFormVariant,
35
+ size = '2xl' as Size,
36
+ drawerWidth,
37
+ modalSize = '2xl' as Size | 'full',
38
+ modalWidth,
39
+ modalHeight,
40
+ canEdit = false,
41
+ onedit,
42
+ onclose,
43
+ detailExtra,
44
+ }: {
45
+ open?: boolean
46
+ item?: Record<string, unknown> | null
47
+ fields?: FieldDef[]
48
+ density?: CrudDensity
49
+ title?: string
50
+ variant?: CrudFormVariant
51
+ size?: Size
52
+ drawerWidth?: string
53
+ modalSize?: Size | 'full'
54
+ modalWidth?: string
55
+ modalHeight?: string
56
+ canEdit?: boolean
57
+ onedit?: () => void
58
+ onclose?: () => void
59
+ detailExtra?: Snippet<[Record<string, unknown>]>
60
+ } = $props()
61
+
62
+ function resolveText(text: string | (() => string)): string {
63
+ return typeof text === 'function' ? text() : text
64
+ }
65
+
66
+ function resolveOptions(options?: FieldDef['options']): Array<{ label: string, value: string | number | boolean }> {
67
+ if (!options)
68
+ return []
69
+ return typeof options === 'function' ? options() : options
70
+ }
71
+
72
+ function formatValue(field: FieldDef, value: unknown): string {
73
+ if (value === null || value === undefined)
74
+ return uiM('crud_detail_empty')
75
+
76
+ if (field.render && item) {
77
+ return field.render(value, item)
78
+ }
79
+
80
+ if (field.type === 'select' || field.type === 'radio') {
81
+ const opts = resolveOptions(field.options)
82
+ const found = opts.find(o => String(o.value) === String(value))
83
+ return found?.label ?? String(value)
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
+ if (field.type === 'boolean' || field.type === 'checkbox') {
95
+ return value ? '✓' : '✗'
96
+ }
97
+
98
+ if (field.type === 'date' && (typeof value === 'string' || typeof value === 'number' || value instanceof Date)) {
99
+ return new Date(value as string | number).toLocaleDateString()
100
+ }
101
+ if (field.type === 'datetime' && (typeof value === 'string' || typeof value === 'number' || value instanceof Date)) {
102
+ return new Date(value as string | number).toLocaleString()
103
+ }
104
+
105
+ return String(value)
106
+ }
107
+
108
+ const detailFields = $derived(
109
+ [...fields]
110
+ .filter(f => f.inDetail !== false)
111
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
112
+ )
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
+
124
+ function handleClose() {
125
+ open = false
126
+ onclose?.()
127
+ }
128
+ </script>
129
+
130
+ {#snippet detailBody()}
131
+ {#if item}
132
+ <div class={contentClass}>
133
+ {#each detailFields as field (field.id)}
134
+ {@const value = item[field.id]}
135
+ <div>
136
+ <div class={labelClass}>
137
+ {resolveText(field.label)}
138
+ </div>
139
+ <div class={valueClass}>
140
+ {#if field.type === 'multi-select' && Array.isArray(value) && value.length > 0}
141
+ <div class='flex flex-wrap gap-1'>
142
+ {#each value as v, index (`${String(v)}:${index}`)}
143
+ {@const opts = resolveOptions(field.options)}
144
+ {@const found = opts.find(o => String(o.value) === String(v))}
145
+ <Badge variant='ghost' size={badgeSize}>{found?.label ?? String(v)}</Badge>
146
+ {/each}
147
+ </div>
148
+ {:else}
149
+ {formatValue(field, value)}
150
+ {/if}
151
+ </div>
152
+ </div>
153
+ {/each}
154
+
155
+ {#if detailExtra && item}
156
+ <div class={detailExtraClass}>
157
+ {@render detailExtra(item)}
158
+ </div>
159
+ {/if}
160
+ </div>
161
+ {/if}
162
+ {/snippet}
163
+
164
+ {#snippet footerActions()}
165
+ {#if canEdit}
166
+ <Button variant='primary' size='sm' onclick={onedit}>
167
+ <span class='icon-[tabler--edit] {iconClass}'></span>
168
+ {uiM('crud_edit')}
169
+ </Button>
170
+ {/if}
171
+ <Button variant='ghost' size='sm' onclick={handleClose}>
172
+ {uiM('crud_close')}
173
+ </Button>
174
+ {/snippet}
175
+
176
+ {#if variant === 'modal'}
177
+ <Modal
178
+ bind:open
179
+ {title}
180
+ size={modalSize}
181
+ width={modalWidth}
182
+ height={modalHeight}
183
+ onclose={handleClose}
184
+ >
185
+ {@render detailBody()}
186
+
187
+ {#snippet footer()}
188
+ {@render footerActions()}
189
+ {/snippet}
190
+ </Modal>
191
+ {:else}
192
+ <Drawer bind:open {title} position='right' {size} width={drawerWidth} onclose={handleClose}>
193
+ {#if item}
194
+ <div class={drawerBodyClass}>
195
+ {@render detailBody()}
196
+ </div>
197
+
198
+ <!-- 底部操作栏 -->
199
+ <div class={drawerFooterClass}>
200
+ {@render footerActions()}
201
+ </div>
202
+ {/if}
203
+ </Drawer>
204
+ {/if}
@@ -0,0 +1,45 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { Size } from '../../../types.js';
3
+ import type { CrudDensity, 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
+ density?: CrudDensity;
24
+ title?: string;
25
+ variant?: CrudFormVariant;
26
+ size?: Size;
27
+ drawerWidth?: string;
28
+ modalSize?: Size | 'full';
29
+ modalWidth?: string;
30
+ modalHeight?: string;
31
+ canEdit?: boolean;
32
+ onedit?: () => void;
33
+ onclose?: () => void;
34
+ detailExtra?: Snippet<[Record<string, unknown>]>;
35
+ };
36
+ /**
37
+ * CrudDetailPanel
38
+ * CRUD 详情组件(只读展示),支持抽屉(drawer)与弹窗(modal)两种展示形式
39
+ *
40
+ * 使用 Svelte 5 Runes ($props, $derived)
41
+ * 使用 compounds 组件:Drawer, Modal, Badge
42
+ */
43
+ declare const CrudDetailPanel: import("svelte").Component<$$ComponentProps, {}, "open">;
44
+ type CrudDetailPanel = ReturnType<typeof CrudDetailPanel>;
45
+ export default CrudDetailPanel;
@@ -0,0 +1,281 @@
1
+ <!--
2
+ @component CrudEditPanel
3
+ CRUD 编辑/新建表单组件,支持抽屉(drawer)与弹窗(modal)两种展示形式
4
+
5
+ 使用 Svelte 5 Runes ($props, $state, $derived)
6
+ 使用 compounds/primitives 组件:Drawer, Modal, FormField, Input, Select, Textarea, Checkbox, Button
7
+ -->
8
+ <script lang='ts'>
9
+ import type { Snippet } from 'svelte'
10
+ import type { Size } from '../../../types.js'
11
+ import type { CrudDensity, CrudFormVariant } from './crud-types.js'
12
+ import { uiM } from '../../../messages.js'
13
+ import Drawer from '../../compounds/Drawer.svelte'
14
+ import FormField from '../../compounds/FormField.svelte'
15
+ import Modal from '../../compounds/Modal.svelte'
16
+ import Button from '../../primitives/Button.svelte'
17
+ import Checkbox from '../../primitives/Checkbox.svelte'
18
+ import Input from '../../primitives/Input.svelte'
19
+ import Select from '../../primitives/Select.svelte'
20
+ import Textarea from '../../primitives/Textarea.svelte'
21
+
22
+ type FieldDef = {
23
+ id: string
24
+ label: string | (() => string)
25
+ type: string
26
+ readonly?: boolean
27
+ options?: Array<{ label: string, value: string | number | boolean }> | (() => Array<{ label: string, value: string | number | boolean }>)
28
+ validation?: { required?: boolean, min?: number, max?: number, pattern?: string, message?: string }
29
+ placeholder?: string | (() => string)
30
+ order?: number
31
+ }
32
+
33
+ let {
34
+ open = $bindable(false),
35
+ mode = 'create' as 'create' | 'edit',
36
+ fields = [],
37
+ formData = $bindable<Record<string, unknown>>({}),
38
+ density = 'normal' as CrudDensity,
39
+ title = '',
40
+ variant = 'drawer' as CrudFormVariant,
41
+ size = '2xl' as Size,
42
+ drawerWidth,
43
+ modalSize = '2xl' as Size | 'full',
44
+ modalWidth,
45
+ modalHeight,
46
+ submitting = false,
47
+ error = '',
48
+ onsubmit,
49
+ onclose,
50
+ editFormExtra,
51
+ editingItem = null,
52
+ }: {
53
+ open?: boolean
54
+ mode?: 'create' | 'edit'
55
+ fields?: FieldDef[]
56
+ formData?: Record<string, unknown>
57
+ density?: CrudDensity
58
+ title?: string
59
+ variant?: CrudFormVariant
60
+ size?: Size
61
+ drawerWidth?: string
62
+ modalSize?: Size | 'full'
63
+ modalWidth?: string
64
+ modalHeight?: string
65
+ submitting?: boolean
66
+ error?: string
67
+ onsubmit?: (data: Record<string, unknown>) => Promise<void>
68
+ onclose?: () => void
69
+ editFormExtra?: Snippet<[Record<string, unknown> | null, 'create' | 'edit']>
70
+ editingItem?: Record<string, unknown> | null
71
+ } = $props()
72
+
73
+ function resolveText(text: string | (() => string)): string {
74
+ return typeof text === 'function' ? text() : text
75
+ }
76
+
77
+ function resolveOptions(options?: FieldDef['options']): Array<{ label: string, value: string | number | boolean }> {
78
+ if (!options)
79
+ return []
80
+ return typeof options === 'function' ? options() : options
81
+ }
82
+
83
+ const sortedFields = $derived(
84
+ [...fields].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
85
+ )
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
+
97
+ function handleClose() {
98
+ open = false
99
+ onclose?.()
100
+ }
101
+
102
+ async function handleSubmit(e: Event) {
103
+ e.preventDefault()
104
+ await onsubmit?.(formData)
105
+ }
106
+
107
+ function getInputType(fieldType: string): 'email' | 'url' | 'tel' | 'password' | 'number' | 'text' {
108
+ switch (fieldType) {
109
+ case 'email': return 'email'
110
+ case 'url': return 'url'
111
+ case 'tel': return 'tel'
112
+ case 'password': return 'password'
113
+ case 'number': return 'number'
114
+ case 'date': return 'text'
115
+ case 'datetime': return 'text'
116
+ default: return 'text'
117
+ }
118
+ }
119
+
120
+ function updateField(fieldId: string, value: unknown) {
121
+ formData = { ...formData, [fieldId]: value }
122
+ }
123
+
124
+ function toggleMultiSelect(fieldId: string, optValue: string | number | boolean) {
125
+ const current = (formData[fieldId] as Array<string | number | boolean>) ?? []
126
+ const strVal = String(optValue)
127
+ if (current.map(String).includes(strVal)) {
128
+ formData = { ...formData, [fieldId]: current.filter(v => String(v) !== strVal) }
129
+ }
130
+ else {
131
+ formData = { ...formData, [fieldId]: [...current, optValue] }
132
+ }
133
+ }
134
+ </script>
135
+
136
+ {#snippet formFields()}
137
+ {#if error}
138
+ <div class='p-3 bg-error/10 border border-error/20 rounded-lg text-sm text-error flex items-center gap-2'>
139
+ <span class='icon-[tabler--alert-circle] size-4 shrink-0'></span>
140
+ <span>{error}</span>
141
+ </div>
142
+ {/if}
143
+
144
+ {#each sortedFields as field (field.id)}
145
+ {@const isReadonly = field.readonly && mode === 'edit'}
146
+ {@const isRequired = field.validation?.required}
147
+ {@const fieldValue = formData[field.id]}
148
+ {@const placeholder = field.placeholder ? resolveText(field.placeholder) : ''}
149
+
150
+ {#if field.type === 'textarea'}
151
+ <FormField label={resolveText(field.label)} required={isRequired}>
152
+ <Textarea
153
+ id={field.id}
154
+ size={controlSize}
155
+ value={String(fieldValue ?? '')}
156
+ {placeholder}
157
+ disabled={submitting || isReadonly}
158
+ required={isRequired}
159
+ oninput={e => updateField(field.id, (e.target as HTMLTextAreaElement).value)}
160
+ />
161
+ </FormField>
162
+
163
+ {:else if field.type === 'select' || field.type === 'radio'}
164
+ {@const opts = resolveOptions(field.options)}
165
+ <FormField label={resolveText(field.label)} required={isRequired}>
166
+ <Select
167
+ id={field.id}
168
+ size={controlSize}
169
+ value={String(fieldValue ?? '')}
170
+ disabled={submitting || isReadonly}
171
+ onchange={value => updateField(field.id, value)}
172
+ >
173
+ <option value="">{placeholder || uiM('crud_filter_all')}</option>
174
+ {#each opts as opt (String(opt.value))}
175
+ <option value={String(opt.value)}>{opt.label}</option>
176
+ {/each}
177
+ </Select>
178
+ </FormField>
179
+
180
+ {:else if field.type === 'multi-select'}
181
+ {@const opts = resolveOptions(field.options)}
182
+ {@const selectedValues = ((fieldValue ?? []) as Array<string | number | boolean>).map(String)}
183
+ <FormField label={resolveText(field.label)} required={isRequired}>
184
+ <div class={multiSelectWrapClass}>
185
+ {#each opts as opt (String(opt.value))}
186
+ <label class={multiSelectLabelClass}>
187
+ <Checkbox
188
+ size={controlSize}
189
+ checked={selectedValues.includes(String(opt.value))}
190
+ onchange={() => toggleMultiSelect(field.id, opt.value)}
191
+ disabled={submitting || isReadonly}
192
+ />
193
+ <span class={multiSelectTextClass}>{opt.label}</span>
194
+ </label>
195
+ {/each}
196
+ {#if opts.length === 0}
197
+ <span class={multiSelectTextClass}>{uiM('crud_no_data')}</span>
198
+ {/if}
199
+ </div>
200
+ </FormField>
201
+
202
+ {:else if field.type === 'boolean' || field.type === 'checkbox'}
203
+ <FormField label={resolveText(field.label)}>
204
+ <Checkbox
205
+ size={controlSize}
206
+ checked={Boolean(fieldValue)}
207
+ onchange={(checked: boolean) => updateField(field.id, checked)}
208
+ disabled={submitting || isReadonly}
209
+ />
210
+ </FormField>
211
+
212
+ {:else}
213
+ <FormField label={resolveText(field.label)} required={isRequired}>
214
+ <Input
215
+ id={field.id}
216
+ type={getInputType(field.type)}
217
+ size={controlSize}
218
+ value={String(fieldValue ?? '')}
219
+ {placeholder}
220
+ disabled={submitting || isReadonly}
221
+ required={isRequired}
222
+ pattern={field.validation?.pattern}
223
+ minlength={field.type !== 'number' ? field.validation?.min : undefined}
224
+ maxlength={field.type !== 'number' ? field.validation?.max : undefined}
225
+ min={field.type === 'number' ? field.validation?.min : undefined}
226
+ max={field.type === 'number' ? field.validation?.max : undefined}
227
+ oninput={e => updateField(field.id, field.type === 'number' ? Number((e.currentTarget as HTMLInputElement).value) : (e.currentTarget as HTMLInputElement).value)}
228
+ />
229
+ </FormField>
230
+ {/if}
231
+ {/each}
232
+
233
+ {#if editFormExtra}
234
+ <div class={extraClass}>
235
+ {@render editFormExtra(editingItem, mode)}
236
+ </div>
237
+ {/if}
238
+ {/snippet}
239
+
240
+ {#snippet footerActions()}
241
+ <Button variant='ghost' size='sm' onclick={handleClose} disabled={submitting}>
242
+ {uiM('crud_cancel')}
243
+ </Button>
244
+ <Button variant='primary' size='sm' onclick={handleSubmit} disabled={submitting}>
245
+ {#if submitting}
246
+ <span class='loading loading-spinner loading-xs mr-2'></span>
247
+ {/if}
248
+ {mode === 'create' ? uiM('crud_create') : uiM('crud_save')}
249
+ </Button>
250
+ {/snippet}
251
+
252
+ {#if variant === 'modal'}
253
+ <Modal
254
+ bind:open
255
+ {title}
256
+ size={modalSize}
257
+ width={modalWidth}
258
+ height={modalHeight}
259
+ closeOnBackdrop={false}
260
+ onclose={handleClose}
261
+ >
262
+ <form onsubmit={handleSubmit} class={formClass}>
263
+ {@render formFields()}
264
+ </form>
265
+
266
+ {#snippet footer()}
267
+ {@render footerActions()}
268
+ {/snippet}
269
+ </Modal>
270
+ {:else}
271
+ <Drawer bind:open {title} position='right' {size} width={drawerWidth} onclose={handleClose} closeOnBackdrop={false}>
272
+ <form onsubmit={handleSubmit} class={drawerFormClass}>
273
+ {@render formFields()}
274
+ </form>
275
+
276
+ <!-- 底部操作栏 -->
277
+ <div class={drawerFooterClass}>
278
+ {@render footerActions()}
279
+ </div>
280
+ </Drawer>
281
+ {/if}
@@ -1,5 +1,6 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { Size } from '../../../types.js';
3
+ import type { CrudDensity, CrudFormVariant } from './crud-types.js';
3
4
  type FieldDef = {
4
5
  id: string;
5
6
  label: string | (() => string);
@@ -27,8 +28,14 @@ type $$ComponentProps = {
27
28
  mode?: 'create' | 'edit';
28
29
  fields?: FieldDef[];
29
30
  formData?: Record<string, unknown>;
31
+ density?: CrudDensity;
30
32
  title?: string;
33
+ variant?: CrudFormVariant;
31
34
  size?: Size;
35
+ drawerWidth?: string;
36
+ modalSize?: Size | 'full';
37
+ modalWidth?: string;
38
+ modalHeight?: string;
32
39
  submitting?: boolean;
33
40
  error?: string;
34
41
  onsubmit?: (data: Record<string, unknown>) => Promise<void>;
@@ -37,12 +44,12 @@ type $$ComponentProps = {
37
44
  editingItem?: Record<string, unknown> | null;
38
45
  };
39
46
  /**
40
- * CrudEditDrawer
41
- * CRUD 编辑/新建抽屉组件
47
+ * CrudEditPanel
48
+ * CRUD 编辑/新建表单组件,支持抽屉(drawer)与弹窗(modal)两种展示形式
42
49
  *
43
50
  * 使用 Svelte 5 Runes ($props, $state, $derived)
44
- * 使用 compounds/primitives 组件:Drawer, FormField, Input, Select, Textarea, Checkbox, Button
51
+ * 使用 compounds/primitives 组件:Drawer, Modal, FormField, Input, Select, Textarea, Checkbox, Button
45
52
  */
46
- declare const CrudEditDrawer: import("svelte").Component<$$ComponentProps, {}, "open" | "formData">;
47
- type CrudEditDrawer = ReturnType<typeof CrudEditDrawer>;
48
- export default CrudEditDrawer;
53
+ declare const CrudEditPanel: import("svelte").Component<$$ComponentProps, {}, "open" | "formData">;
54
+ type CrudEditPanel = ReturnType<typeof CrudEditPanel>;
55
+ export default CrudEditPanel;
@@ -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>