@h-ai/ui 0.1.0-alpha.62 → 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 (57) hide show
  1. package/README.en.md +33 -7
  2. package/README.ja.md +33 -7
  3. package/README.md +33 -7
  4. package/auto-import.d.ts +1 -0
  5. package/auto-import.js +1 -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/iam/AuthPanel.svelte +55 -47
  34. package/dist/components/scenes/iam/ChangePasswordForm.svelte +57 -59
  35. package/dist/components/scenes/iam/ForgotPasswordForm.svelte +89 -84
  36. package/dist/components/scenes/iam/LoginForm.svelte +134 -132
  37. package/dist/components/scenes/iam/PasswordInput.svelte +25 -7
  38. package/dist/components/scenes/iam/PasswordInput.svelte.d.ts +2 -2
  39. package/dist/components/scenes/iam/RegisterForm.svelte +95 -97
  40. package/dist/components/scenes/iam/ResetPasswordForm.svelte +83 -85
  41. package/dist/components/scenes/iam/UserProfile.svelte +9 -5
  42. package/dist/components/scenes/storage/FileUpload.svelte +1 -1
  43. package/dist/components/scenes/storage/ImageUpload.svelte +1 -0
  44. package/dist/components/scenes/types.d.ts +9 -9
  45. package/dist/i18n.svelte.d.ts +2 -1
  46. package/dist/i18n.svelte.js +2 -1
  47. package/dist/internal/form-context.d.ts +12 -0
  48. package/dist/internal/form-context.js +9 -0
  49. package/dist/internal/form-validation.svelte.d.ts +16 -0
  50. package/dist/internal/form-validation.svelte.js +76 -0
  51. package/dist/internal/locale.d.ts +2 -0
  52. package/dist/internal/locale.js +22 -0
  53. package/dist/messages/en-US.json +13 -1
  54. package/dist/messages/zh-CN.json +13 -1
  55. package/dist/messages.js +4 -3
  56. package/dist/types.d.ts +57 -10
  57. package/package.json +7 -2
@@ -12,6 +12,8 @@
12
12
  />
13
13
  -->
14
14
  <script lang='ts'>
15
+ import FormField from '../../compounds/FormField.svelte'
16
+ import FormLayout from '../../compounds/FormLayout.svelte'
15
17
  import Modal from '../../compounds/Modal.svelte'
16
18
  import Button from '../../primitives/Button.svelte'
17
19
  import Input from '../../primitives/Input.svelte'
@@ -47,18 +49,18 @@
47
49
  }
48
50
  }
49
51
 
50
- import type { DataAttributes } from '../../../types.js'
52
+ import type { DataAttributes, FormLayoutOptions } from '../../../types.js'
51
53
  import { uiM } from '../../../messages.js'
52
54
  import { getDataAttributes } from '../../../utils.js'
53
55
 
54
- interface Props {
56
+ interface Props extends FormLayoutOptions {
55
57
  open?: boolean
56
58
  labels?: FeedbackLabels
57
59
  onsubmit?: (data: FeedbackData) => Promise<void>
58
60
  class?: string
59
61
  }
60
62
 
61
- let { open = $bindable(false), labels = {}, onsubmit, class: className = '', ...restProps }: Props & DataAttributes = $props()
63
+ let { open = $bindable(false), labels = {}, columns = 1, labelPosition = 'top', labelWidth = '8rem', onsubmit, class: className = '', ...restProps }: Props & DataAttributes = $props()
62
64
 
63
65
  const dataAttributes = $derived(getDataAttributes(restProps))
64
66
  // 文案优先使用传入的 labels,缺省回退到内置消息 uiM(...)
@@ -67,7 +69,9 @@
67
69
  let description = $state('')
68
70
  let contact = $state('')
69
71
  let loading = $state(false)
70
- let error = $state('')
72
+ let error = $state<'empty' | 'submit' | ''>('')
73
+ let submitError = $state('')
74
+ const errorText = $derived(error === 'empty' ? labels.errorEmpty ?? uiM('feedback_error_empty') : error === 'submit' ? submitError || labels.errorSubmit || uiM('feedback_error_submit') : '')
71
75
 
72
76
  const typeOptions = $derived([
73
77
  { value: 'bug', label: labels.types?.bug ?? uiM('feedback_type_bug') },
@@ -77,8 +81,10 @@
77
81
  ])
78
82
 
79
83
  async function handleSubmit() {
84
+ if (loading)
85
+ return
80
86
  if (!description.trim()) {
81
- error = labels.errorEmpty ?? uiM('feedback_error_empty')
87
+ error = 'empty'
82
88
  return
83
89
  }
84
90
 
@@ -95,7 +101,8 @@
95
101
  resetForm()
96
102
  }
97
103
  catch (e) {
98
- error = e instanceof Error ? e.message : (labels.errorSubmit ?? uiM('feedback_error_submit'))
104
+ submitError = e instanceof Error ? e.message : ''
105
+ error = 'submit'
99
106
  }
100
107
  finally {
101
108
  loading = false
@@ -107,41 +114,44 @@
107
114
  description = ''
108
115
  contact = ''
109
116
  error = ''
117
+ submitError = ''
110
118
  }
111
119
  </script>
112
120
 
113
121
  <Modal {...dataAttributes} class={className} bind:open title={labels.title ?? uiM('feedback_title')}>
114
122
  <p class='text-base-content/70 mb-4'>{labels.description ?? uiM('feedback_description')}</p>
115
123
 
116
- {#if error}
124
+ {#if error === 'submit'}
117
125
  <div class='alert alert-error mb-4'>
118
126
  <span class='icon-[tabler--alert-circle] size-5'></span>
119
- <span>{error}</span>
127
+ <span>{errorText}</span>
120
128
  </div>
121
129
  {/if}
122
130
 
123
- <div class='space-y-4'>
124
- <div class='fieldset'>
125
- <legend class='fieldset-legend'>{labels.typeLabel ?? uiM('feedback_type_label')}</legend>
131
+ <FormLayout {columns} {labelPosition} {labelWidth}>
132
+ <FormField for='feedback-type' label={labels.typeLabel ?? uiM('feedback_type_label')}>
126
133
  <Select
127
134
  id='feedback-type'
128
135
  options={typeOptions}
129
136
  bind:value={feedbackType}
130
137
  />
131
- </div>
138
+ </FormField>
132
139
 
133
- <div class='fieldset'>
134
- <legend class='fieldset-legend'>{labels.contentLabel ?? uiM('feedback_content_label')} <span class='text-error'>*</span></legend>
140
+ <FormField for='feedback-desc' label={labels.contentLabel ?? uiM('feedback_content_label')} required error={error === 'empty' ? errorText : ''}>
135
141
  <Textarea
136
142
  id='feedback-desc'
137
143
  class='h-32'
138
144
  placeholder={labels.contentPlaceholder ?? uiM('feedback_content_placeholder')}
139
145
  bind:value={description}
146
+ disabled={loading}
147
+ oninput={() => {
148
+ if (description.trim())
149
+ error = ''
150
+ }}
140
151
  />
141
- </div>
152
+ </FormField>
142
153
 
143
- <div class='fieldset'>
144
- <legend class='fieldset-legend'>{labels.contactLabel ?? uiM('feedback_contact_label')}</legend>
154
+ <FormField for='feedback-contact' label={labels.contactLabel ?? uiM('feedback_contact_label')}>
145
155
  <Input
146
156
  id='feedback-contact'
147
157
  type='text'
@@ -150,8 +160,8 @@
150
160
  bind:value={contact}
151
161
  autocomplete='email'
152
162
  />
153
- </div>
154
- </div>
163
+ </FormField>
164
+ </FormLayout>
155
165
 
156
166
  {#snippet footer()}
157
167
  <Button variant='ghost' onclick={() => open = false}>
@@ -1,4 +1,4 @@
1
- import type { DataAttributes } from '../../../types.js';
1
+ import type { DataAttributes, FormLayoutOptions } from '../../../types.js';
2
2
  type FeedbackType = 'bug' | 'feature' | 'question' | 'other';
3
3
  interface FeedbackData {
4
4
  type: FeedbackType;
@@ -25,7 +25,7 @@ interface FeedbackLabels {
25
25
  other?: string;
26
26
  };
27
27
  }
28
- interface Props {
28
+ interface Props extends FormLayoutOptions {
29
29
  open?: boolean;
30
30
  labels?: FeedbackLabels;
31
31
  onsubmit?: (data: FeedbackData) => Promise<void>;
@@ -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}