@greypan/web-ui 9.0.0 → 9.1.0

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 (50) hide show
  1. package/README.CN.md +68 -7
  2. package/README.md +81 -18
  3. package/dist/assets/modal-surface.js +6 -0
  4. package/dist/assets/modal-surface.js.map +1 -0
  5. package/dist/components/context-menu/index.d.ts +15 -0
  6. package/dist/components/context-menu/index.js +50 -46
  7. package/dist/components/context-menu/index.js.map +1 -1
  8. package/dist/components/context-menu/scrim.js +1 -1
  9. package/dist/components/context-menu/scrim.js.map +1 -1
  10. package/dist/components/dialog/index.d.ts +3 -3
  11. package/dist/components/dialog/index.js +33 -26
  12. package/dist/components/dialog/index.js.map +1 -1
  13. package/dist/components/dialog/style.js +1 -1
  14. package/dist/components/dialog/style.js.map +1 -1
  15. package/dist/components/drawer/index.js +54 -49
  16. package/dist/components/drawer/index.js.map +1 -1
  17. package/dist/components/drawer/style.js +1 -1
  18. package/dist/components/drawer/style.js.map +1 -1
  19. package/dist/components/image-preview/index.js +66 -61
  20. package/dist/components/image-preview/index.js.map +1 -1
  21. package/dist/components/image-preview/style.js +1 -1
  22. package/dist/components/image-preview/style.js.map +1 -1
  23. package/dist/components/index.d.ts +1 -0
  24. package/dist/components/index.js +17 -16
  25. package/dist/components/input/style.js +1 -1
  26. package/dist/components/input/style.js.map +1 -1
  27. package/dist/components/input-number/style.js +1 -1
  28. package/dist/components/input-number/style.js.map +1 -1
  29. package/dist/components/layout/index.d.ts +22 -4
  30. package/dist/components/layout/index.js +46 -26
  31. package/dist/components/layout/index.js.map +1 -1
  32. package/dist/components/layout/style.js +1 -1
  33. package/dist/components/layout/style.js.map +1 -1
  34. package/dist/components/middle-ellipsis/index.d.ts +100 -0
  35. package/dist/components/middle-ellipsis/index.js +112 -0
  36. package/dist/components/middle-ellipsis/index.js.map +1 -0
  37. package/dist/components/middle-ellipsis/style.js +6 -0
  38. package/dist/components/middle-ellipsis/style.js.map +1 -0
  39. package/dist/components/middle-ellipsis/truncate.d.ts +62 -0
  40. package/dist/components/middle-ellipsis/truncate.js +45 -0
  41. package/dist/components/middle-ellipsis/truncate.js.map +1 -0
  42. package/dist/components/textarea/style.js +1 -1
  43. package/dist/components/textarea/style.js.map +1 -1
  44. package/dist/scrollbars/index.d.ts +33 -0
  45. package/dist/scrollbars/index.js +18 -0
  46. package/dist/scrollbars/index.js.map +1 -0
  47. package/dist/scrollbars/style.css +10 -0
  48. package/dist/shared/element-width/index.js +37 -0
  49. package/dist/shared/element-width/index.js.map +1 -0
  50. package/package.json +14 -2
package/README.CN.md CHANGED
@@ -31,6 +31,29 @@ subpath 导入只注册它点名的那个组件,页面用到的每个标签都
31
31
  <web-ui-button variant="primary">点击我</web-ui-button> <web-ui-icon .icon="${lucidePlus}"></web-ui-icon>
32
32
  ```
33
33
 
34
+ ## 滚动条
35
+
36
+ `@greypan/web-ui/scrollbars` 承载全仓统一的 [OverlayScrollbars](https://github.com/KingSora/OverlayScrollbars)
37
+ 滚动条行为:对齐 macOS 的主题、滚动时出现并自动隐去、点击轨道滚动到该位置。样式表每份文档导入一次,
38
+ 选项传给所用的适配包。
39
+
40
+ ```js
41
+ import '@greypan/web-ui/scrollbars.css'
42
+ import { webUiScrollbarsOptions } from '@greypan/web-ui/scrollbars'
43
+ import { OverlayScrollbarsComponent } from 'overlayscrollbars-vue' // 或 overlayscrollbars-react
44
+
45
+ <OverlayScrollbarsComponent :options="webUiScrollbarsOptions">{children}</OverlayScrollbarsComponent>
46
+ ```
47
+
48
+ - `overlayscrollbars` 是可选 peer dependency,只有这个 subpath 需要它。样式表里已经包含库自己的结构样式,
49
+ 它替代 `import 'overlayscrollbars/overlayscrollbars.css'`。
50
+ - 容器自己的 `overflow` 声明(`overflow-y-auto` 等)要保留:OverlayScrollbars 接管后会覆盖它,但接管之前
51
+ 靠它维持可滚动。
52
+ - 目标必须是「纯粹的滚动容器」。OverlayScrollbars 会把宿主变成 flex 行、把子节点搬进它生成的 viewport,
53
+ 所以宿主自身的布局一旦承重(grid、居中盒子、`ul`/`ol`)就会坏。
54
+ - OverlayScrollbars 只改变它被初始化到的那个元素,子元素保持原生滚动条。要覆盖文档滚动条就初始化
55
+ `document.body`——在那里 `window.scrollY` / `window.scrollTo` 保持原生语义。
56
+
34
57
  ## 框架集成
35
58
 
36
59
  ### 跨框架 API 约定
@@ -276,6 +299,7 @@ dropdown、tooltip)不需要它。
276
299
  | **数据展示** | [`<web-ui-avatar>`](#web-ui-avatar) |
277
300
  | | [`<web-ui-badge>`](#web-ui-badge) |
278
301
  | | [`<web-ui-empty>`](#web-ui-empty) |
302
+ | | [`<web-ui-middle-ellipsis>`](#web-ui-middle-ellipsis) |
279
303
  | | [`<web-ui-icon>`](#web-ui-icon) |
280
304
  | | [`<web-ui-spinner>`](#web-ui-spinner) |
281
305
  | **布局与工具** | [`<web-ui-layout>`](#web-ui-layout) |
@@ -792,7 +816,7 @@ web-ui-radio-group {
792
816
 
793
817
  使用原生 `<dialog>`,`@cancel` 阻止默认关闭行为。除非存在 `no-escape-close`,否则 Escape 调用 `close()`;除非存在 `no-backdrop-close`,否则点击遮罩关闭。启用 `controlled` 后,两者都只派发关闭请求而不自关闭。
794
818
 
795
- 启用 `closable` 后,两种内容模式都会渲染内置关闭按钮,且与 Escape、遮罩点击走同一条关闭路径(`controlled` 对它同样生效)。两种模式的位置不同:没有 `body` 插槽时按钮位于 `.title-row` 这条 flex 行里、与标题同行;有 `body` 插槽时不存在标题行,按钮作为玻璃卡片的直接子元素绝对定位到卡片右上角(偏移由 `--wui-dialog-close-top` / `--wui-dialog-close-right` 控制)。图标与 drawer 关闭按钮同为 `ooui:close`。不启用 `closable` 时什么都不渲染——既没有按钮,也没有多余的包裹层。
819
+ 启用 `closable` 后,两种内容模式都会渲染内置关闭按钮,且与 Escape、遮罩点击走同一条关闭路径(`controlled` 对它同样生效)。两种模式的位置已统一:按钮都绝对定位到卡片右上角,距卡片两沿的偏移由 `--wui-dialog-close-top` / `--wui-dialog-close-right` 控制(默认都是 `20px`;drawer 关闭按钮仍为 `16px`)。只有 `body` 插槽会让标题行消失;标题模式下标题会为按钮留出避让,长标题不会钻到按钮下面。图标与 drawer 关闭按钮同为 `ooui:close`。不启用 `closable` 时什么都不渲染——既没有按钮,也没有多余的包裹层。
796
820
 
797
821
  > **Escape 归属**:Escape 由共享仲裁者统一判定,一次按键只关闭**最内层**的已打开浮层(popover、select、autocomplete、dropdown、context-menu、drawer、dialog 都参与)。例如在 drawer 内打开 select,第一次 Escape 只关 select,第二次才关 drawer。互不嵌套的并列浮层按打开顺序关闭最上层。正在播退场过渡的面板仍在场上,也仍由它接住 Escape——但只要还有别的浮层开着,那一次按键仍然归该层,「一次按键关一层」不因此改变。`image-preview` 同样参与:它的原生 `<dialog>` 会登记进同一个仲裁者,Escape 按层级判定;组件的 `cancel` handler 只是拦掉原生的瞬时关闭,把 top layer 保留到退场过渡结束。
798
822
 
@@ -802,8 +826,8 @@ web-ui-radio-group {
802
826
  | --------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
803
827
  | `--wui-dialog-width` | `360px` | 对话框宽度 |
804
828
  | `--wui-dialog-max-height` | `90vh` / `min(90vh, 90dvh)`(dvh 引擎) | 内容区(`.desc`)的最大高度,不是整卡高度。两套 fallback:基础规则 `90vh`,`@supports (height: 100dvh)` 内 `min(90vh, 90dvh)`——不支持 dvh 的引擎只拿到 `90vh`。整卡换算见下方说明 |
805
- | `--wui-dialog-close-top` | `16px` | `closable` 按钮距卡片顶部的偏移(body 模式) |
806
- | `--wui-dialog-close-right` | `16px` | `closable` 按钮距卡片右沿的偏移(body 模式) |
829
+ | `--wui-dialog-close-top` | `20px` | `closable` 按钮距卡片顶部的偏移 |
830
+ | `--wui-dialog-close-right` | `20px` | `closable` 按钮距卡片右沿的偏移 |
807
831
  | `--wui-dialog-overlay-bg` | `var(--wui-color-backdrop)` | 遮罩背景色 |
808
832
  | `--wui-dialog-bg` | `var(--wui-color-surface-overlay)` | 玻璃卡片背景色,回退到 `rgb(248 248 248 / 0.92)` |
809
833
  | `--wui-dialog-padding` | `20px 24px 24px` | 对话框表面内边距 |
@@ -815,6 +839,8 @@ web-ui-radio-group {
815
839
  | `--wui-dialog-scale-enter` | `1.1` | 进场缩放起点:由 `1.1` 收缩到 `1`,退场反向 |
816
840
 
817
841
  > **高度语义**:本 token 限制的是内容区,不是整卡。标题行与 footer 仍由内容撑开,超出上限的部分在 `.desc` 内部滚动;整卡本身只由字面量 `100vh` / `100dvh` 兜底,接住「视口太矮、chrome 加内容区上限也放不下」的情况。要给**整卡**定上限的宿主仍需自行量出 chrome(卡片内边距 + 标题行 + 间距 + footer)并从 token 里减掉——Interweave 前端三个 dialog 分别减 142、142、106。不再需要这个数的是「让内容区滚起来」:改动前 `.desc` 既没有上限也没有 `overflow`,唯一能滚的是宿主自己撑出来的内层盒子,宿主想让它滚就必须自己算这个高度。
842
+ >
843
+ > **`body` 模式没有 `.desc`**,所以该 token 在这一档不生效。`body` 插槽被包进 `.wui-dialog-content`,滚动发生在这一层;卡片在内容装得下时仍贴合内容,装不下时由同一条字面量 `100vh` / `100dvh` 兜底。内置关闭按钮在内容滚动时保持 `--wui-dialog-close-top` / `--wui-dialog-close-right` 给出的位置不变。
818
844
 
819
845
  #### `<web-ui-drawer>`
820
846
 
@@ -1265,6 +1291,30 @@ Hover 模式使用 `pointerenter`/`pointerleave` 加延迟控制。Click 模式
1265
1291
  | `--wui-empty-description-font-size` | `14px` | 描述字号 |
1266
1292
  | `--wui-empty-action-margin-top` | `20px` | 操作区上方的间距 |
1267
1293
 
1294
+ #### `<web-ui-middle-ellipsis>`
1295
+
1296
+ 单行中间省略:保留首尾、丢掉中间——`very-long-file-…-abcdefghij.txt`——而 `text-overflow: ellipsis` 只能保住头部。
1297
+
1298
+ | 属性 | 类型 | 默认值 | 说明 |
1299
+ | ----------------- | -------- | ------ | -------------------------------------------------------------------------------------------------------- |
1300
+ | `text` | `string` | `''` | 完整文本。组件只读它,不回写。 |
1301
+ | `marker` | `string` | `'…'` | 插在两端之间的标记。空串即截断但不给任何可见信号。 |
1302
+ | `marker-position` | `number` | `50` | 标记所在的横向比例,0–100。`0` 贴行末(只剩头部),`100` 贴行首(只剩尾部)。越界夹紧,`NaN` 回退 `50`。 |
1303
+
1304
+ 使用条件与 `text-overflow: ellipsis` 相同:**单行、且有确定行内尺寸**。块级盒子、flex 行里的 `flex: 1; min-width: 0`、grid 轨道都可以。宿主按自身内容撑宽时不存在可用空间,组件按量到的宽度切一次就停止继续收缩,而不是一路塌到只剩标记。
1305
+
1306
+ 文件名场景靠 `marker-position` 调节,头部是路径噪声、尾部才是扩展名时取大值。下表是示意而非可复现的取值——确切切点取决于字体——取自 16px `system-ui`、240px 盒子:
1307
+
1308
+ | `marker-position` | 240px 盒子里 |
1309
+ | ----------------- | --------------------------------- |
1310
+ | `0` | `very-long-file-name-abcdefghi…` |
1311
+ | `50`(默认) | `very-long-file-…-abcdefghij.txt` |
1312
+ | `80` | `very-l…file-name-abcdefghij.txt` |
1313
+
1314
+ 切点一律落在**字素簇**边界上:代理对、组合符序列、ZWJ emoji 都不会被从中间劈开。截断期间完整原文写在文本元素的 `title` 上,放得下之后移除。宿主需为 `direction: ltr`;`direction: rtl` 下合串会被双向算法重排。
1315
+
1316
+ **已知限制——复制与辅助技术。** 元素里装的是屏幕上那串,因此选中复制拿到的是截断后的文本,读屏与页内查找命中的也是截断后的串。CSS 的 `text-overflow` 没有这两个问题(它从不改动文本节点),这是用 JS 算切点绕不开的代价;原文经 `title` 提供。保持全文留在 DOM 里的替代方案及其代价见 `docs/research/web-ui-middle-ellipsis-261007.md`。
1317
+
1268
1318
  #### `<web-ui-icon>`
1269
1319
 
1270
1320
  图标渲染组件。接受 Iconify 数据对象。
@@ -1338,13 +1388,24 @@ WebUiSpinner.hide() // 隐藏
1338
1388
  | `sidebar-collapsed` | `boolean` | `false` | 桌面端侧边栏受控折叠状态 |
1339
1389
  | `sidebar-open` | `boolean` | `false` | 移动端侧边栏 Drawer 受控打开状态 |
1340
1390
  | `header-glow` | `boolean` | `false` | 在 header 插槽内容背后显示装饰性晕染 |
1391
+ | `mobile` | `boolean` | `false` | 只读派生值:当前视口是否处于移动端断点及以下 |
1341
1392
  | `sidebar-width` | `string` | `'240px'` | 桌面端和移动端展开时的侧边栏宽度 |
1342
1393
  | `collapsed-width` | `string` | `'72px'` | 桌面端折叠时的侧边栏宽度 |
1343
1394
  | `sidebar-resizable` | `boolean` | `false` | 启用桌面端侧边栏右边缘拖拽调整宽度 |
1344
1395
  | `sidebar-min-width` | `string` | — | 拖拽调整的下限(px);默认回退到 `collapsed-width` |
1345
1396
  | `sidebar-max-width` | `string` | — | 拖拽调整的上限(px);钳制在视口一半以内,内置上限优先于配置值 |
1346
1397
 
1347
- **事件:** `sidebar-collapsed-change`(`CustomEvent<{ collapsed: boolean }>`)用于请求更新桌面端折叠状态;`sidebar-open-change`(`CustomEvent<{ open: boolean }>`)用于请求更新移动端 Drawer 打开状态;`sidebar-width-change`(`CustomEvent<{ width: string }>`)用于请求在拖拽调整结束后更新侧边栏宽度。Consumer 必须将请求值回写到对应的受控属性。
1398
+ **事件:** `sidebar-collapsed-change`(`CustomEvent<{ collapsed: boolean }>`)用于请求更新桌面端折叠状态;`sidebar-open-change`(`CustomEvent<{ open: boolean }>`)用于请求更新移动端 Drawer 打开状态;`sidebar-width-change`(`CustomEvent<{ width: string }>`)用于请求在拖拽调整结束后更新侧边栏宽度。Consumer 必须将请求值回写到对应的受控属性。`mobile-change`(`CustomEvent<{ mobile: boolean }>`)不是请求,它报告的是 layout 自己的派生视口状态。
1399
+
1400
+ **移动端视口状态:** `mobile` 是派生输出而不是第二个输入——由 layout 独占写入;property 只读(赋值不生效,严格模式下抛错),写 attribute 会在同一次 reaction 内被恢复、连值一起归位,两种写法都不派发事件。它与 layout 的渲染分支、以及 layout CSS 用来隐藏桌面 aside 的媒体查询(`@media (width <= 640px)`)是同一条条件,三者不可能互相错开;翻转不再经 resize 去抖,就发生在媒体查询变化的那一刻。`mobile-change` 每次跨断点只派发一次,元素在窄视口挂载时连接那一刻的那次求值也算一次。Consumer 用 `layout.mobile` 读当前值、用 `mobile-change` 收更新即可,CSS 侧可以写 `web-ui-layout[mobile]`。
1401
+
1402
+ **框架侧的消费方式:** layout 自身在 microtask 里重渲染,因此**在宏任务上响应的消费者会读到旧值,而新树已经在屏幕上了**:
1403
+
1404
+ - **Vue**:模板里绑 `@mobile-change`,用 `ref` 存值。Vue 在元素插入前就挂上模板监听(因此也能收到连接期那一次),且它的 flush 在 microtask,早于 layout 自己的更新。
1405
+ - **React**:用 ref 拿到元素,在 `useLayoutEffect` 里**先读 `layout.mobile` 再订阅**,并用 `flushSync` 回写。先读是为了拿到「挂在插入之后的监听收不到」的挂载初值;`flushSync` 是为了让运行时跨断点抢在 layout 重渲染之前——React 对 DOM 监听里的 setState 走宏任务 flush,否则会出现一帧「drawer 已按新状态渲染、consumer 的值还是旧的」(本仓 React demo 修复前实测 2/221 帧)。
1406
+ - **CSS**:直接按 `web-ui-layout[mobile]` 写样式,或把它包成一个变体。Tailwind v4 的 app 可以只写一次这条边界的**移动端那一侧**、而不是每个工具类各写一遍:`@custom-variant mobile (&:where(web-ui-layout[mobile], web-ui-layout[mobile] *));`。两条限制:属性选择器只够得到 layout 子树内的节点,被 overlay portal 到 `document.body` 的内容仍需媒体查询;**不要拿自定义变体做 min-width 那一侧**——Tailwind 把 `@custom-variant` 规则排在主题断点之后,同属性的 `desktop:` 变体会在它之上的每个宽度静默压掉 `md:`/`lg:`。
1407
+
1408
+ **折叠只作用于桌面端:** `sidebar-collapsed` 只收窄桌面端侧边栏,布局不对移动端 Drawer 施加任何折叠几何。两条分支承载的是同一份 `sidebar` 插槽内容,所以侧边栏内容密度跟着该属性变的 Consumer 必须按 `mobile` 收窄这个条件(例如 `collapsed && !layout.mobile`,或 CSS 的 `web-ui-layout[mobile]`),否则桌面的折叠偏好会一路渲染进 Drawer。见 issue #195。
1348
1409
 
1349
1410
  **侧边栏调整宽度:** 启用 `sidebar-resizable` 后,桌面端侧边栏右边缘会出现调整手柄(折叠状态下隐藏);悬停或拖拽时显示 3px 宽的强调色垂直线和 `col-resize` 光标。
1350
1411
 
@@ -1389,9 +1450,9 @@ WebUiSpinner.hide() // 隐藏
1389
1450
  }
1390
1451
  ```
1391
1452
 
1392
- 在 `640px` 及以下,侧边栏会切换为使用内置 glass body、可滚动 content 和 drag zone 的 `web-ui-drawer`。Layout 会将 `sidebar-width` 映射为 `--wui-drawer-width`,将 `--wui-layout-sidebar-radius` 映射为 `--wui-drawer-radius`。移动端 Toggle 以 glass 变体位于 header 行内。其左缩进默认 `8px`,可通过 `--wui-layout-mobile-toggle-inset` 与 Consumer 自身的 header 内边距对齐。
1453
+ 在 `640px` 及以下——与 `mobile` 报告的正是同一条条件——侧边栏会切换为使用内置 glass body、可滚动 content 和 drag zone 的 `web-ui-drawer`。Layout 会将 `sidebar-width` 映射为 `--wui-drawer-width`,将 `--wui-layout-sidebar-radius` 映射为 `--wui-drawer-radius`。移动端 Toggle 以 glass 变体位于 header 行内。其左缩进默认 `8px`,可通过 `--wui-layout-mobile-toggle-inset` 与 Consumer 自身的 header 内边距对齐。
1393
1454
 
1394
- `header-glow` 会在 header 插槽内容和移动端 Toggle 的背后添加 `pointer-events: none` 的装饰性晕染。它属于 Header 背景而非前景层,因此插槽内容始终位于其上方。晕染颜色取自 `--wui-color-page`,因此浅色/暗色模式自动跟随;要改颜色,在 `web-ui-layout`、theme 或其上层任意位置覆盖该属性即可。晕染由两层伪元素合成:`::before` 在 header 盒内用 `linear-gradient` 绘制底色并渐变到全透明,`::after` 再叠一层真实的 `backdrop-filter: blur(4px)`,并用 `mask` 让这层模糊的 alpha 沿高度衰减、到底缘附近归零。两层职责分开后,模糊强度与颜色浓度互不牵制。两层都用负 `margin` 而不是 `transform` 向外撑开。横向裁剪归属 `.layout-content`,glow 因此不会撑出横向滚动条。布局层级顺序为 Header(`10`)< Auxiliary(`20`)< Banner(`30`)< Tabbar(`40`)< Sidebar(`50`)。
1455
+ `header-glow` 会在 header 插槽内容和移动端 Toggle 的背后添加 `pointer-events: none` 的装饰性晕染。它属于 Header 背景而非前景层,因此插槽内容始终位于其上方。晕染颜色取自 `--wui-color-page`,因此浅色/暗色模式自动跟随;要改颜色,在 `web-ui-layout`、theme 或其上层任意位置覆盖该属性即可。晕染由两层伪元素合成:`::before` 在 header 盒内用 `linear-gradient` 绘制底色并渐变到全透明,`::after` 再叠一层真实的 `backdrop-filter: blur(4px)`,并用 `mask` 让这层模糊的 alpha 沿高度衰减、到底缘附近归零。两层职责分开后,模糊强度与颜色浓度互不牵制。两层都用负 `margin` 而不是 `transform` 撑开;模糊层止于 header 上缘,只向下与向两侧延伸。横向裁剪归属 `.layout-content`,glow 因此不会撑出横向滚动条。布局层级顺序为 Header(`10`)< Auxiliary(`20`)< Banner(`30`)< Tabbar(`40`)< Sidebar(`50`)。
1395
1456
 
1396
1457
  侧边栏卡片表面使用 `--wui-color-surface-sidebar`:浅色保持半透明中性分层,深色比 `--wui-color-page` 浅一档、与 `--wui-color-surface` 同级。它独立成 token,是因为 dialog、drawer 和 toast 共用 `--wui-color-surface-overlay`,可以采用不同表面。
1397
1458
 
@@ -1498,7 +1559,7 @@ SVG 线条绘制动画,基于 `stroke-dashoffset`。直接在原元素上动
1498
1559
  | ---------------------------- | ------ | -------------------------------------------------- |
1499
1560
  | `--wui-font-size-caption` | `12px` | 密集 chrome 标签(徽标、toast 时间戳、菜单分组头) |
1500
1561
  | `--wui-font-size-readout` | `13px` | 数字读数(图片预览的计数与缩放比) |
1501
- | `--wui-font-size` | `14px` | 正文与控件的基础字号 |
1562
+ | `--wui-font-size` | `14px` | 正文与控件的基础字号(含控件的占位文本) |
1502
1563
  | `--wui-font-size-title` | `18px` | 有界卡片标题(dialog、drawer) |
1503
1564
  | `--wui-font-weight-medium` | `500` | 强调行内文字的中等字重 |
1504
1565
  | `--wui-font-weight-semibold` | `600` | 标题的半粗字重 |
package/README.md CHANGED
@@ -31,6 +31,31 @@ A subpath import registers only the component it names, so import every tag the
31
31
  <web-ui-button variant="primary">Click me</web-ui-button> <web-ui-icon .icon="${lucidePlus}"></web-ui-icon>
32
32
  ```
33
33
 
34
+ ## Scrollbars
35
+
36
+ `@greypan/web-ui/scrollbars` carries the repo-wide scrollbar behavior for [OverlayScrollbars](https://github.com/KingSora/OverlayScrollbars)
37
+ hosts: a macOS-aligned theme, auto-hide that reveals the bar while scrolling, and clicking the track scrolling to that spot.
38
+ Import the stylesheet once per document and pass the options to whichever adapter you initialize with.
39
+
40
+ ```js
41
+ import '@greypan/web-ui/scrollbars.css'
42
+ import { webUiScrollbarsOptions } from '@greypan/web-ui/scrollbars'
43
+ import { OverlayScrollbarsComponent } from 'overlayscrollbars-react' // or overlayscrollbars-vue
44
+
45
+ ;<OverlayScrollbarsComponent options={webUiScrollbarsOptions}>{children}</OverlayScrollbarsComponent>
46
+ ```
47
+
48
+ - `overlayscrollbars` is an optional peer dependency and is only needed by this subpath. The stylesheet already
49
+ contains the library's structural styles, so it replaces `import 'overlayscrollbars/overlayscrollbars.css'`.
50
+ - Keep the container's own `overflow` declaration (`overflow-y-auto`, …). OverlayScrollbars overrides it once it
51
+ takes over, but it is what keeps the container scrollable until then.
52
+ - The target must be a plain scroll container. OverlayScrollbars turns the host into a flex row and moves its
53
+ children into a generated viewport, so a host whose own layout carries meaning — a grid, a centred box, a `ul`/`ol`
54
+ — loses it.
55
+ - OverlayScrollbars only changes the element it is initialized on; children keep their native scrollbars. To cover
56
+ the document scroller, initialize `document.body` — `window.scrollY` / `window.scrollTo` keep their native
57
+ semantics there.
58
+
34
59
  ## Framework Setup
35
60
 
36
61
  ### Cross-framework API conventions
@@ -306,6 +331,7 @@ All form controls participate in native `FormData`, constraint validation, `form
306
331
  | **Data Display** | [`<web-ui-avatar>`](#web-ui-avatar) |
307
332
  | | [`<web-ui-badge>`](#web-ui-badge) |
308
333
  | | [`<web-ui-empty>`](#web-ui-empty) |
334
+ | | [`<web-ui-middle-ellipsis>`](#web-ui-middle-ellipsis) |
309
335
  | | [`<web-ui-icon>`](#web-ui-icon) |
310
336
  | | [`<web-ui-spinner>`](#web-ui-spinner) |
311
337
  | **Layout & Utility** | [`<web-ui-layout>`](#web-ui-layout) |
@@ -829,7 +855,7 @@ Modal dialog using native `<dialog>` with `showModal()`.
829
855
 
830
856
  Uses native `<dialog>` with `@cancel` prevention. Escape calls `close()` unless `no-escape-close` is present. Click on backdrop closes dialog unless `no-backdrop-close` is present. With `controlled`, both only emit the close request instead.
831
857
 
832
- With `closable`, a built-in close button is rendered in both content modes, and it follows the same close path as Escape and backdrop clicks (so `controlled` applies to it identically). The two modes place it differently: without a `body` slot the button sits in a `.title-row` flex line next to the title; with a `body` slot the title row does not exist, so the button is a direct child of the glass card and is absolutely positioned at its top-right corner (offset by `--wui-dialog-close-top` / `--wui-dialog-close-right`). It uses the same `ooui:close` icon as the drawer close button. Omitting `closable` renders nothing at all — no button and no extra wrapper.
858
+ With `closable`, a built-in close button is rendered in both content modes, and it follows the same close path as Escape and backdrop clicks (so `controlled` applies to it identically). Both modes place it the same way: it is absolutely positioned at the card's top-right corner, offset from the card edges by `--wui-dialog-close-top` / `--wui-dialog-close-right` (both default to `20px`; the drawer close button remains at `16px`). Only a `body` slot makes the title row disappear; the title-mode title reserves room for the button so a long title never runs under it. It uses the same `ooui:close` icon as the drawer close button. Omitting `closable` renders nothing at all — no button and no extra wrapper.
833
859
 
834
860
  > **Escape ownership:** Escape is arbitrated by a single shared owner, so one keypress closes only the **innermost** open overlay (popover, select, autocomplete, dropdown, context-menu, drawer and dialog all take part). With a select open inside a drawer, the first Escape closes the select and only the second closes the drawer. Sibling overlays that do not nest fall back to open order, closing the most recently opened one. A panel that is still playing its exit transition stays on screen and still catches the Escape, but any overlay that is still open outranks it, so one keypress still closes exactly one layer. `image-preview` takes part as well: its native `<dialog>` is registered with the same arbiter, so Escape is decided by layer order; the component's `cancel` handler only vetoes the native instant close, keeping the top layer until the exit transition finishes.
835
861
 
@@ -839,8 +865,8 @@ With `closable`, a built-in close button is rendered in both content modes, and
839
865
  | --------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
840
866
  | `--wui-dialog-width` | `360px` | Dialog width |
841
867
  | `--wui-dialog-max-height` | `90vh` / `min(90vh, 90dvh)` (dvh engines) | Max height of the content area (`.desc`), not of the card. Two fallback tiers: `90vh` on the base rule, `min(90vh, 90dvh)` inside `@supports (height: 100dvh)` — an engine without `dvh` support only ever gets `90vh`. See the note below for the chrome conversion |
842
- | `--wui-dialog-close-top` | `16px` | `closable` button offset from the card top (body mode) |
843
- | `--wui-dialog-close-right` | `16px` | `closable` button offset from the card right edge (body mode) |
868
+ | `--wui-dialog-close-top` | `20px` | `closable` button offset from the card top |
869
+ | `--wui-dialog-close-right` | `20px` | `closable` button offset from the card right edge |
844
870
  | `--wui-dialog-overlay-bg` | `var(--wui-color-backdrop)` | Backdrop background |
845
871
  | `--wui-dialog-bg` | `var(--wui-color-surface-overlay)` | Glass card background, falls back to `rgb(248 248 248 / 0.92)` |
846
872
  | `--wui-dialog-padding` | `20px 24px 24px` | Dialog surface padding |
@@ -852,6 +878,8 @@ With `closable`, a built-in close button is rendered in both content modes, and
852
878
  | `--wui-dialog-scale-enter` | `1.1` | Enter scale start: the panel shrinks in from `1.1` to `1`, and exit reverses it |
853
879
 
854
880
  > **Height semantics:** the token caps the content area, not the card. The title row and the footer stay sized by their content, and anything past the cap scrolls inside `.desc`; the card itself is bounded only by a literal `100vh` / `100dvh` backstop, which catches the case where the viewport is too short to fit chrome plus the cap. A host that wants to bound the **whole card** must still measure its own chrome (card padding + title row + gaps + footer) and subtract it from the token — the three dialogs in the Interweave frontend subtract 142, 142 and 106 respectively. What no longer needs that number is making the content area scroll: before, `.desc` had neither a cap nor `overflow`, so the only element that could scroll was a host-sized inner box, and a host that wanted overflow to scroll had to compute that height itself.
881
+ >
882
+ > **`body` mode has no `.desc`**, so the token does not apply there. The `body` slot is wrapped in `.wui-dialog-content`, which is the scroll area, and the card stays content-sized until the same literal `100vh` / `100dvh` backstop kicks in; the built-in close button keeps its `--wui-dialog-close-top` / `--wui-dialog-close-right` offset while that content scrolls.
855
883
 
856
884
  #### `<web-ui-drawer>`
857
885
 
@@ -1302,6 +1330,30 @@ Each one overrides the value `size` derives, so all eight stay authoritative —
1302
1330
  | `--wui-empty-description-font-size` | `14px` | Description font size |
1303
1331
  | `--wui-empty-action-margin-top` | `20px` | Space above the action slot |
1304
1332
 
1333
+ #### `<web-ui-middle-ellipsis>`
1334
+
1335
+ Single-line middle truncation: keeps both ends and drops the middle — `very-long-file-…-abcdefghij.txt` — where `text-overflow: ellipsis` can only keep the head.
1336
+
1337
+ | Attribute | Type | Default | Description |
1338
+ | ----------------- | -------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
1339
+ | `text` | `string` | `''` | Full text. Read-only from the component's side. |
1340
+ | `marker` | `string` | `'…'` | Marker inserted between the two ends. An empty string truncates without any visible signal. |
1341
+ | `marker-position` | `number` | `50` | Where the marker sits, 0–100. `0` puts it at the line-end (head only), `100` at the line-start (tail only). Out-of-range clamps, `NaN` falls back to `50`. |
1342
+
1343
+ It needs what `text-overflow: ellipsis` needs: **one line with a definite inline size**. A block-level box, `flex: 1; min-width: 0` in a flex row, or a grid track all work. A host that sizes itself to its own content has no available space to truncate against, so the component settles on the width it measured and stops shrinking instead of collapsing to the marker.
1344
+
1345
+ `marker-position` is the knob for file names, where the head is path noise and the tail carries the extension. The rows below are illustrative, not reproducible — exact cut points depend on the font; they were taken at 16px `system-ui` in a 240px box:
1346
+
1347
+ | `marker-position` | In a 240px box |
1348
+ | ----------------- | --------------------------------- |
1349
+ | `0` | `very-long-file-name-abcdefghi…` |
1350
+ | `50` _(default)_ | `very-long-file-…-abcdefghij.txt` |
1351
+ | `80` | `very-l…file-name-abcdefghij.txt` |
1352
+
1353
+ Cut points always fall on **grapheme cluster** boundaries: surrogate pairs, combining marks and ZWJ emoji sequences are never split. While truncated, the full string is exposed as the `title` of the text element and removed again once everything fits. The host must be `direction: ltr`; under `direction: rtl` the composed string is reordered by the bidi algorithm.
1354
+
1355
+ **Known limitation — copy and assistive tech.** The element holds the string you can see, so selecting and copying yields the truncated text rather than the original, and screen readers and in-page find match the truncated string too. CSS `text-overflow` has neither problem because it never touches the text node; this is the structural price of computing the split in JavaScript. `title` carries the original for hover. See `docs/research/web-ui-middle-ellipsis-261007.md` for the alternatives that keep the full string in the DOM and what they cost.
1356
+
1305
1357
  #### `<web-ui-icon>`
1306
1358
 
1307
1359
  Icon renderer using Iconify data objects.
@@ -1375,13 +1427,24 @@ Responsive page layout with an optional full-width banner, a collapsible desktop
1375
1427
  | `sidebar-collapsed` | `boolean` | `false` | Controlled desktop sidebar collapsed state |
1376
1428
  | `sidebar-open` | `boolean` | `false` | Controlled mobile sidebar drawer open state |
1377
1429
  | `header-glow` | `boolean` | `false` | Decorative Header background glow behind slot content |
1430
+ | `mobile` | `boolean` | `false` | Read-only, derived: whether the viewport is at or below the mobile breakpoint |
1378
1431
  | `sidebar-width` | `string` | `'240px'` | Expanded desktop and mobile sidebar width |
1379
1432
  | `collapsed-width` | `string` | `'72px'` | Collapsed desktop sidebar width |
1380
1433
  | `sidebar-resizable` | `boolean` | `false` | Enable drag-to-resize on the desktop sidebar edge |
1381
1434
  | `sidebar-min-width` | `string` | — | Resize lower bound (px); falls back to `collapsed-width` |
1382
1435
  | `sidebar-max-width` | `string` | — | Resize upper bound (px); clamped to half the viewport, which always wins over the configured value |
1383
1436
 
1384
- **Events:** `sidebar-collapsed-change` (`CustomEvent<{ collapsed: boolean }>`) requests a desktop collapse-state update. `sidebar-open-change` (`CustomEvent<{ open: boolean }>`) requests a mobile drawer open-state update. `sidebar-width-change` (`CustomEvent<{ width: string }>`) requests a sidebar width update after a resize drag ends. Consumers must write the requested value back to the corresponding controlled property.
1437
+ **Events:** `sidebar-collapsed-change` (`CustomEvent<{ collapsed: boolean }>`) requests a desktop collapse-state update. `sidebar-open-change` (`CustomEvent<{ open: boolean }>`) requests a mobile drawer open-state update. `sidebar-width-change` (`CustomEvent<{ width: string }>`) requests a sidebar width update after a resize drag ends. Consumers must write the requested value back to the corresponding controlled property. `mobile-change` (`CustomEvent<{ mobile: boolean }>`) is not a request: it reports the layout's own derived viewport state.
1438
+
1439
+ **Mobile viewport state:** `mobile` is derived, not a second input — the layout writes it, the property is read-only (assignments take no effect and throw for strict-mode callers), and setting the attribute is reverted in the same reaction, value included, with no event for either. It flips with the media query the layout renders by (`@media (width <= 640px)`) and the same condition the layout CSS uses to hide the desktop sidebar, so the tree, the layout's own CSS and what consumers read can never disagree; there is no resize debounce, the flip lands on the media-query change itself. `mobile-change` fires once per crossing, including the connect-time evaluation when the element mounts on a narrow viewport. Read `layout.mobile` for the current value and subscribe to `mobile-change` for updates; CSS can target `web-ui-layout[mobile]`.
1440
+
1441
+ **Consuming it in a framework.** The layout re-renders in a microtask, so a consumer that reacts on a scheduler macrotask still reads the old value while the new tree is already on screen:
1442
+
1443
+ - **Vue** — bind `@mobile-change` in the template and hold the value in a `ref`. Vue attaches template listeners before the element is inserted (so they receive the connect-time event too) and flushes in a microtask, which lands ahead of the layout's own update.
1444
+ - **React** — take the element through a ref, then read `layout.mobile` and subscribe inside `useLayoutEffect`, and write the value back with `flushSync`. Reading gives you the mount-time value that a later-attached listener misses, and `flushSync` keeps a runtime crossing ahead of the layout's re-render: React flushes updates from DOM listeners on a macrotask, which otherwise leaves the drawer rendered with a stale consumer value for a frame (measured at 2 of 221 frames in this repository's React demo before the fix).
1445
+ - **CSS** — style off `web-ui-layout[mobile]`, either directly or through a variant. A Tailwind v4 app can write the mobile side of the boundary once instead of per utility: `@custom-variant mobile (&:where(web-ui-layout[mobile], web-ui-layout[mobile] *));`. Two limits: the attribute only reaches nodes inside the layout subtree, so content an overlay portals to `document.body` still needs a media query; and do not use a custom variant for the min-width side — Tailwind emits `@custom-variant` rules after the theme breakpoints, so a `desktop:` variant on the same property silently overrides `md:`/`lg:` at every width above them.
1446
+
1447
+ **Desktop-only collapse:** `sidebar-collapsed` only narrows the desktop sidebar; the layout applies no collapsed geometry to the mobile drawer. Both branches host the same `sidebar` slot content, so a consumer whose sidebar content changes density with that property must gate it on `mobile` (for example `collapsed && !layout.mobile`, or CSS under `web-ui-layout[mobile]`) — otherwise a collapsed desktop preference renders inside the drawer. See issue #195.
1385
1448
 
1386
1449
  **Sidebar resize:** With `sidebar-resizable`, a resize handle appears on the desktop sidebar's right edge (hidden while collapsed); hovering or dragging shows a 3px accent vertical line with a `col-resize` cursor.
1387
1450
 
@@ -1426,9 +1489,9 @@ Responsive page layout with an optional full-width banner, a collapsible desktop
1426
1489
  }
1427
1490
  ```
1428
1491
 
1429
- At `640px` and below, the sidebar becomes a `web-ui-drawer` with its built-in glass body, scrollable content, and drag zone. The layout maps `sidebar-width` to `--wui-drawer-width` and `--wui-layout-sidebar-radius` to `--wui-drawer-radius`. The mobile toggle appears in the header row as a glass button. Its left inset defaults to `8px`; align it with your own header padding via `--wui-layout-mobile-toggle-inset`.
1492
+ At `640px` and below — the same condition `mobile` reports — the sidebar becomes a `web-ui-drawer` with its built-in glass body, scrollable content, and drag zone. The layout maps `sidebar-width` to `--wui-drawer-width` and `--wui-layout-sidebar-radius` to `--wui-drawer-radius`. The mobile toggle appears in the header row as a glass button. Its left inset defaults to `8px`; align it with your own header padding via `--wui-layout-mobile-toggle-inset`.
1430
1493
 
1431
- `header-glow` adds a pointer-transparent decorative glow behind header-slot content and the mobile toggle. It is a Header background rather than a foreground layer, so slotted content remains above it. The glow takes its colour from `--wui-color-page`, so it follows light and dark appearance on its own; override that property on `web-ui-layout`, on the theme, or anywhere above it to change it. Two pseudo-elements build it. `::before` paints the colour as a `linear-gradient` inside the header box, fading to fully transparent, and `::after` adds a real `backdrop-filter: blur(4px)` layer whose `mask` fades that blur toward zero alpha at the bottom edge. Keeping the two apart means blur strength and colour strength do not pull on each other. Both expand with a negative `margin` rather than a `transform`. Horizontal clipping is owned by `.layout-content`, so the glow never produces a horizontal scrollbar. Layout layers are ordered as Header (`10`) < Auxiliary (`20`) < Banner (`30`) < Tabbar (`40`) < Sidebar (`50`).
1494
+ `header-glow` adds a pointer-transparent decorative glow behind header-slot content and the mobile toggle. It is a Header background rather than a foreground layer, so slotted content remains above it. The glow takes its colour from `--wui-color-page`, so it follows light and dark appearance on its own; override that property on `web-ui-layout`, on the theme, or anywhere above it to change it. Two pseudo-elements build it. `::before` paints the colour as a `linear-gradient` inside the header box, fading to fully transparent, and `::after` adds a real `backdrop-filter: blur(4px)` layer whose `mask` fades that blur toward zero alpha at the bottom edge. Keeping the two apart means blur strength and colour strength do not pull on each other. Both expand with a negative `margin` rather than a `transform`, and the blur layer stops at the header's top edge — it only extends downward and to the sides. Horizontal clipping is owned by `.layout-content`, so the glow never produces a horizontal scrollbar. Layout layers are ordered as Header (`10`) < Auxiliary (`20`) < Banner (`30`) < Tabbar (`40`) < Sidebar (`50`).
1432
1495
 
1433
1496
  The sidebar card surface is `--wui-color-surface-sidebar`. In light mode it keeps its translucent neutral layer; in dark mode it sits one notch above `--wui-color-page`, level with `--wui-color-surface`. It is a separate token because dialog, drawer and toast share `--wui-color-surface-overlay` and may use a different surface.
1434
1497
 
@@ -1531,18 +1594,18 @@ The host uses `display: contents` and does not paint any background: the library
1531
1594
 
1532
1595
  **Typography tokens:** font sizes are named by the role the text plays, not by scale position — `caption` for dense chrome labels, `readout` for numeric readouts, the unsuffixed base for body copy, and `title` for bounded card headings. Weights are named by weight, and line heights by how much vertical air the text needs. These are appearance-independent: they stay the same under both light and dark.
1533
1596
 
1534
- | Property | Default | Description |
1535
- | ---------------------------- | ------- | --------------------------------------------------------------- |
1536
- | `--wui-font-size-caption` | `12px` | Dense chrome labels (badge, toast timestamp, menu section head) |
1537
- | `--wui-font-size-readout` | `13px` | Numeric readouts (image preview count and zoom ratio) |
1538
- | `--wui-font-size` | `14px` | Base font size for body copy and controls |
1539
- | `--wui-font-size-title` | `18px` | Bounded card headings (dialog, drawer) |
1540
- | `--wui-font-weight-medium` | `500` | Medium weight for emphasized inline text |
1541
- | `--wui-font-weight-semibold` | `600` | Semibold weight for headings |
1542
- | `--wui-line-height-tight` | `1.2` | Tight leading for large headings |
1543
- | `--wui-line-height-snug` | `1.4` | Snug leading for mid-size headings |
1544
- | `--wui-line-height-normal` | `1.5` | Normal leading for body copy |
1545
- | `--wui-line-height-relaxed` | `1.6` | Relaxed leading for long-form reading |
1597
+ | Property | Default | Description |
1598
+ | ---------------------------- | ------- | --------------------------------------------------------------------------- |
1599
+ | `--wui-font-size-caption` | `12px` | Dense chrome labels (badge, toast timestamp, menu section head) |
1600
+ | `--wui-font-size-readout` | `13px` | Numeric readouts (image preview count and zoom ratio) |
1601
+ | `--wui-font-size` | `14px` | Base font size for body copy and controls, including their placeholder text |
1602
+ | `--wui-font-size-title` | `18px` | Bounded card headings (dialog, drawer) |
1603
+ | `--wui-font-weight-medium` | `500` | Medium weight for emphasized inline text |
1604
+ | `--wui-font-weight-semibold` | `600` | Semibold weight for headings |
1605
+ | `--wui-line-height-tight` | `1.2` | Tight leading for large headings |
1606
+ | `--wui-line-height-snug` | `1.4` | Snug leading for mid-size headings |
1607
+ | `--wui-line-height-normal` | `1.5` | Normal leading for body copy |
1608
+ | `--wui-line-height-relaxed` | `1.6` | Relaxed leading for long-form reading |
1546
1609
 
1547
1610
  `line-height: 1` and `line-height: 0` inside components are intentionally not tokenized: the former vertically centers a single-line control label, the latter collapses a wrapper box onto its content. Neither is typographic leading, and tokenizing them would invite callers to retune them as if they were.
1548
1611
 
@@ -0,0 +1,6 @@
1
+ //#region src/assets/modal-surface.css?inline
2
+ var e = "dialog:focus,dialog:focus-visible,dialog :where(.desc,.wui-dialog-content,.wui-drawer-content):focus-visible{outline:none}";
3
+ //#endregion
4
+ export { e as default };
5
+
6
+ //# sourceMappingURL=modal-surface.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"modal-surface.js","names":[],"sources":["../../src/assets/modal-surface.css?inline"],"sourcesContent":["/*\n * 模态表面(dialog / drawer / image-preview 的 <dialog> 面板)的焦点环策略,集中一处。\n *\n * 1. `<dialog>` 壳不是可聚焦控件:面板里没有更优候选时,`showModal()` 会把焦点交给\n * dialog 本身,UA 会为它画一枚默认 ring。它是模态表面,内部控件各自保留 focus ring。\n *\n * 2. 面板的滚动区(`.desc` / `.wui-dialog-content` / `.wui-drawer-content`)同样不是\n * 可聚焦控件。Chromium 会把「可滚动的滚动容器」变成键盘可聚焦项\n * (keyboard-focusable scrollers),于是 dialog 的 focusing steps 会选中面板滚动区,\n * UA 为它画一枚默认蓝环(`outline: auto` + `-webkit-focus-ring-color`)。这一枚环\n * 与库的统一 ring(`outline: var(--wui-focus-ring-width) solid var(--wui-color-focus-ring)`)\n * 形态/颜色都不一致,是「dialog / drawer 打开后卡片上多出一圈蓝框」的来源:滚动区若是\n * 铺满卡片的(body 模式)且卡片自带 `overflow: hidden` + 圆角,这枚直角的 UA 环会被\n * 卡片裁成圆角矩形,看起来正贴着卡片外缘。\n *\n * 逐组件各抄一份正是「新增一个基于 `<dialog>` 的组件就漏掉抑制规则」的成因;新组件把本\n * 文件加进 `static styles` 即可。文档级 scrim(context-menu)不在这条链上——它自己注入\n * 样式表,按同样策略在 `scrim.css` 里就地收口。\n */\ndialog:focus,\ndialog:focus-visible,\ndialog :where(.desc, .wui-dialog-content, .wui-drawer-content):focus-visible {\n outline: none;\n}\n"],"mappings":""}
@@ -176,6 +176,21 @@ export declare class WebUiContextMenu extends LitElement {
176
176
  * 会跨会话残留,而这些都不是本组件能重置的。
177
177
  */
178
178
  private _openScrim;
179
+ /**
180
+ * scrim 的挂载点:**与面板在没有 scrim 时会落到的容器同源** —— 最近主题的 theme-owned
181
+ * overlay root。scrim 就是那个容器的替身(面板必须挂进 scrim,见 `updated()` 的顺序注释),
182
+ * 所以它得站在容器该站的位置上。
183
+ *
184
+ * 为什么不能在整棵 DOM 里随便挑位置:`--wui-color-*` 由 `<web-ui-theme>` 写在自己的 `:host`
185
+ * 上,只沿 DOM 树向下继承。scrim 若挂到 `document.body`,而 `<web-ui-theme>` 是 body 的**子节点**,
186
+ * 这条继承链就断了 —— 面板与 scrim 双双落回 `var(--wui-color-*, <浅色字面量>)` 的 fallback,
187
+ * 深色外观下菜单是一块浅色玻璃(app 侧的 `dark:` 变体也够不到它)。挂进 theme overlay root
188
+ * 之后,面板与 scrim 跟 dropdown / select 一样吃同一份 token。
189
+ *
190
+ * 无主题时保持历史行为挂在 `document.body`:那条路径本来就没有 token 作用域可继承,改用
191
+ * fallback overlay root 只会改变既有测试与消费者的观察面,不换来任何东西。
192
+ */
193
+ private _scrimParent;
179
194
  /**
180
195
  * 关掉并摘除 scrim。幂等:关闭分支与 `disconnectedCallback` 都会调。
181
196
  *