@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
@@ -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;
@@ -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
  }
@@ -86,9 +97,7 @@
86
97
  }
87
98
 
88
99
  function handleInvalid(e: Event & { currentTarget: HTMLInputElement }) {
89
- if (validationMessage) {
90
- e.currentTarget.setCustomValidity(validationMessage)
91
- }
100
+ validation.invalid(e.currentTarget)
92
101
  oninvalid?.(e)
93
102
  }
94
103
  </script>
@@ -97,7 +106,9 @@
97
106
  <div class={containerClass}>
98
107
  <input
99
108
  bind:this={inputRef}
100
- {id}
109
+ id={id ?? field?.controlId}
110
+ aria-invalid={effectiveError ? true : undefined}
111
+ aria-describedby={[field?.messageId, localError && !field?.error ? messageId : ''].filter(Boolean).join(' ') || undefined}
101
112
  {name}
102
113
  {type}
103
114
  {placeholder}
@@ -114,7 +125,7 @@
114
125
  inputmode={inputmode}
115
126
  autocomplete={autocomplete as HTMLInputElement['autocomplete']}
116
127
  class={cn(
117
- 'flex-1 h-full bg-transparent border-none outline-none placeholder:text-base-content/35',
128
+ 'flex-1 min-w-0 h-full bg-transparent border-none outline-none placeholder:text-base-content/35',
118
129
  sizeClasses.spacing,
119
130
  inputTypeClass,
120
131
  )}
@@ -127,7 +138,7 @@
127
138
  oninvalid={handleInvalid}
128
139
  />
129
140
  </div>
130
- {#if error}
131
- <p class='mt-1.5 text-xs text-error/80 leading-tight'>{error}</p>
141
+ {#if localError && !field?.error}
142
+ <p id={messageId} class='mt-1.5 text-xs text-error leading-relaxed break-words' aria-live='polite'>{localError}</p>
132
143
  {/if}
133
144
  </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
 
@@ -238,12 +266,31 @@
238
266
  {...dataAttributes}
239
267
  class='fieldset w-full min-w-0'
240
268
  bind:this={dropdownRef}
241
- role='combobox'
242
- aria-expanded={isDropdownOpen}
243
- aria-haspopup='listbox'
244
269
  onmouseenter={() => isHovered = true}
245
270
  onmouseleave={() => isHovered = false}
246
271
  >
272
+ <select
273
+ bind:this={nativeSelect}
274
+ class='sr-only'
275
+ tabindex='-1'
276
+ aria-hidden='true'
277
+ {name}
278
+ {required}
279
+ {disabled}
280
+ value={nativeValue}
281
+ oninvalid={(e) => {
282
+ e.preventDefault()
283
+ validation.invalid(e.currentTarget)
284
+ inputRef?.focus()
285
+ }}
286
+ >
287
+ <option value=''></option>
288
+ {#each options ?? [] as option (String(option.value))}
289
+ {#if String(option.value) !== ''}
290
+ <option value={String(option.value)} disabled={option.disabled}>{option.label}</option>
291
+ {/if}
292
+ {/each}
293
+ </select>
247
294
  <div
248
295
  class={wrapperClass}
249
296
  bind:this={triggerRef}
@@ -260,11 +307,12 @@
260
307
  <!-- 可筛选模式:可输入的 input -->
261
308
  <input
262
309
  bind:this={inputRef}
263
- {id}
310
+ id={controlId}
311
+ aria-invalid={effectiveError ? true : undefined}
312
+ aria-describedby={[field?.messageId, localError && !field?.error ? messageId : ''].filter(Boolean).join(' ') || undefined}
264
313
  type='text'
265
314
  class={inputClass}
266
315
  {disabled}
267
- {required}
268
316
  {placeholder}
269
317
  value={isDropdownOpen ? filterText : selectedLabel()}
270
318
  oninput={handleFilterInput}
@@ -274,22 +322,33 @@
274
322
  isDropdownOpen = true
275
323
  }}
276
324
  role='combobox'
325
+ aria-haspopup='listbox'
277
326
  aria-autocomplete='list'
278
- aria-controls='{id}-listbox'
327
+ aria-controls='{controlId}-listbox'
279
328
  aria-expanded={isDropdownOpen}
280
329
  />
281
330
  {:else}
282
331
  <!-- 普通模式:只读 input 显示选中值 -->
283
332
  <input
284
- {id}
333
+ bind:this={inputRef}
334
+ id={controlId}
335
+ aria-invalid={effectiveError ? true : undefined}
336
+ aria-describedby={[field?.messageId, localError && !field?.error ? messageId : ''].filter(Boolean).join(' ') || undefined}
285
337
  type='text'
286
338
  class={inputClass}
287
339
  readonly
288
340
  {disabled}
289
- {required}
290
341
  {placeholder}
291
342
  value={selectedLabel()}
292
- tabindex='-1'
343
+ onkeydown={handleInputKeydown}
344
+ onfocus={() => {
345
+ if (!disabled)
346
+ isDropdownOpen = true
347
+ }}
348
+ role='combobox'
349
+ aria-haspopup='listbox'
350
+ aria-controls='{controlId}-listbox'
351
+ aria-expanded={isDropdownOpen}
293
352
  />
294
353
  {/if}
295
354
 
@@ -301,7 +360,7 @@
301
360
  class='flex items-center justify-center w-5 h-5 rounded-full hover:bg-base-content/10 transition-colors'
302
361
  onclick={handleClear}
303
362
  tabindex='-1'
304
- aria-label='清空选择'
363
+ aria-label={uiM('select_clear')}
305
364
  >
306
365
  <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
366
  <path stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M6 18L18 6M6 6l12 12' />
@@ -324,7 +383,7 @@
324
383
  <ul
325
384
  bind:this={listboxRef}
326
385
  use:portal
327
- id='{id}-listbox'
386
+ id='{controlId}-listbox'
328
387
  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
388
  style={dropdownStyle}
330
389
  role='listbox'
@@ -356,13 +415,13 @@
356
415
  </li>
357
416
  {:else}
358
417
  <li class={cn('px-3 py-3 text-base-content/40 text-center', optionTextClass)}>
359
- 无匹配选项
418
+ {uiM('select_no_match')}
360
419
  </li>
361
420
  {/each}
362
421
  </ul>
363
422
  {/if}
364
423
  </div>
365
- {#if error}
366
- <span class='fieldset-label text-error'>{error}</span>
424
+ {#if localError && !field?.error}
425
+ <p id={messageId} class='mt-1.5 text-xs text-error leading-relaxed break-words' aria-live='polite'>{localError}</p>
367
426
  {/if}
368
427
  </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.invalid(e.currentTarget)
92
+ oninvalid?.(e)
84
93
  }
85
94
  </script>
86
95
 
@@ -88,20 +97,25 @@
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}
101
115
  oninvalid={handleInvalid}
102
116
  ></textarea>
103
117
  </div>
104
- {#if error}
105
- <span class='fieldset-label text-error'>{error}</span>
118
+ {#if localError && !field?.error}
119
+ <p id={messageId} class='mt-1.5 text-xs text-error leading-relaxed break-words' aria-live='polite'>{localError}</p>
106
120
  {/if}
107
121
  </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;