在实施 #3393 (PR #3399 ,把字段 label 转发给内置 textarea 的全屏对话框)时发现,未在该 PR 内修改 —— 那一单的完成范围是 label 传递,这一条是 readonly / disabled 根本没往全屏路径送,属于另一条独立缺陷。
现象
packages/components/src/renderers/form/form.tsx 的 renderFieldComponent 在入口把 readonly 从 props 里解构走了:
const { inputType, options = [ ] , placeholder, readonly, emptyHint, ...fieldProps } = props ;
内置 textarea 分支的两条出口对它的处理并不一样:
非全屏出口:Textarea ... {...rest} readOnly={readonly} + readonlyInputClass,正确;
全屏出口:FullscreenTextarea placeholder label className {...rest} —— readonly 既不在 rest 里(已被解构走)也没有显式转发 ,readonlyInputClass 也没给。
FullscreenTextarea 内部同样有两个控件:内联的 Textarea 吃 {...rest},而对话框里那个是独立 的 Textarea autoFocus value={draft} ...,不接 rest 的任何东西;展开按钮 button type="button" 也没有 disabled。
实测(vitest,happy-dom,origin/main @ 9cbcbf4 + PR #3399 分支,两者行为相同 —— 与 label 无关)
字段 { name: 'notes', label: 'Notes', type: 'textarea', mobile_fullscreen: true, readonly: true }:
READONLY: 展开按钮存在 = true
READONLY: 内联 textarea readOnly = false ← 只读字段直接就能改,连对话框都不用开
READONLY: 对话框输入框 readOnly = false / disabled = false
READONLY: 点「完成」后字段值 = "EDITED WHILE READONLY" ← 已进表单状态
字段 { ..., mobile_fullscreen: true, disabled: true }:
DISABLED: 展开按钮存在 = true,按钮 disabled = false
DISABLED: 内联 textarea disabled = true ← 这一路是拦住的
DISABLED: 对话框输入框 disabled = false
DISABLED: 点「完成」后字段值 = "EDITED WHILE DISABLED" ← 绕过禁用,值进表单状态
即:只读 长文本在全屏开关打开时根本不是只读的;禁用 长文本看起来是禁用的(内联控件灰着),但展开按钮仍可点,对话框里可以随便改,「完成」把值写回表单状态。后者尤其难被发现,因为控件外观是对的。
可达性:这个组合是平台自己造出来的,不是刁钻写法
packages/plugin-form/src/ObjectForm.tsx:1076 在 mobile.fullscreenLongText 打开时,给每一个 长文本字段无差别盖 mobile_fullscreen: true,不看该字段是否 readonly/disabled。而 readonly 既可以静态声明,也可以由 readonlyWhen 的 CEL 在运行时解析出来(resolveFieldRuleState),disabled 还会因为 isSubmitting 而为真 —— 也就是说表单提交进行中,长文本字段仍可通过全屏对话框改值 。
顺带证伪了 #3398 的一句话
#3398 (两份全屏实现的观察类记录)写着「两份实现目前行为一致 ,用户碰不到差异」。这一条证明差异已经存在,而且正好在这里:注册路径的 TextAreaField.tsx:40 在 readonly 时提前返回 一个只读展示,压根不渲染全屏按钮(showFullscreenButton 在那之后才算);内置分支不但渲染按钮,还让人改。所以 #3398 的「漂移风险」已经兑现成了实际漂移,建议 triage 时把这条与 #3398 一起看:如果最终裁决是合并实现,这条大概率随之解决;如果维持两份,这条要单独修,并且正是 #3398 选项 B 所说「加对齐测试防漂移」该覆盖的第一条差异。
FullscreenFieldEditor.tsx 里 grep readonly|readOnly|disabled 零命中 —— 它本身也不认这两个状态,只是被 TextAreaField 的提前返回挡在前面了。所以若走「下沉合并」路线,readonly/disabled 语义要在合并时一并定义,而不是假设搬过去就有。
修法方向(不预设结论)
最小修:全屏出口显式转发 readonly(以及把 disabled 送进对话框),展开按钮在 readonly/disabled 时不渲染或禁用,对话框输入框跟随。
对齐注册路径:readonly 时干脆不给全屏按钮(与 TextAreaField 一致),这样两条路径的用户可见行为一样。
若 全屏长文本编辑器有两份实现:form.tsx 的 FullscreenTextarea 与 fields 的 FullscreenFieldEditor(合并受阻于 components ← fields 的依赖方向) #3398 裁决为合并,则在合并后的实现里一次性定义这两个状态。
倾向 2 + 1 的组合(readonly 不给按钮;disabled 时按钮禁用),因为它让两条渲染路径对同一份元数据给出同一种行为 —— 但这取决于 #3398 怎么走,故本条不预设。
证据等级
动态证据:在 worktree 里跑 vitest 探针实测(上面两段输出为真实运行结果,探针为临时文件,未提交);静态证据:读 form.tsx 的解构与两条出口、FullscreenTextarea 的三个控件、TextAreaField.tsx:39-57、FullscreenFieldEditor.tsx 的 grep、ObjectForm.tsx:1076-1086。未做浏览器复现。
相邻但未验证 的一条(不要当结论用)
内置分支读的是 FormField 级的 fieldProps.mobile_fullscreen,而 ObjectForm 在 f.field 存在时把标记盖在 f.field 上(#3245 为修 widget 路径而改的)。若确实存在「type: 'textarea' 且带 .field」的字段,内置分支就看不到这个标记 —— 这是 #3245 的镜像。需要一次 plugin-form 级的实测才能判定真伪,本条未做,列在这里只为不让它丢失。
关联
在实施 #3393(PR #3399,把字段 label 转发给内置 textarea 的全屏对话框)时发现,未在该 PR 内修改 —— 那一单的完成范围是 label 传递,这一条是
readonly/disabled根本没往全屏路径送,属于另一条独立缺陷。现象
packages/components/src/renderers/form/form.tsx的renderFieldComponent在入口把readonly从 props 里解构走了:内置
textarea分支的两条出口对它的处理并不一样:Textarea ... {...rest} readOnly={readonly}+readonlyInputClass,正确;FullscreenTextarea placeholder label className {...rest}——readonly既不在rest里(已被解构走)也没有显式转发,readonlyInputClass也没给。FullscreenTextarea内部同样有两个控件:内联的Textarea吃{...rest},而对话框里那个是独立的Textarea autoFocus value={draft} ...,不接rest的任何东西;展开按钮button type="button"也没有disabled。实测(vitest,happy-dom,
origin/main@ 9cbcbf4 + PR #3399 分支,两者行为相同 —— 与 label 无关)字段
{ name: 'notes', label: 'Notes', type: 'textarea', mobile_fullscreen: true, readonly: true }:字段
{ ..., mobile_fullscreen: true, disabled: true }:即:只读长文本在全屏开关打开时根本不是只读的;禁用长文本看起来是禁用的(内联控件灰着),但展开按钮仍可点,对话框里可以随便改,「完成」把值写回表单状态。后者尤其难被发现,因为控件外观是对的。
可达性:这个组合是平台自己造出来的,不是刁钻写法
packages/plugin-form/src/ObjectForm.tsx:1076在mobile.fullscreenLongText打开时,给每一个长文本字段无差别盖mobile_fullscreen: true,不看该字段是否 readonly/disabled。而readonly既可以静态声明,也可以由readonlyWhen的 CEL 在运行时解析出来(resolveFieldRuleState),disabled还会因为isSubmitting而为真 —— 也就是说表单提交进行中,长文本字段仍可通过全屏对话框改值。顺带证伪了 #3398 的一句话
#3398(两份全屏实现的观察类记录)写着「两份实现目前行为一致,用户碰不到差异」。这一条证明差异已经存在,而且正好在这里:注册路径的
TextAreaField.tsx:40在readonly时提前返回一个只读展示,压根不渲染全屏按钮(showFullscreenButton在那之后才算);内置分支不但渲染按钮,还让人改。所以 #3398 的「漂移风险」已经兑现成了实际漂移,建议 triage 时把这条与 #3398 一起看:如果最终裁决是合并实现,这条大概率随之解决;如果维持两份,这条要单独修,并且正是 #3398 选项 B 所说「加对齐测试防漂移」该覆盖的第一条差异。FullscreenFieldEditor.tsx里 grepreadonly|readOnly|disabled零命中 —— 它本身也不认这两个状态,只是被TextAreaField的提前返回挡在前面了。所以若走「下沉合并」路线,readonly/disabled 语义要在合并时一并定义,而不是假设搬过去就有。修法方向(不预设结论)
readonly(以及把disabled送进对话框),展开按钮在 readonly/disabled 时不渲染或禁用,对话框输入框跟随。TextAreaField一致),这样两条路径的用户可见行为一样。倾向 2 + 1 的组合(readonly 不给按钮;disabled 时按钮禁用),因为它让两条渲染路径对同一份元数据给出同一种行为 —— 但这取决于 #3398 怎么走,故本条不预设。
证据等级
动态证据:在 worktree 里跑 vitest 探针实测(上面两段输出为真实运行结果,探针为临时文件,未提交);静态证据:读
form.tsx的解构与两条出口、FullscreenTextarea的三个控件、TextAreaField.tsx:39-57、FullscreenFieldEditor.tsx的 grep、ObjectForm.tsx:1076-1086。未做浏览器复现。相邻但未验证的一条(不要当结论用)
内置分支读的是 FormField 级的
fieldProps.mobile_fullscreen,而ObjectForm在f.field存在时把标记盖在f.field上(#3245 为修 widget 路径而改的)。若确实存在「type: 'textarea'且带.field」的字段,内置分支就看不到这个标记 —— 这是 #3245 的镜像。需要一次 plugin-form 级的实测才能判定真伪,本条未做,列在这里只为不让它丢失。关联
mobile_fullscreen || fullscreen读全屏开关,而fullscreen这个别名全仓无人产出(消费侧宽容兜底) #3303 / PR fix(components): 表单内置 textarea 分支只读 mobile_fullscreen,删掉无生产者的 fullscreen 别名 (#3303) #3397、ObjectForm 的 mobile.fullscreenLongText 到不了自动生成字段的 TextAreaField:flag 写在 FormField 上,form.tsx 转发的却是 field.field #3245、mobile.fullscreenLongText对 rich-text 字段是空承诺:ObjectForm 给field:markdown/field:html盖了 flag,RichTextField 根本不读;string-multiline分支全仓无人产出 #3301 —— 同一分支/同一标记的历史