@h-ai/ui 0.1.0-alpha.62 → 0.1.0-alpha.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.en.md +33 -7
  2. package/README.ja.md +33 -7
  3. package/README.md +33 -7
  4. package/auto-import.d.ts +1 -0
  5. package/auto-import.js +1 -0
  6. package/dist/components/compounds/Combobox.svelte +19 -6
  7. package/dist/components/compounds/Combobox.svelte.d.ts +2 -2
  8. package/dist/components/compounds/DatePicker.svelte +14 -5
  9. package/dist/components/compounds/DatePicker.svelte.d.ts +2 -2
  10. package/dist/components/compounds/Form.svelte +10 -6
  11. package/dist/components/compounds/FormField.svelte +78 -13
  12. package/dist/components/compounds/FormLayout.svelte +59 -0
  13. package/dist/components/compounds/FormLayout.svelte.d.ts +4 -0
  14. package/dist/components/compounds/TagInput.svelte +1 -1
  15. package/dist/components/compounds/index.d.ts +1 -0
  16. package/dist/components/compounds/index.js +1 -0
  17. package/dist/components/primitives/BareInput.svelte +7 -1
  18. package/dist/components/primitives/Checkbox.svelte +8 -2
  19. package/dist/components/primitives/Input.svelte +22 -11
  20. package/dist/components/primitives/Select.svelte +77 -18
  21. package/dist/components/primitives/Textarea.svelte +27 -13
  22. package/dist/components/primitives/Textarea.svelte.d.ts +1 -1
  23. package/dist/components/scenes/app/FeedbackModal.svelte +29 -19
  24. package/dist/components/scenes/app/FeedbackModal.svelte.d.ts +2 -2
  25. package/dist/components/scenes/crud/CrudEditPanel.svelte +148 -118
  26. package/dist/components/scenes/crud/CrudEditPanel.svelte.d.ts +4 -25
  27. package/dist/components/scenes/crud/CrudPage.svelte +5 -41
  28. package/dist/components/scenes/crud/CrudPage.svelte.d.ts +2 -53
  29. package/dist/components/scenes/crud/crud-controller.svelte.d.ts +4 -1
  30. package/dist/components/scenes/crud/crud-form-validation.d.ts +4 -0
  31. package/dist/components/scenes/crud/crud-form-validation.js +39 -0
  32. package/dist/components/scenes/crud/crud-types.d.ts +9 -42
  33. package/dist/components/scenes/iam/AuthPanel.svelte +55 -47
  34. package/dist/components/scenes/iam/ChangePasswordForm.svelte +57 -59
  35. package/dist/components/scenes/iam/ForgotPasswordForm.svelte +89 -84
  36. package/dist/components/scenes/iam/LoginForm.svelte +134 -132
  37. package/dist/components/scenes/iam/PasswordInput.svelte +25 -7
  38. package/dist/components/scenes/iam/PasswordInput.svelte.d.ts +2 -2
  39. package/dist/components/scenes/iam/RegisterForm.svelte +95 -97
  40. package/dist/components/scenes/iam/ResetPasswordForm.svelte +83 -85
  41. package/dist/components/scenes/iam/UserProfile.svelte +9 -5
  42. package/dist/components/scenes/storage/FileUpload.svelte +1 -1
  43. package/dist/components/scenes/storage/ImageUpload.svelte +1 -0
  44. package/dist/components/scenes/types.d.ts +9 -9
  45. package/dist/i18n.svelte.d.ts +2 -1
  46. package/dist/i18n.svelte.js +2 -1
  47. package/dist/internal/form-context.d.ts +12 -0
  48. package/dist/internal/form-context.js +9 -0
  49. package/dist/internal/form-validation.svelte.d.ts +16 -0
  50. package/dist/internal/form-validation.svelte.js +76 -0
  51. package/dist/internal/locale.d.ts +2 -0
  52. package/dist/internal/locale.js +22 -0
  53. package/dist/messages/en-US.json +13 -1
  54. package/dist/messages/zh-CN.json +13 -1
  55. package/dist/messages.js +4 -3
  56. package/dist/types.d.ts +57 -10
  57. package/package.json +7 -2
package/README.en.md CHANGED
@@ -98,7 +98,7 @@ With auto-import enabled, use components directly in templates without individua
98
98
 
99
99
  > `@h-ai/ui/auto-import` injects all public Svelte components except `toast`, type imports, and `Range` (which conflicts with the DOM `Range` constructor).
100
100
 
101
- Pages using a subset of components, such as administration screens, can import public component-directory entry points directly. Import theme utilities from the lightweight `@h-ai/ui/theme` entry so theme consumers do not load the root entry's AI and chart components. Check the installed package's exports; do not import its internal `src/` or `dist/` paths.
101
+ Pages using a subset of components, such as administration screens, can import public component-directory entry points directly. Import theme utilities from the lightweight `@h-ai/ui/theme` entry so theme consumers do not load the root entry's AI and chart components. Use `@h-ai/ui/i18n` for locale utilities in stores without loading components or tour CSS. Check the installed package's exports; do not import its internal `src/` or `dist/` paths.
102
102
 
103
103
  The preprocessor recognizes semicolon-free, multiline and aliased imports without injecting components already bound locally. Type-only imports do not replace runtime components.
104
104
 
@@ -166,16 +166,42 @@ components/
166
166
 
167
167
  For `BareInput` with `type="file"`, read `event.currentTarget.files` in `onchange` and leave the file path `value` unbound. Other input types support `bind:value`.
168
168
 
169
- ### Compounds (36)
169
+ ### Compounds (37)
170
170
 
171
171
  #### Forms
172
172
 
173
173
  | Component | Description | Key props |
174
174
  | ----------- | -------------- | -------------------------------------------- |
175
- | `Form` | Form container | `loading`, `disabled`, `onsubmit` |
176
- | `FormField` | Form field | `label`, `error`, `hint`, `required` |
175
+ | `Form` | Form container | `columns`, `labelPosition`, `labelWidth`, `loading`, `disabled`, `onsubmit` |
176
+ | `FormField` | Form field | `label`, `for`, `colSpan`, `labelPosition`, `error`, `hint`, `required` |
177
+ | `FormLayout` | Form layout | `columns`, `labelPosition`, `labelWidth` |
177
178
  | `TagInput` | Tag input | `tags`, `maxTags`, `allowDuplicates`, `size` |
178
179
 
180
+ #### Form layouts and validation
181
+
182
+ `Form` and `FormLayout` (for native `<form>` elements) share `columns={1 | 2}`, `labelPosition="top" | "left"` and `labelWidth` (default `8rem`). Defaults are one column and stacked labels. Two columns collapse below a container width of `36rem`; inline labels stack below `28rem`.
183
+
184
+ `FormField.colSpan` defaults to `'auto'`: textareas, file/image uploads, TagInput and multiple Combobox controls span both columns. Use `1` or `2` to override, or mark a custom wide control with `data-form-wide`. Non-field content, including headings, error summaries and actions, spans the full row. Fields may override `labelPosition` / `labelWidth`.
185
+
186
+ Labels connect to nested UI controls automatically; use `<FormField for="control-id">` for explicit IDs or native controls. Errors and hints use `aria-describedby`, invalid controls use `aria-invalid`, and field errors appear once. `label` / `error` / `hint` accept strings or deferred translation functions.
187
+
188
+ `Input`, `Textarea`, `Select` and `PasswordInput` provide Chinese/English native validation messages. `validationMessage` overrides failures without keeping valid input invalid. Sync locale with the UI `setGlobalLocale` / `createLocaleStore` to update visible validation errors immediately. `Textarea` supports `minlength` / `maxlength`; `Select` supports `name`, required validation and FormData serialization.
189
+
190
+ `LoginForm`, `RegisterForm`, `ChangePasswordForm`, `ForgotPasswordForm`, `ResetPasswordForm`, `AuthPanel`, `UserProfile` and `FeedbackModal` accept the same layout options. Pass them through `CrudPage.form` or directly to `CrudEditPanel`. CRUD fields support `colSpan` and deferred `validation.message`. Buttons and Enter both validate before submission; corrections clear errors. Server validation remains required.
191
+
192
+ ```svelte
193
+ <!-- m、save、name、notes 为应用自己的翻译、提交回调和状态 -->
194
+ <Form columns={2} labelPosition="left" labelWidth="6rem" onsubmit={save}>
195
+ <FormField label={() => m.name()} required>
196
+ <Input name="name" bind:value={name} required />
197
+ </FormField>
198
+ <FormField label={() => m.notes()}>
199
+ <Textarea name="notes" bind:value={notes} />
200
+ </FormField>
201
+ <Button type="submit">{m.save()}</Button>
202
+ </Form>
203
+ ```
204
+
179
205
  #### Feedback
180
206
 
181
207
  | Component | Description | Key props |
@@ -531,7 +557,7 @@ Defaults match `@h-ai/crypto`: `SM4` for encrypted inputs, `SM3` for hashes, and
531
557
  crud={roleCrud}
532
558
  {data}
533
559
  permissions={{ create: true, update: true, delete: true }}
534
- form={{ variant: 'modal', modalSize: 'lg' }}
560
+ form={{ variant: 'modal', modalSize: '3xl', columns: 2, labelPosition: 'top' }}
535
561
  pagination={{ showSizeChanger: true, showJumper: true, pageSizeOptions: [10, 20, 50] }}
536
562
  density='compact'
537
563
  detailColumns={['name']}
@@ -698,7 +724,7 @@ Client locale state management, automatically synchronized with the `@h-ai/core`
698
724
 
699
725
  ```svelte
700
726
  <script>
701
- import { createLocaleStore, setGlobalLocale } from '@h-ai/ui'
727
+ import { createLocaleStore, setGlobalLocale } from '@h-ai/ui/i18n'
702
728
  import { setLocale } from '$lib/paraglide/runtime'
703
729
 
704
730
  const localeStore = createLocaleStore()
@@ -729,7 +755,7 @@ import {
729
755
  isLocaleSupported, // 检查 locale 是否支持
730
756
  resolveLocale, // 解析 locale(支持回退)
731
757
  setGlobalLocale, // 设置全局 locale(同步 @h-ai/core)
732
- } from '@h-ai/ui'
758
+ } from '@h-ai/ui/i18n'
733
759
  ```
734
760
 
735
761
  ## Configuration driven authentication
package/README.ja.md CHANGED
@@ -98,7 +98,7 @@ export default defineConfig({
98
98
 
99
99
  > `toast`・型 import・DOM の `Range` と衝突する `Range` を除き、公開 Svelte コンポーネントは `@h-ai/ui/auto-import` で自動注入できます。
100
100
 
101
- 管理画面など一部のコンポーネントを使うページは、公開コンポーネントディレクトリーの入口から直接 import できます。テーマツールは軽量な `@h-ai/ui/theme` から読み込み、テーマの利用だけでルート入口の AI・グラフコンポーネントを読み込まないようにします。インストール済みパッケージの exports を確認し、内部 `src/`・`dist/` は import しないでください。
101
+ 管理画面など一部のコンポーネントを使うページは、公開コンポーネントディレクトリーの入口から直接 import できます。テーマツールは軽量な `@h-ai/ui/theme` から読み込み、テーマの利用だけでルート入口の AI・グラフコンポーネントを読み込まないようにします。ストアの言語ツールは `@h-ai/ui/i18n` から読み込み、コンポーネントやガイド CSS を読み込みません。インストール済みパッケージの exports を確認し、内部 `src/`・`dist/` は import しないでください。
102
102
 
103
103
  自動 import はセミコロンなし、複数行、別名の import を認識し、既存のローカル名を重複注入しません。型のみの import は実行時コンポーネントの代わりにはなりません。
104
104
 
@@ -166,16 +166,42 @@ components/
166
166
 
167
167
  `BareInput` の `type="file"` では `onchange` の `event.currentTarget.files` を読み取り、ファイルパスの `value` はバインドしません。他の入力型では `bind:value` を使用できます。
168
168
 
169
- ### Compounds:複合コンポーネント(36)
169
+ ### Compounds:複合コンポーネント(37)
170
170
 
171
171
  #### フォーム
172
172
 
173
173
  | コンポーネント | 説明 | 主なプロパティ |
174
174
  | -------------- | ------------------ | -------------------------------------------- |
175
- | `Form` | フォームコンテナー | `loading`, `disabled`, `onsubmit` |
176
- | `FormField` | フォーム項目 | `label`, `error`, `hint`, `required` |
175
+ | `Form` | フォームコンテナー | `columns`, `labelPosition`, `labelWidth`, `loading`, `disabled`, `onsubmit` |
176
+ | `FormField` | フォーム項目 | `label`, `for`, `colSpan`, `labelPosition`, `error`, `hint`, `required` |
177
+ | `FormLayout` | フォーム配置 | `columns`, `labelPosition`, `labelWidth` |
177
178
  | `TagInput` | タグ入力 | `tags`, `maxTags`, `allowDuplicates`, `size` |
178
179
 
180
+ #### フォームのレイアウトと検証
181
+
182
+ `Form` とネイティブ `<form>` 内で使う `FormLayout` は、`columns={1 | 2}`、`labelPosition="top" | "left"`、`labelWidth`(既定 `8rem`)を共有します。既定は1列・ラベルを上に配置します。コンテナー幅が `36rem` 未満では1列、`28rem` 未満では横並びラベルも縦並びになります。
183
+
184
+ `FormField.colSpan` の既定は `'auto'`。Textarea、ファイル・画像アップロード、TagInput、複数選択 Combobox は2列を占めます。`1` / `2` で指定でき、独自の幅広コントロールには `data-form-wide` を付けます。見出し、エラー概要、操作ボタンなどの非 FormField 要素は行全体を使用します。項目ごとに `labelPosition` / `labelWidth` を上書きできます。
185
+
186
+ ラベルは内部 UI コントロールに自動で関連付けられます。明示 ID やネイティブ要素は `<FormField for="control-id">` を使います。エラーとヒントは `aria-describedby`、無効な要素は `aria-invalid` で通知し、エラーは重複表示しません。`label` / `error` / `hint` は文字列または翻訳関数を受け取ります。
187
+
188
+ `Input`、`Textarea`、`Select`、`PasswordInput` のネイティブ検証は中国語・英語に対応します。`validationMessage` は失敗時だけ適用され、有効な入力を無効にし続けません。UI の `setGlobalLocale` / `createLocaleStore` で言語を同期すると表示中のエラーも更新されます。`Textarea` は `minlength` / `maxlength`、`Select` は `name`・必須検証・FormData に対応します。
189
+
190
+ `LoginForm`、`RegisterForm`、`ChangePasswordForm`、`ForgotPasswordForm`、`ResetPasswordForm`、`AuthPanel`、`UserProfile`、`FeedbackModal` は同じレイアウト設定に対応します。`CrudPage.form` または `CrudEditPanel` に渡します。CRUD 項目は `colSpan` と翻訳関数の `validation.message` に対応し、ボタンと Enter のどちらも送信前に検証します。入力修正でエラーが消えます。サーバー側の検証も必要です。
191
+
192
+ ```svelte
193
+ <!-- m、save、name、notes 为应用自己的翻译、提交回调和状态 -->
194
+ <Form columns={2} labelPosition="left" labelWidth="6rem" onsubmit={save}>
195
+ <FormField label={() => m.name()} required>
196
+ <Input name="name" bind:value={name} required />
197
+ </FormField>
198
+ <FormField label={() => m.notes()}>
199
+ <Textarea name="notes" bind:value={notes} />
200
+ </FormField>
201
+ <Button type="submit">{m.save()}</Button>
202
+ </Form>
203
+ ```
204
+
179
205
  #### フィードバック
180
206
 
181
207
  | コンポーネント | 説明 | 主なプロパティ |
@@ -531,7 +557,7 @@ components/
531
557
  crud={roleCrud}
532
558
  {data}
533
559
  permissions={{ create: true, update: true, delete: true }}
534
- form={{ variant: 'modal', modalSize: 'lg' }}
560
+ form={{ variant: 'modal', modalSize: '3xl', columns: 2, labelPosition: 'top' }}
535
561
  pagination={{ showSizeChanger: true, showJumper: true, pageSizeOptions: [10, 20, 50] }}
536
562
  density='compact'
537
563
  detailColumns={['name']}
@@ -698,7 +724,7 @@ transformPageChunk: ({ html }) => html.replace('%theme_bootstrap%', themeBootstr
698
724
 
699
725
  ```svelte
700
726
  <script>
701
- import { createLocaleStore, setGlobalLocale } from '@h-ai/ui'
727
+ import { createLocaleStore, setGlobalLocale } from '@h-ai/ui/i18n'
702
728
  import { setLocale } from '$lib/paraglide/runtime'
703
729
 
704
730
  const localeStore = createLocaleStore()
@@ -729,7 +755,7 @@ import {
729
755
  isLocaleSupported, // 检查 locale 是否支持
730
756
  resolveLocale, // 解析 locale(支持回退)
731
757
  setGlobalLocale, // 设置全局 locale(同步 @h-ai/core)
732
- } from '@h-ai/ui'
758
+ } from '@h-ai/ui/i18n'
733
759
  ```
734
760
 
735
761
  ## 設定による認証画面
package/README.md CHANGED
@@ -98,7 +98,7 @@ export default defineConfig({
98
98
 
99
99
  > 除 `toast`、类型导入与 `Range`(会和 DOM `Range` 构造器冲突)外,其余公开 Svelte 组件都可通过 `@h-ai/ui/auto-import` 自动注入。
100
100
 
101
- 管理后台等只使用部分组件的页面,可直接使用公开组件目录入口;主题工具通过轻量 `@h-ai/ui/theme` 导入,避免主题消费加载根入口中的 AI、图表等组件。组件路径以已安装版本的 exports 为准,不导入包内 `src/` 或 `dist/`。
101
+ 管理后台等只使用部分组件的页面,可直接使用公开组件目录入口;主题工具通过轻量 `@h-ai/ui/theme` 导入,避免主题消费加载根入口中的 AI、图表等组件。状态层的语言工具使用 `@h-ai/ui/i18n`,不加载组件与引导 CSS。组件路径以已安装版本的 exports 为准,不导入包内 `src/` 或 `dist/`。
102
102
 
103
103
  自动导入器识别无分号、跨行及带别名的显式导入,已绑定的组件名称不会重复注入;仅类型导入不会替代运行时组件。
104
104
 
@@ -166,16 +166,42 @@ components/
166
166
 
167
167
  `BareInput` 的 `file` 类型通过 `onchange` 读取 `event.currentTarget.files`,不绑定文件路径 `value`;其他输入类型可使用 `bind:value`。
168
168
 
169
- ### 组合组件 Compounds(36 个)
169
+ ### 组合组件 Compounds(37 个)
170
170
 
171
171
  #### 表单
172
172
 
173
173
  | 组件 | 描述 | 主要属性 |
174
174
  | ----------- | -------- | -------------------------------------------- |
175
- | `Form` | 表单容器 | `loading`, `disabled`, `onsubmit` |
176
- | `FormField` | 表单字段 | `label`, `error`, `hint`, `required` |
175
+ | `Form` | 表单容器 | `columns`, `labelPosition`, `labelWidth`, `loading`, `disabled`, `onsubmit` |
176
+ | `FormField` | 表单字段 | `label`, `for`, `colSpan`, `labelPosition`, `error`, `hint`, `required` |
177
+ | `FormLayout` | 表单布局 | `columns`, `labelPosition`, `labelWidth` |
177
178
  | `TagInput` | 标签输入 | `tags`, `maxTags`, `allowDuplicates`, `size` |
178
179
 
180
+ #### 表单布局与校验
181
+
182
+ `Form` 和可嵌入原生 `<form>` 的 `FormLayout` 共用 `columns={1 | 2}`、`labelPosition="top" | "left"`、`labelWidth`(默认 `8rem`)。默认单列、标签独立成行;双列在容器不足 `36rem` 时收为单列,同行标签在容器不足 `28rem` 时改为上下排列。
183
+
184
+ `FormField.colSpan` 默认 `'auto'`:多行文本、文件/图片上传、TagInput 和多选 Combobox 自动占两列;`1` / `2` 显式覆盖。自定义宽控件标注 `data-form-wide`。标题、错误摘要、按钮及其他非 FormField 内容自动独占一行。字段可单独覆盖 `labelPosition` / `labelWidth`。
185
+
186
+ 标签自动关联内部 UI 控件;已有控件 ID 或原生控件使用 `<FormField for="control-id">`。错误/提示通过 `aria-describedby` 关联,错误控件带 `aria-invalid`,只显示一份字段提示。`label` / `error` / `hint` 支持字符串或延迟翻译函数。
187
+
188
+ `Input`、`Textarea`、`Select` 和 `PasswordInput` 的原生校验使用内置中英文提示;`validationMessage` 只覆盖失败提示,不让有效输入一直无效。应用通过 UI 的 `setGlobalLocale` / `createLocaleStore` 同步语言,已显示的校验错误即时更新。`Textarea` 支持 `minlength` / `maxlength`,`Select` 支持 `name`、必填校验和 FormData 序列化。
189
+
190
+ `LoginForm`、`RegisterForm`、`ChangePasswordForm`、`ForgotPasswordForm`、`ResetPasswordForm`、`AuthPanel`、`UserProfile` 和 `FeedbackModal` 接受同一组布局参数。`CrudPage` 通过 `form` 传入;`CrudEditPanel` 直接接收。CRUD 字段支持 `colSpan`,`validation.message` 支持翻译函数;按钮与回车均先校验,修正后错误消失,客户端校验不能替代服务端校验。
191
+
192
+ ```svelte
193
+ <!-- m、save、name、notes 为应用自己的翻译、提交回调和状态 -->
194
+ <Form columns={2} labelPosition="left" labelWidth="6rem" onsubmit={save}>
195
+ <FormField label={() => m.name()} required>
196
+ <Input name="name" bind:value={name} required />
197
+ </FormField>
198
+ <FormField label={() => m.notes()}>
199
+ <Textarea name="notes" bind:value={notes} />
200
+ </FormField>
201
+ <Button type="submit">{m.save()}</Button>
202
+ </Form>
203
+ ```
204
+
179
205
  #### 反馈
180
206
 
181
207
  | 组件 | 描述 | 主要属性 |
@@ -530,7 +556,7 @@ components/
530
556
  crud={roleCrud}
531
557
  {data}
532
558
  permissions={{ create: true, update: true, delete: true }}
533
- form={{ variant: 'modal', modalSize: 'lg' }}
559
+ form={{ variant: 'modal', modalSize: '3xl', columns: 2, labelPosition: 'top' }}
534
560
  pagination={{ showSizeChanger: true, showJumper: true, pageSizeOptions: [10, 20, 50] }}
535
561
  density='compact'
536
562
  detailColumns={['name']}
@@ -728,7 +754,7 @@ AuthPanel 在 SSR 阶段禁用原生表单控件,客户端挂载并绑定事
728
754
 
729
755
  ```svelte
730
756
  <script>
731
- import { createLocaleStore, setGlobalLocale } from '@h-ai/ui'
757
+ import { createLocaleStore, setGlobalLocale } from '@h-ai/ui/i18n'
732
758
  import { setLocale } from '$lib/paraglide/runtime'
733
759
 
734
760
  const localeStore = createLocaleStore()
@@ -759,7 +785,7 @@ import {
759
785
  isLocaleSupported, // 检查 locale 是否支持
760
786
  resolveLocale, // 解析 locale(支持回退)
761
787
  setGlobalLocale, // 设置全局 locale(同步 @h-ai/core)
762
- } from '@h-ai/ui'
788
+ } from '@h-ai/ui/i18n'
763
789
  ```
764
790
 
765
791
 
package/auto-import.d.ts CHANGED
@@ -58,6 +58,7 @@ declare global {
58
58
  const Empty: typeof import('@h-ai/ui')['Empty']
59
59
  const Form: typeof import('@h-ai/ui')['Form']
60
60
  const FormField: typeof import('@h-ai/ui')['FormField']
61
+ const FormLayout: typeof import('@h-ai/ui')['FormLayout']
61
62
  const InfiniteScroll: typeof import('@h-ai/ui')['InfiniteScroll']
62
63
  const MessageContainer: typeof import('@h-ai/ui')['MessageContainer']
63
64
  const MessageBoxContainer: typeof import('@h-ai/ui')['MessageBoxContainer']
package/auto-import.js CHANGED
@@ -49,6 +49,7 @@ const UI_COMPONENTS = new Set([
49
49
  'Empty',
50
50
  'Form',
51
51
  'FormField',
52
+ 'FormLayout',
52
53
  'InfiniteScroll',
53
54
  'MessageContainer',
54
55
  'MessageBoxContainer',
@@ -34,8 +34,10 @@
34
34
  />
35
35
  -->
36
36
  <script lang='ts'>
37
- import type { DataAttributes, Size } from '../../types.js'
37
+ import type { DataAttributes, FormText, Size } from '../../types.js'
38
38
  import { Combobox } from 'bits-ui'
39
+ import { getFormFieldContext } from '../../internal/form-context.js'
40
+ import { resolveFormText } from '../../internal/form-validation.svelte.js'
39
41
  import { uiM } from '../../messages.js'
40
42
  import { cn, getDataAttributes } from '../../utils.js'
41
43
  import { getFormControlSizeClasses } from '../control-size.js'
@@ -67,7 +69,7 @@
67
69
  /** 是否禁用 */
68
70
  disabled?: boolean
69
71
  /** 错误消息 */
70
- error?: string
72
+ error?: FormText
71
73
  /** 表单标签 */
72
74
  label?: string
73
75
  /** 自定义类名 */
@@ -91,6 +93,11 @@
91
93
  }: Props & DataAttributes = $props()
92
94
 
93
95
  const dataAttributes = $derived(getDataAttributes(restProps))
96
+ const field = getFormFieldContext()
97
+ const uniqueId = $props.id()
98
+ const messageId = `${uniqueId}-message`
99
+ const localError = $derived(resolveFormText(error))
100
+ const effectiveError = $derived(field?.error || localError)
94
101
  const sizeClasses = $derived(getFormControlSizeClasses(size))
95
102
  // 为未提供 value 时设置初始默认值(仅在初始化时执行一次)
96
103
  // 统一以空字符串初始化,避免在初始化阶段捕获 multiple 的初始值。
@@ -101,7 +108,7 @@
101
108
 
102
109
  // ─── 统一视觉样式(与 Select 对齐:rounded-lg / 柔和边框 / focus ring) ───
103
110
  const controlStateClass = $derived(
104
- error
111
+ effectiveError
105
112
  ? 'border-error/60 focus-within:ring-2 focus-within:ring-error/15'
106
113
  : 'border-base-content/15 hover:border-base-content/25 focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/10',
107
114
  )
@@ -200,7 +207,7 @@
200
207
  }
201
208
  </script>
202
209
 
203
- <div {...dataAttributes} class='fieldset w-full {className}'>
210
+ <div {...dataAttributes} class='fieldset w-full {className}' data-form-wide={multiple ? '' : undefined}>
204
211
  {#if fieldLabel}
205
212
  <legend class='fieldset-legend font-medium'>{fieldLabel}</legend>
206
213
  {/if}
@@ -243,6 +250,9 @@
243
250
  {/each}
244
251
 
245
252
  <Combobox.Input
253
+ id={field?.controlId}
254
+ aria-invalid={effectiveError ? true : undefined}
255
+ aria-describedby={[field?.messageId, localError && !field?.error ? messageId : ''].filter(Boolean).join(' ') || undefined}
246
256
  bind:ref={inputRef}
247
257
  class={cn('flex-1 min-w-20 bg-transparent py-0.5 outline-none placeholder:text-base-content/40', sizeClasses.spacing)}
248
258
  placeholder={selectedOptions.length === 0 ? placeholder : ''}
@@ -312,6 +322,9 @@
312
322
  >
313
323
  <div class='relative'>
314
324
  <Combobox.Input
325
+ id={field?.controlId}
326
+ aria-invalid={effectiveError ? true : undefined}
327
+ aria-describedby={[field?.messageId, localError && !field?.error ? messageId : ''].filter(Boolean).join(' ') || undefined}
315
328
  bind:ref={inputRef}
316
329
  class={cn(
317
330
  'w-full rounded-lg border bg-base-100 pr-9 outline-none transition-[border-color,box-shadow] duration-150 placeholder:text-base-content/40',
@@ -372,7 +385,7 @@
372
385
  </Combobox.Root>
373
386
  {/if}
374
387
 
375
- {#if error}
376
- <span class='fieldset-label text-error'>{error}</span>
388
+ {#if localError && !field?.error}
389
+ <p id={messageId} class='mt-1.5 text-xs text-error leading-relaxed break-words' aria-live='polite'>{localError}</p>
377
390
  {/if}
378
391
  </div>
@@ -1,4 +1,4 @@
1
- import type { DataAttributes, Size } from '../../types.js';
1
+ import type { DataAttributes, FormText, Size } from '../../types.js';
2
2
  import { Combobox } from 'bits-ui';
3
3
  /** 选项定义 */
4
4
  interface Option {
@@ -25,7 +25,7 @@ interface Props {
25
25
  /** 是否禁用 */
26
26
  disabled?: boolean;
27
27
  /** 错误消息 */
28
- error?: string;
28
+ error?: FormText;
29
29
  /** 表单标签 */
30
30
  label?: string;
31
31
  /** 自定义类名 */
@@ -23,8 +23,10 @@
23
23
  -->
24
24
  <script lang='ts'>
25
25
  import type { DateValue } from '@internationalized/date'
26
- import type { DataAttributes, Size } from '../../types.js'
26
+ import type { DataAttributes, FormText, Size } from '../../types.js'
27
27
  import { DatePicker as BitsDatePicker } from 'bits-ui'
28
+ import { getFormFieldContext } from '../../internal/form-context.js'
29
+ import { resolveFormText } from '../../internal/form-validation.svelte.js'
28
30
  import { cn, getDataAttributes } from '../../utils.js'
29
31
  import { getFormControlSizeClasses } from '../control-size.js'
30
32
 
@@ -44,7 +46,7 @@
44
46
  /** 尺寸 */
45
47
  size?: Size
46
48
  /** 错误消息 */
47
- error?: string
49
+ error?: FormText
48
50
  /** 自定义类名 */
49
51
  class?: string
50
52
  /** 日期变更回调 */
@@ -66,6 +68,11 @@
66
68
  }: Props & DataAttributes = $props()
67
69
 
68
70
  const dataAttributes = $derived(getDataAttributes(restProps))
71
+ const field = getFormFieldContext()
72
+ const uniqueId = $props.id()
73
+ const messageId = `${uniqueId}-message`
74
+ const localError = $derived(resolveFormText(error))
75
+ const effectiveError = $derived(field?.error || localError)
69
76
  const sizeClasses = $derived(getFormControlSizeClasses(size))
70
77
  </script>
71
78
 
@@ -80,10 +87,12 @@
80
87
  onValueChange={v => onchange?.(v)}
81
88
  >
82
89
  <BitsDatePicker.Input
90
+ aria-labelledby={field?.labelId}
91
+ aria-describedby={[field?.messageId, localError && !field?.error ? messageId : ''].filter(Boolean).join(' ') || undefined}
83
92
  class={cn(
84
93
  'flex w-full items-center gap-0.5 rounded-lg border bg-base-100 pr-2 transition-[border-color,box-shadow] duration-150',
85
94
  sizeClasses.control,
86
- error
95
+ effectiveError
87
96
  ? 'border-error/60 focus-within:ring-2 focus-within:ring-error/15'
88
97
  : 'border-base-content/15 hover:border-base-content/25 focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/10',
89
98
  disabled && 'cursor-not-allowed opacity-50',
@@ -149,7 +158,7 @@
149
158
  </BitsDatePicker.Content>
150
159
  </BitsDatePicker.Root>
151
160
 
152
- {#if error}
153
- <span class='fieldset-label text-error'>{error}</span>
161
+ {#if localError && !field?.error}
162
+ <p id={messageId} class='mt-1.5 text-xs text-error leading-relaxed break-words' aria-live='polite'>{localError}</p>
154
163
  {/if}
155
164
  </div>
@@ -1,5 +1,5 @@
1
1
  import type { DateValue } from '@internationalized/date';
2
- import type { DataAttributes, Size } from '../../types.js';
2
+ import type { DataAttributes, FormText, Size } from '../../types.js';
3
3
  interface Props {
4
4
  /** 选中日期(双向绑定) */
5
5
  value?: DateValue;
@@ -16,7 +16,7 @@ interface Props {
16
16
  /** 尺寸 */
17
17
  size?: Size;
18
18
  /** 错误消息 */
19
- error?: string;
19
+ error?: FormText;
20
20
  /** 自定义类名 */
21
21
  class?: string;
22
22
  /** 日期变更回调 */
@@ -10,12 +10,16 @@
10
10
  <script lang='ts'>
11
11
  import type { DataAttributes, FormProps } from '../../types.js'
12
12
  import { cn, getDataAttributes } from '../../utils.js'
13
+ import FormLayout from './FormLayout.svelte'
13
14
 
14
15
  const {
15
16
  id = '',
16
17
  class: className = '',
17
18
  loading = false,
18
19
  disabled = false,
20
+ columns = 1,
21
+ labelPosition = 'top',
22
+ labelWidth = '8rem',
19
23
  onsubmit,
20
24
  onreset,
21
25
  onerror,
@@ -65,9 +69,7 @@
65
69
  }
66
70
  }
67
71
 
68
- function handleReset(e: Event) {
69
- e.preventDefault()
70
- formElement?.reset()
72
+ function handleReset() {
71
73
  onreset?.()
72
74
  }
73
75
 
@@ -94,7 +96,9 @@
94
96
  onsubmit={handleSubmit}
95
97
  onreset={handleReset}
96
98
  >
97
- {#if children}
98
- {@render children()}
99
- {/if}
99
+ <FormLayout {columns} {labelPosition} {labelWidth}>
100
+ {#if children}
101
+ {@render children()}
102
+ {/if}
103
+ </FormLayout>
100
104
  </form>
@@ -9,6 +9,9 @@
9
9
  -->
10
10
  <script lang='ts'>
11
11
  import type { DataAttributes, FormFieldProps } from '../../types.js'
12
+ import { setContext } from 'svelte'
13
+ import { FORM_FIELD_CONTEXT, getFormLayoutContext } from '../../internal/form-context.js'
14
+ import { resolveFormText } from '../../internal/form-validation.svelte.js'
12
15
  import { cn, getDataAttributes } from '../../utils.js'
13
16
 
14
17
  const {
@@ -16,43 +19,105 @@
16
19
  error = '',
17
20
  hint = '',
18
21
  required = false,
22
+ for: providedControlId,
23
+ labelPosition,
24
+ labelWidth,
25
+ colSpan = 'auto',
19
26
  class: className = '',
20
27
  children,
21
28
  ...restProps
22
29
  }: FormFieldProps & DataAttributes = $props()
23
30
 
24
31
  const dataAttributes = $derived(getDataAttributes(restProps))
32
+ const layout = getFormLayoutContext()
33
+ const uniqueId = $props.id()
34
+ const controlId = $derived(providedControlId ?? `${uniqueId}-control`)
35
+ const messageId = `${uniqueId}-message`
36
+ const labelId = `${uniqueId}-label`
37
+ const resolvedLabel = $derived(resolveFormText(label))
38
+ const resolvedError = $derived(resolveFormText(error))
39
+ const resolvedHint = $derived(resolveFormText(hint))
40
+ const position = $derived(labelPosition ?? layout?.labelPosition ?? 'top')
41
+ const width = $derived(labelWidth ?? layout?.labelWidth ?? '8rem')
42
+ setContext(FORM_FIELD_CONTEXT, {
43
+ get controlId() { return controlId },
44
+ get labelId() { return labelId },
45
+ get messageId() { return resolvedError || resolvedHint ? messageId : undefined },
46
+ get error() { return resolvedError },
47
+ })
25
48
  const fieldClass = $derived(
26
49
  cn(
27
- 'fieldset w-full',
50
+ 'hai-form-field w-full min-w-0',
28
51
  className,
29
52
  ),
30
53
  )
31
54
  </script>
32
55
 
33
- <div {...dataAttributes} class={fieldClass}>
34
- {#if label}
35
- <legend class='fieldset-legend'>
36
- {label}
56
+ <div {...dataAttributes} class={fieldClass} data-col-span={colSpan} data-label-position={position} style:--hai-form-label-width={width}>
57
+ {#if resolvedLabel}
58
+ <label id={labelId} for={controlId} class='field-label text-sm font-medium text-base-content/70'>
59
+ {resolvedLabel}
37
60
  {#if required}
38
61
  <span class='text-error'>*</span>
39
62
  {/if}
40
- </legend>
63
+ </label>
41
64
  {/if}
42
65
 
43
- <div class='field-content'>
66
+ <div class='field-content min-w-0'>
44
67
  {#if children}
45
68
  {@render children()}
46
69
  {/if}
47
70
  </div>
48
71
 
49
- {#if error || hint}
50
- <div>
51
- {#if error}
52
- <span class='fieldset-label text-error'>{error}</span>
53
- {:else if hint}
54
- <span class='fieldset-label text-base-content/60'>{hint}</span>
72
+ {#if resolvedError || resolvedHint}
73
+ <div id={messageId} class='field-message text-xs leading-relaxed break-words' aria-live='polite'>
74
+ {#if resolvedError}
75
+ <span class='text-error'>{resolvedError}</span>
76
+ {:else if resolvedHint}
77
+ <span class='text-base-content/60'>{resolvedHint}</span>
55
78
  {/if}
56
79
  </div>
57
80
  {/if}
58
81
  </div>
82
+
83
+ <style>
84
+ .hai-form-field {
85
+ display: grid;
86
+ grid-template-columns: minmax(0, 1fr);
87
+ gap: 0.375rem 1rem;
88
+ }
89
+
90
+ .field-label {
91
+ overflow-wrap: anywhere;
92
+ }
93
+
94
+ .field-content > :global(.fieldset) {
95
+ padding-block: 0;
96
+ }
97
+
98
+ .hai-form-field[data-label-position='left']:has(.field-label) {
99
+ grid-template-columns: minmax(0, var(--hai-form-label-width)) minmax(0, 1fr);
100
+ }
101
+
102
+ .hai-form-field[data-label-position='left'] .field-label {
103
+ padding-top: 0.5rem;
104
+ }
105
+
106
+ .hai-form-field[data-label-position='left']:has(.field-label) .field-message {
107
+ grid-column: 2;
108
+ }
109
+
110
+ @container hai-form (max-width: 27.99rem) {
111
+ .hai-form-field[data-label-position='left']:has(.field-label) {
112
+ grid-template-columns: minmax(0, 1fr);
113
+ }
114
+
115
+ .hai-form-field[data-label-position='left'] .field-label {
116
+ padding-top: 0;
117
+ }
118
+
119
+ .hai-form-field[data-label-position='left']:has(.field-label) .field-message {
120
+ grid-column: 1;
121
+ }
122
+ }
123
+ </style>