@dashfoo/react 0.6.4 → 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,12 +1,12 @@
1
1
  "use client";
2
2
 
3
- import { canRedo, canUndo, dashfooMachine, dragDockMachine, findTabset, fromJSON, normalize, reducer, resolveDockTarget, snapEnabled, snapSizes, 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
- import { createStore, useStore } from "zustand";
7
- import { CollisionPriority, CollisionType } from "@dnd-kit/abstract";
8
6
  import { useActorRef, useSelector } from "@xstate/react";
7
+ import { CollisionPriority, CollisionType } from "@dnd-kit/abstract";
9
8
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
9
+ import { createStore, useStore } from "zustand";
10
10
  import { Group, Panel as Panel$1, Separator } from "react-resizable-panels";
11
11
 
12
12
  //#region src/lib/topmost-collision.ts
@@ -63,17 +63,11 @@ const createDragManager = () => new DragDropManager({
63
63
  plugins: (defaults) => [...defaults.filter((plugin) => plugin !== Accessibility && plugin !== Feedback), Feedback.configure({ dropAnimation: null })],
64
64
  sensors: () => [PointerSensor.configure({ preventActivation })]
65
65
  });
66
+ const DragRootContext = createContext(null);
66
67
  const DragContext = createContext(null);
67
68
  const SharedDragManagerContext = createContext(null);
68
- const createDragSubjectStore = () => createStore(() => ({
69
- intent: null,
70
- intentOwner: null,
71
- subject: null
72
- }));
69
+ /** The intent is rebuilt every pointer move, so readers compare by value. */
73
70
  const sameDropIntent = (a, b) => a === b || a !== null && b !== null && a.index === b.index && a.location === b.location && a.targetId === b.targetId;
74
- const sameDragSubject = (a, b) => a === b || a !== null && b !== null && a.id === b.id && a.kind === b.kind;
75
- const nullSubjectStore = createDragSubjectStore();
76
- const DragSubjectStoreContext = createContext(null);
77
71
  const useDraggableHandle = () => {
78
72
  const elementRef = useRef(null);
79
73
  const draggableRef = useRef(null);
@@ -194,11 +188,24 @@ const useExternalTabSource = ({ createTab, disabled = false, label = "" }) => {
194
188
  id: `external-${id}`
195
189
  }, disabled, label);
196
190
  };
191
+ /**
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.
195
+ */
197
196
  const useDragSubject = () => {
198
- return useStore(useContext(DragSubjectStoreContext) ?? nullSubjectStore, (state) => state.subject);
197
+ const actorRef = useContext(DragRootContext)?.actorRef;
198
+ return useSelector(actorRef, (snapshot) => {
199
+ const drag = snapshot?.context.drag;
200
+ return drag?.kind === "dragging" ? drag.subject : null;
201
+ });
199
202
  };
200
203
  const useDropIntent = () => {
201
- return useStore(useContext(DragSubjectStoreContext) ?? nullSubjectStore, (state) => state.intent);
204
+ const actorRef = useContext(DragRootContext)?.actorRef;
205
+ return useSelector(actorRef, (snapshot) => {
206
+ const drag = snapshot?.context.drag;
207
+ return drag?.kind === "dragging" ? drag.drop?.intent ?? null : null;
208
+ }, sameDropIntent);
202
209
  };
203
210
 
204
211
  //#endregion
@@ -244,28 +251,29 @@ const usePersistence = (config, defaultModel) => {
244
251
  useEffect(() => {
245
252
  configRef.current = config;
246
253
  });
247
- const [initialModel] = useState(() => {
248
- if (config === null || defaultModel === void 0) return defaultModel;
254
+ const [load] = useState(() => {
255
+ if (config === null) return { kind: "absent" };
249
256
  const raw = config.storage.getItem(config.key);
250
- if (raw === null) return defaultModel;
257
+ if (raw === null) return { kind: "absent" };
251
258
  try {
252
- return fromJSON(raw);
253
- } catch {
254
- return defaultModel;
259
+ return {
260
+ kind: "loaded",
261
+ model: fromJSON(raw)
262
+ };
263
+ } catch (error) {
264
+ return {
265
+ error,
266
+ kind: "corrupt"
267
+ };
255
268
  }
256
269
  });
270
+ const initialModel = load.kind === "loaded" && defaultModel !== void 0 ? load.model : defaultModel;
257
271
  useEffect(() => {
258
272
  const current = configRef.current;
259
- if (current === null) return;
260
- const raw = current.storage.getItem(current.key);
261
- if (raw === null) return;
262
- try {
263
- fromJSON(raw);
264
- } catch (error) {
265
- console.warn("[dashfoo] discarding unreadable persisted layout", error);
266
- current.storage.removeItem(current.key);
267
- }
268
- }, []);
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]);
269
277
  const loadedKey = useRef(config?.key);
270
278
  useEffect(() => {
271
279
  if (config !== null && config.key !== loadedKey.current) {
@@ -393,21 +401,33 @@ const useResponsiveModel = ({ breakpoints }) => {
393
401
  }, [active, containerRef]);
394
402
  };
395
403
 
404
+ //#endregion
405
+ //#region src/lib/warn-once.ts
406
+ const warned = /* @__PURE__ */ new Set();
407
+ const warnOnce = (key, message) => {
408
+ if (warned.has(key)) return;
409
+ warned.add(key);
410
+ console.warn(`[dashfoo] ${message}`);
411
+ };
412
+
396
413
  //#endregion
397
414
  //#region src/hooks/store.ts
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. */
398
421
  const useDashfooStore = (options) => {
399
422
  const { defaultModel, model: controlledModel, onAction, onActiveTabsetChange, onMaximizedTabsetChange, onModelChange } = options;
400
423
  const initialModel = controlledModel ?? defaultModel;
401
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);
402
428
  const actorRef = useActorRef(dashfooMachine, { input: { model: normalize(initialModel) } });
403
429
  const history = useSelector(actorRef, (snapshot) => snapshot.context.history);
404
- useEffect(() => {
405
- if (controlledModel !== void 0) actorRef.send({
406
- model: normalize(controlledModel),
407
- type: "SET_MODEL"
408
- });
409
- }, [actorRef, controlledModel]);
410
- const model = controlledModel ?? history.present;
430
+ const model = useMemo(() => controlledModel === void 0 ? history.present : normalize(controlledModel), [controlledModel, history.present]);
411
431
  const notify = useCallback((before, after, action) => {
412
432
  if (Object.is(before, after)) return;
413
433
  onModelChange?.(after, action);
@@ -421,61 +441,62 @@ const useDashfooStore = (options) => {
421
441
  const dispatch = useCallback((action) => {
422
442
  const resolved = onAction ? onAction(action) : action;
423
443
  if (!resolved) return;
424
- const transition = (() => {
425
- if (controlledModel === void 0) {
426
- const before = actorRef.getSnapshot().context.history.present;
427
- actorRef.send({
428
- action: resolved,
429
- type: "DISPATCH"
430
- });
431
- return {
432
- after: actorRef.getSnapshot().context.history.present,
433
- before
434
- };
435
- }
436
- return {
437
- after: reducer(controlledModel, resolved),
438
- before: normalize(controlledModel)
439
- };
440
- })();
441
- notify(transition.before, transition.after, resolved);
444
+ if (controlled) {
445
+ notify(model, reducer(model, resolved), resolved);
446
+ return;
447
+ }
448
+ const before = actorRef.getSnapshot().context.history.present;
449
+ actorRef.send({
450
+ action: resolved,
451
+ type: "DISPATCH"
452
+ });
453
+ notify(before, actorRef.getSnapshot().context.history.present, resolved);
442
454
  }, [
443
455
  actorRef,
444
- controlledModel,
456
+ controlled,
457
+ model,
445
458
  notify,
446
459
  onAction
447
460
  ]);
448
461
  const undo = useCallback(() => {
449
- if (controlledModel !== void 0) return;
462
+ if (controlled) {
463
+ warnOnce("controlled-undo", CONTROLLED_HISTORY_MESSAGE);
464
+ return;
465
+ }
450
466
  const before = actorRef.getSnapshot().context.history.present;
451
467
  actorRef.send({ type: "UNDO" });
452
- const after = actorRef.getSnapshot().context.history.present;
453
- notify(before, after);
468
+ notify(before, actorRef.getSnapshot().context.history.present);
454
469
  }, [
455
470
  actorRef,
456
- controlledModel,
471
+ controlled,
457
472
  notify
458
473
  ]);
459
474
  const redo = useCallback(() => {
460
- if (controlledModel !== void 0) return;
475
+ if (controlled) {
476
+ warnOnce("controlled-redo", CONTROLLED_HISTORY_MESSAGE);
477
+ return;
478
+ }
461
479
  const before = actorRef.getSnapshot().context.history.present;
462
480
  actorRef.send({ type: "REDO" });
463
- const after = actorRef.getSnapshot().context.history.present;
464
- notify(before, after);
481
+ notify(before, actorRef.getSnapshot().context.history.present);
465
482
  }, [
466
483
  actorRef,
467
- controlledModel,
484
+ controlled,
468
485
  notify
469
486
  ]);
470
487
  const setModel = useCallback((next) => {
488
+ if (controlled) {
489
+ warnOnce("controlled-set-model", CONTROLLED_SET_MODEL_MESSAGE);
490
+ return;
491
+ }
471
492
  actorRef.send({
472
493
  model: normalize(next),
473
494
  type: "SET_MODEL"
474
495
  });
475
- }, [actorRef]);
496
+ }, [actorRef, controlled]);
476
497
  return useMemo(() => ({
477
- canRedo: () => canRedo(actorRef.getSnapshot().context.history),
478
- canUndo: () => canUndo(actorRef.getSnapshot().context.history),
498
+ canRedo: () => !controlled && canRedo(actorRef.getSnapshot().context.history),
499
+ canUndo: () => !controlled && canUndo(actorRef.getSnapshot().context.history),
479
500
  dispatch,
480
501
  model,
481
502
  redo,
@@ -483,6 +504,7 @@ const useDashfooStore = (options) => {
483
504
  undo
484
505
  }), [
485
506
  actorRef,
507
+ controlled,
486
508
  dispatch,
487
509
  model,
488
510
  redo,
@@ -491,6 +513,46 @@ const useDashfooStore = (options) => {
491
513
  ]);
492
514
  };
493
515
 
516
+ //#endregion
517
+ //#region src/lib/drag-subject.ts
518
+ const isTabFactory = (value) => typeof value === "function";
519
+ /** Beside the manager wiring, not the layer adapter: `dragstart` fires once per
520
+ * gesture on the shared manager, above any one layer. */
521
+ const subjectFor = (source) => {
522
+ const data = source.data;
523
+ if (data?.type === "tabset") return {
524
+ id: String(data.tabsetId),
525
+ kind: "tabset"
526
+ };
527
+ if (data?.type === "external") {
528
+ if (!isTabFactory(data.createTab)) {
529
+ console.warn("[dashfoo] external drag source is missing its createTab function");
530
+ return null;
531
+ }
532
+ let candidate;
533
+ try {
534
+ candidate = data.createTab();
535
+ } catch (error) {
536
+ console.warn("[dashfoo] external drag source createTab threw", error);
537
+ return null;
538
+ }
539
+ const parsed = tabNodeSchema.safeParse(candidate);
540
+ if (!parsed.success) {
541
+ console.warn("[dashfoo] external drag source returned an invalid tab", parsed.error);
542
+ return null;
543
+ }
544
+ return {
545
+ id: String(source.id),
546
+ kind: "external",
547
+ tab: parsed.data
548
+ };
549
+ }
550
+ return {
551
+ id: String(source.id),
552
+ kind: "tab"
553
+ };
554
+ };
555
+
494
556
  //#endregion
495
557
  //#region src/lib/tab-insertion.ts
496
558
  const pointInRect = (point, rect) => point.x >= rect.x && point.x <= rect.x + rect.width && point.y >= rect.y && point.y <= rect.y + rect.height;
@@ -517,7 +579,7 @@ const shouldAllowDrop = (draggedId, targetId, tabIds) => {
517
579
  };
518
580
 
519
581
  //#endregion
520
- //#region src/components/drag-overlays.tsx
582
+ //#region src/components/drag-preview-overlay.tsx
521
583
  const overlayBase = {
522
584
  boxSizing: "border-box",
523
585
  pointerEvents: "none",
@@ -541,9 +603,10 @@ const lineStyle = (zone) => ({
541
603
  });
542
604
  const tabItemRects = (strip, excludeId) => [...strip.querySelectorAll("[data-dashfoo=\"tab-item\"]")].flatMap((item) => item.querySelector("[data-dashfoo=\"tab\"]")?.dataset.tabId === excludeId ? [] : [item.getBoundingClientRect()]);
543
605
  const DockIndicator = ({ actorRef, getTabsetElement }) => {
544
- const intent = useSelector(actorRef, (snapshot) => snapshot.context.intent);
545
- const draggedId = useSelector(actorRef, (snapshot) => snapshot.context.subject?.id);
546
- 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;
547
610
  const element = getTabsetElement(intent.targetId);
548
611
  if (!element) return null;
549
612
  const zIndex = element.closest("[data-dashfoo=\"float\"]") ? overlayBase.zIndex : "auto";
@@ -599,12 +662,13 @@ const DragPreviewOverlay = ({ manager }) => {
599
662
  offEnd();
600
663
  };
601
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]);
602
669
  return /* @__PURE__ */ jsx("div", {
603
670
  "data-dnd-overlay": "",
604
- ref: useCallback((element) => {
605
- const feedback = manager.registry.plugins.get(Feedback);
606
- if (feedback) feedback.overlay = element ?? void 0;
607
- }, [manager]),
671
+ ref: attachOverlay,
608
672
  children: chip === null ? null : /* @__PURE__ */ jsx("div", {
609
673
  "data-dashfoo": "drag-preview",
610
674
  style: {
@@ -620,166 +684,45 @@ const DragPreviewOverlay = ({ manager }) => {
620
684
  };
621
685
 
622
686
  //#endregion
623
- //#region src/components/drag-root.tsx
687
+ //#region src/components/dashfoo-drag-provider.tsx
624
688
  const DashfooDragProvider = ({ children }) => {
625
689
  const [manager] = useState(createDragManager);
626
690
  useInsertionEffect(() => () => {
627
691
  manager.destroy();
628
692
  }, [manager]);
629
- const [subjectStore] = useState(createDragSubjectStore);
630
- return /* @__PURE__ */ jsx(SharedDragManagerContext.Provider, {
631
- value: manager,
632
- children: /* @__PURE__ */ jsxs(DragSubjectStoreContext.Provider, {
633
- value: subjectStore,
634
- children: [children, /* @__PURE__ */ jsx(DragPreviewOverlay, { manager })]
635
- })
636
- });
637
- };
638
-
639
- //#endregion
640
- //#region src/hooks/layout-store.ts
641
- const createLayoutStore = (initial) => createStore(() => initial);
642
- const LayoutStoreContext = createContext(null);
643
- const useLayout = (selector) => {
644
- const store = useContext(LayoutStoreContext);
645
- if (store === null) throw new Error("dashfoo components must be rendered inside <DashfooLayout> or <Layout.Root>.");
646
- return useStore(store, selector);
647
- };
648
-
649
- //#endregion
650
- //#region src/lib/warn-once.ts
651
- const warned = /* @__PURE__ */ new Set();
652
- const warnOnce = (key, message) => {
653
- if (warned.has(key)) return;
654
- warned.add(key);
655
- console.warn(`[dashfoo] ${message}`);
656
- };
657
-
658
- //#endregion
659
- //#region src/components/drag-adapter.tsx
660
- const tabRects = (strip, excludeId) => [...strip.querySelectorAll("[data-dashfoo=\"tab\"]")].flatMap((tab) => tab.dataset.tabId === excludeId ? [] : [tab.getBoundingClientRect()]);
661
- const intentForTabset = (id, element, point, draggedId) => {
662
- const strip = element.querySelector("[data-dashfoo=\"tabstrip\"]");
663
- if (strip && pointInRect(point, strip.getBoundingClientRect())) return {
664
- index: insertionIndex(tabRects(strip, draggedId), point.x),
665
- location: "center",
666
- targetId: id
667
- };
668
- const target = resolveDockTarget(point, element.getBoundingClientRect());
669
- const location = target.kind === "tab" ? "center" : `split-${target.edge}`;
670
- if (location === "center" && strip) return {
671
- index: tabRects(strip, draggedId).length,
672
- location,
673
- targetId: id
674
- };
675
- return {
676
- location,
677
- targetId: id
678
- };
679
- };
680
- const isTabFactory = (value) => typeof value === "function";
681
- const subjectFor = (source) => {
682
- const data = source.data;
683
- if (data?.type === "tabset") return {
684
- id: String(data.tabsetId),
685
- kind: "tabset"
686
- };
687
- if (data?.type === "external") {
688
- if (!isTabFactory(data.createTab)) {
689
- console.warn("[dashfoo] external drag source is missing its createTab function");
690
- return null;
691
- }
692
- let candidate;
693
- try {
694
- candidate = data.createTab();
695
- } catch (error) {
696
- console.warn("[dashfoo] external drag source createTab threw", error);
697
- return null;
698
- }
699
- const parsed = tabNodeSchema.safeParse(candidate);
700
- if (!parsed.success) {
701
- console.warn("[dashfoo] external drag source returned an invalid tab", parsed.error);
702
- return null;
703
- }
704
- return {
705
- id: String(source.id),
706
- kind: "external",
707
- tab: parsed.data
708
- };
709
- }
710
- return {
711
- id: String(source.id),
712
- kind: "tab"
713
- };
714
- };
715
- const DragProvider = ({ children, onCommit, splitDock }) => {
716
693
  const actorRef = useActorRef(dragDockMachine);
717
- const layoutStore = useContext(LayoutStoreContext);
718
- const sharedManager = useContext(SharedDragManagerContext);
719
- const [ownManager] = useState(() => sharedManager ? null : createDragManager());
720
- useInsertionEffect(() => () => ownManager?.destroy(), [ownManager]);
721
- const manager = sharedManager ?? ownManager;
722
- if (manager === null) throw new Error("[dashfoo] DashfooDragProvider unmounted while its layout is still mounted; keep the provider above the layout or remount the layout");
723
- const inheritedStore = useContext(DragSubjectStoreContext);
724
- const [subjectStore] = useState(() => inheritedStore ?? createDragSubjectStore());
725
- const ownerId = useId();
726
- useEffect(() => {
727
- const subscription = actorRef.subscribe((snapshot) => {
728
- const { intent, subject } = snapshot.context;
729
- const state = subjectStore.getState();
730
- if (intent !== null) {
731
- if (!sameDropIntent(state.intent, intent) || state.intentOwner !== ownerId) subjectStore.setState({
732
- intent,
733
- intentOwner: ownerId
734
- });
735
- } else if (state.intent !== null && state.intentOwner === ownerId) subjectStore.setState({
736
- intent: null,
737
- intentOwner: null
738
- });
739
- if (!sameDragSubject(subjectStore.getState().subject, subject)) subjectStore.setState({ subject });
740
- });
741
- return () => {
742
- subscription.unsubscribe();
743
- };
744
- }, [
745
- actorRef,
746
- ownerId,
747
- subjectStore
748
- ]);
749
- const onCommitRef = useRef(onCommit);
750
- const splitDockRef = useRef(splitDock);
751
- useEffect(() => {
752
- onCommitRef.current = onCommit;
753
- splitDockRef.current = splitDock;
754
- });
755
- const getTabsetElement = useCallback((id) => {
756
- const element = manager.registry.droppables.get(`${ownerId}:${id}`)?.element;
757
- return element instanceof HTMLElement ? element : void 0;
758
- }, [manager, ownerId]);
759
- useEffect(() => {
760
- const subscription = actorRef.on("COMMIT", (emitted) => {
761
- if (layoutStore?.getState().editable === false) return;
762
- const commit = onCommitRef.current ?? layoutStore?.getState().dispatch;
763
- if (!commit) {
764
- warnOnce("drag-layer-no-commit", "Layout.DragLayer has neither an onCommit prop nor a <Layout.Root> above it; drops are ignored");
765
- return;
766
- }
767
- commit(emitted.action);
768
- });
694
+ const [scopes] = useState(() => /* @__PURE__ */ new Map());
695
+ const registerScope = useCallback((scope) => {
696
+ scopes.set(scope.layerId, scope);
769
697
  return () => {
770
- subscription.unsubscribe();
698
+ scopes.delete(scope.layerId);
771
699
  };
772
- }, [actorRef, layoutStore]);
700
+ }, [scopes]);
773
701
  useEffect(() => {
774
- const resolveIntent = (targetId, element, point, draggedId) => {
775
- if (layoutStore?.getState().editable === false) return null;
776
- if (!shouldAllowDrop(draggedId, targetId, [...element.querySelectorAll("[data-dashfoo=\"tab\"]")].map((tab) => tab.dataset.tabId ?? ""))) return null;
777
- const intent = intentForTabset(targetId, element, point, draggedId);
778
- if (!(splitDockRef.current ?? layoutStore?.getState().splitDock ?? true) && intent.location.startsWith("split-")) return {
779
- location: "center",
780
- targetId
702
+ const scopedTarget = () => {
703
+ const target = manager.dragOperation.target;
704
+ const data = target?.data;
705
+ const scope = data?.layerId === void 0 ? void 0 : scopes.get(data.layerId);
706
+ const element = target?.element;
707
+ if (!scope || data?.tabsetId === void 0 || !(element instanceof HTMLElement)) return null;
708
+ return {
709
+ element,
710
+ scope,
711
+ tabsetId: data.tabsetId
781
712
  };
782
- return intent;
713
+ };
714
+ const syncIntent = () => {
715
+ const operation = manager.dragOperation;
716
+ const found = scopedTarget();
717
+ const draggedId = operation.source ? String(operation.source.id) : void 0;
718
+ const intent = found === null ? null : found.scope.resolveIntent(found.tabsetId, found.element, operation.position.current, draggedId);
719
+ actorRef.send({
720
+ drop: intent === null || found === null ? null : {
721
+ intent,
722
+ scope: found.scope.layerId
723
+ },
724
+ type: "OVER"
725
+ });
783
726
  };
784
727
  const handleStart = (event) => {
785
728
  const source = event.operation.source;
@@ -794,26 +737,22 @@ const DragProvider = ({ children, onCommit, splitDock }) => {
794
737
  type: "START"
795
738
  });
796
739
  };
797
- const syncIntent = () => {
798
- const operation = manager.dragOperation;
799
- const point = operation.position.current;
800
- const target = operation.target;
801
- const data = target?.data;
802
- const tabsetId = data?.layerId === ownerId ? data.tabsetId : void 0;
803
- const element = tabsetId === void 0 ? void 0 : target?.element;
804
- const draggedId = operation.source ? String(operation.source.id) : void 0;
805
- actorRef.send({
806
- intent: tabsetId !== void 0 && element instanceof HTMLElement ? resolveIntent(tabsetId, element, point, draggedId) : null,
807
- type: "OVER"
740
+ let frame = null;
741
+ const scheduleResolve = () => {
742
+ if (frame !== null) return;
743
+ frame = requestAnimationFrame(() => {
744
+ frame = null;
745
+ syncIntent();
808
746
  });
809
747
  };
810
- const handleMove = () => {
811
- syncIntent();
812
- };
813
- const handleCollision = () => {
814
- syncIntent();
748
+ const cancelPendingResolve = () => {
749
+ if (frame !== null) {
750
+ cancelAnimationFrame(frame);
751
+ frame = null;
752
+ }
815
753
  };
816
754
  const handleEnd = (event) => {
755
+ cancelPendingResolve();
817
756
  if (event.canceled) {
818
757
  actorRef.send({ type: "CANCEL" });
819
758
  return;
@@ -821,11 +760,16 @@ const DragProvider = ({ children, onCommit, splitDock }) => {
821
760
  syncIntent();
822
761
  actorRef.send({ type: "DROP" });
823
762
  };
763
+ const commitSubscription = actorRef.on("COMMIT", (emitted) => {
764
+ scopes.get(emitted.scope)?.commit(emitted.action);
765
+ });
824
766
  const offStart = manager.monitor.addEventListener("dragstart", handleStart);
825
- const offMove = manager.monitor.addEventListener("dragmove", handleMove);
826
- const offCollision = manager.monitor.addEventListener("collision", handleCollision);
767
+ const offMove = manager.monitor.addEventListener("dragmove", scheduleResolve);
768
+ const offCollision = manager.monitor.addEventListener("collision", scheduleResolve);
827
769
  const offEnd = manager.monitor.addEventListener("dragend", handleEnd);
828
770
  return () => {
771
+ cancelPendingResolve();
772
+ commitSubscription.unsubscribe();
829
773
  offStart();
830
774
  offMove();
831
775
  offCollision();
@@ -833,114 +777,144 @@ const DragProvider = ({ children, onCommit, splitDock }) => {
833
777
  };
834
778
  }, [
835
779
  actorRef,
836
- layoutStore,
837
780
  manager,
838
- ownerId
781
+ scopes
839
782
  ]);
840
- const contextValue = useMemo(() => ({
841
- layerId: ownerId,
842
- manager
843
- }), [manager, ownerId]);
844
- return /* @__PURE__ */ jsx(DragContext.Provider, {
845
- value: contextValue,
846
- children: /* @__PURE__ */ jsxs(DragSubjectStoreContext.Provider, {
847
- value: subjectStore,
848
- children: [
849
- children,
850
- /* @__PURE__ */ jsx(DockIndicator, {
851
- actorRef,
852
- getTabsetElement
853
- }),
854
- ownManager === null ? null : /* @__PURE__ */ jsx(DragPreviewOverlay, { manager: ownManager })
855
- ]
783
+ const rootValue = useMemo(() => ({
784
+ actorRef,
785
+ manager,
786
+ registerScope
787
+ }), [
788
+ actorRef,
789
+ manager,
790
+ registerScope
791
+ ]);
792
+ return /* @__PURE__ */ jsx(SharedDragManagerContext.Provider, {
793
+ value: manager,
794
+ children: /* @__PURE__ */ jsxs(DragRootContext.Provider, {
795
+ value: rootValue,
796
+ children: [children, /* @__PURE__ */ jsx(DragPreviewOverlay, { manager })]
856
797
  })
857
798
  });
858
799
  };
859
800
 
860
801
  //#endregion
861
- //#region src/components/float-context.ts
862
- const FloatLayerContext = createContext(false);
863
- const useHasFloatLayer = () => useContext(FloatLayerContext);
802
+ //#region src/hooks/layout-store.ts
803
+ const createLayoutStore = (initial) => createStore(() => initial);
804
+ const LayoutStoreContext = createContext(null);
805
+ const useLayout = (selector) => {
806
+ const store = useContext(LayoutStoreContext);
807
+ if (store === null) throw new Error("dashfoo components must be rendered inside <DashfooLayout> or <Layout.Root>.");
808
+ return useStore(store, selector);
809
+ };
864
810
 
865
811
  //#endregion
866
- //#region src/hooks/use-inline-rename.ts
867
- const useInlineRename = ({ currentName, inputRef, onCommit, onDone }) => {
868
- const done = useRef(false);
869
- useEffect(() => {
870
- inputRef.current?.focus();
871
- inputRef.current?.select();
872
- }, [inputRef]);
873
- const commit = (value) => {
874
- const trimmed = value.trim();
875
- if (trimmed && trimmed !== currentName) onCommit(trimmed);
876
- onDone();
812
+ //#region src/components/drag-provider.tsx
813
+ const MISSING_ROOT = "[dashfoo] DashfooDragProvider unmounted while its layout is still mounted; keep the provider above the layout or remount the layout";
814
+ /** One DOM pass per pointer move, where resolving a drop used to run three. */
815
+ const measureStrip = (element, excludeId) => {
816
+ const strip = element.querySelector("[data-dashfoo=\"tabstrip\"]");
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
877
829
  };
878
- const handleKeyDown = (event) => {
879
- if (event.key === "Enter") {
880
- done.current = true;
881
- commit(event.currentTarget.value);
882
- } else if (event.key === "Escape") {
883
- done.current = true;
884
- onDone();
885
- }
830
+ };
831
+ const intentForTabset = (id, element, point, strip) => {
832
+ if (strip && pointInRect(point, strip.rect)) return {
833
+ index: insertionIndex(strip.tabRects, point.x),
834
+ location: "center",
835
+ targetId: id
886
836
  };
887
- const handleBlur = (event) => {
888
- if (done.current) return;
889
- done.current = true;
890
- commit(event.currentTarget.value);
837
+ const location = resolveDockTarget(point, element.getBoundingClientRect());
838
+ if (location === "center" && strip) return {
839
+ index: strip.tabRects.length,
840
+ location,
841
+ targetId: id
891
842
  };
892
843
  return {
893
- handleBlur,
894
- handleKeyDown
844
+ location,
845
+ targetId: id
895
846
  };
896
847
  };
897
-
898
- //#endregion
899
- //#region src/lib/float-geometry.ts
900
- const MIN_WIDTH = 200;
901
- const MIN_HEIGHT = 120;
902
- const DEFAULT_WIDTH = 480;
903
- const DEFAULT_HEIGHT = 340;
904
- const LIFT_OFFSET = 16;
905
- const clampToBounds = (rect, bounds, size = rect) => ({
906
- height: rect.height,
907
- left: Math.max(0, Math.min(rect.left, Math.max(0, bounds.width - size.width))),
908
- top: Math.max(0, Math.min(rect.top, Math.max(0, bounds.height - size.height))),
909
- width: rect.width
910
- });
911
- const measureFloatRect = (element) => {
912
- const rect = (element.closest("[data-dashfoo=\"tabset\"]") ?? element).getBoundingClientRect();
913
- const seeded = {
914
- height: Math.max(MIN_HEIGHT, Math.min(Math.round(rect.height), DEFAULT_HEIGHT)),
915
- left: Math.round(rect.left) + LIFT_OFFSET,
916
- top: Math.round(rect.top) + LIFT_OFFSET,
917
- width: Math.max(MIN_WIDTH, Math.min(Math.round(rect.width), DEFAULT_WIDTH))
918
- };
919
- return clampToBounds(seeded, {
920
- height: window.innerHeight,
921
- width: window.innerWidth
848
+ /** One drag layer (the main tree, or one float). Holds no drag state of its own. */
849
+ const DragScope = ({ children, onCommit, splitDock }) => {
850
+ const root = useContext(DragRootContext);
851
+ const layoutStore = useContext(LayoutStoreContext);
852
+ const layerId = useId();
853
+ const manager = root?.manager;
854
+ const getTabsetElement = useCallback((id) => {
855
+ const element = manager?.registry.droppables.get(`${layerId}:${id}`)?.element;
856
+ return element instanceof HTMLElement ? element : void 0;
857
+ }, [layerId, manager]);
858
+ const scope = useMemo(() => ({
859
+ commit: (action) => {
860
+ if (layoutStore?.getState().editable === false) return;
861
+ const commit = onCommit ?? layoutStore?.getState().dispatch;
862
+ if (!commit) {
863
+ warnOnce("drag-layer-no-commit", "Layout.DragLayer has neither an onCommit prop nor a <Layout.Root> above it; drops are ignored");
864
+ return;
865
+ }
866
+ commit(action);
867
+ },
868
+ layerId,
869
+ resolveIntent: (targetId, element, point, draggedId) => {
870
+ if (layoutStore?.getState().editable === false) return null;
871
+ const strip = measureStrip(element, draggedId);
872
+ if (!shouldAllowDrop(draggedId, targetId, strip?.tabIds ?? [])) return null;
873
+ const intent = intentForTabset(targetId, element, point, strip);
874
+ if (!(splitDock ?? layoutStore?.getState().splitDock ?? true) && splitEdge(intent.location) !== void 0) return {
875
+ location: "center",
876
+ targetId
877
+ };
878
+ return intent;
879
+ }
880
+ }), [
881
+ layerId,
882
+ layoutStore,
883
+ onCommit,
884
+ splitDock
885
+ ]);
886
+ const registerScope = root?.registerScope;
887
+ useEffect(() => registerScope?.(scope), [registerScope, scope]);
888
+ const contextValue = useMemo(() => root === null ? null : {
889
+ actorRef: root.actorRef,
890
+ layerId,
891
+ manager: root.manager
892
+ }, [layerId, root]);
893
+ if (contextValue === null) throw new Error(MISSING_ROOT);
894
+ return /* @__PURE__ */ jsxs(DragContext.Provider, {
895
+ value: contextValue,
896
+ children: [children, /* @__PURE__ */ jsx(DockIndicator, {
897
+ actorRef: contextValue.actorRef,
898
+ getTabsetElement
899
+ })]
922
900
  });
923
901
  };
924
- const resizeRect = (start, edges, dx, dy) => {
925
- let { height, left, top, width } = start;
926
- if (edges.x === 1) width = Math.max(MIN_WIDTH, start.width + dx);
927
- else if (edges.x === -1) {
928
- width = Math.max(MIN_WIDTH, start.width - dx);
929
- left = Math.max(0, start.left + (start.width - width));
930
- }
931
- if (edges.y === 1) height = Math.max(MIN_HEIGHT, start.height + dy);
932
- else if (edges.y === -1) {
933
- height = Math.max(MIN_HEIGHT, start.height - dy);
934
- top = Math.max(0, start.top + (start.height - height));
935
- }
936
- return {
937
- height,
938
- left,
939
- top,
940
- width
941
- };
902
+ /**
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.
905
+ */
906
+ const DragProvider = (props) => {
907
+ const hasRoot = useContext(DragRootContext) !== null;
908
+ const [ownsRoot] = useState(() => !hasRoot);
909
+ const layer = /* @__PURE__ */ jsx(DragScope, { ...props });
910
+ return ownsRoot ? /* @__PURE__ */ jsx(DashfooDragProvider, { children: layer }) : layer;
942
911
  };
943
912
 
913
+ //#endregion
914
+ //#region src/components/float-context.ts
915
+ const FloatLayerContext = createContext(false);
916
+ const useHasFloatLayer = () => useContext(FloatLayerContext);
917
+
944
918
  //#endregion
945
919
  //#region src/components/float-resize-handles.ts
946
920
  const EDGE_THICKNESS = 12;
@@ -1061,122 +1035,527 @@ const RESIZE_HANDLES = [
1061
1035
  const EDGE_BY_KEY = new Map(RESIZE_HANDLES.map((h) => [h.key, h.edges]));
1062
1036
 
1063
1037
  //#endregion
1064
- //#region src/components/layout-root.tsx
1065
- const DEFAULT_TABSET_MIN_SIZE = 320;
1066
- const rootStyle = {
1067
- display: "flex",
1068
- height: "100%",
1069
- position: "relative",
1070
- width: "100%"
1071
- };
1072
- 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, rootRef, snap, style, ...props }) => {
1073
- const global = model.global;
1074
- const snapshot = {
1075
- closableTabs: editable && closableTabs && global.tabEnableClose !== false,
1076
- dispatch,
1077
- draggableTabs: editable && draggableTabs && global.tabEnableDrag !== false,
1078
- draggableTabsets: editable && draggableTabsets,
1079
- editable,
1080
- floatable: editable && floatable,
1081
- keepMounted,
1082
- maximizable: maximizable && global.tabSetEnableMaximize !== false,
1083
- maximizedTabsetId: model.maximizedTabsetId,
1084
- renamableTabs: editable && renamableTabs && global.tabEnableRename !== false,
1085
- renderTab,
1086
- renderTabLabel,
1087
- renderTabsetToolbar,
1088
- resizableSplits: editable && resizableSplits && global.enableSplitResize !== false,
1089
- snap: snap ?? global.snap ?? null,
1090
- splitDock: global.enableSplitDock !== false,
1091
- tabLocation: global.tabLocation ?? "top",
1092
- tabsetMinSize: global.tabSetMinSize ?? DEFAULT_TABSET_MIN_SIZE,
1093
- tabStripEnabled: global.tabSetEnableTabStrip !== false
1038
+ //#region src/lib/float-geometry.ts
1039
+ const MIN_WIDTH = 200;
1040
+ const MIN_HEIGHT = 120;
1041
+ const DEFAULT_WIDTH = 480;
1042
+ const DEFAULT_HEIGHT = 340;
1043
+ const LIFT_OFFSET = 16;
1044
+ const clampToBounds = (rect, bounds, size = rect) => ({
1045
+ height: rect.height,
1046
+ left: Math.max(0, Math.min(rect.left, Math.max(0, bounds.width - size.width))),
1047
+ top: Math.max(0, Math.min(rect.top, Math.max(0, bounds.height - size.height))),
1048
+ width: rect.width
1049
+ });
1050
+ const measureFloatRect = (element) => {
1051
+ const rect = (element.closest("[data-dashfoo=\"tabset\"]") ?? element).getBoundingClientRect();
1052
+ const seeded = {
1053
+ height: Math.max(MIN_HEIGHT, Math.min(Math.round(rect.height), DEFAULT_HEIGHT)),
1054
+ left: Math.round(rect.left) + LIFT_OFFSET,
1055
+ top: Math.round(rect.top) + LIFT_OFFSET,
1056
+ width: Math.max(MIN_WIDTH, Math.min(Math.round(rect.width), DEFAULT_WIDTH))
1094
1057
  };
1095
- const [store] = useState(() => createLayoutStore(snapshot));
1096
- useLayoutEffect(() => {
1097
- store.setState(snapshot);
1058
+ return clampToBounds(seeded, {
1059
+ height: window.innerHeight,
1060
+ width: window.innerWidth
1098
1061
  });
1099
- const layoutStyle = global.splitterSize === void 0 ? {
1100
- ...rootStyle,
1101
- ...style
1102
- } : {
1103
- ...rootStyle,
1104
- "--dashfoo-splitter-size": `${global.splitterSize}px`,
1105
- ...style
1062
+ };
1063
+ const resizeRect = (start, edges, dx, dy) => {
1064
+ let { height, left, top, width } = start;
1065
+ if (edges.x === 1) width = Math.max(MIN_WIDTH, start.width + dx);
1066
+ else if (edges.x === -1) {
1067
+ width = Math.max(MIN_WIDTH, start.width - dx);
1068
+ left = Math.max(0, start.left + (start.width - width));
1069
+ }
1070
+ if (edges.y === 1) height = Math.max(MIN_HEIGHT, start.height + dy);
1071
+ else if (edges.y === -1) {
1072
+ height = Math.max(MIN_HEIGHT, start.height - dy);
1073
+ top = Math.max(0, start.top + (start.height - height));
1074
+ }
1075
+ return {
1076
+ height,
1077
+ left,
1078
+ top,
1079
+ width
1106
1080
  };
1107
- return /* @__PURE__ */ jsx(LayoutStoreContext.Provider, {
1108
- value: store,
1109
- children: /* @__PURE__ */ jsx("div", {
1110
- ...props,
1111
- "data-dashfoo": "layout",
1112
- ref: rootRef,
1113
- style: layoutStyle,
1114
- children
1115
- })
1116
- });
1117
1081
  };
1118
1082
 
1119
1083
  //#endregion
1120
- //#region src/lib/tab-ids.ts
1121
- const tabDomId = (tabsetId, tabId) => `dashfoo-tab-${tabsetId}-${tabId}`;
1122
- const panelDomId = (tabsetId) => `dashfoo-panel-${tabsetId}`;
1123
-
1124
- //#endregion
1125
- //#region src/components/tabset/tabset-store.ts
1126
- const tabButtonIn = (scope, tabsetId, tabId) => scope?.querySelector(`#${CSS.escape(tabDomId(tabsetId, tabId))}`) ?? null;
1127
- const createTabsetStore = (initial) => createStore((set, get) => ({
1128
- ...initial,
1129
- cancelRename: () => {
1130
- set({ editingTabId: null });
1131
- },
1132
- closeTab: (tabId) => {
1133
- set({ restoreFocus: true });
1134
- get().dispatch({
1135
- tabId,
1136
- type: "deleteTab"
1137
- });
1138
- },
1139
- commitRename: (tabId, value) => {
1140
- set({ editingTabId: null });
1141
- const { node, renameTab } = get();
1142
- const trimmed = value.trim();
1143
- const current = node.children.find((tab) => tab.id === tabId)?.name;
1144
- if (trimmed && trimmed !== current) renameTab(tabId, trimmed);
1145
- },
1146
- editingTabId: null,
1147
- overflowItems: [],
1148
- registerRenameInput: () => {
1149
- set((state) => ({ renameInputCount: state.renameInputCount + 1 }));
1150
- return () => {
1151
- set((state) => ({ renameInputCount: state.renameInputCount - 1 }));
1084
+ //#region src/hooks/use-float-gesture.ts
1085
+ const CHIP_SIZE = {
1086
+ height: 34,
1087
+ width: 168
1088
+ };
1089
+ const TAP_SLOP = 4;
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. */
1092
+ const useFloatGesture = ({ editable, geometry, minimized, onCommit, onTap }) => {
1093
+ const panelRef = useRef(null);
1094
+ const gestureRef = useRef(null);
1095
+ const setPanel = (element) => {
1096
+ panelRef.current = element;
1097
+ };
1098
+ const handlePointerDown = (event) => {
1099
+ const panel = panelRef.current;
1100
+ if (!panel || !editable) return;
1101
+ const prior = gestureRef.current;
1102
+ if (prior && panel.hasPointerCapture?.(prior.pointerId)) panel.releasePointerCapture(prior.pointerId);
1103
+ const edgeKey = event.currentTarget.dataset.edge;
1104
+ const parent = panel.offsetParent instanceof HTMLElement ? panel.offsetParent : null;
1105
+ const gesture = {
1106
+ bounds: {
1107
+ height: parent?.clientHeight ?? 0,
1108
+ width: parent?.clientWidth ?? 0
1109
+ },
1110
+ edges: edgeKey === void 0 ? null : EDGE_BY_KEY.get(edgeKey) ?? null,
1111
+ latest: geometry,
1112
+ moved: false,
1113
+ pointerId: event.pointerId,
1114
+ start: geometry,
1115
+ startX: event.clientX,
1116
+ startY: event.clientY
1152
1117
  };
1153
- },
1154
- registerTablist: (element) => {
1155
- if (element) {
1156
- const current = get().tablistElement;
1157
- if (current && current !== element) warnOnce(`duplicate-tablist-${get().node.id}`, "more than one <Tabset.Tablist> under the same <Tabset.Root>; the last one wins for keyboard focus and overflow");
1158
- set({ tablistElement: element });
1118
+ gestureRef.current = gesture;
1119
+ if (gesture.edges) panel.setPointerCapture(event.pointerId);
1120
+ };
1121
+ const handlePointerUp = (event) => {
1122
+ const gesture = gestureRef.current;
1123
+ if (!gesture || event.pointerId !== gesture.pointerId) return;
1124
+ gestureRef.current = null;
1125
+ const panel = panelRef.current;
1126
+ if (panel?.hasPointerCapture?.(event.pointerId) === true) panel.releasePointerCapture(event.pointerId);
1127
+ if (!gesture.moved) {
1128
+ onTap();
1159
1129
  return;
1160
1130
  }
1161
- set({ tablistElement: null });
1162
- },
1163
- renameInputCount: 0,
1164
- renameTab: (tabId, name) => {
1165
- get().dispatch({
1166
- name,
1167
- tabId,
1168
- type: "renameTab"
1169
- });
1170
- },
1171
- restoreFocus: false,
1172
- selectOverflowTab: (tabId) => {
1173
- const { dispatch, node, tablistElement } = get();
1174
- const index = node.children.findIndex((tab) => tab.id === tabId);
1175
- if (index === -1) return;
1176
- dispatch({
1177
- index,
1178
- tabsetId: node.id,
1179
- type: "selectTab"
1131
+ onCommit(gesture.latest);
1132
+ };
1133
+ const handlePointerMove = (event) => {
1134
+ const gesture = gestureRef.current;
1135
+ const panel = panelRef.current;
1136
+ if (!gesture || !panel || event.pointerId !== gesture.pointerId) return;
1137
+ if (event.buttons === 0) {
1138
+ handlePointerUp(event);
1139
+ return;
1140
+ }
1141
+ const dx = event.clientX - gesture.startX;
1142
+ const dy = event.clientY - gesture.startY;
1143
+ if (!gesture.moved) {
1144
+ if (Math.hypot(dx, dy) <= TAP_SLOP) return;
1145
+ gesture.moved = true;
1146
+ if (!gesture.edges) panel.setPointerCapture(gesture.pointerId);
1147
+ }
1148
+ const next = gesture.edges ? resizeRect(gesture.start, gesture.edges, dx, dy) : clampToBounds({
1149
+ ...gesture.start,
1150
+ left: gesture.start.left + dx,
1151
+ top: gesture.start.top + dy
1152
+ }, gesture.bounds, minimized ? CHIP_SIZE : void 0);
1153
+ gesture.latest = next;
1154
+ panel.style.left = `${next.left}px`;
1155
+ panel.style.top = `${next.top}px`;
1156
+ if (!minimized) {
1157
+ panel.style.width = `${next.width}px`;
1158
+ panel.style.height = `${next.height}px`;
1159
+ }
1160
+ };
1161
+ const handlePointerCancel = (event) => {
1162
+ const gesture = gestureRef.current;
1163
+ const panel = panelRef.current;
1164
+ if (!gesture || !panel || event.pointerId !== gesture.pointerId) return;
1165
+ gestureRef.current = null;
1166
+ if (panel.hasPointerCapture?.(event.pointerId)) panel.releasePointerCapture(event.pointerId);
1167
+ if (!gesture.moved) return;
1168
+ panel.style.left = `${gesture.start.left}px`;
1169
+ panel.style.top = `${gesture.start.top}px`;
1170
+ if (!minimized) {
1171
+ panel.style.width = `${gesture.start.width}px`;
1172
+ panel.style.height = `${gesture.start.height}px`;
1173
+ }
1174
+ };
1175
+ return {
1176
+ handlers: {
1177
+ onPointerCancel: handlePointerCancel,
1178
+ onPointerDown: handlePointerDown,
1179
+ onPointerMove: handlePointerMove,
1180
+ onPointerUp: handlePointerUp
1181
+ },
1182
+ ref: setPanel,
1183
+ style: minimized ? {
1184
+ left: geometry.left,
1185
+ top: geometry.top
1186
+ } : {
1187
+ height: geometry.height,
1188
+ left: geometry.left,
1189
+ top: geometry.top,
1190
+ width: geometry.width
1191
+ }
1192
+ };
1193
+ };
1194
+
1195
+ //#endregion
1196
+ //#region src/hooks/use-inline-rename.ts
1197
+ const useInlineRename = ({ currentName, inputRef, onCommit, onDone }) => {
1198
+ const done = useRef(false);
1199
+ useEffect(() => {
1200
+ inputRef.current?.focus();
1201
+ inputRef.current?.select();
1202
+ }, [inputRef]);
1203
+ const commit = (value) => {
1204
+ const trimmed = value.trim();
1205
+ if (trimmed && trimmed !== currentName) onCommit(trimmed);
1206
+ onDone();
1207
+ };
1208
+ const handleKeyDown = (event) => {
1209
+ if (event.key === "Enter") {
1210
+ done.current = true;
1211
+ commit(event.currentTarget.value);
1212
+ } else if (event.key === "Escape") {
1213
+ done.current = true;
1214
+ onDone();
1215
+ }
1216
+ };
1217
+ const handleBlur = (event) => {
1218
+ if (done.current) return;
1219
+ done.current = true;
1220
+ commit(event.currentTarget.value);
1221
+ };
1222
+ return {
1223
+ handleBlur,
1224
+ handleKeyDown
1225
+ };
1226
+ };
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
+
1317
+ //#endregion
1318
+ //#region src/components/layout-root.tsx
1319
+ const DEFAULT_TABSET_MIN_SIZE = 320;
1320
+ const rootStyle = {
1321
+ display: "flex",
1322
+ height: "100%",
1323
+ position: "relative",
1324
+ width: "100%"
1325
+ };
1326
+ 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 }) => {
1327
+ const global = model.global;
1328
+ const snapshot = {
1329
+ closableTabs: editable && closableTabs && global.tabEnableClose !== false,
1330
+ dispatch,
1331
+ draggableTabs: editable && restructurable && draggableTabs && global.tabEnableDrag !== false,
1332
+ draggableTabsets: editable && restructurable && draggableTabsets,
1333
+ editable,
1334
+ floatable: editable && restructurable && floatable,
1335
+ keepMounted,
1336
+ maximizable: maximizable && global.tabSetEnableMaximize !== false,
1337
+ maximizedTabsetId: model.maximizedTabsetId,
1338
+ renamableTabs: editable && renamableTabs && global.tabEnableRename !== false,
1339
+ renderTab,
1340
+ renderTabLabel,
1341
+ renderTabsetToolbar,
1342
+ resizableSplits: editable && restructurable && resizableSplits && global.enableSplitResize !== false,
1343
+ snap: snap ?? global.snap ?? null,
1344
+ splitDock: global.enableSplitDock !== false,
1345
+ tabLocation: global.tabLocation ?? "top",
1346
+ tabsetMinSize: global.tabSetMinSize ?? DEFAULT_TABSET_MIN_SIZE,
1347
+ tabStripEnabled: global.tabSetEnableTabStrip !== false
1348
+ };
1349
+ const [store] = useState(() => createLayoutStore(snapshot));
1350
+ useLayoutEffect(() => {
1351
+ store.setState(snapshot);
1352
+ });
1353
+ const layoutStyle = global.splitterSize === void 0 ? {
1354
+ ...rootStyle,
1355
+ ...style
1356
+ } : {
1357
+ ...rootStyle,
1358
+ "--dashfoo-splitter-size": `${global.splitterSize}px`,
1359
+ ...style
1360
+ };
1361
+ return /* @__PURE__ */ jsx(LayoutStoreContext.Provider, {
1362
+ value: store,
1363
+ children: /* @__PURE__ */ jsx("div", {
1364
+ ...props,
1365
+ "data-dashfoo": "layout",
1366
+ ref: rootRef,
1367
+ style: layoutStyle,
1368
+ children
1369
+ })
1370
+ });
1371
+ };
1372
+ /**
1373
+ * Republishes the parent store with overrides applied rather than re-deriving
1374
+ * flags, so anything the parent changes keeps flowing into the subtree.
1375
+ */
1376
+ const LayoutOverrides = ({ children, overrides }) => {
1377
+ const snapshot = {
1378
+ ...useLayout((state) => state),
1379
+ ...overrides
1380
+ };
1381
+ const [store] = useState(() => createLayoutStore(snapshot));
1382
+ useLayoutEffect(() => {
1383
+ store.setState(snapshot);
1384
+ });
1385
+ return /* @__PURE__ */ jsx(LayoutStoreContext.Provider, {
1386
+ value: store,
1387
+ children: /* @__PURE__ */ jsx("div", {
1388
+ "data-dashfoo": "layout",
1389
+ style: rootStyle,
1390
+ children
1391
+ })
1392
+ });
1393
+ };
1394
+
1395
+ //#endregion
1396
+ //#region src/hooks/use-snap-resize.ts
1397
+ const WEIGHT_EPSILON = .01;
1398
+ const IDLE = { kind: "idle" };
1399
+ const boundaryOf = (state) => state.kind === "dragging" || state.kind === "snapped" ? state.boundary : null;
1400
+ const snappedBoundaryOf = (state) => state.kind === "snapped" ? state.boundary : null;
1401
+ const layoutFromWeights = (children) => {
1402
+ const total = children.reduce((sum, child) => sum + child.weight, 0);
1403
+ return children.reduce((layout, child) => {
1404
+ layout[child.id] = child.weight / total * 100;
1405
+ return layout;
1406
+ }, {});
1407
+ };
1408
+ const orderedFromLayout = (layout, children) => children.map((child) => layout[child.id] ?? 0);
1409
+ const layoutFromOrdered = (sizes, children) => children.reduce((layout, child, index) => {
1410
+ layout[child.id] = sizes[index] ?? 0;
1411
+ return layout;
1412
+ }, {});
1413
+ const layoutsMatch = (current, expected) => {
1414
+ const currentIds = Object.keys(current);
1415
+ const expectedIds = Object.keys(expected);
1416
+ if (currentIds.length !== expectedIds.length) return false;
1417
+ return expectedIds.every((id) => Math.abs((current[id] ?? NaN) - expected[id]) <= WEIGHT_EPSILON);
1418
+ };
1419
+ const useSnapResize = ({ children, onWeightsSettled, resizableSplits, snap }) => {
1420
+ const groupRef = useRef(null);
1421
+ const state = useRef(IDLE);
1422
+ const [snappedBoundary, setSnappedBoundary] = useState(null);
1423
+ const transition = useCallback((next) => {
1424
+ state.current = next;
1425
+ setSnappedBoundary(snappedBoundaryOf(next));
1426
+ }, []);
1427
+ const desiredLayout = useMemo(() => layoutFromWeights(children), [children]);
1428
+ useLayoutEffect(() => {
1429
+ const group = groupRef.current;
1430
+ if (!group) return;
1431
+ const current = group.getLayout();
1432
+ if (!(Object.keys(current).length === Object.keys(desiredLayout).length && Object.keys(desiredLayout).every((id) => id in current)) || layoutsMatch(current, desiredLayout)) return;
1433
+ transition({ kind: "syncing" });
1434
+ group.setLayout(desiredLayout);
1435
+ const frame = requestAnimationFrame(() => {
1436
+ transition(IDLE);
1437
+ });
1438
+ return () => {
1439
+ cancelAnimationFrame(frame);
1440
+ transition(IDLE);
1441
+ };
1442
+ }, [desiredLayout, transition]);
1443
+ const onLayoutChange = (layout) => {
1444
+ if (state.current.kind === "syncing" || !resizableSplits) return;
1445
+ const boundary = boundaryOf(state.current);
1446
+ if (boundary === null) return;
1447
+ const decision = decideSnap(orderedFromLayout(layout, children), boundary, snap);
1448
+ if (decision.kind === "inactive") return;
1449
+ if (decision.kind === "clear") {
1450
+ transition({
1451
+ boundary,
1452
+ kind: "dragging"
1453
+ });
1454
+ return;
1455
+ }
1456
+ transition({
1457
+ boundary,
1458
+ kind: "snapped"
1459
+ });
1460
+ const group = groupRef.current;
1461
+ const target = layoutFromOrdered(decision.sizes, children);
1462
+ if (!group || layoutsMatch(layout, target)) return;
1463
+ transition({ kind: "syncing" });
1464
+ group.setLayout(target);
1465
+ transition({
1466
+ boundary,
1467
+ kind: "snapped"
1468
+ });
1469
+ };
1470
+ const onLayoutChanged = (layout, meta) => {
1471
+ if (!meta.isUserInteraction) return;
1472
+ if (state.current.kind === "syncing") return;
1473
+ const boundary = boundaryOf(state.current);
1474
+ transition(IDLE);
1475
+ if (!resizableSplits) return;
1476
+ const weights = settleSnap(children.map((child) => layout[child.id] ?? child.weight), boundary, snap);
1477
+ if (!weights.some((weight, index) => {
1478
+ const child = children[index];
1479
+ return child !== void 0 && Math.abs(weight - child.weight) > WEIGHT_EPSILON;
1480
+ })) return;
1481
+ onWeightsSettled(weights);
1482
+ };
1483
+ const beginBoundaryDrag = (boundary) => {
1484
+ transition({
1485
+ boundary,
1486
+ kind: "dragging"
1487
+ });
1488
+ };
1489
+ return {
1490
+ beginBoundaryDrag,
1491
+ groupRef,
1492
+ onLayoutChange,
1493
+ onLayoutChanged,
1494
+ snappedBoundary
1495
+ };
1496
+ };
1497
+
1498
+ //#endregion
1499
+ //#region src/lib/tab-ids.ts
1500
+ const tabDomId = (tabsetId, tabId) => `dashfoo-tab-${tabsetId}-${tabId}`;
1501
+ const panelDomId = (tabsetId) => `dashfoo-panel-${tabsetId}`;
1502
+
1503
+ //#endregion
1504
+ //#region src/components/tabset/tabset-store.ts
1505
+ const tabButtonIn = (scope, tabsetId, tabId) => scope?.querySelector(`#${CSS.escape(tabDomId(tabsetId, tabId))}`) ?? null;
1506
+ const createTabsetStore = (initial) => createStore((set, get) => ({
1507
+ ...initial,
1508
+ cancelRename: () => {
1509
+ set({ editingTabId: null });
1510
+ },
1511
+ closeTab: (tabId) => {
1512
+ set({ pendingCloseTabId: tabId });
1513
+ get().dispatch({
1514
+ tabId,
1515
+ type: "deleteTab"
1516
+ });
1517
+ },
1518
+ commitRename: (tabId, value) => {
1519
+ set({ editingTabId: null });
1520
+ const { node, renameTab } = get();
1521
+ const trimmed = value.trim();
1522
+ const current = node.children.find((tab) => tab.id === tabId)?.name;
1523
+ if (trimmed && trimmed !== current) renameTab(tabId, trimmed);
1524
+ },
1525
+ editingTabId: null,
1526
+ overflowItems: [],
1527
+ pendingCloseTabId: null,
1528
+ registerRenameInput: () => {
1529
+ set((state) => ({ renameInputCount: state.renameInputCount + 1 }));
1530
+ return () => {
1531
+ set((state) => ({ renameInputCount: state.renameInputCount - 1 }));
1532
+ };
1533
+ },
1534
+ registerTablist: (element) => {
1535
+ if (element) {
1536
+ const current = get().tablistElement;
1537
+ if (current && current !== element) warnOnce(`duplicate-tablist-${get().node.id}`, "more than one <Tabset.Tablist> under the same <Tabset.Root>; the last one wins for keyboard focus and overflow");
1538
+ set({ tablistElement: element });
1539
+ return;
1540
+ }
1541
+ set({ tablistElement: null });
1542
+ },
1543
+ renameInputCount: 0,
1544
+ renameTab: (tabId, name) => {
1545
+ get().dispatch({
1546
+ name,
1547
+ tabId,
1548
+ type: "renameTab"
1549
+ });
1550
+ },
1551
+ selectOverflowTab: (tabId) => {
1552
+ const { dispatch, node, tablistElement } = get();
1553
+ const index = node.children.findIndex((tab) => tab.id === tabId);
1554
+ if (index === -1) return;
1555
+ dispatch({
1556
+ index,
1557
+ tabsetId: node.id,
1558
+ type: "selectTab"
1180
1559
  });
1181
1560
  tabButtonIn(tablistElement, node.id, tabId)?.scrollIntoView({
1182
1561
  block: "nearest",
@@ -1282,7 +1661,7 @@ const TabsetContent = ({ children, style, ...props }) => {
1282
1661
  };
1283
1662
 
1284
1663
  //#endregion
1285
- //#region src/components/tab-overflow.tsx
1664
+ //#region src/components/tab-overflow-menu.tsx
1286
1665
  const OverflowIcon = () => /* @__PURE__ */ jsxs("svg", {
1287
1666
  "aria-hidden": "true",
1288
1667
  fill: "currentColor",
@@ -1370,8 +1749,6 @@ const TabOverflowMenu = ({ items, onSelect }) => {
1370
1749
  case "Escape":
1371
1750
  event.preventDefault();
1372
1751
  closeAndReturnFocus();
1373
- break;
1374
- default: break;
1375
1752
  }
1376
1753
  };
1377
1754
  const handleFocusOut = (event) => {
@@ -1415,16 +1792,93 @@ const TabOverflowMenu = ({ items, onSelect }) => {
1415
1792
  }) : null]
1416
1793
  });
1417
1794
  };
1418
-
1419
- //#endregion
1420
- //#region src/components/tabset/tabset-overflow.tsx
1421
- const TabsetOverflowMenu = () => {
1422
- const overflowItems = useTabset((state) => state.overflowItems);
1423
- const selectOverflowTab = useTabset((state) => state.selectOverflowTab);
1424
- if (overflowItems.length === 0) return null;
1425
- return /* @__PURE__ */ jsx(TabOverflowMenu, {
1426
- items: overflowItems,
1427
- onSelect: selectOverflowTab
1795
+
1796
+ //#endregion
1797
+ //#region src/components/tabset/tabset-overflow-menu.tsx
1798
+ const TabsetOverflowMenu = () => {
1799
+ const overflowItems = useTabset((state) => state.overflowItems);
1800
+ const selectOverflowTab = useTabset((state) => state.selectOverflowTab);
1801
+ if (overflowItems.length === 0) return null;
1802
+ return /* @__PURE__ */ jsx(TabOverflowMenu, {
1803
+ items: overflowItems,
1804
+ onSelect: selectOverflowTab
1805
+ });
1806
+ };
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, {})
1428
1882
  });
1429
1883
  };
1430
1884
 
@@ -1439,13 +1893,14 @@ const overflowingIds = (tablist) => {
1439
1893
  return clipped && tabId !== void 0 && tabId !== "" ? [tabId] : [];
1440
1894
  });
1441
1895
  };
1896
+ const sameIds = (a, b) => a.length === b.length && a.every((id, index) => id === b[index]);
1442
1897
  const useTabOverflow = (tablist, signature) => {
1443
1898
  const [overflow, setOverflow] = useState([]);
1444
1899
  useEffect(() => {
1445
1900
  const recompute = () => {
1446
1901
  setOverflow((previous) => {
1447
1902
  const next = tablist ? overflowingIds(tablist) : [];
1448
- return next.length === 0 && previous.length === 0 ? previous : next;
1903
+ return sameIds(previous, next) ? previous : next;
1449
1904
  });
1450
1905
  };
1451
1906
  recompute();
@@ -1507,7 +1962,9 @@ const TabsetRoot = ({ children, node, ref: userRef, style, ...props }) => {
1507
1962
  useLayoutEffect(() => {
1508
1963
  store.setState(snapshot);
1509
1964
  });
1510
- 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);
1511
1968
  const overflowItems = useMemo(() => {
1512
1969
  const names = new Map(node.children.map((tab) => [tab.id, tab.name]));
1513
1970
  return overflow.map((id) => ({
@@ -1519,8 +1976,10 @@ const TabsetRoot = ({ children, node, ref: userRef, style, ...props }) => {
1519
1976
  store.setState({ overflowItems });
1520
1977
  }, [overflowItems, store]);
1521
1978
  useEffect(() => {
1522
- if (!store.getState().restoreFocus) return;
1523
- 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;
1524
1983
  const activeTabId = node.children[node.selected]?.id;
1525
1984
  if (activeTabId) tabsetRef.current?.querySelector(`#${CSS.escape(tabDomId(node.id, activeTabId))}`)?.focus();
1526
1985
  else tabsetRef.current?.focus();
@@ -1548,253 +2007,87 @@ const TabsetRoot = ({ children, node, ref: userRef, style, ...props }) => {
1548
2007
  ...style
1549
2008
  },
1550
2009
  tabIndex: -1,
1551
- children
1552
- })
1553
- });
1554
- };
1555
-
1556
- //#endregion
1557
- //#region src/lib/merge-refs.ts
1558
- const applyRef = (ref, value) => {
1559
- if (typeof ref === "function") ref(value);
1560
- else if (ref) ref.current = value;
1561
- };
1562
- const mergeRefs = (...refs) => (value) => {
1563
- for (const ref of refs) applyRef(ref, value);
1564
- };
1565
-
1566
- //#endregion
1567
- //#region src/components/tabset/tabset-tab.tsx
1568
- const TabsetTab = ({ children, ref: userRef, tab, ...props }) => {
1569
- const store = useContext(TabsetStoreContext);
1570
- const node = useTabset((state) => state.node);
1571
- const editing = useTabset((state) => state.editingTabId === tab.id);
1572
- const dragSubject = useDragSubject();
1573
- const isDragging = dragSubject?.kind === "tab" && dragSubject.id === tab.id;
1574
- const itemRef = useRef(null);
1575
- const wasEditing = useRef(false);
1576
- const index = node.children.findIndex((child) => child.id === tab.id);
1577
- useEffect(() => {
1578
- if (store?.getState().node.children.some((child) => child.id === tab.id) === false) warnOnce(`orphan-tab-${tab.id}`, `<Tabset.Tab> for "${tab.id}" is not a child of its Tabset.Root's node; it renders but can never be selected`);
1579
- }, [
1580
- index === -1,
1581
- store,
1582
- tab.id
1583
- ]);
1584
- useEffect(() => {
1585
- if (wasEditing.current && !editing) itemRef.current?.querySelector("[data-dashfoo=\"tab\"]")?.focus();
1586
- wasEditing.current = editing;
1587
- }, [editing]);
1588
- const contextValue = useMemo(() => ({
1589
- index,
1590
- tab
1591
- }), [index, tab]);
1592
- const refCallback = useMemo(() => mergeRefs(itemRef, userRef), [userRef]);
1593
- return /* @__PURE__ */ jsx(TabContext.Provider, {
1594
- value: contextValue,
1595
- children: /* @__PURE__ */ jsx("span", {
1596
- ...props,
1597
- "data-dashfoo": "tab-item",
1598
- "data-dragging": isDragging || void 0,
1599
- ref: refCallback,
1600
- children
1601
- })
1602
- });
1603
- };
1604
- const TabsetTrigger = ({ children, onClick, onDoubleClick, ref: userRef, ...props }) => {
1605
- const { index, tab } = useTab();
1606
- const node = useTabset((state) => state.node);
1607
- const editing = useTabset((state) => state.editingTabId === tab.id);
1608
- const selectTab = useTabset((state) => state.selectTab);
1609
- const startEditing = useTabset((state) => state.startEditing);
1610
- const tabsRenamable = useTabset((state) => state.tabsRenamable);
1611
- const visualSelected = useTabset((state) => state.visualSelected);
1612
- const draggableTabs = useLayout((state) => state.draggableTabs);
1613
- const renderTabLabel = useLayout((state) => state.renderTabLabel);
1614
- const { ref } = useTabDraggable(tab.id, !draggableTabs || tab.enableDrag === false, tab.name);
1615
- const refCallback = useMemo(() => mergeRefs(ref, userRef), [ref, userRef]);
1616
- if (editing) return null;
1617
- const selected = index === visualSelected;
1618
- const renamable = tabsRenamable && tab.enableRename !== false;
1619
- const handleClick = (event) => {
1620
- onClick?.(event);
1621
- selectTab(index);
1622
- };
1623
- const handleDoubleClick = (event) => {
1624
- onDoubleClick?.(event);
1625
- if (renamable) startEditing(tab.id);
1626
- };
1627
- return /* @__PURE__ */ jsx("button", {
1628
- "aria-label": renderTabLabel ? tab.name : void 0,
1629
- ...props,
1630
- "aria-controls": panelDomId(node.id),
1631
- "aria-selected": selected,
1632
- "data-dashfoo": "tab",
1633
- "data-tab-id": tab.id,
1634
- id: tabDomId(node.id, tab.id),
1635
- onClick: handleClick,
1636
- onDoubleClick: handleDoubleClick,
1637
- ref: refCallback,
1638
- role: "tab",
1639
- tabIndex: selected ? 0 : -1,
1640
- type: "button",
1641
- children: children ?? (renderTabLabel ? renderTabLabel(tab) : tab.name)
1642
- });
1643
- };
1644
-
1645
- //#endregion
1646
- //#region src/components/tabset-icons.tsx
1647
- const iconStyle = { pointerEvents: "none" };
1648
- const Icon = ({ children }) => /* @__PURE__ */ jsx("svg", {
1649
- "aria-hidden": "true",
1650
- fill: "none",
1651
- height: "12",
1652
- stroke: "currentColor",
1653
- strokeLinecap: "round",
1654
- strokeLinejoin: "round",
1655
- strokeWidth: "2",
1656
- style: iconStyle,
1657
- viewBox: "0 0 24 24",
1658
- width: "12",
1659
- children
1660
- });
1661
- const CloseIcon = () => /* @__PURE__ */ jsxs(Icon, { children: [/* @__PURE__ */ jsx("path", { d: "M18 6 6 18" }), /* @__PURE__ */ jsx("path", { d: "m6 6 12 12" })] });
1662
- const GripIcon = () => /* @__PURE__ */ jsxs(Icon, { children: [
1663
- /* @__PURE__ */ jsx("circle", {
1664
- cx: "12",
1665
- cy: "5",
1666
- r: "1"
1667
- }),
1668
- /* @__PURE__ */ jsx("circle", {
1669
- cx: "19",
1670
- cy: "5",
1671
- r: "1"
1672
- }),
1673
- /* @__PURE__ */ jsx("circle", {
1674
- cx: "5",
1675
- cy: "5",
1676
- r: "1"
1677
- }),
1678
- /* @__PURE__ */ jsx("circle", {
1679
- cx: "12",
1680
- cy: "12",
1681
- r: "1"
1682
- }),
1683
- /* @__PURE__ */ jsx("circle", {
1684
- cx: "19",
1685
- cy: "12",
1686
- r: "1"
1687
- }),
1688
- /* @__PURE__ */ jsx("circle", {
1689
- cx: "5",
1690
- cy: "12",
1691
- r: "1"
1692
- }),
1693
- /* @__PURE__ */ jsx("circle", {
1694
- cx: "12",
1695
- cy: "19",
1696
- r: "1"
1697
- }),
1698
- /* @__PURE__ */ jsx("circle", {
1699
- cx: "19",
1700
- cy: "19",
1701
- r: "1"
1702
- }),
1703
- /* @__PURE__ */ jsx("circle", {
1704
- cx: "5",
1705
- cy: "19",
1706
- r: "1"
1707
- })
1708
- ] });
1709
- const MaximizeIcon = ({ maximized }) => maximized ? /* @__PURE__ */ jsxs(Icon, { children: [
1710
- /* @__PURE__ */ jsx("path", { d: "m14 10 7-7" }),
1711
- /* @__PURE__ */ jsx("path", { d: "M20 10h-6V4" }),
1712
- /* @__PURE__ */ jsx("path", { d: "m3 21 7-7" }),
1713
- /* @__PURE__ */ jsx("path", { d: "M4 14h6v6" })
1714
- ] }) : /* @__PURE__ */ jsxs(Icon, { children: [
1715
- /* @__PURE__ */ jsx("path", { d: "M15 3h6v6" }),
1716
- /* @__PURE__ */ jsx("path", { d: "m21 3-7 7" }),
1717
- /* @__PURE__ */ jsx("path", { d: "m3 21 7-7" }),
1718
- /* @__PURE__ */ jsx("path", { d: "M9 21H3v-6" })
1719
- ] });
1720
- const FloatIcon = () => /* @__PURE__ */ jsxs(Icon, { children: [/* @__PURE__ */ jsx("rect", {
1721
- height: "14",
1722
- rx: "2",
1723
- width: "18",
1724
- x: "3",
1725
- y: "6"
1726
- }), /* @__PURE__ */ jsx("path", { d: "M3 10h18" })] });
1727
- const MinimizeIcon = () => /* @__PURE__ */ jsx(Icon, { children: /* @__PURE__ */ jsx("path", { d: "M5 12h14" }) });
1728
- const DockIcon = () => /* @__PURE__ */ jsxs(Icon, { children: [
1729
- /* @__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" }),
1730
- /* @__PURE__ */ jsx("path", { d: "M11 8 7 12l4 4" }),
1731
- /* @__PURE__ */ jsx("path", { d: "M7 12h10" })
1732
- ] });
1733
-
1734
- //#endregion
1735
- //#region src/components/tabset/tabset-tab-controls.tsx
1736
- const RenameEditor = ({ defaultValue, onBlur, onKeyDown, ref: userRef, ...props }) => {
1737
- const { tab } = useTab();
1738
- const cancelRename = useTabset((state) => state.cancelRename);
1739
- const commitRename = useTabset((state) => state.commitRename);
1740
- const inputRef = useRef(null);
1741
- const rename = useInlineRename({
1742
- currentName: tab.name,
1743
- inputRef,
1744
- onCommit: (name) => {
1745
- commitRename(tab.id, name);
1746
- },
1747
- onDone: cancelRename
1748
- });
1749
- const refCallback = useMemo(() => mergeRefs(inputRef, userRef), [userRef]);
1750
- const handleKeyDown = (event) => {
1751
- onKeyDown?.(event);
1752
- rename.handleKeyDown(event);
1753
- };
1754
- const handleCommitOnBlur = (event) => {
1755
- onBlur?.(event);
1756
- rename.handleBlur(event);
1757
- };
1758
- return /* @__PURE__ */ jsx("input", {
1759
- "aria-label": `Rename ${tab.name}`,
1760
- ...props,
1761
- "data-dashfoo": "tab-rename",
1762
- defaultValue: defaultValue ?? tab.name,
1763
- onBlur: handleCommitOnBlur,
1764
- onKeyDown: handleKeyDown,
1765
- ref: refCallback,
1766
- type: "text"
2010
+ children
2011
+ })
1767
2012
  });
1768
2013
  };
1769
- const TabsetRenameInput = (props) => {
1770
- const { tab } = useTab();
2014
+
2015
+ //#endregion
2016
+ //#region src/components/tabset/tabset-tab.tsx
2017
+ const TabsetTab = ({ children, ref: userRef, tab, ...props }) => {
2018
+ const store = useContext(TabsetStoreContext);
2019
+ const node = useTabset((state) => state.node);
1771
2020
  const editing = useTabset((state) => state.editingTabId === tab.id);
1772
- const registerRenameInput = useTabset((state) => state.registerRenameInput);
1773
- useEffect(() => registerRenameInput(), [registerRenameInput]);
1774
- if (!editing) return null;
1775
- return /* @__PURE__ */ jsx(RenameEditor, { ...props });
2021
+ const dragSubject = useDragSubject();
2022
+ const isDragging = dragSubject?.kind === "tab" && dragSubject.id === tab.id;
2023
+ const itemRef = useRef(null);
2024
+ const wasEditing = useRef(false);
2025
+ const index = node.children.findIndex((child) => child.id === tab.id);
2026
+ useEffect(() => {
2027
+ if (store?.getState().node.children.some((child) => child.id === tab.id) === false) warnOnce(`orphan-tab-${tab.id}`, `<Tabset.Tab> for "${tab.id}" is not a child of its Tabset.Root's node; it renders but can never be selected`);
2028
+ }, [
2029
+ index === -1,
2030
+ store,
2031
+ tab.id
2032
+ ]);
2033
+ useEffect(() => {
2034
+ if (wasEditing.current && !editing) itemRef.current?.querySelector("[data-dashfoo=\"tab\"]")?.focus();
2035
+ wasEditing.current = editing;
2036
+ }, [editing]);
2037
+ const contextValue = useMemo(() => ({
2038
+ index,
2039
+ tab
2040
+ }), [index, tab]);
2041
+ const refCallback = useMemo(() => mergeRefs(itemRef, userRef), [userRef]);
2042
+ return /* @__PURE__ */ jsx(TabContext.Provider, {
2043
+ value: contextValue,
2044
+ children: /* @__PURE__ */ jsx("span", {
2045
+ ...props,
2046
+ "data-dashfoo": "tab-item",
2047
+ "data-dragging": isDragging || void 0,
2048
+ ref: refCallback,
2049
+ children
2050
+ })
2051
+ });
1776
2052
  };
1777
- const TabsetCloseButton = ({ children, onClick, ...props }) => {
2053
+ const TabsetTrigger = ({ children, onClick, onDoubleClick, ref: userRef, ...props }) => {
1778
2054
  const { index, tab } = useTab();
1779
- const closeTab = useTabset((state) => state.closeTab);
2055
+ const node = useTabset((state) => state.node);
1780
2056
  const editing = useTabset((state) => state.editingTabId === tab.id);
1781
- const tabsClosable = useTabset((state) => state.tabsClosable);
2057
+ const selectTab = useTabset((state) => state.selectTab);
2058
+ const startEditing = useTabset((state) => state.startEditing);
2059
+ const tabsRenamable = useTabset((state) => state.tabsRenamable);
1782
2060
  const visualSelected = useTabset((state) => state.visualSelected);
1783
- if (!(tabsClosable && tab.enableClose !== false) || editing) return null;
2061
+ const draggableTabs = useLayout((state) => state.draggableTabs);
2062
+ const renderTabLabel = useLayout((state) => state.renderTabLabel);
2063
+ const { ref } = useTabDraggable(tab.id, !draggableTabs || tab.enableDrag === false, tab.name);
2064
+ const refCallback = useMemo(() => mergeRefs(ref, userRef), [ref, userRef]);
2065
+ if (editing) return null;
2066
+ const selected = index === visualSelected;
2067
+ const renamable = tabsRenamable && tab.enableRename !== false;
1784
2068
  const handleClick = (event) => {
1785
2069
  onClick?.(event);
1786
- event.stopPropagation();
1787
- closeTab(tab.id);
2070
+ selectTab(index);
2071
+ };
2072
+ const handleDoubleClick = (event) => {
2073
+ onDoubleClick?.(event);
2074
+ if (renamable) startEditing(tab.id);
1788
2075
  };
1789
2076
  return /* @__PURE__ */ jsx("button", {
1790
- "aria-label": `Close ${tab.name}`,
1791
- title: `Close ${tab.name}`,
2077
+ "aria-label": renderTabLabel ? tab.name : void 0,
1792
2078
  ...props,
1793
- "data-dashfoo": "tab-close",
2079
+ "aria-controls": panelDomId(node.id),
2080
+ "aria-selected": selected,
2081
+ "data-dashfoo": "tab",
2082
+ "data-tab-id": tab.id,
2083
+ id: tabDomId(node.id, tab.id),
1794
2084
  onClick: handleClick,
1795
- tabIndex: index === visualSelected ? 0 : -1,
2085
+ onDoubleClick: handleDoubleClick,
2086
+ ref: refCallback,
2087
+ role: "tab",
2088
+ tabIndex: selected ? 0 : -1,
1796
2089
  type: "button",
1797
- children: children ?? /* @__PURE__ */ jsx(CloseIcon, {})
2090
+ children: children ?? (renderTabLabel ? renderTabLabel(tab) : tab.name)
1798
2091
  });
1799
2092
  };
1800
2093
 
@@ -1821,15 +2114,17 @@ const TabsetTablist = ({ onKeyDown, ref: userRef, style, ...props }) => {
1821
2114
  const node = useTabset((state) => state.node);
1822
2115
  const registerTablist = useTabset((state) => state.registerTablist);
1823
2116
  const selectTab = useTabset((state) => state.selectTab);
2117
+ const visualSelected = useTabset((state) => state.visualSelected);
1824
2118
  const handleKeyDown = (event) => {
1825
2119
  onKeyDown?.(event);
1826
2120
  if (event.defaultPrevented) return;
1827
2121
  if (!(event.target instanceof HTMLElement) || !event.target.closest("[role=\"tab\"]")) return;
1828
2122
  const count = node.children.length;
1829
2123
  if (count === 0) return;
2124
+ const from = Math.max(visualSelected, 0);
1830
2125
  const next = {
1831
- ArrowLeft: (node.selected - 1 + count) % count,
1832
- ArrowRight: (node.selected + 1) % count,
2126
+ ArrowLeft: (from - 1 + count) % count,
2127
+ ArrowRight: (from + 1) % count,
1833
2128
  End: count - 1,
1834
2129
  Home: 0
1835
2130
  }[event.key];
@@ -1870,14 +2165,32 @@ const TabsetToolbar = ({ style, ...props }) => /* @__PURE__ */ jsx("div", {
1870
2165
  ...style
1871
2166
  }
1872
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
+ };
1873
2187
  const TabsetGrip = ({ children, ref: userRef, ...props }) => {
1874
2188
  const activeTabName = useTabset((state) => state.activeTab?.name ?? "");
1875
- const isMaximized = useTabset((state) => state.isMaximized);
1876
2189
  const node = useTabset((state) => state.node);
1877
- const hidden = !useLayout((state) => state.draggableTabsets) || isMaximized;
1878
- const { ref } = useTabsetDraggable(node.id, hidden, activeTabName);
2190
+ const { grip } = useTabsetChrome();
2191
+ const { ref } = useTabsetDraggable(node.id, !grip, activeTabName);
1879
2192
  const refCallback = useMemo(() => mergeRefs(ref, userRef), [ref, userRef]);
1880
- if (hidden) return null;
2193
+ if (!grip) return null;
1881
2194
  return /* @__PURE__ */ jsx("button", {
1882
2195
  "aria-label": "Move tabset",
1883
2196
  title: "Move tabset",
@@ -1890,9 +2203,9 @@ const TabsetGrip = ({ children, ref: userRef, ...props }) => {
1890
2203
  };
1891
2204
  const TabsetMaximizeButton = ({ children, onClick, ...props }) => {
1892
2205
  const isMaximized = useTabset((state) => state.isMaximized);
1893
- const showMaximize = useTabset((state) => state.showMaximize);
1894
2206
  const toggleMaximize = useTabset((state) => state.toggleMaximize);
1895
- if (!showMaximize) return null;
2207
+ const { maximize } = useTabsetChrome();
2208
+ if (!maximize) return null;
1896
2209
  const handleClick = (event) => {
1897
2210
  onClick?.(event);
1898
2211
  toggleMaximize();
@@ -1909,16 +2222,10 @@ const TabsetMaximizeButton = ({ children, onClick, ...props }) => {
1909
2222
  });
1910
2223
  };
1911
2224
  const TabsetFloatButton = ({ children, onClick, ...props }) => {
1912
- const floatable = useLayout((state) => state.floatable);
1913
2225
  const dispatch = useLayout((state) => state.dispatch);
1914
- const isMaximized = useTabset((state) => state.isMaximized);
1915
2226
  const node = useTabset((state) => state.node);
1916
- const hasFloatLayer = useHasFloatLayer();
1917
- if (!floatable || isMaximized) return null;
1918
- if (!hasFloatLayer) {
1919
- warnOnce("float-no-layer", "Tabset.FloatButton needs a <Layout.FloatLayer> around the layout (DashfooLayout adds one); the float control is hidden");
1920
- return null;
1921
- }
2227
+ const { float } = useTabsetChrome();
2228
+ if (!float) return null;
1922
2229
  const handleClick = (event) => {
1923
2230
  onClick?.(event);
1924
2231
  const geometry = measureFloatRect(event.currentTarget);
@@ -1943,15 +2250,12 @@ const TabsetFloatButton = ({ children, onClick, ...props }) => {
1943
2250
  //#region src/components/tabset/tabset-view.tsx
1944
2251
  const tabsetContent = /* @__PURE__ */ jsx(TabsetContent, {});
1945
2252
  const DefaultTabsetLayout = () => {
1946
- const draggableTabsets = useLayout((state) => state.draggableTabsets);
1947
- const floatable = useLayout((state) => state.floatable);
1948
2253
  const renderTabsetToolbar = useLayout((state) => state.renderTabsetToolbar);
1949
2254
  const tabLocation = useLayout((state) => state.tabLocation);
1950
2255
  const tabStripEnabled = useLayout((state) => state.tabStripEnabled);
1951
- const isMaximized = useTabset((state) => state.isMaximized);
1952
2256
  const node = useTabset((state) => state.node);
1953
- const overflowCount = useTabset((state) => state.overflowItems.length);
1954
- 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);
1955
2259
  const strip = tabStripEnabled ? /* @__PURE__ */ jsxs(TabsetTabStrip, { children: [/* @__PURE__ */ jsx(TabsetTablist, { children: node.children.map((tab) => /* @__PURE__ */ jsxs(TabsetTab, {
1956
2260
  tab,
1957
2261
  children: [
@@ -1983,22 +2287,6 @@ const groupStyle = {
1983
2287
  minHeight: 0,
1984
2288
  minWidth: 0
1985
2289
  };
1986
- const WEIGHT_EPSILON = .01;
1987
- const layoutFromWeights = (children, total) => children.reduce((layout, child) => {
1988
- layout[child.id] = (child.weight ?? 1) / total * 100;
1989
- return layout;
1990
- }, {});
1991
- const orderedFromLayout = (layout, children) => children.map((child) => layout[child.id] ?? 0);
1992
- const layoutFromOrdered = (sizes, children) => children.reduce((layout, child, index) => {
1993
- layout[child.id] = sizes[index] ?? 0;
1994
- return layout;
1995
- }, {});
1996
- const layoutsMatch = (current, expected) => {
1997
- const currentIds = Object.keys(current);
1998
- const expectedIds = Object.keys(expected);
1999
- if (currentIds.length !== expectedIds.length) return false;
2000
- return expectedIds.every((id) => Math.abs((current[id] ?? NaN) - expected[id]) <= WEIGHT_EPSILON);
2001
- };
2002
2290
  const descendantMinSize = (child, axis, tabsetMinSize) => {
2003
2291
  const explicit = dimensionToPixels(child.min);
2004
2292
  if (explicit !== void 0) return explicit;
@@ -2019,91 +2307,33 @@ const RowView = ({ node, renderTabset }) => {
2019
2307
  const tabsetMinSize = useLayout((state) => state.tabsetMinSize);
2020
2308
  const globalSnap = useLayout((state) => state.snap);
2021
2309
  const effectiveSnap = node.snap ?? globalSnap;
2022
- const snapActive = snapEnabled(effectiveSnap);
2310
+ const snapGrid = useMemo(() => resolveSnapGrid(effectiveSnap, node.children.length), [effectiveSnap, node.children.length]);
2311
+ const snapActive = snapEnabled(snapGrid);
2023
2312
  const orientation = node.orientation === "row" ? "horizontal" : "vertical";
2024
- const total = node.children.reduce((sum, child) => sum + (child.weight ?? 1), 0);
2025
- const desiredLayout = useMemo(() => layoutFromWeights(node.children, total), [node.children, total]);
2026
- const groupRef = useRef(null);
2027
- const groupElement = useRef(null);
2028
- const syncing = useRef(false);
2029
- const activeBoundary = useRef(null);
2030
- const snapping = useRef(false);
2031
- const snappedBoundaryRef = useRef(null);
2032
- const [snappedBoundary, setSnappedBoundary] = useState(null);
2033
- const setHighlight = (boundary) => {
2034
- if (boundary !== snappedBoundaryRef.current) {
2035
- snappedBoundaryRef.current = boundary;
2036
- setSnappedBoundary(boundary);
2037
- }
2038
- };
2039
- const setSnapTransition = (active) => {
2040
- const element = groupElement.current;
2041
- if (!element) return;
2042
- if (active) element.dataset.dashfooSnapping = "true";
2043
- else delete element.dataset.dashfooSnapping;
2044
- };
2045
- useLayoutEffect(() => {
2046
- const group = groupRef.current;
2047
- if (!group) return;
2048
- const current = group.getLayout();
2049
- if (!(Object.keys(current).length === Object.keys(desiredLayout).length && Object.keys(desiredLayout).every((id) => id in current)) || layoutsMatch(current, desiredLayout)) return;
2050
- syncing.current = true;
2051
- group.setLayout(desiredLayout);
2052
- const frame = requestAnimationFrame(() => {
2053
- syncing.current = false;
2054
- });
2055
- return () => {
2056
- cancelAnimationFrame(frame);
2057
- syncing.current = false;
2058
- };
2059
- }, [desiredLayout]);
2060
- const handleLayoutChange = (layout) => {
2061
- if (snapping.current || syncing.current || !resizableSplits) return;
2062
- if (effectiveSnap === null || !snapActive) return;
2063
- const boundary = activeBoundary.current;
2064
- if (boundary === null) return;
2065
- const { sizes, snapped } = snapSizes(orderedFromLayout(layout, node.children), boundary, effectiveSnap);
2066
- setHighlight(snapped ? boundary : null);
2067
- if (!snapped) {
2068
- setSnapTransition(false);
2069
- return;
2070
- }
2071
- setSnapTransition(true);
2072
- const group = groupRef.current;
2073
- const target = layoutFromOrdered(sizes, node.children);
2074
- if (!group || layoutsMatch(layout, target)) return;
2075
- snapping.current = true;
2076
- group.setLayout(target);
2077
- snapping.current = false;
2078
- };
2079
- const handleLayoutChanged = (layout, meta) => {
2080
- if (!meta.isUserInteraction) return;
2081
- if (syncing.current) return;
2082
- const boundary = activeBoundary.current;
2083
- activeBoundary.current = null;
2084
- setHighlight(null);
2085
- setSnapTransition(false);
2086
- if (!resizableSplits) return;
2087
- let weights = node.children.map((child) => layout[child.id] ?? child.weight ?? 1);
2088
- if (boundary !== null && effectiveSnap !== null && snapActive) weights = snapSizes(weights, boundary, effectiveSnap).sizes;
2089
- if (!weights.some((weight, index) => Math.abs(weight - (node.children[index]?.weight ?? 1)) > WEIGHT_EPSILON)) return;
2090
- dispatch({
2091
- rowId: node.id,
2092
- type: "adjustSplit",
2093
- weights
2094
- });
2095
- };
2313
+ const total = node.children.reduce((sum, child) => sum + child.weight, 0);
2314
+ const { beginBoundaryDrag, groupRef, onLayoutChange, onLayoutChanged, snappedBoundary } = useSnapResize({
2315
+ children: node.children,
2316
+ onWeightsSettled: (weights) => {
2317
+ dispatch({
2318
+ rowId: node.id,
2319
+ type: "adjustSplit",
2320
+ weights
2321
+ });
2322
+ },
2323
+ resizableSplits,
2324
+ snap: snapGrid
2325
+ });
2096
2326
  return /* @__PURE__ */ jsx(Group, {
2097
2327
  "data-dashfoo": "row",
2328
+ "data-dashfoo-snapping": snappedBoundary === null ? void 0 : "true",
2098
2329
  disabled: !resizableSplits,
2099
- elementRef: groupElement,
2100
2330
  groupRef,
2101
- onLayoutChange: snapActive ? handleLayoutChange : void 0,
2102
- onLayoutChanged: handleLayoutChanged,
2331
+ onLayoutChange: snapActive ? onLayoutChange : void 0,
2332
+ onLayoutChanged,
2103
2333
  orientation,
2104
2334
  style: groupStyle,
2105
2335
  children: node.children.map((child, index) => {
2106
- const percent = (child.weight ?? 1) / total * 100;
2336
+ const percent = child.weight / total * 100;
2107
2337
  let min = child.min ? dimensionToSize(child.min) : void 0;
2108
2338
  if (min === void 0) {
2109
2339
  const minimum = descendantMinSize(child, node.orientation, tabsetMinSize);
@@ -2115,7 +2345,7 @@ const RowView = ({ node, renderTabset }) => {
2115
2345
  "data-dashfoo-snapped": snappedBoundary === index - 1 ? "true" : void 0,
2116
2346
  disabled: !resizableSplits,
2117
2347
  onPointerDown: snapActive && resizableSplits ? () => {
2118
- activeBoundary.current = index - 1;
2348
+ beginBoundaryDrag(index - 1);
2119
2349
  } : void 0
2120
2350
  }) : null, /* @__PURE__ */ jsx(Panel$1, {
2121
2351
  defaultSize: `${percent}%`,
@@ -2159,11 +2389,12 @@ const bodyStyle = {
2159
2389
  minHeight: 0,
2160
2390
  position: "relative"
2161
2391
  };
2162
- const CHIP_SIZE = {
2163
- height: 34,
2164
- width: 168
2392
+ const FLOAT_OVERRIDES = {
2393
+ draggableTabsets: false,
2394
+ floatable: false,
2395
+ maximizable: false,
2396
+ maximizedTabsetId: void 0
2165
2397
  };
2166
- const TAP_SLOP = 4;
2167
2398
  const FloatTitleEditor = ({ dispatch, node, onDone }) => {
2168
2399
  const inputRef = useRef(null);
2169
2400
  const { handleBlur, handleKeyDown } = useInlineRename({
@@ -2211,25 +2442,10 @@ const FloatTitle = ({ dispatch, node, renamable }) => {
2211
2442
  children: floatTitle(node)
2212
2443
  });
2213
2444
  };
2214
- const FloatPanel = ({ global, node, onFocus, zIndex }) => {
2445
+ const FloatPanel = ({ node, onFocus, zIndex }) => {
2215
2446
  const dispatch = useLayout((state) => state.dispatch);
2216
- const closableTabs = useLayout((state) => state.closableTabs);
2217
- const draggableTabs = useLayout((state) => state.draggableTabs);
2218
2447
  const editable = useLayout((state) => state.editable);
2219
- const renamableTabs = useLayout((state) => state.renamableTabs);
2220
- const resizableSplits = useLayout((state) => state.resizableSplits);
2221
- const keepMounted = useLayout((state) => state.keepMounted);
2222
- const renderTab = useLayout((state) => state.renderTab);
2223
- const renderTabLabel = useLayout((state) => state.renderTabLabel);
2224
- const renderTabsetToolbar = useLayout((state) => state.renderTabsetToolbar);
2225
- const snap = useLayout((state) => state.snap);
2226
- const panelRef = useRef(null);
2227
- const gestureRef = useRef(null);
2228
- const latestRef = useRef(node.geometry);
2229
- const movedRef = useRef(false);
2230
- const setPanel = (element) => {
2231
- panelRef.current = element;
2232
- };
2448
+ const minimized = node.minimized === true;
2233
2449
  const restore = () => {
2234
2450
  if (!editable) return;
2235
2451
  dispatch({
@@ -2238,88 +2454,30 @@ const FloatPanel = ({ global, node, onFocus, zIndex }) => {
2238
2454
  type: "setFloatMinimized"
2239
2455
  });
2240
2456
  };
2241
- const handlePointerDown = (event) => {
2242
- const panel = panelRef.current;
2243
- if (!panel || !editable) return;
2244
- const prior = gestureRef.current;
2245
- if (prior && panel.hasPointerCapture?.(prior.pointerId)) panel.releasePointerCapture(prior.pointerId);
2246
- movedRef.current = false;
2247
- const edgeKey = event.currentTarget.dataset.edge;
2248
- const parent = panel.offsetParent instanceof HTMLElement ? panel.offsetParent : null;
2249
- const gesture = {
2250
- bounds: {
2251
- height: parent?.clientHeight ?? 0,
2252
- width: parent?.clientWidth ?? 0
2253
- },
2254
- edges: edgeKey === void 0 ? null : EDGE_BY_KEY.get(edgeKey) ?? null,
2255
- pointerId: event.pointerId,
2256
- start: node.geometry,
2257
- startX: event.clientX,
2258
- startY: event.clientY
2259
- };
2260
- gestureRef.current = gesture;
2261
- latestRef.current = node.geometry;
2262
- if (gesture.edges) panel.setPointerCapture(event.pointerId);
2263
- };
2264
- const handlePointerUp = (event) => {
2265
- const gesture = gestureRef.current;
2266
- if (!gesture || event.pointerId !== gesture.pointerId) return;
2267
- gestureRef.current = null;
2268
- const panel = panelRef.current;
2269
- if (panel?.hasPointerCapture?.(event.pointerId) === true) panel.releasePointerCapture(event.pointerId);
2270
- if (!movedRef.current) {
2271
- if (node.minimized === true) restore();
2272
- return;
2273
- }
2274
- dispatch({
2275
- floatId: node.id,
2276
- geometry: latestRef.current,
2277
- type: "moveFloat"
2278
- });
2279
- };
2280
- const handlePointerMove = (event) => {
2281
- const gesture = gestureRef.current;
2282
- const panel = panelRef.current;
2283
- if (!gesture || !panel || event.pointerId !== gesture.pointerId) return;
2284
- if (event.buttons === 0) {
2285
- handlePointerUp(event);
2286
- return;
2287
- }
2288
- const dx = event.clientX - gesture.startX;
2289
- const dy = event.clientY - gesture.startY;
2290
- if (!movedRef.current) {
2291
- if (Math.hypot(dx, dy) <= TAP_SLOP) return;
2292
- movedRef.current = true;
2293
- if (!gesture.edges) panel.setPointerCapture(gesture.pointerId);
2294
- }
2295
- const next = gesture.edges ? resizeRect(gesture.start, gesture.edges, dx, dy) : clampToBounds({
2296
- ...gesture.start,
2297
- left: gesture.start.left + dx,
2298
- top: gesture.start.top + dy
2299
- }, gesture.bounds, node.minimized === true ? CHIP_SIZE : void 0);
2300
- latestRef.current = next;
2301
- panel.style.left = `${next.left}px`;
2302
- panel.style.top = `${next.top}px`;
2303
- if (node.minimized !== true) {
2304
- panel.style.width = `${next.width}px`;
2305
- panel.style.height = `${next.height}px`;
2306
- }
2307
- };
2308
- const handlePointerCancel = (event) => {
2309
- const gesture = gestureRef.current;
2310
- const panel = panelRef.current;
2311
- if (!gesture || !panel || event.pointerId !== gesture.pointerId) return;
2312
- gestureRef.current = null;
2313
- if (panel.hasPointerCapture?.(event.pointerId)) panel.releasePointerCapture(event.pointerId);
2314
- if (!movedRef.current) return;
2315
- panel.style.left = `${gesture.start.left}px`;
2316
- panel.style.top = `${gesture.start.top}px`;
2317
- if (node.minimized !== true) {
2318
- panel.style.width = `${gesture.start.width}px`;
2319
- panel.style.height = `${gesture.start.height}px`;
2457
+ const { handlers, ref, style } = useFloatGesture({
2458
+ editable,
2459
+ geometry: node.geometry,
2460
+ minimized,
2461
+ onCommit: (geometry) => {
2462
+ dispatch({
2463
+ floatId: node.id,
2464
+ geometry,
2465
+ type: "moveFloat"
2466
+ });
2467
+ },
2468
+ onTap: () => {
2469
+ if (minimized) restore();
2320
2470
  }
2471
+ });
2472
+ const { onPointerDown, ...rootHandlers } = handlers;
2473
+ const frameStyle = {
2474
+ ...style,
2475
+ pointerEvents: "auto",
2476
+ position: "absolute",
2477
+ zIndex
2321
2478
  };
2322
- if (node.minimized === true) return /* @__PURE__ */ jsxs("button", {
2479
+ if (minimized) return /* @__PURE__ */ jsxs("button", {
2480
+ ...rootHandlers,
2323
2481
  "aria-label": `Restore ${floatTitle(node)} panel`,
2324
2482
  "data-dashfoo": "float-chip",
2325
2483
  onKeyDown: (event) => {
@@ -2328,19 +2486,10 @@ const FloatPanel = ({ global, node, onFocus, zIndex }) => {
2328
2486
  restore();
2329
2487
  }
2330
2488
  },
2331
- onPointerCancel: handlePointerCancel,
2332
- onPointerDown: handlePointerDown,
2489
+ onPointerDown,
2333
2490
  onPointerDownCapture: onFocus,
2334
- onPointerMove: handlePointerMove,
2335
- onPointerUp: handlePointerUp,
2336
- ref: setPanel,
2337
- style: {
2338
- left: node.geometry.left,
2339
- pointerEvents: "auto",
2340
- position: "absolute",
2341
- top: node.geometry.top,
2342
- zIndex
2343
- },
2491
+ ref,
2492
+ style: frameStyle,
2344
2493
  title: floatTitle(node),
2345
2494
  type: "button",
2346
2495
  children: [/* @__PURE__ */ jsx(FloatIcon, {}), /* @__PURE__ */ jsx("span", {
@@ -2348,31 +2497,16 @@ const FloatPanel = ({ global, node, onFocus, zIndex }) => {
2348
2497
  children: floatTitle(node)
2349
2498
  })]
2350
2499
  });
2351
- const floatModel = {
2352
- global,
2353
- layout: node.layout,
2354
- version: 1
2355
- };
2356
2500
  return /* @__PURE__ */ jsxs("div", {
2501
+ ...rootHandlers,
2357
2502
  "data-dashfoo": "float",
2358
- onPointerCancel: handlePointerCancel,
2359
2503
  onPointerDownCapture: onFocus,
2360
- onPointerMove: handlePointerMove,
2361
- onPointerUp: handlePointerUp,
2362
- ref: setPanel,
2363
- style: {
2364
- height: node.geometry.height,
2365
- left: node.geometry.left,
2366
- pointerEvents: "auto",
2367
- position: "absolute",
2368
- top: node.geometry.top,
2369
- width: node.geometry.width,
2370
- zIndex
2371
- },
2504
+ ref,
2505
+ style: frameStyle,
2372
2506
  children: [
2373
2507
  /* @__PURE__ */ jsxs("div", {
2374
2508
  "data-dashfoo": "float-titlebar",
2375
- onPointerDown: handlePointerDown,
2509
+ onPointerDown,
2376
2510
  style: editable ? titleBarStyle : {
2377
2511
  ...titleBarStyle,
2378
2512
  cursor: "default"
@@ -2427,29 +2561,15 @@ const FloatPanel = ({ global, node, onFocus, zIndex }) => {
2427
2561
  /* @__PURE__ */ jsx("div", {
2428
2562
  "data-dashfoo": "float-body",
2429
2563
  style: bodyStyle,
2430
- children: /* @__PURE__ */ jsx(LayoutRoot, {
2431
- closableTabs,
2432
- dispatch,
2433
- draggableTabs,
2434
- draggableTabsets: false,
2435
- editable,
2436
- floatable: false,
2437
- keepMounted,
2438
- maximizable: false,
2439
- model: floatModel,
2440
- renamableTabs,
2441
- renderTab,
2442
- renderTabLabel,
2443
- renderTabsetToolbar,
2444
- resizableSplits,
2445
- snap: snap ?? void 0,
2564
+ children: /* @__PURE__ */ jsx(LayoutOverrides, {
2565
+ overrides: FLOAT_OVERRIDES,
2446
2566
  children: /* @__PURE__ */ jsx(DragProvider, { children: /* @__PURE__ */ jsx(RowView, { node: node.layout }) })
2447
2567
  })
2448
2568
  }),
2449
2569
  editable && RESIZE_HANDLES.map((handle) => /* @__PURE__ */ jsx("div", {
2450
2570
  "data-dashfoo": "float-resize",
2451
2571
  "data-edge": handle.key,
2452
- onPointerDown: handlePointerDown,
2572
+ onPointerDown,
2453
2573
  style: {
2454
2574
  position: "absolute",
2455
2575
  touchAction: "none",
@@ -2469,14 +2589,13 @@ const overlayStyle = {
2469
2589
  position: "fixed",
2470
2590
  zIndex: 1
2471
2591
  };
2472
- const FloatOverlay = ({ floats, global }) => {
2592
+ const FloatOverlay = ({ floats }) => {
2473
2593
  const [topId, setTopId] = useState(null);
2474
2594
  if (floats.length === 0) return null;
2475
2595
  return /* @__PURE__ */ jsx("div", {
2476
2596
  "data-dashfoo": "float-overlay",
2477
2597
  style: overlayStyle,
2478
2598
  children: floats.map((node) => /* @__PURE__ */ jsx(FloatPanel, {
2479
- global,
2480
2599
  node,
2481
2600
  onFocus: () => {
2482
2601
  setTopId(node.id);
@@ -2485,12 +2604,9 @@ const FloatOverlay = ({ floats, global }) => {
2485
2604
  }, node.id))
2486
2605
  });
2487
2606
  };
2488
- const FloatLayer = ({ children, floats, global }) => /* @__PURE__ */ jsxs(FloatLayerContext.Provider, {
2607
+ const FloatLayer = ({ children, floats }) => /* @__PURE__ */ jsxs(FloatLayerContext.Provider, {
2489
2608
  value: true,
2490
- children: [children, /* @__PURE__ */ jsx(FloatOverlay, {
2491
- floats,
2492
- global
2493
- })]
2609
+ children: [children, /* @__PURE__ */ jsx(FloatOverlay, { floats })]
2494
2610
  });
2495
2611
 
2496
2612
  //#endregion
@@ -2506,8 +2622,8 @@ const Layout = {
2506
2622
  //#endregion
2507
2623
  //#region src/components/dashfoo-layout.tsx
2508
2624
  const DEFAULT_PERSIST_DEBOUNCE_MS = 300;
2509
- const resolvePersist = (persist, hasDefaultModel) => {
2510
- if (persist === void 0 || !hasDefaultModel) return null;
2625
+ const resolvePersist = (persist) => {
2626
+ if (persist === void 0) return null;
2511
2627
  if (typeof persist === "string") return {
2512
2628
  debounceMs: DEFAULT_PERSIST_DEBOUNCE_MS,
2513
2629
  key: persist,
@@ -2522,7 +2638,8 @@ const resolvePersist = (persist, hasDefaultModel) => {
2522
2638
  const warnedComponents = /* @__PURE__ */ new Set();
2523
2639
  const DashfooLayout = forwardRef((props, ref) => {
2524
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;
2525
- 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);
2526
2643
  const handleModelChange = useCallback((next, action) => {
2527
2644
  persistence.save(next);
2528
2645
  onModelChange?.(next, action);
@@ -2613,37 +2730,43 @@ const DashfooLayout = forwardRef((props, ref) => {
2613
2730
  }, [components, factory]);
2614
2731
  const [containerRef, width] = useContainerWidth();
2615
2732
  const isCompact = responsive !== void 0 && width <= responsive.maxWidth;
2616
- 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
+ }, [
2617
2743
  isCompact,
2744
+ maximizable,
2618
2745
  responsive?.orientation,
2619
2746
  store.model
2620
2747
  ]);
2621
- const storeDispatch = store.dispatch;
2622
- const dispatch = useCallback((action) => {
2623
- if (isCompact && action.type === "adjustSplit") return;
2624
- storeDispatch(action);
2625
- }, [isCompact, storeDispatch]);
2748
+ const view = presentation.model;
2626
2749
  const maximized = view.maximizedTabsetId === void 0 ? void 0 : findTabset(view, view.maximizedTabsetId);
2627
2750
  const tree = /* @__PURE__ */ jsx(Layout.FloatLayer, {
2628
- floats: store.model.floats ?? [],
2629
- global: store.model.global,
2751
+ floats: view.floats,
2630
2752
  children: /* @__PURE__ */ jsx(Layout.DragLayer, { children: maximized ? /* @__PURE__ */ jsx(Layout.Tabset, { node: maximized }) : /* @__PURE__ */ jsx(Layout.Rows, { node: view.layout }) })
2631
2753
  });
2632
2754
  return /* @__PURE__ */ jsx(Layout.Root, {
2633
2755
  closableTabs,
2634
- dispatch,
2635
- draggableTabs: isCompact ? false : draggableTabs,
2636
- draggableTabsets: isCompact ? false : draggableTabsets,
2756
+ dispatch: store.dispatch,
2757
+ draggableTabs,
2758
+ draggableTabsets,
2637
2759
  editable,
2638
- floatable: isCompact ? false : floatable,
2760
+ floatable,
2639
2761
  keepMounted,
2640
- maximizable,
2762
+ maximizable: presentation.maximizable,
2641
2763
  model: view,
2642
2764
  renamableTabs,
2643
2765
  renderTab,
2644
2766
  renderTabLabel,
2645
2767
  renderTabsetToolbar,
2646
- resizableSplits: isCompact ? false : resizableSplits,
2768
+ resizableSplits,
2769
+ restructurable: presentation.restructurable,
2647
2770
  rootRef: containerRef,
2648
2771
  snap,
2649
2772
  children: hasSharedManager ? tree : /* @__PURE__ */ jsx(DashfooDragProvider, { children: tree })