@deepseek-ai/dsh-client-ui-dockkit 0.1.5-alpha.1 → 0.1.5-alpha.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/lib/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
- import { Fragment as Fragment$1, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
2
+ import { Children, Fragment as Fragment$1, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
3
3
  import clsx from "clsx";
4
+ import { IconCloseFill14, IconCloseOutline16, IconPanelLeftOutline16, IconPlusOutline16, Tooltip } from "@deepseek-ai/dsh-client-ui-primitives";
4
5
  import { createPortal } from "react-dom";
5
6
  import css from "./components/dockkit.module.css";
6
7
  //#region lib/types/engine/tree.js
@@ -1231,21 +1232,23 @@ function insertionIndex(tabRects, x) {
1231
1232
  }
1232
1233
  /**
1233
1234
  * The minimums where no computed style can be read, mirroring
1234
- * `dockkit.module.css`: `.splitRow > .divider` is 4px wide; `.tab` is 44px of
1235
- * content plus 10px + 5px of padding (content-box), 59px; the body's 12px
1236
- * padding above and below one 13px secondary line at 1.6 line-height is 45px,
1237
- * held to 48px.
1235
+ * `dockkit.module.css`: `.splitRow > .divider` takes no layout width (its
1236
+ * hairline is painted over the seam); `.tab` is 80px of content plus
1237
+ * 10px + 10px of padding (content-box), 100px; 12px above and below one 13px
1238
+ * secondary line at 1.6 line-height — the inset a body draws for itself, as
1239
+ * `.empty` does — is 45px, held to 48px.
1238
1240
  */
1239
1241
  const SPLIT_MINIMUMS = {
1240
- divider: 4,
1241
- chip: 59,
1242
+ divider: 0,
1243
+ chip: 100,
1242
1244
  body: 48
1243
1245
  };
1244
1246
  /**
1245
1247
  * The room rule. After an equal split each half must hold what cannot shrink:
1246
- * horizontally the strip's fixed part — its width minus the chip box and the
1247
- * fill, which is the padding, the gaps, and every control that pane draws —
1248
- * plus one chip at its minimum; vertically the strip plus a minimum body. The
1248
+ * horizontally the strip's fixed part — its width minus the chip box, the
1249
+ * fill, and `splitControlWidth`, which is the padding, the gaps, and every
1250
+ * control a half would still draw — plus one chip at its minimum; vertically
1251
+ * the strip plus a minimum body. The
1249
1252
  * borders are what the pane's box exceeds the strip's by. An unmeasured pane
1250
1253
  * (no layout, as under jsdom) fits: the rule only blocks on a positive reading.
1251
1254
  * @param measure - the pane's rectangles.
@@ -1259,7 +1262,7 @@ function halvesFit(measure, minimums = SPLIT_MINIMUMS) {
1259
1262
  column: true
1260
1263
  };
1261
1264
  const borders = Math.max(0, pane.width - strip.width);
1262
- const fixed = Math.max(0, strip.width - measure.chipsWidth - measure.fillWidth);
1265
+ const fixed = Math.max(0, strip.width - measure.chipsWidth - measure.fillWidth - (measure.splitControlWidth ?? 0));
1263
1266
  const halfWidth = (pane.width - minimums.divider) / 2 - borders;
1264
1267
  const halfHeight = (pane.height - minimums.divider) / 2 - borders;
1265
1268
  return {
@@ -1565,16 +1568,19 @@ function planPlaceTab(state, tabId, toPaneId, index) {
1565
1568
  /**
1566
1569
  * Resolve a tab release on a pane body: the centre moves the tab in, an edge
1567
1570
  * splits the pane and seats the tab in the new half. A pane's only tab released
1568
- * on that pane changes nothing in either zone: the split would empty the pane
1569
- * and seat the tab beside where it already was.
1571
+ * on that pane's centre changes nothing; released on its edge it splits, and
1572
+ * the factory's tab backfills the pane the drag would otherwise empty — without
1573
+ * a factory that release also changes nothing, since the split would empty the
1574
+ * pane and seat the tab beside where it already was.
1570
1575
  * @param state - current layout.
1571
1576
  * @param mint - id source for a pane an edge release creates.
1572
1577
  * @param tabId - the dragged tab.
1573
1578
  * @param targetPaneId - pane under the pointer.
1574
1579
  * @param zone - dock region the pointer released in.
1580
+ * @param makeTab - builds the tab that backfills a pane its only tab splits away from.
1575
1581
  * @returns the operations, or none when the release changes nothing.
1576
1582
  */
1577
- function planDropTab(state, mint, tabId, targetPaneId, zone) {
1583
+ function planDropTab(state, mint, tabId, targetPaneId, zone, makeTab) {
1578
1584
  const source = findTabPane(state, tabId);
1579
1585
  const target = getPane(state, targetPaneId);
1580
1586
  if (target.host !== "dock") return NOTHING;
@@ -1583,17 +1589,26 @@ function planDropTab(state, mint, tabId, targetPaneId, zone) {
1583
1589
  if (source.id === targetPaneId) return NOTHING;
1584
1590
  return [tabInto(source, tabId, targetPaneId, target.tabs.length)];
1585
1591
  }
1586
- if (source.id === targetPaneId && source.tabs.length === 1) return NOTHING;
1592
+ const vacates = source.id === targetPaneId && source.tabs.length === 1;
1593
+ if (vacates && makeTab === void 0) return NOTHING;
1587
1594
  if (!canSplit(state)) return NOTHING;
1588
1595
  const newPaneId = mint("pane");
1589
- return [{
1596
+ const ops = [{
1590
1597
  type: "split",
1591
1598
  paneId: targetPaneId,
1592
1599
  axis: split.axis,
1593
1600
  direction: split.direction,
1594
1601
  newPaneId,
1595
1602
  newSplitId: mint("split")
1596
- }, tabInto(source, tabId, newPaneId, 0)];
1603
+ }];
1604
+ if (vacates && makeTab !== void 0) ops.push({
1605
+ type: "openTab",
1606
+ paneId: targetPaneId,
1607
+ tab: makeTab(mint("tab")),
1608
+ index: source.tabs.length
1609
+ });
1610
+ ops.push(tabInto(source, tabId, newPaneId, 0));
1611
+ return ops;
1597
1612
  }
1598
1613
  /**
1599
1614
  * Take a tab out into a floating panel.
@@ -2052,11 +2067,29 @@ function dividerSize(root) {
2052
2067
  return thickness > 0 ? thickness : SPLIT_MINIMUMS.divider;
2053
2068
  }
2054
2069
  /**
2070
+ * The rendered split control's footprint in the strip's fixed part: its box
2071
+ * plus the strip's own gap, both of which the strip sheds when the control
2072
+ * hides. 0 while the control is hidden or unmeasured.
2073
+ */
2074
+ function splitControlFootprint(pane) {
2075
+ const control = pane.querySelector("[data-dockkit-split-button]");
2076
+ if (control === null) return 0;
2077
+ const width = control.getBoundingClientRect().width;
2078
+ if (!(width > 0)) return 0;
2079
+ const strip = pane.querySelector("[data-dockkit-strip]");
2080
+ /* v8 ignore next -- the control only renders inside a strip. */
2081
+ return width + (strip === null ? 0 : px(getComputedStyle(strip).columnGap));
2082
+ }
2083
+ /**
2055
2084
  * Measure every docked pane under `root`.
2056
2085
  * @param root - the docked surface's element.
2086
+ * @param splitHiddenWhenBlocked - whether the embedder hides blocked split
2087
+ * controls (`hideSplitWhenBlocked`); the room rule then leaves the control's
2088
+ * footprint out of each strip's fixed part, so the reading cannot flip with
2089
+ * the control's visibility (see `PaneMeasure.splitControlWidth`).
2057
2090
  * @returns each pane's fit, keyed by pane id.
2058
2091
  */
2059
- function measurePaneFits(root) {
2092
+ function measurePaneFits(root, splitHiddenWhenBlocked = false) {
2060
2093
  const minimums = {
2061
2094
  divider: dividerSize(root),
2062
2095
  chip: chipMinimum(root),
@@ -2067,7 +2100,8 @@ function measurePaneFits(root) {
2067
2100
  pane: rectOf(pane),
2068
2101
  strip: rectOf(pane.querySelector("[data-dockkit-strip]")),
2069
2102
  chipsWidth: rectOf(pane.querySelector("[data-dockkit-strip-tabs]")).width,
2070
- fillWidth: rectOf(pane.querySelector("[data-dockkit-strip-fill]")).width
2103
+ fillWidth: rectOf(pane.querySelector("[data-dockkit-strip-fill]")).width,
2104
+ splitControlWidth: splitHiddenWhenBlocked ? splitControlFootprint(pane) : 0
2071
2105
  }, minimums));
2072
2106
  return fits;
2073
2107
  }
@@ -2189,8 +2223,10 @@ function useGesture(reset) {
2189
2223
  * The per-tab context menu, opened by a secondary press on the chip. It carries
2190
2224
  * the close gesture and whatever the embedder appends; the copy and float
2191
2225
  * gestures have no menu item — copying is an embedder API, floating is a drag
2192
- * released clear of the surface. Presentational — it renders what its props
2193
- * supply and dismisses itself on outside presses.
2226
+ * released clear of the surface. A menu that would hold no item at all renders
2227
+ * no popup, so a secondary press on a chip with nothing to offer shows nothing.
2228
+ * Presentational — it renders what its props supply and dismisses itself on
2229
+ * outside presses.
2194
2230
  *
2195
2231
  * It renders in a portal, positioned against the control that opened it. The tab
2196
2232
  * strip clips its overflow on purpose (so it never becomes a scroll container
@@ -2215,11 +2251,11 @@ function placeMenu(anchor, menu) {
2215
2251
  function TabMenu({ labels, anchor, onClose, onDismiss, extras }) {
2216
2252
  const self = useRef(null);
2217
2253
  const [position, setPosition] = useState(void 0);
2254
+ const hasItems = onClose !== void 0 || Children.toArray(extras).some((item) => item !== "");
2218
2255
  useLayoutEffect(() => {
2219
- /* v8 ignore next -- the ref is attached by effect time: the menu renders unconditionally. */
2220
2256
  if (self.current === null) return;
2221
2257
  setPosition(placeMenu(anchor, self.current));
2222
- }, [anchor]);
2258
+ }, [anchor, hasItems]);
2223
2259
  useEffect(() => {
2224
2260
  const menu = self.current;
2225
2261
  /* v8 ignore next -- the ref is attached by effect time: the menu renders unconditionally. */
@@ -2232,7 +2268,8 @@ function TabMenu({ labels, anchor, onClose, onDismiss, extras }) {
2232
2268
  return () => {
2233
2269
  window.removeEventListener("pointerdown", onPointerDown, true);
2234
2270
  };
2235
- }, [onDismiss]);
2271
+ }, [onDismiss, hasItems]);
2272
+ if (!hasItems) return null;
2236
2273
  return createPortal(jsxs("div", {
2237
2274
  className: css.menu,
2238
2275
  ref: self,
@@ -2249,7 +2286,7 @@ function TabMenu({ labels, anchor, onClose, onDismiss, extras }) {
2249
2286
  onClick: (event) => {
2250
2287
  event.stopPropagation();
2251
2288
  },
2252
- children: [jsx("button", {
2289
+ children: [onClose !== void 0 && jsx("button", {
2253
2290
  type: "button",
2254
2291
  role: "menuitem",
2255
2292
  className: css.menuItem,
@@ -2260,70 +2297,142 @@ function TabMenu({ labels, anchor, onClose, onDismiss, extras }) {
2260
2297
  }), document.body);
2261
2298
  }
2262
2299
  //#endregion
2300
+ //#region lib/types/components/TabTitle.js
2301
+ /**
2302
+ * A chip's title: one line, clipped at the chip's inset, never ellipsized.
2303
+ * While the text is wider than its box the span carries
2304
+ * `data-dockkit-tab-clipped`, and the stylesheet fades the text out at the
2305
+ * clipped edge in place of an ellipsis. Written to the DOM directly rather
2306
+ * than through state: a reading changes nothing that renders, only how the
2307
+ * stylesheet paints it. Re-read after every commit (the text may have
2308
+ * changed) and whenever the span's box resizes (the chip shrank or grew).
2309
+ */
2310
+ /** Set or clear the span's `data-dockkit-tab-clipped` from its current geometry. */
2311
+ function markClipped(element) {
2312
+ if (element.scrollWidth > element.clientWidth + 1) element.dataset.dockkitTabClipped = "";
2313
+ else delete element.dataset.dockkitTabClipped;
2314
+ }
2315
+ /** The title span of a strip chip or a floating panel's header chip. */
2316
+ function TabTitle({ children }) {
2317
+ const span = useRef(null);
2318
+ useLayoutEffect(() => {
2319
+ /* v8 ignore next -- the span is rendered unconditionally. */
2320
+ if (span.current !== null) markClipped(span.current);
2321
+ });
2322
+ useLayoutEffect(() => {
2323
+ const element = span.current;
2324
+ /* v8 ignore next -- the span is rendered unconditionally. */
2325
+ if (element === null || typeof ResizeObserver === "undefined") return void 0;
2326
+ const observer = new ResizeObserver(() => {
2327
+ markClipped(element);
2328
+ });
2329
+ observer.observe(element);
2330
+ return () => {
2331
+ observer.disconnect();
2332
+ };
2333
+ }, []);
2334
+ return jsx("span", {
2335
+ ref: span,
2336
+ className: css.tabTitle,
2337
+ "data-dockkit-tab-title": true,
2338
+ children
2339
+ });
2340
+ }
2341
+ //#endregion
2263
2342
  //#region lib/types/components/TabPanel.js
2264
2343
  /**
2265
2344
  * One pane: its tab strip (drag source, drop target, split control) and the
2266
2345
  * active tab's body with the dock preview overlay. Presentational; every gesture
2267
2346
  * leaves through `PaneCallbacks`, and the body itself comes from `renderTab`.
2268
2347
  *
2269
- * A chip is a capsule carrying one control, its close, at its right end; the
2270
- * context menu (secondary press) carries the same close plus whatever the
2271
- * embedder appends. The chips sit in their own box, the strip's one shrinking
2272
- * part: in a narrow pane they ellipsize and then clip there, so the add
2273
- * control after them (drawn while the embedder's `canAddTab` allows), the
2274
- * pane's split control, and the embedder's chrome keep their width and their
2275
- * place at the strip's end.
2276
- */
2277
- /** The split control's glyph: a frame divided by a vertical line, as the split itself is. */
2348
+ * A chip is a capsule carrying one control, its close, shown over its right
2349
+ * end while the chip is active, hovered, or focused; the context menu
2350
+ * (secondary press) carries the same close plus whatever the embedder appends.
2351
+ * Both close routes draw only while the embedder's `canCloseTab` allows, and
2352
+ * a pane's lone chip whose close is withheld draws quiet — no capsule, no
2353
+ * hover fill — since there is nothing to select against and nothing to do to
2354
+ * it.
2355
+ * Between neighbouring chips sits a slot: a fixed-width box drawing a
2356
+ * hairline, blank beside the active chip, and the drop caret when a drag
2357
+ * targets that index, so a caret never widens the row; the two end slots
2358
+ * exist only while targeted. The chips sit in their own box, the strip's one
2359
+ * shrinking part: in a narrow pane their titles fade at the clipped edge down
2360
+ * to the chip's floor and then the chips scroll there, keeping the active one
2361
+ * in view, so the add control after them (drawn while the embedder's
2362
+ * `canAddTab` allows), the pane's split control, and the embedder's chrome keep
2363
+ * their width and their place at the strip's end.
2364
+ */
2365
+ /**
2366
+ * The ic_ds_panel_left_outline_16 frame alone: its outer and inner rounded
2367
+ * rectangles as one even-odd ring, without the divider. The glyphs below draw
2368
+ * inside it so they read as siblings of the panel controls beside them.
2369
+ */
2370
+ const PANEL_FRAME = "M9.67272 0.522841C10.8339 0.522841 11.76 0.522714 12.4963 0.602493C13.2453 0.683657 13.8789 0.854248 14.4264 1.25197C14.7504 1.48739 15.0355 1.77247 15.2709 2.0965C15.6686 2.64394 15.8392 3.27758 15.9204 4.02655C16.0002 4.7629 16 5.68895 16 6.85014V9.14986C16 10.3111 16.0002 11.2371 15.9204 11.9735C15.8392 12.7224 15.6686 13.3561 15.2709 13.9035C15.0355 14.2275 14.7504 14.5126 14.4264 14.748C13.8789 15.1458 13.2453 15.3163 12.4963 15.3975C11.76 15.4773 10.8339 15.4772 9.67272 15.4772H6.3273C5.16611 15.4772 4.24006 15.4773 3.50371 15.3975C2.75474 15.3163 2.1211 15.1458 1.57366 14.748C1.24963 14.5126 0.964549 14.2275 0.729131 13.9035C0.331407 13.3561 0.160817 12.7224 0.0796529 11.9735C-0.000126137 11.2371 1.25338e-09 10.3111 1.25338e-09 9.14986V6.85014C1.25329e-09 5.68895 -0.000126137 4.7629 0.0796529 4.02655C0.160817 3.27758 0.331407 2.64394 0.729131 2.0965C0.964549 1.77247 1.24963 1.48739 1.57366 1.25197C2.1211 0.854248 2.75474 0.683657 3.50371 0.602493C4.24006 0.522714 5.16611 0.522841 6.3273 0.522841H9.67272ZM4.1828 14.0873L5.54303 14.1118C5.78636 14.1128 6.04709 14.1169 6.3273 14.1169H9.67272C10.8639 14.1169 11.7032 14.1164 12.3493 14.0465C12.9824 13.9779 13.3497 13.8494 13.6268 13.6482C13.8354 13.4966 14.0195 13.3125 14.1711 13.1039C14.3723 12.8268 14.5007 12.4595 14.5693 11.8264C14.6393 11.1803 14.6398 10.341 14.6398 9.14986V6.85014C14.6398 5.65896 14.6393 4.81967 14.5693 4.1736C14.5007 3.54048 14.3723 3.17318 14.1711 2.89609C14.0195 2.68747 13.8354 2.50337 13.6268 2.35179C13.3497 2.1506 12.9824 2.02212 12.3493 1.95353C11.7032 1.88358 10.8639 1.88307 9.67272 1.88307H6.3273C6.04709 1.88307 5.78636 1.8862 5.54303 1.88715L4.1828 1.91166C3.99125 1.9216 3.8148 1.93577 3.65076 1.95353C3.01764 2.02212 2.65034 2.1506 2.37325 2.35179C2.16463 2.50337 1.98052 2.68747 1.82895 2.89609C1.62776 3.17318 1.49928 3.54048 1.43069 4.1736C1.36074 4.81967 1.36023 5.65896 1.36023 6.85014V9.14986C1.36023 10.341 1.36074 11.1803 1.43069 11.8264C1.49928 12.4595 1.62776 12.8268 1.82895 13.1039C1.98052 13.3125 2.16463 13.4966 2.37325 13.6482C2.65034 13.8494 3.01764 13.9779 3.65076 14.0465C3.81478 14.0642 3.99127 14.0774 4.1828 14.0873Z";
2371
+ /** The split control's glyph: the panel frame with its divider moved to the centre. */
2278
2372
  function SplitGlyph() {
2279
- return jsxs("svg", {
2280
- width: "14",
2281
- height: "14",
2282
- viewBox: "0 0 14 14",
2283
- fill: "none",
2284
- "aria-hidden": "true",
2285
- children: [jsx("rect", {
2286
- x: "1.5",
2287
- y: "2",
2288
- width: "11",
2289
- height: "10",
2290
- rx: "1.5",
2291
- stroke: "currentColor"
2292
- }), jsx("path", {
2293
- d: "M7 2v10",
2294
- stroke: "currentColor"
2295
- })]
2296
- });
2297
- }
2298
- /** The add control's glyph. */
2299
- function PlusGlyph() {
2300
2373
  return jsx("svg", {
2301
- width: "12",
2302
- height: "12",
2303
- viewBox: "0 0 12 12",
2374
+ width: "16",
2375
+ height: "16",
2376
+ viewBox: "0 0 16 16",
2304
2377
  fill: "none",
2305
2378
  "aria-hidden": "true",
2306
2379
  children: jsx("path", {
2307
- d: "M6 1.5v9M1.5 6h9",
2308
- stroke: "currentColor",
2309
- strokeWidth: "1.2",
2310
- strokeLinecap: "round"
2380
+ fillRule: "evenodd",
2381
+ clipRule: "evenodd",
2382
+ d: `${PANEL_FRAME}M7.31989 1.88307H8.68012V14.1169H7.31989V1.88307Z`,
2383
+ fill: "currentColor"
2311
2384
  })
2312
2385
  });
2313
2386
  }
2314
- /** The close control's glyph. */
2315
- function CloseGlyph() {
2316
- return jsx("svg", {
2317
- width: "10",
2318
- height: "10",
2319
- viewBox: "0 0 10 10",
2387
+ /**
2388
+ * The drop hint's fill per zone: the half or the whole a release would fill
2389
+ * drawn solid, so the hint names its zone before its caption is read. A half
2390
+ * is drawn out to the frame's outer edge, under the ring, so its visible edge
2391
+ * is exactly the ring's inner edge with no seam at the corners; the whole sits
2392
+ * one stroke inside the frame so the ring stays visible around it.
2393
+ */
2394
+ const ZONE_FILL = {
2395
+ center: "M4.56 3.48H11.44A1.6 1.6 0 0 1 13.04 5.08V10.92A1.6 1.6 0 0 1 11.44 12.52H4.56A1.6 1.6 0 0 1 2.96 10.92V5.08A1.6 1.6 0 0 1 4.56 3.48Z",
2396
+ left: "M4 0.523H8V15.477H4A4 4 0 0 1 0 11.477V4.523A4 4 0 0 1 4 0.523Z",
2397
+ right: "M8 0.523H12A4 4 0 0 1 16 4.523V11.477A4 4 0 0 1 12 15.477H8Z",
2398
+ top: "M0 8V4.523A4 4 0 0 1 4 0.523H12A4 4 0 0 1 16 4.523V8Z",
2399
+ bottom: "M0 8H16V11.477A4 4 0 0 1 12 15.477H4A4 4 0 0 1 0 11.477Z"
2400
+ };
2401
+ /** The drop hint's glyph: the panel frame with the zone's fill drawn solid. */
2402
+ function ZoneGlyph({ zone }) {
2403
+ return jsxs("svg", {
2404
+ width: "16",
2405
+ height: "16",
2406
+ viewBox: "0 0 16 16",
2320
2407
  fill: "none",
2321
2408
  "aria-hidden": "true",
2322
- children: jsx("path", {
2323
- d: "M1.5 1.5l7 7M8.5 1.5l-7 7",
2324
- stroke: "currentColor",
2325
- strokeWidth: "1.2",
2326
- strokeLinecap: "round"
2409
+ children: [jsx("path", {
2410
+ fillRule: "evenodd",
2411
+ clipRule: "evenodd",
2412
+ d: PANEL_FRAME,
2413
+ fill: "currentColor"
2414
+ }), jsx("path", {
2415
+ d: ZONE_FILL[zone],
2416
+ fill: "currentColor"
2417
+ })]
2418
+ });
2419
+ }
2420
+ /**
2421
+ * One landing card, inset inside the region a release would fill: a dashed
2422
+ * frame, the zone's glyph, and its caption. `active` is the region under the
2423
+ * pointer; a sibling shown for orientation only draws quieter.
2424
+ */
2425
+ function DockHint({ zone, active, labels }) {
2426
+ return jsx("div", {
2427
+ className: css.dockHint,
2428
+ "data-dockkit-dock-zone": zone,
2429
+ "data-dockkit-drop-active": active || void 0,
2430
+ children: jsxs("div", {
2431
+ className: css.dockHintCard,
2432
+ children: [jsx(ZoneGlyph, { zone }), jsx("span", {
2433
+ className: css.dockHintLabel,
2434
+ children: labels.dropZone[zone]
2435
+ })]
2327
2436
  })
2328
2437
  });
2329
2438
  }
@@ -2348,10 +2457,80 @@ function chipToFocus(key, tabs, tabId) {
2348
2457
  function selects(key) {
2349
2458
  return key === "Enter" || key === " ";
2350
2459
  }
2351
- /** The split control's title: what it does, or why it cannot right now. */
2352
- function splitTitle(labels, block) {
2460
+ /**
2461
+ * Which sides of the chip box hold chips scrolled out of view, as the
2462
+ * `data-dockkit-strip-scroll` value the stylesheet fades: `undefined` while
2463
+ * every chip is in view.
2464
+ */
2465
+ function hiddenSides(box) {
2466
+ const start = box.scrollLeft > 1;
2467
+ const end = box.scrollLeft + box.clientWidth < box.scrollWidth - 1;
2468
+ if (start && end) return "start end";
2469
+ if (start) return "start";
2470
+ if (end) return "end";
2471
+ }
2472
+ /**
2473
+ * Keep the chip box's `data-dockkit-strip-scroll` current: read after each
2474
+ * commit that can change the chips, on scroll, and on resize. Written to the
2475
+ * DOM directly rather than through state because a reading never changes
2476
+ * what renders, only how the stylesheet fades it.
2477
+ *
2478
+ * Known gap: a content-width change that alters neither `tabs` nor the box's
2479
+ * outer size — a live `renderTabTitle` growing a chip, or a drop-caret slot
2480
+ * mounting mid-drag — keeps the fade at its last reading until the next
2481
+ * scroll or resize. The fade is orientation chrome, so a stale edge fades a
2482
+ * few frames late rather than hiding anything.
2483
+ */
2484
+ function useStripScrollFades(box, tabs) {
2485
+ useLayoutEffect(() => {
2486
+ const element = box.current;
2487
+ /* v8 ignore next -- the box is rendered unconditionally with the strip. */
2488
+ if (element === null) return void 0;
2489
+ const apply = () => {
2490
+ const sides = hiddenSides(element);
2491
+ if (sides === void 0) delete element.dataset.dockkitStripScroll;
2492
+ else element.dataset.dockkitStripScroll = sides;
2493
+ };
2494
+ apply();
2495
+ element.addEventListener("scroll", apply, { passive: true });
2496
+ const observer = typeof ResizeObserver === "undefined" ? void 0 : new ResizeObserver(apply);
2497
+ observer?.observe(element);
2498
+ return () => {
2499
+ element.removeEventListener("scroll", apply);
2500
+ observer?.disconnect();
2501
+ };
2502
+ }, [box, tabs]);
2503
+ }
2504
+ /**
2505
+ * Bring the active chip into the chip box's view whenever the active tab or
2506
+ * the row of chips changes: a tab opened or selected past the box's edge, or
2507
+ * moved there by a close or a reorder, scrolls the box to it, with the fade
2508
+ * band (24px) cleared so the chip is not under it. A chip already in view
2509
+ * moves nothing. Direct DOM, like the fades above: the box's scroll position
2510
+ * renders nothing.
2511
+ */
2512
+ function useActiveChipInView(box, chips, tabs, activeTabId) {
2513
+ useLayoutEffect(() => {
2514
+ const element = box.current;
2515
+ const chip = activeTabId === void 0 ? void 0 : chips.get(activeTabId);
2516
+ /* v8 ignore next -- the box and the active tab's chip are rendered with the strip. */
2517
+ if (element === null || chip === void 0) return;
2518
+ const bounds = element.getBoundingClientRect();
2519
+ const rect = chip.getBoundingClientRect();
2520
+ if (rect.left < bounds.left) element.scrollLeft += rect.left - bounds.left - STRIP_FADE;
2521
+ else if (rect.right > bounds.right) element.scrollLeft += rect.right - bounds.right + STRIP_FADE;
2522
+ }, [
2523
+ box,
2524
+ chips,
2525
+ tabs,
2526
+ activeTabId
2527
+ ]);
2528
+ }
2529
+ /** Width of the chip box's fade at a hidden side; mirrors the stylesheet's 24px. */
2530
+ const STRIP_FADE = 24;
2531
+ /** Why the split control cannot act right now. */
2532
+ function splitBlockedTitle(labels, block) {
2353
2533
  switch (block) {
2354
- case void 0: return labels.splitPane;
2355
2534
  case "budget": return labels.splitPaneDisabled;
2356
2535
  case "width": return labels.splitPaneNarrow;
2357
2536
  }
@@ -2360,6 +2539,9 @@ function splitTitle(labels, block) {
2360
2539
  function TabPanel({ state, pane, callbacks }) {
2361
2540
  const [menu, setMenu] = useState(void 0);
2362
2541
  const [chips] = useState(() => /* @__PURE__ */ new Map());
2542
+ const stripTabs = useRef(null);
2543
+ useStripScrollFades(stripTabs, pane.tabs);
2544
+ useActiveChipInView(stripTabs, chips, pane.tabs, pane.activeTabId);
2363
2545
  const active = pane.activeTabId === void 0 ? void 0 : getTab(state, pane.activeTabId);
2364
2546
  const block = callbacks.splitBlock(pane.id);
2365
2547
  const target = callbacks.dropTarget;
@@ -2390,21 +2572,25 @@ function TabPanel({ state, pane, callbacks }) {
2390
2572
  "data-dockkit-strip": pane.id,
2391
2573
  children: [
2392
2574
  jsxs("div", {
2575
+ ref: stripTabs,
2393
2576
  className: css.stripTabs,
2394
2577
  role: "presentation",
2395
2578
  "data-dockkit-strip-tabs": pane.id,
2396
2579
  children: [pane.tabs.map((tabId, index) => {
2397
2580
  const tab = getTab(state, tabId);
2398
2581
  const selected = tabId === pane.activeTabId;
2399
- return jsxs(Fragment$1, { children: [stripIndex === index && jsx("div", {
2400
- className: css.caret,
2401
- "data-dockkit-caret": index
2582
+ const closable = callbacks.canCloseTab(tabId);
2583
+ const quiet = !closable && pane.tabs.length === 1;
2584
+ return jsxs(Fragment$1, { children: [(index > 0 || stripIndex === index) && jsx("div", {
2585
+ className: clsx(css.slot, stripIndex === index && css.slotCaret),
2586
+ "data-dockkit-caret": stripIndex === index ? index : void 0
2402
2587
  }), jsxs("div", {
2403
2588
  role: "tab",
2404
2589
  "aria-selected": selected,
2405
2590
  tabIndex: selected ? 0 : -1,
2406
- className: clsx(css.tab, selected && css.tabActive, callbacks.draggingTabId === tabId && css.tabDragging),
2591
+ className: clsx(css.tab, selected && css.tabActive, quiet && css.tabQuiet, callbacks.draggingTabId === tabId && css.tabDragging),
2407
2592
  "data-dockkit-tab": tabId,
2593
+ "data-dockkit-tab-quiet": quiet || void 0,
2408
2594
  ref: (element) => {
2409
2595
  if (element === null) chips.delete(tabId);
2410
2596
  else chips.set(tabId, element);
@@ -2439,12 +2625,8 @@ function TabPanel({ state, pane, callbacks }) {
2439
2625
  });
2440
2626
  },
2441
2627
  children: [
2442
- jsx("span", {
2443
- className: css.tabTitle,
2444
- "data-dockkit-tab-title": true,
2445
- children: callbacks.renderTabTitle?.(tab) ?? tab.title
2446
- }),
2447
- jsx("button", {
2628
+ jsx(TabTitle, { children: callbacks.renderTabTitle?.(tab) ?? tab.title }),
2629
+ closable && jsx("button", {
2448
2630
  type: "button",
2449
2631
  className: css.tabClose,
2450
2632
  "aria-label": callbacks.labels.closeTab,
@@ -2456,15 +2638,15 @@ function TabPanel({ state, pane, callbacks }) {
2456
2638
  event.stopPropagation();
2457
2639
  callbacks.onCloseTab(tabId);
2458
2640
  },
2459
- children: jsx(CloseGlyph, {})
2641
+ children: jsx(IconCloseFill14, { size: 14 })
2460
2642
  }),
2461
2643
  menu?.tabId === tabId && jsx(TabMenu, {
2462
2644
  labels: callbacks.labels,
2463
2645
  anchor: menu.anchor,
2464
- onClose: () => {
2646
+ onClose: closable ? () => {
2465
2647
  setMenu(void 0);
2466
2648
  callbacks.onCloseTab(tabId);
2467
- },
2649
+ } : void 0,
2468
2650
  onDismiss: () => {
2469
2651
  setMenu(void 0);
2470
2652
  },
@@ -2475,39 +2657,49 @@ function TabPanel({ state, pane, callbacks }) {
2475
2657
  ]
2476
2658
  })] }, tabId);
2477
2659
  }), stripIndex === pane.tabs.length && jsx("div", {
2478
- className: css.caret,
2660
+ className: clsx(css.slot, css.slotCaret),
2479
2661
  "data-dockkit-caret": stripIndex
2480
2662
  })]
2481
2663
  }),
2482
- callbacks.canAddTab(pane.id) && jsx("button", {
2483
- type: "button",
2484
- className: css.addTab,
2485
- "aria-label": callbacks.labels.addTab,
2486
- title: callbacks.labels.addTab,
2487
- "data-dockkit-add-tab": pane.id,
2488
- onClick: (event) => {
2489
- event.stopPropagation();
2490
- callbacks.onAddTab(pane.id);
2491
- },
2492
- children: jsx(PlusGlyph, {})
2664
+ callbacks.canAddTab(pane.id) && jsx(Tooltip, {
2665
+ label: callbacks.labels.addTab,
2666
+ side: "bottom",
2667
+ delayMs: 500,
2668
+ children: jsx("button", {
2669
+ type: "button",
2670
+ className: css.addTab,
2671
+ "aria-label": callbacks.labels.addTab,
2672
+ "data-dockkit-add-tab": pane.id,
2673
+ onClick: (event) => {
2674
+ event.stopPropagation();
2675
+ callbacks.onAddTab(pane.id);
2676
+ },
2677
+ children: jsx(IconPlusOutline16, { size: 14 })
2678
+ })
2493
2679
  }),
2494
2680
  jsx("div", {
2495
2681
  className: css.stripFill,
2496
2682
  "data-dockkit-strip-fill": true
2497
2683
  }),
2498
- !(callbacks.hideSplitAtCapacity && block === "budget") && jsx("button", {
2499
- type: "button",
2500
- className: css.iconButton,
2501
- "aria-label": callbacks.labels.splitPane,
2502
- title: splitTitle(callbacks.labels, block),
2684
+ !(callbacks.hideSplitWhenBlocked && block !== void 0) && jsx(Tooltip, {
2685
+ label: callbacks.labels.splitPane,
2686
+ side: "bottom",
2687
+ delayMs: 500,
2503
2688
  disabled: block !== void 0,
2504
- "data-dockkit-split-button": pane.id,
2505
- "data-dockkit-split-blocked": block,
2506
- onClick: (event) => {
2507
- event.stopPropagation();
2508
- callbacks.onSplitPane(pane.id);
2509
- },
2510
- children: jsx(SplitGlyph, {})
2689
+ children: jsx("button", {
2690
+ type: "button",
2691
+ className: css.iconButton,
2692
+ "aria-label": callbacks.labels.splitPane,
2693
+ title: block === void 0 ? void 0 : splitBlockedTitle(callbacks.labels, block),
2694
+ disabled: block !== void 0,
2695
+ "data-dockkit-split-button": pane.id,
2696
+ "data-dockkit-split-blocked": block,
2697
+ onClick: (event) => {
2698
+ event.stopPropagation();
2699
+ callbacks.onSplitPane(pane.id);
2700
+ },
2701
+ children: jsx(SplitGlyph, {})
2702
+ })
2511
2703
  }),
2512
2704
  pane.id === callbacks.chromePaneId && callbacks.chrome !== void 0 && jsx("div", {
2513
2705
  className: css.stripChrome,
@@ -2523,18 +2715,22 @@ function TabPanel({ state, pane, callbacks }) {
2523
2715
  children: [active === void 0 ? jsx("p", {
2524
2716
  className: css.empty,
2525
2717
  children: callbacks.labels.emptyPane
2526
- }) : callbacks.renderTab(active), zone !== void 0 && (callbacks.horizontalDrops && zone !== "center" ? jsxs(Fragment, { children: [jsx("div", {
2527
- className: css.dockHint,
2528
- "data-dockkit-dock-zone": "left",
2529
- "data-dockkit-drop-active": zone === "left" || void 0
2530
- }), jsx("div", {
2531
- className: css.dockHint,
2532
- "data-dockkit-dock-zone": "right",
2533
- "data-dockkit-drop-active": zone === "right" || void 0
2534
- })] }) : jsx("div", {
2535
- className: css.dockHint,
2536
- "data-dockkit-dock-zone": zone
2537
- }))]
2718
+ }) : callbacks.renderTab(active), zone !== void 0 && jsxs(Fragment, { children: [jsx("div", {
2719
+ className: css.dockScrim,
2720
+ "data-dockkit-dock-scrim": true
2721
+ }), callbacks.horizontalDrops && zone !== "center" ? jsxs(Fragment, { children: [jsx(DockHint, {
2722
+ zone: "left",
2723
+ active: zone === "left",
2724
+ labels: callbacks.labels
2725
+ }), jsx(DockHint, {
2726
+ zone: "right",
2727
+ active: zone === "right",
2728
+ labels: callbacks.labels
2729
+ })] }) : jsx(DockHint, {
2730
+ zone,
2731
+ active: true,
2732
+ labels: callbacks.labels
2733
+ })] })]
2538
2734
  })]
2539
2735
  });
2540
2736
  }
@@ -2596,7 +2792,7 @@ const NO_PREVIEW = {
2596
2792
  };
2597
2793
  /** Nothing measured yet: every pane fits until a reading says otherwise. */
2598
2794
  const NO_FITS = /* @__PURE__ */ new Map();
2599
- /** The default add-control policy: every pane offers one. */
2795
+ /** The default policy for the omitted callbacks: every pane offers the add control, every tab its close. */
2600
2796
  const ALWAYS = () => true;
2601
2797
  /**
2602
2798
  * Resolve where a pointer sits inside the docked surface. An edge zone is only
@@ -2642,7 +2838,7 @@ function sameSizes(a, b) {
2642
2838
  });
2643
2839
  }
2644
2840
  /** The split tree and the gestures over it. */
2645
- function DockSurface({ state, canSplit, canAddTab, intents, labels, renderTab, renderTabTitle, renderTabMenuItems, chrome, onRoom, dropZones = "edges", minPaneFraction = MIN_PANE_FRACTION, hideSplitAtCapacity = false }) {
2841
+ function DockSurface({ state, canSplit, canAddTab, canCloseTab, intents, labels, renderTab, renderTabTitle, renderTabMenuItems, chrome, onRoom, dropZones = "edges", minPaneFraction = MIN_PANE_FRACTION, hideSplitWhenBlocked = false }) {
2646
2842
  const surface = useRef(null);
2647
2843
  const [preview, setPreview] = useState(NO_PREVIEW);
2648
2844
  const [fits, setFits] = useState(NO_FITS);
@@ -2658,10 +2854,10 @@ function DockSurface({ state, canSplit, canAddTab, intents, labels, renderTab, r
2658
2854
  }, []);
2659
2855
  const remeasure = useCallback(() => {
2660
2856
  withSurface((root) => {
2661
- const next = measurePaneFits(root);
2857
+ const next = measurePaneFits(root, hideSplitWhenBlocked);
2662
2858
  setFits((current) => sameFits(current, next) ? current : next);
2663
2859
  });
2664
- }, [withSurface]);
2860
+ }, [withSurface, hideSplitWhenBlocked]);
2665
2861
  useLayoutEffect(() => {
2666
2862
  remeasure();
2667
2863
  });
@@ -2753,8 +2949,9 @@ function DockSurface({ state, canSplit, canAddTab, intents, labels, renderTab, r
2753
2949
  });
2754
2950
  },
2755
2951
  splitBlock,
2756
- hideSplitAtCapacity,
2952
+ hideSplitWhenBlocked,
2757
2953
  canAddTab: canAddTab ?? ALWAYS,
2954
+ canCloseTab: canCloseTab ?? ALWAYS,
2758
2955
  dropTarget: preview.dropTarget,
2759
2956
  horizontalDrops: dropZones === "horizontal",
2760
2957
  draggingTabId: preview.draggingTabId,
@@ -2782,8 +2979,9 @@ function DockSurface({ state, canSplit, canAddTab, intents, labels, renderTab, r
2782
2979
  //#region lib/types/components/FloatLayer.js
2783
2980
  /**
2784
2981
  * The floating layer: one overlay panel per floating pane, bottom-to-top in the
2785
- * model's z order. A floating pane hosts exactly one tab and renders no tab
2786
- * strip — the panel *is* the tab. Pressing a panel's body raises it. Its grip
2982
+ * model's z order. A floating pane hosts exactly one tab; its header is the
2983
+ * strip's row holding that tab's chip, never selectable or closable from the
2984
+ * chip, and the send-back and close controls. Pressing a panel's body raises it. Its grip
2787
2985
  * and corner report through their gesture instead: a press released in place is
2788
2986
  * a click and raises the panel; a drag records the move or resize, and that
2789
2987
  * operation raises the panel itself, so one gesture is one intent. Raising a
@@ -2809,7 +3007,7 @@ function raised(state, paneId) {
2809
3007
  return state.activePaneId === paneId && state.floats.at(-1) === paneId;
2810
3008
  }
2811
3009
  /** Every floating panel, in z order. */
2812
- function FloatLayer({ state, intents, labels, renderTab, renderTabTitle }) {
3010
+ function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, canCloseTab }) {
2813
3011
  const [preview, setPreview] = useState(void 0);
2814
3012
  const begin = useGesture(() => {
2815
3013
  setPreview(void 0);
@@ -2864,42 +3062,53 @@ function FloatLayer({ state, intents, labels, renderTab, renderTabTitle }) {
2864
3062
  },
2865
3063
  children: [
2866
3064
  jsxs("header", {
2867
- className: css.floatHeader,
3065
+ className: clsx(css.tabStrip, css.floatHeader),
2868
3066
  "data-dockkit-float-grip": paneId,
2869
3067
  onPointerDown: (event) => {
2870
3068
  drag("move", paneId, event);
2871
3069
  },
2872
3070
  children: [
2873
- jsx("span", {
2874
- className: css.floatTitle,
3071
+ jsx("div", {
3072
+ className: clsx(css.tab, css.floatTitle),
2875
3073
  "data-dockkit-float-title": true,
2876
- children: renderTabTitle?.(tab) ?? tab.title
3074
+ children: jsx(TabTitle, { children: renderTabTitle?.(tab) ?? tab.title })
2877
3075
  }),
2878
- jsx("button", {
2879
- type: "button",
2880
- className: css.iconButton,
2881
- "aria-label": labels.dockFloat,
2882
- "data-dockkit-float-dock": paneId,
2883
- onPointerDown: (event) => {
2884
- event.stopPropagation();
2885
- },
2886
- onClick: () => {
2887
- intents.unfloatPane(paneId);
2888
- },
2889
- children: "⇤"
3076
+ jsx("div", { className: css.stripFill }),
3077
+ jsx(Tooltip, {
3078
+ label: labels.dockFloat,
3079
+ side: "bottom",
3080
+ delayMs: 500,
3081
+ children: jsx("button", {
3082
+ type: "button",
3083
+ className: css.iconButton,
3084
+ "aria-label": labels.dockFloat,
3085
+ "data-dockkit-float-dock": paneId,
3086
+ onPointerDown: (event) => {
3087
+ event.stopPropagation();
3088
+ },
3089
+ onClick: () => {
3090
+ intents.unfloatPane(paneId);
3091
+ },
3092
+ children: jsx(IconPanelLeftOutline16, { className: css.dockGlyph })
3093
+ })
2890
3094
  }),
2891
- jsx("button", {
2892
- type: "button",
2893
- className: css.iconButton,
2894
- "aria-label": labels.closeFloat,
2895
- "data-dockkit-float-close": paneId,
2896
- onPointerDown: (event) => {
2897
- event.stopPropagation();
2898
- },
2899
- onClick: () => {
2900
- intents.closeTab(tab.id);
2901
- },
2902
- children: "✕"
3095
+ (canCloseTab?.(tab.id) ?? true) && jsx(Tooltip, {
3096
+ label: labels.closeFloat,
3097
+ side: "bottom",
3098
+ delayMs: 500,
3099
+ children: jsx("button", {
3100
+ type: "button",
3101
+ className: css.iconButton,
3102
+ "aria-label": labels.closeFloat,
3103
+ "data-dockkit-float-close": paneId,
3104
+ onPointerDown: (event) => {
3105
+ event.stopPropagation();
3106
+ },
3107
+ onClick: () => {
3108
+ intents.closeTab(tab.id);
3109
+ },
3110
+ children: jsx(IconCloseOutline16, {})
3111
+ })
2903
3112
  })
2904
3113
  ]
2905
3114
  }),