@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.
- package/README.en.md +37 -7
- package/README.ja.md +37 -7
- package/README.md +37 -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/Drawer.svelte +5 -1
- 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/Modal.svelte +5 -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 +23 -11
- package/dist/components/primitives/Select.svelte +90 -18
- package/dist/components/primitives/Textarea.svelte +28 -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 +158 -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 +66 -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 +30 -10
- package/dist/components/scenes/iam/PasswordInput.svelte.d.ts +3 -3
- 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 +14 -7
- 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 +12 -10
- package/dist/i18n.svelte.d.ts +2 -1
- package/dist/i18n.svelte.js +2 -1
- package/dist/internal/form-context.d.ts +18 -0
- package/dist/internal/form-context.js +13 -0
- package/dist/internal/form-validation.svelte.d.ts +16 -0
- package/dist/internal/form-validation.svelte.js +86 -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>
|
|
@@ -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>()
|
|
@@ -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
|
}
|
|
@@ -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
|
-
|
|
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
|
|
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 ||
|
|
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
|
|
|
@@ -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
|
-
{
|
|
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='{
|
|
339
|
+
aria-controls='{controlId}-listbox'
|
|
279
340
|
aria-expanded={isDropdownOpen}
|
|
280
341
|
/>
|
|
281
342
|
{:else}
|
|
282
343
|
<!-- 普通模式:只读 input 显示选中值 -->
|
|
283
344
|
<input
|
|
284
|
-
{
|
|
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
|
-
|
|
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='{
|
|
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
|
-
<
|
|
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
|
-
|
|
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.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
|
-
<
|
|
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;
|