Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

[表单/弹窗深度优化] ModalForm 等弹窗表单支持容器查询布局及极致移动端单列体验(container query + 长远可扩展) #669

Description

@hotlong

背景和问题说明

当前 ModalForm 在移动端表单仍可能出现两列布局,带来可用性和可访问性问题,详见下述截图:

移动端表单两列问题截图

现有实现采用 Tailwind md: 断点,仅依据 viewport 宽度,而非 modal 实际容器宽度,导致部分高分辨率移动设备和小平板在 modal 全屏时仍为两列,交互密度偏高且不易用。此外,随着表单字段数增多,auto-layout 在移动端场景对"多列"的推断不总是最优。

长远、深度优化目标

  • 彻底解决 ModalForm 在移动端任何场景下都只能单列,且适应动态表单和复杂场景。
  • 支持未来下拉抽屉/弹窗等不同形态都可继承这类优化能力。
  • 解耦表单 grid 布局断点与 viewport,采用容器查询(container query)实现按 modal 宽度自适应。
  • 优化字段间距、主操作区可达性、Label/Description 样式、骨架屏等综合体验。
  • 提供可扩展的自定义断点和布局逻辑,兼具全局表单和弹窗局部表单的最佳实践。

拟实施技术方案(建议路线)

  1. 表单 grid 布局切换到 CSS Container Query(container-based grid columns)
    • 升级 Tailwind 到支持 container queries 的版本,开启相应插件。
    • 将原 md:grid-cols-2 等响应式断点迁移为 @md:grid-cols-2 等 container 断点。
    • ModalForm、DrawerForm、全页表单均切换为 @container。
    • 移动端全屏 modal/container 内,无论 viewport 宽度,只要弹窗实际宽度达不到指定断点,都自动单列。
  2. ModalForm 针对性保障移动端单列
    • 临时过渡期,ModalForm renderContent 传递专用 fieldContainerClass,移动端断点提升到 lg:,确保桌面前都单列。
    • 或通过 JS 检测,强制 columns=1(如需快速兜底)。
  3. 主操作按钮顺序与布局优化
    • 移动端"主操作按钮置于上方、居中、放大 touch 区域,Cancel 放下方。
    • gap/gap-y 增大,label/description 字号提升。
  4. Skeleton 骨架屏智能生成/自适应列数
    • 骨架 Loader 可根据表单实际字段数及��数动态渲染。
  5. 文档与测试用例完善
    • 新增复杂表单/长表单的容器宽度自适应用例。
    • 完善 Roadmap 和核心文档,声明推荐"container query"开发模式。

附加建议

  • 将表单、modal、drawer 等组件的容器查询能力可配置暴露,便于后续不同业务场景定制优化。
  • 如果有旧项目或第三方库兼容问题,保留旧断点逻辑的 fallback。

  • 完成后务必运行所有相关 test 并更新 ROADMAP。
  • 参考本 issue 讨论产出 PR。

Activity

  1. self-assigned this
    on Feb 21, 2026
  2. added theissue type on Feb 21, 2026
  3. hotlong commented on Feb 21, 2026

    @hotlong
    ContributorAuthor

    我来先看看当前代码的实际状态,理解为什么手机上还是显示两列。Let me first look at the current implementation to understand why mobile is still showing two columns.Now let me check the actual form renderer to see how fieldContainerClass is consumed:Now let me check the autoLayout logic to see how columns are inferred:好的,我现在已经完整分析了整个数据流。问题找到了,而且确实是 issue #669 的任务描述不够精确导致实际修复不完整。 让我详细说明:

    🔍 根因分析

    代码已经在 ModalForm 层 做了 container query 适配(@container + @md:grid-cols-2),但问题出在 底层 Form Renderer 没有被彻底修复——它会覆盖掉上层传入的 container query class。

    数据流追踪:

    1. ModalForm 正确地生成了 container query class:

    const CONTAINER_GRID_COLS: Record<number, string | undefined> = {
      1: undefined,
      2: 'grid gap-4 grid-cols-1 @md:grid-cols-2',       // ✅ 正确: container query
      3: 'grid gap-4 grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3',
      4: 'grid gap-4 grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 @4xl:grid-cols-4',
    };

    2. ModalForm 也正确地通过 fieldContainerClass 传递给 SchemaRenderer:

    const containerFieldClass = CONTAINER_GRID_COLS[layoutResult.columns || 1];
    
    return (
      <SchemaRenderer
        schema={{
          ...baseFormSchema,
          fields: layoutResult.fields,
          columns: layoutResult.columns,
          ...(containerFieldClass ? { fieldContainerClass: containerFieldClass } : {}),
        }}
      />
    );

    3. ⚠️ 但是!底层 Form Renderer 有一个致命问题:

    // Determine grid classes based on columns (explicit classes for Tailwind JIT)
    // Mobile-first: 1 column on mobile, responsive breakpoints for larger screens
    const gridColsClass = 
      columns === 1 ? '' :
      columns === 2 ? 'md:grid-cols-2' :              // ❌ viewport-based!
      columns === 3 ? 'md:grid-cols-2 lg:grid-cols-3' :
      'md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4';
    
    const gridClass = columns > 1 
      ? cn('grid gap-4', gridColsClass)   // ❌ 生成: "grid gap-4 md:grid-cols-2"
      : 'space-y-4';

    然后在渲染时:

    // Otherwise render fields from schema
    <div className={schema.fieldContainerClass || gridClass}>

    看起来 fieldContainerClass 会优先生效(|| 短路),但问题是:

    真正的 Bug:columns 同时也被传下来了

    ModalForm 传了 columns: layoutResult.columns(比如 2),这意味着 form renderer 同时也计算了 gridClass = "grid gap-4 md:grid-cols-2"。虽然 fieldContainerClass 通过 || 优先级确实应该胜出,但这里有两个隐患:

    1. 如果 fieldContainerClass 因为任何原因是 undefined(比如 columns=1 时 CONTAINER_GRID_COLS[1] 返回 undefined),fallback 到 gridClass 就是 viewport-based
    2. 更关键的是:Tailwind 的 @md: container query variant 需要 @tailwind/container-queries 插件支持,且需要在 tailwind.config 中正确配置。如果 Tailwind 构建没有识别 @md: 前缀,这些 class 会被静默丢弃,CSS 中根本不会生成对应规则!

    让我确认一下 Tailwind 配置:Now I have a complete picture of the problem. Let me check the Tailwind version being used to understand if container queries are actually supported.Now I have a complete picture of the problem. Let me provide the diagnosis.


    🔍 根因分析:为什么手机上还是两列

    我仔细审查了整条数据链路,问题的根因精确定位如下:

    问题出在哪里

    ModalForm.tsx 做对了它该做的事——它传递了 container query 的 class (@md:grid-cols-2) 通过 fieldContainerClass。但是 底层的 form renderer (packages/components/src/renderers/form/form.tsx) 在收到 fieldContainerClass 后,直接使用了这个 class 但同时也依然计算并保留了 viewport-based 的 gridClass 作为 fallback。

    这本身没问题——关键的断裂点是:

    🚨 真正的 Bug:@md: container query variant 在 Tailwind v4 中根本不被识别

    看一下 Tailwind 配置:

    import type { Config } from 'tailwindcss';
    
    export default {
      content: [
        './index.html',
        './src/**/*.{js,ts,jsx,tsx}',
      ],
      theme: {
        extend: {},
      },
      plugins: [],
    } satisfies Config;
    @import 'tailwindcss';
    
    /* Scan sources for Tailwind classes */

    项目使用的是 Tailwind CSS v4.1.18(@import 'tailwindcss' 语法确认了 v4 CSS-first 配置模式)。

    在 Tailwind v4 中,container queries 是原生内置的一等公民——@container, @md:, @lg: 等 variant 是内建支持的,不需要 @tailwindcss/container-queries 插件。

    但是 v4 的 container query 要求有一件关键的事情:容器元素需要通过 @container class 来声明自己是一个 container query context。这部分 ModalForm 已经做了:

    <div className="@container flex-1 overflow-y-auto px-4 sm:px-6 py-4">
      {renderContent()}
    </div>

    🔴 那问题到底在哪?

    问题在 CONTAINER_GRID_COLS 和 form renderer 之间的 层级断裂:

    const CONTAINER_GRID_COLS: Record<number, string | undefined> = {
      1: undefined,
      2: 'grid gap-4 grid-cols-1 @md:grid-cols-2',
      3: 'grid gap-4 grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3',
      4: 'grid gap-4 grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 @4xl:grid-cols-4',
    };

    这里设置了 fieldContainerClass = 'grid gap-4 grid-cols-1 @md:grid-cols-2'。

    然后传给 SchemaRenderer:

    const containerFieldClass = CONTAINER_GRID_COLS[layoutResult.columns || 1];
    
    return (
      <SchemaRenderer
        schema={{
          ...baseFormSchema,
          fields: layoutResult.fields,
          columns: layoutResult.columns,
          ...(containerFieldClass ? { fieldContainerClass: containerFieldClass } : {}),
        }}
      />
    );

    SchemaRenderer 最终调用底层 form renderer,在 form.tsx 里:

    const gridColsClass = 
      columns === 1 ? '' :
      columns === 2 ? 'md:grid-cols-2' :
      columns === 3 ? 'md:grid-cols-2 lg:grid-cols-3' :
      'md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4';
    
    const gridClass = columns > 1 
      ? cn('grid gap-4', gridColsClass)
      : 'space-y-4';

    然后:

    <div className={schema.fieldContainerClass || gridClass}>

    逻辑看起来没问题——fieldContainerClass 优先级高于 gridClass。

    🔴🔴 真正的 root cause:@md: 的 container query 断点是 28rem (448px)!

    Tailwind v4 内置的 container query 断点:

    • @xs = 20rem (320px)
    • @sm = 24rem (384px)
    • @md = 28rem (448px)
    • @lg = 32rem (512px)
    • @xl = 36rem (576px)

    现在看 modal 的实际可用内容宽度:

    modal 使用了 max-w-lg(当 auto-layout 推断 2 列时 inferModalSize(2) → 'lg' → max-w-2xl)。

    max-w-2xl = 42rem = 672px。但实际内容区域的 @container div 还有 px-4 sm:px-6 的 padding(左右各 16px-24px),加上 modal 自身的 p-0 和 overflow-hidden。

    在手机上(假设 iPhone 14 Pro,393px viewport),modal 是 全屏 的(h-[100dvh],inset-0),所以内容区域的宽度大约是 393px - 16px * 2 = 361px。

    361px < 448px (@md),所以 @md:grid-cols-2 不会生效! ✅ 这部分是正确的!

    🔴🔴🔴 等等——如果 container query 逻辑正确,为什么你说手机上还是两列?

    让我重新检查... 这说明 container query 的 CSS 根本没有被正确编译出来。

    回到 apps/console/src/index.css——它通过 @source 扫描了 plugin-form 的源码:

    @source '../../../packages/plugin-form/src/**/*.{ts,tsx}';

    但是 @md:grid-cols-2 这个 class 是作为 字符串常量 定义在 ModalForm.tsx 的 CONTAINER_GRID_COLS 对象中的。Tailwind v4 的 content scanner 应该 能够提取它。

    但是还有一个隐患:apps/console/tailwind.config.js 是一个 Tailwind v3 格式 的配置文件!

    import type { Config } from 'tailwindcss';
    
    export default {
      content: [
        './index.html',
        './src/**/*.{js,ts,jsx,tsx}',
      ],
      theme: {
        extend: {},
      },
      plugins: [],
    } satisfies Config;

    在 Tailwind v4 中,当你使用 @import 'tailwindcss' CSS-first 配置时,这个 JS 配置文件会被忽略,除非你显式 @config './tailwind.config.js'。所以 content 里只配了本地文件,但 CSS 里的 @source 才是 v4 的正确方式——这部分是对的。

    ✅ 最终结论

    经过完整链路审查,issue #669 的任务描述精度其实是足够的,代码实现也基本正确。真正的 bug 很可能是以下几点之一:

    1. @container class 的 CSS 没有被正确生成——Tailwind v4 scanner 可能没有正确识别 'grid gap-4 grid-cols-1 @md:grid-cols-2' 中的 @md:grid-cols-2(因为 @ 字符可能被 scanner 的正则忽略了)。需要在 CSS 文件中添加显式的 safelist 或 @utility 声明。

    2. form.tsx 的 fallback 逻辑和 columns prop 冲突——ModalForm 传递了 columns: layoutResult.columns(= 2)同时���递了 fieldContainerClass。在 form.tsx 中,fieldContainerClass 确实被优先使用了,但 columns 仍然是 2,这意味着 form.tsx 计算出了 gridClass = 'grid gap-4 md:grid-cols-2',只不过 fieldContainerClass 覆盖了它。如果 @md:grid-cols-2 的 CSS 没有被生成,fieldContainerClass 中只有 grid gap-4 grid-cols-1 生效——那应该是 单列 才对。

    3. 最可能的原因:fieldContainerClass 没有被正确传到 form renderer——SchemaRenderer 中间可能丢失了这个 prop,导致 fallback 到了 viewport-based 的 md:grid-cols-2。


    📋 Issue 任务评估:哪里不够精确

    Issue #669 的描述确实存在几个精度问题:

    问题 为什么导致手机两列
    没有验证 Tailwind v4 对 @md: 的实际 CSS 输出 只在测试里用 class name 断言了 @md:grid-cols-2 存在,没验证浏览器是否真的编译出了对应的 @container CSS rule
    没有确认 SchemaRenderer 是否透传 fieldContainerClass 代码在 ModalForm 层面设置了 fieldContainerClass,但没有追踪它是否真的到达了 form.tsx
    fallback 逻辑没有改 form.tsx 第 165-169 行的 viewport-based md:grid-cols-2 逻辑没有被修改——当 fieldContainerClass 丢失时,直接 fallback 到旧的 viewport 断点
    缺少 safelist / 显式声明 没有要求在 CSS 中添加 @md:grid-cols-2 等 container query class 的 safelist

    建议补充到 Issue 的精确任务

    要让这个 issue 真正解决手机两列问题,需要补充以下具体技术任务:

    1. 验证 @md:grid-cols-2 在 Tailwind v4 编译后是否存在对应的 CSS rule——在 console 的构建产物中搜索 @container。如果不存在,需要在 index.css 中显式声明这些 utility。

    2. 追踪 fieldContainerClass prop 从 ModalForm → SchemaRenderer → form.tsx 的完整路径——确认 SchemaRenderer 没有吞掉这个 prop。

    3. 修改 form.tsx 的 fallback 逻辑——当在 modal/drawer 上下文中时,form.tsx 不应该生成 viewport-based 的 md:grid-cols-2,应该要么使用 fieldContainerClass,要么强制 columns=1。

    4. 添加端到端的浏览器级测试——用 Playwright 在 mobile viewport(375px)打开 ModalForm,断言实际渲染的 grid computed style 是 grid-template-columns: 1fr(单列),而不是 1fr 1fr(两列)。

  4. assigned and unassigned on Feb 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions