@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
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
/>
|
|
13
13
|
-->
|
|
14
14
|
<script lang='ts'>
|
|
15
|
+
import FormField from '../../compounds/FormField.svelte'
|
|
16
|
+
import FormLayout from '../../compounds/FormLayout.svelte'
|
|
15
17
|
import Modal from '../../compounds/Modal.svelte'
|
|
16
18
|
import Button from '../../primitives/Button.svelte'
|
|
17
19
|
import Input from '../../primitives/Input.svelte'
|
|
@@ -47,18 +49,18 @@
|
|
|
47
49
|
}
|
|
48
50
|
}
|
|
49
51
|
|
|
50
|
-
import type { DataAttributes } from '../../../types.js'
|
|
52
|
+
import type { DataAttributes, FormLayoutOptions } from '../../../types.js'
|
|
51
53
|
import { uiM } from '../../../messages.js'
|
|
52
54
|
import { getDataAttributes } from '../../../utils.js'
|
|
53
55
|
|
|
54
|
-
interface Props {
|
|
56
|
+
interface Props extends FormLayoutOptions {
|
|
55
57
|
open?: boolean
|
|
56
58
|
labels?: FeedbackLabels
|
|
57
59
|
onsubmit?: (data: FeedbackData) => Promise<void>
|
|
58
60
|
class?: string
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
let { open = $bindable(false), labels = {}, onsubmit, class: className = '', ...restProps }: Props & DataAttributes = $props()
|
|
63
|
+
let { open = $bindable(false), labels = {}, columns = 1, labelPosition = 'top', labelWidth = '8rem', onsubmit, class: className = '', ...restProps }: Props & DataAttributes = $props()
|
|
62
64
|
|
|
63
65
|
const dataAttributes = $derived(getDataAttributes(restProps))
|
|
64
66
|
// 文案优先使用传入的 labels,缺省回退到内置消息 uiM(...)
|
|
@@ -67,7 +69,9 @@
|
|
|
67
69
|
let description = $state('')
|
|
68
70
|
let contact = $state('')
|
|
69
71
|
let loading = $state(false)
|
|
70
|
-
let error = $state('')
|
|
72
|
+
let error = $state<'empty' | 'submit' | ''>('')
|
|
73
|
+
let submitError = $state('')
|
|
74
|
+
const errorText = $derived(error === 'empty' ? labels.errorEmpty ?? uiM('feedback_error_empty') : error === 'submit' ? submitError || labels.errorSubmit || uiM('feedback_error_submit') : '')
|
|
71
75
|
|
|
72
76
|
const typeOptions = $derived([
|
|
73
77
|
{ value: 'bug', label: labels.types?.bug ?? uiM('feedback_type_bug') },
|
|
@@ -77,8 +81,10 @@
|
|
|
77
81
|
])
|
|
78
82
|
|
|
79
83
|
async function handleSubmit() {
|
|
84
|
+
if (loading)
|
|
85
|
+
return
|
|
80
86
|
if (!description.trim()) {
|
|
81
|
-
error =
|
|
87
|
+
error = 'empty'
|
|
82
88
|
return
|
|
83
89
|
}
|
|
84
90
|
|
|
@@ -95,7 +101,8 @@
|
|
|
95
101
|
resetForm()
|
|
96
102
|
}
|
|
97
103
|
catch (e) {
|
|
98
|
-
|
|
104
|
+
submitError = e instanceof Error ? e.message : ''
|
|
105
|
+
error = 'submit'
|
|
99
106
|
}
|
|
100
107
|
finally {
|
|
101
108
|
loading = false
|
|
@@ -107,41 +114,44 @@
|
|
|
107
114
|
description = ''
|
|
108
115
|
contact = ''
|
|
109
116
|
error = ''
|
|
117
|
+
submitError = ''
|
|
110
118
|
}
|
|
111
119
|
</script>
|
|
112
120
|
|
|
113
121
|
<Modal {...dataAttributes} class={className} bind:open title={labels.title ?? uiM('feedback_title')}>
|
|
114
122
|
<p class='text-base-content/70 mb-4'>{labels.description ?? uiM('feedback_description')}</p>
|
|
115
123
|
|
|
116
|
-
{#if error}
|
|
124
|
+
{#if error === 'submit'}
|
|
117
125
|
<div class='alert alert-error mb-4'>
|
|
118
126
|
<span class='icon-[tabler--alert-circle] size-5'></span>
|
|
119
|
-
<span>{
|
|
127
|
+
<span>{errorText}</span>
|
|
120
128
|
</div>
|
|
121
129
|
{/if}
|
|
122
130
|
|
|
123
|
-
<
|
|
124
|
-
<
|
|
125
|
-
<legend class='fieldset-legend'>{labels.typeLabel ?? uiM('feedback_type_label')}</legend>
|
|
131
|
+
<FormLayout {columns} {labelPosition} {labelWidth}>
|
|
132
|
+
<FormField for='feedback-type' label={labels.typeLabel ?? uiM('feedback_type_label')}>
|
|
126
133
|
<Select
|
|
127
134
|
id='feedback-type'
|
|
128
135
|
options={typeOptions}
|
|
129
136
|
bind:value={feedbackType}
|
|
130
137
|
/>
|
|
131
|
-
</
|
|
138
|
+
</FormField>
|
|
132
139
|
|
|
133
|
-
<
|
|
134
|
-
<legend class='fieldset-legend'>{labels.contentLabel ?? uiM('feedback_content_label')} <span class='text-error'>*</span></legend>
|
|
140
|
+
<FormField for='feedback-desc' label={labels.contentLabel ?? uiM('feedback_content_label')} required error={error === 'empty' ? errorText : ''}>
|
|
135
141
|
<Textarea
|
|
136
142
|
id='feedback-desc'
|
|
137
143
|
class='h-32'
|
|
138
144
|
placeholder={labels.contentPlaceholder ?? uiM('feedback_content_placeholder')}
|
|
139
145
|
bind:value={description}
|
|
146
|
+
disabled={loading}
|
|
147
|
+
oninput={() => {
|
|
148
|
+
if (description.trim())
|
|
149
|
+
error = ''
|
|
150
|
+
}}
|
|
140
151
|
/>
|
|
141
|
-
</
|
|
152
|
+
</FormField>
|
|
142
153
|
|
|
143
|
-
<
|
|
144
|
-
<legend class='fieldset-legend'>{labels.contactLabel ?? uiM('feedback_contact_label')}</legend>
|
|
154
|
+
<FormField for='feedback-contact' label={labels.contactLabel ?? uiM('feedback_contact_label')}>
|
|
145
155
|
<Input
|
|
146
156
|
id='feedback-contact'
|
|
147
157
|
type='text'
|
|
@@ -150,8 +160,8 @@
|
|
|
150
160
|
bind:value={contact}
|
|
151
161
|
autocomplete='email'
|
|
152
162
|
/>
|
|
153
|
-
</
|
|
154
|
-
</
|
|
163
|
+
</FormField>
|
|
164
|
+
</FormLayout>
|
|
155
165
|
|
|
156
166
|
{#snippet footer()}
|
|
157
167
|
<Button variant='ghost' onclick={() => open = false}>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DataAttributes } from '../../../types.js';
|
|
1
|
+
import type { DataAttributes, FormLayoutOptions } from '../../../types.js';
|
|
2
2
|
type FeedbackType = 'bug' | 'feature' | 'question' | 'other';
|
|
3
3
|
interface FeedbackData {
|
|
4
4
|
type: FeedbackType;
|
|
@@ -25,7 +25,7 @@ interface FeedbackLabels {
|
|
|
25
25
|
other?: string;
|
|
26
26
|
};
|
|
27
27
|
}
|
|
28
|
-
interface Props {
|
|
28
|
+
interface Props extends FormLayoutOptions {
|
|
29
29
|
open?: boolean;
|
|
30
30
|
labels?: FeedbackLabels;
|
|
31
31
|
onsubmit?: (data: FeedbackData) => Promise<void>;
|
|
@@ -7,29 +7,23 @@
|
|
|
7
7
|
-->
|
|
8
8
|
<script lang='ts'>
|
|
9
9
|
import type { Snippet } from 'svelte'
|
|
10
|
-
import type { DataAttributes, Size } from '../../../types.js'
|
|
10
|
+
import type { DataAttributes, FormLayoutOptions, Size } from '../../../types.js'
|
|
11
|
+
import type { CrudFieldDef } from './crud-controller.svelte.js'
|
|
11
12
|
import type { CrudDensity, CrudFormVariant } from './crud-types.js'
|
|
13
|
+
import { tick } from 'svelte'
|
|
14
|
+
import { resolveFormText as resolveText } from '../../../internal/form-validation.svelte.js'
|
|
12
15
|
import { uiM } from '../../../messages.js'
|
|
13
16
|
import { getDataAttributes } from '../../../utils.js'
|
|
14
17
|
import Drawer from '../../compounds/Drawer.svelte'
|
|
15
18
|
import FormField from '../../compounds/FormField.svelte'
|
|
19
|
+
import FormLayout from '../../compounds/FormLayout.svelte'
|
|
16
20
|
import Modal from '../../compounds/Modal.svelte'
|
|
17
21
|
import Button from '../../primitives/Button.svelte'
|
|
18
22
|
import Checkbox from '../../primitives/Checkbox.svelte'
|
|
19
23
|
import Input from '../../primitives/Input.svelte'
|
|
20
24
|
import Select from '../../primitives/Select.svelte'
|
|
21
25
|
import Textarea from '../../primitives/Textarea.svelte'
|
|
22
|
-
|
|
23
|
-
type FieldDef = {
|
|
24
|
-
id: string
|
|
25
|
-
label: string | (() => string)
|
|
26
|
-
type: string
|
|
27
|
-
readonly?: boolean
|
|
28
|
-
options?: Array<{ label: string, value: string | number | boolean }> | (() => Array<{ label: string, value: string | number | boolean }>)
|
|
29
|
-
validation?: { required?: boolean, min?: number, max?: number, pattern?: string, message?: string }
|
|
30
|
-
placeholder?: string | (() => string)
|
|
31
|
-
order?: number
|
|
32
|
-
}
|
|
26
|
+
import { getCrudValidationIssue } from './crud-form-validation.js'
|
|
33
27
|
|
|
34
28
|
let {
|
|
35
29
|
open = $bindable(false),
|
|
@@ -47,6 +41,9 @@
|
|
|
47
41
|
modalWidth,
|
|
48
42
|
modalHeight,
|
|
49
43
|
submitting = false,
|
|
44
|
+
columns = 1,
|
|
45
|
+
labelPosition = 'top',
|
|
46
|
+
labelWidth = '8rem',
|
|
50
47
|
error = '',
|
|
51
48
|
onsubmit,
|
|
52
49
|
onclose,
|
|
@@ -57,7 +54,7 @@
|
|
|
57
54
|
}: {
|
|
58
55
|
open?: boolean
|
|
59
56
|
mode?: 'create' | 'edit'
|
|
60
|
-
fields?:
|
|
57
|
+
fields?: CrudFieldDef[]
|
|
61
58
|
formData?: Record<string, unknown>
|
|
62
59
|
density?: CrudDensity
|
|
63
60
|
title?: string
|
|
@@ -76,14 +73,17 @@
|
|
|
76
73
|
editFormExtra?: Snippet<[Record<string, unknown> | null, 'create' | 'edit']>
|
|
77
74
|
editingItem?: Record<string, unknown> | null
|
|
78
75
|
class?: string
|
|
79
|
-
} & DataAttributes = $props()
|
|
76
|
+
} & FormLayoutOptions & DataAttributes = $props()
|
|
80
77
|
|
|
81
78
|
const dataAttributes = $derived(getDataAttributes(restProps))
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
79
|
+
const panelId = $props.id()
|
|
80
|
+
let formElement: HTMLFormElement | undefined = $state()
|
|
81
|
+
let attempted = $state(false)
|
|
82
|
+
$effect(() => {
|
|
83
|
+
if (!open)
|
|
84
|
+
attempted = false
|
|
85
|
+
})
|
|
86
|
+
function resolveOptions(options?: CrudFieldDef['options']): Array<{ label: string, value: string | number | boolean }> {
|
|
87
87
|
if (!options)
|
|
88
88
|
return []
|
|
89
89
|
return typeof options === 'function' ? options() : options
|
|
@@ -92,15 +92,27 @@
|
|
|
92
92
|
const sortedFields = $derived(
|
|
93
93
|
[...fields].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
|
|
94
94
|
)
|
|
95
|
+
const fieldErrors = $derived.by(() => {
|
|
96
|
+
const errors: Record<string, string> = {}
|
|
97
|
+
if (!attempted)
|
|
98
|
+
return errors
|
|
99
|
+
for (const field of sortedFields) {
|
|
100
|
+
if (mode === 'edit' && field.readonly)
|
|
101
|
+
continue
|
|
102
|
+
const issue = getCrudValidationIssue(field, formData[field.id])
|
|
103
|
+
if (issue)
|
|
104
|
+
errors[field.id] = field.validation?.message ? resolveText(field.validation.message) : uiM(issue.key, issue.params)
|
|
105
|
+
}
|
|
106
|
+
return errors
|
|
107
|
+
})
|
|
95
108
|
|
|
96
109
|
const isCompact = $derived(density === 'compact')
|
|
97
110
|
const controlSize = $derived(isCompact ? 'xs' : 'sm')
|
|
98
|
-
const formClass = $derived(isCompact ? 'space-y-3' : 'space-y-4')
|
|
99
111
|
const extraClass = $derived(isCompact ? 'border-t border-base-content/5 pt-3' : 'border-t border-base-content/5 pt-4')
|
|
100
112
|
const multiSelectWrapClass = $derived(isCompact ? 'flex flex-wrap gap-2 p-2.5 bg-base-200 rounded-lg max-h-48 overflow-y-auto' : 'flex flex-wrap gap-3 p-3 bg-base-200 rounded-lg max-h-48 overflow-y-auto')
|
|
101
113
|
const multiSelectLabelClass = $derived(isCompact ? 'inline-flex items-center gap-1.5 cursor-pointer' : 'inline-flex items-center gap-2 cursor-pointer')
|
|
102
114
|
const multiSelectTextClass = $derived(isCompact ? 'text-xs text-base-content' : 'text-sm text-base-content')
|
|
103
|
-
const drawerFormClass = $derived(isCompact ? '
|
|
115
|
+
const drawerFormClass = $derived(isCompact ? 'pb-16' : 'pb-20')
|
|
104
116
|
const drawerFooterClass = $derived(isCompact ? 'absolute bottom-0 left-0 right-0 p-3 bg-base-200 border-t border-base-content/10 flex justify-end gap-2' : 'absolute bottom-0 left-0 right-0 p-4 bg-base-200 border-t border-base-content/10 flex justify-end gap-2')
|
|
105
117
|
|
|
106
118
|
function handleClose() {
|
|
@@ -110,6 +122,14 @@
|
|
|
110
122
|
|
|
111
123
|
async function handleSubmit(e: Event) {
|
|
112
124
|
e.preventDefault()
|
|
125
|
+
if (submitting)
|
|
126
|
+
return
|
|
127
|
+
attempted = true
|
|
128
|
+
if (Object.keys(fieldErrors).length) {
|
|
129
|
+
await tick()
|
|
130
|
+
formElement?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus()
|
|
131
|
+
return
|
|
132
|
+
}
|
|
113
133
|
await onsubmit?.(formData)
|
|
114
134
|
}
|
|
115
135
|
|
|
@@ -120,8 +140,6 @@
|
|
|
120
140
|
case 'tel': return 'tel'
|
|
121
141
|
case 'password': return 'password'
|
|
122
142
|
case 'number': return 'number'
|
|
123
|
-
case 'date': return 'text'
|
|
124
|
-
case 'datetime': return 'text'
|
|
125
143
|
default: return 'text'
|
|
126
144
|
}
|
|
127
145
|
}
|
|
@@ -143,113 +161,122 @@
|
|
|
143
161
|
</script>
|
|
144
162
|
|
|
145
163
|
{#snippet formFields()}
|
|
146
|
-
{
|
|
147
|
-
|
|
148
|
-
<
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
164
|
+
<FormLayout {columns} {labelPosition} {labelWidth} class={isCompact ? '[--hai-form-gap:0.75rem]' : ''}>
|
|
165
|
+
{#if error}
|
|
166
|
+
<div {...dataAttributes} role='alert' class='p-3 bg-error/10 border border-error/20 rounded-lg text-sm text-error flex items-center gap-2'>
|
|
167
|
+
<span class='icon-[tabler--alert-circle] size-4 shrink-0'></span>
|
|
168
|
+
<span>{error}</span>
|
|
169
|
+
</div>
|
|
170
|
+
{/if}
|
|
152
171
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
172
|
+
{#each sortedFields as field (field.id)}
|
|
173
|
+
{@const isReadonly = field.readonly && mode === 'edit'}
|
|
174
|
+
{@const isRequired = field.validation?.required}
|
|
175
|
+
{@const fieldValue = formData[field.id]}
|
|
176
|
+
{@const placeholder = field.placeholder ? resolveText(field.placeholder) : ''}
|
|
177
|
+
{@const controlId = `${panelId}-${field.id}`}
|
|
178
|
+
<FormField
|
|
179
|
+
for={controlId}
|
|
180
|
+
label={resolveText(field.label)}
|
|
181
|
+
required={isRequired}
|
|
182
|
+
error={fieldErrors[field.id]}
|
|
183
|
+
colSpan={field.colSpan ?? (['textarea', 'multi-select', 'date-range', 'custom'].includes(field.type) ? 2 : 'auto')}
|
|
184
|
+
data-field={field.id}
|
|
185
|
+
>
|
|
158
186
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
187
|
+
{#if field.type === 'textarea'}
|
|
188
|
+
<Textarea
|
|
189
|
+
id={controlId}
|
|
190
|
+
name={field.id}
|
|
191
|
+
size={controlSize}
|
|
192
|
+
value={String(fieldValue ?? '')}
|
|
193
|
+
{placeholder}
|
|
194
|
+
disabled={submitting || isReadonly}
|
|
195
|
+
required={isRequired}
|
|
196
|
+
minlength={field.validation?.min}
|
|
197
|
+
maxlength={field.validation?.max}
|
|
198
|
+
oninput={e => updateField(field.id, (e.target as HTMLTextAreaElement).value)}
|
|
199
|
+
/>
|
|
171
200
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
{
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
201
|
+
{:else if field.type === 'select' || field.type === 'radio'}
|
|
202
|
+
{@const opts = resolveOptions(field.options)}
|
|
203
|
+
<Select
|
|
204
|
+
id={controlId}
|
|
205
|
+
name={field.id}
|
|
206
|
+
required={isRequired}
|
|
207
|
+
size={controlSize}
|
|
208
|
+
value={String(fieldValue ?? '')}
|
|
209
|
+
disabled={submitting || isReadonly}
|
|
210
|
+
options={[
|
|
211
|
+
{ value: '', label: placeholder || uiM('crud_filter_all') },
|
|
212
|
+
...opts.map(opt => ({ value: String(opt.value), label: opt.label })),
|
|
213
|
+
]}
|
|
214
|
+
onchange={value => updateField(field.id, opts.find(opt => String(opt.value) === value)?.value ?? value)}
|
|
215
|
+
/>
|
|
187
216
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
</div>
|
|
208
|
-
</FormField>
|
|
217
|
+
{:else if field.type === 'multi-select'}
|
|
218
|
+
{@const opts = resolveOptions(field.options)}
|
|
219
|
+
{@const selectedValues = ((fieldValue ?? []) as Array<string | number | boolean>).map(String)}
|
|
220
|
+
<div id={controlId} role='group' tabindex='-1' aria-label={resolveText(field.label)} class={multiSelectWrapClass} data-form-wide>
|
|
221
|
+
{#each opts as opt (String(opt.value))}
|
|
222
|
+
<div class={multiSelectLabelClass}>
|
|
223
|
+
<Checkbox
|
|
224
|
+
label={opt.label}
|
|
225
|
+
size={controlSize}
|
|
226
|
+
checked={selectedValues.includes(String(opt.value))}
|
|
227
|
+
onchange={() => toggleMultiSelect(field.id, opt.value)}
|
|
228
|
+
disabled={submitting || isReadonly}
|
|
229
|
+
/>
|
|
230
|
+
</div>
|
|
231
|
+
{/each}
|
|
232
|
+
{#if opts.length === 0}
|
|
233
|
+
<span class={multiSelectTextClass}>{uiM('crud_no_data')}</span>
|
|
234
|
+
{/if}
|
|
235
|
+
</div>
|
|
209
236
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
</FormField>
|
|
237
|
+
{:else if field.type === 'boolean' || field.type === 'checkbox'}
|
|
238
|
+
<Checkbox
|
|
239
|
+
id={controlId}
|
|
240
|
+
size={controlSize}
|
|
241
|
+
checked={Boolean(fieldValue)}
|
|
242
|
+
onchange={(checked: boolean) => updateField(field.id, checked)}
|
|
243
|
+
disabled={submitting || isReadonly}
|
|
244
|
+
/>
|
|
219
245
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
246
|
+
{:else}
|
|
247
|
+
<Input
|
|
248
|
+
id={controlId}
|
|
249
|
+
name={field.id}
|
|
250
|
+
type={getInputType(field.type)}
|
|
251
|
+
size={controlSize}
|
|
252
|
+
value={String(fieldValue ?? '')}
|
|
253
|
+
{placeholder}
|
|
254
|
+
disabled={submitting || isReadonly}
|
|
255
|
+
required={isRequired}
|
|
256
|
+
pattern={field.validation?.pattern}
|
|
257
|
+
minlength={field.type !== 'number' ? field.validation?.min : undefined}
|
|
258
|
+
maxlength={field.type !== 'number' ? field.validation?.max : undefined}
|
|
259
|
+
min={field.type === 'number' ? field.validation?.min : undefined}
|
|
260
|
+
max={field.type === 'number' ? field.validation?.max : undefined}
|
|
261
|
+
oninput={e => updateField(field.id, field.type === 'number' && e.currentTarget.value !== '' ? Number(e.currentTarget.value) : e.currentTarget.value)}
|
|
262
|
+
/>
|
|
263
|
+
{/if}
|
|
237
264
|
</FormField>
|
|
238
|
-
{/
|
|
239
|
-
{/each}
|
|
265
|
+
{/each}
|
|
240
266
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
267
|
+
{#if editFormExtra}
|
|
268
|
+
<div class={extraClass}>
|
|
269
|
+
{@render editFormExtra(editingItem, mode)}
|
|
270
|
+
</div>
|
|
271
|
+
{/if}
|
|
272
|
+
</FormLayout>
|
|
246
273
|
{/snippet}
|
|
247
274
|
|
|
248
275
|
{#snippet footerActions()}
|
|
249
276
|
<Button variant='ghost' size='sm' onclick={handleClose} disabled={submitting}>
|
|
250
277
|
{uiM('crud_cancel')}
|
|
251
278
|
</Button>
|
|
252
|
-
<Button variant='primary' size='sm' onclick={
|
|
279
|
+
<Button variant='primary' size='sm' onclick={() => formElement?.requestSubmit()} disabled={submitting}>
|
|
253
280
|
{#if submitting}
|
|
254
281
|
<span class='loading loading-spinner loading-xs mr-2'></span>
|
|
255
282
|
{/if}
|
|
@@ -268,7 +295,9 @@
|
|
|
268
295
|
closeOnBackdrop={false}
|
|
269
296
|
onclose={handleClose}
|
|
270
297
|
>
|
|
271
|
-
<form
|
|
298
|
+
<form bind:this={formElement} novalidate onsubmit={handleSubmit}>
|
|
299
|
+
<!-- 底部按钮在表单外;保留原生提交控件,使多字段表单支持回车提交。 -->
|
|
300
|
+
<input type='submit' class='hidden' tabindex='-1' aria-hidden='true' value={uiM('crud_save')} />
|
|
272
301
|
{@render formFields()}
|
|
273
302
|
</form>
|
|
274
303
|
|
|
@@ -289,7 +318,8 @@
|
|
|
289
318
|
onclose={handleClose}
|
|
290
319
|
closeOnBackdrop={false}
|
|
291
320
|
>
|
|
292
|
-
<form onsubmit={handleSubmit} class={drawerFormClass}>
|
|
321
|
+
<form bind:this={formElement} novalidate onsubmit={handleSubmit} class={drawerFormClass}>
|
|
322
|
+
<input type='submit' class='hidden' tabindex='-1' aria-hidden='true' value={uiM('crud_save')} />
|
|
293
323
|
{@render formFields()}
|
|
294
324
|
</form>
|
|
295
325
|
|
|
@@ -1,32 +1,11 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
|
-
import type { DataAttributes, Size } from '../../../types.js';
|
|
2
|
+
import type { DataAttributes, FormLayoutOptions, Size } from '../../../types.js';
|
|
3
|
+
import type { CrudFieldDef } from './crud-controller.svelte.js';
|
|
3
4
|
import type { CrudDensity, CrudFormVariant } from './crud-types.js';
|
|
4
|
-
type FieldDef = {
|
|
5
|
-
id: string;
|
|
6
|
-
label: string | (() => string);
|
|
7
|
-
type: string;
|
|
8
|
-
readonly?: boolean;
|
|
9
|
-
options?: Array<{
|
|
10
|
-
label: string;
|
|
11
|
-
value: string | number | boolean;
|
|
12
|
-
}> | (() => Array<{
|
|
13
|
-
label: string;
|
|
14
|
-
value: string | number | boolean;
|
|
15
|
-
}>);
|
|
16
|
-
validation?: {
|
|
17
|
-
required?: boolean;
|
|
18
|
-
min?: number;
|
|
19
|
-
max?: number;
|
|
20
|
-
pattern?: string;
|
|
21
|
-
message?: string;
|
|
22
|
-
};
|
|
23
|
-
placeholder?: string | (() => string);
|
|
24
|
-
order?: number;
|
|
25
|
-
};
|
|
26
5
|
type $$ComponentProps = {
|
|
27
6
|
open?: boolean;
|
|
28
7
|
mode?: 'create' | 'edit';
|
|
29
|
-
fields?:
|
|
8
|
+
fields?: CrudFieldDef[];
|
|
30
9
|
formData?: Record<string, unknown>;
|
|
31
10
|
density?: CrudDensity;
|
|
32
11
|
title?: string;
|
|
@@ -45,7 +24,7 @@ type $$ComponentProps = {
|
|
|
45
24
|
editFormExtra?: Snippet<[Record<string, unknown> | null, 'create' | 'edit']>;
|
|
46
25
|
editingItem?: Record<string, unknown> | null;
|
|
47
26
|
class?: string;
|
|
48
|
-
} & DataAttributes;
|
|
27
|
+
} & FormLayoutOptions & DataAttributes;
|
|
49
28
|
/**
|
|
50
29
|
* CrudEditPanel
|
|
51
30
|
* CRUD 编辑/新建表单组件,支持抽屉(drawer)与弹窗(modal)两种展示形式
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
<script lang='ts'>
|
|
14
14
|
import type { Snippet } from 'svelte'
|
|
15
15
|
import type { DataAttributes } from '../../../types.js'
|
|
16
|
+
import type { CrudFieldDef } from './crud-controller.svelte.js'
|
|
16
17
|
import type { CrudDensity, CrudFormConfig, CrudPaginationConfig, CrudToolbarStyle } from './crud-types.js'
|
|
17
18
|
import type { NavAdapter } from './nav-adapter.js'
|
|
18
19
|
import { SvelteURLSearchParams } from 'svelte/reactivity'
|
|
@@ -33,47 +34,7 @@
|
|
|
33
34
|
|
|
34
35
|
// ─── 类型定义 ───
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
type FieldDef = {
|
|
38
|
-
/** 字段稳定标识,同时对应数据记录中的属性名。 */
|
|
39
|
-
id: string
|
|
40
|
-
/** 字段显示文案,允许通过函数延迟读取当前语言。 */
|
|
41
|
-
label: string | (() => string)
|
|
42
|
-
/** 字段控件类型,最终由表单或筛选器解释。 */
|
|
43
|
-
type: string
|
|
44
|
-
/** 是否出现在列表中;未设置时默认显示。 */
|
|
45
|
-
inList?: boolean
|
|
46
|
-
/** 是否出现在筛选器中;只有显式为 true 时显示。 */
|
|
47
|
-
filterable?: boolean
|
|
48
|
-
/** 编辑时是否禁止修改。 */
|
|
49
|
-
readonly?: boolean
|
|
50
|
-
/** 是否出现在新建表单中;未设置时默认显示。 */
|
|
51
|
-
inCreate?: boolean
|
|
52
|
-
/** 是否出现在编辑表单中;未设置时默认显示。 */
|
|
53
|
-
inEdit?: boolean
|
|
54
|
-
/** 是否出现在详情面板中;未设置时默认显示。 */
|
|
55
|
-
inDetail?: boolean
|
|
56
|
-
/** 选择类字段的静态选项或动态选项工厂。 */
|
|
57
|
-
options?: Array<{ label: string, value: string | number | boolean }> | (() => Array<{ label: string, value: string | number | boolean }>)
|
|
58
|
-
/** 表单校验规则。 */
|
|
59
|
-
validation?: { required?: boolean, min?: number, max?: number, pattern?: string, message?: string }
|
|
60
|
-
/** 列宽 CSS 值。 */
|
|
61
|
-
width?: string
|
|
62
|
-
/** 单元格对齐方式。 */
|
|
63
|
-
align?: 'left' | 'center' | 'right'
|
|
64
|
-
/** 列表单元格的自定义渲染函数。 */
|
|
65
|
-
render?: (value: unknown, item: Record<string, unknown>) => string
|
|
66
|
-
/** 输入控件占位文案。 */
|
|
67
|
-
placeholder?: string | (() => string)
|
|
68
|
-
/** 日期范围起始值 key。 */
|
|
69
|
-
startKey?: string
|
|
70
|
-
/** 日期范围结束值 key。 */
|
|
71
|
-
endKey?: string
|
|
72
|
-
/** 新建记录时使用的默认值。 */
|
|
73
|
-
defaultValue?: unknown
|
|
74
|
-
/** 字段排序权重,数值越小越靠前。 */
|
|
75
|
-
order?: number
|
|
76
|
-
}
|
|
37
|
+
type FieldDef = CrudFieldDef
|
|
77
38
|
|
|
78
39
|
// 使用 method shorthand 语法(双向协变)以兼容来自 @h-ai/kit 的 CrudOperations 类型
|
|
79
40
|
// 详见:https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-6.html#strict-function-types
|
|
@@ -911,6 +872,9 @@
|
|
|
911
872
|
modalHeight={formModalHeight}
|
|
912
873
|
{submitting}
|
|
913
874
|
error={formError}
|
|
875
|
+
columns={form?.columns}
|
|
876
|
+
labelPosition={form?.labelPosition}
|
|
877
|
+
labelWidth={form?.labelWidth}
|
|
914
878
|
onsubmit={handleSubmit}
|
|
915
879
|
onclose={closePanel}
|
|
916
880
|
{editFormExtra}
|