@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
@@ -14,11 +14,16 @@
14
14
  import { uiM } from '../../../messages.js'
15
15
  import { cn, getDataAttributes } from '../../../utils.js'
16
16
  import Alert from '../../compounds/Alert.svelte'
17
+ import FormField from '../../compounds/FormField.svelte'
18
+ import FormLayout from '../../compounds/FormLayout.svelte'
17
19
  import Button from '../../primitives/Button.svelte'
18
20
  import Input from '../../primitives/Input.svelte'
19
21
 
20
22
  const {
21
23
  loading = false,
24
+ columns = 1,
25
+ labelPosition = 'top',
26
+ labelWidth = '8rem',
22
27
  disabled = false,
23
28
  showTitle = false,
24
29
  showDescription = false,
@@ -69,91 +74,91 @@
69
74
  </script>
70
75
 
71
76
  <form {...dataAttributes} class={formClass} onsubmit={handleSubmit}>
72
- <!-- 标题 -->
73
- {#if showTitle}
74
- <h2 class='text-2xl font-semibold text-center mb-4'>{uiM('forgot_password_title')}</h2>
75
- {/if}
76
-
77
- <!-- 描述 -->
78
- {#if showDescription}
79
- <p class='text-center text-base-content/60 text-sm mb-6'>
80
- {uiM('forgot_password_desc')}
81
- </p>
82
- {/if}
83
-
84
- <!-- 自定义头部 -->
85
- {#if header}
86
- <div class='forgot-password-form-header'>
87
- {@render header()}
88
- </div>
89
- {/if}
90
-
91
- <!-- 邮箱输入 -->
92
- {#if mode === 'email'}
93
- <div class='fieldset'>
94
- <legend class='fieldset-legend'>{uiM('forgot_password_email_label')}</legend>
95
- <Input
96
- id='forgot-email'
97
- name='email'
98
- type='email'
99
- placeholder={uiM('forgot_password_email_placeholder')}
100
- bind:value={email}
101
- {disabled}
102
- required
103
- error={errors.email}
104
- />
105
- </div>
106
- {:else}
107
- <div class='fieldset'>
108
- <legend class='fieldset-legend'>{uiM('forgot_password_phone_label')}</legend>
109
- <Input
110
- id='forgot-phone'
111
- name='phone'
112
- type='tel'
113
- placeholder={uiM('forgot_password_phone_placeholder')}
114
- bind:value={phone}
115
- {disabled}
116
- required
117
- error={errors.phone}
118
- />
119
- </div>
120
- {/if}
121
-
122
- <!-- 提示信息 -->
123
- <p class='text-sm text-base-content/60'>
77
+ <FormLayout {columns} {labelPosition} {labelWidth}>
78
+ <!-- 标题 -->
79
+ {#if showTitle}
80
+ <h2 class='text-2xl font-semibold text-center mb-4'>{uiM('forgot_password_title')}</h2>
81
+ {/if}
82
+
83
+ <!-- 描述 -->
84
+ {#if showDescription}
85
+ <p class='text-center text-base-content/60 text-sm mb-6'>
86
+ {uiM('forgot_password_desc')}
87
+ </p>
88
+ {/if}
89
+
90
+ <!-- 自定义头部 -->
91
+ {#if header}
92
+ <div class='forgot-password-form-header'>
93
+ {@render header()}
94
+ </div>
95
+ {/if}
96
+
97
+ <!-- 邮箱输入 -->
124
98
  {#if mode === 'email'}
125
- {uiM('forgot_password_email_hint')}
99
+ <FormField for='forgot-email' label={uiM('forgot_password_email_label')}>
100
+ <Input
101
+ id='forgot-email'
102
+ name='email'
103
+ type='email'
104
+ placeholder={uiM('forgot_password_email_placeholder')}
105
+ bind:value={email}
106
+ {disabled}
107
+ required
108
+ error={errors.email}
109
+ />
110
+ </FormField>
126
111
  {:else}
127
- {uiM('forgot_password_phone_hint')}
112
+ <FormField for='forgot-phone' label={uiM('forgot_password_phone_label')}>
113
+ <Input
114
+ id='forgot-phone'
115
+ name='phone'
116
+ type='tel'
117
+ placeholder={uiM('forgot_password_phone_placeholder')}
118
+ bind:value={phone}
119
+ {disabled}
120
+ required
121
+ error={errors.phone}
122
+ />
123
+ </FormField>
124
+ {/if}
125
+
126
+ <!-- 提示信息 -->
127
+ <p class='text-sm text-base-content/60'>
128
+ {#if mode === 'email'}
129
+ {uiM('forgot_password_email_hint')}
130
+ {:else}
131
+ {uiM('forgot_password_phone_hint')}
132
+ {/if}
133
+ </p>
134
+
135
+ <!-- 通用错误 -->
136
+ {#if errors.general}
137
+ <Alert variant='error'>
138
+ {errors.general}
139
+ </Alert>
140
+ {/if}
141
+
142
+ <!-- 提交按钮 -->
143
+ <Button
144
+ type='submit'
145
+ variant='primary'
146
+ {loading}
147
+ disabled={loading || disabled}
148
+ class='w-full'
149
+ >
150
+ {submitText || uiM('forgot_password_submit')}
151
+ </Button>
152
+
153
+ <!-- 自定义底部 -->
154
+ {#if footer}
155
+ <div class='forgot-password-form-footer'>
156
+ {@render footer()}
157
+ </div>
158
+ {:else if showBackLink}
159
+ <div class='text-center mt-4 text-sm'>
160
+ <a href={loginUrl} class='link link-primary'>← {uiM('forgot_password_back')}</a>
161
+ </div>
128
162
  {/if}
129
- </p>
130
-
131
- <!-- 通用错误 -->
132
- {#if errors.general}
133
- <Alert variant='error'>
134
- {errors.general}
135
- </Alert>
136
- {/if}
137
-
138
- <!-- 提交按钮 -->
139
- <Button
140
- type='submit'
141
- variant='primary'
142
- {loading}
143
- disabled={loading || disabled}
144
- class='w-full'
145
- >
146
- {submitText || uiM('forgot_password_submit')}
147
- </Button>
148
-
149
- <!-- 自定义底部 -->
150
- {#if footer}
151
- <div class='forgot-password-form-footer'>
152
- {@render footer()}
153
- </div>
154
- {:else if showBackLink}
155
- <div class='text-center mt-4 text-sm'>
156
- <a href={loginUrl} class='link link-primary'>← {uiM('forgot_password_back')}</a>
157
- </div>
158
- {/if}
163
+ </FormLayout>
159
164
  </form>
@@ -3,11 +3,14 @@
3
3
  用户登录表单组件,搭配 primitives/compounds 使用。
4
4
  -->
5
5
  <script lang='ts'>
6
+ import type { HaiError } from '@h-ai/core'
6
7
  import type { DataAttributes } from '../../../types.js'
7
8
  import type { AuthLoginMethod, LoginFormData, LoginFormProps } from '../types.js'
8
9
  import { uiM } from '../../../messages.js'
9
10
  import { cn, getDataAttributes } from '../../../utils.js'
10
11
  import Alert from '../../compounds/Alert.svelte'
12
+ import FormField from '../../compounds/FormField.svelte'
13
+ import FormLayout from '../../compounds/FormLayout.svelte'
11
14
  import BareButton from '../../primitives/BareButton.svelte'
12
15
  import Button from '../../primitives/Button.svelte'
13
16
  import Checkbox from '../../primitives/Checkbox.svelte'
@@ -22,6 +25,9 @@
22
25
  resendInterval = 60,
23
26
  onsendotp,
24
27
  loading = false,
28
+ columns = 1,
29
+ labelPosition = 'top',
30
+ labelWidth = '8rem',
25
31
  disabled = false,
26
32
  showTitle = false,
27
33
  showRememberMe = true,
@@ -51,7 +57,8 @@
51
57
  const isOtp = $derived(method === 'email' || method === 'sms')
52
58
  let code = $state('')
53
59
  let sending = $state(false)
54
- let sendError = $state('')
60
+ let sendFailure = $state<Pick<HaiError, 'code' | 'message'> | 'network'>()
61
+ const sendError = $derived(sendFailure === 'network' ? uiM('auth_network_error') : sendFailure ? authErrorMessage(sendFailure) : '')
55
62
  let resendAt = $state(0)
56
63
  let now = $state(Date.now())
57
64
  const remaining = $derived(Math.max(0, Math.ceil((resendAt - now) / 1000)))
@@ -79,11 +86,11 @@
79
86
  if (!onsendotp || sending || remaining || !username.trim())
80
87
  return
81
88
  sending = true
82
- sendError = ''
89
+ sendFailure = undefined
83
90
  try {
84
91
  const result = await onsendotp(username.trim())
85
92
  if (!result.success) {
86
- sendError = authErrorMessage(result.error)
93
+ sendFailure = result.error
87
94
  }
88
95
  else {
89
96
  now = Date.now()
@@ -91,7 +98,7 @@
91
98
  }
92
99
  }
93
100
  catch {
94
- sendError = uiM('auth_network_error')
101
+ sendFailure = 'network'
95
102
  }
96
103
  finally { sending = false }
97
104
  }
@@ -125,149 +132,144 @@
125
132
  </script>
126
133
 
127
134
  <form {...dataAttributes} class={formClass} onsubmit={handleSubmit}>
128
- <!-- 标题 -->
129
- {#if showTitle}
130
- <h2 class='text-xl font-semibold text-center mb-5'>{uiM('login_title')}</h2>
131
- {/if}
132
-
133
- <!-- 自定义头部 -->
134
- {#if header}
135
- <div class='login-form-header'>
136
- {@render header()}
137
- </div>
138
- {/if}
135
+ <FormLayout {columns} {labelPosition} {labelWidth}>
136
+ <!-- 标题 -->
137
+ {#if showTitle}
138
+ <h2 class='text-xl font-semibold text-center mb-5'>{uiM('login_title')}</h2>
139
+ {/if}
139
140
 
140
- <!-- 用户名 -->
141
- {#if methods.length > 1}
142
- <div class='grid grid-cols-2 gap-1 rounded-lg bg-base-200 p-1 sm:flex' aria-label={uiM('login_methods')}>
143
- {#each methods as value (value)}
144
- <button type='button' class={cn('flex-1 rounded-md px-2 py-2 text-sm transition-colors', method === value ? 'bg-base-100 font-medium shadow-sm' : 'text-base-content/60')} aria-pressed={method === value} disabled={loading || disabled} onclick={() => {
145
- selectedMethod = value
146
- password = ''
147
- code = ''
148
- sendError = ''
149
- }}>
150
- {methodLabel(value)}
151
- </button>
152
- {/each}
153
- </div>
154
- {/if}
155
- {#if !method}
156
- <Alert variant='warning'>{uiM('auth_unavailable')}</Alert>
157
- {:else}
158
- <div class='space-y-1.5'>
159
- <label class='text-sm font-medium text-base-content/70' for='login-username'>
160
- {method === 'email' ? uiM('register_email') : method === 'sms' ? uiM('register_phone') : uiM('login_username')}
161
- </label>
162
- <Input
163
- id='login-username'
164
- name='username'
165
- type={method === 'email' ? 'email' : method === 'sms' ? 'tel' : 'text'}
166
- placeholder={method === 'email' ? uiM('register_email_placeholder') : method === 'sms' ? uiM('register_phone_placeholder') : uiM('login_username_placeholder')}
167
- bind:value={username}
168
- autocomplete='username'
169
- {disabled}
170
- required
171
- error={errors.username}
172
- />
173
- </div>
141
+ <!-- 自定义头部 -->
142
+ {#if header}
143
+ <div class='login-form-header'>
144
+ {@render header()}
145
+ </div>
146
+ {/if}
174
147
 
175
- <!-- 密码 -->
176
- {#if isOtp}
177
- <div class='space-y-1.5'>
178
- <label class='text-sm font-medium text-base-content/70' for='login-code'>{uiM('login_code')}</label>
179
- <div class='flex items-start gap-2'>
180
- <Input id='login-code' name='code' bind:value={code} autocomplete='one-time-code' inputmode='numeric' maxlength={otpLength} pattern={`[0-9]{${otpLength}}`} placeholder={uiM('login_code_placeholder', { length: otpLength })} required {disabled} />
181
- <Button type='button' variant='outline' loading={sending} disabled={loading || disabled || !username.trim() || remaining > 0 || !onsendotp} onclick={sendOtp}>
182
- {remaining ? uiM('login_resend_wait', { seconds: remaining }) : uiM('login_send_code')}
183
- </Button>
184
- </div>
185
- {#if sendError}<p role='alert' class='text-sm text-error'>{sendError}</p>{/if}
148
+ <!-- 用户名 -->
149
+ {#if methods.length > 1}
150
+ <div class='grid grid-cols-2 gap-1 rounded-lg bg-base-200 p-1 sm:flex' aria-label={uiM('login_methods')}>
151
+ {#each methods as value (value)}
152
+ <button type='button' class={cn('flex-1 rounded-md px-2 py-2 text-sm transition-colors', method === value ? 'bg-base-100 font-medium shadow-sm' : 'text-base-content/60')} aria-pressed={method === value} disabled={loading || disabled} onclick={() => {
153
+ selectedMethod = value
154
+ password = ''
155
+ code = ''
156
+ sendFailure = undefined
157
+ }}>
158
+ {methodLabel(value)}
159
+ </button>
160
+ {/each}
186
161
  </div>
162
+ {/if}
163
+ {#if !method}
164
+ <Alert variant='warning'>{uiM('auth_unavailable')}</Alert>
187
165
  {:else}
188
- <div class='space-y-1.5'>
189
- <label class='text-sm font-medium text-base-content/70' for='login-password'>
190
- {uiM('login_password')}
191
- </label>
192
- <PasswordInput
193
- id='login-password'
194
- bind:value={password}
195
- placeholder={uiM('login_password_placeholder')}
196
- autocomplete='current-password'
166
+ <FormField for='login-username' label={method === 'email' ? uiM('register_email') : method === 'sms' ? uiM('register_phone') : uiM('login_username')}>
167
+ <Input
168
+ id='login-username'
169
+ name='username'
170
+ type={method === 'email' ? 'email' : method === 'sms' ? 'tel' : 'text'}
171
+ placeholder={method === 'email' ? uiM('register_email_placeholder') : method === 'sms' ? uiM('register_phone_placeholder') : uiM('login_username_placeholder')}
172
+ bind:value={username}
173
+ autocomplete='username'
197
174
  {disabled}
198
175
  required
199
- error={errors.password}
200
- showStrength={false}
176
+ error={errors.username}
201
177
  />
202
- </div>
203
- {/if}
204
- {/if}
178
+ </FormField>
205
179
 
206
- <!-- 记住我 & 忘记密码 -->
207
- {#if method === 'password' && (showRememberMe || showForgotPassword)}
208
- <div class='flex items-center justify-between'>
209
- {#if showRememberMe}
210
- <Checkbox
211
- bind:checked={rememberMe}
212
- label={uiM('login_remember_me')}
213
- {disabled}
214
- size='sm'
215
- />
180
+ <!-- 密码 -->
181
+ {#if isOtp}
182
+ <FormField for='login-code' label={uiM('login_code')}>
183
+ <div class='flex items-start gap-2'>
184
+ <Input id='login-code' name='code' bind:value={code} autocomplete='one-time-code' inputmode='numeric' maxlength={otpLength} pattern={`[0-9]{${otpLength}}`} placeholder={uiM('login_code_placeholder', { length: otpLength })} required {disabled} />
185
+ <Button type='button' variant='outline' loading={sending} disabled={loading || disabled || !username.trim() || remaining > 0 || !onsendotp} onclick={sendOtp}>
186
+ {remaining ? uiM('login_resend_wait', { seconds: remaining }) : uiM('login_send_code')}
187
+ </Button>
188
+ </div>
189
+ {#if sendError}<p role='alert' class='text-sm text-error'>{sendError}</p>{/if}
190
+ </FormField>
216
191
  {:else}
217
- <div></div>
192
+ <FormField for='login-password' label={uiM('login_password')}>
193
+ <PasswordInput
194
+ id='login-password'
195
+ bind:value={password}
196
+ placeholder={uiM('login_password_placeholder')}
197
+ autocomplete='current-password'
198
+ {disabled}
199
+ required
200
+ error={errors.password}
201
+ showStrength={false}
202
+ />
203
+ </FormField>
218
204
  {/if}
205
+ {/if}
219
206
 
220
- {#if showForgotPassword}
221
- {#if onforgotpassword}
222
- <BareButton
223
- type='button'
224
- class='link link-primary text-sm'
225
- onclick={handleForgotPassword}
226
- >
227
- {uiM('login_forgot_password')}
228
- </BareButton>
207
+ <!-- 记住我 & 忘记密码 -->
208
+ {#if method === 'password' && (showRememberMe || showForgotPassword)}
209
+ <div class='flex items-center justify-between'>
210
+ {#if showRememberMe}
211
+ <Checkbox
212
+ bind:checked={rememberMe}
213
+ label={uiM('login_remember_me')}
214
+ {disabled}
215
+ size='sm'
216
+ />
229
217
  {:else}
230
- <a href={forgotPasswordUrl} class='link link-primary text-sm'>
231
- {uiM('login_forgot_password')}
232
- </a>
218
+ <div></div>
233
219
  {/if}
234
- {/if}
235
- </div>
236
- {/if}
237
220
 
238
- <!-- 通用错误 -->
239
- {#if extraFields}
240
- <div class='login-form-extra-fields'>{@render extraFields()}</div>
241
- {/if}
242
- {#if errors.general}
243
- <Alert variant='error'>
244
- {errors.general}
245
- </Alert>
246
- {/if}
221
+ {#if showForgotPassword}
222
+ {#if onforgotpassword}
223
+ <BareButton
224
+ type='button'
225
+ class='link link-primary text-sm'
226
+ onclick={handleForgotPassword}
227
+ >
228
+ {uiM('login_forgot_password')}
229
+ </BareButton>
230
+ {:else}
231
+ <a href={forgotPasswordUrl} class='link link-primary text-sm'>
232
+ {uiM('login_forgot_password')}
233
+ </a>
234
+ {/if}
235
+ {/if}
236
+ </div>
237
+ {/if}
238
+
239
+ <!-- 通用错误 -->
240
+ {#if extraFields}
241
+ <div class='login-form-extra-fields'>{@render extraFields()}</div>
242
+ {/if}
243
+ {#if errors.general}
244
+ <Alert variant='error'>
245
+ {errors.general}
246
+ </Alert>
247
+ {/if}
247
248
 
248
- <!-- 提交按钮 -->
249
- <Button
250
- type='submit'
251
- variant='primary'
252
- {loading}
253
- disabled={loading || disabled || !method}
254
- class='w-full'
255
- >
256
- {submitText || uiM('login_submit')}
257
- </Button>
249
+ <!-- 提交按钮 -->
250
+ <Button
251
+ type='submit'
252
+ variant='primary'
253
+ {loading}
254
+ disabled={loading || disabled || !method}
255
+ class='w-full'
256
+ >
257
+ {submitText || uiM('login_submit')}
258
+ </Button>
258
259
 
259
- <!-- 协议提示 -->
260
- <AgreementNotice {agreements} />
260
+ <!-- 协议提示 -->
261
+ <AgreementNotice {agreements} />
261
262
 
262
- <!-- 自定义底部 -->
263
- {#if footer}
264
- <div class='login-form-footer'>
265
- {@render footer()}
266
- </div>
267
- {:else if showRegisterLink}
268
- <div class='text-center mt-4 text-sm text-base-content/60'>
269
- <span>{uiM('login_no_account')}</span>
270
- <a href={registerUrl} class='link link-primary ml-1'>{uiM('login_register_now')}</a>
271
- </div>
272
- {/if}
263
+ <!-- 自定义底部 -->
264
+ {#if footer}
265
+ <div class='login-form-footer'>
266
+ {@render footer()}
267
+ </div>
268
+ {:else if showRegisterLink}
269
+ <div class='text-center mt-4 text-sm text-base-content/60'>
270
+ <span>{uiM('login_no_account')}</span>
271
+ <a href={registerUrl} class='link link-primary ml-1'>{uiM('login_register_now')}</a>
272
+ </div>
273
+ {/if}
274
+ </FormLayout>
273
275
  </form>
@@ -4,7 +4,9 @@
4
4
  内置多语言支持,自动跟随全局 locale。
5
5
  -->
6
6
  <script lang='ts'>
7
- import type { DataAttributes, Size } from '../../../types.js'
7
+ import type { DataAttributes, FormText, Size } from '../../../types.js'
8
+ import { getFormFieldContext } from '../../../internal/form-context.js'
9
+ import { createControlValidation, resolveFormText } from '../../../internal/form-validation.svelte.js'
8
10
  import { uiM } from '../../../messages.js'
9
11
  import { cn, getDataAttributes } from '../../../utils.js'
10
12
  import { getFormControlSizeClasses } from '../../control-size.js'
@@ -35,7 +37,7 @@
35
37
  /** 是否必填 */
36
38
  required?: boolean
37
39
  /** 错误消息 */
38
- error?: string
40
+ error?: FormText
39
41
  /** 是否显示切换按钮 */
40
42
  showToggle?: boolean
41
43
  /** 是否显示密码强度 */
@@ -76,6 +78,16 @@
76
78
  }: Props & DataAttributes = $props()
77
79
 
78
80
  const dataAttributes = $derived(getDataAttributes(restProps))
81
+ const field = getFormFieldContext()
82
+ const validation = createControlValidation(undefined, () => [value, required, readonly, disabled])
83
+ const uniqueId = $props.id()
84
+ const messageId = `${uniqueId}-error`
85
+ const localError = $derived(resolveFormText(error) || validation.message)
86
+ const effectiveError = $derived(field?.error || localError)
87
+ $effect(() => {
88
+ if (inputRef)
89
+ inputRef.setCustomValidity(validation.message)
90
+ })
79
91
  let showPassword = $state(false)
80
92
  const sizeClasses = $derived(getFormControlSizeClasses(size))
81
93
 
@@ -124,6 +136,7 @@
124
136
  })
125
137
 
126
138
  function handleInput(e: Event & { currentTarget: HTMLInputElement }) {
139
+ validation.input(e.currentTarget)
127
140
  value = e.currentTarget.value
128
141
  oninput?.(e)
129
142
  }
@@ -142,7 +155,7 @@
142
155
  <div class={cn(
143
156
  'flex items-center w-full rounded-lg border bg-base-100',
144
157
  sizeClasses.height,
145
- error
158
+ effectiveError
146
159
  ? 'border-error/60 focus-within:ring-2 focus-within:ring-error/15'
147
160
  : 'border-base-content/15 focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/10',
148
161
  'transition-[border-color,box-shadow] duration-150',
@@ -152,7 +165,7 @@
152
165
  <BareInput
153
166
  type={showPassword ? 'text' : 'password'}
154
167
  {id}
155
- class={cn('flex-1 h-full bg-transparent border-none outline-none placeholder:text-base-content/35', sizeClasses.spacing)}
168
+ class={cn('flex-1 min-w-0 h-full bg-transparent border-none outline-none placeholder:text-base-content/35', sizeClasses.spacing)}
156
169
  bind:value
157
170
  bind:inputRef={inputRef}
158
171
  {placeholder}
@@ -160,9 +173,14 @@
160
173
  {disabled}
161
174
  {readonly}
162
175
  {required}
176
+ ariaInvalid={effectiveError ? true : undefined}
177
+ ariaDescribedby={[field?.messageId, localError && !field?.error ? messageId : ''].filter(Boolean).join(' ') || undefined}
163
178
  oninput={handleInput}
164
179
  onchange={handleChange}
165
- oninvalid={oninvalid}
180
+ oninvalid={(e) => {
181
+ validation.invalid(e.currentTarget)
182
+ oninvalid?.(e)
183
+ }}
166
184
  />
167
185
 
168
186
  {#if showToggle}
@@ -197,7 +215,7 @@
197
215
  </div>
198
216
  {/if}
199
217
 
200
- {#if error}
201
- <p class='mt-1.5 text-xs text-error/80 leading-tight'>{error}</p>
218
+ {#if localError && !field?.error}
219
+ <p id={messageId} class='mt-1.5 text-xs text-error leading-relaxed break-words' aria-live='polite'>{localError}</p>
202
220
  {/if}
203
221
  </div>
@@ -1,4 +1,4 @@
1
- import type { DataAttributes, Size } from '../../../types.js';
1
+ import type { DataAttributes, FormText, Size } from '../../../types.js';
2
2
  interface Props {
3
3
  /** 元素 ID */
4
4
  id?: string;
@@ -17,7 +17,7 @@ interface Props {
17
17
  /** 是否必填 */
18
18
  required?: boolean;
19
19
  /** 错误消息 */
20
- error?: string;
20
+ error?: FormText;
21
21
  /** 是否显示切换按钮 */
22
22
  showToggle?: boolean;
23
23
  /** 是否显示密码强度 */