@h-ai/ui 0.1.0-alpha.62 → 0.1.0-alpha.64

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 (59) hide show
  1. package/README.en.md +37 -7
  2. package/README.ja.md +37 -7
  3. package/README.md +37 -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/Drawer.svelte +5 -1
  11. package/dist/components/compounds/Form.svelte +10 -6
  12. package/dist/components/compounds/FormField.svelte +78 -13
  13. package/dist/components/compounds/FormLayout.svelte +59 -0
  14. package/dist/components/compounds/FormLayout.svelte.d.ts +4 -0
  15. package/dist/components/compounds/Modal.svelte +5 -0
  16. package/dist/components/compounds/TagInput.svelte +1 -1
  17. package/dist/components/compounds/index.d.ts +1 -0
  18. package/dist/components/compounds/index.js +1 -0
  19. package/dist/components/primitives/BareInput.svelte +7 -1
  20. package/dist/components/primitives/Checkbox.svelte +8 -2
  21. package/dist/components/primitives/Input.svelte +23 -11
  22. package/dist/components/primitives/Select.svelte +90 -18
  23. package/dist/components/primitives/Textarea.svelte +28 -13
  24. package/dist/components/primitives/Textarea.svelte.d.ts +1 -1
  25. package/dist/components/scenes/app/FeedbackModal.svelte +29 -19
  26. package/dist/components/scenes/app/FeedbackModal.svelte.d.ts +2 -2
  27. package/dist/components/scenes/crud/CrudEditPanel.svelte +158 -118
  28. package/dist/components/scenes/crud/CrudEditPanel.svelte.d.ts +4 -25
  29. package/dist/components/scenes/crud/CrudPage.svelte +5 -41
  30. package/dist/components/scenes/crud/CrudPage.svelte.d.ts +2 -53
  31. package/dist/components/scenes/crud/crud-controller.svelte.d.ts +4 -1
  32. package/dist/components/scenes/crud/crud-form-validation.d.ts +4 -0
  33. package/dist/components/scenes/crud/crud-form-validation.js +39 -0
  34. package/dist/components/scenes/crud/crud-types.d.ts +9 -42
  35. package/dist/components/scenes/iam/AuthPanel.svelte +55 -47
  36. package/dist/components/scenes/iam/ChangePasswordForm.svelte +66 -59
  37. package/dist/components/scenes/iam/ForgotPasswordForm.svelte +89 -84
  38. package/dist/components/scenes/iam/LoginForm.svelte +134 -132
  39. package/dist/components/scenes/iam/PasswordInput.svelte +30 -10
  40. package/dist/components/scenes/iam/PasswordInput.svelte.d.ts +3 -3
  41. package/dist/components/scenes/iam/RegisterForm.svelte +95 -97
  42. package/dist/components/scenes/iam/ResetPasswordForm.svelte +83 -85
  43. package/dist/components/scenes/iam/UserProfile.svelte +14 -7
  44. package/dist/components/scenes/storage/FileUpload.svelte +1 -1
  45. package/dist/components/scenes/storage/ImageUpload.svelte +1 -0
  46. package/dist/components/scenes/types.d.ts +12 -10
  47. package/dist/i18n.svelte.d.ts +2 -1
  48. package/dist/i18n.svelte.js +2 -1
  49. package/dist/internal/form-context.d.ts +18 -0
  50. package/dist/internal/form-context.js +13 -0
  51. package/dist/internal/form-validation.svelte.d.ts +16 -0
  52. package/dist/internal/form-validation.svelte.js +86 -0
  53. package/dist/internal/locale.d.ts +2 -0
  54. package/dist/internal/locale.js +22 -0
  55. package/dist/messages/en-US.json +13 -1
  56. package/dist/messages/zh-CN.json +13 -1
  57. package/dist/messages.js +4 -3
  58. package/dist/types.d.ts +57 -10
  59. 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,20 @@
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
+ let touched = $state<Record<string, boolean>>({})
83
+ $effect(() => {
84
+ if (!open) {
85
+ attempted = false
86
+ touched = {}
87
+ }
88
+ })
89
+ function resolveOptions(options?: CrudFieldDef['options']): Array<{ label: string, value: string | number | boolean }> {
87
90
  if (!options)
88
91
  return []
89
92
  return typeof options === 'function' ? options() : options
@@ -92,15 +95,25 @@
92
95
  const sortedFields = $derived(
93
96
  [...fields].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
94
97
  )
98
+ const fieldErrors = $derived.by(() => {
99
+ const errors: Record<string, string> = {}
100
+ for (const field of sortedFields) {
101
+ if ((!attempted && !touched[field.id]) || (mode === 'edit' && field.readonly))
102
+ continue
103
+ const issue = getCrudValidationIssue(field, formData[field.id])
104
+ if (issue)
105
+ errors[field.id] = field.validation?.message ? resolveText(field.validation.message) : uiM(issue.key, issue.params)
106
+ }
107
+ return errors
108
+ })
95
109
 
96
110
  const isCompact = $derived(density === 'compact')
97
111
  const controlSize = $derived(isCompact ? 'xs' : 'sm')
98
- const formClass = $derived(isCompact ? 'space-y-3' : 'space-y-4')
99
112
  const extraClass = $derived(isCompact ? 'border-t border-base-content/5 pt-3' : 'border-t border-base-content/5 pt-4')
100
113
  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
114
  const multiSelectLabelClass = $derived(isCompact ? 'inline-flex items-center gap-1.5 cursor-pointer' : 'inline-flex items-center gap-2 cursor-pointer')
102
115
  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')
116
+ const drawerFormClass = $derived(isCompact ? 'pb-16' : 'pb-20')
104
117
  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
118
 
106
119
  function handleClose() {
@@ -110,6 +123,14 @@
110
123
 
111
124
  async function handleSubmit(e: Event) {
112
125
  e.preventDefault()
126
+ if (submitting)
127
+ return
128
+ attempted = true
129
+ if (Object.keys(fieldErrors).length) {
130
+ await tick()
131
+ formElement?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus()
132
+ return
133
+ }
113
134
  await onsubmit?.(formData)
114
135
  }
115
136
 
@@ -120,8 +141,6 @@
120
141
  case 'tel': return 'tel'
121
142
  case 'password': return 'password'
122
143
  case 'number': return 'number'
123
- case 'date': return 'text'
124
- case 'datetime': return 'text'
125
144
  default: return 'text'
126
145
  }
127
146
  }
@@ -130,6 +149,15 @@
130
149
  formData = { ...formData, [fieldId]: value }
131
150
  }
132
151
 
152
+ function handleFieldBlur(event: FocusEvent) {
153
+ if (!(event.target instanceof HTMLElement))
154
+ return
155
+ const field = event.target.closest<HTMLElement>('[data-field]')
156
+ if (!field?.dataset.field || (event.relatedTarget instanceof Node && field.contains(event.relatedTarget)))
157
+ return
158
+ touched = { ...touched, [field.dataset.field]: true }
159
+ }
160
+
133
161
  function toggleMultiSelect(fieldId: string, optValue: string | number | boolean) {
134
162
  const current = (formData[fieldId] as Array<string | number | boolean>) ?? []
135
163
  const strVal = String(optValue)
@@ -143,113 +171,122 @@
143
171
  </script>
144
172
 
145
173
  {#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}
174
+ <FormLayout {columns} {labelPosition} {labelWidth} class={isCompact ? '[--hai-form-gap:0.75rem]' : ''}>
175
+ {#if error}
176
+ <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'>
177
+ <span class='icon-[tabler--alert-circle] size-4 shrink-0'></span>
178
+ <span>{error}</span>
179
+ </div>
180
+ {/if}
152
181
 
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) : ''}
182
+ {#each sortedFields as field (field.id)}
183
+ {@const isReadonly = field.readonly && mode === 'edit'}
184
+ {@const isRequired = field.validation?.required}
185
+ {@const fieldValue = formData[field.id]}
186
+ {@const placeholder = field.placeholder ? resolveText(field.placeholder) : ''}
187
+ {@const controlId = `${panelId}-${field.id}`}
188
+ <FormField
189
+ for={controlId}
190
+ label={resolveText(field.label)}
191
+ required={isRequired}
192
+ error={fieldErrors[field.id]}
193
+ colSpan={field.colSpan ?? (['textarea', 'multi-select', 'date-range', 'custom'].includes(field.type) ? 2 : 'auto')}
194
+ data-field={field.id}
195
+ >
158
196
 
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>
197
+ {#if field.type === 'textarea'}
198
+ <Textarea
199
+ id={controlId}
200
+ name={field.id}
201
+ size={controlSize}
202
+ value={String(fieldValue ?? '')}
203
+ {placeholder}
204
+ disabled={submitting || isReadonly}
205
+ required={isRequired}
206
+ minlength={field.validation?.min}
207
+ maxlength={field.validation?.max}
208
+ oninput={e => updateField(field.id, (e.target as HTMLTextAreaElement).value)}
209
+ />
171
210
 
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>
211
+ {:else if field.type === 'select' || field.type === 'radio'}
212
+ {@const opts = resolveOptions(field.options)}
213
+ <Select
214
+ id={controlId}
215
+ name={field.id}
216
+ required={isRequired}
217
+ size={controlSize}
218
+ value={String(fieldValue ?? '')}
219
+ disabled={submitting || isReadonly}
220
+ options={[
221
+ { value: '', label: placeholder || uiM('crud_filter_all') },
222
+ ...opts.map(opt => ({ value: String(opt.value), label: opt.label })),
223
+ ]}
224
+ onchange={value => updateField(field.id, opts.find(opt => String(opt.value) === value)?.value ?? value)}
225
+ />
187
226
 
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>
227
+ {:else if field.type === 'multi-select'}
228
+ {@const opts = resolveOptions(field.options)}
229
+ {@const selectedValues = ((fieldValue ?? []) as Array<string | number | boolean>).map(String)}
230
+ <div id={controlId} role='group' tabindex='-1' aria-label={resolveText(field.label)} class={multiSelectWrapClass} data-form-wide>
231
+ {#each opts as opt (String(opt.value))}
232
+ <div class={multiSelectLabelClass}>
233
+ <Checkbox
234
+ label={opt.label}
235
+ size={controlSize}
236
+ checked={selectedValues.includes(String(opt.value))}
237
+ onchange={() => toggleMultiSelect(field.id, opt.value)}
238
+ disabled={submitting || isReadonly}
239
+ />
240
+ </div>
241
+ {/each}
242
+ {#if opts.length === 0}
243
+ <span class={multiSelectTextClass}>{uiM('crud_no_data')}</span>
244
+ {/if}
245
+ </div>
209
246
 
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>
247
+ {:else if field.type === 'boolean' || field.type === 'checkbox'}
248
+ <Checkbox
249
+ id={controlId}
250
+ size={controlSize}
251
+ checked={Boolean(fieldValue)}
252
+ onchange={(checked: boolean) => updateField(field.id, checked)}
253
+ disabled={submitting || isReadonly}
254
+ />
219
255
 
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
- />
256
+ {:else}
257
+ <Input
258
+ id={controlId}
259
+ name={field.id}
260
+ type={getInputType(field.type)}
261
+ size={controlSize}
262
+ value={String(fieldValue ?? '')}
263
+ {placeholder}
264
+ disabled={submitting || isReadonly}
265
+ required={isRequired}
266
+ pattern={field.validation?.pattern}
267
+ minlength={field.type !== 'number' ? field.validation?.min : undefined}
268
+ maxlength={field.type !== 'number' ? field.validation?.max : undefined}
269
+ min={field.type === 'number' ? field.validation?.min : undefined}
270
+ max={field.type === 'number' ? field.validation?.max : undefined}
271
+ oninput={e => updateField(field.id, field.type === 'number' && e.currentTarget.value !== '' ? Number(e.currentTarget.value) : e.currentTarget.value)}
272
+ />
273
+ {/if}
237
274
  </FormField>
238
- {/if}
239
- {/each}
275
+ {/each}
240
276
 
241
- {#if editFormExtra}
242
- <div class={extraClass}>
243
- {@render editFormExtra(editingItem, mode)}
244
- </div>
245
- {/if}
277
+ {#if editFormExtra}
278
+ <div class={extraClass}>
279
+ {@render editFormExtra(editingItem, mode)}
280
+ </div>
281
+ {/if}
282
+ </FormLayout>
246
283
  {/snippet}
247
284
 
248
285
  {#snippet footerActions()}
249
286
  <Button variant='ghost' size='sm' onclick={handleClose} disabled={submitting}>
250
287
  {uiM('crud_cancel')}
251
288
  </Button>
252
- <Button variant='primary' size='sm' onclick={handleSubmit} disabled={submitting}>
289
+ <Button variant='primary' size='sm' onclick={() => formElement?.requestSubmit()} disabled={submitting}>
253
290
  {#if submitting}
254
291
  <span class='loading loading-spinner loading-xs mr-2'></span>
255
292
  {/if}
@@ -268,7 +305,9 @@
268
305
  closeOnBackdrop={false}
269
306
  onclose={handleClose}
270
307
  >
271
- <form onsubmit={handleSubmit} class={formClass}>
308
+ <form bind:this={formElement} novalidate onsubmit={handleSubmit} onfocusout={handleFieldBlur}>
309
+ <!-- 底部按钮在表单外;保留原生提交控件,使多字段表单支持回车提交。 -->
310
+ <input type='submit' class='hidden' tabindex='-1' aria-hidden='true' value={uiM('crud_save')} />
272
311
  {@render formFields()}
273
312
  </form>
274
313
 
@@ -289,7 +328,8 @@
289
328
  onclose={handleClose}
290
329
  closeOnBackdrop={false}
291
330
  >
292
- <form onsubmit={handleSubmit} class={drawerFormClass}>
331
+ <form bind:this={formElement} novalidate onsubmit={handleSubmit} onfocusout={handleFieldBlur} class={drawerFormClass}>
332
+ <input type='submit' class='hidden' tabindex='-1' aria-hidden='true' value={uiM('crud_save')} />
293
333
  {@render formFields()}
294
334
  </form>
295
335
 
@@ -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}