@dashfoo/react 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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, stackModel, 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);
@@ -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
@@ -251,28 +251,29 @@ const usePersistence = (config, defaultModel) => {
251
251
  useEffect(() => {
252
252
  configRef.current = config;
253
253
  });
254
- const [initialModel] = useState(() => {
255
- if (config === null || defaultModel === void 0) return defaultModel;
254
+ const [load] = useState(() => {
255
+ if (config === null) return { kind: "absent" };
256
256
  const raw = config.storage.getItem(config.key);
257
- if (raw === null) return defaultModel;
257
+ if (raw === null) return { kind: "absent" };
258
258
  try {
259
- return fromJSON(raw);
260
- } catch {
261
- return defaultModel;
259
+ return {
260
+ kind: "loaded",
261
+ model: fromJSON(raw)
262
+ };
263
+ } catch (error) {
264
+ return {
265
+ error,
266
+ kind: "corrupt"
267
+ };
262
268
  }
263
269
  });
270
+ const initialModel = load.kind === "loaded" && defaultModel !== void 0 ? load.model : defaultModel;
264
271
  useEffect(() => {
265
272
  const current = configRef.current;
266
- if (current === null) return;
267
- const raw = current.storage.getItem(current.key);
268
- if (raw === null) return;
269
- try {
270
- fromJSON(raw);
271
- } catch (error) {
272
- console.warn("[dashfoo] discarding unreadable persisted layout", error);
273
- current.storage.removeItem(current.key);
274
- }
275
- }, []);
273
+ if (current === null || load.kind !== "corrupt") return;
274
+ console.warn("[dashfoo] discarding unreadable persisted layout", load.error);
275
+ current.storage.removeItem(current.key);
276
+ }, [load]);
276
277
  const loadedKey = useRef(config?.key);
277
278
  useEffect(() => {
278
279
  if (config !== null && config.key !== loadedKey.current) {
@@ -412,19 +413,21 @@ const warnOnce = (key, message) => {
412
413
  //#endregion
413
414
  //#region src/hooks/store.ts
414
415
  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";
416
+ const CONTROLLED_SET_MODEL_MESSAGE = "setModel (and resetLayout) do nothing while `model` is controlled; set the model you own instead";
417
+ 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";
418
+ const CONTROLLED_WITH_DEFAULT_MESSAGE = "`model` and `defaultModel` were both passed; `model` wins and `defaultModel` (and `persist`) are ignored";
419
+ /** The mode is latched on the first render; running both sources leaves the
420
+ * unread one describing a document nobody rendered. */
415
421
  const useDashfooStore = (options) => {
416
422
  const { defaultModel, model: controlledModel, onAction, onActiveTabsetChange, onMaximizedTabsetChange, onModelChange } = options;
417
423
  const initialModel = controlledModel ?? defaultModel;
418
424
  if (initialModel === void 0) throw new Error("useDashfooStore requires either a `model` or a `defaultModel`.");
425
+ const [controlled] = useState(() => controlledModel !== void 0);
426
+ if (controlled !== (controlledModel !== void 0)) warnOnce("controlled-mode-switch", CONTROLLED_MODE_SWITCH_MESSAGE);
427
+ if (controlled && defaultModel !== void 0) warnOnce("controlled-with-default", CONTROLLED_WITH_DEFAULT_MESSAGE);
419
428
  const actorRef = useActorRef(dashfooMachine, { input: { model: normalize(initialModel) } });
420
429
  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;
430
+ const model = useMemo(() => controlledModel === void 0 ? history.present : normalize(controlledModel), [controlledModel, history.present]);
428
431
  const notify = useCallback((before, after, action) => {
429
432
  if (Object.is(before, after)) return;
430
433
  onModelChange?.(after, action);
@@ -438,6 +441,10 @@ const useDashfooStore = (options) => {
438
441
  const dispatch = useCallback((action) => {
439
442
  const resolved = onAction ? onAction(action) : action;
440
443
  if (!resolved) return;
444
+ if (controlled) {
445
+ notify(model, reducer(model, resolved), resolved);
446
+ return;
447
+ }
441
448
  const before = actorRef.getSnapshot().context.history.present;
442
449
  actorRef.send({
443
450
  action: resolved,
@@ -446,46 +453,50 @@ const useDashfooStore = (options) => {
446
453
  notify(before, actorRef.getSnapshot().context.history.present, resolved);
447
454
  }, [
448
455
  actorRef,
456
+ controlled,
457
+ model,
449
458
  notify,
450
459
  onAction
451
460
  ]);
452
461
  const undo = useCallback(() => {
453
- if (controlledModel !== void 0) {
462
+ if (controlled) {
454
463
  warnOnce("controlled-undo", CONTROLLED_HISTORY_MESSAGE);
455
464
  return;
456
465
  }
457
466
  const before = actorRef.getSnapshot().context.history.present;
458
467
  actorRef.send({ type: "UNDO" });
459
- const after = actorRef.getSnapshot().context.history.present;
460
- notify(before, after);
468
+ notify(before, actorRef.getSnapshot().context.history.present);
461
469
  }, [
462
470
  actorRef,
463
- controlledModel,
471
+ controlled,
464
472
  notify
465
473
  ]);
466
474
  const redo = useCallback(() => {
467
- if (controlledModel !== void 0) {
475
+ if (controlled) {
468
476
  warnOnce("controlled-redo", CONTROLLED_HISTORY_MESSAGE);
469
477
  return;
470
478
  }
471
479
  const before = actorRef.getSnapshot().context.history.present;
472
480
  actorRef.send({ type: "REDO" });
473
- const after = actorRef.getSnapshot().context.history.present;
474
- notify(before, after);
481
+ notify(before, actorRef.getSnapshot().context.history.present);
475
482
  }, [
476
483
  actorRef,
477
- controlledModel,
484
+ controlled,
478
485
  notify
479
486
  ]);
480
487
  const setModel = useCallback((next) => {
488
+ if (controlled) {
489
+ warnOnce("controlled-set-model", CONTROLLED_SET_MODEL_MESSAGE);
490
+ return;
491
+ }
481
492
  actorRef.send({
482
493
  model: normalize(next),
483
494
  type: "SET_MODEL"
484
495
  });
485
- }, [actorRef]);
496
+ }, [actorRef, controlled]);
486
497
  return useMemo(() => ({
487
- canRedo: () => controlledModel === void 0 && canRedo(actorRef.getSnapshot().context.history),
488
- canUndo: () => controlledModel === void 0 && canUndo(actorRef.getSnapshot().context.history),
498
+ canRedo: () => !controlled && canRedo(actorRef.getSnapshot().context.history),
499
+ canUndo: () => !controlled && canUndo(actorRef.getSnapshot().context.history),
489
500
  dispatch,
490
501
  model,
491
502
  redo,
@@ -493,7 +504,7 @@ const useDashfooStore = (options) => {
493
504
  undo
494
505
  }), [
495
506
  actorRef,
496
- controlledModel,
507
+ controlled,
497
508
  dispatch,
498
509
  model,
499
510
  redo,
@@ -505,11 +516,8 @@ const useDashfooStore = (options) => {
505
516
  //#endregion
506
517
  //#region src/lib/drag-subject.ts
507
518
  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
- */
519
+ /** Beside the manager wiring, not the layer adapter: `dragstart` fires once per
520
+ * gesture on the shared manager, above any one layer. */
513
521
  const subjectFor = (source) => {
514
522
  const data = source.data;
515
523
  if (data?.type === "tabset") return {
@@ -571,7 +579,7 @@ const shouldAllowDrop = (draggedId, targetId, tabIds) => {
571
579
  };
572
580
 
573
581
  //#endregion
574
- //#region src/components/drag-overlays.tsx
582
+ //#region src/components/drag-preview-overlay.tsx
575
583
  const overlayBase = {
576
584
  boxSizing: "border-box",
577
585
  pointerEvents: "none",
@@ -595,9 +603,10 @@ const lineStyle = (zone) => ({
595
603
  });
596
604
  const tabItemRects = (strip, excludeId) => [...strip.querySelectorAll("[data-dashfoo=\"tab-item\"]")].flatMap((item) => item.querySelector("[data-dashfoo=\"tab\"]")?.dataset.tabId === excludeId ? [] : [item.getBoundingClientRect()]);
597
605
  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;
606
+ const drag = useSelector(actorRef, (snapshot) => snapshot.context.drag);
607
+ if (drag.kind !== "dragging" || drag.drop === null) return null;
608
+ const { intent } = drag.drop;
609
+ const draggedId = drag.subject.id;
601
610
  const element = getTabsetElement(intent.targetId);
602
611
  if (!element) return null;
603
612
  const zIndex = element.closest("[data-dashfoo=\"float\"]") ? overlayBase.zIndex : "auto";
@@ -653,12 +662,13 @@ const DragPreviewOverlay = ({ manager }) => {
653
662
  offEnd();
654
663
  };
655
664
  }, [manager]);
665
+ const attachOverlay = useCallback((element) => {
666
+ const feedback = manager.registry.plugins.get(Feedback);
667
+ if (feedback) feedback.overlay = element ?? void 0;
668
+ }, [manager]);
656
669
  return /* @__PURE__ */ jsx("div", {
657
670
  "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]),
671
+ ref: attachOverlay,
662
672
  children: chip === null ? null : /* @__PURE__ */ jsx("div", {
663
673
  "data-dashfoo": "drag-preview",
664
674
  style: {
@@ -674,7 +684,7 @@ const DragPreviewOverlay = ({ manager }) => {
674
684
  };
675
685
 
676
686
  //#endregion
677
- //#region src/components/drag-root.tsx
687
+ //#region src/components/dashfoo-drag-provider.tsx
678
688
  const DashfooDragProvider = ({ children }) => {
679
689
  const [manager] = useState(createDragManager);
680
690
  useInsertionEffect(() => () => {
@@ -688,11 +698,6 @@ const DashfooDragProvider = ({ children }) => {
688
698
  scopes.delete(scope.layerId);
689
699
  };
690
700
  }, [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
701
  useEffect(() => {
697
702
  const scopedTarget = () => {
698
703
  const target = manager.dragOperation.target;
@@ -706,15 +711,16 @@ const DashfooDragProvider = ({ children }) => {
706
711
  tabsetId: data.tabsetId
707
712
  };
708
713
  };
709
- let intentLayerId = null;
710
714
  const syncIntent = () => {
711
715
  const operation = manager.dragOperation;
712
716
  const found = scopedTarget();
713
717
  const draggedId = operation.source ? String(operation.source.id) : void 0;
714
718
  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
719
  actorRef.send({
717
- intent,
720
+ drop: intent === null || found === null ? null : {
721
+ intent,
722
+ scope: found.scope.layerId
723
+ },
718
724
  type: "OVER"
719
725
  });
720
726
  };
@@ -726,19 +732,27 @@ const DashfooDragProvider = ({ children }) => {
726
732
  manager.actions.stop({ canceled: true });
727
733
  return;
728
734
  }
729
- intentLayerId = null;
730
735
  actorRef.send({
731
736
  subject,
732
737
  type: "START"
733
738
  });
734
739
  };
735
- const handleMove = () => {
736
- syncIntent();
740
+ let frame = null;
741
+ const scheduleResolve = () => {
742
+ if (frame !== null) return;
743
+ frame = requestAnimationFrame(() => {
744
+ frame = null;
745
+ syncIntent();
746
+ });
737
747
  };
738
- const handleCollision = () => {
739
- syncIntent();
748
+ const cancelPendingResolve = () => {
749
+ if (frame !== null) {
750
+ cancelAnimationFrame(frame);
751
+ frame = null;
752
+ }
740
753
  };
741
754
  const handleEnd = (event) => {
755
+ cancelPendingResolve();
742
756
  if (event.canceled) {
743
757
  actorRef.send({ type: "CANCEL" });
744
758
  return;
@@ -747,13 +761,14 @@ const DashfooDragProvider = ({ children }) => {
747
761
  actorRef.send({ type: "DROP" });
748
762
  };
749
763
  const commitSubscription = actorRef.on("COMMIT", (emitted) => {
750
- (intentLayerId === null ? void 0 : scopes.get(intentLayerId))?.commit(emitted.action);
764
+ scopes.get(emitted.scope)?.commit(emitted.action);
751
765
  });
752
766
  const offStart = manager.monitor.addEventListener("dragstart", handleStart);
753
- const offMove = manager.monitor.addEventListener("dragmove", handleMove);
754
- const offCollision = manager.monitor.addEventListener("collision", handleCollision);
767
+ const offMove = manager.monitor.addEventListener("dragmove", scheduleResolve);
768
+ const offCollision = manager.monitor.addEventListener("collision", scheduleResolve);
755
769
  const offEnd = manager.monitor.addEventListener("dragend", handleEnd);
756
770
  return () => {
771
+ cancelPendingResolve();
757
772
  commitSubscription.unsubscribe();
758
773
  offStart();
759
774
  offMove();
@@ -794,19 +809,34 @@ const useLayout = (selector) => {
794
809
  };
795
810
 
796
811
  //#endregion
797
- //#region src/components/drag-adapter.tsx
812
+ //#region src/components/drag-provider.tsx
798
813
  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) => {
814
+ /** One DOM pass per pointer move, where resolving a drop used to run three. */
815
+ const measureStrip = (element, excludeId) => {
801
816
  const strip = element.querySelector("[data-dashfoo=\"tabstrip\"]");
802
- if (strip && pointInRect(point, strip.getBoundingClientRect())) return {
803
- index: insertionIndex(tabRects(strip, draggedId), point.x),
817
+ if (!strip) return null;
818
+ const tabIds = [];
819
+ const tabRects = [];
820
+ for (const tab of strip.querySelectorAll("[data-dashfoo=\"tab\"]")) {
821
+ const tabId = tab.dataset.tabId ?? "";
822
+ tabIds.push(tabId);
823
+ if (tabId !== excludeId) tabRects.push(tab.getBoundingClientRect());
824
+ }
825
+ return {
826
+ rect: strip.getBoundingClientRect(),
827
+ tabIds,
828
+ tabRects
829
+ };
830
+ };
831
+ const intentForTabset = (id, element, point, strip) => {
832
+ if (strip && pointInRect(point, strip.rect)) return {
833
+ index: insertionIndex(strip.tabRects, point.x),
804
834
  location: "center",
805
835
  targetId: id
806
836
  };
807
- const location = dockLocationFor(resolveDockTarget(point, element.getBoundingClientRect()));
837
+ const location = resolveDockTarget(point, element.getBoundingClientRect());
808
838
  if (location === "center" && strip) return {
809
- index: tabRects(strip, draggedId).length,
839
+ index: strip.tabRects.length,
810
840
  location,
811
841
  targetId: id
812
842
  };
@@ -815,11 +845,7 @@ const intentForTabset = (id, element, point, draggedId) => {
815
845
  targetId: id
816
846
  };
817
847
  };
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
- */
848
+ /** One drag layer (the main tree, or one float). Holds no drag state of its own. */
823
849
  const DragScope = ({ children, onCommit, splitDock }) => {
824
850
  const root = useContext(DragRootContext);
825
851
  const layoutStore = useContext(LayoutStoreContext);
@@ -842,8 +868,9 @@ const DragScope = ({ children, onCommit, splitDock }) => {
842
868
  layerId,
843
869
  resolveIntent: (targetId, element, point, draggedId) => {
844
870
  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);
871
+ const strip = measureStrip(element, draggedId);
872
+ if (!shouldAllowDrop(draggedId, targetId, strip?.tabIds ?? [])) return null;
873
+ const intent = intentForTabset(targetId, element, point, strip);
847
874
  if (!(splitDock ?? layoutStore?.getState().splitDock ?? true) && splitEdge(intent.location) !== void 0) return {
848
875
  location: "center",
849
876
  targetId
@@ -873,10 +900,8 @@ const DragScope = ({ children, onCommit, splitDock }) => {
873
900
  });
874
901
  };
875
902
  /**
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.
903
+ * Mounts its own root when there is none above it. Decided on the first render:
904
+ * a root that disappears later means a torn-down tree, which `DragScope` reports.
880
905
  */
881
906
  const DragProvider = (props) => {
882
907
  const hasRoot = useContext(DragRootContext) !== null;
@@ -1062,16 +1087,11 @@ const CHIP_SIZE = {
1062
1087
  width: 168
1063
1088
  };
1064
1089
  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
- */
1090
+ /** Writes inline style and dispatches only on pointer-up: routing every move
1091
+ * through the model would re-render the whole tree behind the float. */
1070
1092
  const useFloatGesture = ({ editable, geometry, minimized, onCommit, onTap }) => {
1071
1093
  const panelRef = useRef(null);
1072
1094
  const gestureRef = useRef(null);
1073
- const latestRef = useRef(geometry);
1074
- const movedRef = useRef(false);
1075
1095
  const setPanel = (element) => {
1076
1096
  panelRef.current = element;
1077
1097
  };
@@ -1080,7 +1100,6 @@ const useFloatGesture = ({ editable, geometry, minimized, onCommit, onTap }) =>
1080
1100
  if (!panel || !editable) return;
1081
1101
  const prior = gestureRef.current;
1082
1102
  if (prior && panel.hasPointerCapture?.(prior.pointerId)) panel.releasePointerCapture(prior.pointerId);
1083
- movedRef.current = false;
1084
1103
  const edgeKey = event.currentTarget.dataset.edge;
1085
1104
  const parent = panel.offsetParent instanceof HTMLElement ? panel.offsetParent : null;
1086
1105
  const gesture = {
@@ -1089,13 +1108,14 @@ const useFloatGesture = ({ editable, geometry, minimized, onCommit, onTap }) =>
1089
1108
  width: parent?.clientWidth ?? 0
1090
1109
  },
1091
1110
  edges: edgeKey === void 0 ? null : EDGE_BY_KEY.get(edgeKey) ?? null,
1111
+ latest: geometry,
1112
+ moved: false,
1092
1113
  pointerId: event.pointerId,
1093
1114
  start: geometry,
1094
1115
  startX: event.clientX,
1095
1116
  startY: event.clientY
1096
1117
  };
1097
1118
  gestureRef.current = gesture;
1098
- latestRef.current = geometry;
1099
1119
  if (gesture.edges) panel.setPointerCapture(event.pointerId);
1100
1120
  };
1101
1121
  const handlePointerUp = (event) => {
@@ -1104,11 +1124,11 @@ const useFloatGesture = ({ editable, geometry, minimized, onCommit, onTap }) =>
1104
1124
  gestureRef.current = null;
1105
1125
  const panel = panelRef.current;
1106
1126
  if (panel?.hasPointerCapture?.(event.pointerId) === true) panel.releasePointerCapture(event.pointerId);
1107
- if (!movedRef.current) {
1127
+ if (!gesture.moved) {
1108
1128
  onTap();
1109
1129
  return;
1110
1130
  }
1111
- onCommit(latestRef.current);
1131
+ onCommit(gesture.latest);
1112
1132
  };
1113
1133
  const handlePointerMove = (event) => {
1114
1134
  const gesture = gestureRef.current;
@@ -1120,9 +1140,9 @@ const useFloatGesture = ({ editable, geometry, minimized, onCommit, onTap }) =>
1120
1140
  }
1121
1141
  const dx = event.clientX - gesture.startX;
1122
1142
  const dy = event.clientY - gesture.startY;
1123
- if (!movedRef.current) {
1143
+ if (!gesture.moved) {
1124
1144
  if (Math.hypot(dx, dy) <= TAP_SLOP) return;
1125
- movedRef.current = true;
1145
+ gesture.moved = true;
1126
1146
  if (!gesture.edges) panel.setPointerCapture(gesture.pointerId);
1127
1147
  }
1128
1148
  const next = gesture.edges ? resizeRect(gesture.start, gesture.edges, dx, dy) : clampToBounds({
@@ -1130,7 +1150,7 @@ const useFloatGesture = ({ editable, geometry, minimized, onCommit, onTap }) =>
1130
1150
  left: gesture.start.left + dx,
1131
1151
  top: gesture.start.top + dy
1132
1152
  }, gesture.bounds, minimized ? CHIP_SIZE : void 0);
1133
- latestRef.current = next;
1153
+ gesture.latest = next;
1134
1154
  panel.style.left = `${next.left}px`;
1135
1155
  panel.style.top = `${next.top}px`;
1136
1156
  if (!minimized) {
@@ -1144,7 +1164,7 @@ const useFloatGesture = ({ editable, geometry, minimized, onCommit, onTap }) =>
1144
1164
  if (!gesture || !panel || event.pointerId !== gesture.pointerId) return;
1145
1165
  gestureRef.current = null;
1146
1166
  if (panel.hasPointerCapture?.(event.pointerId)) panel.releasePointerCapture(event.pointerId);
1147
- if (!movedRef.current) return;
1167
+ if (!gesture.moved) return;
1148
1168
  panel.style.left = `${gesture.start.left}px`;
1149
1169
  panel.style.top = `${gesture.start.top}px`;
1150
1170
  if (!minimized) {
@@ -1205,6 +1225,95 @@ const useInlineRename = ({ currentName, inputRef, onCommit, onDone }) => {
1205
1225
  };
1206
1226
  };
1207
1227
 
1228
+ //#endregion
1229
+ //#region src/components/close-icon.tsx
1230
+ const iconStyle = { pointerEvents: "none" };
1231
+ const Icon = ({ children }) => /* @__PURE__ */ jsx("svg", {
1232
+ "aria-hidden": "true",
1233
+ fill: "none",
1234
+ height: "12",
1235
+ stroke: "currentColor",
1236
+ strokeLinecap: "round",
1237
+ strokeLinejoin: "round",
1238
+ strokeWidth: "2",
1239
+ style: iconStyle,
1240
+ viewBox: "0 0 24 24",
1241
+ width: "12",
1242
+ children
1243
+ });
1244
+ const CloseIcon = () => /* @__PURE__ */ jsxs(Icon, { children: [/* @__PURE__ */ jsx("path", { d: "M18 6 6 18" }), /* @__PURE__ */ jsx("path", { d: "m6 6 12 12" })] });
1245
+ const GripIcon = () => /* @__PURE__ */ jsxs(Icon, { children: [
1246
+ /* @__PURE__ */ jsx("circle", {
1247
+ cx: "12",
1248
+ cy: "5",
1249
+ r: "1"
1250
+ }),
1251
+ /* @__PURE__ */ jsx("circle", {
1252
+ cx: "19",
1253
+ cy: "5",
1254
+ r: "1"
1255
+ }),
1256
+ /* @__PURE__ */ jsx("circle", {
1257
+ cx: "5",
1258
+ cy: "5",
1259
+ r: "1"
1260
+ }),
1261
+ /* @__PURE__ */ jsx("circle", {
1262
+ cx: "12",
1263
+ cy: "12",
1264
+ r: "1"
1265
+ }),
1266
+ /* @__PURE__ */ jsx("circle", {
1267
+ cx: "19",
1268
+ cy: "12",
1269
+ r: "1"
1270
+ }),
1271
+ /* @__PURE__ */ jsx("circle", {
1272
+ cx: "5",
1273
+ cy: "12",
1274
+ r: "1"
1275
+ }),
1276
+ /* @__PURE__ */ jsx("circle", {
1277
+ cx: "12",
1278
+ cy: "19",
1279
+ r: "1"
1280
+ }),
1281
+ /* @__PURE__ */ jsx("circle", {
1282
+ cx: "19",
1283
+ cy: "19",
1284
+ r: "1"
1285
+ }),
1286
+ /* @__PURE__ */ jsx("circle", {
1287
+ cx: "5",
1288
+ cy: "19",
1289
+ r: "1"
1290
+ })
1291
+ ] });
1292
+ const MaximizeIcon = ({ maximized }) => maximized ? /* @__PURE__ */ jsxs(Icon, { children: [
1293
+ /* @__PURE__ */ jsx("path", { d: "m14 10 7-7" }),
1294
+ /* @__PURE__ */ jsx("path", { d: "M20 10h-6V4" }),
1295
+ /* @__PURE__ */ jsx("path", { d: "m3 21 7-7" }),
1296
+ /* @__PURE__ */ jsx("path", { d: "M4 14h6v6" })
1297
+ ] }) : /* @__PURE__ */ jsxs(Icon, { children: [
1298
+ /* @__PURE__ */ jsx("path", { d: "M15 3h6v6" }),
1299
+ /* @__PURE__ */ jsx("path", { d: "m21 3-7 7" }),
1300
+ /* @__PURE__ */ jsx("path", { d: "m3 21 7-7" }),
1301
+ /* @__PURE__ */ jsx("path", { d: "M9 21H3v-6" })
1302
+ ] });
1303
+ const FloatIcon = () => /* @__PURE__ */ jsxs(Icon, { children: [/* @__PURE__ */ jsx("rect", {
1304
+ height: "14",
1305
+ rx: "2",
1306
+ width: "18",
1307
+ x: "3",
1308
+ y: "6"
1309
+ }), /* @__PURE__ */ jsx("path", { d: "M3 10h18" })] });
1310
+ const MinimizeIcon = () => /* @__PURE__ */ jsx(Icon, { children: /* @__PURE__ */ jsx("path", { d: "M5 12h14" }) });
1311
+ const DockIcon = () => /* @__PURE__ */ jsxs(Icon, { children: [
1312
+ /* @__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" }),
1313
+ /* @__PURE__ */ jsx("path", { d: "M11 8 7 12l4 4" }),
1314
+ /* @__PURE__ */ jsx("path", { d: "M7 12h10" })
1315
+ ] });
1316
+
1208
1317
  //#endregion
1209
1318
  //#region src/components/layout-root.tsx
1210
1319
  const DEFAULT_TABSET_MIN_SIZE = 320;
@@ -1261,10 +1370,8 @@ const LayoutRoot = ({ children, closableTabs = true, dispatch, draggableTabs = t
1261
1370
  });
1262
1371
  };
1263
1372
  /**
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.
1373
+ * Republishes the parent store with overrides applied rather than re-deriving
1374
+ * flags, so anything the parent changes keeps flowing into the subtree.
1268
1375
  */
1269
1376
  const LayoutOverrides = ({ children, overrides }) => {
1270
1377
  const snapshot = {
@@ -1402,7 +1509,7 @@ const createTabsetStore = (initial) => createStore((set, get) => ({
1402
1509
  set({ editingTabId: null });
1403
1510
  },
1404
1511
  closeTab: (tabId) => {
1405
- set({ restoreFocus: true });
1512
+ set({ pendingCloseTabId: tabId });
1406
1513
  get().dispatch({
1407
1514
  tabId,
1408
1515
  type: "deleteTab"
@@ -1417,6 +1524,7 @@ const createTabsetStore = (initial) => createStore((set, get) => ({
1417
1524
  },
1418
1525
  editingTabId: null,
1419
1526
  overflowItems: [],
1527
+ pendingCloseTabId: null,
1420
1528
  registerRenameInput: () => {
1421
1529
  set((state) => ({ renameInputCount: state.renameInputCount + 1 }));
1422
1530
  return () => {
@@ -1440,7 +1548,6 @@ const createTabsetStore = (initial) => createStore((set, get) => ({
1440
1548
  type: "renameTab"
1441
1549
  });
1442
1550
  },
1443
- restoreFocus: false,
1444
1551
  selectOverflowTab: (tabId) => {
1445
1552
  const { dispatch, node, tablistElement } = get();
1446
1553
  const index = node.children.findIndex((tab) => tab.id === tabId);
@@ -1554,7 +1661,7 @@ const TabsetContent = ({ children, style, ...props }) => {
1554
1661
  };
1555
1662
 
1556
1663
  //#endregion
1557
- //#region src/components/tab-overflow.tsx
1664
+ //#region src/components/tab-overflow-menu.tsx
1558
1665
  const OverflowIcon = () => /* @__PURE__ */ jsxs("svg", {
1559
1666
  "aria-hidden": "true",
1560
1667
  fill: "currentColor",
@@ -1642,8 +1749,6 @@ const TabOverflowMenu = ({ items, onSelect }) => {
1642
1749
  case "Escape":
1643
1750
  event.preventDefault();
1644
1751
  closeAndReturnFocus();
1645
- break;
1646
- default: break;
1647
1752
  }
1648
1753
  };
1649
1754
  const handleFocusOut = (event) => {
@@ -1689,7 +1794,7 @@ const TabOverflowMenu = ({ items, onSelect }) => {
1689
1794
  };
1690
1795
 
1691
1796
  //#endregion
1692
- //#region src/components/tabset/tabset-overflow.tsx
1797
+ //#region src/components/tabset/tabset-overflow-menu.tsx
1693
1798
  const TabsetOverflowMenu = () => {
1694
1799
  const overflowItems = useTabset((state) => state.overflowItems);
1695
1800
  const selectOverflowTab = useTabset((state) => state.selectOverflowTab);
@@ -1700,6 +1805,83 @@ const TabsetOverflowMenu = () => {
1700
1805
  });
1701
1806
  };
1702
1807
 
1808
+ //#endregion
1809
+ //#region src/lib/merge-refs.ts
1810
+ const applyRef = (ref, value) => {
1811
+ if (typeof ref === "function") ref(value);
1812
+ else if (ref) ref.current = value;
1813
+ };
1814
+ const mergeRefs = (...refs) => (value) => {
1815
+ for (const ref of refs) applyRef(ref, value);
1816
+ };
1817
+
1818
+ //#endregion
1819
+ //#region src/components/tabset/tabset-rename-input.tsx
1820
+ const RenameEditor = ({ defaultValue, onBlur, onKeyDown, ref: userRef, ...props }) => {
1821
+ const { tab } = useTab();
1822
+ const cancelRename = useTabset((state) => state.cancelRename);
1823
+ const commitRename = useTabset((state) => state.commitRename);
1824
+ const inputRef = useRef(null);
1825
+ const rename = useInlineRename({
1826
+ currentName: tab.name,
1827
+ inputRef,
1828
+ onCommit: (name) => {
1829
+ commitRename(tab.id, name);
1830
+ },
1831
+ onDone: cancelRename
1832
+ });
1833
+ const refCallback = useMemo(() => mergeRefs(inputRef, userRef), [userRef]);
1834
+ const handleKeyDown = (event) => {
1835
+ onKeyDown?.(event);
1836
+ rename.handleKeyDown(event);
1837
+ };
1838
+ const handleCommitOnBlur = (event) => {
1839
+ onBlur?.(event);
1840
+ rename.handleBlur(event);
1841
+ };
1842
+ return /* @__PURE__ */ jsx("input", {
1843
+ "aria-label": `Rename ${tab.name}`,
1844
+ ...props,
1845
+ "data-dashfoo": "tab-rename",
1846
+ defaultValue: defaultValue ?? tab.name,
1847
+ onBlur: handleCommitOnBlur,
1848
+ onKeyDown: handleKeyDown,
1849
+ ref: refCallback,
1850
+ type: "text"
1851
+ });
1852
+ };
1853
+ const TabsetRenameInput = (props) => {
1854
+ const { tab } = useTab();
1855
+ const editing = useTabset((state) => state.editingTabId === tab.id);
1856
+ const registerRenameInput = useTabset((state) => state.registerRenameInput);
1857
+ useEffect(() => registerRenameInput(), [registerRenameInput]);
1858
+ if (!editing) return null;
1859
+ return /* @__PURE__ */ jsx(RenameEditor, { ...props });
1860
+ };
1861
+ const TabsetCloseButton = ({ children, onClick, ...props }) => {
1862
+ const { index, tab } = useTab();
1863
+ const closeTab = useTabset((state) => state.closeTab);
1864
+ const editing = useTabset((state) => state.editingTabId === tab.id);
1865
+ const tabsClosable = useTabset((state) => state.tabsClosable);
1866
+ const visualSelected = useTabset((state) => state.visualSelected);
1867
+ if (!(tabsClosable && tab.enableClose !== false) || editing) return null;
1868
+ const handleClick = (event) => {
1869
+ onClick?.(event);
1870
+ event.stopPropagation();
1871
+ closeTab(tab.id);
1872
+ };
1873
+ return /* @__PURE__ */ jsx("button", {
1874
+ "aria-label": `Close ${tab.name}`,
1875
+ title: `Close ${tab.name}`,
1876
+ ...props,
1877
+ "data-dashfoo": "tab-close",
1878
+ onClick: handleClick,
1879
+ tabIndex: index === visualSelected ? 0 : -1,
1880
+ type: "button",
1881
+ children: children ?? /* @__PURE__ */ jsx(CloseIcon, {})
1882
+ });
1883
+ };
1884
+
1703
1885
  //#endregion
1704
1886
  //#region src/hooks/use-tab-overflow.ts
1705
1887
  const overflowingIds = (tablist) => {
@@ -1711,13 +1893,14 @@ const overflowingIds = (tablist) => {
1711
1893
  return clipped && tabId !== void 0 && tabId !== "" ? [tabId] : [];
1712
1894
  });
1713
1895
  };
1896
+ const sameIds = (a, b) => a.length === b.length && a.every((id, index) => id === b[index]);
1714
1897
  const useTabOverflow = (tablist, signature) => {
1715
1898
  const [overflow, setOverflow] = useState([]);
1716
1899
  useEffect(() => {
1717
1900
  const recompute = () => {
1718
1901
  setOverflow((previous) => {
1719
1902
  const next = tablist ? overflowingIds(tablist) : [];
1720
- return next.length === 0 && previous.length === 0 ? previous : next;
1903
+ return sameIds(previous, next) ? previous : next;
1721
1904
  });
1722
1905
  };
1723
1906
  recompute();
@@ -1779,7 +1962,9 @@ const TabsetRoot = ({ children, node, ref: userRef, style, ...props }) => {
1779
1962
  useLayoutEffect(() => {
1780
1963
  store.setState(snapshot);
1781
1964
  });
1782
- const overflow = useTabOverflow(useStore(store, (state) => state.tablistElement), node.children.map((tab) => `${tab.id}:${tab.name}`).join("|"));
1965
+ const tablistElement = useStore(store, (state) => state.tablistElement);
1966
+ const overflowSignature = node.children.map((tab) => `${tab.id}:${tab.name}`).join("|");
1967
+ const overflow = useTabOverflow(tablistElement, overflowSignature);
1783
1968
  const overflowItems = useMemo(() => {
1784
1969
  const names = new Map(node.children.map((tab) => [tab.id, tab.name]));
1785
1970
  return overflow.map((id) => ({
@@ -1791,8 +1976,10 @@ const TabsetRoot = ({ children, node, ref: userRef, style, ...props }) => {
1791
1976
  store.setState({ overflowItems });
1792
1977
  }, [overflowItems, store]);
1793
1978
  useEffect(() => {
1794
- if (!store.getState().restoreFocus) return;
1795
- store.setState({ restoreFocus: false });
1979
+ const { pendingCloseTabId } = store.getState();
1980
+ if (pendingCloseTabId === null) return;
1981
+ store.setState({ pendingCloseTabId: null });
1982
+ if (node.children.some((tab) => tab.id === pendingCloseTabId)) return;
1796
1983
  const activeTabId = node.children[node.selected]?.id;
1797
1984
  if (activeTabId) tabsetRef.current?.querySelector(`#${CSS.escape(tabDomId(node.id, activeTabId))}`)?.focus();
1798
1985
  else tabsetRef.current?.focus();
@@ -1825,16 +2012,6 @@ const TabsetRoot = ({ children, node, ref: userRef, style, ...props }) => {
1825
2012
  });
1826
2013
  };
1827
2014
 
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
- };
1837
-
1838
2015
  //#endregion
1839
2016
  //#region src/components/tabset/tabset-tab.tsx
1840
2017
  const TabsetTab = ({ children, ref: userRef, tab, ...props }) => {
@@ -1914,162 +2091,6 @@ const TabsetTrigger = ({ children, onClick, onDoubleClick, ref: userRef, ...prop
1914
2091
  });
1915
2092
  };
1916
2093
 
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
- });
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
- };
2072
-
2073
2094
  //#endregion
2074
2095
  //#region src/components/tabset/tabset-tablist.tsx
2075
2096
  const stripStyle = {
@@ -2093,15 +2114,17 @@ const TabsetTablist = ({ onKeyDown, ref: userRef, style, ...props }) => {
2093
2114
  const node = useTabset((state) => state.node);
2094
2115
  const registerTablist = useTabset((state) => state.registerTablist);
2095
2116
  const selectTab = useTabset((state) => state.selectTab);
2117
+ const visualSelected = useTabset((state) => state.visualSelected);
2096
2118
  const handleKeyDown = (event) => {
2097
2119
  onKeyDown?.(event);
2098
2120
  if (event.defaultPrevented) return;
2099
2121
  if (!(event.target instanceof HTMLElement) || !event.target.closest("[role=\"tab\"]")) return;
2100
2122
  const count = node.children.length;
2101
2123
  if (count === 0) return;
2124
+ const from = Math.max(visualSelected, 0);
2102
2125
  const next = {
2103
- ArrowLeft: (node.selected - 1 + count) % count,
2104
- ArrowRight: (node.selected + 1) % count,
2126
+ ArrowLeft: (from - 1 + count) % count,
2127
+ ArrowRight: (from + 1) % count,
2105
2128
  End: count - 1,
2106
2129
  Home: 0
2107
2130
  }[event.key];
@@ -2142,14 +2165,32 @@ const TabsetToolbar = ({ style, ...props }) => /* @__PURE__ */ jsx("div", {
2142
2165
  ...style
2143
2166
  }
2144
2167
  });
2168
+ /**
2169
+ * One answer per control, shared by the controls and by the layout deciding
2170
+ * whether to render a toolbar at all, so the two cannot disagree.
2171
+ */
2172
+ const useTabsetChrome = () => {
2173
+ const draggableTabsets = useLayout((state) => state.draggableTabsets);
2174
+ const floatable = useLayout((state) => state.floatable);
2175
+ const isMaximized = useTabset((state) => state.isMaximized);
2176
+ const overflowCount = useTabset((state) => state.overflowItems.length);
2177
+ const showMaximize = useTabset((state) => state.showMaximize);
2178
+ const hasFloatLayer = useHasFloatLayer();
2179
+ 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");
2180
+ return {
2181
+ float: floatable && !isMaximized && hasFloatLayer,
2182
+ grip: draggableTabsets && !isMaximized,
2183
+ maximize: showMaximize,
2184
+ overflow: overflowCount > 0
2185
+ };
2186
+ };
2145
2187
  const TabsetGrip = ({ children, ref: userRef, ...props }) => {
2146
2188
  const activeTabName = useTabset((state) => state.activeTab?.name ?? "");
2147
- const isMaximized = useTabset((state) => state.isMaximized);
2148
2189
  const node = useTabset((state) => state.node);
2149
- const hidden = !useLayout((state) => state.draggableTabsets) || isMaximized;
2150
- const { ref } = useTabsetDraggable(node.id, hidden, activeTabName);
2190
+ const { grip } = useTabsetChrome();
2191
+ const { ref } = useTabsetDraggable(node.id, !grip, activeTabName);
2151
2192
  const refCallback = useMemo(() => mergeRefs(ref, userRef), [ref, userRef]);
2152
- if (hidden) return null;
2193
+ if (!grip) return null;
2153
2194
  return /* @__PURE__ */ jsx("button", {
2154
2195
  "aria-label": "Move tabset",
2155
2196
  title: "Move tabset",
@@ -2162,9 +2203,9 @@ const TabsetGrip = ({ children, ref: userRef, ...props }) => {
2162
2203
  };
2163
2204
  const TabsetMaximizeButton = ({ children, onClick, ...props }) => {
2164
2205
  const isMaximized = useTabset((state) => state.isMaximized);
2165
- const showMaximize = useTabset((state) => state.showMaximize);
2166
2206
  const toggleMaximize = useTabset((state) => state.toggleMaximize);
2167
- if (!showMaximize) return null;
2207
+ const { maximize } = useTabsetChrome();
2208
+ if (!maximize) return null;
2168
2209
  const handleClick = (event) => {
2169
2210
  onClick?.(event);
2170
2211
  toggleMaximize();
@@ -2181,16 +2222,10 @@ const TabsetMaximizeButton = ({ children, onClick, ...props }) => {
2181
2222
  });
2182
2223
  };
2183
2224
  const TabsetFloatButton = ({ children, onClick, ...props }) => {
2184
- const floatable = useLayout((state) => state.floatable);
2185
2225
  const dispatch = useLayout((state) => state.dispatch);
2186
- const isMaximized = useTabset((state) => state.isMaximized);
2187
2226
  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
- }
2227
+ const { float } = useTabsetChrome();
2228
+ if (!float) return null;
2194
2229
  const handleClick = (event) => {
2195
2230
  onClick?.(event);
2196
2231
  const geometry = measureFloatRect(event.currentTarget);
@@ -2215,15 +2250,12 @@ const TabsetFloatButton = ({ children, onClick, ...props }) => {
2215
2250
  //#region src/components/tabset/tabset-view.tsx
2216
2251
  const tabsetContent = /* @__PURE__ */ jsx(TabsetContent, {});
2217
2252
  const DefaultTabsetLayout = () => {
2218
- const draggableTabsets = useLayout((state) => state.draggableTabsets);
2219
- const floatable = useLayout((state) => state.floatable);
2220
2253
  const renderTabsetToolbar = useLayout((state) => state.renderTabsetToolbar);
2221
2254
  const tabLocation = useLayout((state) => state.tabLocation);
2222
2255
  const tabStripEnabled = useLayout((state) => state.tabStripEnabled);
2223
- const isMaximized = useTabset((state) => state.isMaximized);
2224
2256
  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;
2257
+ const chrome = useTabsetChrome();
2258
+ const showToolbar = renderTabsetToolbar !== void 0 || Object.values(chrome).some(Boolean);
2227
2259
  const strip = tabStripEnabled ? /* @__PURE__ */ jsxs(TabsetTabStrip, { children: [/* @__PURE__ */ jsx(TabsetTablist, { children: node.children.map((tab) => /* @__PURE__ */ jsxs(TabsetTab, {
2228
2260
  tab,
2229
2261
  children: [
@@ -2275,7 +2307,8 @@ const RowView = ({ node, renderTabset }) => {
2275
2307
  const tabsetMinSize = useLayout((state) => state.tabsetMinSize);
2276
2308
  const globalSnap = useLayout((state) => state.snap);
2277
2309
  const effectiveSnap = node.snap ?? globalSnap;
2278
- const snapActive = snapEnabled(effectiveSnap);
2310
+ const snapGrid = useMemo(() => resolveSnapGrid(effectiveSnap, node.children.length), [effectiveSnap, node.children.length]);
2311
+ const snapActive = snapEnabled(snapGrid);
2279
2312
  const orientation = node.orientation === "row" ? "horizontal" : "vertical";
2280
2313
  const total = node.children.reduce((sum, child) => sum + child.weight, 0);
2281
2314
  const { beginBoundaryDrag, groupRef, onLayoutChange, onLayoutChanged, snappedBoundary } = useSnapResize({
@@ -2288,7 +2321,7 @@ const RowView = ({ node, renderTabset }) => {
2288
2321
  });
2289
2322
  },
2290
2323
  resizableSplits,
2291
- snap: effectiveSnap
2324
+ snap: snapGrid
2292
2325
  });
2293
2326
  return /* @__PURE__ */ jsx(Group, {
2294
2327
  "data-dashfoo": "row",
@@ -2589,8 +2622,8 @@ const Layout = {
2589
2622
  //#endregion
2590
2623
  //#region src/components/dashfoo-layout.tsx
2591
2624
  const DEFAULT_PERSIST_DEBOUNCE_MS = 300;
2592
- const resolvePersist = (persist, hasDefaultModel) => {
2593
- if (persist === void 0 || !hasDefaultModel) return null;
2625
+ const resolvePersist = (persist) => {
2626
+ if (persist === void 0) return null;
2594
2627
  if (typeof persist === "string") return {
2595
2628
  debounceMs: DEFAULT_PERSIST_DEBOUNCE_MS,
2596
2629
  key: persist,
@@ -2605,7 +2638,8 @@ const resolvePersist = (persist, hasDefaultModel) => {
2605
2638
  const warnedComponents = /* @__PURE__ */ new Set();
2606
2639
  const DashfooLayout = forwardRef((props, ref) => {
2607
2640
  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);
2641
+ const persistConfig = useMemo(() => resolvePersist(persist), [persist]);
2642
+ const persistence = usePersistence(persistConfig, defaultModel);
2609
2643
  const handleModelChange = useCallback((next, action) => {
2610
2644
  persistence.save(next);
2611
2645
  onModelChange?.(next, action);
@@ -2696,14 +2730,25 @@ const DashfooLayout = forwardRef((props, ref) => {
2696
2730
  }, [components, factory]);
2697
2731
  const [containerRef, width] = useContainerWidth();
2698
2732
  const isCompact = responsive !== void 0 && width <= responsive.maxWidth;
2699
- const view = useMemo(() => isCompact ? stackModel(store.model, responsive?.orientation) : store.model, [
2733
+ /** Everything the compact breakpoint changes, decided once. */
2734
+ const presentation = useMemo(() => isCompact ? {
2735
+ maximizable: false,
2736
+ model: stackModel(store.model, responsive?.orientation),
2737
+ restructurable: false
2738
+ } : {
2739
+ maximizable,
2740
+ model: store.model,
2741
+ restructurable: true
2742
+ }, [
2700
2743
  isCompact,
2744
+ maximizable,
2701
2745
  responsive?.orientation,
2702
2746
  store.model
2703
2747
  ]);
2748
+ const view = presentation.model;
2704
2749
  const maximized = view.maximizedTabsetId === void 0 ? void 0 : findTabset(view, view.maximizedTabsetId);
2705
2750
  const tree = /* @__PURE__ */ jsx(Layout.FloatLayer, {
2706
- floats: store.model.floats,
2751
+ floats: view.floats,
2707
2752
  children: /* @__PURE__ */ jsx(Layout.DragLayer, { children: maximized ? /* @__PURE__ */ jsx(Layout.Tabset, { node: maximized }) : /* @__PURE__ */ jsx(Layout.Rows, { node: view.layout }) })
2708
2753
  });
2709
2754
  return /* @__PURE__ */ jsx(Layout.Root, {
@@ -2714,14 +2759,14 @@ const DashfooLayout = forwardRef((props, ref) => {
2714
2759
  editable,
2715
2760
  floatable,
2716
2761
  keepMounted,
2717
- maximizable,
2762
+ maximizable: presentation.maximizable,
2718
2763
  model: view,
2719
2764
  renamableTabs,
2720
2765
  renderTab,
2721
2766
  renderTabLabel,
2722
2767
  renderTabsetToolbar,
2723
2768
  resizableSplits,
2724
- restructurable: !isCompact,
2769
+ restructurable: presentation.restructurable,
2725
2770
  rootRef: containerRef,
2726
2771
  snap,
2727
2772
  children: hasSharedManager ? tree : /* @__PURE__ */ jsx(DashfooDragProvider, { children: tree })