@h-ai/ui 0.1.0-alpha.61 → 0.1.0-alpha.63

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 (79) hide show
  1. package/README.en.md +93 -8
  2. package/README.ja.md +93 -8
  3. package/README.md +94 -8
  4. package/auto-import.d.ts +3 -0
  5. package/auto-import.js +3 -0
  6. package/dist/components/compounds/Combobox.svelte +19 -6
  7. package/dist/components/compounds/Combobox.svelte.d.ts +2 -2
  8. package/dist/components/compounds/DatePicker.svelte +14 -5
  9. package/dist/components/compounds/DatePicker.svelte.d.ts +2 -2
  10. package/dist/components/compounds/Form.svelte +10 -6
  11. package/dist/components/compounds/FormField.svelte +78 -13
  12. package/dist/components/compounds/FormLayout.svelte +59 -0
  13. package/dist/components/compounds/FormLayout.svelte.d.ts +4 -0
  14. package/dist/components/compounds/TagInput.svelte +1 -1
  15. package/dist/components/compounds/index.d.ts +1 -0
  16. package/dist/components/compounds/index.js +1 -0
  17. package/dist/components/primitives/BareInput.svelte +7 -1
  18. package/dist/components/primitives/Checkbox.svelte +8 -2
  19. package/dist/components/primitives/Input.svelte +22 -11
  20. package/dist/components/primitives/Select.svelte +77 -18
  21. package/dist/components/primitives/Textarea.svelte +27 -13
  22. package/dist/components/primitives/Textarea.svelte.d.ts +1 -1
  23. package/dist/components/scenes/app/FeedbackModal.svelte +29 -19
  24. package/dist/components/scenes/app/FeedbackModal.svelte.d.ts +2 -2
  25. package/dist/components/scenes/crud/CrudEditPanel.svelte +148 -118
  26. package/dist/components/scenes/crud/CrudEditPanel.svelte.d.ts +4 -25
  27. package/dist/components/scenes/crud/CrudPage.svelte +5 -41
  28. package/dist/components/scenes/crud/CrudPage.svelte.d.ts +2 -53
  29. package/dist/components/scenes/crud/crud-controller.svelte.d.ts +4 -1
  30. package/dist/components/scenes/crud/crud-form-validation.d.ts +4 -0
  31. package/dist/components/scenes/crud/crud-form-validation.js +39 -0
  32. package/dist/components/scenes/crud/crud-types.d.ts +9 -42
  33. package/dist/components/scenes/guide/GuideHost.svelte +111 -0
  34. package/dist/components/scenes/guide/GuideHost.svelte.d.ts +16 -0
  35. package/dist/components/scenes/guide/HelpCenter.svelte +94 -0
  36. package/dist/components/scenes/guide/HelpCenter.svelte.d.ts +13 -0
  37. package/dist/components/scenes/guide/index.d.ts +2 -0
  38. package/dist/components/scenes/guide/index.js +2 -0
  39. package/dist/components/scenes/iam/AuthPanel.svelte +55 -47
  40. package/dist/components/scenes/iam/ChangePasswordForm.svelte +57 -59
  41. package/dist/components/scenes/iam/ForgotPasswordForm.svelte +89 -84
  42. package/dist/components/scenes/iam/LoginForm.svelte +134 -132
  43. package/dist/components/scenes/iam/PasswordInput.svelte +25 -7
  44. package/dist/components/scenes/iam/PasswordInput.svelte.d.ts +2 -2
  45. package/dist/components/scenes/iam/RegisterForm.svelte +95 -97
  46. package/dist/components/scenes/iam/ResetPasswordForm.svelte +83 -85
  47. package/dist/components/scenes/iam/UserProfile.svelte +9 -5
  48. package/dist/components/scenes/iam/auth-error-message.d.ts +2 -4
  49. package/dist/components/scenes/index.d.ts +1 -0
  50. package/dist/components/scenes/index.js +1 -0
  51. package/dist/components/scenes/storage/FileUpload.svelte +1 -1
  52. package/dist/components/scenes/storage/ImageUpload.svelte +1 -0
  53. package/dist/components/scenes/types.d.ts +9 -9
  54. package/dist/guide-storage.d.ts +6 -0
  55. package/dist/guide-storage.js +67 -0
  56. package/dist/guide-types.d.ts +78 -0
  57. package/dist/guide-types.js +1 -0
  58. package/dist/guide.d.ts +3 -0
  59. package/dist/guide.js +3 -0
  60. package/dist/guide.svelte.d.ts +8 -0
  61. package/dist/guide.svelte.js +450 -0
  62. package/dist/i18n.svelte.d.ts +2 -1
  63. package/dist/i18n.svelte.js +2 -1
  64. package/dist/index.d.ts +2 -0
  65. package/dist/index.js +2 -0
  66. package/dist/internal/form-context.d.ts +12 -0
  67. package/dist/internal/form-context.js +9 -0
  68. package/dist/internal/form-validation.svelte.d.ts +16 -0
  69. package/dist/internal/form-validation.svelte.js +76 -0
  70. package/dist/internal/locale.d.ts +2 -0
  71. package/dist/internal/locale.js +22 -0
  72. package/dist/messages/en-US.json +30 -1
  73. package/dist/messages/zh-CN.json +30 -1
  74. package/dist/messages.d.ts +2 -1
  75. package/dist/messages.js +6 -4
  76. package/dist/styles/guide.css +88 -0
  77. package/dist/types.d.ts +57 -10
  78. package/package.json +13 -2
  79. package/vite.js +1 -0
@@ -7,29 +7,23 @@
7
7
  -->
8
8
  <script lang='ts'>
9
9
  import type { Snippet } from 'svelte'
10
- import type { DataAttributes, Size } from '../../../types.js'
10
+ import type { DataAttributes, FormLayoutOptions, Size } from '../../../types.js'
11
+ import type { CrudFieldDef } from './crud-controller.svelte.js'
11
12
  import type { CrudDensity, CrudFormVariant } from './crud-types.js'
13
+ import { tick } from 'svelte'
14
+ import { resolveFormText as resolveText } from '../../../internal/form-validation.svelte.js'
12
15
  import { uiM } from '../../../messages.js'
13
16
  import { getDataAttributes } from '../../../utils.js'
14
17
  import Drawer from '../../compounds/Drawer.svelte'
15
18
  import FormField from '../../compounds/FormField.svelte'
19
+ import FormLayout from '../../compounds/FormLayout.svelte'
16
20
  import Modal from '../../compounds/Modal.svelte'
17
21
  import Button from '../../primitives/Button.svelte'
18
22
  import Checkbox from '../../primitives/Checkbox.svelte'
19
23
  import Input from '../../primitives/Input.svelte'
20
24
  import Select from '../../primitives/Select.svelte'
21
25
  import Textarea from '../../primitives/Textarea.svelte'
22
-
23
- type FieldDef = {
24
- id: string
25
- label: string | (() => string)
26
- type: string
27
- readonly?: boolean
28
- options?: Array<{ label: string, value: string | number | boolean }> | (() => Array<{ label: string, value: string | number | boolean }>)
29
- validation?: { required?: boolean, min?: number, max?: number, pattern?: string, message?: string }
30
- placeholder?: string | (() => string)
31
- order?: number
32
- }
26
+ import { getCrudValidationIssue } from './crud-form-validation.js'
33
27
 
34
28
  let {
35
29
  open = $bindable(false),
@@ -47,6 +41,9 @@
47
41
  modalWidth,
48
42
  modalHeight,
49
43
  submitting = false,
44
+ columns = 1,
45
+ labelPosition = 'top',
46
+ labelWidth = '8rem',
50
47
  error = '',
51
48
  onsubmit,
52
49
  onclose,
@@ -57,7 +54,7 @@
57
54
  }: {
58
55
  open?: boolean
59
56
  mode?: 'create' | 'edit'
60
- fields?: FieldDef[]
57
+ fields?: CrudFieldDef[]
61
58
  formData?: Record<string, unknown>
62
59
  density?: CrudDensity
63
60
  title?: string
@@ -76,14 +73,17 @@
76
73
  editFormExtra?: Snippet<[Record<string, unknown> | null, 'create' | 'edit']>
77
74
  editingItem?: Record<string, unknown> | null
78
75
  class?: string
79
- } & DataAttributes = $props()
76
+ } & FormLayoutOptions & DataAttributes = $props()
80
77
 
81
78
  const dataAttributes = $derived(getDataAttributes(restProps))
82
- function resolveText(text: string | (() => string)): string {
83
- return typeof text === 'function' ? text() : text
84
- }
85
-
86
- function resolveOptions(options?: FieldDef['options']): Array<{ label: string, value: string | number | boolean }> {
79
+ const panelId = $props.id()
80
+ let formElement: HTMLFormElement | undefined = $state()
81
+ let attempted = $state(false)
82
+ $effect(() => {
83
+ if (!open)
84
+ attempted = false
85
+ })
86
+ function resolveOptions(options?: CrudFieldDef['options']): Array<{ label: string, value: string | number | boolean }> {
87
87
  if (!options)
88
88
  return []
89
89
  return typeof options === 'function' ? options() : options
@@ -92,15 +92,27 @@
92
92
  const sortedFields = $derived(
93
93
  [...fields].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
94
94
  )
95
+ const fieldErrors = $derived.by(() => {
96
+ const errors: Record<string, string> = {}
97
+ if (!attempted)
98
+ return errors
99
+ for (const field of sortedFields) {
100
+ if (mode === 'edit' && field.readonly)
101
+ continue
102
+ const issue = getCrudValidationIssue(field, formData[field.id])
103
+ if (issue)
104
+ errors[field.id] = field.validation?.message ? resolveText(field.validation.message) : uiM(issue.key, issue.params)
105
+ }
106
+ return errors
107
+ })
95
108
 
96
109
  const isCompact = $derived(density === 'compact')
97
110
  const controlSize = $derived(isCompact ? 'xs' : 'sm')
98
- const formClass = $derived(isCompact ? 'space-y-3' : 'space-y-4')
99
111
  const extraClass = $derived(isCompact ? 'border-t border-base-content/5 pt-3' : 'border-t border-base-content/5 pt-4')
100
112
  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')
101
113
  const multiSelectLabelClass = $derived(isCompact ? 'inline-flex items-center gap-1.5 cursor-pointer' : 'inline-flex items-center gap-2 cursor-pointer')
102
114
  const multiSelectTextClass = $derived(isCompact ? 'text-xs text-base-content' : 'text-sm text-base-content')
103
- const drawerFormClass = $derived(isCompact ? 'space-y-3 pb-16' : 'space-y-4 pb-20')
115
+ const drawerFormClass = $derived(isCompact ? 'pb-16' : 'pb-20')
104
116
  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')
105
117
 
106
118
  function handleClose() {
@@ -110,6 +122,14 @@
110
122
 
111
123
  async function handleSubmit(e: Event) {
112
124
  e.preventDefault()
125
+ if (submitting)
126
+ return
127
+ attempted = true
128
+ if (Object.keys(fieldErrors).length) {
129
+ await tick()
130
+ formElement?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus()
131
+ return
132
+ }
113
133
  await onsubmit?.(formData)
114
134
  }
115
135
 
@@ -120,8 +140,6 @@
120
140
  case 'tel': return 'tel'
121
141
  case 'password': return 'password'
122
142
  case 'number': return 'number'
123
- case 'date': return 'text'
124
- case 'datetime': return 'text'
125
143
  default: return 'text'
126
144
  }
127
145
  }
@@ -143,113 +161,122 @@
143
161
  </script>
144
162
 
145
163
  {#snippet formFields()}
146
- {#if error}
147
- <div {...dataAttributes} class='p-3 bg-error/10 border border-error/20 rounded-lg text-sm text-error flex items-center gap-2'>
148
- <span class='icon-[tabler--alert-circle] size-4 shrink-0'></span>
149
- <span>{error}</span>
150
- </div>
151
- {/if}
164
+ <FormLayout {columns} {labelPosition} {labelWidth} class={isCompact ? '[--hai-form-gap:0.75rem]' : ''}>
165
+ {#if error}
166
+ <div {...dataAttributes} role='alert' class='p-3 bg-error/10 border border-error/20 rounded-lg text-sm text-error flex items-center gap-2'>
167
+ <span class='icon-[tabler--alert-circle] size-4 shrink-0'></span>
168
+ <span>{error}</span>
169
+ </div>
170
+ {/if}
152
171
 
153
- {#each sortedFields as field (field.id)}
154
- {@const isReadonly = field.readonly && mode === 'edit'}
155
- {@const isRequired = field.validation?.required}
156
- {@const fieldValue = formData[field.id]}
157
- {@const placeholder = field.placeholder ? resolveText(field.placeholder) : ''}
172
+ {#each sortedFields as field (field.id)}
173
+ {@const isReadonly = field.readonly && mode === 'edit'}
174
+ {@const isRequired = field.validation?.required}
175
+ {@const fieldValue = formData[field.id]}
176
+ {@const placeholder = field.placeholder ? resolveText(field.placeholder) : ''}
177
+ {@const controlId = `${panelId}-${field.id}`}
178
+ <FormField
179
+ for={controlId}
180
+ label={resolveText(field.label)}
181
+ required={isRequired}
182
+ error={fieldErrors[field.id]}
183
+ colSpan={field.colSpan ?? (['textarea', 'multi-select', 'date-range', 'custom'].includes(field.type) ? 2 : 'auto')}
184
+ data-field={field.id}
185
+ >
158
186
 
159
- {#if field.type === 'textarea'}
160
- <FormField label={resolveText(field.label)} required={isRequired}>
161
- <Textarea
162
- id={field.id}
163
- size={controlSize}
164
- value={String(fieldValue ?? '')}
165
- {placeholder}
166
- disabled={submitting || isReadonly}
167
- required={isRequired}
168
- oninput={e => updateField(field.id, (e.target as HTMLTextAreaElement).value)}
169
- />
170
- </FormField>
187
+ {#if field.type === 'textarea'}
188
+ <Textarea
189
+ id={controlId}
190
+ name={field.id}
191
+ size={controlSize}
192
+ value={String(fieldValue ?? '')}
193
+ {placeholder}
194
+ disabled={submitting || isReadonly}
195
+ required={isRequired}
196
+ minlength={field.validation?.min}
197
+ maxlength={field.validation?.max}
198
+ oninput={e => updateField(field.id, (e.target as HTMLTextAreaElement).value)}
199
+ />
171
200
 
172
- {:else if field.type === 'select' || field.type === 'radio'}
173
- {@const opts = resolveOptions(field.options)}
174
- <FormField label={resolveText(field.label)} required={isRequired}>
175
- <Select
176
- id={field.id}
177
- size={controlSize}
178
- value={String(fieldValue ?? '')}
179
- disabled={submitting || isReadonly}
180
- options={[
181
- { value: '', label: placeholder || uiM('crud_filter_all') },
182
- ...opts.map(opt => ({ value: String(opt.value), label: opt.label })),
183
- ]}
184
- onchange={value => updateField(field.id, value)}
185
- />
186
- </FormField>
201
+ {:else if field.type === 'select' || field.type === 'radio'}
202
+ {@const opts = resolveOptions(field.options)}
203
+ <Select
204
+ id={controlId}
205
+ name={field.id}
206
+ required={isRequired}
207
+ size={controlSize}
208
+ value={String(fieldValue ?? '')}
209
+ disabled={submitting || isReadonly}
210
+ options={[
211
+ { value: '', label: placeholder || uiM('crud_filter_all') },
212
+ ...opts.map(opt => ({ value: String(opt.value), label: opt.label })),
213
+ ]}
214
+ onchange={value => updateField(field.id, opts.find(opt => String(opt.value) === value)?.value ?? value)}
215
+ />
187
216
 
188
- {:else if field.type === 'multi-select'}
189
- {@const opts = resolveOptions(field.options)}
190
- {@const selectedValues = ((fieldValue ?? []) as Array<string | number | boolean>).map(String)}
191
- <FormField label={resolveText(field.label)} required={isRequired}>
192
- <div class={multiSelectWrapClass}>
193
- {#each opts as opt (String(opt.value))}
194
- <label class={multiSelectLabelClass}>
195
- <Checkbox
196
- size={controlSize}
197
- checked={selectedValues.includes(String(opt.value))}
198
- onchange={() => toggleMultiSelect(field.id, opt.value)}
199
- disabled={submitting || isReadonly}
200
- />
201
- <span class={multiSelectTextClass}>{opt.label}</span>
202
- </label>
203
- {/each}
204
- {#if opts.length === 0}
205
- <span class={multiSelectTextClass}>{uiM('crud_no_data')}</span>
206
- {/if}
207
- </div>
208
- </FormField>
217
+ {:else if field.type === 'multi-select'}
218
+ {@const opts = resolveOptions(field.options)}
219
+ {@const selectedValues = ((fieldValue ?? []) as Array<string | number | boolean>).map(String)}
220
+ <div id={controlId} role='group' tabindex='-1' aria-label={resolveText(field.label)} class={multiSelectWrapClass} data-form-wide>
221
+ {#each opts as opt (String(opt.value))}
222
+ <div class={multiSelectLabelClass}>
223
+ <Checkbox
224
+ label={opt.label}
225
+ size={controlSize}
226
+ checked={selectedValues.includes(String(opt.value))}
227
+ onchange={() => toggleMultiSelect(field.id, opt.value)}
228
+ disabled={submitting || isReadonly}
229
+ />
230
+ </div>
231
+ {/each}
232
+ {#if opts.length === 0}
233
+ <span class={multiSelectTextClass}>{uiM('crud_no_data')}</span>
234
+ {/if}
235
+ </div>
209
236
 
210
- {:else if field.type === 'boolean' || field.type === 'checkbox'}
211
- <FormField label={resolveText(field.label)}>
212
- <Checkbox
213
- size={controlSize}
214
- checked={Boolean(fieldValue)}
215
- onchange={(checked: boolean) => updateField(field.id, checked)}
216
- disabled={submitting || isReadonly}
217
- />
218
- </FormField>
237
+ {:else if field.type === 'boolean' || field.type === 'checkbox'}
238
+ <Checkbox
239
+ id={controlId}
240
+ size={controlSize}
241
+ checked={Boolean(fieldValue)}
242
+ onchange={(checked: boolean) => updateField(field.id, checked)}
243
+ disabled={submitting || isReadonly}
244
+ />
219
245
 
220
- {:else}
221
- <FormField label={resolveText(field.label)} required={isRequired}>
222
- <Input
223
- id={field.id}
224
- type={getInputType(field.type)}
225
- size={controlSize}
226
- value={String(fieldValue ?? '')}
227
- {placeholder}
228
- disabled={submitting || isReadonly}
229
- required={isRequired}
230
- pattern={field.validation?.pattern}
231
- minlength={field.type !== 'number' ? field.validation?.min : undefined}
232
- maxlength={field.type !== 'number' ? field.validation?.max : undefined}
233
- min={field.type === 'number' ? field.validation?.min : undefined}
234
- max={field.type === 'number' ? field.validation?.max : undefined}
235
- oninput={e => updateField(field.id, field.type === 'number' ? Number((e.currentTarget as HTMLInputElement).value) : (e.currentTarget as HTMLInputElement).value)}
236
- />
246
+ {:else}
247
+ <Input
248
+ id={controlId}
249
+ name={field.id}
250
+ type={getInputType(field.type)}
251
+ size={controlSize}
252
+ value={String(fieldValue ?? '')}
253
+ {placeholder}
254
+ disabled={submitting || isReadonly}
255
+ required={isRequired}
256
+ pattern={field.validation?.pattern}
257
+ minlength={field.type !== 'number' ? field.validation?.min : undefined}
258
+ maxlength={field.type !== 'number' ? field.validation?.max : undefined}
259
+ min={field.type === 'number' ? field.validation?.min : undefined}
260
+ max={field.type === 'number' ? field.validation?.max : undefined}
261
+ oninput={e => updateField(field.id, field.type === 'number' && e.currentTarget.value !== '' ? Number(e.currentTarget.value) : e.currentTarget.value)}
262
+ />
263
+ {/if}
237
264
  </FormField>
238
- {/if}
239
- {/each}
265
+ {/each}
240
266
 
241
- {#if editFormExtra}
242
- <div class={extraClass}>
243
- {@render editFormExtra(editingItem, mode)}
244
- </div>
245
- {/if}
267
+ {#if editFormExtra}
268
+ <div class={extraClass}>
269
+ {@render editFormExtra(editingItem, mode)}
270
+ </div>
271
+ {/if}
272
+ </FormLayout>
246
273
  {/snippet}
247
274
 
248
275
  {#snippet footerActions()}
249
276
  <Button variant='ghost' size='sm' onclick={handleClose} disabled={submitting}>
250
277
  {uiM('crud_cancel')}
251
278
  </Button>
252
- <Button variant='primary' size='sm' onclick={handleSubmit} disabled={submitting}>
279
+ <Button variant='primary' size='sm' onclick={() => formElement?.requestSubmit()} disabled={submitting}>
253
280
  {#if submitting}
254
281
  <span class='loading loading-spinner loading-xs mr-2'></span>
255
282
  {/if}
@@ -268,7 +295,9 @@
268
295
  closeOnBackdrop={false}
269
296
  onclose={handleClose}
270
297
  >
271
- <form onsubmit={handleSubmit} class={formClass}>
298
+ <form bind:this={formElement} novalidate onsubmit={handleSubmit}>
299
+ <!-- 底部按钮在表单外;保留原生提交控件,使多字段表单支持回车提交。 -->
300
+ <input type='submit' class='hidden' tabindex='-1' aria-hidden='true' value={uiM('crud_save')} />
272
301
  {@render formFields()}
273
302
  </form>
274
303
 
@@ -289,7 +318,8 @@
289
318
  onclose={handleClose}
290
319
  closeOnBackdrop={false}
291
320
  >
292
- <form onsubmit={handleSubmit} class={drawerFormClass}>
321
+ <form bind:this={formElement} novalidate onsubmit={handleSubmit} class={drawerFormClass}>
322
+ <input type='submit' class='hidden' tabindex='-1' aria-hidden='true' value={uiM('crud_save')} />
293
323
  {@render formFields()}
294
324
  </form>
295
325
 
@@ -1,32 +1,11 @@
1
1
  import type { Snippet } from 'svelte';
2
- import type { DataAttributes, Size } from '../../../types.js';
2
+ import type { DataAttributes, FormLayoutOptions, Size } from '../../../types.js';
3
+ import type { CrudFieldDef } from './crud-controller.svelte.js';
3
4
  import type { CrudDensity, CrudFormVariant } from './crud-types.js';
4
- type FieldDef = {
5
- id: string;
6
- label: string | (() => string);
7
- type: string;
8
- readonly?: boolean;
9
- options?: Array<{
10
- label: string;
11
- value: string | number | boolean;
12
- }> | (() => Array<{
13
- label: string;
14
- value: string | number | boolean;
15
- }>);
16
- validation?: {
17
- required?: boolean;
18
- min?: number;
19
- max?: number;
20
- pattern?: string;
21
- message?: string;
22
- };
23
- placeholder?: string | (() => string);
24
- order?: number;
25
- };
26
5
  type $$ComponentProps = {
27
6
  open?: boolean;
28
7
  mode?: 'create' | 'edit';
29
- fields?: FieldDef[];
8
+ fields?: CrudFieldDef[];
30
9
  formData?: Record<string, unknown>;
31
10
  density?: CrudDensity;
32
11
  title?: string;
@@ -45,7 +24,7 @@ type $$ComponentProps = {
45
24
  editFormExtra?: Snippet<[Record<string, unknown> | null, 'create' | 'edit']>;
46
25
  editingItem?: Record<string, unknown> | null;
47
26
  class?: string;
48
- } & DataAttributes;
27
+ } & FormLayoutOptions & DataAttributes;
49
28
  /**
50
29
  * CrudEditPanel
51
30
  * CRUD 编辑/新建表单组件,支持抽屉(drawer)与弹窗(modal)两种展示形式
@@ -13,6 +13,7 @@
13
13
  <script lang='ts'>
14
14
  import type { Snippet } from 'svelte'
15
15
  import type { DataAttributes } from '../../../types.js'
16
+ import type { CrudFieldDef } from './crud-controller.svelte.js'
16
17
  import type { CrudDensity, CrudFormConfig, CrudPaginationConfig, CrudToolbarStyle } from './crud-types.js'
17
18
  import type { NavAdapter } from './nav-adapter.js'
18
19
  import { SvelteURLSearchParams } from 'svelte/reactivity'
@@ -33,47 +34,7 @@
33
34
 
34
35
  // ─── 类型定义 ───
35
36
 
36
- /** CRUD 字段定义,决定列表、筛选器和表单的字段行为。 */
37
- type FieldDef = {
38
- /** 字段稳定标识,同时对应数据记录中的属性名。 */
39
- id: string
40
- /** 字段显示文案,允许通过函数延迟读取当前语言。 */
41
- label: string | (() => string)
42
- /** 字段控件类型,最终由表单或筛选器解释。 */
43
- type: string
44
- /** 是否出现在列表中;未设置时默认显示。 */
45
- inList?: boolean
46
- /** 是否出现在筛选器中;只有显式为 true 时显示。 */
47
- filterable?: boolean
48
- /** 编辑时是否禁止修改。 */
49
- readonly?: boolean
50
- /** 是否出现在新建表单中;未设置时默认显示。 */
51
- inCreate?: boolean
52
- /** 是否出现在编辑表单中;未设置时默认显示。 */
53
- inEdit?: boolean
54
- /** 是否出现在详情面板中;未设置时默认显示。 */
55
- inDetail?: boolean
56
- /** 选择类字段的静态选项或动态选项工厂。 */
57
- options?: Array<{ label: string, value: string | number | boolean }> | (() => Array<{ label: string, value: string | number | boolean }>)
58
- /** 表单校验规则。 */
59
- validation?: { required?: boolean, min?: number, max?: number, pattern?: string, message?: string }
60
- /** 列宽 CSS 值。 */
61
- width?: string
62
- /** 单元格对齐方式。 */
63
- align?: 'left' | 'center' | 'right'
64
- /** 列表单元格的自定义渲染函数。 */
65
- render?: (value: unknown, item: Record<string, unknown>) => string
66
- /** 输入控件占位文案。 */
67
- placeholder?: string | (() => string)
68
- /** 日期范围起始值 key。 */
69
- startKey?: string
70
- /** 日期范围结束值 key。 */
71
- endKey?: string
72
- /** 新建记录时使用的默认值。 */
73
- defaultValue?: unknown
74
- /** 字段排序权重,数值越小越靠前。 */
75
- order?: number
76
- }
37
+ type FieldDef = CrudFieldDef
77
38
 
78
39
  // 使用 method shorthand 语法(双向协变)以兼容来自 @h-ai/kit 的 CrudOperations 类型
79
40
  // 详见:https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-6.html#strict-function-types
@@ -911,6 +872,9 @@
911
872
  modalHeight={formModalHeight}
912
873
  {submitting}
913
874
  error={formError}
875
+ columns={form?.columns}
876
+ labelPosition={form?.labelPosition}
877
+ labelWidth={form?.labelWidth}
914
878
  onsubmit={handleSubmit}
915
879
  onclose={closePanel}
916
880
  {editFormExtra}
@@ -1,60 +1,9 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { DataAttributes } from '../../../types.js';
3
+ import type { CrudFieldDef } from './crud-controller.svelte.js';
3
4
  import type { CrudDensity, CrudFormConfig, CrudPaginationConfig, CrudToolbarStyle } from './crud-types.js';
4
5
  import type { NavAdapter } from './nav-adapter.js';
5
- /** CRUD 字段定义,决定列表、筛选器和表单的字段行为。 */
6
- type FieldDef = {
7
- /** 字段稳定标识,同时对应数据记录中的属性名。 */
8
- id: string;
9
- /** 字段显示文案,允许通过函数延迟读取当前语言。 */
10
- label: string | (() => string);
11
- /** 字段控件类型,最终由表单或筛选器解释。 */
12
- type: string;
13
- /** 是否出现在列表中;未设置时默认显示。 */
14
- inList?: boolean;
15
- /** 是否出现在筛选器中;只有显式为 true 时显示。 */
16
- filterable?: boolean;
17
- /** 编辑时是否禁止修改。 */
18
- readonly?: boolean;
19
- /** 是否出现在新建表单中;未设置时默认显示。 */
20
- inCreate?: boolean;
21
- /** 是否出现在编辑表单中;未设置时默认显示。 */
22
- inEdit?: boolean;
23
- /** 是否出现在详情面板中;未设置时默认显示。 */
24
- inDetail?: boolean;
25
- /** 选择类字段的静态选项或动态选项工厂。 */
26
- options?: Array<{
27
- label: string;
28
- value: string | number | boolean;
29
- }> | (() => Array<{
30
- label: string;
31
- value: string | number | boolean;
32
- }>);
33
- /** 表单校验规则。 */
34
- validation?: {
35
- required?: boolean;
36
- min?: number;
37
- max?: number;
38
- pattern?: string;
39
- message?: string;
40
- };
41
- /** 列宽 CSS 值。 */
42
- width?: string;
43
- /** 单元格对齐方式。 */
44
- align?: 'left' | 'center' | 'right';
45
- /** 列表单元格的自定义渲染函数。 */
46
- render?: (value: unknown, item: Record<string, unknown>) => string;
47
- /** 输入控件占位文案。 */
48
- placeholder?: string | (() => string);
49
- /** 日期范围起始值 key。 */
50
- startKey?: string;
51
- /** 日期范围结束值 key。 */
52
- endKey?: string;
53
- /** 新建记录时使用的默认值。 */
54
- defaultValue?: unknown;
55
- /** 字段排序权重,数值越小越靠前。 */
56
- order?: number;
57
- };
6
+ type FieldDef = CrudFieldDef;
58
7
  /** CrudPage 使用的异步资源操作集合。 */
59
8
  type CrudApi = {
60
9
  /** 获取当前分页、筛选和排序结果。 */
@@ -13,6 +13,7 @@
13
13
  * @module crud-controller
14
14
  */
15
15
  import type { PaginatedResult } from '@h-ai/core';
16
+ import type { FormColSpan } from '../../../types.js';
16
17
  import type { NavAdapter } from './nav-adapter.js';
17
18
  /** 字段展示形式;与 `CrudEditPanel` / `CrudFilterBar` 支持的类型对齐,保留 `string` 以兼容 `@h-ai/kit`。 */
18
19
  export type CrudFieldType = 'text' | 'string' | 'textarea' | 'select' | 'radio' | 'multi-select' | 'boolean' | 'checkbox' | 'email' | 'url' | 'tel' | 'password' | 'number' | 'date' | 'datetime' | 'date-range' | 'custom';
@@ -51,8 +52,10 @@ export interface CrudFieldDef {
51
52
  min?: number;
52
53
  max?: number;
53
54
  pattern?: string;
54
- message?: string;
55
+ message?: CrudText;
55
56
  };
57
+ /** 表单占用列数,默认 auto;多行文本、多选等自动占两列。 */
58
+ colSpan?: FormColSpan;
56
59
  /** 列宽(CSS)。 */
57
60
  width?: string;
58
61
  /** 列对齐。 */
@@ -0,0 +1,4 @@
1
+ /** CRUD 客户端字段校验;仅返回消息键,避免错误文案锁定在提交时的语言。 */
2
+ import type { FormValidationIssue } from '../../../internal/form-validation.svelte.js';
3
+ import type { CrudFieldDef } from './crud-controller.svelte.js';
4
+ export declare function getCrudValidationIssue(field: CrudFieldDef, value: unknown): FormValidationIssue | undefined;
@@ -0,0 +1,39 @@
1
+ export function getCrudValidationIssue(field, value) {
2
+ const rules = field.validation;
3
+ const empty = value === undefined || value === null || (typeof value === 'string' && !value.trim()) || (Array.isArray(value) && value.length === 0);
4
+ if (rules?.required && (empty || (value === false && (field.type === 'boolean' || field.type === 'checkbox'))))
5
+ return { key: 'form_required' };
6
+ if (empty)
7
+ return undefined;
8
+ if (field.type === 'number') {
9
+ const number = typeof value === 'number' ? value : Number(value);
10
+ if (!Number.isFinite(number))
11
+ return { key: 'form_number' };
12
+ if (rules?.min !== undefined && number < rules.min)
13
+ return { key: 'form_min', params: { min: rules.min } };
14
+ if (rules?.max !== undefined && number > rules.max)
15
+ return { key: 'form_max', params: { max: rules.max } };
16
+ }
17
+ else if (typeof value === 'string') {
18
+ if (rules?.min !== undefined && value.length < rules.min)
19
+ return { key: 'form_min_length', params: { min: rules.min } };
20
+ if (rules?.max !== undefined && value.length > rules.max)
21
+ return { key: 'form_max_length', params: { max: rules.max } };
22
+ }
23
+ if (field.type === 'email' && !/^[\w.!#$%&'*+/=?^`{|}~-]+@[a-z\d](?:[a-z\d-]*[a-z\d])?(?:\.[a-z\d](?:[a-z\d-]*[a-z\d])?)*$/i.test(String(value)))
24
+ return { key: 'form_email' };
25
+ if (field.type === 'url') {
26
+ if (!URL.canParse(String(value)))
27
+ return { key: 'form_url' };
28
+ }
29
+ if (rules?.pattern) {
30
+ try {
31
+ if (!new RegExp(`^(?:${rules.pattern})$`, 'v').test(String(value)))
32
+ return { key: 'form_pattern' };
33
+ }
34
+ catch {
35
+ return { key: 'form_pattern' };
36
+ }
37
+ }
38
+ return undefined;
39
+ }