@dashfoo/react 0.7.0 → 0.8.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/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { canRedo, canUndo, dashfooMachine, decideSnap, dockLocationFor, dragDockMachine, findTabset, fromJSON, normalize, resolveDockTarget, settleSnap, snapEnabled, splitEdge, stackModel, tabNodeSchema, toJSON, zoneRect } from "@dashfoo/core";
3
+ import { canRedo, canUndo, dashfooMachine, decideSnap, dragDockMachine, findTabset, fromJSON, normalize, reducer, resolveDockTarget, resolveSnapGrid, settleSnap, snapEnabled, splitEdge, tabNodeSchema, toJSON, zoneRect } from "@dashfoo/core";
4
4
  import { Fragment, createContext, createElement, forwardRef, useCallback, useContext, useEffect, useId, useImperativeHandle, useInsertionEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
5
5
  import { Accessibility, DragDropManager, Draggable, Droppable, Feedback, PointerSensor } from "@dnd-kit/dom";
6
6
  import { useActorRef, useSelector } from "@xstate/react";
@@ -66,11 +66,7 @@ const createDragManager = () => new DragDropManager({
66
66
  const DragRootContext = createContext(null);
67
67
  const DragContext = createContext(null);
68
68
  const SharedDragManagerContext = createContext(null);
69
- /**
70
- * The actor assigns a freshly built intent on every pointer move, so selecting
71
- * `context.intent` by reference would re-render every reader of `useDropIntent`
72
- * for each move that resolved to the same slot.
73
- */
69
+ /** The intent is rebuilt every pointer move, so readers compare by value. */
74
70
  const sameDropIntent = (a, b) => a === b || a !== null && b !== null && a.index === b.index && a.location === b.location && a.targetId === b.targetId;
75
71
  const useDraggableHandle = () => {
76
72
  const elementRef = useRef(null);
@@ -182,7 +178,7 @@ const useExternalTabSource = ({ createTab, disabled = false, label = "" }) => {
182
178
  const createTabRef = useRef(createTab);
183
179
  useEffect(() => {
184
180
  createTabRef.current = createTab;
185
- });
181
+ }, [createTab]);
186
182
  const data = useMemo(() => ({
187
183
  createTab: () => createTabRef.current(),
188
184
  type: "external"
@@ -193,19 +189,23 @@ const useExternalTabSource = ({ createTab, disabled = false, label = "" }) => {
193
189
  }, disabled, label);
194
190
  };
195
191
  /**
196
- * Both drag readers are documented as working anywhere under the provider, which
197
- * includes a custom overlay mounted beside the layout rather than inside a drag
198
- * layer, so they read the root's actor. Outside a provider entirely there is no
199
- * actor to read and they report the empty state instead of throwing: a component
200
- * that renders both inside and outside a layout should not crash in one of them.
192
+ * Reads the root's actor, so it works from an overlay mounted beside the layout
193
+ * rather than inside a drag layer. Outside a provider it reports the empty state
194
+ * instead of throwing.
201
195
  */
202
196
  const useDragSubject = () => {
203
197
  const actorRef = useContext(DragRootContext)?.actorRef;
204
- return useSelector(actorRef, (snapshot) => snapshot?.context.subject ?? null);
198
+ return useSelector(actorRef, (snapshot) => {
199
+ const drag = snapshot?.context.drag;
200
+ return drag?.kind === "dragging" ? drag.subject : null;
201
+ });
205
202
  };
206
203
  const useDropIntent = () => {
207
204
  const actorRef = useContext(DragRootContext)?.actorRef;
208
- return useSelector(actorRef, (snapshot) => snapshot?.context.intent ?? null, sameDropIntent);
205
+ return useSelector(actorRef, (snapshot) => {
206
+ const drag = snapshot?.context.drag;
207
+ return drag?.kind === "dragging" ? drag.drop?.intent ?? null : null;
208
+ }, sameDropIntent);
209
209
  };
210
210
 
211
211
  //#endregion
@@ -227,7 +227,8 @@ const localStorageAdapter = {
227
227
  if (typeof window === "undefined") return null;
228
228
  try {
229
229
  return window.localStorage.getItem(key);
230
- } catch {
230
+ } catch (error) {
231
+ console.warn("[dashfoo] failed to load persisted layout", error);
231
232
  return null;
232
233
  }
233
234
  },
@@ -235,7 +236,9 @@ const localStorageAdapter = {
235
236
  if (typeof window === "undefined") return;
236
237
  try {
237
238
  window.localStorage.removeItem(key);
238
- } catch {}
239
+ } catch (error) {
240
+ console.warn("[dashfoo] failed to clear persisted layout", error);
241
+ }
239
242
  },
240
243
  setItem: (key, value) => {
241
244
  if (typeof window === "undefined") return;
@@ -246,33 +249,39 @@ const localStorageAdapter = {
246
249
  }
247
250
  }
248
251
  };
252
+ const removeStoredLayout = ({ key, storage }) => {
253
+ try {
254
+ storage.removeItem(key);
255
+ } catch (error) {
256
+ console.warn("[dashfoo] failed to clear persisted layout", error);
257
+ }
258
+ };
249
259
  const usePersistence = (config, defaultModel) => {
250
260
  const configRef = useRef(config);
251
261
  useEffect(() => {
252
262
  configRef.current = config;
253
- });
254
- const [initialModel] = useState(() => {
255
- if (config === null || defaultModel === void 0) return defaultModel;
256
- const raw = config.storage.getItem(config.key);
257
- if (raw === null) return defaultModel;
258
- try {
259
- return fromJSON(raw);
260
- } catch {
261
- return defaultModel;
262
- }
263
- });
263
+ }, [config]);
264
+ const [initialModel, setInitialModel] = useState(defaultModel);
265
+ const loaded = useRef(false);
264
266
  useEffect(() => {
267
+ if (loaded.current) return;
268
+ loaded.current = true;
265
269
  const current = configRef.current;
266
- if (current === null) return;
267
- const raw = current.storage.getItem(current.key);
268
- if (raw === null) return;
270
+ if (current === null || defaultModel === void 0) return;
269
271
  try {
270
- fromJSON(raw);
272
+ const raw = current.storage.getItem(current.key);
273
+ if (raw === null) return;
274
+ try {
275
+ const restored = fromJSON(raw);
276
+ setInitialModel(restored);
277
+ } catch (error) {
278
+ console.warn("[dashfoo] discarding unreadable persisted layout", error);
279
+ removeStoredLayout(current);
280
+ }
271
281
  } catch (error) {
272
- console.warn("[dashfoo] discarding unreadable persisted layout", error);
273
- current.storage.removeItem(current.key);
282
+ console.warn("[dashfoo] failed to load persisted layout", error);
274
283
  }
275
- }, []);
284
+ }, [defaultModel]);
276
285
  const loadedKey = useRef(config?.key);
277
286
  useEffect(() => {
278
287
  if (config !== null && config.key !== loadedKey.current) {
@@ -288,8 +297,13 @@ const usePersistence = (config, defaultModel) => {
288
297
  timer.current = null;
289
298
  }
290
299
  if (pending.current !== null) {
291
- pending.current.storage.setItem(pending.current.key, pending.current.value);
300
+ const write = pending.current;
292
301
  pending.current = null;
302
+ try {
303
+ write.storage.setItem(write.key, write.value);
304
+ } catch (error) {
305
+ console.warn("[dashfoo] failed to persist layout", error);
306
+ }
293
307
  }
294
308
  }, []);
295
309
  const save = useCallback((next) => {
@@ -325,7 +339,7 @@ const usePersistence = (config, defaultModel) => {
325
339
  }
326
340
  pending.current = null;
327
341
  const current = configRef.current;
328
- if (current !== null) current.storage.removeItem(current.key);
342
+ if (current !== null) removeStoredLayout(current);
329
343
  }, []);
330
344
  return useMemo(() => ({
331
345
  clear,
@@ -352,23 +366,30 @@ const activeBreakpoint = (breakpoints, width) => {
352
366
  if (!fallback) throw new Error("useResponsiveModel requires at least one breakpoint.");
353
367
  return breakpoints.find((breakpoint) => matchBreakpoint(breakpoint, width)) ?? fallback;
354
368
  };
369
+ const observeWidth = (element, onWidth) => {
370
+ const observer = new ResizeObserver((entries) => {
371
+ const entry = entries.at(0);
372
+ if (entry) onWidth(entry.contentRect.width);
373
+ });
374
+ observer.observe(element);
375
+ return () => {
376
+ observer.disconnect();
377
+ };
378
+ };
379
+ const listenToMedia = (list, handleChange) => {
380
+ list.addEventListener("change", handleChange);
381
+ return () => {
382
+ list.removeEventListener("change", handleChange);
383
+ };
384
+ };
355
385
  const useContainerWidth = () => {
356
386
  const [width, setWidth] = useState(Number.POSITIVE_INFINITY);
357
- const observerRef = useRef(null);
358
- const containerRef = useCallback((element) => {
359
- observerRef.current?.disconnect();
360
- if (!element) return;
361
- const observer = new ResizeObserver((entries) => {
362
- const entry = entries.at(0);
363
- if (entry) setWidth(entry.contentRect.width);
364
- });
365
- observer.observe(element);
366
- observerRef.current = observer;
367
- }, []);
368
- useEffect(() => () => {
369
- observerRef.current?.disconnect();
370
- }, []);
371
- return [containerRef, width];
387
+ const [container, setContainer] = useState(null);
388
+ useEffect(() => {
389
+ if (!container) return;
390
+ return observeWidth(container, setWidth);
391
+ }, [container]);
392
+ return [setContainer, width];
372
393
  };
373
394
  const useResponsiveModel = ({ breakpoints }) => {
374
395
  const [containerRef, width] = useContainerWidth();
@@ -380,9 +401,9 @@ const useResponsiveModel = ({ breakpoints }) => {
380
401
  const handleChange = () => {
381
402
  setMediaTick((tick) => tick + 1);
382
403
  };
383
- for (const list of lists) list.addEventListener("change", handleChange);
404
+ const cleanups = lists.map((list) => listenToMedia(list, handleChange));
384
405
  return () => {
385
- for (const list of lists) list.removeEventListener("change", handleChange);
406
+ for (const cleanup of cleanups) cleanup();
386
407
  };
387
408
  }, [breakpoints]);
388
409
  const active = activeBreakpoint(breakpoints, width);
@@ -412,19 +433,21 @@ const warnOnce = (key, message) => {
412
433
  //#endregion
413
434
  //#region src/hooks/store.ts
414
435
  const CONTROLLED_HISTORY_MESSAGE = "undo/redo are unavailable while `model` is controlled; keep the history in the state you own and drive it through onModelChange";
436
+ const CONTROLLED_SET_MODEL_MESSAGE = "setModel (and resetLayout) do nothing while `model` is controlled; set the model you own instead";
437
+ const CONTROLLED_MODE_SWITCH_MESSAGE = "`model` was added or removed after mount; a layout is controlled or uncontrolled for its whole life, so the first render wins. Remount (key=…) to switch";
438
+ const CONTROLLED_WITH_DEFAULT_MESSAGE = "`model` and `defaultModel` were both passed; `model` wins and `defaultModel` (and `persist`) are ignored";
439
+ /** The mode is latched on the first render; running both sources leaves the
440
+ * unread one describing a document nobody rendered. */
415
441
  const useDashfooStore = (options) => {
416
442
  const { defaultModel, model: controlledModel, onAction, onActiveTabsetChange, onMaximizedTabsetChange, onModelChange } = options;
417
443
  const initialModel = controlledModel ?? defaultModel;
418
444
  if (initialModel === void 0) throw new Error("useDashfooStore requires either a `model` or a `defaultModel`.");
445
+ const [controlled] = useState(() => controlledModel !== void 0);
446
+ if (controlled !== (controlledModel !== void 0)) warnOnce("controlled-mode-switch", CONTROLLED_MODE_SWITCH_MESSAGE);
447
+ if (controlled && defaultModel !== void 0) warnOnce("controlled-with-default", CONTROLLED_WITH_DEFAULT_MESSAGE);
419
448
  const actorRef = useActorRef(dashfooMachine, { input: { model: normalize(initialModel) } });
420
449
  const history = useSelector(actorRef, (snapshot) => snapshot.context.history);
421
- useEffect(() => {
422
- if (controlledModel !== void 0) actorRef.send({
423
- model: normalize(controlledModel),
424
- type: "SET_MODEL"
425
- });
426
- }, [actorRef, controlledModel]);
427
- const model = controlledModel ?? history.present;
450
+ const model = useMemo(() => controlledModel === void 0 ? history.present : normalize(controlledModel), [controlledModel, history.present]);
428
451
  const notify = useCallback((before, after, action) => {
429
452
  if (Object.is(before, after)) return;
430
453
  onModelChange?.(after, action);
@@ -438,6 +461,10 @@ const useDashfooStore = (options) => {
438
461
  const dispatch = useCallback((action) => {
439
462
  const resolved = onAction ? onAction(action) : action;
440
463
  if (!resolved) return;
464
+ if (controlled) {
465
+ notify(model, reducer(model, resolved), resolved);
466
+ return;
467
+ }
441
468
  const before = actorRef.getSnapshot().context.history.present;
442
469
  actorRef.send({
443
470
  action: resolved,
@@ -446,46 +473,50 @@ const useDashfooStore = (options) => {
446
473
  notify(before, actorRef.getSnapshot().context.history.present, resolved);
447
474
  }, [
448
475
  actorRef,
476
+ controlled,
477
+ model,
449
478
  notify,
450
479
  onAction
451
480
  ]);
452
481
  const undo = useCallback(() => {
453
- if (controlledModel !== void 0) {
482
+ if (controlled) {
454
483
  warnOnce("controlled-undo", CONTROLLED_HISTORY_MESSAGE);
455
484
  return;
456
485
  }
457
486
  const before = actorRef.getSnapshot().context.history.present;
458
487
  actorRef.send({ type: "UNDO" });
459
- const after = actorRef.getSnapshot().context.history.present;
460
- notify(before, after);
488
+ notify(before, actorRef.getSnapshot().context.history.present);
461
489
  }, [
462
490
  actorRef,
463
- controlledModel,
491
+ controlled,
464
492
  notify
465
493
  ]);
466
494
  const redo = useCallback(() => {
467
- if (controlledModel !== void 0) {
495
+ if (controlled) {
468
496
  warnOnce("controlled-redo", CONTROLLED_HISTORY_MESSAGE);
469
497
  return;
470
498
  }
471
499
  const before = actorRef.getSnapshot().context.history.present;
472
500
  actorRef.send({ type: "REDO" });
473
- const after = actorRef.getSnapshot().context.history.present;
474
- notify(before, after);
501
+ notify(before, actorRef.getSnapshot().context.history.present);
475
502
  }, [
476
503
  actorRef,
477
- controlledModel,
504
+ controlled,
478
505
  notify
479
506
  ]);
480
507
  const setModel = useCallback((next) => {
508
+ if (controlled) {
509
+ warnOnce("controlled-set-model", CONTROLLED_SET_MODEL_MESSAGE);
510
+ return;
511
+ }
481
512
  actorRef.send({
482
513
  model: normalize(next),
483
514
  type: "SET_MODEL"
484
515
  });
485
- }, [actorRef]);
516
+ }, [actorRef, controlled]);
486
517
  return useMemo(() => ({
487
- canRedo: () => controlledModel === void 0 && canRedo(actorRef.getSnapshot().context.history),
488
- canUndo: () => controlledModel === void 0 && canUndo(actorRef.getSnapshot().context.history),
518
+ canRedo: () => !controlled && canRedo(actorRef.getSnapshot().context.history),
519
+ canUndo: () => !controlled && canUndo(actorRef.getSnapshot().context.history),
489
520
  dispatch,
490
521
  model,
491
522
  redo,
@@ -493,7 +524,7 @@ const useDashfooStore = (options) => {
493
524
  undo
494
525
  }), [
495
526
  actorRef,
496
- controlledModel,
527
+ controlled,
497
528
  dispatch,
498
529
  model,
499
530
  redo,
@@ -502,21 +533,38 @@ const useDashfooStore = (options) => {
502
533
  ]);
503
534
  };
504
535
 
536
+ //#endregion
537
+ //#region src/lib/compact-model.ts
538
+ const compactRow = (node, orientation) => {
539
+ const children = node.children.map((child) => child.type === "row" ? compactRow(child, orientation) : {
540
+ ...child,
541
+ weight: 1
542
+ });
543
+ return {
544
+ ...node,
545
+ children,
546
+ orientation,
547
+ weight: children.reduce((sum, child) => sum + child.weight, 0)
548
+ };
549
+ };
550
+ const compactModel = (model, orientation = "column") => ({
551
+ ...model,
552
+ layout: compactRow(model.layout, orientation),
553
+ maximizedTabsetId: void 0
554
+ });
555
+
505
556
  //#endregion
506
557
  //#region src/lib/drag-subject.ts
507
558
  const isTabFactory = (value) => typeof value === "function";
508
- /**
509
- * Reads what a dnd-kit source represents. It lives beside the manager wiring
510
- * rather than in the layer adapter because `dragstart` fires once per gesture on
511
- * the shared manager, above any one layer.
512
- */
559
+ /** Beside the manager wiring, not the layer adapter: `dragstart` fires once per
560
+ * gesture on the shared manager, above any one layer. */
513
561
  const subjectFor = (source) => {
514
562
  const data = source.data;
515
- if (data?.type === "tabset") return {
563
+ if (data.type === "tabset") return {
516
564
  id: String(data.tabsetId),
517
565
  kind: "tabset"
518
566
  };
519
- if (data?.type === "external") {
567
+ if (data.type === "external") {
520
568
  if (!isTabFactory(data.createTab)) {
521
569
  console.warn("[dashfoo] external drag source is missing its createTab function");
522
570
  return null;
@@ -571,7 +619,7 @@ const shouldAllowDrop = (draggedId, targetId, tabIds) => {
571
619
  };
572
620
 
573
621
  //#endregion
574
- //#region src/components/drag-overlays.tsx
622
+ //#region src/components/drag-preview-overlay.tsx
575
623
  const overlayBase = {
576
624
  boxSizing: "border-box",
577
625
  pointerEvents: "none",
@@ -595,9 +643,10 @@ const lineStyle = (zone) => ({
595
643
  });
596
644
  const tabItemRects = (strip, excludeId) => [...strip.querySelectorAll("[data-dashfoo=\"tab-item\"]")].flatMap((item) => item.querySelector("[data-dashfoo=\"tab\"]")?.dataset.tabId === excludeId ? [] : [item.getBoundingClientRect()]);
597
645
  const DockIndicator = ({ actorRef, getTabsetElement }) => {
598
- const intent = useSelector(actorRef, (snapshot) => snapshot.context.intent);
599
- const draggedId = useSelector(actorRef, (snapshot) => snapshot.context.subject?.id);
600
- if (!intent) return null;
646
+ const drag = useSelector(actorRef, (snapshot) => snapshot.context.drag);
647
+ if (drag.kind !== "dragging" || drag.drop === null) return null;
648
+ const { intent } = drag.drop;
649
+ const draggedId = drag.subject.id;
601
650
  const element = getTabsetElement(intent.targetId);
602
651
  if (!element) return null;
603
652
  const zIndex = element.closest("[data-dashfoo=\"float\"]") ? overlayBase.zIndex : "auto";
@@ -628,13 +677,13 @@ const PREVIEW_OFFSET = {
628
677
  y: 8
629
678
  };
630
679
  const labelOf = (source) => {
631
- const raw = source?.data?.label;
632
- return typeof raw === "string" ? raw : "";
680
+ if (typeof source?.data.label !== "string") return "";
681
+ return source.data.label;
633
682
  };
634
683
  const DragPreviewOverlay = ({ manager }) => {
635
684
  const [chip, setChip] = useState(null);
636
685
  useEffect(() => {
637
- const offStart = manager.monitor.addEventListener("dragstart", (event) => {
686
+ const handleStart = (event) => {
638
687
  const source = event.operation.source;
639
688
  if (!source) return;
640
689
  const rect = source.element?.getBoundingClientRect();
@@ -644,21 +693,24 @@ const DragPreviewOverlay = ({ manager }) => {
644
693
  x: PREVIEW_OFFSET.x + (rect ? point.x - rect.left : 0),
645
694
  y: PREVIEW_OFFSET.y + (rect ? point.y - rect.top : 0)
646
695
  });
647
- });
648
- const offEnd = manager.monitor.addEventListener("dragend", () => {
696
+ };
697
+ const handleEnd = () => {
649
698
  setChip(null);
650
- });
699
+ };
700
+ manager.monitor.addEventListener("dragstart", handleStart);
701
+ manager.monitor.addEventListener("dragend", handleEnd);
651
702
  return () => {
652
- offStart();
653
- offEnd();
703
+ manager.monitor.removeEventListener("dragstart", handleStart);
704
+ manager.monitor.removeEventListener("dragend", handleEnd);
654
705
  };
655
706
  }, [manager]);
707
+ const attachOverlay = useCallback((element) => {
708
+ const feedback = manager.registry.plugins.get(Feedback);
709
+ if (feedback) feedback.overlay = element ?? void 0;
710
+ }, [manager]);
656
711
  return /* @__PURE__ */ jsx("div", {
657
712
  "data-dnd-overlay": "",
658
- ref: useCallback((element) => {
659
- const feedback = manager.registry.plugins.get(Feedback);
660
- if (feedback) feedback.overlay = element ?? void 0;
661
- }, [manager]),
713
+ ref: attachOverlay,
662
714
  children: chip === null ? null : /* @__PURE__ */ jsx("div", {
663
715
  "data-dashfoo": "drag-preview",
664
716
  style: {
@@ -674,7 +726,8 @@ const DragPreviewOverlay = ({ manager }) => {
674
726
  };
675
727
 
676
728
  //#endregion
677
- //#region src/components/drag-root.tsx
729
+ //#region src/components/dashfoo-drag-provider.tsx
730
+ const isScopedTargetData = (data) => typeof data.layerId === "string" && typeof data.tabsetId === "string";
678
731
  const DashfooDragProvider = ({ children }) => {
679
732
  const [manager] = useState(createDragManager);
680
733
  useInsertionEffect(() => () => {
@@ -688,33 +741,29 @@ const DashfooDragProvider = ({ children }) => {
688
741
  scopes.delete(scope.layerId);
689
742
  };
690
743
  }, [scopes]);
691
- /**
692
- * One listener set and one actor for the whole tree. Attached per layer, a
693
- * single pointer move fanned out to every float's actor, and each one that did
694
- * not own the target resolved `intent: null` and then swallowed the drop.
695
- */
696
744
  useEffect(() => {
697
745
  const scopedTarget = () => {
698
746
  const target = manager.dragOperation.target;
699
747
  const data = target?.data;
700
- const scope = data?.layerId === void 0 ? void 0 : scopes.get(data.layerId);
748
+ const scope = data && isScopedTargetData(data) ? scopes.get(data.layerId) : void 0;
701
749
  const element = target?.element;
702
- if (!scope || data?.tabsetId === void 0 || !(element instanceof HTMLElement)) return null;
750
+ if (!scope || !data || !isScopedTargetData(data) || !(element instanceof HTMLElement)) return null;
703
751
  return {
704
752
  element,
705
753
  scope,
706
754
  tabsetId: data.tabsetId
707
755
  };
708
756
  };
709
- let intentLayerId = null;
710
757
  const syncIntent = () => {
711
758
  const operation = manager.dragOperation;
712
759
  const found = scopedTarget();
713
760
  const draggedId = operation.source ? String(operation.source.id) : void 0;
714
761
  const intent = found === null ? null : found.scope.resolveIntent(found.tabsetId, found.element, operation.position.current, draggedId);
715
- intentLayerId = intent === null || found === null ? null : found.scope.layerId;
716
762
  actorRef.send({
717
- intent,
763
+ drop: intent === null || found === null ? null : {
764
+ intent,
765
+ scope: found.scope.layerId
766
+ },
718
767
  type: "OVER"
719
768
  });
720
769
  };
@@ -726,19 +775,27 @@ const DashfooDragProvider = ({ children }) => {
726
775
  manager.actions.stop({ canceled: true });
727
776
  return;
728
777
  }
729
- intentLayerId = null;
730
778
  actorRef.send({
731
779
  subject,
732
780
  type: "START"
733
781
  });
734
782
  };
735
- const handleMove = () => {
736
- syncIntent();
783
+ let frame = null;
784
+ const scheduleResolve = () => {
785
+ if (frame !== null) return;
786
+ frame = requestAnimationFrame(() => {
787
+ frame = null;
788
+ syncIntent();
789
+ });
737
790
  };
738
- const handleCollision = () => {
739
- syncIntent();
791
+ const cancelPendingResolve = () => {
792
+ if (frame !== null) {
793
+ cancelAnimationFrame(frame);
794
+ frame = null;
795
+ }
740
796
  };
741
797
  const handleEnd = (event) => {
798
+ cancelPendingResolve();
742
799
  if (event.canceled) {
743
800
  actorRef.send({ type: "CANCEL" });
744
801
  return;
@@ -747,18 +804,19 @@ const DashfooDragProvider = ({ children }) => {
747
804
  actorRef.send({ type: "DROP" });
748
805
  };
749
806
  const commitSubscription = actorRef.on("COMMIT", (emitted) => {
750
- (intentLayerId === null ? void 0 : scopes.get(intentLayerId))?.commit(emitted.action);
807
+ scopes.get(emitted.scope)?.commit(emitted.action);
751
808
  });
752
- const offStart = manager.monitor.addEventListener("dragstart", handleStart);
753
- const offMove = manager.monitor.addEventListener("dragmove", handleMove);
754
- const offCollision = manager.monitor.addEventListener("collision", handleCollision);
755
- const offEnd = manager.monitor.addEventListener("dragend", handleEnd);
809
+ manager.monitor.addEventListener("dragstart", handleStart);
810
+ manager.monitor.addEventListener("dragmove", scheduleResolve);
811
+ manager.monitor.addEventListener("collision", scheduleResolve);
812
+ manager.monitor.addEventListener("dragend", handleEnd);
756
813
  return () => {
814
+ cancelPendingResolve();
757
815
  commitSubscription.unsubscribe();
758
- offStart();
759
- offMove();
760
- offCollision();
761
- offEnd();
816
+ manager.monitor.removeEventListener("dragstart", handleStart);
817
+ manager.monitor.removeEventListener("dragmove", scheduleResolve);
818
+ manager.monitor.removeEventListener("collision", scheduleResolve);
819
+ manager.monitor.removeEventListener("dragend", handleEnd);
762
820
  };
763
821
  }, [
764
822
  actorRef,
@@ -794,19 +852,34 @@ const useLayout = (selector) => {
794
852
  };
795
853
 
796
854
  //#endregion
797
- //#region src/components/drag-adapter.tsx
855
+ //#region src/components/drag-provider.tsx
798
856
  const MISSING_ROOT = "[dashfoo] DashfooDragProvider unmounted while its layout is still mounted; keep the provider above the layout or remount the layout";
799
- const tabRects = (strip, excludeId) => [...strip.querySelectorAll("[data-dashfoo=\"tab\"]")].flatMap((tab) => tab.dataset.tabId === excludeId ? [] : [tab.getBoundingClientRect()]);
800
- const intentForTabset = (id, element, point, draggedId) => {
857
+ /** One DOM pass per pointer move, where resolving a drop used to run three. */
858
+ const measureStrip = (element, excludeId) => {
801
859
  const strip = element.querySelector("[data-dashfoo=\"tabstrip\"]");
802
- if (strip && pointInRect(point, strip.getBoundingClientRect())) return {
803
- index: insertionIndex(tabRects(strip, draggedId), point.x),
860
+ if (!strip) return null;
861
+ const tabIds = [];
862
+ const tabRects = [];
863
+ for (const tab of strip.querySelectorAll("[data-dashfoo=\"tab\"]")) {
864
+ const tabId = tab.dataset.tabId ?? "";
865
+ tabIds.push(tabId);
866
+ if (tabId !== excludeId) tabRects.push(tab.getBoundingClientRect());
867
+ }
868
+ return {
869
+ rect: strip.getBoundingClientRect(),
870
+ tabIds,
871
+ tabRects
872
+ };
873
+ };
874
+ const intentForTabset = (id, element, point, strip) => {
875
+ if (strip && pointInRect(point, strip.rect)) return {
876
+ index: insertionIndex(strip.tabRects, point.x),
804
877
  location: "center",
805
878
  targetId: id
806
879
  };
807
- const location = dockLocationFor(resolveDockTarget(point, element.getBoundingClientRect()));
880
+ const location = resolveDockTarget(point, element.getBoundingClientRect());
808
881
  if (location === "center" && strip) return {
809
- index: tabRects(strip, draggedId).length,
882
+ index: strip.tabRects.length,
810
883
  location,
811
884
  targetId: id
812
885
  };
@@ -815,11 +888,7 @@ const intentForTabset = (id, element, point, draggedId) => {
815
888
  targetId: id
816
889
  };
817
890
  };
818
- /**
819
- * One drag layer: the main tree, or one float. It holds no drag state of its
820
- * own. It registers how its own tabsets turn a pointer into an intent and where
821
- * a committed action goes, then draws the indicator over its own droppables.
822
- */
891
+ /** One drag layer (the main tree, or one float). Holds no drag state of its own. */
823
892
  const DragScope = ({ children, onCommit, splitDock }) => {
824
893
  const root = useContext(DragRootContext);
825
894
  const layoutStore = useContext(LayoutStoreContext);
@@ -842,8 +911,9 @@ const DragScope = ({ children, onCommit, splitDock }) => {
842
911
  layerId,
843
912
  resolveIntent: (targetId, element, point, draggedId) => {
844
913
  if (layoutStore?.getState().editable === false) return null;
845
- if (!shouldAllowDrop(draggedId, targetId, [...element.querySelectorAll("[data-dashfoo=\"tab\"]")].map((tab) => tab.dataset.tabId ?? ""))) return null;
846
- const intent = intentForTabset(targetId, element, point, draggedId);
914
+ const strip = measureStrip(element, draggedId);
915
+ if (!shouldAllowDrop(draggedId, targetId, strip?.tabIds ?? [])) return null;
916
+ const intent = intentForTabset(targetId, element, point, strip);
847
917
  if (!(splitDock ?? layoutStore?.getState().splitDock ?? true) && splitEdge(intent.location) !== void 0) return {
848
918
  location: "center",
849
919
  targetId
@@ -873,10 +943,8 @@ const DragScope = ({ children, onCommit, splitDock }) => {
873
943
  });
874
944
  };
875
945
  /**
876
- * Mounts its own root when there is none above it, so a hand-composed
877
- * `Layout.DragLayer` still works standalone. The choice is made on the first
878
- * render, as it was for the manager this used to create itself: a root that
879
- * disappears later means a torn-down tree, which `DragScope` reports.
946
+ * Mounts its own root when there is none above it. Decided on the first render:
947
+ * a root that disappears later means a torn-down tree, which `DragScope` reports.
880
948
  */
881
949
  const DragProvider = (props) => {
882
950
  const hasRoot = useContext(DragRootContext) !== null;
@@ -1062,16 +1130,11 @@ const CHIP_SIZE = {
1062
1130
  width: 168
1063
1131
  };
1064
1132
  const TAP_SLOP = 4;
1065
- /**
1066
- * Moving and resizing writes straight to the panel's inline style and only
1067
- * dispatches on pointer-up: routing every pointermove through the model would
1068
- * re-render the whole layout tree behind the float.
1069
- */
1133
+ /** Writes inline style and dispatches only on pointer-up: routing every move
1134
+ * through the model would re-render the whole tree behind the float. */
1070
1135
  const useFloatGesture = ({ editable, geometry, minimized, onCommit, onTap }) => {
1071
1136
  const panelRef = useRef(null);
1072
1137
  const gestureRef = useRef(null);
1073
- const latestRef = useRef(geometry);
1074
- const movedRef = useRef(false);
1075
1138
  const setPanel = (element) => {
1076
1139
  panelRef.current = element;
1077
1140
  };
@@ -1080,22 +1143,35 @@ const useFloatGesture = ({ editable, geometry, minimized, onCommit, onTap }) =>
1080
1143
  if (!panel || !editable) return;
1081
1144
  const prior = gestureRef.current;
1082
1145
  if (prior && panel.hasPointerCapture?.(prior.pointerId)) panel.releasePointerCapture(prior.pointerId);
1083
- movedRef.current = false;
1084
1146
  const edgeKey = event.currentTarget.dataset.edge;
1085
1147
  const parent = panel.offsetParent instanceof HTMLElement ? panel.offsetParent : null;
1148
+ const rect = panel.getBoundingClientRect();
1149
+ const visibleGeometry = {
1150
+ height: minimized ? geometry.height : rect.height,
1151
+ left: rect.left,
1152
+ top: rect.top,
1153
+ width: minimized ? geometry.width : rect.width
1154
+ };
1086
1155
  const gesture = {
1087
1156
  bounds: {
1088
1157
  height: parent?.clientHeight ?? 0,
1089
1158
  width: parent?.clientWidth ?? 0
1090
1159
  },
1091
1160
  edges: edgeKey === void 0 ? null : EDGE_BY_KEY.get(edgeKey) ?? null,
1161
+ initialStyle: {
1162
+ height: panel.style.height,
1163
+ left: panel.style.left,
1164
+ top: panel.style.top,
1165
+ width: panel.style.width
1166
+ },
1167
+ latest: visibleGeometry,
1168
+ moved: false,
1092
1169
  pointerId: event.pointerId,
1093
- start: geometry,
1170
+ start: visibleGeometry,
1094
1171
  startX: event.clientX,
1095
1172
  startY: event.clientY
1096
1173
  };
1097
1174
  gestureRef.current = gesture;
1098
- latestRef.current = geometry;
1099
1175
  if (gesture.edges) panel.setPointerCapture(event.pointerId);
1100
1176
  };
1101
1177
  const handlePointerUp = (event) => {
@@ -1104,11 +1180,11 @@ const useFloatGesture = ({ editable, geometry, minimized, onCommit, onTap }) =>
1104
1180
  gestureRef.current = null;
1105
1181
  const panel = panelRef.current;
1106
1182
  if (panel?.hasPointerCapture?.(event.pointerId) === true) panel.releasePointerCapture(event.pointerId);
1107
- if (!movedRef.current) {
1183
+ if (!gesture.moved) {
1108
1184
  onTap();
1109
1185
  return;
1110
1186
  }
1111
- onCommit(latestRef.current);
1187
+ onCommit(gesture.latest);
1112
1188
  };
1113
1189
  const handlePointerMove = (event) => {
1114
1190
  const gesture = gestureRef.current;
@@ -1120,9 +1196,9 @@ const useFloatGesture = ({ editable, geometry, minimized, onCommit, onTap }) =>
1120
1196
  }
1121
1197
  const dx = event.clientX - gesture.startX;
1122
1198
  const dy = event.clientY - gesture.startY;
1123
- if (!movedRef.current) {
1199
+ if (!gesture.moved) {
1124
1200
  if (Math.hypot(dx, dy) <= TAP_SLOP) return;
1125
- movedRef.current = true;
1201
+ gesture.moved = true;
1126
1202
  if (!gesture.edges) panel.setPointerCapture(gesture.pointerId);
1127
1203
  }
1128
1204
  const next = gesture.edges ? resizeRect(gesture.start, gesture.edges, dx, dy) : clampToBounds({
@@ -1130,7 +1206,7 @@ const useFloatGesture = ({ editable, geometry, minimized, onCommit, onTap }) =>
1130
1206
  left: gesture.start.left + dx,
1131
1207
  top: gesture.start.top + dy
1132
1208
  }, gesture.bounds, minimized ? CHIP_SIZE : void 0);
1133
- latestRef.current = next;
1209
+ gesture.latest = next;
1134
1210
  panel.style.left = `${next.left}px`;
1135
1211
  panel.style.top = `${next.top}px`;
1136
1212
  if (!minimized) {
@@ -1144,13 +1220,8 @@ const useFloatGesture = ({ editable, geometry, minimized, onCommit, onTap }) =>
1144
1220
  if (!gesture || !panel || event.pointerId !== gesture.pointerId) return;
1145
1221
  gestureRef.current = null;
1146
1222
  if (panel.hasPointerCapture?.(event.pointerId)) panel.releasePointerCapture(event.pointerId);
1147
- if (!movedRef.current) return;
1148
- panel.style.left = `${gesture.start.left}px`;
1149
- panel.style.top = `${gesture.start.top}px`;
1150
- if (!minimized) {
1151
- panel.style.width = `${gesture.start.width}px`;
1152
- panel.style.height = `${gesture.start.height}px`;
1153
- }
1223
+ if (!gesture.moved) return;
1224
+ Object.assign(panel.style, gesture.initialStyle);
1154
1225
  };
1155
1226
  return {
1156
1227
  handlers: {
@@ -1160,14 +1231,11 @@ const useFloatGesture = ({ editable, geometry, minimized, onCommit, onTap }) =>
1160
1231
  onPointerUp: handlePointerUp
1161
1232
  },
1162
1233
  ref: setPanel,
1163
- style: minimized ? {
1164
- left: geometry.left,
1165
- top: geometry.top
1166
- } : {
1167
- height: geometry.height,
1168
- left: geometry.left,
1169
- top: geometry.top,
1170
- width: geometry.width
1234
+ style: {
1235
+ height: minimized ? void 0 : `min(${geometry.height}px, 100dvh)`,
1236
+ left: `clamp(0px, ${geometry.left}px, calc(100vw - min(${minimized ? CHIP_SIZE.width : geometry.width}px, 100vw)))`,
1237
+ top: `clamp(0px, ${geometry.top}px, calc(100dvh - min(${minimized ? CHIP_SIZE.height : geometry.height}px, 100dvh)))`,
1238
+ width: minimized ? void 0 : `min(${geometry.width}px, 100vw)`
1171
1239
  }
1172
1240
  };
1173
1241
  };
@@ -1205,6 +1273,95 @@ const useInlineRename = ({ currentName, inputRef, onCommit, onDone }) => {
1205
1273
  };
1206
1274
  };
1207
1275
 
1276
+ //#endregion
1277
+ //#region src/components/close-icon.tsx
1278
+ const iconStyle = { pointerEvents: "none" };
1279
+ const Icon = ({ children }) => /* @__PURE__ */ jsx("svg", {
1280
+ "aria-hidden": "true",
1281
+ fill: "none",
1282
+ height: "12",
1283
+ stroke: "currentColor",
1284
+ strokeLinecap: "round",
1285
+ strokeLinejoin: "round",
1286
+ strokeWidth: "2",
1287
+ style: iconStyle,
1288
+ viewBox: "0 0 24 24",
1289
+ width: "12",
1290
+ children
1291
+ });
1292
+ const CloseIcon = () => /* @__PURE__ */ jsxs(Icon, { children: [/* @__PURE__ */ jsx("path", { d: "M18 6 6 18" }), /* @__PURE__ */ jsx("path", { d: "m6 6 12 12" })] });
1293
+ const GripIcon = () => /* @__PURE__ */ jsxs(Icon, { children: [
1294
+ /* @__PURE__ */ jsx("circle", {
1295
+ cx: "12",
1296
+ cy: "5",
1297
+ r: "1"
1298
+ }),
1299
+ /* @__PURE__ */ jsx("circle", {
1300
+ cx: "19",
1301
+ cy: "5",
1302
+ r: "1"
1303
+ }),
1304
+ /* @__PURE__ */ jsx("circle", {
1305
+ cx: "5",
1306
+ cy: "5",
1307
+ r: "1"
1308
+ }),
1309
+ /* @__PURE__ */ jsx("circle", {
1310
+ cx: "12",
1311
+ cy: "12",
1312
+ r: "1"
1313
+ }),
1314
+ /* @__PURE__ */ jsx("circle", {
1315
+ cx: "19",
1316
+ cy: "12",
1317
+ r: "1"
1318
+ }),
1319
+ /* @__PURE__ */ jsx("circle", {
1320
+ cx: "5",
1321
+ cy: "12",
1322
+ r: "1"
1323
+ }),
1324
+ /* @__PURE__ */ jsx("circle", {
1325
+ cx: "12",
1326
+ cy: "19",
1327
+ r: "1"
1328
+ }),
1329
+ /* @__PURE__ */ jsx("circle", {
1330
+ cx: "19",
1331
+ cy: "19",
1332
+ r: "1"
1333
+ }),
1334
+ /* @__PURE__ */ jsx("circle", {
1335
+ cx: "5",
1336
+ cy: "19",
1337
+ r: "1"
1338
+ })
1339
+ ] });
1340
+ const MaximizeIcon = ({ maximized }) => maximized ? /* @__PURE__ */ jsxs(Icon, { children: [
1341
+ /* @__PURE__ */ jsx("path", { d: "m14 10 7-7" }),
1342
+ /* @__PURE__ */ jsx("path", { d: "M20 10h-6V4" }),
1343
+ /* @__PURE__ */ jsx("path", { d: "m3 21 7-7" }),
1344
+ /* @__PURE__ */ jsx("path", { d: "M4 14h6v6" })
1345
+ ] }) : /* @__PURE__ */ jsxs(Icon, { children: [
1346
+ /* @__PURE__ */ jsx("path", { d: "M15 3h6v6" }),
1347
+ /* @__PURE__ */ jsx("path", { d: "m21 3-7 7" }),
1348
+ /* @__PURE__ */ jsx("path", { d: "m3 21 7-7" }),
1349
+ /* @__PURE__ */ jsx("path", { d: "M9 21H3v-6" })
1350
+ ] });
1351
+ const FloatIcon = () => /* @__PURE__ */ jsxs(Icon, { children: [/* @__PURE__ */ jsx("rect", {
1352
+ height: "14",
1353
+ rx: "2",
1354
+ width: "18",
1355
+ x: "3",
1356
+ y: "6"
1357
+ }), /* @__PURE__ */ jsx("path", { d: "M3 10h18" })] });
1358
+ const MinimizeIcon = () => /* @__PURE__ */ jsx(Icon, { children: /* @__PURE__ */ jsx("path", { d: "M5 12h14" }) });
1359
+ const DockIcon = () => /* @__PURE__ */ jsxs(Icon, { children: [
1360
+ /* @__PURE__ */ jsx("path", { d: "M3 7V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2" }),
1361
+ /* @__PURE__ */ jsx("path", { d: "M11 8 7 12l4 4" }),
1362
+ /* @__PURE__ */ jsx("path", { d: "M7 12h10" })
1363
+ ] });
1364
+
1208
1365
  //#endregion
1209
1366
  //#region src/components/layout-root.tsx
1210
1367
  const DEFAULT_TABSET_MIN_SIZE = 320;
@@ -1214,23 +1371,39 @@ const rootStyle = {
1214
1371
  position: "relative",
1215
1372
  width: "100%"
1216
1373
  };
1217
- const LayoutRoot = ({ children, closableTabs = true, dispatch, draggableTabs = true, draggableTabsets = true, editable = true, floatable = false, keepMounted = false, maximizable = true, model, renamableTabs = true, renderTab, renderTabLabel, renderTabsetToolbar, resizableSplits = true, restructurable = true, rootRef, snap, style, ...props }) => {
1374
+ const resolveCapabilities = (global, options) => {
1375
+ const structural = options.editable && options.restructurable;
1376
+ return {
1377
+ closableTabs: options.editable && options.closableTabs && global.tabEnableClose !== false,
1378
+ draggableTabs: structural && options.draggableTabs && global.tabEnableDrag !== false,
1379
+ draggableTabsets: structural && options.draggableTabsets,
1380
+ floatable: structural && options.floatable,
1381
+ maximizable: options.maximizable && global.tabSetEnableMaximize !== false,
1382
+ renamableTabs: options.editable && options.renamableTabs && global.tabEnableRename !== false,
1383
+ resizableSplits: structural && options.resizableSplits && global.enableSplitResize !== false
1384
+ };
1385
+ };
1386
+ const LayoutRoot = ({ children, closableTabs = true, dispatch, draggableTabs = true, draggableTabsets = true, editable = true, floatable = false, keepMounted = false, maximizable = true, model, renamableTabs = true, renderers, resizableSplits = true, restructurable = true, rootRef, snap, style, ...props }) => {
1218
1387
  const global = model.global;
1219
1388
  const snapshot = {
1220
- closableTabs: editable && closableTabs && global.tabEnableClose !== false,
1389
+ ...resolveCapabilities(global, {
1390
+ closableTabs,
1391
+ draggableTabs,
1392
+ draggableTabsets,
1393
+ editable,
1394
+ floatable,
1395
+ maximizable,
1396
+ renamableTabs,
1397
+ resizableSplits,
1398
+ restructurable
1399
+ }),
1221
1400
  dispatch,
1222
- draggableTabs: editable && restructurable && draggableTabs && global.tabEnableDrag !== false,
1223
- draggableTabsets: editable && restructurable && draggableTabsets,
1224
1401
  editable,
1225
- floatable: editable && restructurable && floatable,
1226
1402
  keepMounted,
1227
- maximizable: maximizable && global.tabSetEnableMaximize !== false,
1228
1403
  maximizedTabsetId: model.maximizedTabsetId,
1229
- renamableTabs: editable && renamableTabs && global.tabEnableRename !== false,
1230
- renderTab,
1231
- renderTabLabel,
1232
- renderTabsetToolbar,
1233
- resizableSplits: editable && restructurable && resizableSplits && global.enableSplitResize !== false,
1404
+ renderTab: renderers.tab,
1405
+ renderTabLabel: renderers.tabLabel,
1406
+ renderTabsetToolbar: renderers.tabsetToolbar,
1234
1407
  snap: snap ?? global.snap ?? null,
1235
1408
  splitDock: global.enableSplitDock !== false,
1236
1409
  tabLocation: global.tabLocation ?? "top",
@@ -1241,14 +1414,11 @@ const LayoutRoot = ({ children, closableTabs = true, dispatch, draggableTabs = t
1241
1414
  useLayoutEffect(() => {
1242
1415
  store.setState(snapshot);
1243
1416
  });
1244
- const layoutStyle = global.splitterSize === void 0 ? {
1417
+ const layoutStyle = {
1245
1418
  ...rootStyle,
1246
1419
  ...style
1247
- } : {
1248
- ...rootStyle,
1249
- "--dashfoo-splitter-size": `${global.splitterSize}px`,
1250
- ...style
1251
1420
  };
1421
+ if (global.splitterSize !== void 0) layoutStyle["--dashfoo-splitter-size"] = `${global.splitterSize}px`;
1252
1422
  return /* @__PURE__ */ jsx(LayoutStoreContext.Provider, {
1253
1423
  value: store,
1254
1424
  children: /* @__PURE__ */ jsx("div", {
@@ -1261,10 +1431,8 @@ const LayoutRoot = ({ children, closableTabs = true, dispatch, draggableTabs = t
1261
1431
  });
1262
1432
  };
1263
1433
  /**
1264
- * A nested subtree (a float) renders through the same components as the main
1265
- * layout, minus a few capabilities. It republishes the parent store with the
1266
- * overrides applied instead of re-deriving every flag from a synthetic model,
1267
- * so anything the parent changes keeps flowing into the subtree.
1434
+ * Republishes the parent store with overrides applied rather than re-deriving
1435
+ * flags, so anything the parent changes keeps flowing into the subtree.
1268
1436
  */
1269
1437
  const LayoutOverrides = ({ children, overrides }) => {
1270
1438
  const snapshot = {
@@ -1402,7 +1570,7 @@ const createTabsetStore = (initial) => createStore((set, get) => ({
1402
1570
  set({ editingTabId: null });
1403
1571
  },
1404
1572
  closeTab: (tabId) => {
1405
- set({ restoreFocus: true });
1573
+ set({ pendingCloseTabId: tabId });
1406
1574
  get().dispatch({
1407
1575
  tabId,
1408
1576
  type: "deleteTab"
@@ -1417,6 +1585,7 @@ const createTabsetStore = (initial) => createStore((set, get) => ({
1417
1585
  },
1418
1586
  editingTabId: null,
1419
1587
  overflowItems: [],
1588
+ pendingCloseTabId: null,
1420
1589
  registerRenameInput: () => {
1421
1590
  set((state) => ({ renameInputCount: state.renameInputCount + 1 }));
1422
1591
  return () => {
@@ -1440,7 +1609,6 @@ const createTabsetStore = (initial) => createStore((set, get) => ({
1440
1609
  type: "renameTab"
1441
1610
  });
1442
1611
  },
1443
- restoreFocus: false,
1444
1612
  selectOverflowTab: (tabId) => {
1445
1613
  const { dispatch, node, tablistElement } = get();
1446
1614
  const index = node.children.findIndex((tab) => tab.id === tabId);
@@ -1510,8 +1678,9 @@ const TabPanel = ({ render, tab }) => {
1510
1678
  const renderTab = useLayout((state) => state.renderTab);
1511
1679
  return render ? render(tab) : renderTab(tab);
1512
1680
  };
1513
- const TabsetContent = ({ children, style, ...props }) => {
1681
+ const TabsetContent = forwardRef(({ children, style, ...props }, ref) => {
1514
1682
  const node = useTabset((state) => state.node);
1683
+ const editingTabId = useTabset((state) => state.editingTabId);
1515
1684
  const visualSelected = useTabset((state) => state.visualSelected);
1516
1685
  const keepMounted = useLayout((state) => state.keepMounted);
1517
1686
  const mergedStyle = {
@@ -1520,11 +1689,13 @@ const TabsetContent = ({ children, style, ...props }) => {
1520
1689
  };
1521
1690
  if (keepMounted && node.children.length > 0) return node.children.map((tab, index) => /* @__PURE__ */ createElement("div", {
1522
1691
  ...props,
1523
- "aria-labelledby": tabDomId(node.id, tab.id),
1692
+ "aria-label": editingTabId === tab.id ? tab.name : void 0,
1693
+ "aria-labelledby": editingTabId === tab.id ? void 0 : tabDomId(node.id, tab.id),
1524
1694
  "data-dashfoo": "tabcontent",
1525
1695
  hidden: index !== visualSelected || void 0,
1526
1696
  id: index === visualSelected ? panelDomId(node.id) : void 0,
1527
1697
  key: tab.id,
1698
+ ref: index === visualSelected ? ref : void 0,
1528
1699
  role: index === visualSelected ? "tabpanel" : void 0,
1529
1700
  style: mergedStyle,
1530
1701
  tabIndex: index === visualSelected ? 0 : void 0
@@ -1535,9 +1706,11 @@ const TabsetContent = ({ children, style, ...props }) => {
1535
1706
  const active = node.children[visualSelected];
1536
1707
  if (active) return /* @__PURE__ */ jsx("div", {
1537
1708
  ...props,
1538
- "aria-labelledby": tabDomId(node.id, active.id),
1709
+ "aria-label": editingTabId === active.id ? active.name : void 0,
1710
+ "aria-labelledby": editingTabId === active.id ? void 0 : tabDomId(node.id, active.id),
1539
1711
  "data-dashfoo": "tabcontent",
1540
1712
  id: panelDomId(node.id),
1713
+ ref,
1541
1714
  role: "tabpanel",
1542
1715
  style: mergedStyle,
1543
1716
  tabIndex: 0,
@@ -1549,12 +1722,14 @@ const TabsetContent = ({ children, style, ...props }) => {
1549
1722
  return /* @__PURE__ */ jsx("div", {
1550
1723
  ...props,
1551
1724
  "data-dashfoo": "tabcontent",
1725
+ ref,
1552
1726
  style: mergedStyle
1553
1727
  });
1554
- };
1728
+ });
1729
+ TabsetContent.displayName = "TabsetContent";
1555
1730
 
1556
1731
  //#endregion
1557
- //#region src/components/tab-overflow.tsx
1732
+ //#region src/components/tab-overflow-menu.tsx
1558
1733
  const OverflowIcon = () => /* @__PURE__ */ jsxs("svg", {
1559
1734
  "aria-hidden": "true",
1560
1735
  fill: "currentColor",
@@ -1642,8 +1817,6 @@ const TabOverflowMenu = ({ items, onSelect }) => {
1642
1817
  case "Escape":
1643
1818
  event.preventDefault();
1644
1819
  closeAndReturnFocus();
1645
- break;
1646
- default: break;
1647
1820
  }
1648
1821
  };
1649
1822
  const handleFocusOut = (event) => {
@@ -1689,7 +1862,7 @@ const TabOverflowMenu = ({ items, onSelect }) => {
1689
1862
  };
1690
1863
 
1691
1864
  //#endregion
1692
- //#region src/components/tabset/tabset-overflow.tsx
1865
+ //#region src/components/tabset/tabset-overflow-menu.tsx
1693
1866
  const TabsetOverflowMenu = () => {
1694
1867
  const overflowItems = useTabset((state) => state.overflowItems);
1695
1868
  const selectOverflowTab = useTabset((state) => state.selectOverflowTab);
@@ -1700,6 +1873,90 @@ const TabsetOverflowMenu = () => {
1700
1873
  });
1701
1874
  };
1702
1875
 
1876
+ //#endregion
1877
+ //#region src/lib/merge-refs.ts
1878
+ const applyRef = (ref, value) => {
1879
+ if (typeof ref === "function") ref(value);
1880
+ else if (ref) ref.current = value;
1881
+ };
1882
+ const mergeRefs = (...refs) => (value) => {
1883
+ for (const ref of refs) applyRef(ref, value);
1884
+ };
1885
+
1886
+ //#endregion
1887
+ //#region src/components/tabset/tabset-rename-input.tsx
1888
+ const RenameEditor = forwardRef(({ defaultValue, onBlur, onKeyDown, ...props }, userRef) => {
1889
+ const { tab } = useTab();
1890
+ const cancelRename = useTabset((state) => state.cancelRename);
1891
+ const commitRename = useTabset((state) => state.commitRename);
1892
+ const inputRef = useRef(null);
1893
+ const rename = useInlineRename({
1894
+ currentName: tab.name,
1895
+ inputRef,
1896
+ onCommit: (name) => {
1897
+ commitRename(tab.id, name);
1898
+ },
1899
+ onDone: cancelRename
1900
+ });
1901
+ const refCallback = useMemo(() => mergeRefs(inputRef, userRef), [userRef]);
1902
+ const handleKeyDown = (event) => {
1903
+ onKeyDown?.(event);
1904
+ rename.handleKeyDown(event);
1905
+ };
1906
+ const handleCommitOnBlur = (event) => {
1907
+ onBlur?.(event);
1908
+ rename.handleBlur(event);
1909
+ };
1910
+ return /* @__PURE__ */ jsx("input", {
1911
+ "aria-label": `Rename ${tab.name}`,
1912
+ ...props,
1913
+ "data-dashfoo": "tab-rename",
1914
+ defaultValue: defaultValue ?? tab.name,
1915
+ onBlur: handleCommitOnBlur,
1916
+ onKeyDown: handleKeyDown,
1917
+ ref: refCallback,
1918
+ type: "text"
1919
+ });
1920
+ });
1921
+ RenameEditor.displayName = "RenameEditor";
1922
+ const TabsetRenameInput = forwardRef((props, ref) => {
1923
+ const { tab } = useTab();
1924
+ const editing = useTabset((state) => state.editingTabId === tab.id);
1925
+ const registerRenameInput = useTabset((state) => state.registerRenameInput);
1926
+ useEffect(() => registerRenameInput(), [registerRenameInput]);
1927
+ if (!editing) return null;
1928
+ return /* @__PURE__ */ jsx(RenameEditor, {
1929
+ ...props,
1930
+ ref
1931
+ });
1932
+ });
1933
+ TabsetRenameInput.displayName = "TabsetRenameInput";
1934
+ const TabsetCloseButton = forwardRef(({ children, onClick, ...props }, ref) => {
1935
+ const { index, tab } = useTab();
1936
+ const closeTab = useTabset((state) => state.closeTab);
1937
+ const editing = useTabset((state) => state.editingTabId === tab.id);
1938
+ const tabsClosable = useTabset((state) => state.tabsClosable);
1939
+ const visualSelected = useTabset((state) => state.visualSelected);
1940
+ if (!(tabsClosable && tab.enableClose !== false) || editing) return null;
1941
+ const handleClick = (event) => {
1942
+ onClick?.(event);
1943
+ event.stopPropagation();
1944
+ closeTab(tab.id);
1945
+ };
1946
+ return /* @__PURE__ */ jsx("button", {
1947
+ "aria-label": `Close ${tab.name}`,
1948
+ title: `Close ${tab.name}`,
1949
+ ...props,
1950
+ "data-dashfoo": "tab-close",
1951
+ onClick: handleClick,
1952
+ ref,
1953
+ tabIndex: index === visualSelected ? 0 : -1,
1954
+ type: "button",
1955
+ children: children ?? /* @__PURE__ */ jsx(CloseIcon, {})
1956
+ });
1957
+ });
1958
+ TabsetCloseButton.displayName = "TabsetCloseButton";
1959
+
1703
1960
  //#endregion
1704
1961
  //#region src/hooks/use-tab-overflow.ts
1705
1962
  const overflowingIds = (tablist) => {
@@ -1711,13 +1968,14 @@ const overflowingIds = (tablist) => {
1711
1968
  return clipped && tabId !== void 0 && tabId !== "" ? [tabId] : [];
1712
1969
  });
1713
1970
  };
1971
+ const sameIds = (a, b) => a.length === b.length && a.every((id, index) => id === b[index]);
1714
1972
  const useTabOverflow = (tablist, signature) => {
1715
1973
  const [overflow, setOverflow] = useState([]);
1716
1974
  useEffect(() => {
1717
1975
  const recompute = () => {
1718
1976
  setOverflow((previous) => {
1719
1977
  const next = tablist ? overflowingIds(tablist) : [];
1720
- return next.length === 0 && previous.length === 0 ? previous : next;
1978
+ return sameIds(previous, next) ? previous : next;
1721
1979
  });
1722
1980
  };
1723
1981
  recompute();
@@ -1747,7 +2005,7 @@ const tabsetStyle = {
1747
2005
  minWidth: 0,
1748
2006
  width: "100%"
1749
2007
  };
1750
- const TabsetRoot = ({ children, node, ref: userRef, style, ...props }) => {
2008
+ const TabsetRoot = forwardRef(({ children, node, style, ...props }, userRef) => {
1751
2009
  const closableTabs = useLayout((state) => state.closableTabs);
1752
2010
  const dispatch = useLayout((state) => state.dispatch);
1753
2011
  const maximizable = useLayout((state) => state.maximizable);
@@ -1779,7 +2037,9 @@ const TabsetRoot = ({ children, node, ref: userRef, style, ...props }) => {
1779
2037
  useLayoutEffect(() => {
1780
2038
  store.setState(snapshot);
1781
2039
  });
1782
- const overflow = useTabOverflow(useStore(store, (state) => state.tablistElement), node.children.map((tab) => `${tab.id}:${tab.name}`).join("|"));
2040
+ const tablistElement = useStore(store, (state) => state.tablistElement);
2041
+ const overflowSignature = node.children.map((tab) => `${tab.id}:${tab.name}`).join("|");
2042
+ const overflow = useTabOverflow(tablistElement, overflowSignature);
1783
2043
  const overflowItems = useMemo(() => {
1784
2044
  const names = new Map(node.children.map((tab) => [tab.id, tab.name]));
1785
2045
  return overflow.map((id) => ({
@@ -1791,8 +2051,10 @@ const TabsetRoot = ({ children, node, ref: userRef, style, ...props }) => {
1791
2051
  store.setState({ overflowItems });
1792
2052
  }, [overflowItems, store]);
1793
2053
  useEffect(() => {
1794
- if (!store.getState().restoreFocus) return;
1795
- store.setState({ restoreFocus: false });
2054
+ const { pendingCloseTabId } = store.getState();
2055
+ if (pendingCloseTabId === null) return;
2056
+ store.setState({ pendingCloseTabId: null });
2057
+ if (node.children.some((tab) => tab.id === pendingCloseTabId)) return;
1796
2058
  const activeTabId = node.children[node.selected]?.id;
1797
2059
  if (activeTabId) tabsetRef.current?.querySelector(`#${CSS.escape(tabDomId(node.id, activeTabId))}`)?.focus();
1798
2060
  else tabsetRef.current?.focus();
@@ -1823,21 +2085,12 @@ const TabsetRoot = ({ children, node, ref: userRef, style, ...props }) => {
1823
2085
  children
1824
2086
  })
1825
2087
  });
1826
- };
1827
-
1828
- //#endregion
1829
- //#region src/lib/merge-refs.ts
1830
- const applyRef = (ref, value) => {
1831
- if (typeof ref === "function") ref(value);
1832
- else if (ref) ref.current = value;
1833
- };
1834
- const mergeRefs = (...refs) => (value) => {
1835
- for (const ref of refs) applyRef(ref, value);
1836
- };
2088
+ });
2089
+ TabsetRoot.displayName = "TabsetRoot";
1837
2090
 
1838
2091
  //#endregion
1839
2092
  //#region src/components/tabset/tabset-tab.tsx
1840
- const TabsetTab = ({ children, ref: userRef, tab, ...props }) => {
2093
+ const TabsetTab = forwardRef(({ children, tab, ...props }, userRef) => {
1841
2094
  const store = useContext(TabsetStoreContext);
1842
2095
  const node = useTabset((state) => state.node);
1843
2096
  const editing = useTabset((state) => state.editingTabId === tab.id);
@@ -1872,8 +2125,9 @@ const TabsetTab = ({ children, ref: userRef, tab, ...props }) => {
1872
2125
  children
1873
2126
  })
1874
2127
  });
1875
- };
1876
- const TabsetTrigger = ({ children, onClick, onDoubleClick, ref: userRef, ...props }) => {
2128
+ });
2129
+ TabsetTab.displayName = "TabsetTab";
2130
+ const TabsetTrigger = forwardRef(({ children, onClick, onDoubleClick, ...props }, userRef) => {
1877
2131
  const { index, tab } = useTab();
1878
2132
  const node = useTabset((state) => state.node);
1879
2133
  const editing = useTabset((state) => state.editingTabId === tab.id);
@@ -1912,163 +2166,8 @@ const TabsetTrigger = ({ children, onClick, onDoubleClick, ref: userRef, ...prop
1912
2166
  type: "button",
1913
2167
  children: children ?? (renderTabLabel ? renderTabLabel(tab) : tab.name)
1914
2168
  });
1915
- };
1916
-
1917
- //#endregion
1918
- //#region src/components/tabset-icons.tsx
1919
- const iconStyle = { pointerEvents: "none" };
1920
- const Icon = ({ children }) => /* @__PURE__ */ jsx("svg", {
1921
- "aria-hidden": "true",
1922
- fill: "none",
1923
- height: "12",
1924
- stroke: "currentColor",
1925
- strokeLinecap: "round",
1926
- strokeLinejoin: "round",
1927
- strokeWidth: "2",
1928
- style: iconStyle,
1929
- viewBox: "0 0 24 24",
1930
- width: "12",
1931
- children
1932
2169
  });
1933
- const CloseIcon = () => /* @__PURE__ */ jsxs(Icon, { children: [/* @__PURE__ */ jsx("path", { d: "M18 6 6 18" }), /* @__PURE__ */ jsx("path", { d: "m6 6 12 12" })] });
1934
- const GripIcon = () => /* @__PURE__ */ jsxs(Icon, { children: [
1935
- /* @__PURE__ */ jsx("circle", {
1936
- cx: "12",
1937
- cy: "5",
1938
- r: "1"
1939
- }),
1940
- /* @__PURE__ */ jsx("circle", {
1941
- cx: "19",
1942
- cy: "5",
1943
- r: "1"
1944
- }),
1945
- /* @__PURE__ */ jsx("circle", {
1946
- cx: "5",
1947
- cy: "5",
1948
- r: "1"
1949
- }),
1950
- /* @__PURE__ */ jsx("circle", {
1951
- cx: "12",
1952
- cy: "12",
1953
- r: "1"
1954
- }),
1955
- /* @__PURE__ */ jsx("circle", {
1956
- cx: "19",
1957
- cy: "12",
1958
- r: "1"
1959
- }),
1960
- /* @__PURE__ */ jsx("circle", {
1961
- cx: "5",
1962
- cy: "12",
1963
- r: "1"
1964
- }),
1965
- /* @__PURE__ */ jsx("circle", {
1966
- cx: "12",
1967
- cy: "19",
1968
- r: "1"
1969
- }),
1970
- /* @__PURE__ */ jsx("circle", {
1971
- cx: "19",
1972
- cy: "19",
1973
- r: "1"
1974
- }),
1975
- /* @__PURE__ */ jsx("circle", {
1976
- cx: "5",
1977
- cy: "19",
1978
- r: "1"
1979
- })
1980
- ] });
1981
- const MaximizeIcon = ({ maximized }) => maximized ? /* @__PURE__ */ jsxs(Icon, { children: [
1982
- /* @__PURE__ */ jsx("path", { d: "m14 10 7-7" }),
1983
- /* @__PURE__ */ jsx("path", { d: "M20 10h-6V4" }),
1984
- /* @__PURE__ */ jsx("path", { d: "m3 21 7-7" }),
1985
- /* @__PURE__ */ jsx("path", { d: "M4 14h6v6" })
1986
- ] }) : /* @__PURE__ */ jsxs(Icon, { children: [
1987
- /* @__PURE__ */ jsx("path", { d: "M15 3h6v6" }),
1988
- /* @__PURE__ */ jsx("path", { d: "m21 3-7 7" }),
1989
- /* @__PURE__ */ jsx("path", { d: "m3 21 7-7" }),
1990
- /* @__PURE__ */ jsx("path", { d: "M9 21H3v-6" })
1991
- ] });
1992
- const FloatIcon = () => /* @__PURE__ */ jsxs(Icon, { children: [/* @__PURE__ */ jsx("rect", {
1993
- height: "14",
1994
- rx: "2",
1995
- width: "18",
1996
- x: "3",
1997
- y: "6"
1998
- }), /* @__PURE__ */ jsx("path", { d: "M3 10h18" })] });
1999
- const MinimizeIcon = () => /* @__PURE__ */ jsx(Icon, { children: /* @__PURE__ */ jsx("path", { d: "M5 12h14" }) });
2000
- const DockIcon = () => /* @__PURE__ */ jsxs(Icon, { children: [
2001
- /* @__PURE__ */ jsx("path", { d: "M3 7V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2" }),
2002
- /* @__PURE__ */ jsx("path", { d: "M11 8 7 12l4 4" }),
2003
- /* @__PURE__ */ jsx("path", { d: "M7 12h10" })
2004
- ] });
2005
-
2006
- //#endregion
2007
- //#region src/components/tabset/tabset-tab-controls.tsx
2008
- const RenameEditor = ({ defaultValue, onBlur, onKeyDown, ref: userRef, ...props }) => {
2009
- const { tab } = useTab();
2010
- const cancelRename = useTabset((state) => state.cancelRename);
2011
- const commitRename = useTabset((state) => state.commitRename);
2012
- const inputRef = useRef(null);
2013
- const rename = useInlineRename({
2014
- currentName: tab.name,
2015
- inputRef,
2016
- onCommit: (name) => {
2017
- commitRename(tab.id, name);
2018
- },
2019
- onDone: cancelRename
2020
- });
2021
- const refCallback = useMemo(() => mergeRefs(inputRef, userRef), [userRef]);
2022
- const handleKeyDown = (event) => {
2023
- onKeyDown?.(event);
2024
- rename.handleKeyDown(event);
2025
- };
2026
- const handleCommitOnBlur = (event) => {
2027
- onBlur?.(event);
2028
- rename.handleBlur(event);
2029
- };
2030
- return /* @__PURE__ */ jsx("input", {
2031
- "aria-label": `Rename ${tab.name}`,
2032
- ...props,
2033
- "data-dashfoo": "tab-rename",
2034
- defaultValue: defaultValue ?? tab.name,
2035
- onBlur: handleCommitOnBlur,
2036
- onKeyDown: handleKeyDown,
2037
- ref: refCallback,
2038
- type: "text"
2039
- });
2040
- };
2041
- const TabsetRenameInput = (props) => {
2042
- const { tab } = useTab();
2043
- const editing = useTabset((state) => state.editingTabId === tab.id);
2044
- const registerRenameInput = useTabset((state) => state.registerRenameInput);
2045
- useEffect(() => registerRenameInput(), [registerRenameInput]);
2046
- if (!editing) return null;
2047
- return /* @__PURE__ */ jsx(RenameEditor, { ...props });
2048
- };
2049
- const TabsetCloseButton = ({ children, onClick, ...props }) => {
2050
- const { index, tab } = useTab();
2051
- const closeTab = useTabset((state) => state.closeTab);
2052
- const editing = useTabset((state) => state.editingTabId === tab.id);
2053
- const tabsClosable = useTabset((state) => state.tabsClosable);
2054
- const visualSelected = useTabset((state) => state.visualSelected);
2055
- if (!(tabsClosable && tab.enableClose !== false) || editing) return null;
2056
- const handleClick = (event) => {
2057
- onClick?.(event);
2058
- event.stopPropagation();
2059
- closeTab(tab.id);
2060
- };
2061
- return /* @__PURE__ */ jsx("button", {
2062
- "aria-label": `Close ${tab.name}`,
2063
- title: `Close ${tab.name}`,
2064
- ...props,
2065
- "data-dashfoo": "tab-close",
2066
- onClick: handleClick,
2067
- tabIndex: index === visualSelected ? 0 : -1,
2068
- type: "button",
2069
- children: children ?? /* @__PURE__ */ jsx(CloseIcon, {})
2070
- });
2071
- };
2170
+ TabsetTrigger.displayName = "TabsetTrigger";
2072
2171
 
2073
2172
  //#endregion
2074
2173
  //#region src/components/tabset/tabset-tablist.tsx
@@ -2081,50 +2180,60 @@ const tablistStyle = {
2081
2180
  minWidth: 0,
2082
2181
  overflowX: "auto"
2083
2182
  };
2084
- const TabsetTabStrip = ({ style, ...props }) => /* @__PURE__ */ jsx("div", {
2183
+ const TabsetTabStrip = forwardRef(({ style, ...props }, ref) => /* @__PURE__ */ jsx("div", {
2085
2184
  ...props,
2086
2185
  "data-dashfoo": "tabstrip",
2186
+ ref,
2087
2187
  style: {
2088
2188
  ...stripStyle,
2089
2189
  ...style
2090
2190
  }
2091
- });
2092
- const TabsetTablist = ({ onKeyDown, ref: userRef, style, ...props }) => {
2191
+ }));
2192
+ TabsetTabStrip.displayName = "TabsetTabStrip";
2193
+ const TabsetTablist = forwardRef(({ "aria-label": ariaLabel, children, onKeyDown, style, ...props }, userRef) => {
2093
2194
  const node = useTabset((state) => state.node);
2094
2195
  const registerTablist = useTabset((state) => state.registerTablist);
2095
2196
  const selectTab = useTabset((state) => state.selectTab);
2197
+ const editingTabId = useTabset((state) => state.editingTabId);
2198
+ const visualSelected = useTabset((state) => state.visualSelected);
2096
2199
  const handleKeyDown = (event) => {
2097
2200
  onKeyDown?.(event);
2098
2201
  if (event.defaultPrevented) return;
2099
2202
  if (!(event.target instanceof HTMLElement) || !event.target.closest("[role=\"tab\"]")) return;
2100
2203
  const count = node.children.length;
2101
2204
  if (count === 0) return;
2102
- const next = {
2103
- ArrowLeft: (node.selected - 1 + count) % count,
2104
- ArrowRight: (node.selected + 1) % count,
2105
- End: count - 1,
2106
- Home: 0
2107
- }[event.key];
2205
+ const from = Math.max(visualSelected, 0);
2206
+ const next = (/* @__PURE__ */ new Map([
2207
+ ["ArrowLeft", (from - 1 + count) % count],
2208
+ ["ArrowRight", (from + 1) % count],
2209
+ ["End", count - 1],
2210
+ ["Home", 0]
2211
+ ])).get(event.key);
2108
2212
  if (next === void 0) return;
2109
2213
  event.preventDefault();
2110
2214
  selectTab(next, { focus: true });
2111
2215
  };
2112
2216
  const refCallback = useMemo(() => mergeRefs(registerTablist, userRef), [registerTablist, userRef]);
2113
- return /* @__PURE__ */ jsx("div", {
2114
- "aria-label": node.name ?? "Tabs",
2217
+ const ownedTabs = node.children.filter((tab) => tab.id !== editingTabId).map((tab) => tabDomId(node.id, tab.id)).join(" ");
2218
+ return /* @__PURE__ */ jsxs("div", {
2115
2219
  ...props,
2116
- "aria-orientation": "horizontal",
2117
2220
  "data-dashfoo": "tablist",
2118
2221
  onKeyDown: handleKeyDown,
2119
2222
  ref: refCallback,
2120
- role: "tablist",
2121
2223
  style: {
2122
2224
  ...tablistStyle,
2123
2225
  ...style
2124
2226
  },
2125
- tabIndex: -1
2227
+ tabIndex: -1,
2228
+ children: [ownedTabs ? /* @__PURE__ */ jsx("span", {
2229
+ "aria-label": ariaLabel ?? node.name ?? "Tabs",
2230
+ "aria-orientation": "horizontal",
2231
+ "aria-owns": ownedTabs,
2232
+ role: "tablist"
2233
+ }) : null, children]
2126
2234
  });
2127
- };
2235
+ });
2236
+ TabsetTablist.displayName = "TabsetTablist";
2128
2237
 
2129
2238
  //#endregion
2130
2239
  //#region src/components/tabset/tabset-toolbar.tsx
@@ -2134,22 +2243,42 @@ const toolbarStyle = {
2134
2243
  flexShrink: 0,
2135
2244
  marginInlineStart: "auto"
2136
2245
  };
2137
- const TabsetToolbar = ({ style, ...props }) => /* @__PURE__ */ jsx("div", {
2246
+ const TabsetToolbar = forwardRef(({ style, ...props }, ref) => /* @__PURE__ */ jsx("div", {
2138
2247
  ...props,
2139
2248
  "data-dashfoo": "tabset-toolbar",
2249
+ ref,
2140
2250
  style: {
2141
2251
  ...toolbarStyle,
2142
2252
  ...style
2143
2253
  }
2144
- });
2145
- const TabsetGrip = ({ children, ref: userRef, ...props }) => {
2146
- const activeTabName = useTabset((state) => state.activeTab?.name ?? "");
2254
+ }));
2255
+ TabsetToolbar.displayName = "TabsetToolbar";
2256
+ /**
2257
+ * One answer per control, shared by the controls and by the layout deciding
2258
+ * whether to render a toolbar at all, so the two cannot disagree.
2259
+ */
2260
+ const useTabsetChrome = () => {
2261
+ const draggableTabsets = useLayout((state) => state.draggableTabsets);
2262
+ const floatable = useLayout((state) => state.floatable);
2147
2263
  const isMaximized = useTabset((state) => state.isMaximized);
2264
+ const overflowCount = useTabset((state) => state.overflowItems.length);
2265
+ const showMaximize = useTabset((state) => state.showMaximize);
2266
+ const hasFloatLayer = useHasFloatLayer();
2267
+ if (floatable && !isMaximized && !hasFloatLayer) warnOnce("float-no-layer", "Tabset.FloatButton needs a <Layout.FloatLayer> around the layout (DashfooLayout adds one); the float control is hidden");
2268
+ return {
2269
+ float: floatable && !isMaximized && hasFloatLayer,
2270
+ grip: draggableTabsets && !isMaximized,
2271
+ maximize: showMaximize,
2272
+ overflow: overflowCount > 0
2273
+ };
2274
+ };
2275
+ const TabsetGrip = forwardRef(({ children, ...props }, userRef) => {
2276
+ const activeTabName = useTabset((state) => state.activeTab?.name ?? "");
2148
2277
  const node = useTabset((state) => state.node);
2149
- const hidden = !useLayout((state) => state.draggableTabsets) || isMaximized;
2150
- const { ref } = useTabsetDraggable(node.id, hidden, activeTabName);
2278
+ const { grip } = useTabsetChrome();
2279
+ const { ref } = useTabsetDraggable(node.id, !grip, activeTabName);
2151
2280
  const refCallback = useMemo(() => mergeRefs(ref, userRef), [ref, userRef]);
2152
- if (hidden) return null;
2281
+ if (!grip) return null;
2153
2282
  return /* @__PURE__ */ jsx("button", {
2154
2283
  "aria-label": "Move tabset",
2155
2284
  title: "Move tabset",
@@ -2159,12 +2288,13 @@ const TabsetGrip = ({ children, ref: userRef, ...props }) => {
2159
2288
  type: "button",
2160
2289
  children: children ?? /* @__PURE__ */ jsx(GripIcon, {})
2161
2290
  });
2162
- };
2163
- const TabsetMaximizeButton = ({ children, onClick, ...props }) => {
2291
+ });
2292
+ TabsetGrip.displayName = "TabsetGrip";
2293
+ const TabsetMaximizeButton = forwardRef(({ children, onClick, ...props }, ref) => {
2164
2294
  const isMaximized = useTabset((state) => state.isMaximized);
2165
- const showMaximize = useTabset((state) => state.showMaximize);
2166
2295
  const toggleMaximize = useTabset((state) => state.toggleMaximize);
2167
- if (!showMaximize) return null;
2296
+ const { maximize } = useTabsetChrome();
2297
+ if (!maximize) return null;
2168
2298
  const handleClick = (event) => {
2169
2299
  onClick?.(event);
2170
2300
  toggleMaximize();
@@ -2176,21 +2306,17 @@ const TabsetMaximizeButton = ({ children, onClick, ...props }) => {
2176
2306
  "aria-pressed": isMaximized,
2177
2307
  "data-dashfoo": "tabset-maximize",
2178
2308
  onClick: handleClick,
2309
+ ref,
2179
2310
  type: "button",
2180
2311
  children: children ?? /* @__PURE__ */ jsx(MaximizeIcon, { maximized: isMaximized })
2181
2312
  });
2182
- };
2183
- const TabsetFloatButton = ({ children, onClick, ...props }) => {
2184
- const floatable = useLayout((state) => state.floatable);
2313
+ });
2314
+ TabsetMaximizeButton.displayName = "TabsetMaximizeButton";
2315
+ const TabsetFloatButton = forwardRef(({ children, onClick, ...props }, ref) => {
2185
2316
  const dispatch = useLayout((state) => state.dispatch);
2186
- const isMaximized = useTabset((state) => state.isMaximized);
2187
2317
  const node = useTabset((state) => state.node);
2188
- const hasFloatLayer = useHasFloatLayer();
2189
- if (!floatable || isMaximized) return null;
2190
- if (!hasFloatLayer) {
2191
- warnOnce("float-no-layer", "Tabset.FloatButton needs a <Layout.FloatLayer> around the layout (DashfooLayout adds one); the float control is hidden");
2192
- return null;
2193
- }
2318
+ const { float } = useTabsetChrome();
2319
+ if (!float) return null;
2194
2320
  const handleClick = (event) => {
2195
2321
  onClick?.(event);
2196
2322
  const geometry = measureFloatRect(event.currentTarget);
@@ -2206,24 +2332,23 @@ const TabsetFloatButton = ({ children, onClick, ...props }) => {
2206
2332
  ...props,
2207
2333
  "data-dashfoo": "tabset-float",
2208
2334
  onClick: handleClick,
2335
+ ref,
2209
2336
  type: "button",
2210
2337
  children: children ?? /* @__PURE__ */ jsx(FloatIcon, {})
2211
2338
  });
2212
- };
2339
+ });
2340
+ TabsetFloatButton.displayName = "TabsetFloatButton";
2213
2341
 
2214
2342
  //#endregion
2215
2343
  //#region src/components/tabset/tabset-view.tsx
2216
2344
  const tabsetContent = /* @__PURE__ */ jsx(TabsetContent, {});
2217
2345
  const DefaultTabsetLayout = () => {
2218
- const draggableTabsets = useLayout((state) => state.draggableTabsets);
2219
- const floatable = useLayout((state) => state.floatable);
2220
2346
  const renderTabsetToolbar = useLayout((state) => state.renderTabsetToolbar);
2221
2347
  const tabLocation = useLayout((state) => state.tabLocation);
2222
2348
  const tabStripEnabled = useLayout((state) => state.tabStripEnabled);
2223
- const isMaximized = useTabset((state) => state.isMaximized);
2224
2349
  const node = useTabset((state) => state.node);
2225
- const overflowCount = useTabset((state) => state.overflowItems.length);
2226
- const showToolbar = useTabset((state) => state.showMaximize) || renderTabsetToolbar !== void 0 || overflowCount > 0 || floatable && !isMaximized || draggableTabsets && !isMaximized;
2350
+ const chrome = useTabsetChrome();
2351
+ const showToolbar = renderTabsetToolbar !== void 0 || Object.values(chrome).some(Boolean);
2227
2352
  const strip = tabStripEnabled ? /* @__PURE__ */ jsxs(TabsetTabStrip, { children: [/* @__PURE__ */ jsx(TabsetTablist, { children: node.children.map((tab) => /* @__PURE__ */ jsxs(TabsetTab, {
2228
2353
  tab,
2229
2354
  children: [
@@ -2275,7 +2400,8 @@ const RowView = ({ node, renderTabset }) => {
2275
2400
  const tabsetMinSize = useLayout((state) => state.tabsetMinSize);
2276
2401
  const globalSnap = useLayout((state) => state.snap);
2277
2402
  const effectiveSnap = node.snap ?? globalSnap;
2278
- const snapActive = snapEnabled(effectiveSnap);
2403
+ const snapGrid = useMemo(() => resolveSnapGrid(effectiveSnap, node.children.length), [effectiveSnap, node.children.length]);
2404
+ const snapActive = snapEnabled(snapGrid);
2279
2405
  const orientation = node.orientation === "row" ? "horizontal" : "vertical";
2280
2406
  const total = node.children.reduce((sum, child) => sum + child.weight, 0);
2281
2407
  const { beginBoundaryDrag, groupRef, onLayoutChange, onLayoutChanged, snappedBoundary } = useSnapResize({
@@ -2288,7 +2414,7 @@ const RowView = ({ node, renderTabset }) => {
2288
2414
  });
2289
2415
  },
2290
2416
  resizableSplits,
2291
- snap: effectiveSnap
2417
+ snap: snapGrid
2292
2418
  });
2293
2419
  return /* @__PURE__ */ jsx(Group, {
2294
2420
  "data-dashfoo": "row",
@@ -2589,8 +2715,8 @@ const Layout = {
2589
2715
  //#endregion
2590
2716
  //#region src/components/dashfoo-layout.tsx
2591
2717
  const DEFAULT_PERSIST_DEBOUNCE_MS = 300;
2592
- const resolvePersist = (persist, hasDefaultModel) => {
2593
- if (persist === void 0 || !hasDefaultModel) return null;
2718
+ const resolvePersist = (persist) => {
2719
+ if (persist === void 0) return null;
2594
2720
  if (typeof persist === "string") return {
2595
2721
  debounceMs: DEFAULT_PERSIST_DEBOUNCE_MS,
2596
2722
  key: persist,
@@ -2605,7 +2731,8 @@ const resolvePersist = (persist, hasDefaultModel) => {
2605
2731
  const warnedComponents = /* @__PURE__ */ new Set();
2606
2732
  const DashfooLayout = forwardRef((props, ref) => {
2607
2733
  const { closableTabs = true, components, defaultModel, draggableTabs = true, draggableTabsets = true, editable = true, factory, floatable = false, keepMounted = false, maximizable = true, model, onAction, onActiveTabsetChange, onMaximizedTabsetChange, onModelChange, persist, renamableTabs = true, renderTabLabel, renderTabsetToolbar, resizableSplits = true, responsive, snap } = props;
2608
- const persistence = usePersistence(useMemo(() => resolvePersist(persist, defaultModel !== void 0), [persist, defaultModel]), defaultModel);
2734
+ const persistConfig = useMemo(() => model === void 0 ? resolvePersist(persist) : null, [model, persist]);
2735
+ const persistence = usePersistence(persistConfig, defaultModel);
2609
2736
  const handleModelChange = useCallback((next, action) => {
2610
2737
  persistence.save(next);
2611
2738
  onModelChange?.(next, action);
@@ -2618,6 +2745,14 @@ const DashfooLayout = forwardRef((props, ref) => {
2618
2745
  onMaximizedTabsetChange,
2619
2746
  onModelChange: handleModelChange
2620
2747
  });
2748
+ const { setModel } = store;
2749
+ const appliedInitialModel = useRef(persistence.initialModel);
2750
+ useEffect(() => {
2751
+ if (persistence.initialModel !== void 0 && persistence.initialModel !== appliedInitialModel.current) {
2752
+ appliedInitialModel.current = persistence.initialModel;
2753
+ setModel(persistence.initialModel);
2754
+ }
2755
+ }, [persistence.initialModel, setModel]);
2621
2756
  const hasSharedManager = useContext(SharedDragManagerContext) !== null;
2622
2757
  useImperativeHandle(ref, () => ({
2623
2758
  addTab: (tab, target) => {
@@ -2694,16 +2829,36 @@ const DashfooLayout = forwardRef((props, ref) => {
2694
2829
  }
2695
2830
  return /* @__PURE__ */ jsx(Component, { node: tab });
2696
2831
  }, [components, factory]);
2832
+ const renderers = useMemo(() => ({
2833
+ tab: renderTab,
2834
+ tabLabel: renderTabLabel,
2835
+ tabsetToolbar: renderTabsetToolbar
2836
+ }), [
2837
+ renderTab,
2838
+ renderTabLabel,
2839
+ renderTabsetToolbar
2840
+ ]);
2697
2841
  const [containerRef, width] = useContainerWidth();
2698
2842
  const isCompact = responsive !== void 0 && width <= responsive.maxWidth;
2699
- const view = useMemo(() => isCompact ? stackModel(store.model, responsive?.orientation) : store.model, [
2843
+ /** Everything the compact breakpoint changes, decided once. */
2844
+ const presentation = useMemo(() => isCompact ? {
2845
+ maximizable: false,
2846
+ model: compactModel(store.model, responsive?.orientation),
2847
+ restructurable: false
2848
+ } : {
2849
+ maximizable,
2850
+ model: store.model,
2851
+ restructurable: true
2852
+ }, [
2700
2853
  isCompact,
2854
+ maximizable,
2701
2855
  responsive?.orientation,
2702
2856
  store.model
2703
2857
  ]);
2858
+ const view = presentation.model;
2704
2859
  const maximized = view.maximizedTabsetId === void 0 ? void 0 : findTabset(view, view.maximizedTabsetId);
2705
2860
  const tree = /* @__PURE__ */ jsx(Layout.FloatLayer, {
2706
- floats: store.model.floats,
2861
+ floats: view.floats,
2707
2862
  children: /* @__PURE__ */ jsx(Layout.DragLayer, { children: maximized ? /* @__PURE__ */ jsx(Layout.Tabset, { node: maximized }) : /* @__PURE__ */ jsx(Layout.Rows, { node: view.layout }) })
2708
2863
  });
2709
2864
  return /* @__PURE__ */ jsx(Layout.Root, {
@@ -2714,14 +2869,12 @@ const DashfooLayout = forwardRef((props, ref) => {
2714
2869
  editable,
2715
2870
  floatable,
2716
2871
  keepMounted,
2717
- maximizable,
2872
+ maximizable: presentation.maximizable,
2718
2873
  model: view,
2719
2874
  renamableTabs,
2720
- renderTab,
2721
- renderTabLabel,
2722
- renderTabsetToolbar,
2875
+ renderers,
2723
2876
  resizableSplits,
2724
- restructurable: !isCompact,
2877
+ restructurable: presentation.restructurable,
2725
2878
  rootRef: containerRef,
2726
2879
  snap,
2727
2880
  children: hasSharedManager ? tree : /* @__PURE__ */ jsx(DashfooDragProvider, { children: tree })
@@ -2730,31 +2883,62 @@ const DashfooLayout = forwardRef((props, ref) => {
2730
2883
  DashfooLayout.displayName = "DashfooLayout";
2731
2884
 
2732
2885
  //#endregion
2733
- //#region src/components/panel.tsx
2734
- const PanelRoot = (props) => /* @__PURE__ */ jsx("div", {
2886
+ //#region src/components/panel-badge.tsx
2887
+ const PanelBadge = forwardRef((props, ref) => /* @__PURE__ */ jsx("span", {
2735
2888
  ...props,
2736
- "data-dashfoo": "panel"
2737
- });
2738
- const PanelHeader = (props) => /* @__PURE__ */ jsx("div", {
2889
+ "data-dashfoo": "panel-badge",
2890
+ ref
2891
+ }));
2892
+ PanelBadge.displayName = "PanelBadge";
2893
+
2894
+ //#endregion
2895
+ //#region src/components/panel-body.tsx
2896
+ const PanelBody = forwardRef((props, ref) => /* @__PURE__ */ jsx("div", {
2739
2897
  ...props,
2740
- "data-dashfoo": "panel-header"
2741
- });
2742
- const PanelBody = (props) => /* @__PURE__ */ jsx("div", {
2898
+ "data-dashfoo": "panel-body",
2899
+ ref,
2900
+ tabIndex: props.tabIndex ?? 0
2901
+ }));
2902
+ PanelBody.displayName = "PanelBody";
2903
+
2904
+ //#endregion
2905
+ //#region src/components/panel-header.tsx
2906
+ const PanelHeader = forwardRef((props, ref) => /* @__PURE__ */ jsx("div", {
2743
2907
  ...props,
2744
- "data-dashfoo": "panel-body"
2745
- });
2746
- const PanelTitle = (props) => /* @__PURE__ */ jsx("span", {
2908
+ "data-dashfoo": "panel-header",
2909
+ ref
2910
+ }));
2911
+ PanelHeader.displayName = "PanelHeader";
2912
+
2913
+ //#endregion
2914
+ //#region src/components/panel-icon.tsx
2915
+ const PanelIcon = forwardRef((props, ref) => /* @__PURE__ */ jsx("span", {
2747
2916
  ...props,
2748
- "data-dashfoo": "panel-title"
2749
- });
2750
- const PanelIcon = (props) => /* @__PURE__ */ jsx("span", {
2917
+ "data-dashfoo": "panel-icon",
2918
+ ref
2919
+ }));
2920
+ PanelIcon.displayName = "PanelIcon";
2921
+
2922
+ //#endregion
2923
+ //#region src/components/panel-root.tsx
2924
+ const PanelRoot = forwardRef((props, ref) => /* @__PURE__ */ jsx("div", {
2751
2925
  ...props,
2752
- "data-dashfoo": "panel-icon"
2753
- });
2754
- const PanelBadge = (props) => /* @__PURE__ */ jsx("span", {
2926
+ "data-dashfoo": "panel",
2927
+ ref
2928
+ }));
2929
+ PanelRoot.displayName = "PanelRoot";
2930
+
2931
+ //#endregion
2932
+ //#region src/components/panel-title.tsx
2933
+ const PanelTitle = forwardRef((props, ref) => /* @__PURE__ */ jsx("span", {
2755
2934
  ...props,
2756
- "data-dashfoo": "panel-badge"
2757
- });
2935
+ "data-dashfoo": "panel-title",
2936
+ ref
2937
+ }));
2938
+ PanelTitle.displayName = "PanelTitle";
2939
+
2940
+ //#endregion
2941
+ //#region src/components/panel.tsx
2758
2942
  const Panel = {
2759
2943
  Badge: PanelBadge,
2760
2944
  Body: PanelBody,