@jcoder-stack/registry 0.4.1 → 0.4.2
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/package.json +2 -2
- package/public/r/form.json +2 -2
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jcoder-stack/registry",
|
|
3
3
|
"description": "Copy-in sources for the auth shell and shadcn blocks: login/callback/logout handlers, the callAbp proxy, route guards, and TanStack wiring",
|
|
4
|
-
"version": "0.4.
|
|
4
|
+
"version": "0.4.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
7
7
|
"auth",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"postpack": "node ../scripts/restore-publish-config.mjs"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@jcoder-stack/abp-react": "^0.4.
|
|
17
|
+
"@jcoder-stack/abp-react": "^0.4.2",
|
|
18
18
|
"@tanstack/react-router": "^1.0.0",
|
|
19
19
|
"@tanstack/react-start": "^1.0.0",
|
|
20
20
|
"zod": "^4.0.0"
|
package/public/r/form.json
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"files": [
|
|
26
26
|
{
|
|
27
27
|
"path": "ui/blocks/form/form-hook.tsx",
|
|
28
|
-
"content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { createFormHook, createFormHookContexts } from \"@tanstack/react-form\";\nimport type { ReactNode } from \"react\";\nimport { createContext, lazy, Suspense, useContext } from \"react\";\nimport { Combobox } from \"@/components/combobox/combobox\";\nimport type { ComboboxOption } from \"@/components/combobox/use-combobox-options\";\nimport { formatIso, ISO_DATE, ISO_DATE_TIME, parseIso } from \"@/components/date-picker/date-io\";\nimport { FormErrorSummary } from \"@/components/form/form-error-summary\";\nimport { OptionalMark } from \"@/components/form/optional-mark\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Field, FieldDescription, FieldError, FieldLabel } from \"@/components/ui/field\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { cn } from \"@/lib/utils\";\n\nconst MultiCombobox = lazy(() =>\n import(\"@/components/combobox/multi-combobox\").then((m) => ({ default: m.MultiCombobox })),\n);\nconst DatePicker = lazy(() =>\n import(\"@/components/date-picker/date-picker\").then((m) => ({ default: m.DatePicker })),\n);\nconst DateTimePicker = lazy(() =>\n import(\"@/components/date-picker/date-time-picker\").then((m) => ({\n default: m.DateTimePicker,\n })),\n);\nconst DateRangePicker = lazy(() =>\n import(\"@/components/date-picker/date-range-picker\").then((m) => ({\n default: m.DateRangePicker,\n })),\n);\n\nexport const { fieldContext, formContext, useFieldContext, useFormContext } =\n createFormHookContexts();\n\n/** 错误元素可能是服务端原始字符串或 Standard Schema issue;FieldError 只认 {message}。 */\nfunction toFieldErrors(errors: unknown[]): { message?: string }[] {\n return errors.map((e) => (typeof e === \"string\" ? { message: e } : (e as { message?: string })));\n}\n\ninterface ServerErrorFieldMeta {\n errorMap?: Record<string, unknown>;\n}\n\n/** `clearServerSubmitErrors` 需要的最小结构面,避免依赖 TanStack Form 的完整泛型形态。 */\ninterface ServerErrorForm {\n state: { fieldMeta: Record<string, ServerErrorFieldMeta | undefined> };\n setFieldMeta: (\n name: string,\n updater: (meta: ServerErrorFieldMeta) => ServerErrorFieldMeta,\n ) => void;\n setErrorMap: (map: { onSubmit?: undefined }) => void;\n}\n\n/**\n * 绕过 @tanstack/react-form 1.33.2 死锁:onSubmitAsync 注入的字段级错误不会在重提交时自清,\n * 不清则提交被永久短路。约束:onSubmit 槽位只放服务端错误,勿给 field 另挂 onSubmit 校验器。\n */\nfunction clearServerSubmitErrors(form: ServerErrorForm): void {\n for (const name of Object.keys(form.state.fieldMeta)) {\n form.setFieldMeta(name, (meta) => ({\n ...meta,\n errorMap: { ...meta.errorMap, onSubmit: undefined },\n }));\n }\n form.setErrorMap({ onSubmit: undefined });\n}\n\n/**\n * 「选填」标记开关。\n *\n * 标少数派才有意义,而少数派是谁取决于**整张表单的构成**,不是单个字段:录入表单里多数字段\n * 必填,标出那一两个选填的很有用;查询表单里字段本来就全是选填,逐个标等于每个标签都挂一句\n * 废话。所以这个决定放在表单层——SheetForm 打开它,查询表单不打开。\n */\nconst OptionalMarksContext = createContext(false);\n\n/**\n * 只读渲染开关。打开后字段不再渲染「禁用的控件」,而是渲染一行「键 / 值」。\n *\n * 禁用控件的对比度是 WCAG 明确豁免的——豁免的前提正是它**不承载内容**。用一排灰掉的输入框\n * 展示一条记录,既难读又像坏了,用户还会去点那些框。\n *\n * 渲染归字段管(只有它知道该把枚举值、日期、布尔显示成什么),容器归表单管,调用方的写法\n * 一个字都不用改。\n */\nconst ReadOnlyContext = createContext(false);\n\nexport function ReadOnlyFields(props: { children: ReactNode }) {\n return <ReadOnlyContext.Provider value={true}>{props.children}</ReadOnlyContext.Provider>;\n}\n\n/** 只读态的一行:左键右值,行高与表格同源(40px),值右对齐排成一条竖线。 */\nfunction FieldRow(props: { label: string; display: ReactNode }) {\n const L = useLocalization();\n const empty =\n props.display === undefined ||\n props.display === null ||\n props.display === \"\" ||\n (typeof props.display === \"number\" && Number.isNaN(props.display));\n return (\n <div className=\"flex min-h-10 items-center justify-between gap-4 px-3 py-2\">\n <dt className=\"shrink-0 text-sm text-muted-foreground\">{props.label}</dt>\n <dd className={cn(\"text-right text-sm\", empty && \"text-muted-foreground\")}>\n {empty ? L(\"Form:Empty\") : props.display}\n </dd>\n </div>\n );\n}\n\nexport function OptionalMarks(props: { children: ReactNode }) {\n return (\n <OptionalMarksContext.Provider value={true}>{props.children}</OptionalMarksContext.Provider>\n );\n}\n\n/**\n * 字段外壳:标签 → 说明 → 控件 → 错误,顺序恒定。\n *\n * 说明刻意放在控件**之上**:控件下方是错误信息的位置,两者挤在一起时,出错那一刻要么说明\n * 被顶走、要么错误被推到看不见的地方。\n */\nfunction FieldShell(props: {\n label: string;\n required?: boolean;\n description?: string;\n /**\n * 只读态下这个字段该显示成什么。\n *\n * `null` 表示「接了只读渲染,但此刻没有值」——渲染成「未填写」那一行。\n * `undefined` 表示「这个字段还没接只读渲染」,回退到禁用控件。两者不能混用:空值若传\n * undefined,就会在只读的键值卡里冒出一个控件,把整张卡的排版撑坏。\n */\n display?: ReactNode;\n orientation?: \"vertical\" | \"horizontal\";\n children: ReactNode;\n}) {\n const field = useFieldContext();\n const invalid = field.state.meta.errors.length > 0;\n const marksOptional = useContext(OptionalMarksContext);\n const readOnly = useContext(ReadOnlyContext);\n if (readOnly && props.display !== undefined) {\n return <FieldRow label={props.label} display={props.display} />;\n }\n return (\n <Field orientation={props.orientation} data-invalid={invalid ? true : undefined}>\n <FieldLabel htmlFor={field.name}>\n {props.label}\n {marksOptional && props.required !== true && <OptionalMark />}\n </FieldLabel>\n {props.description !== undefined && <FieldDescription>{props.description}</FieldDescription>}\n {props.children}\n <FieldError errors={toFieldErrors(field.state.meta.errors)} />\n </Field>\n );\n}\n\nexport function TextField(props: {\n label: string;\n required?: boolean;\n description?: string;\n type?: \"text\" | \"email\" | \"password\" | \"date\";\n disabled?: boolean;\n placeholder?: string;\n autoComplete?: string;\n}) {\n const field = useFieldContext<string>();\n return (\n <FieldShell\n label={props.label}\n required={props.required}\n description={props.description}\n display={field.state.value}\n >\n <Input\n id={field.name}\n name={field.name}\n type={props.type ?? \"text\"}\n aria-required={props.required === true || undefined}\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n value={field.state.value}\n disabled={props.disabled}\n placeholder={props.placeholder}\n autoComplete={props.autoComplete}\n onBlur={field.handleBlur}\n onChange={(event) => field.handleChange(event.target.value)}\n />\n </FieldShell>\n );\n}\n\nexport function NumberField(props: {\n label: string;\n required?: boolean;\n placeholder?: string;\n description?: string;\n disabled?: boolean;\n step?: string;\n}) {\n const field = useFieldContext<number>();\n return (\n <FieldShell\n label={props.label}\n required={props.required}\n description={props.description}\n display={\n Number.isNaN(field.state.value) ? null : (\n <span className=\"tabular-nums\">{String(field.state.value)}</span>\n )\n }\n >\n <Input\n id={field.name}\n name={field.name}\n type=\"number\"\n step={props.step}\n placeholder={props.placeholder}\n aria-required={props.required === true || undefined}\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n value={Number.isNaN(field.state.value) ? \"\" : String(field.state.value)}\n disabled={props.disabled}\n onBlur={field.handleBlur}\n onChange={(event) => {\n // 空串裸转 Number 是 0,会把「清空了」写成「改成 0」;映射 NaN 让 z.number() 判失败\n const raw = event.target.value;\n field.handleChange(raw.trim() === \"\" ? Number.NaN : Number(raw));\n }}\n />\n </FieldShell>\n );\n}\n\nexport function SwitchField(props: { label: string; disabled?: boolean }) {\n const L = useLocalization();\n const field = useFieldContext<boolean>();\n return (\n <FieldShell\n label={props.label}\n orientation=\"horizontal\"\n display={L(field.state.value ? \"Form:Yes\" : \"Form:No\")}\n >\n <Switch\n id={field.name}\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n checked={field.state.value}\n disabled={props.disabled}\n onCheckedChange={(checked: boolean) => field.handleChange(checked)}\n />\n </FieldShell>\n );\n}\n\nexport function SelectField(props: {\n label: string;\n options: { value: string; label: string }[];\n required?: boolean;\n description?: string;\n disabled?: boolean;\n}) {\n const field = useFieldContext<string>();\n return (\n <FieldShell\n label={props.label}\n required={props.required}\n description={props.description}\n display={props.options.find((option) => option.value === field.state.value)?.label ?? null}\n >\n <Select\n value={field.state.value}\n onValueChange={(value) => field.handleChange(value)}\n disabled={props.disabled}\n >\n <SelectTrigger\n id={field.name}\n className=\"w-full\"\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n >\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {props.options.map((option) => (\n <SelectItem key={option.value} value={option.value}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </FieldShell>\n );\n}\n\nexport function DateField(props: {\n label: string;\n required?: boolean;\n description?: string;\n disabled?: boolean;\n placeholder?: string;\n}) {\n const field = useFieldContext<string>();\n return (\n <FieldShell\n label={props.label}\n required={props.required}\n description={props.description}\n display={field.state.value ? <span className=\"tabular-nums\">{field.state.value}</span> : null}\n >\n <Suspense fallback={<Skeleton className=\"h-9 w-full\" />}>\n <DatePicker\n id={field.name}\n value={parseIso(field.state.value, ISO_DATE)}\n disabled={props.disabled}\n placeholder={props.placeholder}\n aria-required={props.required === true || undefined}\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n onChange={(date) => field.handleChange(formatIso(date, ISO_DATE))}\n />\n </Suspense>\n </FieldShell>\n );\n}\n\nexport function DateTimeField(props: {\n label: string;\n description?: string;\n required?: boolean;\n disabled?: boolean;\n placeholder?: string;\n}) {\n const field = useFieldContext<string>();\n return (\n <FieldShell\n label={props.label}\n required={props.required}\n description={props.description}\n display={field.state.value ? <span className=\"tabular-nums\">{field.state.value}</span> : null}\n >\n <Suspense fallback={<Skeleton className=\"h-9 w-full\" />}>\n <DateTimePicker\n id={field.name}\n value={parseIso(field.state.value, ISO_DATE_TIME)}\n disabled={props.disabled}\n placeholder={props.placeholder}\n aria-required={props.required === true || undefined}\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n onChange={(date) => field.handleChange(formatIso(date, ISO_DATE_TIME))}\n />\n </Suspense>\n </FieldShell>\n );\n}\n\nexport function DateRangeField(props: {\n label: string;\n description?: string;\n required?: boolean;\n disabled?: boolean;\n}) {\n const field = useFieldContext<{ from: string; to: string }>();\n return (\n <FieldShell\n label={props.label}\n required={props.required}\n description={props.description}\n display={\n field.state.value.from || field.state.value.to ? (\n <span className=\"tabular-nums\">\n {field.state.value.from} – {field.state.value.to}\n </span>\n ) : null\n }\n >\n <Suspense fallback={<Skeleton className=\"h-9 w-full\" />}>\n <DateRangePicker\n id={field.name}\n value={{\n from: parseIso(field.state.value.from, ISO_DATE),\n to: parseIso(field.state.value.to, ISO_DATE),\n }}\n disabled={props.disabled}\n aria-required={props.required === true || undefined}\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n onChange={(range) =>\n field.handleChange({\n from: formatIso(range?.from, ISO_DATE),\n to: formatIso(range?.to, ISO_DATE),\n })\n }\n />\n </Suspense>\n </FieldShell>\n );\n}\n\nexport function ComboboxField(props: {\n label: string;\n description?: string;\n options?: ComboboxOption[];\n loadOptions?: (search: string) => Promise<ComboboxOption[]>;\n placeholder?: string;\n required?: boolean;\n disabled?: boolean;\n}) {\n const field = useFieldContext<string>();\n return (\n <FieldShell\n label={props.label}\n required={props.required}\n description={props.description}\n display={\n props.options?.find((option) => option.value === field.state.value)?.label ??\n field.state.value\n }\n >\n <Combobox\n value={field.state.value || undefined}\n onChange={(value) => field.handleChange(value ?? \"\")}\n options={props.options ?? []}\n loadOptions={props.loadOptions}\n placeholder={props.placeholder}\n disabled={props.disabled}\n />\n </FieldShell>\n );\n}\n\nexport function MultiComboboxField(props: {\n label: string;\n options: ComboboxOption[];\n editable: boolean;\n}) {\n const field = useFieldContext<string[]>();\n return (\n <FieldShell\n label={props.label}\n display={\n field.state.value.length === 0 ? null : (\n <div className=\"flex flex-wrap justify-end gap-1\">\n {field.state.value.map((name) => (\n <Badge key={name} variant=\"secondary\">\n {name}\n </Badge>\n ))}\n </div>\n )\n }\n >\n {props.editable ? (\n <Suspense fallback={<Skeleton className=\"h-9 w-full\" />}>\n <MultiCombobox\n values={field.state.value}\n onChange={(values) => field.handleChange(values)}\n options={props.options}\n />\n </Suspense>\n ) : (\n <div className=\"flex flex-wrap gap-1\">\n {field.state.value.length === 0 ? (\n <span className=\"text-sm text-muted-foreground\">—</span>\n ) : (\n field.state.value.map((name) => (\n <Badge key={name} variant=\"secondary\">\n {name}\n </Badge>\n ))\n )}\n </div>\n )}\n </FieldShell>\n );\n}\n\n/** onSubmitAsync 返回的 form 键落 state.errorMap.onSubmit;\\n 拆多条渲染。 */\nfunction FormErrors() {\n const form = useFormContext();\n return (\n <form.Subscribe selector={(state) => state.errorMap.onSubmit}>\n {(onSubmit) => {\n const message = typeof onSubmit === \"string\" ? onSubmit : undefined;\n return <FormErrorSummary errors={message ? message.split(\"\\n\") : []} />;\n }}\n </form.Subscribe>\n );\n}\n\nconst { useAppForm: useAppFormBase, withForm } = createFormHook({\n fieldContext,\n formContext,\n fieldComponents: {\n TextField,\n NumberField,\n SwitchField,\n SelectField,\n DateField,\n DateTimeField,\n DateRangeField,\n ComboboxField,\n MultiComboboxField,\n },\n formComponents: { FormErrors },\n});\n\n/** 提交结束后把焦点移到第一个仍有错误的字段;取不到 DOM 元素(如自定义组件未转发 id)则顺延到\n * 下一个可聚焦的错误字段。字段输入的 id 约定为 field.name(内置字段组件已遵守)。「第一个」按\n * fieldMeta 的字段注册序(≈挂载序/tab 序);条件渲染或重排字段的极端场景下未必是视觉最上者。 */\nfunction focusFirstInvalidField(form: {\n state: { fieldMeta: Record<string, { errors?: unknown[] } | undefined> };\n}): void {\n for (const [name, meta] of Object.entries(form.state.fieldMeta)) {\n if (!meta?.errors?.length) continue;\n const el = document.getElementById(name);\n if (el) {\n el.focus();\n return;\n }\n }\n}\n\nconst patchedForms = new WeakSet<object>();\n\n/**\n * 包装 useAppFormBase:提交前注入服务端错误清理(见 clearServerSubmitErrors),提交结束后聚焦\n * 首错字段(见 focusFirstInvalidField)。WeakSet 守卫而不是 useState/useRef,后者初始化器在\n * StrictMode 下双调用,保证不了只 patch 一次。\n */\nexport const useAppForm: typeof useAppFormBase = (opts) => {\n const form = useAppFormBase(opts);\n if (!patchedForms.has(form)) {\n patchedForms.add(form);\n const original = form.handleSubmit;\n form.handleSubmit = ((submitMeta?: unknown) => {\n clearServerSubmitErrors(form as unknown as ServerErrorForm);\n const result = submitMeta === undefined ? original() : original(submitMeta as never);\n return result.then(() => {\n focusFirstInvalidField(form as unknown as Parameters<typeof focusFirstInvalidField>[0]);\n });\n }) as typeof form.handleSubmit;\n }\n return form;\n};\n\nexport { withForm };\n",
|
|
28
|
+
"content": "import { useLocalization } from \"@jcoder-stack/abp-react/react\";\nimport { createFormHook, createFormHookContexts } from \"@tanstack/react-form\";\nimport type { ReactNode } from \"react\";\nimport { createContext, lazy, Suspense, useContext } from \"react\";\nimport { Combobox } from \"@/components/combobox/combobox\";\nimport type { ComboboxOption } from \"@/components/combobox/use-combobox-options\";\nimport { formatIso, ISO_DATE, ISO_DATE_TIME, parseIso } from \"@/components/date-picker/date-io\";\nimport { FormErrorSummary } from \"@/components/form/form-error-summary\";\nimport { OptionalMark } from \"@/components/form/optional-mark\";\nimport { Badge } from \"@/components/ui/badge\";\nimport {\n Field,\n FieldContent,\n FieldDescription,\n FieldError,\n FieldLabel,\n} from \"@/components/ui/field\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { cn } from \"@/lib/utils\";\n\nconst MultiCombobox = lazy(() =>\n import(\"@/components/combobox/multi-combobox\").then((m) => ({ default: m.MultiCombobox })),\n);\nconst DatePicker = lazy(() =>\n import(\"@/components/date-picker/date-picker\").then((m) => ({ default: m.DatePicker })),\n);\nconst DateTimePicker = lazy(() =>\n import(\"@/components/date-picker/date-time-picker\").then((m) => ({\n default: m.DateTimePicker,\n })),\n);\nconst DateRangePicker = lazy(() =>\n import(\"@/components/date-picker/date-range-picker\").then((m) => ({\n default: m.DateRangePicker,\n })),\n);\n\nexport const { fieldContext, formContext, useFieldContext, useFormContext } =\n createFormHookContexts();\n\n/** 错误元素可能是服务端原始字符串或 Standard Schema issue;FieldError 只认 {message}。 */\nfunction toFieldErrors(errors: unknown[]): { message?: string }[] {\n return errors.map((e) => (typeof e === \"string\" ? { message: e } : (e as { message?: string })));\n}\n\ninterface ServerErrorFieldMeta {\n errorMap?: Record<string, unknown>;\n}\n\n/** `clearServerSubmitErrors` 需要的最小结构面,避免依赖 TanStack Form 的完整泛型形态。 */\ninterface ServerErrorForm {\n state: { fieldMeta: Record<string, ServerErrorFieldMeta | undefined> };\n setFieldMeta: (\n name: string,\n updater: (meta: ServerErrorFieldMeta) => ServerErrorFieldMeta,\n ) => void;\n setErrorMap: (map: { onSubmit?: undefined }) => void;\n}\n\n/**\n * 绕过 @tanstack/react-form 1.33.2 死锁:onSubmitAsync 注入的字段级错误不会在重提交时自清,\n * 不清则提交被永久短路。约束:onSubmit 槽位只放服务端错误,勿给 field 另挂 onSubmit 校验器。\n */\nfunction clearServerSubmitErrors(form: ServerErrorForm): void {\n for (const name of Object.keys(form.state.fieldMeta)) {\n form.setFieldMeta(name, (meta) => ({\n ...meta,\n errorMap: { ...meta.errorMap, onSubmit: undefined },\n }));\n }\n form.setErrorMap({ onSubmit: undefined });\n}\n\n/**\n * 「选填」标记开关。\n *\n * 标少数派才有意义,而少数派是谁取决于**整张表单的构成**,不是单个字段:录入表单里多数字段\n * 必填,标出那一两个选填的很有用;查询表单里字段本来就全是选填,逐个标等于每个标签都挂一句\n * 废话。所以这个决定放在表单层——SheetForm 打开它,查询表单不打开。\n */\nconst OptionalMarksContext = createContext(false);\n\n/**\n * 只读渲染开关。打开后字段不再渲染「禁用的控件」,而是渲染一行「键 / 值」。\n *\n * 禁用控件的对比度是 WCAG 明确豁免的——豁免的前提正是它**不承载内容**。用一排灰掉的输入框\n * 展示一条记录,既难读又像坏了,用户还会去点那些框。\n *\n * 渲染归字段管(只有它知道该把枚举值、日期、布尔显示成什么),容器归表单管,调用方的写法\n * 一个字都不用改。\n */\nconst ReadOnlyContext = createContext(false);\n\nexport function ReadOnlyFields(props: { children: ReactNode }) {\n return <ReadOnlyContext.Provider value={true}>{props.children}</ReadOnlyContext.Provider>;\n}\n\n/**\n * 只读态的一行:左键右值,行高与表格同源(40px),值右对齐排成一条竖线。\n *\n * 导出给页面自己排「不是表单字段」的那一行(计算值、关联记录的摘要),让它与生成字段落在同一\n * 条竖线上。`display` 为 `null`/空串/NaN 时渲染成「未填写」;只在 `ReadOnlyFields` 的 `<dl>`\n * 里使用,它输出的是 `<dt>/<dd>`。\n */\nexport function FieldRow(props: { label: string; display: ReactNode }) {\n const L = useLocalization();\n const empty =\n props.display === undefined ||\n props.display === null ||\n props.display === \"\" ||\n (typeof props.display === \"number\" && Number.isNaN(props.display));\n return (\n <div className=\"flex min-h-10 items-center justify-between gap-4 px-3 py-2\">\n <dt className=\"shrink-0 text-sm text-muted-foreground\">{props.label}</dt>\n <dd className={cn(\"text-right text-sm\", empty && \"text-muted-foreground\")}>\n {empty ? L(\"Form:Empty\") : props.display}\n </dd>\n </div>\n );\n}\n\nexport function OptionalMarks(props: { children: ReactNode }) {\n return (\n <OptionalMarksContext.Provider value={true}>{props.children}</OptionalMarksContext.Provider>\n );\n}\n\n/**\n * 字段外壳:标签 → 说明 → 控件 → 错误,顺序恒定。\n *\n * 说明刻意放在控件**之上**:控件下方是错误信息的位置,两者挤在一起时,出错那一刻要么说明\n * 被顶走、要么错误被推到看不见的地方。\n */\nfunction FieldShell(props: {\n label: string;\n required?: boolean;\n description?: string;\n /**\n * 只读态下这个字段该显示成什么。\n *\n * `null` 表示「接了只读渲染,但此刻没有值」——渲染成「未填写」那一行。\n * `undefined` 表示「这个字段还没接只读渲染」,回退到禁用控件。两者不能混用:空值若传\n * undefined,就会在只读的键值卡里冒出一个控件,把整张卡的排版撑坏。\n */\n display?: ReactNode;\n orientation?: \"vertical\" | \"horizontal\";\n children: ReactNode;\n}) {\n const field = useFieldContext();\n const invalid = field.state.meta.errors.length > 0;\n const marksOptional = useContext(OptionalMarksContext);\n const readOnly = useContext(ReadOnlyContext);\n if (readOnly && props.display !== undefined) {\n return <FieldRow label={props.label} display={props.display} />;\n }\n const label = (\n <FieldLabel htmlFor={field.name}>\n {props.label}\n {marksOptional && props.required !== true && <OptionalMark />}\n </FieldLabel>\n );\n const description = props.description !== undefined && (\n <FieldDescription>{props.description}</FieldDescription>\n );\n const error = <FieldError errors={toFieldErrors(field.state.meta.errors)} />;\n if (props.orientation === \"horizontal\") {\n // 横排时说明与错误都归到标签那一列,控件留在行尾——否则它们会挤进同一行把开关推走。\n return (\n <Field orientation=\"horizontal\" data-invalid={invalid ? true : undefined}>\n <FieldContent>\n {label}\n {description}\n {error}\n </FieldContent>\n {props.children}\n </Field>\n );\n }\n return (\n <Field orientation={props.orientation} data-invalid={invalid ? true : undefined}>\n {label}\n {description}\n {props.children}\n {error}\n </Field>\n );\n}\n\nexport function TextField(props: {\n label: string;\n required?: boolean;\n description?: string;\n type?: \"text\" | \"email\" | \"password\" | \"date\";\n disabled?: boolean;\n placeholder?: string;\n autoComplete?: string;\n}) {\n const field = useFieldContext<string>();\n return (\n <FieldShell\n label={props.label}\n required={props.required}\n description={props.description}\n display={field.state.value}\n >\n <Input\n id={field.name}\n name={field.name}\n type={props.type ?? \"text\"}\n aria-required={props.required === true || undefined}\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n value={field.state.value}\n disabled={props.disabled}\n placeholder={props.placeholder}\n autoComplete={props.autoComplete}\n onBlur={field.handleBlur}\n onChange={(event) => field.handleChange(event.target.value)}\n />\n </FieldShell>\n );\n}\n\nexport function NumberField(props: {\n label: string;\n required?: boolean;\n placeholder?: string;\n description?: string;\n disabled?: boolean;\n step?: string;\n}) {\n const field = useFieldContext<number>();\n return (\n <FieldShell\n label={props.label}\n required={props.required}\n description={props.description}\n display={\n Number.isNaN(field.state.value) ? null : (\n <span className=\"tabular-nums\">{String(field.state.value)}</span>\n )\n }\n >\n <Input\n id={field.name}\n name={field.name}\n type=\"number\"\n step={props.step}\n placeholder={props.placeholder}\n aria-required={props.required === true || undefined}\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n value={Number.isNaN(field.state.value) ? \"\" : String(field.state.value)}\n disabled={props.disabled}\n onBlur={field.handleBlur}\n onChange={(event) => {\n // 空串裸转 Number 是 0,会把「清空了」写成「改成 0」;映射 NaN 让 z.number() 判失败\n const raw = event.target.value;\n field.handleChange(raw.trim() === \"\" ? Number.NaN : Number(raw));\n }}\n />\n </FieldShell>\n );\n}\n\nexport function SwitchField(props: {\n label: string;\n required?: boolean;\n description?: string;\n disabled?: boolean;\n}) {\n const L = useLocalization();\n const field = useFieldContext<boolean>();\n return (\n <FieldShell\n label={props.label}\n required={props.required}\n description={props.description}\n orientation=\"horizontal\"\n display={L(field.state.value ? \"Form:Yes\" : \"Form:No\")}\n >\n <Switch\n id={field.name}\n aria-required={props.required === true || undefined}\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n checked={field.state.value}\n disabled={props.disabled}\n onCheckedChange={(checked: boolean) => field.handleChange(checked)}\n />\n </FieldShell>\n );\n}\n\nexport function SelectField(props: {\n label: string;\n options: { value: string; label: string }[];\n required?: boolean;\n description?: string;\n disabled?: boolean;\n}) {\n const field = useFieldContext<string>();\n return (\n <FieldShell\n label={props.label}\n required={props.required}\n description={props.description}\n display={props.options.find((option) => option.value === field.state.value)?.label ?? null}\n >\n <Select\n value={field.state.value}\n onValueChange={(value) => field.handleChange(value)}\n disabled={props.disabled}\n >\n <SelectTrigger\n id={field.name}\n className=\"w-full\"\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n >\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {props.options.map((option) => (\n <SelectItem key={option.value} value={option.value}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </FieldShell>\n );\n}\n\nexport function DateField(props: {\n label: string;\n required?: boolean;\n description?: string;\n disabled?: boolean;\n placeholder?: string;\n}) {\n const field = useFieldContext<string>();\n return (\n <FieldShell\n label={props.label}\n required={props.required}\n description={props.description}\n display={field.state.value ? <span className=\"tabular-nums\">{field.state.value}</span> : null}\n >\n <Suspense fallback={<Skeleton className=\"h-9 w-full\" />}>\n <DatePicker\n id={field.name}\n value={parseIso(field.state.value, ISO_DATE)}\n disabled={props.disabled}\n placeholder={props.placeholder}\n aria-required={props.required === true || undefined}\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n onChange={(date) => field.handleChange(formatIso(date, ISO_DATE))}\n />\n </Suspense>\n </FieldShell>\n );\n}\n\nexport function DateTimeField(props: {\n label: string;\n description?: string;\n required?: boolean;\n disabled?: boolean;\n placeholder?: string;\n}) {\n const field = useFieldContext<string>();\n return (\n <FieldShell\n label={props.label}\n required={props.required}\n description={props.description}\n display={field.state.value ? <span className=\"tabular-nums\">{field.state.value}</span> : null}\n >\n <Suspense fallback={<Skeleton className=\"h-9 w-full\" />}>\n <DateTimePicker\n id={field.name}\n value={parseIso(field.state.value, ISO_DATE_TIME)}\n disabled={props.disabled}\n placeholder={props.placeholder}\n aria-required={props.required === true || undefined}\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n onChange={(date) => field.handleChange(formatIso(date, ISO_DATE_TIME))}\n />\n </Suspense>\n </FieldShell>\n );\n}\n\nexport function DateRangeField(props: {\n label: string;\n description?: string;\n required?: boolean;\n disabled?: boolean;\n}) {\n const field = useFieldContext<{ from: string; to: string }>();\n return (\n <FieldShell\n label={props.label}\n required={props.required}\n description={props.description}\n display={\n field.state.value.from || field.state.value.to ? (\n <span className=\"tabular-nums\">\n {field.state.value.from} – {field.state.value.to}\n </span>\n ) : null\n }\n >\n <Suspense fallback={<Skeleton className=\"h-9 w-full\" />}>\n <DateRangePicker\n id={field.name}\n value={{\n from: parseIso(field.state.value.from, ISO_DATE),\n to: parseIso(field.state.value.to, ISO_DATE),\n }}\n disabled={props.disabled}\n aria-required={props.required === true || undefined}\n aria-invalid={field.state.meta.errors.length > 0 || undefined}\n onChange={(range) =>\n field.handleChange({\n from: formatIso(range?.from, ISO_DATE),\n to: formatIso(range?.to, ISO_DATE),\n })\n }\n />\n </Suspense>\n </FieldShell>\n );\n}\n\nexport function ComboboxField(props: {\n label: string;\n description?: string;\n options?: ComboboxOption[];\n loadOptions?: (search: string) => Promise<ComboboxOption[]>;\n placeholder?: string;\n required?: boolean;\n disabled?: boolean;\n}) {\n const field = useFieldContext<string>();\n return (\n <FieldShell\n label={props.label}\n required={props.required}\n description={props.description}\n display={\n props.options?.find((option) => option.value === field.state.value)?.label ??\n field.state.value\n }\n >\n <Combobox\n value={field.state.value || undefined}\n onChange={(value) => field.handleChange(value ?? \"\")}\n options={props.options ?? []}\n loadOptions={props.loadOptions}\n placeholder={props.placeholder}\n disabled={props.disabled}\n />\n </FieldShell>\n );\n}\n\nexport function MultiComboboxField(props: {\n label: string;\n options: ComboboxOption[];\n editable: boolean;\n}) {\n const field = useFieldContext<string[]>();\n return (\n <FieldShell\n label={props.label}\n display={\n field.state.value.length === 0 ? null : (\n <div className=\"flex flex-wrap justify-end gap-1\">\n {field.state.value.map((name) => (\n <Badge key={name} variant=\"secondary\">\n {name}\n </Badge>\n ))}\n </div>\n )\n }\n >\n {props.editable ? (\n <Suspense fallback={<Skeleton className=\"h-9 w-full\" />}>\n <MultiCombobox\n values={field.state.value}\n onChange={(values) => field.handleChange(values)}\n options={props.options}\n />\n </Suspense>\n ) : (\n <div className=\"flex flex-wrap gap-1\">\n {field.state.value.length === 0 ? (\n <span className=\"text-sm text-muted-foreground\">—</span>\n ) : (\n field.state.value.map((name) => (\n <Badge key={name} variant=\"secondary\">\n {name}\n </Badge>\n ))\n )}\n </div>\n )}\n </FieldShell>\n );\n}\n\n/** onSubmitAsync 返回的 form 键落 state.errorMap.onSubmit;\\n 拆多条渲染。 */\nfunction FormErrors() {\n const form = useFormContext();\n return (\n <form.Subscribe selector={(state) => state.errorMap.onSubmit}>\n {(onSubmit) => {\n const message = typeof onSubmit === \"string\" ? onSubmit : undefined;\n return <FormErrorSummary errors={message ? message.split(\"\\n\") : []} />;\n }}\n </form.Subscribe>\n );\n}\n\nconst { useAppForm: useAppFormBase, withForm } = createFormHook({\n fieldContext,\n formContext,\n fieldComponents: {\n TextField,\n NumberField,\n SwitchField,\n SelectField,\n DateField,\n DateTimeField,\n DateRangeField,\n ComboboxField,\n MultiComboboxField,\n },\n formComponents: { FormErrors },\n});\n\n/** 提交结束后把焦点移到第一个仍有错误的字段;取不到 DOM 元素(如自定义组件未转发 id)则顺延到\n * 下一个可聚焦的错误字段。字段输入的 id 约定为 field.name(内置字段组件已遵守)。「第一个」按\n * fieldMeta 的字段注册序(≈挂载序/tab 序);条件渲染或重排字段的极端场景下未必是视觉最上者。 */\nfunction focusFirstInvalidField(form: {\n state: { fieldMeta: Record<string, { errors?: unknown[] } | undefined> };\n}): void {\n for (const [name, meta] of Object.entries(form.state.fieldMeta)) {\n if (!meta?.errors?.length) continue;\n const el = document.getElementById(name);\n if (el) {\n el.focus();\n return;\n }\n }\n}\n\nconst patchedForms = new WeakSet<object>();\n\n/**\n * 包装 useAppFormBase:提交前注入服务端错误清理(见 clearServerSubmitErrors),提交结束后聚焦\n * 首错字段(见 focusFirstInvalidField)。WeakSet 守卫而不是 useState/useRef,后者初始化器在\n * StrictMode 下双调用,保证不了只 patch 一次。\n */\nexport const useAppForm: typeof useAppFormBase = (opts) => {\n const form = useAppFormBase(opts);\n if (!patchedForms.has(form)) {\n patchedForms.add(form);\n const original = form.handleSubmit;\n form.handleSubmit = ((submitMeta?: unknown) => {\n clearServerSubmitErrors(form as unknown as ServerErrorForm);\n const result = submitMeta === undefined ? original() : original(submitMeta as never);\n return result.then(() => {\n focusFirstInvalidField(form as unknown as Parameters<typeof focusFirstInvalidField>[0]);\n });\n }) as typeof form.handleSubmit;\n }\n return form;\n};\n\nexport { withForm };\n",
|
|
29
29
|
"type": "registry:component",
|
|
30
30
|
"target": "components/form/form-hook.tsx"
|
|
31
31
|
},
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
},
|
|
38
38
|
{
|
|
39
39
|
"path": "ui/blocks/form/form-section.tsx",
|
|
40
|
-
"content": "import type { ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n
|
|
40
|
+
"content": "import type { ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * 放进 `<div className=\"divide-y rounded-lg border bg-card\">` 容器里连排,分区线由容器 divide-y 画。\n *\n * 落在 `SheetForm` 查看态的 `<dl>` 里时自动收成记录布局:去掉两栏网格与 p-6 内边距,标题变成\n * 一行小节头,字段行之间改由发丝线分隔——否则每个分区会顶着自己的留白挤在键值卡里,行的\n * 左右边距也与顶层 `FieldRow` 对不齐。\n */\nexport function FormSection(props: {\n title: string;\n description?: string;\n children: ReactNode;\n className?: string;\n}) {\n return (\n <div\n className={cn(\n \"grid gap-4 p-6 md:grid-cols-[220px_1fr] md:gap-8\",\n \"in-[dl]:block in-[dl]:p-0\",\n props.className,\n )}\n >\n <div className=\"in-[dl]:px-3 in-[dl]:pt-3 in-[dl]:pb-1\">\n <h3 className=\"text-sm font-semibold\">{props.title}</h3>\n {props.description !== undefined && (\n <p className=\"mt-1.5 text-xs leading-relaxed text-muted-foreground\">\n {props.description}\n </p>\n )}\n </div>\n <div className=\"min-w-0 space-y-4 in-[dl]:divide-y in-[dl]:space-y-0\">{props.children}</div>\n </div>\n );\n}\n",
|
|
41
41
|
"type": "registry:component",
|
|
42
42
|
"target": "components/form/form-section.tsx"
|
|
43
43
|
},
|