@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.
- package/README.en.md +33 -7
- package/README.ja.md +33 -7
- package/README.md +33 -7
- package/auto-import.d.ts +1 -0
- package/auto-import.js +1 -0
- package/dist/components/compounds/Combobox.svelte +19 -6
- package/dist/components/compounds/Combobox.svelte.d.ts +2 -2
- package/dist/components/compounds/DatePicker.svelte +14 -5
- package/dist/components/compounds/DatePicker.svelte.d.ts +2 -2
- package/dist/components/compounds/Form.svelte +10 -6
- package/dist/components/compounds/FormField.svelte +78 -13
- package/dist/components/compounds/FormLayout.svelte +59 -0
- package/dist/components/compounds/FormLayout.svelte.d.ts +4 -0
- package/dist/components/compounds/TagInput.svelte +1 -1
- package/dist/components/compounds/index.d.ts +1 -0
- package/dist/components/compounds/index.js +1 -0
- package/dist/components/primitives/BareInput.svelte +7 -1
- package/dist/components/primitives/Checkbox.svelte +8 -2
- package/dist/components/primitives/Input.svelte +22 -11
- package/dist/components/primitives/Select.svelte +77 -18
- package/dist/components/primitives/Textarea.svelte +27 -13
- package/dist/components/primitives/Textarea.svelte.d.ts +1 -1
- package/dist/components/scenes/app/FeedbackModal.svelte +29 -19
- package/dist/components/scenes/app/FeedbackModal.svelte.d.ts +2 -2
- package/dist/components/scenes/crud/CrudEditPanel.svelte +148 -118
- package/dist/components/scenes/crud/CrudEditPanel.svelte.d.ts +4 -25
- package/dist/components/scenes/crud/CrudPage.svelte +5 -41
- package/dist/components/scenes/crud/CrudPage.svelte.d.ts +2 -53
- package/dist/components/scenes/crud/crud-controller.svelte.d.ts +4 -1
- package/dist/components/scenes/crud/crud-form-validation.d.ts +4 -0
- package/dist/components/scenes/crud/crud-form-validation.js +39 -0
- package/dist/components/scenes/crud/crud-types.d.ts +9 -42
- package/dist/components/scenes/iam/AuthPanel.svelte +55 -47
- package/dist/components/scenes/iam/ChangePasswordForm.svelte +57 -59
- package/dist/components/scenes/iam/ForgotPasswordForm.svelte +89 -84
- package/dist/components/scenes/iam/LoginForm.svelte +134 -132
- package/dist/components/scenes/iam/PasswordInput.svelte +25 -7
- package/dist/components/scenes/iam/PasswordInput.svelte.d.ts +2 -2
- package/dist/components/scenes/iam/RegisterForm.svelte +95 -97
- package/dist/components/scenes/iam/ResetPasswordForm.svelte +83 -85
- package/dist/components/scenes/iam/UserProfile.svelte +9 -5
- package/dist/components/scenes/storage/FileUpload.svelte +1 -1
- package/dist/components/scenes/storage/ImageUpload.svelte +1 -0
- package/dist/components/scenes/types.d.ts +9 -9
- package/dist/i18n.svelte.d.ts +2 -1
- package/dist/i18n.svelte.js +2 -1
- package/dist/internal/form-context.d.ts +12 -0
- package/dist/internal/form-context.js +9 -0
- package/dist/internal/form-validation.svelte.d.ts +16 -0
- package/dist/internal/form-validation.svelte.js +76 -0
- package/dist/internal/locale.d.ts +2 -0
- package/dist/internal/locale.js +22 -0
- package/dist/messages/en-US.json +13 -1
- package/dist/messages/zh-CN.json +13 -1
- package/dist/messages.js +4 -3
- package/dist/types.d.ts +57 -10
- 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>
|
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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='{
|
|
327
|
+
aria-controls='{controlId}-listbox'
|
|
279
328
|
aria-expanded={isDropdownOpen}
|
|
280
329
|
/>
|
|
281
330
|
{:else}
|
|
282
331
|
<!-- 普通模式:只读 input 显示选中值 -->
|
|
283
332
|
<input
|
|
284
|
-
{
|
|
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
|
-
|
|
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='{
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
71
|
+
textareaRef.setCustomValidity(validation.message)
|
|
60
72
|
}
|
|
61
73
|
})
|
|
62
74
|
|
|
@@ -70,17 +82,14 @@
|
|
|
70
82
|
}
|
|
71
83
|
|
|
72
84
|
// 输入时重置自定义验证
|
|
73
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
<
|
|
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;
|