@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
|
@@ -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,20 @@
|
|
|
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
|
+
let touched = $state<Record<string, boolean>>({})
|
|
83
|
+
$effect(() => {
|
|
84
|
+
if (!open) {
|
|
85
|
+
attempted = false
|
|
86
|
+
touched = {}
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
function resolveOptions(options?: CrudFieldDef['options']): Array<{ label: string, value: string | number | boolean }> {
|
|
87
90
|
if (!options)
|
|
88
91
|
return []
|
|
89
92
|
return typeof options === 'function' ? options() : options
|
|
@@ -92,15 +95,25 @@
|
|
|
92
95
|
const sortedFields = $derived(
|
|
93
96
|
[...fields].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
|
|
94
97
|
)
|
|
98
|
+
const fieldErrors = $derived.by(() => {
|
|
99
|
+
const errors: Record<string, string> = {}
|
|
100
|
+
for (const field of sortedFields) {
|
|
101
|
+
if ((!attempted && !touched[field.id]) || (mode === 'edit' && field.readonly))
|
|
102
|
+
continue
|
|
103
|
+
const issue = getCrudValidationIssue(field, formData[field.id])
|
|
104
|
+
if (issue)
|
|
105
|
+
errors[field.id] = field.validation?.message ? resolveText(field.validation.message) : uiM(issue.key, issue.params)
|
|
106
|
+
}
|
|
107
|
+
return errors
|
|
108
|
+
})
|
|
95
109
|
|
|
96
110
|
const isCompact = $derived(density === 'compact')
|
|
97
111
|
const controlSize = $derived(isCompact ? 'xs' : 'sm')
|
|
98
|
-
const formClass = $derived(isCompact ? 'space-y-3' : 'space-y-4')
|
|
99
112
|
const extraClass = $derived(isCompact ? 'border-t border-base-content/5 pt-3' : 'border-t border-base-content/5 pt-4')
|
|
100
113
|
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
114
|
const multiSelectLabelClass = $derived(isCompact ? 'inline-flex items-center gap-1.5 cursor-pointer' : 'inline-flex items-center gap-2 cursor-pointer')
|
|
102
115
|
const multiSelectTextClass = $derived(isCompact ? 'text-xs text-base-content' : 'text-sm text-base-content')
|
|
103
|
-
const drawerFormClass = $derived(isCompact ? '
|
|
116
|
+
const drawerFormClass = $derived(isCompact ? 'pb-16' : 'pb-20')
|
|
104
117
|
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
118
|
|
|
106
119
|
function handleClose() {
|
|
@@ -110,6 +123,14 @@
|
|
|
110
123
|
|
|
111
124
|
async function handleSubmit(e: Event) {
|
|
112
125
|
e.preventDefault()
|
|
126
|
+
if (submitting)
|
|
127
|
+
return
|
|
128
|
+
attempted = true
|
|
129
|
+
if (Object.keys(fieldErrors).length) {
|
|
130
|
+
await tick()
|
|
131
|
+
formElement?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus()
|
|
132
|
+
return
|
|
133
|
+
}
|
|
113
134
|
await onsubmit?.(formData)
|
|
114
135
|
}
|
|
115
136
|
|
|
@@ -120,8 +141,6 @@
|
|
|
120
141
|
case 'tel': return 'tel'
|
|
121
142
|
case 'password': return 'password'
|
|
122
143
|
case 'number': return 'number'
|
|
123
|
-
case 'date': return 'text'
|
|
124
|
-
case 'datetime': return 'text'
|
|
125
144
|
default: return 'text'
|
|
126
145
|
}
|
|
127
146
|
}
|
|
@@ -130,6 +149,15 @@
|
|
|
130
149
|
formData = { ...formData, [fieldId]: value }
|
|
131
150
|
}
|
|
132
151
|
|
|
152
|
+
function handleFieldBlur(event: FocusEvent) {
|
|
153
|
+
if (!(event.target instanceof HTMLElement))
|
|
154
|
+
return
|
|
155
|
+
const field = event.target.closest<HTMLElement>('[data-field]')
|
|
156
|
+
if (!field?.dataset.field || (event.relatedTarget instanceof Node && field.contains(event.relatedTarget)))
|
|
157
|
+
return
|
|
158
|
+
touched = { ...touched, [field.dataset.field]: true }
|
|
159
|
+
}
|
|
160
|
+
|
|
133
161
|
function toggleMultiSelect(fieldId: string, optValue: string | number | boolean) {
|
|
134
162
|
const current = (formData[fieldId] as Array<string | number | boolean>) ?? []
|
|
135
163
|
const strVal = String(optValue)
|
|
@@ -143,113 +171,122 @@
|
|
|
143
171
|
</script>
|
|
144
172
|
|
|
145
173
|
{#snippet formFields()}
|
|
146
|
-
{
|
|
147
|
-
|
|
148
|
-
<
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
174
|
+
<FormLayout {columns} {labelPosition} {labelWidth} class={isCompact ? '[--hai-form-gap:0.75rem]' : ''}>
|
|
175
|
+
{#if error}
|
|
176
|
+
<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'>
|
|
177
|
+
<span class='icon-[tabler--alert-circle] size-4 shrink-0'></span>
|
|
178
|
+
<span>{error}</span>
|
|
179
|
+
</div>
|
|
180
|
+
{/if}
|
|
152
181
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
182
|
+
{#each sortedFields as field (field.id)}
|
|
183
|
+
{@const isReadonly = field.readonly && mode === 'edit'}
|
|
184
|
+
{@const isRequired = field.validation?.required}
|
|
185
|
+
{@const fieldValue = formData[field.id]}
|
|
186
|
+
{@const placeholder = field.placeholder ? resolveText(field.placeholder) : ''}
|
|
187
|
+
{@const controlId = `${panelId}-${field.id}`}
|
|
188
|
+
<FormField
|
|
189
|
+
for={controlId}
|
|
190
|
+
label={resolveText(field.label)}
|
|
191
|
+
required={isRequired}
|
|
192
|
+
error={fieldErrors[field.id]}
|
|
193
|
+
colSpan={field.colSpan ?? (['textarea', 'multi-select', 'date-range', 'custom'].includes(field.type) ? 2 : 'auto')}
|
|
194
|
+
data-field={field.id}
|
|
195
|
+
>
|
|
158
196
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
197
|
+
{#if field.type === 'textarea'}
|
|
198
|
+
<Textarea
|
|
199
|
+
id={controlId}
|
|
200
|
+
name={field.id}
|
|
201
|
+
size={controlSize}
|
|
202
|
+
value={String(fieldValue ?? '')}
|
|
203
|
+
{placeholder}
|
|
204
|
+
disabled={submitting || isReadonly}
|
|
205
|
+
required={isRequired}
|
|
206
|
+
minlength={field.validation?.min}
|
|
207
|
+
maxlength={field.validation?.max}
|
|
208
|
+
oninput={e => updateField(field.id, (e.target as HTMLTextAreaElement).value)}
|
|
209
|
+
/>
|
|
171
210
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
{
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
211
|
+
{:else if field.type === 'select' || field.type === 'radio'}
|
|
212
|
+
{@const opts = resolveOptions(field.options)}
|
|
213
|
+
<Select
|
|
214
|
+
id={controlId}
|
|
215
|
+
name={field.id}
|
|
216
|
+
required={isRequired}
|
|
217
|
+
size={controlSize}
|
|
218
|
+
value={String(fieldValue ?? '')}
|
|
219
|
+
disabled={submitting || isReadonly}
|
|
220
|
+
options={[
|
|
221
|
+
{ value: '', label: placeholder || uiM('crud_filter_all') },
|
|
222
|
+
...opts.map(opt => ({ value: String(opt.value), label: opt.label })),
|
|
223
|
+
]}
|
|
224
|
+
onchange={value => updateField(field.id, opts.find(opt => String(opt.value) === value)?.value ?? value)}
|
|
225
|
+
/>
|
|
187
226
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
</div>
|
|
208
|
-
</FormField>
|
|
227
|
+
{:else if field.type === 'multi-select'}
|
|
228
|
+
{@const opts = resolveOptions(field.options)}
|
|
229
|
+
{@const selectedValues = ((fieldValue ?? []) as Array<string | number | boolean>).map(String)}
|
|
230
|
+
<div id={controlId} role='group' tabindex='-1' aria-label={resolveText(field.label)} class={multiSelectWrapClass} data-form-wide>
|
|
231
|
+
{#each opts as opt (String(opt.value))}
|
|
232
|
+
<div class={multiSelectLabelClass}>
|
|
233
|
+
<Checkbox
|
|
234
|
+
label={opt.label}
|
|
235
|
+
size={controlSize}
|
|
236
|
+
checked={selectedValues.includes(String(opt.value))}
|
|
237
|
+
onchange={() => toggleMultiSelect(field.id, opt.value)}
|
|
238
|
+
disabled={submitting || isReadonly}
|
|
239
|
+
/>
|
|
240
|
+
</div>
|
|
241
|
+
{/each}
|
|
242
|
+
{#if opts.length === 0}
|
|
243
|
+
<span class={multiSelectTextClass}>{uiM('crud_no_data')}</span>
|
|
244
|
+
{/if}
|
|
245
|
+
</div>
|
|
209
246
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
</FormField>
|
|
247
|
+
{:else if field.type === 'boolean' || field.type === 'checkbox'}
|
|
248
|
+
<Checkbox
|
|
249
|
+
id={controlId}
|
|
250
|
+
size={controlSize}
|
|
251
|
+
checked={Boolean(fieldValue)}
|
|
252
|
+
onchange={(checked: boolean) => updateField(field.id, checked)}
|
|
253
|
+
disabled={submitting || isReadonly}
|
|
254
|
+
/>
|
|
219
255
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
256
|
+
{:else}
|
|
257
|
+
<Input
|
|
258
|
+
id={controlId}
|
|
259
|
+
name={field.id}
|
|
260
|
+
type={getInputType(field.type)}
|
|
261
|
+
size={controlSize}
|
|
262
|
+
value={String(fieldValue ?? '')}
|
|
263
|
+
{placeholder}
|
|
264
|
+
disabled={submitting || isReadonly}
|
|
265
|
+
required={isRequired}
|
|
266
|
+
pattern={field.validation?.pattern}
|
|
267
|
+
minlength={field.type !== 'number' ? field.validation?.min : undefined}
|
|
268
|
+
maxlength={field.type !== 'number' ? field.validation?.max : undefined}
|
|
269
|
+
min={field.type === 'number' ? field.validation?.min : undefined}
|
|
270
|
+
max={field.type === 'number' ? field.validation?.max : undefined}
|
|
271
|
+
oninput={e => updateField(field.id, field.type === 'number' && e.currentTarget.value !== '' ? Number(e.currentTarget.value) : e.currentTarget.value)}
|
|
272
|
+
/>
|
|
273
|
+
{/if}
|
|
237
274
|
</FormField>
|
|
238
|
-
{/
|
|
239
|
-
{/each}
|
|
275
|
+
{/each}
|
|
240
276
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
277
|
+
{#if editFormExtra}
|
|
278
|
+
<div class={extraClass}>
|
|
279
|
+
{@render editFormExtra(editingItem, mode)}
|
|
280
|
+
</div>
|
|
281
|
+
{/if}
|
|
282
|
+
</FormLayout>
|
|
246
283
|
{/snippet}
|
|
247
284
|
|
|
248
285
|
{#snippet footerActions()}
|
|
249
286
|
<Button variant='ghost' size='sm' onclick={handleClose} disabled={submitting}>
|
|
250
287
|
{uiM('crud_cancel')}
|
|
251
288
|
</Button>
|
|
252
|
-
<Button variant='primary' size='sm' onclick={
|
|
289
|
+
<Button variant='primary' size='sm' onclick={() => formElement?.requestSubmit()} disabled={submitting}>
|
|
253
290
|
{#if submitting}
|
|
254
291
|
<span class='loading loading-spinner loading-xs mr-2'></span>
|
|
255
292
|
{/if}
|
|
@@ -268,7 +305,9 @@
|
|
|
268
305
|
closeOnBackdrop={false}
|
|
269
306
|
onclose={handleClose}
|
|
270
307
|
>
|
|
271
|
-
<form onsubmit={handleSubmit}
|
|
308
|
+
<form bind:this={formElement} novalidate onsubmit={handleSubmit} onfocusout={handleFieldBlur}>
|
|
309
|
+
<!-- 底部按钮在表单外;保留原生提交控件,使多字段表单支持回车提交。 -->
|
|
310
|
+
<input type='submit' class='hidden' tabindex='-1' aria-hidden='true' value={uiM('crud_save')} />
|
|
272
311
|
{@render formFields()}
|
|
273
312
|
</form>
|
|
274
313
|
|
|
@@ -289,7 +328,8 @@
|
|
|
289
328
|
onclose={handleClose}
|
|
290
329
|
closeOnBackdrop={false}
|
|
291
330
|
>
|
|
292
|
-
<form onsubmit={handleSubmit} class={drawerFormClass}>
|
|
331
|
+
<form bind:this={formElement} novalidate onsubmit={handleSubmit} onfocusout={handleFieldBlur} class={drawerFormClass}>
|
|
332
|
+
<input type='submit' class='hidden' tabindex='-1' aria-hidden='true' value={uiM('crud_save')} />
|
|
293
333
|
{@render formFields()}
|
|
294
334
|
</form>
|
|
295
335
|
|
|
@@ -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}
|