@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
@@ -0,0 +1,59 @@
1
+ <!-- 表单字段布局,供 Form 和原生表单/场景组件共同复用。 -->
2
+ <script lang='ts'>
3
+ import type { FormLayoutProps } from '../../types.js'
4
+ import { setContext } from 'svelte'
5
+ import { FORM_LAYOUT_CONTEXT } from '../../internal/form-context.js'
6
+ import { cn, getDataAttributes } from '../../utils.js'
7
+
8
+ const {
9
+ columns = 1,
10
+ labelPosition = 'top',
11
+ labelWidth = '8rem',
12
+ class: className = '',
13
+ children,
14
+ ...restProps
15
+ }: FormLayoutProps = $props()
16
+
17
+ const dataAttributes = $derived(getDataAttributes(restProps))
18
+ setContext(FORM_LAYOUT_CONTEXT, {
19
+ get labelPosition() { return labelPosition },
20
+ get labelWidth() { return labelWidth },
21
+ })
22
+ </script>
23
+
24
+ <div {...dataAttributes} class={cn('hai-form-layout', className)}>
25
+ <div class='hai-form-grid' data-columns={columns}>
26
+ {#if children}{@render children()}{/if}
27
+ </div>
28
+ </div>
29
+
30
+ <style>
31
+ .hai-form-layout {
32
+ container: hai-form / inline-size;
33
+ width: 100%;
34
+ min-width: 0;
35
+ }
36
+
37
+ .hai-form-grid {
38
+ display: grid;
39
+ grid-template-columns: minmax(0, 1fr);
40
+ gap: var(--hai-form-gap, 1rem);
41
+ align-items: start;
42
+ }
43
+
44
+ .hai-form-grid > :global(*) {
45
+ min-width: 0;
46
+ }
47
+
48
+ @container hai-form (min-width: 36rem) {
49
+ .hai-form-grid[data-columns='2'] {
50
+ grid-template-columns: repeat(2, minmax(0, 1fr));
51
+ }
52
+
53
+ .hai-form-grid[data-columns='2'] > :global(:not(.hai-form-field)),
54
+ .hai-form-grid[data-columns='2'] > :global(.hai-form-field[data-col-span='2']),
55
+ .hai-form-grid[data-columns='2'] > :global(.hai-form-field[data-col-span='auto']:has(textarea, input[type='file'], [data-form-wide], [aria-multiselectable='true'])) {
56
+ grid-column: 1 / -1;
57
+ }
58
+ }
59
+ </style>
@@ -0,0 +1,4 @@
1
+ import type { FormLayoutProps } from '../../types.js';
2
+ declare const FormLayout: import("svelte").Component<FormLayoutProps, {}, "">;
3
+ type FormLayout = ReturnType<typeof FormLayout>;
4
+ export default FormLayout;
@@ -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>()
@@ -82,7 +82,7 @@
82
82
  }
83
83
  </script>
84
84
 
85
- <div {...dataAttributes} class='fieldset w-full'>
85
+ <div {...dataAttributes} class='fieldset w-full' data-form-wide>
86
86
  <div
87
87
  class={containerClass}
88
88
  onclick={() => inputElement?.focus()}
@@ -24,6 +24,7 @@ export { default as Dropdown } from './Dropdown.svelte';
24
24
  export { default as Empty } from './Empty.svelte';
25
25
  export { default as Form } from './Form.svelte';
26
26
  export { default as FormField } from './FormField.svelte';
27
+ export { default as FormLayout } from './FormLayout.svelte';
27
28
  export { default as InfiniteScroll } from './InfiniteScroll.svelte';
28
29
  export { default as MessageBoxContainer } from './MessageBoxContainer.svelte';
29
30
  export { default as MessageBoxDialog } from './MessageBoxDialog.svelte';
@@ -31,6 +31,7 @@ export { default as Empty } from './Empty.svelte';
31
31
  // 表单组合
32
32
  export { default as Form } from './Form.svelte';
33
33
  export { default as FormField } from './FormField.svelte';
34
+ export { default as FormLayout } from './FormLayout.svelte';
34
35
  // 无限滚动 / 上拉加载
35
36
  export { default as InfiniteScroll } from './InfiniteScroll.svelte';
36
37
  // MessageBox 命令式弹框(类似 ElementUI MessageBox)
@@ -9,6 +9,7 @@
9
9
  -->
10
10
  <script lang='ts'>
11
11
  import type { BareInputProps, DataAttributes } from '../../types.js'
12
+ import { getFormFieldContext } from '../../internal/form-context.js'
12
13
  import { getDataAttributes } from '../../utils.js'
13
14
 
14
15
  let {
@@ -37,6 +38,8 @@
37
38
  ariaExpanded,
38
39
  ariaControls,
39
40
  ariaAutocomplete,
41
+ ariaInvalid,
42
+ ariaDescribedby,
40
43
  oninput,
41
44
  onchange,
42
45
  onkeydown,
@@ -47,6 +50,7 @@
47
50
  }: BareInputProps & DataAttributes = $props()
48
51
 
49
52
  const dataAttributes = $derived(getDataAttributes(restProps))
53
+ const field = getFormFieldContext()
50
54
  function handleInput(e: Event & { currentTarget: HTMLInputElement }) {
51
55
  // 文件输入的 value 由浏览器管理,不能把 fakepath 写回原生控件。
52
56
  if (type !== 'file')
@@ -73,7 +77,9 @@
73
77
 
74
78
  <input {...dataAttributes}
75
79
  bind:this={inputRef}
76
- {id}
80
+ id={id ?? field?.controlId}
81
+ aria-invalid={ariaInvalid ?? (field?.error ? true : undefined)}
82
+ aria-describedby={ariaDescribedby ?? field?.messageId}
77
83
  {name}
78
84
  {type}
79
85
  {placeholder}
@@ -9,10 +9,12 @@
9
9
  -->
10
10
  <script lang='ts'>
11
11
  import type { CheckboxProps, DataAttributes } from '../../types.js'
12
- import { cn, generateId, getDataAttributes, getSizeClass } from '../../utils.js'
12
+ import { getFormFieldContext } from '../../internal/form-context.js'
13
+ import { cn, getDataAttributes, getSizeClass } from '../../utils.js'
13
14
 
14
15
  let {
15
16
  checked = $bindable(false),
17
+ id: providedId,
16
18
  label = '',
17
19
  size = 'md',
18
20
  disabled = false,
@@ -24,7 +26,9 @@
24
26
  }: CheckboxProps & DataAttributes = $props()
25
27
 
26
28
  const dataAttributes = $derived(getDataAttributes(restProps))
27
- const id = generateId('checkbox')
29
+ const field = getFormFieldContext()
30
+ const uniqueId = $props.id()
31
+ const id = $derived(providedId ?? uniqueId)
28
32
 
29
33
  const checkboxClass = $derived(
30
34
  cn(
@@ -46,6 +50,8 @@
46
50
  <label class='label cursor-pointer gap-2' for={id}>
47
51
  <input
48
52
  {id}
53
+ aria-invalid={field?.error ? true : undefined}
54
+ aria-describedby={field?.messageId}
49
55
  type='checkbox'
50
56
  class={checkboxClass}
51
57
  disabled={disabled || readonly}
@@ -4,6 +4,8 @@
4
4
  -->
5
5
  <script lang='ts'>
6
6
  import type { DataAttributes, InputProps } from '../../types.js'
7
+ import { getFormFieldContext } from '../../internal/form-context.js'
8
+ import { createControlValidation, resolveFormText } from '../../internal/form-validation.svelte.js'
7
9
  import { cn, getDataAttributes } from '../../utils.js'
8
10
  import { getFormControlSizeClasses } from '../control-size.js'
9
11
 
@@ -40,6 +42,17 @@
40
42
  }: InputProps & DataAttributes = $props()
41
43
 
42
44
  const dataAttributes = $derived(getDataAttributes(restProps))
45
+ const field = getFormFieldContext()
46
+ const validation = createControlValidation(() => validationMessage, () => [value, required, readonly, disabled, pattern, min, max, minlength, maxlength, step])
47
+ const uniqueId = $props.id()
48
+ const messageId = `${uniqueId}-error`
49
+ const localError = $derived(resolveFormText(error) || validation.message)
50
+ const effectiveError = $derived(field?.error || localError)
51
+ $effect(() => {
52
+ // 修正输入后清除旧的 customError,切换语言时同步原生提示。
53
+ if (inputRef)
54
+ inputRef.setCustomValidity(validation.message)
55
+ })
43
56
  const sizeClasses = $derived(getFormControlSizeClasses(size))
44
57
 
45
58
  const inputTypeClass = $derived(
@@ -52,7 +65,7 @@
52
65
  cn(
53
66
  'flex items-center w-full rounded-lg border bg-base-100',
54
67
  sizeClasses.height,
55
- error
68
+ effectiveError
56
69
  ? 'border-error/60 focus-within:ring-2 focus-within:ring-error/15'
57
70
  : 'border-base-content/15 focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/10',
58
71
  'transition-[border-color,box-shadow] duration-150',
@@ -62,9 +75,7 @@
62
75
  )
63
76
 
64
77
  function handleInput(e: Event & { currentTarget: HTMLInputElement }) {
65
- if (validationMessage) {
66
- e.currentTarget.setCustomValidity('')
67
- }
78
+ validation.input(e.currentTarget)
68
79
  value = e.currentTarget.value
69
80
  oninput?.(e)
70
81
  }
@@ -78,6 +89,7 @@
78
89
  }
79
90
 
80
91
  function handleBlur(e: FocusEvent & { currentTarget: HTMLInputElement }) {
92
+ validation.validate(e.currentTarget)
81
93
  onblur?.(e)
82
94
  }
83
95
 
@@ -86,9 +98,7 @@
86
98
  }
87
99
 
88
100
  function handleInvalid(e: Event & { currentTarget: HTMLInputElement }) {
89
- if (validationMessage) {
90
- e.currentTarget.setCustomValidity(validationMessage)
91
- }
101
+ validation.validate(e.currentTarget)
92
102
  oninvalid?.(e)
93
103
  }
94
104
  </script>
@@ -97,7 +107,9 @@
97
107
  <div class={containerClass}>
98
108
  <input
99
109
  bind:this={inputRef}
100
- {id}
110
+ id={id ?? field?.controlId}
111
+ aria-invalid={effectiveError ? true : undefined}
112
+ aria-describedby={[field?.messageId, localError && !field?.error ? messageId : ''].filter(Boolean).join(' ') || undefined}
101
113
  {name}
102
114
  {type}
103
115
  {placeholder}
@@ -114,7 +126,7 @@
114
126
  inputmode={inputmode}
115
127
  autocomplete={autocomplete as HTMLInputElement['autocomplete']}
116
128
  class={cn(
117
- 'flex-1 h-full bg-transparent border-none outline-none placeholder:text-base-content/35',
129
+ 'flex-1 min-w-0 h-full bg-transparent border-none outline-none placeholder:text-base-content/35',
118
130
  sizeClasses.spacing,
119
131
  inputTypeClass,
120
132
  )}
@@ -127,7 +139,7 @@
127
139
  oninvalid={handleInvalid}
128
140
  />
129
141
  </div>
130
- {#if error}
131
- <p class='mt-1.5 text-xs text-error/80 leading-tight'>{error}</p>
142
+ {#if localError && !field?.error}
143
+ <p id={messageId} class='mt-1.5 text-xs text-error leading-relaxed break-words' aria-live='polite'>{localError}</p>
132
144
  {/if}
133
145
  </div>
@@ -10,7 +10,10 @@
10
10
  -->
11
11
  <script lang='ts' generics="T = string">
12
12
  import type { DataAttributes, SelectProps } from '../../types.js'
13
- import { tick } from 'svelte'
13
+ import { tick, untrack } from 'svelte'
14
+ import { getFormFieldContext } from '../../internal/form-context.js'
15
+ import { createControlValidation, resolveFormText } from '../../internal/form-validation.svelte.js'
16
+ import { uiM } from '../../messages.js'
14
17
  import { cn, getDataAttributes, portal } from '../../utils.js'
15
18
  import { getFormControlSizeClasses } from '../control-size.js'
16
19
 
@@ -24,13 +27,38 @@
24
27
  clearable = false,
25
28
  filterable = false,
26
29
  error = '',
30
+ validationMessage = '',
27
31
  class: className = '',
28
32
  id,
33
+ name,
29
34
  onchange,
30
35
  ...restProps
31
36
  }: SelectProps<T> & DataAttributes = $props()
32
37
 
33
38
  const dataAttributes = $derived(getDataAttributes(restProps))
39
+ const field = getFormFieldContext()
40
+ const uniqueId = $props.id()
41
+ const controlId = $derived(id ?? field?.controlId ?? `${uniqueId}-control`)
42
+ const messageId = `${uniqueId}-error`
43
+ const validation = createControlValidation(() => validationMessage)
44
+ const localError = $derived(resolveFormText(error) || validation.message)
45
+ const effectiveError = $derived(field?.error || localError)
46
+ let nativeSelect: HTMLSelectElement | undefined = $state()
47
+ // 独立保存原生选中值,筛选输入的文字不能代替表单实际选项。
48
+ const nativeValue = $derived(value === undefined || value === null ? '' : String(value))
49
+ $effect(() => {
50
+ void options
51
+ void required
52
+ void disabled
53
+ if (nativeSelect) {
54
+ nativeSelect.value = nativeValue
55
+ untrack(() => validation.input(nativeSelect!))
56
+ }
57
+ })
58
+ $effect(() => {
59
+ if (nativeSelect)
60
+ nativeSelect.setCustomValidity(validation.message)
61
+ })
34
62
  let inputRef: HTMLInputElement | undefined = $state()
35
63
  let dropdownRef: HTMLDivElement | undefined = $state()
36
64
  let triggerRef: HTMLDivElement | undefined = $state()
@@ -58,7 +86,7 @@
58
86
 
59
87
  // 获取当前选中的标签
60
88
  const selectedLabel = $derived(() => {
61
- if (!options || !value)
89
+ if (!options || value === undefined || value === null)
62
90
  return ''
63
91
  const selected = options.find(opt => opt.value === value)
64
92
  return selected ? selected.label : ''
@@ -79,7 +107,7 @@
79
107
  cn(
80
108
  'relative flex w-full min-w-0 items-center rounded-lg border bg-base-100 cursor-pointer',
81
109
  sizeClasses.control,
82
- error
110
+ effectiveError
83
111
  ? 'border-error/60 focus-within:ring-2 focus-within:ring-error/15'
84
112
  : isDropdownOpen
85
113
  ? 'border-primary/50 ring-2 ring-primary/10'
@@ -95,7 +123,7 @@
95
123
  'flex-1 min-w-0 bg-transparent outline-none border-none',
96
124
  'focus:outline-none focus:ring-0 focus:shadow-none',
97
125
  'placeholder:text-base-content/40',
98
- error && 'text-error',
126
+ effectiveError && 'text-error',
99
127
  ),
100
128
  )
101
129
 
@@ -230,6 +258,17 @@
230
258
  onchange?.(undefined as T)
231
259
  }
232
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
+
233
272
  // 判断是否显示清除按钮
234
273
  const showClear = $derived(clearable && value !== undefined && value !== null && value !== '' && isHovered)
235
274
  </script>
@@ -238,12 +277,32 @@
238
277
  {...dataAttributes}
239
278
  class='fieldset w-full min-w-0'
240
279
  bind:this={dropdownRef}
241
- role='combobox'
242
- aria-expanded={isDropdownOpen}
243
- aria-haspopup='listbox'
244
280
  onmouseenter={() => isHovered = true}
245
281
  onmouseleave={() => isHovered = false}
282
+ onfocusout={handleFocusOut}
246
283
  >
284
+ <select
285
+ bind:this={nativeSelect}
286
+ class='sr-only'
287
+ tabindex='-1'
288
+ aria-hidden='true'
289
+ {name}
290
+ {required}
291
+ {disabled}
292
+ value={nativeValue}
293
+ oninvalid={(e) => {
294
+ e.preventDefault()
295
+ validation.validate(e.currentTarget)
296
+ inputRef?.focus()
297
+ }}
298
+ >
299
+ <option value=''></option>
300
+ {#each options ?? [] as option (String(option.value))}
301
+ {#if String(option.value) !== ''}
302
+ <option value={String(option.value)} disabled={option.disabled}>{option.label}</option>
303
+ {/if}
304
+ {/each}
305
+ </select>
247
306
  <div
248
307
  class={wrapperClass}
249
308
  bind:this={triggerRef}
@@ -260,11 +319,12 @@
260
319
  <!-- 可筛选模式:可输入的 input -->
261
320
  <input
262
321
  bind:this={inputRef}
263
- {id}
322
+ id={controlId}
323
+ aria-invalid={effectiveError ? true : undefined}
324
+ aria-describedby={[field?.messageId, localError && !field?.error ? messageId : ''].filter(Boolean).join(' ') || undefined}
264
325
  type='text'
265
326
  class={inputClass}
266
327
  {disabled}
267
- {required}
268
328
  {placeholder}
269
329
  value={isDropdownOpen ? filterText : selectedLabel()}
270
330
  oninput={handleFilterInput}
@@ -274,22 +334,33 @@
274
334
  isDropdownOpen = true
275
335
  }}
276
336
  role='combobox'
337
+ aria-haspopup='listbox'
277
338
  aria-autocomplete='list'
278
- aria-controls='{id}-listbox'
339
+ aria-controls='{controlId}-listbox'
279
340
  aria-expanded={isDropdownOpen}
280
341
  />
281
342
  {:else}
282
343
  <!-- 普通模式:只读 input 显示选中值 -->
283
344
  <input
284
- {id}
345
+ bind:this={inputRef}
346
+ id={controlId}
347
+ aria-invalid={effectiveError ? true : undefined}
348
+ aria-describedby={[field?.messageId, localError && !field?.error ? messageId : ''].filter(Boolean).join(' ') || undefined}
285
349
  type='text'
286
350
  class={inputClass}
287
351
  readonly
288
352
  {disabled}
289
- {required}
290
353
  {placeholder}
291
354
  value={selectedLabel()}
292
- tabindex='-1'
355
+ onkeydown={handleInputKeydown}
356
+ onfocus={() => {
357
+ if (!disabled)
358
+ isDropdownOpen = true
359
+ }}
360
+ role='combobox'
361
+ aria-haspopup='listbox'
362
+ aria-controls='{controlId}-listbox'
363
+ aria-expanded={isDropdownOpen}
293
364
  />
294
365
  {/if}
295
366
 
@@ -301,7 +372,7 @@
301
372
  class='flex items-center justify-center w-5 h-5 rounded-full hover:bg-base-content/10 transition-colors'
302
373
  onclick={handleClear}
303
374
  tabindex='-1'
304
- aria-label='清空选择'
375
+ aria-label={uiM('select_clear')}
305
376
  >
306
377
  <svg xmlns='http://www.w3.org/2000/svg' class='h-3.5 w-3.5' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
307
378
  <path stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M6 18L18 6M6 6l12 12' />
@@ -324,10 +395,11 @@
324
395
  <ul
325
396
  bind:this={listboxRef}
326
397
  use:portal
327
- id='{id}-listbox'
398
+ id='{controlId}-listbox'
328
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'
329
400
  style={dropdownStyle}
330
401
  role='listbox'
402
+ onfocusout={handleFocusOut}
331
403
  >
332
404
  {#each filteredOptions() as option (String(option.value))}
333
405
  <li
@@ -356,13 +428,13 @@
356
428
  </li>
357
429
  {:else}
358
430
  <li class={cn('px-3 py-3 text-base-content/40 text-center', optionTextClass)}>
359
- 无匹配选项
431
+ {uiM('select_no_match')}
360
432
  </li>
361
433
  {/each}
362
434
  </ul>
363
435
  {/if}
364
436
  </div>
365
- {#if error}
366
- <span class='fieldset-label text-error'>{error}</span>
437
+ {#if localError && !field?.error}
438
+ <p id={messageId} class='mt-1.5 text-xs text-error leading-relaxed break-words' aria-live='polite'>{localError}</p>
367
439
  {/if}
368
440
  </div>
@@ -10,6 +10,8 @@
10
10
  -->
11
11
  <script lang='ts'>
12
12
  import type { DataAttributes, TextareaProps } from '../../types.js'
13
+ import { getFormFieldContext } from '../../internal/form-context.js'
14
+ import { createControlValidation, resolveFormText } from '../../internal/form-validation.svelte.js'
13
15
  import { cn, getDataAttributes, getInputSizeClass } from '../../utils.js'
14
16
 
15
17
  let {
@@ -23,20 +25,30 @@
23
25
  autoResize = false,
24
26
  error = '',
25
27
  validationMessage = '',
28
+ minlength,
29
+ maxlength,
30
+ inputmode,
31
+ textareaRef = $bindable<HTMLTextAreaElement | undefined>(),
26
32
  class: className = '',
27
33
  id,
28
34
  name,
29
35
  oninput,
36
+ oninvalid,
30
37
  ...restProps
31
38
  }: TextareaProps & DataAttributes = $props()
32
39
 
33
40
  const dataAttributes = $derived(getDataAttributes(restProps))
34
- let textareaRef: HTMLTextAreaElement | undefined = $state()
41
+ const field = getFormFieldContext()
42
+ const validation = createControlValidation(() => validationMessage, () => [value, required, readonly, disabled, minlength, maxlength])
43
+ const uniqueId = $props.id()
44
+ const messageId = `${uniqueId}-error`
45
+ const localError = $derived(resolveFormText(error) || validation.message)
46
+ const effectiveError = $derived(field?.error || localError)
35
47
 
36
48
  const wrapperClass = $derived(
37
49
  cn(
38
50
  'flex w-full rounded-lg border bg-base-100',
39
- error
51
+ effectiveError
40
52
  ? 'border-error/60 focus-within:ring-2 focus-within:ring-error/15'
41
53
  : 'border-base-content/15 focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/10',
42
54
  'transition-[border-color,box-shadow] duration-150',
@@ -48,7 +60,7 @@
48
60
  cn(
49
61
  'textarea w-full bg-transparent border-none outline-none',
50
62
  getInputSizeClass(size),
51
- error && 'textarea-error',
63
+ effectiveError && 'textarea-error',
52
64
  className,
53
65
  ),
54
66
  )
@@ -56,7 +68,7 @@
56
68
  // 当 validationMessage 变化时更新自定义验证消息
57
69
  $effect(() => {
58
70
  if (textareaRef) {
59
- textareaRef.setCustomValidity(validationMessage)
71
+ textareaRef.setCustomValidity(validation.message)
60
72
  }
61
73
  })
62
74
 
@@ -70,17 +82,14 @@
70
82
  }
71
83
 
72
84
  // 输入时重置自定义验证
73
- if (validationMessage) {
74
- e.currentTarget.setCustomValidity(validationMessage)
75
- }
85
+ validation.input(e.currentTarget)
76
86
 
77
87
  oninput?.(e)
78
88
  }
79
89
 
80
90
  function handleInvalid(e: Event & { currentTarget: HTMLTextAreaElement }) {
81
- if (validationMessage) {
82
- e.currentTarget.setCustomValidity(validationMessage)
83
- }
91
+ validation.validate(e.currentTarget)
92
+ oninvalid?.(e)
84
93
  }
85
94
  </script>
86
95
 
@@ -88,20 +97,26 @@
88
97
  <div class={wrapperClass}>
89
98
  <textarea
90
99
  bind:this={textareaRef}
91
- {id}
100
+ id={id ?? field?.controlId}
101
+ aria-invalid={effectiveError ? true : undefined}
102
+ aria-describedby={[field?.messageId, localError && !field?.error ? messageId : ''].filter(Boolean).join(' ') || undefined}
92
103
  {name}
93
104
  {placeholder}
94
105
  {rows}
95
106
  {disabled}
96
107
  {readonly}
97
108
  {required}
109
+ {minlength}
110
+ {maxlength}
111
+ {inputmode}
98
112
  class={textareaClass}
99
113
  bind:value
100
114
  oninput={handleInput}
115
+ onblur={e => validation.validate(e.currentTarget)}
101
116
  oninvalid={handleInvalid}
102
117
  ></textarea>
103
118
  </div>
104
- {#if error}
105
- <span class='fieldset-label text-error'>{error}</span>
119
+ {#if localError && !field?.error}
120
+ <p id={messageId} class='mt-1.5 text-xs text-error leading-relaxed break-words' aria-live='polite'>{localError}</p>
106
121
  {/if}
107
122
  </div>
@@ -1,5 +1,5 @@
1
1
  import type { DataAttributes, TextareaProps } from '../../types.js';
2
2
  type $$ComponentProps = TextareaProps & DataAttributes;
3
- declare const Textarea: import("svelte").Component<$$ComponentProps, {}, "value">;
3
+ declare const Textarea: import("svelte").Component<$$ComponentProps, {}, "value" | "textareaRef">;
4
4
  type Textarea = ReturnType<typeof Textarea>;
5
5
  export default Textarea;