@deepseek-ai/dsh-client-ui-dockkit 0.1.7-rc.1 → 0.2.0-rc.1

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/README.i18n.yaml CHANGED
@@ -1,6 +1,40 @@
1
- # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
2
- # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
- # after editing either side, bring the other along and re-record with:
1
+ # Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
2
+ # section, a hash of its English and Chinese blocks outside code blocks and generated regions.
3
+ # After editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/client/ui-dockkit/README.md
5
- README.md: 613cdbcb105108eb848160f1cb6bc8ee28d39fac
6
- README.zh.md: 77b256f01dd46aea5144e9f8da644fd39330f187
5
+ /:
6
+ en: 93fc2e9e86fc50f8
7
+ zh: 61c7c38a66935ad6
8
+ /deepseek-ai-dsh-client-ui-dockkit:
9
+ en: 9d34bd85fc9e76b4
10
+ zh: 615d1d2eab451c87
11
+ /deepseek-ai-dsh-client-ui-dockkit/summary:
12
+ en: 3ee4584a73215ef4
13
+ zh: 051170a6d0993fa5
14
+ /deepseek-ai-dsh-client-ui-dockkit/table-of-contents:
15
+ en: 42038fdc402b9c2a
16
+ zh: 99890f8f908624ce
17
+ /deepseek-ai-dsh-client-ui-dockkit/the-two-layers:
18
+ en: ea872ff2c2089037
19
+ zh: b3ec3a218eacd5c8
20
+ /deepseek-ai-dsh-client-ui-dockkit/embedding-it:
21
+ en: 47b9852393643b6a
22
+ zh: ce5f2ab60eec9564
23
+ /deepseek-ai-dsh-client-ui-dockkit/interaction-rules-worth-keeping:
24
+ en: 7e5c1e9a9f9f244f
25
+ zh: 7e3a8fcef86bc7dd
26
+ /deepseek-ai-dsh-client-ui-dockkit/build-shape:
27
+ en: 21216ba5798d7f5f
28
+ zh: ac505fdcd5c114d4
29
+ /deepseek-ai-dsh-client-ui-dockkit/model-experience:
30
+ en: cdebb91645623401
31
+ zh: fe262db316714fa8
32
+ /deepseek-ai-dsh-client-ui-dockkit/model-experience/kv-cache-effect:
33
+ en: ca75c51c89c2c9b0
34
+ zh: f57cc48a1b5cbfa9
35
+ /deepseek-ai-dsh-client-ui-dockkit/known-limitations-and-deferred-work:
36
+ en: 4323810ca5a87e6c
37
+ zh: 3a24af2ea4467ab7
38
+ /deepseek-ai-dsh-client-ui-dockkit/known-limitations-and-deferred-work/dev-note:
39
+ en: 9eda9e1ea3785ca7
40
+ zh: b734eb644b27e474
package/README.md CHANGED
@@ -39,6 +39,8 @@ A docking layout kit: a split tree of tabbed panes with invertible operations, a
39
39
 
40
40
  **The components** render a layout snapshot and report settled intents — one per gesture, never a drag frame. A drag previews in local state while the gesture's own facts stay in its closure; on release the net result leaves through one `DockIntents` call — a strip release reports the caret slot as drawn, the dragged chip counted, and `planPlaceTab` turns that into the reorder or the move. That is what lets an embedder record exactly one history entry per gesture. The strip follows the WAI-ARIA tabs pattern with manual activation: the selected chip is in the tab order; Left and Right (wrapping), Home, and End move focus between chips without selecting; Enter or Space selects the focused chip through the same intent as a click. A chip is a capsule carrying one control, its close; the context menu (a secondary press on the chip) carries the same close plus the embedder's items — a menu that would hold no item at all never shows — and renders in a portal positioned against the chip because the chip box clips its overflow on purpose (see below). After the chips sits the add control, which asks the embedder (`DockIntents.addTab`) to seat its seeded tab; the embedder's `canAddTab(paneId)` decides per pane whether the control is drawn at all. Copying a tab has no kit control — it is the embedder's API — and floating is the drag released clear of the surface.
41
41
 
42
+ Tab context menus show the close action name without a shortcut hint: they act on the clicked tab, while shortcuts can target another focused pane. An unmodified Escape dismisses the foreground tab menu without closing the tab and returns focus from a menu item to its tab without drawing a focus outline. Tab and arrow navigation retain the tab’s visible focus indicator. Composition and held-key repeats do not dismiss it.
43
+
42
44
  <a id="embedding-it"></a>
43
45
  ## Embedding it
44
46
 
@@ -58,11 +60,13 @@ A tab's `kind` is an opaque string. Seeded tabs are factories (`DockControllerOp
58
60
 
59
61
  `DockLayout`, used by the Sidebar, keeps each tab in a stable DOM cell across selection, pane moves and floating. It accepts one docked pane or two horizontal panes; CSS Grid resolves their widths. `keepMounted(tab)` retains visited bodies and `active` controls Session visibility without reparenting content.
60
62
 
61
- `DockSurface` is the docked area for the separate recursive renderer. Chrome around it — a rail, a collapsed presentation, any history controls — belongs to the embedder, which reads `state.expanded` and decides; the kit ships no undo/redo control of its own. Surface-wide controls the embedder does want on the surface go through the `chrome` prop, which the kit places at the far end of the top-right pane's tab strip (the last child of every row split, the first of every column split), so a surface needs no header row of its own. `FloatLayer` owns its own gestures and positions panels in viewport coordinates, so it may be mounted anywhere, including a portal.
63
+ `DockSurface` is the docked area for the separate recursive renderer. Chrome around it — a rail, a collapsed presentation, any history controls — belongs to the embedder, which reads `state.expanded` and decides; the kit ships no undo/redo control of its own. Surface-wide controls the embedder does want on the surface go through the `chrome` prop, which the kit places at the far end of the top-right pane's tab strip (the last child of every row split, the first of every column split), so a surface needs no header row of its own. `FloatLayer` owns its own gestures and positions panels in viewport coordinates, so it may be mounted anywhere, including a portal. Both float renderers keep the header at least 20px below the Windows caption (20px below the viewport top in native fullscreen), including newly opened and restored panels. Dragging and resizing start at the displayed position.
62
64
 
63
65
  <a id="interaction-rules-worth-keeping"></a>
64
66
  ## Interaction rules worth keeping
65
67
 
68
+ The split control accepts localized tooltip text, separate effective keys through `splitPaneKeys`, and an ARIA combination from its embedder. Close controls receive their keys through `closeTabKeys`. A disabled control has a keyboard-focusable wrapper that explains the pane-budget or width restriction. Docked and floating pane containers can receive programmatic focus without entering the normal tab sequence or drawing a focus outline; their controls retain their own keyboard focus indicators. Tab navigation and selection use unmodified keys and leave composition input to its owner.
69
+
66
70
  These are not stylistic; each one fixes a defect found in a real browser.
67
71
 
68
72
  - **Capture the pointer** when a gesture starts. Without it any scroll container the pointer crosses can claim the gesture, which the browser reports as a cancelled pointer and an abandoned drag. Capture is hardening — the window listeners carry the gesture either way, so an environment without the API still works.
@@ -99,11 +103,15 @@ None; this package neither assembles nor sends a provider request.
99
103
  <a id="dev-note"></a>
100
104
  ### Dev Note
101
105
 
106
+ Menus use the shared `MenuSurface` material, including the macOS backing for background blur; custom content follows the [menu rules](../../../docs/web-styling.md#component-rules).
107
+
102
108
  <details>
103
109
  <summary>Working context for maintainers — click to expand</summary>
104
110
 
105
111
  None.
106
112
 
113
+ The embedder supplies effective close keycaps and ARIA combinations for tab and floating close controls. Close controls expose the same tooltip on hover and keyboard focus. Tab close tooltips stay hidden while their pane's context menu is open.
114
+
107
115
  </details>
108
116
 
109
117
  **Runtime invariant:** No companion is published. The engine is pure functions over plain data and the components report intents only; the operation sequence's invertibility and the settle rule are asserted directly by this package's engine specs, and no cordis service is provided or observed.
package/README.zh.md CHANGED
@@ -39,6 +39,8 @@ kind: "package-reference"
39
39
 
40
40
  **组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `DockIntents` 调用离开——在标签条上松手上报的是按绘制顺序数出的插入槽位(被拖的 chip 也计入),由 `planPlaceTab` 换算成重排或移动。正是这一点让嵌入方能为每次手势记录恰好一条历史。标签条遵循 WAI-ARIA tabs 模式的手动激活:选中的 chip 在 Tab 键序里;左右方向键(循环)、Home、End 只在 chip 之间移动焦点而不选中;Enter 或空格选中当前聚焦的 chip,走与点击相同的意图。chip 是一个胶囊,携带唯一的控件——它的关闭按钮;上下文菜单(在 chip 上的次键按下)携带同样的关闭项加上嵌入方的条目——一个连一项都没有的菜单绝不展示——并渲染在按 chip 定位的 portal 里,因为 chip 盒会故意裁掉溢出(见下文)。chip 之后是添加控件,它请嵌入方(`DockIntents.addTab`)安放其种子 tab;嵌入方的 `canAddTab(paneId)` 按格决定是否绘制该控件。复制 tab 没有套件控件——那是嵌入方的 API——而浮出就是把拖动松手在停靠区之外。
41
41
 
42
+ 标签上下文菜单只显示关闭操作名,不显示快捷键:菜单操作针对被点击的标签,快捷键则可能作用于其他焦点分栏。无修饰键的 Esc 关闭前台标签菜单而不关闭标签,并将菜单项的焦点交还标签,不绘制焦点外轮廓线。Tab 和方向键导航保留标签页的可见焦点提示。输入法组合输入和长按重复事件不关闭菜单。
43
+
42
44
  <a id="embedding-it"></a>
43
45
  ## 如何嵌入
44
46
 
@@ -58,11 +60,13 @@ tab 的 `kind` 是不透明字符串。种子 tab 是工厂(`DockControllerOpt
58
60
 
59
61
  Sidebar 使用的 `DockLayout` 在选中项变化、跨格移动与浮动期间,把每个 tab 保留在稳定的 DOM 容器中。它接受一个停靠格或左右两个格,由 CSS Grid 计算宽度。`keepMounted(tab)` 保留已访问正文,`active` 控制 Session 可见性,不改变内容的父子关系。
60
62
 
61
- `DockSurface` 是独立递归渲染器的停靠区。它周围的 chrome——轨道、折叠形态、任何历史控件——属于嵌入方,由嵌入方读取 `state.expanded` 后自行决定;套件不自带撤销/重做控件。嵌入方确实想放到面上的整面控件通过 `chrome` prop 传入,套件把它放在右上格 tab 条的最末端(每个横向分裂的最后一个子节点、每个纵向分裂的第一个子节点),因此停靠面不需要自己的标题行。`FloatLayer` 拥有自己的手势并以视口坐标定位浮窗,因此可以挂在任何位置,包括 portal 里。
63
+ `DockSurface` 是独立递归渲染器的停靠区。它周围的 chrome——轨道、折叠形态、任何历史控件——属于嵌入方,由嵌入方读取 `state.expanded` 后自行决定;套件不自带撤销/重做控件。嵌入方确实想放到面上的整面控件通过 `chrome` prop 传入,套件把它放在右上格 tab 条的最末端(每个横向分裂的最后一个子节点、每个纵向分裂的第一个子节点),因此停靠面不需要自己的标题行。`FloatLayer` 拥有自己的手势并以视口坐标定位浮窗,因此可以挂在任何位置,包括 portal 里。 两种浮窗渲染器都让标题栏与 Windows 顶栏至少间隔 20px(原生全屏时距视口顶部 20px),新建和恢复的浮窗均适用。拖动和调整大小从实际显示位置开始。
62
64
 
63
65
  <a id="interaction-rules-worth-keeping"></a>
64
66
  ## 值得保留的交互规则
65
67
 
68
+ 分栏控件接收嵌入方传入的本地化提示文本、`splitPaneKeys` 独立按键及 ARIA 组合。关闭控件通过 `closeTabKeys` 接收按键。禁用控件由可键盘聚焦的容器承载,说明分栏数量或宽度限制。停靠与浮动面板容器支持程序设置焦点,不进入正常 Tab 顺序,也不绘制焦点外框;内部控件保留各自的键盘焦点提示。标签导航和选择只使用无修饰键组合,输入法按键交回其所属输入区域。
69
+
66
70
  这些不是风格偏好;每一条都修复了在真实浏览器里发现的缺陷。
67
71
 
68
72
  - **手势开始时捕获指针。** 不捕获的话,指针经过的任何滚动容器都可能接管手势,浏览器会将其报告为指针取消和拖动中止。捕获是加固——无论如何都由 window 监听器承载手势,所以没有该 API 的环境照样可用。
@@ -99,11 +103,15 @@ Sidebar 使用的 `DockLayout` 在选中项变化、跨格移动与浮动期间
99
103
  <a id="dev-note"></a>
100
104
  ### 开发备注
101
105
 
106
+ 菜单采用共享 `MenuSurface` 材质,包括用于背景模糊的 macOS 底层;自定义内容遵循[菜单规则](../../../docs/web-styling.zh.md#component-rules)。
107
+
102
108
  <details>
103
109
  <summary>维护者工作上下文——点击展开</summary>
104
110
 
105
111
  无。
106
112
 
113
+ 嵌入方为标签和浮动面板的关闭控件提供有效关闭键帽及 ARIA 组合。关闭控件在悬停和键盘聚焦时显示相同 Tooltip。分栏的上下文菜单打开时,该分栏的标签关闭 Tooltip 保持隐藏。
114
+
107
115
  </details>
108
116
 
109
117
  **运行时不变量:** 不发布 companion。引擎是作用于纯数据的纯函数,组件只上报意图;操作序列的可逆性与 settle 规则由本包的引擎 spec 直接断言,不提供也不观察任何 Cordis 服务。
@@ -215,6 +215,11 @@
215
215
  overflow: hidden;
216
216
  }
217
217
 
218
+ .pane:focus,
219
+ .float:focus {
220
+ outline: none;
221
+ }
222
+
218
223
  /* A 38px row whose 28px content band sits at its bottom: chips fill the band,
219
224
  so they end flush with the strip's bottom edge, and the 28px add control, the
220
225
  28px split control, and the embedder's 28px chrome centre on the band's one
@@ -297,16 +302,17 @@
297
302
  margin-left: 4px;
298
303
  }
299
304
 
300
- /* macOS desktop: an embedder may pin this surface under the window's top drag
301
- band (ui-layout .leadingBand — full-window geometry that Electron composes
302
- in DOM order, not by stacking). The chip run, the strip-end chrome, the
303
- pane body, and the split divider subtract their whole boxes so tabs,
304
- controls, content, and the resize grab stay usable even where they overlap
305
- the band; the strip's blank fill and top inset keep the band's drag, so the
306
- row moves the window like a native tab bar. The divider must be named here:
307
- it is a plain div, so ui-web base.css's interactive-element subtraction
308
- never matches it. Hidden surfaces contribute nothing: Blink skips
309
- non-visible boxes when it collects draggable regions. */
305
+ /* macOS desktop: an embedder may pin this surface at the window's top edge (the
306
+ right sidebar does, in both presentations). The strip row is marked
307
+ `data-window-drag` in markup (TabPanel), so ui-web base.css's single darwin
308
+ rule drags it over its own box; the chip run, the strip-end chrome, the pane
309
+ body, and the split divider subtract their whole boxes so tabs, controls,
310
+ content, and the resize grab stay usable wherever they overlap that row. The
311
+ divider must be named here: it is a plain div, so ui-web base.css's
312
+ interactive-element subtraction never matches it. A floating pane subtracts its
313
+ own box the same way, so the strip row never drags the window over it. Hidden
314
+ surfaces contribute nothing: Blink skips non-visible boxes when it collects
315
+ draggable regions. */
310
316
  :global(html[data-platform='darwin']) .stripTabs,
311
317
  :global(html[data-platform='darwin']) .stripChrome,
312
318
  :global(html[data-platform='darwin']) .paneBody,
@@ -334,8 +340,8 @@
334
340
  background: var(--dsw-alias-border-l4);
335
341
  }
336
342
 
337
- /* The active chip is a filled capsule and needs no rule against it: the two
338
- slots beside it go blank, so the row reads as the capsule between bare
343
+ /* The active chip is a filled tab and needs no rule against it: the two
344
+ slots beside it go blank, so the row reads as the tab between bare
339
345
  chips. A caret in either slot still draws. */
340
346
  .tabActive + .slot:not(.slotCaret)::before,
341
347
  .slot:not(.slotCaret):has(+ .tabActive)::before {
@@ -348,7 +354,7 @@
348
354
  background: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
349
355
  }
350
356
 
351
- /* The chip: a 28px capsule whose title runs to the right inset. The close
357
+ /* The chip: a 28px tab whose title runs to the right inset. The close
352
358
  control is not in the flow — it sits over the title's last 14px and shows
353
359
  while the chip is active, hovered, or holds focus — so a chip is the same
354
360
  width with and without it and nothing shifts on hover. */
@@ -365,7 +371,7 @@
365
371
  font-size: var(--dsh-content-font-size-secondary, 13px);
366
372
  line-height: 1;
367
373
  white-space: nowrap;
368
- border-radius: 12px;
374
+ border-radius: var(--dsw-radius-sm);
369
375
  cursor: pointer;
370
376
  touch-action: none;
371
377
  user-select: none;
@@ -422,7 +428,7 @@
422
428
  line-height: 1;
423
429
  background: transparent;
424
430
  border: none;
425
- border-radius: 20px;
431
+ border-radius: var(--dsw-radius-sm);
426
432
  cursor: pointer;
427
433
  opacity: 0;
428
434
  /* Hidden means untouchable too: without this a touch press on the chip's
@@ -439,7 +445,7 @@
439
445
  pointer-events: auto;
440
446
  }
441
447
 
442
- /* The chip's height and corner, so it reads as one more capsule in the row. */
448
+ /* The chip's height and corner, so it reads as one more tab in the row. */
443
449
  .addTab {
444
450
  display: flex;
445
451
  flex: none;
@@ -452,7 +458,7 @@
452
458
  line-height: 1;
453
459
  background: transparent;
454
460
  border: none;
455
- border-radius: 12px;
461
+ border-radius: var(--dsw-radius-sm);
456
462
  cursor: pointer;
457
463
  }
458
464
 
@@ -470,13 +476,13 @@
470
476
  background: var(--dsw-alias-interactive-bg-hover);
471
477
  }
472
478
 
473
- /* The active chip is the filled capsule, in the tag fill; the rest are bare text. */
479
+ /* The active chip is the filled tab, in the tag fill; the rest are bare text. */
474
480
  .tabActive {
475
481
  color: var(--dsw-alias-label-primary);
476
482
  background: var(--dsw-alias-markdown-tag);
477
483
  }
478
484
 
479
- /* A pane's lone unclosable chip is a label, not a choice: no capsule, no
485
+ /* A pane's lone unclosable chip is a label, not a choice: no tab, no
480
486
  hover fill — the primary ink on the pane's own ground. Its title never
481
487
  yields to a close control, so it keeps the clipped fade alone. */
482
488
  .tab.tabQuiet,
@@ -514,7 +520,7 @@
514
520
  line-height: 1;
515
521
  background: transparent;
516
522
  border: none;
517
- border-radius: 28px;
523
+ border-radius: var(--dsw-radius-sm);
518
524
  cursor: pointer;
519
525
  }
520
526
 
@@ -544,11 +550,8 @@
544
550
  display: flex;
545
551
  flex-direction: column;
546
552
  min-width: 96px;
547
- padding: 3px;
548
- background: var(--dsw-specific-menu);
549
- backdrop-filter: var(--dsw-menu-backdrop-filter);
553
+ padding: 4px;
550
554
  border: 0.5px solid var(--dsw-alias-border-l2);
551
- border-radius: 8px;
552
555
  }
553
556
 
554
557
  .menu:not(:has([role='menuitem'], [role='menuitemcheckbox'], [role='menuitemradio'])) {
@@ -562,7 +565,7 @@
562
565
  text-align: left;
563
566
  background: transparent;
564
567
  border: none;
565
- border-radius: 5px;
568
+ border-radius: var(--dsw-radius-md);
566
569
  cursor: pointer;
567
570
  }
568
571
 
@@ -667,7 +670,7 @@
667
670
  overflow: hidden;
668
671
  color: var(--dsw-alias-label-tertiary);
669
672
  border: 1.5px dashed var(--dsw-alias-border-l2);
670
- border-radius: 12px;
673
+ border-radius: var(--dsw-radius-lg);
671
674
  transition: color 120ms ease-out, background-color 120ms ease-out, border-color 120ms ease-out;
672
675
  animation: dockHintIn 140ms ease-out;
673
676
  }
@@ -736,6 +739,14 @@
736
739
  height: 40%;
737
740
  }
738
741
 
742
+ :global(html[data-windows-titlebar]) .float {
743
+ --dsh-dockkit-float-top: calc(var(--dsh-windows-titlebar-height) + 20px);
744
+ }
745
+
746
+ :global(html[data-windows-titlebar][data-fullscreen]) .float {
747
+ --dsh-dockkit-float-top: 20px;
748
+ }
749
+
739
750
  .float {
740
751
  position: fixed;
741
752
  display: flex;
@@ -744,7 +755,7 @@
744
755
  to its own corners. */
745
756
  overflow: hidden;
746
757
  background: var(--dsw-alias-bg-layer-2);
747
- border-radius: 20px;
758
+ border-radius: var(--dsw-radius-panel);
748
759
  /* The menu's shadow with the stroke rebound one step lighter than the
749
760
  default (l2): the panel reads as the same kind of raised surface, and the
750
761
  shadow's hairline outlines it, so the frame draws no border of its own. */
@@ -801,20 +812,18 @@
801
812
  touch-action: none;
802
813
  }
803
814
 
804
- /* The grip is a quarter arc concentric with the frame's 20px corner (16px
805
- radius at a 4px inset), so it reads as part of the frame. It shows only
806
- while the pointer is over the panel — an idle panel keeps a clean corner —
807
- and `:active` keeps it lit while a drag holds the pointer capture. */
815
+ /* The grip follows the floating panel's corner with a 4px inset. Pointer
816
+ hover and an active drag reveal it. */
808
817
  .floatResize::after {
809
818
  content: '';
810
819
  position: absolute;
811
820
  right: 4px;
812
821
  bottom: 4px;
813
- width: 16px;
814
- height: 16px;
822
+ width: calc(var(--dsw-radius-panel) - 4px);
823
+ height: calc(var(--dsw-radius-panel) - 4px);
815
824
  border-right: 1.5px solid var(--dsw-alias-label-caption);
816
825
  border-bottom: 1.5px solid var(--dsw-alias-label-caption);
817
- border-bottom-right-radius: 16px;
826
+ border-bottom-right-radius: calc(var(--dsw-radius-panel) - 4px);
818
827
  opacity: 0;
819
828
  transition: opacity 120ms ease-out;
820
829
  }
package/lib/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
2
  import { Children, Fragment as Fragment$1, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
3
3
  import clsx from "clsx";
4
- import { IconCloseFillRegular, IconCloseOutlineRegular, IconPanelLeftOutlineRegular, IconPlusOutlineRegular, Tooltip } from "@deepseek-ai/dsh-client-ui-primitives";
4
+ import { IconCloseFillRegular, IconCloseOutlineRegular, IconPanelLeftOutlineRegular, IconPlusOutlineRegular, MenuSurface, Tooltip, focusWithoutRing, modalSelector, observeComposition } from "@deepseek-ai/dsh-client-ui-primitives";
5
5
  import { createPortal } from "react-dom";
6
6
  import css from "./components/dockkit.module.css";
7
7
  //#region lib/types/engine/tree.js
@@ -2230,7 +2230,7 @@ function useGesture(reset) {
2230
2230
  * released clear of the surface. A menu that would hold no item at all renders
2231
2231
  * no popup, so a secondary press on a chip with nothing to offer shows nothing.
2232
2232
  * Presentational — it renders what its props supply and dismisses itself on
2233
- * outside presses.
2233
+ * outside presses or an unmodified Escape, returning menu focus to its tab.
2234
2234
  *
2235
2235
  * It renders in a portal, positioned against the control that opened it. The tab
2236
2236
  * strip clips its overflow on purpose (so it never becomes a scroll container
@@ -2264,17 +2264,35 @@ function TabMenu({ labels, anchor, onClose, onDismiss, extras }) {
2264
2264
  const menu = self.current;
2265
2265
  /* v8 ignore next -- the ref is attached by effect time: the menu renders unconditionally. */
2266
2266
  if (menu === null) return void 0;
2267
+ const document = menu.ownerDocument;
2268
+ const composition = observeComposition(document);
2269
+ const onKeyDown = (event) => {
2270
+ if (composition.guards(event) || event.defaultPrevented || event.key !== "Escape" || event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) return;
2271
+ if ([...document.querySelectorAll(modalSelector)].at(-1) !== menu) return;
2272
+ event.preventDefault();
2273
+ if (event.repeat) return;
2274
+ const restoreFocus = menu.contains(document.activeElement);
2275
+ onDismiss();
2276
+ if (restoreFocus) focusWithoutRing(anchor);
2277
+ };
2278
+ document.addEventListener("keydown", onKeyDown, true);
2267
2279
  const onPointerDown = (event) => {
2268
2280
  if (event.target instanceof Node && menu.contains(event.target)) return;
2269
2281
  onDismiss();
2270
2282
  };
2271
2283
  window.addEventListener("pointerdown", onPointerDown, true);
2272
2284
  return () => {
2285
+ composition.dispose();
2286
+ document.removeEventListener("keydown", onKeyDown, true);
2273
2287
  window.removeEventListener("pointerdown", onPointerDown, true);
2274
2288
  };
2275
- }, [onDismiss, hasItems]);
2289
+ }, [
2290
+ anchor,
2291
+ onDismiss,
2292
+ hasItems
2293
+ ]);
2276
2294
  if (!hasItems) return null;
2277
- return createPortal(jsxs("div", {
2295
+ return createPortal(jsxs(MenuSurface, {
2278
2296
  className: css.menu,
2279
2297
  ref: self,
2280
2298
  role: "menu",
@@ -2565,6 +2583,7 @@ function TabStrip({ state, pane, callbacks }) {
2565
2583
  className: css.tabStrip,
2566
2584
  role: "tablist",
2567
2585
  "data-dockkit-strip": pane.id,
2586
+ "data-window-drag": state.floats.length === 0 ? true : void 0,
2568
2587
  children: [
2569
2588
  jsxs("div", {
2570
2589
  ref: stripTabs,
@@ -2600,6 +2619,7 @@ function TabStrip({ state, pane, callbacks }) {
2600
2619
  },
2601
2620
  onKeyDown: (event) => {
2602
2621
  if (event.target !== event.currentTarget) return;
2622
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || event.nativeEvent.isComposing) return;
2603
2623
  const next = chipToFocus(event.key, pane.tabs, tabId);
2604
2624
  if (next !== void 0) {
2605
2625
  event.preventDefault();
@@ -2621,19 +2641,27 @@ function TabStrip({ state, pane, callbacks }) {
2621
2641
  },
2622
2642
  children: [
2623
2643
  jsx(TabTitle, { children: callbacks.renderTabTitle?.(tab) ?? tab.title }),
2624
- closable && jsx("button", {
2625
- type: "button",
2626
- className: css.tabClose,
2627
- "aria-label": callbacks.labels.closeTab,
2628
- "data-dockkit-tab-close": tabId,
2629
- onPointerDown: (event) => {
2630
- event.stopPropagation();
2631
- },
2632
- onClick: (event) => {
2633
- event.stopPropagation();
2634
- callbacks.onCloseTab(tabId);
2635
- },
2636
- children: jsx(IconCloseFillRegular, { size: 14 })
2644
+ closable && jsx(Tooltip, {
2645
+ disabled: menu !== void 0,
2646
+ label: callbacks.labels.closeTab,
2647
+ shortcutKeys: callbacks.labels.closeTabKeys,
2648
+ side: "bottom",
2649
+ delayMs: 500,
2650
+ children: jsx("button", {
2651
+ type: "button",
2652
+ className: css.tabClose,
2653
+ "aria-label": callbacks.labels.closeTab,
2654
+ "aria-keyshortcuts": callbacks.labels.closeTabShortcut,
2655
+ "data-dockkit-tab-close": tabId,
2656
+ onPointerDown: (event) => {
2657
+ event.stopPropagation();
2658
+ },
2659
+ onClick: (event) => {
2660
+ event.stopPropagation();
2661
+ callbacks.onCloseTab(tabId);
2662
+ },
2663
+ children: jsx(IconCloseFillRegular, { size: 14 })
2664
+ })
2637
2665
  }),
2638
2666
  menu?.tabId === tabId && jsx(TabMenu, {
2639
2667
  labels: callbacks.labels,
@@ -2677,23 +2705,27 @@ function TabStrip({ state, pane, callbacks }) {
2677
2705
  "data-dockkit-strip-fill": true
2678
2706
  }),
2679
2707
  !(callbacks.hideSplitWhenBlocked && block !== void 0) && jsx(Tooltip, {
2680
- label: callbacks.labels.splitPane,
2708
+ label: block === void 0 ? callbacks.labels.splitPane : splitBlockedTitle(callbacks.labels, block),
2709
+ shortcutKeys: callbacks.labels.splitPaneKeys,
2681
2710
  side: "bottom",
2682
2711
  delayMs: 500,
2683
- disabled: block !== void 0,
2684
- children: jsx("button", {
2685
- type: "button",
2686
- className: css.iconButton,
2687
- "aria-label": callbacks.labels.splitPane,
2688
- title: block === void 0 ? void 0 : splitBlockedTitle(callbacks.labels, block),
2689
- disabled: block !== void 0,
2690
- "data-dockkit-split-button": pane.id,
2691
- "data-dockkit-split-blocked": block,
2692
- onClick: (event) => {
2693
- event.stopPropagation();
2694
- callbacks.onSplitPane(pane.id);
2695
- },
2696
- children: jsx(SplitGlyph, {})
2712
+ children: jsx("span", {
2713
+ tabIndex: block === void 0 ? void 0 : 0,
2714
+ "aria-label": block === void 0 ? void 0 : splitBlockedTitle(callbacks.labels, block),
2715
+ children: jsx("button", {
2716
+ type: "button",
2717
+ className: css.iconButton,
2718
+ "aria-label": callbacks.labels.splitPane,
2719
+ "aria-keyshortcuts": callbacks.labels.splitPaneShortcut,
2720
+ disabled: block !== void 0,
2721
+ "data-dockkit-split-button": pane.id,
2722
+ "data-dockkit-split-blocked": block,
2723
+ onClick: (event) => {
2724
+ event.stopPropagation();
2725
+ callbacks.onSplitPane(pane.id);
2726
+ },
2727
+ children: jsx(SplitGlyph, {})
2728
+ })
2697
2729
  })
2698
2730
  }),
2699
2731
  pane.id === callbacks.chromePaneId && callbacks.chrome !== void 0 && jsx("div", {
@@ -2735,6 +2767,7 @@ function TabPanel({ state, pane, callbacks }) {
2735
2767
  return jsxs("section", {
2736
2768
  className: css.pane,
2737
2769
  "data-dockkit-pane": pane.id,
2770
+ tabIndex: -1,
2738
2771
  "data-dockkit-pane-active": state.activePaneId === pane.id || void 0,
2739
2772
  onClick: () => {
2740
2773
  if (state.activePaneId !== pane.id) callbacks.onFocusPane(pane.id);
@@ -2844,11 +2877,19 @@ function useFloatGestures(state, intents) {
2844
2877
  /** Start a move or resize from a press on the panel's grip or corner; a release that moved nothing is a click. */
2845
2878
  const drag = (mode, paneId, event) => {
2846
2879
  event.stopPropagation();
2880
+ const root = document.documentElement;
2881
+ const windows = root.hasAttribute("data-windows-titlebar");
2882
+ const caption = windows && !root.hasAttribute("data-fullscreen") ? Number.parseFloat(root.style.getPropertyValue("--dsh-windows-titlebar-height")) : 0;
2883
+ const saved = floatRect(getPane(state, paneId));
2884
+ const rect = {
2885
+ ...saved,
2886
+ y: Math.max(saved.y, windows ? caption + 20 : 0)
2887
+ };
2847
2888
  const start = {
2848
2889
  mode,
2849
2890
  originX: event.clientX,
2850
2891
  originY: event.clientY,
2851
- rect: floatRect(getPane(state, paneId))
2892
+ rect
2852
2893
  };
2853
2894
  begin(event.currentTarget, event.pointerId, {
2854
2895
  move: (moved) => {
@@ -2909,13 +2950,14 @@ function FloatHeader({ paneId, tab, labels, intents, renderTabTitle, canCloseTab
2909
2950
  })
2910
2951
  }),
2911
2952
  (canCloseTab?.(tab.id) ?? true) && jsx(Tooltip, {
2912
- label: labels.closeFloat,
2953
+ label: labels.closeTab,
2954
+ shortcutKeys: labels.closeTabKeys,
2913
2955
  side: "bottom",
2914
2956
  delayMs: 500,
2915
2957
  children: jsx("button", {
2916
2958
  type: "button",
2917
2959
  className: css.iconButton,
2918
- "aria-label": labels.closeFloat,
2960
+ "aria-label": labels.closeTab,
2919
2961
  "data-dockkit-float-close": paneId,
2920
2962
  onPointerDown: (event) => {
2921
2963
  event.stopPropagation();
@@ -2940,10 +2982,11 @@ function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, canClos
2940
2982
  return jsxs("div", {
2941
2983
  className: css.float,
2942
2984
  "data-dockkit-float": paneId,
2985
+ tabIndex: -1,
2943
2986
  "data-dockkit-float-active": state.activePaneId === paneId || void 0,
2944
2987
  style: {
2945
2988
  left: live.x,
2946
- top: live.y,
2989
+ top: `max(var(--dsh-dockkit-float-top, 0px), ${live.y}px)`,
2947
2990
  width: live.width,
2948
2991
  height: live.height,
2949
2992
  zIndex: lifted === void 0 ? depth + 1 : state.floats.length + 1
@@ -3038,6 +3081,7 @@ function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRe
3038
3081
  },
3039
3082
  children: jsxs("section", {
3040
3083
  ref: host,
3084
+ tabIndex: -1,
3041
3085
  className: clsx(css.tabHost, floating ? css.float : css.pane),
3042
3086
  "aria-hidden": !visible || void 0,
3043
3087
  "data-dockkit-content": tab.id,
@@ -3048,7 +3092,7 @@ function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRe
3048
3092
  "data-dockkit-column": !floating ? column : void 0,
3049
3093
  style: rect === void 0 ? void 0 : {
3050
3094
  left: rect.x,
3051
- top: rect.y,
3095
+ top: `max(var(--dsh-dockkit-float-top, 0px), ${rect.y}px)`,
3052
3096
  width: rect.width,
3053
3097
  height: rect.height
3054
3098
  },
@@ -18,12 +18,20 @@ export interface DockLabels {
18
18
  readonly emptyPane: string;
19
19
  /** The split control, while splitting is allowed. */
20
20
  readonly splitPane: string;
21
+ /** ARIA combination from the effective binding, when bound. */
22
+ readonly splitPaneShortcut?: string | undefined;
23
+ /** Effective split keys shown as separate tooltip keycaps. */
24
+ readonly splitPaneKeys?: readonly string[] | undefined;
21
25
  /** The split control, once the pane budget is spent. */
22
26
  readonly splitPaneDisabled: string;
23
27
  /** The split control, while the pane is too narrow for two working halves. */
24
28
  readonly splitPaneNarrow: string;
25
29
  /** Destroy a tab: the chip's close control and the menu's close item. */
26
30
  readonly closeTab: string;
31
+ /** Effective close combination, when bound. */
32
+ readonly closeTabShortcut?: string | undefined;
33
+ /** Effective close keys shown as separate tooltip keycaps. */
34
+ readonly closeTabKeys?: readonly string[] | undefined;
27
35
  /** The strip's add control, which seats the embedder's seeded tab. */
28
36
  readonly addTab: string;
29
37
  /** Send a floating panel back into the docked tree. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-client-ui-dockkit",
3
3
  "description": "Docking layout kit: split-tree engine with invertible operations, and the React components that render and drive it (zero cordis)",
4
- "version": "0.1.7-rc.1",
4
+ "version": "0.2.0-rc.1",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -29,9 +29,9 @@
29
29
  "react-dom": "^18.2.0",
30
30
  "react": "^18.2.0",
31
31
  "clsx": "^2.0.0",
32
- "@deepseek-ai/cordis": "~4.0.4",
33
- "@deepseek-ai/dsh-brand": "0.1.7-rc.1",
34
- "@deepseek-ai/dsh-client-ui-primitives": "0.1.7-rc.1"
32
+ "@deepseek-ai/dsh-brand": "0.2.0-rc.1",
33
+ "@deepseek-ai/dsh-client-ui-primitives": "0.2.0-rc.1",
34
+ "@deepseek-ai/cordis": "~4.0.4"
35
35
  },
36
36
  "files": [
37
37
  "lib/index.js",