@h-ai/ui 0.1.0-alpha.63 → 0.1.0-alpha.65
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.
- package/README.en.md +4 -0
- package/README.ja.md +4 -0
- package/README.md +4 -0
- package/dist/components/compounds/Drawer.svelte +5 -1
- package/dist/components/compounds/Modal.svelte +5 -0
- package/dist/components/primitives/Input.svelte +2 -1
- package/dist/components/primitives/Select.svelte +14 -1
- package/dist/components/primitives/Textarea.svelte +2 -1
- package/dist/components/scenes/crud/CrudEditPanel.svelte +16 -6
- package/dist/components/scenes/iam/ChangePasswordForm.svelte +9 -0
- package/dist/components/scenes/iam/PasswordInput.svelte +7 -5
- package/dist/components/scenes/iam/PasswordInput.svelte.d.ts +1 -1
- package/dist/components/scenes/iam/UserProfile.svelte +6 -3
- package/dist/components/scenes/types.d.ts +4 -2
- package/dist/internal/form-context.d.ts +6 -0
- package/dist/internal/form-context.js +4 -0
- package/dist/internal/form-validation.svelte.d.ts +1 -1
- package/dist/internal/form-validation.svelte.js +13 -3
- package/package.json +2 -2
package/README.en.md
CHANGED
|
@@ -187,6 +187,10 @@ Labels connect to nested UI controls automatically; use `<FormField for="control
|
|
|
187
187
|
|
|
188
188
|
`Input`, `Textarea`, `Select` and `PasswordInput` provide Chinese/English native validation messages. `validationMessage` overrides failures without keeping valid input invalid. Sync locale with the UI `setGlobalLocale` / `createLocaleStore` to update visible validation errors immediately. `Textarea` supports `minlength` / `maxlength`; `Select` supports `name`, required validation and FormData serialization.
|
|
189
189
|
|
|
190
|
+
Client validation appears on the first blur and updates as invalid input is corrected. Untouched fields are validated on submit. CRUD create/edit forms share this behavior, honor custom messages and reset validation when reopened. `Select` validates when focus leaves both its trigger and options. `PasswordInput.minLength` enforces length and informs strength; omitting it leaves login password length unrestricted while strength uses an eight-character baseline.
|
|
191
|
+
|
|
192
|
+
`UserProfile.fieldConstraints` accepts per-field `required`, `minlength`, `maxlength`, `pattern` and `validationMessage` (`pattern` applies to single-line inputs), so application rules such as username formats share blur validation. `validationMessage` also accepts a deferred translation function.
|
|
193
|
+
|
|
190
194
|
`LoginForm`, `RegisterForm`, `ChangePasswordForm`, `ForgotPasswordForm`, `ResetPasswordForm`, `AuthPanel`, `UserProfile` and `FeedbackModal` accept the same layout options. Pass them through `CrudPage.form` or directly to `CrudEditPanel`. CRUD fields support `colSpan` and deferred `validation.message`. Buttons and Enter both validate before submission; corrections clear errors. Server validation remains required.
|
|
191
195
|
|
|
192
196
|
```svelte
|
package/README.ja.md
CHANGED
|
@@ -187,6 +187,10 @@ components/
|
|
|
187
187
|
|
|
188
188
|
`Input`、`Textarea`、`Select`、`PasswordInput` のネイティブ検証は中国語・英語に対応します。`validationMessage` は失敗時だけ適用され、有効な入力を無効にし続けません。UI の `setGlobalLocale` / `createLocaleStore` で言語を同期すると表示中のエラーも更新されます。`Textarea` は `minlength` / `maxlength`、`Select` は `name`・必須検証・FormData に対応します。
|
|
189
189
|
|
|
190
|
+
初回のフォーカス離脱時にエラーを表示し、修正中も更新します。未操作の項目は送信時に検証します。CRUD の新規作成・編集も同じ動作とカスタムメッセージに対応し、再表示時に検証状態をリセットします。`Select` はトリガーと選択肢の両方からフォーカスが離れた時に検証します。`PasswordInput.minLength` は長さ制約と強度計算に使い、省略時はログイン用の長さ制限を設けず、強度のみ8文字を基準にします。
|
|
191
|
+
|
|
192
|
+
`UserProfile.fieldConstraints` は項目別に `required`、`minlength`、`maxlength`、`pattern`、`validationMessage` を受け取り、ユーザー名などの業務ルールをフォーカス離脱時に検証します。`pattern` は単一行の入力に適用し、`validationMessage` は翻訳関数にも対応します。
|
|
193
|
+
|
|
190
194
|
`LoginForm`、`RegisterForm`、`ChangePasswordForm`、`ForgotPasswordForm`、`ResetPasswordForm`、`AuthPanel`、`UserProfile`、`FeedbackModal` は同じレイアウト設定に対応します。`CrudPage.form` または `CrudEditPanel` に渡します。CRUD 項目は `colSpan` と翻訳関数の `validation.message` に対応し、ボタンと Enter のどちらも送信前に検証します。入力修正でエラーが消えます。サーバー側の検証も必要です。
|
|
191
195
|
|
|
192
196
|
```svelte
|
package/README.md
CHANGED
|
@@ -187,6 +187,10 @@ components/
|
|
|
187
187
|
|
|
188
188
|
`Input`、`Textarea`、`Select` 和 `PasswordInput` 的原生校验使用内置中英文提示;`validationMessage` 只覆盖失败提示,不让有效输入一直无效。应用通过 UI 的 `setGlobalLocale` / `createLocaleStore` 同步语言,已显示的校验错误即时更新。`Textarea` 支持 `minlength` / `maxlength`,`Select` 支持 `name`、必填校验和 FormData 序列化。
|
|
189
189
|
|
|
190
|
+
字段首次失焦后显示前端校验错误,修正输入时即时更新;未访问的字段在提交时统一校验。CRUD 新增/编辑复用该行为,支持自定义校验文案,关闭重开清空校验状态。`Select` 在焦点离开触发器和选项后校验。`PasswordInput.minLength` 同时约束长度并用于强度计算;省略时不限制登录密码长度,强度计算仍以 8 位为基准。
|
|
191
|
+
|
|
192
|
+
`UserProfile.fieldConstraints` 按字段接收 `required`、`minlength`、`maxlength`、`pattern` 和 `validationMessage`(`pattern` 用于单行输入),让应用的用户名等业务规则复用失焦校验;`validationMessage` 支持延迟翻译函数。
|
|
193
|
+
|
|
190
194
|
`LoginForm`、`RegisterForm`、`ChangePasswordForm`、`ForgotPasswordForm`、`ResetPasswordForm`、`AuthPanel`、`UserProfile` 和 `FeedbackModal` 接受同一组布局参数。`CrudPage` 通过 `form` 传入;`CrudEditPanel` 直接接收。CRUD 字段支持 `colSpan`,`validation.message` 支持翻译函数;按钮与回车均先校验,修正后错误消失,客户端校验不能替代服务端校验。
|
|
191
195
|
|
|
192
196
|
```svelte
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
-->
|
|
11
11
|
<script lang='ts'>
|
|
12
12
|
import type { DataAttributes, DrawerProps } from '../../types.js'
|
|
13
|
-
import { onDestroy, onMount } from 'svelte'
|
|
13
|
+
import { onDestroy, onMount, setContext } from 'svelte'
|
|
14
14
|
import { readStoredValue, writeStoredValue } from '../../internal/browser-safety.js'
|
|
15
|
+
import { FORM_VISIBILITY_CONTEXT } from '../../internal/form-context.js'
|
|
15
16
|
import { uiM } from '../../messages.js'
|
|
16
17
|
import { cn, generateId, getDataAttributes } from '../../utils.js'
|
|
17
18
|
import IconButton from '../primitives/IconButton.svelte'
|
|
@@ -34,6 +35,9 @@
|
|
|
34
35
|
}: DrawerProps & DataAttributes = $props()
|
|
35
36
|
|
|
36
37
|
const dataAttributes = $derived(getDataAttributes(restProps))
|
|
38
|
+
setContext(FORM_VISIBILITY_CONTEXT, {
|
|
39
|
+
get open() { return open },
|
|
40
|
+
})
|
|
37
41
|
const id = generateId('drawer')
|
|
38
42
|
/** 用户拖动后的像素宽度;优先于 width 与 size。 */
|
|
39
43
|
let resizedWidth = $state<number | null>(null)
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
-->
|
|
5
5
|
<script lang='ts'>
|
|
6
6
|
import type { DataAttributes, ModalProps } from '../../types.js'
|
|
7
|
+
import { setContext } from 'svelte'
|
|
8
|
+
import { FORM_VISIBILITY_CONTEXT } from '../../internal/form-context.js'
|
|
7
9
|
import { uiM } from '../../messages.js'
|
|
8
10
|
import { cn, getDataAttributes } from '../../utils.js'
|
|
9
11
|
|
|
@@ -28,6 +30,9 @@
|
|
|
28
30
|
}: ModalProps & DataAttributes = $props()
|
|
29
31
|
|
|
30
32
|
const dataAttributes = $derived(getDataAttributes(restProps))
|
|
33
|
+
setContext(FORM_VISIBILITY_CONTEXT, {
|
|
34
|
+
get open() { return open },
|
|
35
|
+
})
|
|
31
36
|
const modalId = $props.id()
|
|
32
37
|
const titleId = `${modalId}-title`
|
|
33
38
|
let panel = $state<HTMLDivElement>()
|
|
@@ -89,6 +89,7 @@
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
function handleBlur(e: FocusEvent & { currentTarget: HTMLInputElement }) {
|
|
92
|
+
validation.validate(e.currentTarget)
|
|
92
93
|
onblur?.(e)
|
|
93
94
|
}
|
|
94
95
|
|
|
@@ -97,7 +98,7 @@
|
|
|
97
98
|
}
|
|
98
99
|
|
|
99
100
|
function handleInvalid(e: Event & { currentTarget: HTMLInputElement }) {
|
|
100
|
-
validation.
|
|
101
|
+
validation.validate(e.currentTarget)
|
|
101
102
|
oninvalid?.(e)
|
|
102
103
|
}
|
|
103
104
|
</script>
|
|
@@ -258,6 +258,17 @@
|
|
|
258
258
|
onchange?.(undefined as T)
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
+
function handleFocusOut(event: FocusEvent) {
|
|
262
|
+
const next = event.relatedTarget
|
|
263
|
+
// 选项通过 portal 渲染,触发器与选项之间的焦点移动仍属于同一字段。
|
|
264
|
+
if (next instanceof Node && (dropdownRef?.contains(next) || listboxRef?.contains(next)))
|
|
265
|
+
return
|
|
266
|
+
if (nativeSelect)
|
|
267
|
+
validation.validate(nativeSelect)
|
|
268
|
+
isDropdownOpen = false
|
|
269
|
+
filterText = ''
|
|
270
|
+
}
|
|
271
|
+
|
|
261
272
|
// 判断是否显示清除按钮
|
|
262
273
|
const showClear = $derived(clearable && value !== undefined && value !== null && value !== '' && isHovered)
|
|
263
274
|
</script>
|
|
@@ -268,6 +279,7 @@
|
|
|
268
279
|
bind:this={dropdownRef}
|
|
269
280
|
onmouseenter={() => isHovered = true}
|
|
270
281
|
onmouseleave={() => isHovered = false}
|
|
282
|
+
onfocusout={handleFocusOut}
|
|
271
283
|
>
|
|
272
284
|
<select
|
|
273
285
|
bind:this={nativeSelect}
|
|
@@ -280,7 +292,7 @@
|
|
|
280
292
|
value={nativeValue}
|
|
281
293
|
oninvalid={(e) => {
|
|
282
294
|
e.preventDefault()
|
|
283
|
-
validation.
|
|
295
|
+
validation.validate(e.currentTarget)
|
|
284
296
|
inputRef?.focus()
|
|
285
297
|
}}
|
|
286
298
|
>
|
|
@@ -387,6 +399,7 @@
|
|
|
387
399
|
class='max-h-60 overflow-auto rounded-xl border border-base-content/10 bg-base-100 p-1.5 shadow-xl shadow-base-content/10'
|
|
388
400
|
style={dropdownStyle}
|
|
389
401
|
role='listbox'
|
|
402
|
+
onfocusout={handleFocusOut}
|
|
390
403
|
>
|
|
391
404
|
{#each filteredOptions() as option (String(option.value))}
|
|
392
405
|
<li
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
function handleInvalid(e: Event & { currentTarget: HTMLTextAreaElement }) {
|
|
91
|
-
validation.
|
|
91
|
+
validation.validate(e.currentTarget)
|
|
92
92
|
oninvalid?.(e)
|
|
93
93
|
}
|
|
94
94
|
</script>
|
|
@@ -112,6 +112,7 @@
|
|
|
112
112
|
class={textareaClass}
|
|
113
113
|
bind:value
|
|
114
114
|
oninput={handleInput}
|
|
115
|
+
onblur={e => validation.validate(e.currentTarget)}
|
|
115
116
|
oninvalid={handleInvalid}
|
|
116
117
|
></textarea>
|
|
117
118
|
</div>
|
|
@@ -79,9 +79,12 @@
|
|
|
79
79
|
const panelId = $props.id()
|
|
80
80
|
let formElement: HTMLFormElement | undefined = $state()
|
|
81
81
|
let attempted = $state(false)
|
|
82
|
+
let touched = $state<Record<string, boolean>>({})
|
|
82
83
|
$effect(() => {
|
|
83
|
-
if (!open)
|
|
84
|
+
if (!open) {
|
|
84
85
|
attempted = false
|
|
86
|
+
touched = {}
|
|
87
|
+
}
|
|
85
88
|
})
|
|
86
89
|
function resolveOptions(options?: CrudFieldDef['options']): Array<{ label: string, value: string | number | boolean }> {
|
|
87
90
|
if (!options)
|
|
@@ -94,10 +97,8 @@
|
|
|
94
97
|
)
|
|
95
98
|
const fieldErrors = $derived.by(() => {
|
|
96
99
|
const errors: Record<string, string> = {}
|
|
97
|
-
if (!attempted)
|
|
98
|
-
return errors
|
|
99
100
|
for (const field of sortedFields) {
|
|
100
|
-
if (mode === 'edit' && field.readonly)
|
|
101
|
+
if ((!attempted && !touched[field.id]) || (mode === 'edit' && field.readonly))
|
|
101
102
|
continue
|
|
102
103
|
const issue = getCrudValidationIssue(field, formData[field.id])
|
|
103
104
|
if (issue)
|
|
@@ -148,6 +149,15 @@
|
|
|
148
149
|
formData = { ...formData, [fieldId]: value }
|
|
149
150
|
}
|
|
150
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
|
+
|
|
151
161
|
function toggleMultiSelect(fieldId: string, optValue: string | number | boolean) {
|
|
152
162
|
const current = (formData[fieldId] as Array<string | number | boolean>) ?? []
|
|
153
163
|
const strVal = String(optValue)
|
|
@@ -295,7 +305,7 @@
|
|
|
295
305
|
closeOnBackdrop={false}
|
|
296
306
|
onclose={handleClose}
|
|
297
307
|
>
|
|
298
|
-
<form bind:this={formElement} novalidate onsubmit={handleSubmit}>
|
|
308
|
+
<form bind:this={formElement} novalidate onsubmit={handleSubmit} onfocusout={handleFieldBlur}>
|
|
299
309
|
<!-- 底部按钮在表单外;保留原生提交控件,使多字段表单支持回车提交。 -->
|
|
300
310
|
<input type='submit' class='hidden' tabindex='-1' aria-hidden='true' value={uiM('crud_save')} />
|
|
301
311
|
{@render formFields()}
|
|
@@ -318,7 +328,7 @@
|
|
|
318
328
|
onclose={handleClose}
|
|
319
329
|
closeOnBackdrop={false}
|
|
320
330
|
>
|
|
321
|
-
<form bind:this={formElement} novalidate onsubmit={handleSubmit} class={drawerFormClass}>
|
|
331
|
+
<form bind:this={formElement} novalidate onsubmit={handleSubmit} onfocusout={handleFieldBlur} class={drawerFormClass}>
|
|
322
332
|
<input type='submit' class='hidden' tabindex='-1' aria-hidden='true' value={uiM('crud_save')} />
|
|
323
333
|
{@render formFields()}
|
|
324
334
|
</form>
|
|
@@ -83,6 +83,9 @@
|
|
|
83
83
|
<FormField for='old-password' label={uiM('change_password_old')}>
|
|
84
84
|
<PasswordInput
|
|
85
85
|
value={oldPassword}
|
|
86
|
+
id='old-password'
|
|
87
|
+
autocomplete='current-password'
|
|
88
|
+
required
|
|
86
89
|
oninput={(e) => { oldPassword = e.currentTarget.value }}
|
|
87
90
|
placeholder={uiM('change_password_old_placeholder')}
|
|
88
91
|
{disabled}
|
|
@@ -95,6 +98,9 @@
|
|
|
95
98
|
<FormField for='new-password' label={uiM('change_password_new')}>
|
|
96
99
|
<PasswordInput
|
|
97
100
|
value={newPassword}
|
|
101
|
+
id='new-password'
|
|
102
|
+
autocomplete='new-password'
|
|
103
|
+
required
|
|
98
104
|
oninput={(e) => { newPassword = e.currentTarget.value }}
|
|
99
105
|
placeholder={uiM('change_password_new_placeholder')}
|
|
100
106
|
{disabled}
|
|
@@ -107,6 +113,9 @@
|
|
|
107
113
|
<FormField for='confirm-password' label={uiM('change_password_confirm')}>
|
|
108
114
|
<PasswordInput
|
|
109
115
|
value={confirmPassword}
|
|
116
|
+
id='confirm-password'
|
|
117
|
+
autocomplete='new-password'
|
|
118
|
+
required
|
|
110
119
|
oninput={(e) => { confirmPassword = e.currentTarget.value }}
|
|
111
120
|
placeholder={uiM('change_password_confirm_placeholder')}
|
|
112
121
|
{disabled}
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
showToggle?: boolean
|
|
43
43
|
/** 是否显示密码强度 */
|
|
44
44
|
showStrength?: boolean
|
|
45
|
-
/**
|
|
45
|
+
/** 最小长度;提供时校验,省略时仅以 8 位为基准计算强度。 */
|
|
46
46
|
minLength?: number
|
|
47
47
|
/** 自定义类名 */
|
|
48
48
|
class?: string
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
error = '',
|
|
69
69
|
showToggle = true,
|
|
70
70
|
showStrength = false,
|
|
71
|
-
minLength
|
|
71
|
+
minLength,
|
|
72
72
|
class: className = '',
|
|
73
73
|
inputRef = $bindable<HTMLInputElement | undefined>(),
|
|
74
74
|
oninput,
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
|
|
80
80
|
const dataAttributes = $derived(getDataAttributes(restProps))
|
|
81
81
|
const field = getFormFieldContext()
|
|
82
|
-
const validation = createControlValidation(undefined, () => [value, required, readonly, disabled])
|
|
82
|
+
const validation = createControlValidation(undefined, () => [value, required, readonly, disabled, minLength])
|
|
83
83
|
const uniqueId = $props.id()
|
|
84
84
|
const messageId = `${uniqueId}-error`
|
|
85
85
|
const localError = $derived(resolveFormText(error) || validation.message)
|
|
@@ -110,7 +110,7 @@
|
|
|
110
110
|
let score = 0
|
|
111
111
|
|
|
112
112
|
// 长度检查
|
|
113
|
-
if (value.length >= minLength)
|
|
113
|
+
if (value.length >= (minLength ?? 8))
|
|
114
114
|
score += 1
|
|
115
115
|
if (value.length >= 12)
|
|
116
116
|
score += 1
|
|
@@ -173,12 +173,14 @@
|
|
|
173
173
|
{disabled}
|
|
174
174
|
{readonly}
|
|
175
175
|
{required}
|
|
176
|
+
minlength={minLength}
|
|
176
177
|
ariaInvalid={effectiveError ? true : undefined}
|
|
177
178
|
ariaDescribedby={[field?.messageId, localError && !field?.error ? messageId : ''].filter(Boolean).join(' ') || undefined}
|
|
178
179
|
oninput={handleInput}
|
|
179
180
|
onchange={handleChange}
|
|
181
|
+
onblur={e => validation.validate(e.currentTarget)}
|
|
180
182
|
oninvalid={(e) => {
|
|
181
|
-
validation.
|
|
183
|
+
validation.validate(e.currentTarget)
|
|
182
184
|
oninvalid?.(e)
|
|
183
185
|
}}
|
|
184
186
|
/>
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
labelPosition = 'top',
|
|
32
32
|
labelWidth = '8rem',
|
|
33
33
|
fields = ['avatar', 'username', 'email', 'displayName', 'phone'],
|
|
34
|
+
fieldConstraints = {},
|
|
34
35
|
avatarAccept = 'image/*',
|
|
35
36
|
avatarHint,
|
|
36
37
|
class: className = '',
|
|
@@ -167,7 +168,7 @@
|
|
|
167
168
|
{@const modelKey = getModelKey(field)}
|
|
168
169
|
{#if field === 'avatar'}
|
|
169
170
|
<!-- 头像 -->
|
|
170
|
-
<div class='flex items-center gap-4'>
|
|
171
|
+
<div class='flex flex-wrap items-center gap-4 border-b border-base-content/8 pb-5'>
|
|
171
172
|
<Avatar
|
|
172
173
|
src={user?.avatarUrl ?? user?.avatar}
|
|
173
174
|
alt={uiM('user_profile_avatar')}
|
|
@@ -176,7 +177,7 @@
|
|
|
176
177
|
class='shadow-sm'
|
|
177
178
|
/>
|
|
178
179
|
{#if editable && (editMode || alwaysEditable)}
|
|
179
|
-
<div>
|
|
180
|
+
<div class='min-w-0 flex-1 basis-48'>
|
|
180
181
|
<BareInput
|
|
181
182
|
id={avatarInputId}
|
|
182
183
|
type='file'
|
|
@@ -196,10 +197,11 @@
|
|
|
196
197
|
</div>
|
|
197
198
|
{:else}
|
|
198
199
|
<!-- 其他字段 -->
|
|
199
|
-
<FormField label={getFieldLabel(field)} colSpan={field === 'bio' ? 2 : 'auto'}>
|
|
200
|
+
<FormField label={getFieldLabel(field)} required={fieldConstraints[field]?.required} colSpan={field === 'bio' ? 2 : 'auto'}>
|
|
200
201
|
{#if editable && (editMode || alwaysEditable)}
|
|
201
202
|
{#if field === 'bio'}
|
|
202
203
|
<Textarea
|
|
204
|
+
{...fieldConstraints[field]}
|
|
203
205
|
name={modelKey}
|
|
204
206
|
rows={3}
|
|
205
207
|
bind:value={formData[modelKey]}
|
|
@@ -208,6 +210,7 @@
|
|
|
208
210
|
/>
|
|
209
211
|
{:else}
|
|
210
212
|
<Input
|
|
213
|
+
{...fieldConstraints[field]}
|
|
211
214
|
type={field === 'email' ? 'email' : field === 'phone' ? 'tel' : 'text'}
|
|
212
215
|
name={modelKey}
|
|
213
216
|
bind:value={formData[modelKey]}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { HaiResult } from '@h-ai/core';
|
|
8
8
|
import type { Snippet } from 'svelte';
|
|
9
|
-
import type { DataAttributes, FormLayoutOptions, FormText, Size } from '../../types.js';
|
|
9
|
+
import type { DataAttributes, FormLayoutOptions, FormText, InputProps, Size } from '../../types.js';
|
|
10
10
|
/**
|
|
11
11
|
* 密码输入框属性
|
|
12
12
|
*/
|
|
@@ -33,7 +33,7 @@ export interface PasswordInputProps extends DataAttributes {
|
|
|
33
33
|
showToggle?: boolean;
|
|
34
34
|
/** 是否显示密码强度 */
|
|
35
35
|
showStrength?: boolean;
|
|
36
|
-
/**
|
|
36
|
+
/** 最小长度;提供时校验,省略时仅以 8 位为基准计算强度。 */
|
|
37
37
|
minLength?: number;
|
|
38
38
|
/** 自定义类名 */
|
|
39
39
|
class?: string;
|
|
@@ -418,6 +418,8 @@ export interface UserProfileProps extends FormLayoutOptions, DataAttributes {
|
|
|
418
418
|
loading?: boolean;
|
|
419
419
|
/** 显示的字段 */
|
|
420
420
|
fields?: UserProfileField[];
|
|
421
|
+
/** 按业务规则声明字段的原生约束,复用控件失焦校验。 */
|
|
422
|
+
fieldConstraints?: Partial<Record<Exclude<UserProfileField, 'avatar'>, Pick<InputProps, 'required' | 'minlength' | 'maxlength' | 'pattern' | 'validationMessage'>>>;
|
|
421
423
|
/** 头像上传地址 */
|
|
422
424
|
avatarUploadUrl?: string;
|
|
423
425
|
/** 文件选择器允许的头像类型;最终校验由业务上传服务负责。 */
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
import type { FormLayoutOptions } from '../types.js';
|
|
3
3
|
export declare const FORM_LAYOUT_CONTEXT: unique symbol;
|
|
4
4
|
export declare const FORM_FIELD_CONTEXT: unique symbol;
|
|
5
|
+
export declare const FORM_VISIBILITY_CONTEXT: unique symbol;
|
|
6
|
+
interface FormVisibilityContext {
|
|
7
|
+
readonly open: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare function getFormVisibilityContext(): FormVisibilityContext | undefined;
|
|
5
10
|
export interface FormFieldContext {
|
|
6
11
|
readonly controlId: string;
|
|
7
12
|
readonly labelId: string;
|
|
@@ -10,3 +15,4 @@ export interface FormFieldContext {
|
|
|
10
15
|
}
|
|
11
16
|
export declare function getFormLayoutContext(): FormLayoutOptions | undefined;
|
|
12
17
|
export declare function getFormFieldContext(): FormFieldContext | undefined;
|
|
18
|
+
export {};
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { getContext } from 'svelte';
|
|
2
2
|
export const FORM_LAYOUT_CONTEXT = Symbol('hai-form-layout');
|
|
3
3
|
export const FORM_FIELD_CONTEXT = Symbol('hai-form-field');
|
|
4
|
+
export const FORM_VISIBILITY_CONTEXT = Symbol('hai-form-visibility');
|
|
5
|
+
export function getFormVisibilityContext() {
|
|
6
|
+
return getContext(FORM_VISIBILITY_CONTEXT);
|
|
7
|
+
}
|
|
4
8
|
export function getFormLayoutContext() {
|
|
5
9
|
return getContext(FORM_LAYOUT_CONTEXT);
|
|
6
10
|
}
|
|
@@ -9,7 +9,7 @@ type FormControl = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
|
|
|
9
9
|
export declare function resolveFormText(text?: FormText): string;
|
|
10
10
|
export declare function createControlValidation(getOverride?: () => FormText | undefined, getValue?: () => unknown): {
|
|
11
11
|
readonly message: string;
|
|
12
|
-
|
|
12
|
+
validate(control: FormControl): void;
|
|
13
13
|
input(control: FormControl): void;
|
|
14
14
|
reset: () => void;
|
|
15
15
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { untrack } from 'svelte';
|
|
2
2
|
import { getUILocale, uiM } from '../messages.js';
|
|
3
|
+
import { getFormVisibilityContext } from './form-context.js';
|
|
3
4
|
export function resolveFormText(text) {
|
|
4
5
|
if (typeof text !== 'function')
|
|
5
6
|
return text ?? '';
|
|
@@ -15,9 +16,12 @@ function readIssue(control) {
|
|
|
15
16
|
return { key: 'form_required' };
|
|
16
17
|
if (validity.typeMismatch)
|
|
17
18
|
return { key: control.getAttribute('type') === 'email' ? 'form_email' : 'form_url' };
|
|
18
|
-
|
|
19
|
+
// 程序回填的值也需校验长度,原生 tooShort/tooLong 只覆盖用户输入。
|
|
20
|
+
const minLength = control.getAttribute('minlength');
|
|
21
|
+
const maxLength = control.getAttribute('maxlength');
|
|
22
|
+
if (validity.tooShort || (control.value && minLength !== null && control.value.length < Number(minLength)))
|
|
19
23
|
return { key: 'form_min_length', params: { min: control.getAttribute('minlength') ?? '' } };
|
|
20
|
-
if (validity.tooLong)
|
|
24
|
+
if (validity.tooLong || (control.value && maxLength !== null && control.value.length > Number(maxLength)))
|
|
21
25
|
return { key: 'form_max_length', params: { max: control.getAttribute('maxlength') ?? '' } };
|
|
22
26
|
if (validity.rangeUnderflow)
|
|
23
27
|
return { key: 'form_min', params: { min: control.getAttribute('min') ?? '' } };
|
|
@@ -32,12 +36,18 @@ function readIssue(control) {
|
|
|
32
36
|
return undefined;
|
|
33
37
|
}
|
|
34
38
|
export function createControlValidation(getOverride = () => undefined, getValue) {
|
|
39
|
+
const visibility = getFormVisibilityContext();
|
|
35
40
|
let issue = $state();
|
|
36
41
|
let currentControl = $state();
|
|
37
42
|
function reset() {
|
|
38
43
|
issue = undefined;
|
|
39
44
|
currentControl?.setCustomValidity('');
|
|
40
45
|
}
|
|
46
|
+
$effect(() => {
|
|
47
|
+
// 弹窗与抽屉保留子树;关闭时清理原生错误,重开不能沿用上次的触碰状态。
|
|
48
|
+
if (visibility && !visibility.open)
|
|
49
|
+
reset();
|
|
50
|
+
});
|
|
41
51
|
$effect(() => {
|
|
42
52
|
const form = currentControl?.form;
|
|
43
53
|
form?.addEventListener('reset', reset);
|
|
@@ -59,7 +69,7 @@ export function createControlValidation(getOverride = () => undefined, getValue)
|
|
|
59
69
|
get message() {
|
|
60
70
|
return issue ? resolveFormText(getOverride()) || uiM(issue.key, issue.params) : '';
|
|
61
71
|
},
|
|
62
|
-
|
|
72
|
+
validate(control) {
|
|
63
73
|
currentControl = control;
|
|
64
74
|
control.setCustomValidity('');
|
|
65
75
|
issue = readIssue(control);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@h-ai/ui",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.1.0-alpha.
|
|
4
|
+
"version": "0.1.0-alpha.65",
|
|
5
5
|
"description": "Hai Framework UI component library for Svelte 5 Runes.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"svelte": "./dist/index.js",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"svelte": "^5.57.2"
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@h-ai/core": "0.1.0-alpha.
|
|
62
|
+
"@h-ai/core": "0.1.0-alpha.65",
|
|
63
63
|
"@internationalized/date": "^3.12.4",
|
|
64
64
|
"@shikijs/langs": "^4.5.0",
|
|
65
65
|
"bits-ui": "^2.19.5",
|