@mintplayer/ng-bootstrap 21.24.0 → 21.26.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.
@@ -1,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { input, viewChild, TemplateRef, ChangeDetectionStrategy, Component, output, signal, contentChildren, inject, effect, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
2
+ import { input, viewChild, TemplateRef, ChangeDetectionStrategy, Component, computed, output, signal, contentChildren, inject, effect, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
3
3
  import { DOCUMENT, NgTemplateOutlet } from '@angular/common';
4
4
  import { html, unsafeCSS, LitElement } from 'lit';
5
5
  import '@mintplayer/ng-bootstrap/web-components/tab-control';
@@ -318,12 +318,21 @@ const styles = unsafeCSS(`:host {
318
318
  padding: 0.5rem 1rem;
319
319
  margin: -0.5rem -1rem;
320
320
  touch-action: none;
321
+ user-select: none;
322
+ -webkit-user-select: none;
323
+ -webkit-touch-callout: none;
321
324
  }
322
325
 
323
326
  .dock-tab:active {
324
327
  cursor: grabbing;
325
328
  }
326
329
 
330
+ .dock-tab[data-pressing=true] {
331
+ background-color: var(--bs-secondary-bg-subtle, rgba(0, 0, 0, 0.05));
332
+ transform: scale(1.02);
333
+ transition: background-color 100ms ease-out, transform 100ms ease-out;
334
+ }
335
+
327
336
  .dock-stack__pane {
328
337
  position: relative;
329
338
  display: flex;
@@ -419,9 +428,16 @@ class MintDockManagerElement extends LitElement {
419
428
  }
420
429
  }
421
430
  static get observedAttributes() {
422
- return [...(super.observedAttributes ?? []), 'layout'];
431
+ return [...(super.observedAttributes ?? []), 'layout', 'debug-layout-integrity'];
423
432
  }
424
433
  static { this.instanceCounter = 0; }
434
+ // Touch tab-drag gesture: a finger that lands on a tab header must be able
435
+ // to scroll the tabstrip natively. We require a stationary hold before
436
+ // arming the drag, so a horizontal swipe scrolls instead of undocking.
437
+ // See docs/prd/dock-touch-long-press-drag.md.
438
+ static { this.TOUCH_LONG_PRESS_MS = 600; }
439
+ static { this.TOUCH_LONG_PRESS_SLOP_PX = 10; }
440
+ static { this.TOUCH_PRESS_FEEDBACK_DELAY_MS = 150; }
425
441
  renderSnapMarkersForCorner() {
426
442
  if (!this.showSnapMarkers)
427
443
  return;
@@ -501,6 +517,11 @@ class MintDockManagerElement extends LitElement {
501
517
  this.cornerSnapYTargets = [];
502
518
  // Debug: render snap markers while dragging
503
519
  this.showSnapMarkers = false;
520
+ // Debug: assert every pane has a projection slot in shadow DOM after each
521
+ // render. Off by default so production hosts pay no overhead; demo enables
522
+ // it via the `debug-layout-integrity` attribute to catch layout-tree bugs
523
+ // loudly during development.
524
+ this.debugLayoutIntegrity = false;
504
525
  this.pendingDragEndTimeout = null;
505
526
  this.previousSplitSizes = new Map();
506
527
  this.instanceId = `mint-dock-${++MintDockManagerElement.instanceCounter}`;
@@ -615,6 +636,9 @@ class MintDockManagerElement extends LitElement {
615
636
  this.clearSnapMarkers();
616
637
  }
617
638
  }
639
+ else if (name === 'debug-layout-integrity') {
640
+ this.debugLayoutIntegrity = !(newValue === null || newValue === 'false' || newValue === '0');
641
+ }
618
642
  }
619
643
  get layout() {
620
644
  return {
@@ -657,6 +681,9 @@ class MintDockManagerElement extends LitElement {
657
681
  this.rootLayout = this.cloneLayoutNode(snapshot.root);
658
682
  this.floatingLayouts = this.cloneFloatingArray(snapshot.floating);
659
683
  this.titles = snapshot.titles ? { ...snapshot.titles } : {};
684
+ // Sanitize whatever the host fed us: empty stacks, 0/1-child splits, and
685
+ // nested same-direction splits get pruned/flattened before we render.
686
+ this.normalizeAllLayouts();
660
687
  this.renderLayout();
661
688
  }
662
689
  /**
@@ -740,6 +767,7 @@ class MintDockManagerElement extends LitElement {
740
767
  // wired up in firstUpdated (rootResizeObserver, dockedMutationObserver,
741
768
  // and delegated 'resizing' / 'resize-end' events). The MutationObserver
742
769
  // on dockedEl fires when the renderNode subtree above is appended.
770
+ this.verifyProjectionSlots();
743
771
  }
744
772
  renderNode(node, path, floatingIndex) {
745
773
  if (node.kind === 'split') {
@@ -1814,13 +1842,18 @@ class MintDockManagerElement extends LitElement {
1814
1842
  this.pendingTabDragMetrics = null;
1815
1843
  }
1816
1844
  /**
1817
- * Pointerdown handler arms a "may become a drag" gesture. Once the pointer
1818
- * moves past `threshold` pixels we promote it to an actual pane drag via
1819
- * {@link beginPaneDrag}; if the user releases first we just clear the
1820
- * pending tab metrics. All listeners self-clean on resolve so the gesture
1821
- * stays scoped to a single pointerdown.
1845
+ * Pointerdown handler arms a "may become a drag" gesture. Mouse / pen use a
1846
+ * 5 px distance threshold and arm immediately; touch dispatches to
1847
+ * {@link armPaneDragGestureTouch} which requires a 600 ms stationary hold
1848
+ * (so the user can scroll the tabstrip natively without undocking).
1849
+ * Once armed, both paths converge on {@link beginPaneDrag}. All listeners
1850
+ * self-clean on resolve so the gesture stays scoped to a single pointerdown.
1822
1851
  */
1823
1852
  armPaneDragGesture(startEvent, path, pane, stackEl) {
1853
+ if (startEvent.pointerType === 'touch') {
1854
+ this.armPaneDragGestureTouch(startEvent, path, pane, stackEl);
1855
+ return;
1856
+ }
1824
1857
  if (startEvent.pointerType === 'mouse' && startEvent.button !== 0)
1825
1858
  return;
1826
1859
  const win = this.windowRef;
@@ -1857,6 +1890,152 @@ class MintDockManagerElement extends LitElement {
1857
1890
  win.addEventListener('pointerup', onRelease, true);
1858
1891
  win.addEventListener('pointercancel', onRelease, true);
1859
1892
  }
1893
+ /**
1894
+ * Touch-specific gesture arming. With `.dock-tab` set to `touch-action:
1895
+ * none`, the browser never arbitrates the gesture itself, so JS owns it
1896
+ * from frame 1. Three outcomes from the pending state:
1897
+ *
1898
+ * - User holds within {@link TOUCH_LONG_PRESS_SLOP_PX} for
1899
+ * {@link TOUCH_LONG_PRESS_MS} → timer fires → drag arms via
1900
+ * {@link beginPaneDrag}.
1901
+ * - User moves past slop and the move is mostly horizontal, and the
1902
+ * strip's `<ul>` is scrollable → enter `scrolling` mode and drive
1903
+ * `ul.scrollLeft` from JS for the rest of the gesture (no drag, no
1904
+ * momentum — direct 1:1 finger follow).
1905
+ * - User moves past slop in any other direction, releases, or
1906
+ * pointercancel fires → abandoned, no drag, no scroll. Releases
1907
+ * under slop fire the synthesized click that drives `tab-activate`.
1908
+ *
1909
+ * `touch-action: pan-x` was the original PRD design but doesn't work:
1910
+ * the policy is frozen at touchstart and `setPointerCapture` doesn't
1911
+ * promote it, so first move after a long-press fires pointercancel and
1912
+ * strands the panel. JS-driven scroll is the only model that holds.
1913
+ */
1914
+ armPaneDragGestureTouch(startEvent, path, pane, stackEl) {
1915
+ const win = this.windowRef;
1916
+ if (!win)
1917
+ return;
1918
+ const startX = startEvent.clientX;
1919
+ const startY = startEvent.clientY;
1920
+ const pointerId = startEvent.pointerId;
1921
+ let latestX = startX;
1922
+ let latestY = startY;
1923
+ let lastScrollX = startX;
1924
+ let resolved = false;
1925
+ let scrolling = false;
1926
+ let pressFeedbackApplied = false;
1927
+ const tabSpan = startEvent.currentTarget instanceof HTMLElement
1928
+ ? startEvent.currentTarget
1929
+ : startEvent.target instanceof HTMLElement
1930
+ ? startEvent.target.closest('.dock-tab')
1931
+ : null;
1932
+ const stripUl = stackEl
1933
+ ? this.getStackStripEl(stackEl)?.querySelector('ul.nav.nav-tabs') ?? null
1934
+ : null;
1935
+ const cleanup = () => {
1936
+ if (resolved)
1937
+ return;
1938
+ resolved = true;
1939
+ win.clearTimeout(longPressTimeout);
1940
+ win.clearTimeout(pressFeedbackTimeout);
1941
+ win.removeEventListener('pointermove', onMove, true);
1942
+ win.removeEventListener('pointerup', onRelease, true);
1943
+ win.removeEventListener('pointercancel', onCancel, true);
1944
+ if (pressFeedbackApplied && tabSpan) {
1945
+ tabSpan.removeAttribute('data-pressing');
1946
+ }
1947
+ };
1948
+ const onMove = (event) => {
1949
+ if (resolved || event.pointerId !== pointerId)
1950
+ return;
1951
+ if (scrolling) {
1952
+ const dx = event.clientX - lastScrollX;
1953
+ lastScrollX = event.clientX;
1954
+ if (stripUl)
1955
+ stripUl.scrollLeft -= dx;
1956
+ return;
1957
+ }
1958
+ latestX = event.clientX;
1959
+ latestY = event.clientY;
1960
+ const dx = event.clientX - startX;
1961
+ const dy = event.clientY - startY;
1962
+ if (Math.hypot(dx, dy) <= MintDockManagerElement.TOUCH_LONG_PRESS_SLOP_PX) {
1963
+ return;
1964
+ }
1965
+ // Slop crossed before the long-press fired. If the move is mostly
1966
+ // horizontal and the strip can scroll, take over scroll in JS;
1967
+ // otherwise abandon (no drag — browser native UI is fully suppressed
1968
+ // by `touch-action: none` so no fallback is needed).
1969
+ const stripScrollable = !!stripUl && stripUl.scrollWidth > stripUl.clientWidth;
1970
+ if (Math.abs(dx) > Math.abs(dy) && stripScrollable) {
1971
+ win.clearTimeout(longPressTimeout);
1972
+ win.clearTimeout(pressFeedbackTimeout);
1973
+ if (pressFeedbackApplied && tabSpan) {
1974
+ tabSpan.removeAttribute('data-pressing');
1975
+ pressFeedbackApplied = false;
1976
+ }
1977
+ scrolling = true;
1978
+ lastScrollX = event.clientX;
1979
+ stripUl.scrollLeft -= dx;
1980
+ this.clearPendingTabDragMetrics();
1981
+ return;
1982
+ }
1983
+ cleanup();
1984
+ this.clearPendingTabDragMetrics();
1985
+ };
1986
+ const onRelease = (event) => {
1987
+ if (resolved || event.pointerId !== pointerId)
1988
+ return;
1989
+ cleanup();
1990
+ this.clearPendingTabDragMetrics();
1991
+ };
1992
+ const onCancel = (event) => {
1993
+ if (resolved || event.pointerId !== pointerId)
1994
+ return;
1995
+ cleanup();
1996
+ this.clearPendingTabDragMetrics();
1997
+ };
1998
+ const longPressTimeout = win.setTimeout(() => {
1999
+ if (resolved)
2000
+ return;
2001
+ cleanup();
2002
+ try {
2003
+ this.setPointerCapture(pointerId);
2004
+ }
2005
+ catch {
2006
+ // setPointerCapture throws if the pointer is no longer active
2007
+ // (e.g. timer raced a pointerup we didn't see). Drag will abort
2008
+ // naturally on the next event.
2009
+ }
2010
+ if (typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function') {
2011
+ try {
2012
+ navigator.vibrate(10);
2013
+ }
2014
+ catch {
2015
+ // ignore
2016
+ }
2017
+ }
2018
+ const synthEvent = new PointerEvent('pointermove', {
2019
+ pointerId,
2020
+ pointerType: 'touch',
2021
+ isPrimary: true,
2022
+ clientX: latestX,
2023
+ clientY: latestY,
2024
+ bubbles: false,
2025
+ cancelable: false,
2026
+ });
2027
+ this.beginPaneDrag(synthEvent, path, pane, stackEl);
2028
+ }, MintDockManagerElement.TOUCH_LONG_PRESS_MS);
2029
+ const pressFeedbackTimeout = win.setTimeout(() => {
2030
+ if (resolved || !tabSpan)
2031
+ return;
2032
+ pressFeedbackApplied = true;
2033
+ tabSpan.setAttribute('data-pressing', 'true');
2034
+ }, MintDockManagerElement.TOUCH_PRESS_FEEDBACK_DELAY_MS);
2035
+ win.addEventListener('pointermove', onMove, true);
2036
+ win.addEventListener('pointerup', onRelease, true);
2037
+ win.addEventListener('pointercancel', onCancel, true);
2038
+ }
1860
2039
  beginPaneDrag(event, path, pane, stackEl) {
1861
2040
  const { path: sourcePath, floatingIndex, pointerOffsetX, pointerOffsetY, } = this.preparePaneDragSource(path, pane, stackEl, event);
1862
2041
  // Capture header bounds for detecting when to convert to floating.
@@ -2420,7 +2599,32 @@ class MintDockManagerElement extends LitElement {
2420
2599
  const placeholderButton = placeholderTabId
2421
2600
  ? allTabButtons.find((b) => b.id === `${placeholderTabId}-header-button`) ?? null
2422
2601
  : null;
2423
- const targets = allTabButtons.filter((b) => b !== placeholderButton);
2602
+ // The dragged pane's content is `data-hidden`, but mp-tab-control's strip
2603
+ // re-render is async (its MutationObserver schedules a Lit microtask). When
2604
+ // beginPaneDrag calls updateDraggedFloatingPositionFromPoint synchronously
2605
+ // right after preparePaneDragSource, this runs before that re-render — so
2606
+ // the dragged button is still in the strip's shadow. We must exclude it
2607
+ // explicitly, otherwise the loop counts it as a real target and the
2608
+ // placeholder gets appended past the live tabs (visible on touch
2609
+ // long-press, where the finger doesn't move and the user sees the
2610
+ // mis-positioned placeholder for the duration of the hold).
2611
+ const draggedPane = this.dragState?.pane ?? null;
2612
+ let draggedTabId = null;
2613
+ if (draggedPane) {
2614
+ for (const child of Array.from(stack.children)) {
2615
+ if (child instanceof HTMLElement &&
2616
+ child.classList.contains('dock-tab') &&
2617
+ !child.hasAttribute('data-placeholder') &&
2618
+ child.dataset['pane'] === draggedPane) {
2619
+ draggedTabId = child.dataset['tabId'] ?? null;
2620
+ break;
2621
+ }
2622
+ }
2623
+ }
2624
+ const draggedButton = draggedTabId
2625
+ ? allTabButtons.find((b) => b.id === `${draggedTabId}-header-button`) ?? null
2626
+ : null;
2627
+ const targets = allTabButtons.filter((b) => b !== placeholderButton && b !== draggedButton);
2424
2628
  if (targets.length === 0) {
2425
2629
  return 0;
2426
2630
  }
@@ -2543,16 +2747,14 @@ class MintDockManagerElement extends LitElement {
2543
2747
  if (!source) {
2544
2748
  return;
2545
2749
  }
2546
- const stackEmptied = this.removePaneFromLocation(source, pane, true);
2750
+ this.removePaneFromLocation(source, pane, true);
2547
2751
  const newRoot = {
2548
2752
  kind: 'stack',
2549
2753
  panes: [pane],
2550
2754
  activePane: pane,
2551
2755
  };
2552
2756
  this.rootLayout = newRoot;
2553
- if (stackEmptied) {
2554
- this.cleanupLocation(source);
2555
- }
2757
+ this.normalizeAllLayouts();
2556
2758
  this.renderLayout();
2557
2759
  this.dispatchLayoutChanged();
2558
2760
  if (this.dragState) {
@@ -2568,6 +2770,7 @@ class MintDockManagerElement extends LitElement {
2568
2770
  return;
2569
2771
  }
2570
2772
  this.reorderPaneInLocation(source, pane);
2773
+ this.normalizeAllLayouts();
2571
2774
  this.renderLayout();
2572
2775
  this.dispatchLayoutChanged();
2573
2776
  if (this.dragState) {
@@ -2575,13 +2778,11 @@ class MintDockManagerElement extends LitElement {
2575
2778
  }
2576
2779
  return;
2577
2780
  }
2578
- const stackEmptied = this.removePaneFromLocation(source, pane, true);
2781
+ this.removePaneFromLocation(source, pane, true);
2579
2782
  if (zone === 'center') {
2580
2783
  this.addPaneToLocation(target, pane);
2581
2784
  this.setActivePaneForLocation(target, pane);
2582
- if (stackEmptied) {
2583
- this.cleanupLocation(source);
2584
- }
2785
+ this.normalizeAllLayouts();
2585
2786
  this.renderLayout();
2586
2787
  this.dispatchLayoutChanged();
2587
2788
  if (this.dragState) {
@@ -2600,9 +2801,7 @@ class MintDockManagerElement extends LitElement {
2600
2801
  else {
2601
2802
  const floating = this.floatingLayouts[target.index];
2602
2803
  if (!floating) {
2603
- if (stackEmptied) {
2604
- this.cleanupLocation(source);
2605
- }
2804
+ this.normalizeAllLayouts();
2606
2805
  this.renderLayout();
2607
2806
  this.dispatchLayoutChanged();
2608
2807
  return;
@@ -2610,9 +2809,7 @@ class MintDockManagerElement extends LitElement {
2610
2809
  floating.root = this.dockNodeBeside(floating.root, target.node, newStack, zone);
2611
2810
  floating.activePane = pane;
2612
2811
  }
2613
- if (stackEmptied) {
2614
- this.cleanupLocation(source);
2615
- }
2812
+ this.normalizeAllLayouts();
2616
2813
  this.renderLayout();
2617
2814
  this.dispatchLayoutChanged();
2618
2815
  if (this.dragState) {
@@ -2630,6 +2827,7 @@ class MintDockManagerElement extends LitElement {
2630
2827
  if (!target && targetPath.type === 'docked' && !this.rootLayout) {
2631
2828
  this.rootLayout = this.cloneLayoutNode(source.root);
2632
2829
  this.removeFloatingAt(sourceIndex);
2830
+ this.normalizeAllLayouts();
2633
2831
  this.renderLayout();
2634
2832
  this.dispatchLayoutChanged();
2635
2833
  return true;
@@ -2655,6 +2853,7 @@ class MintDockManagerElement extends LitElement {
2655
2853
  this.setActivePaneForLocation(target, activePane);
2656
2854
  }
2657
2855
  this.removeFloatingAt(sourceIndex);
2856
+ this.normalizeAllLayouts();
2658
2857
  this.renderLayout();
2659
2858
  this.dispatchLayoutChanged();
2660
2859
  return true;
@@ -2667,12 +2866,14 @@ class MintDockManagerElement extends LitElement {
2667
2866
  floating.root = this.dockNodeBeside(floating.root, target.node, source.root, zone);
2668
2867
  floating.activePane = source.activePane ?? this.findFirstPaneName(source.root) ?? undefined;
2669
2868
  this.removeFloatingAt(sourceIndex);
2869
+ this.normalizeAllLayouts();
2670
2870
  this.renderLayout();
2671
2871
  this.dispatchLayoutChanged();
2672
2872
  return true;
2673
2873
  }
2674
2874
  this.rootLayout = this.dockNodeBeside(this.rootLayout, target.node, source.root, zone);
2675
2875
  this.removeFloatingAt(sourceIndex);
2876
+ this.normalizeAllLayouts();
2676
2877
  this.renderLayout();
2677
2878
  this.dispatchLayoutChanged();
2678
2879
  return true;
@@ -2710,7 +2911,7 @@ class MintDockManagerElement extends LitElement {
2710
2911
  if (skipCleanup) {
2711
2912
  return true;
2712
2913
  }
2713
- this.rootLayout = this.cleanupEmptyStackInTree(this.rootLayout, stack);
2914
+ this.normalizeAllLayouts();
2714
2915
  return true;
2715
2916
  }
2716
2917
  findParentSplit(node, child) {
@@ -3083,48 +3284,6 @@ class MintDockManagerElement extends LitElement {
3083
3284
  this.normalizeSplitNode(parentInfo.parent);
3084
3285
  return root;
3085
3286
  }
3086
- cleanupEmptyStackInTree(root, stack) {
3087
- if (!root || stack.panes.length > 0) {
3088
- return root;
3089
- }
3090
- const parentInfo = this.findParentSplit(root, stack);
3091
- if (!parentInfo) {
3092
- return root === stack ? null : root;
3093
- }
3094
- const parent = parentInfo.parent;
3095
- const index = parent.children.indexOf(stack);
3096
- if (index === -1) {
3097
- return root;
3098
- }
3099
- parent.children.splice(index, 1);
3100
- if (Array.isArray(parent.sizes)) {
3101
- parent.sizes.splice(index, 1);
3102
- }
3103
- this.normalizeSplitNode(parent);
3104
- return this.cleanupSplitIfNecessary(root, parent);
3105
- }
3106
- cleanupSplitIfNecessary(root, split) {
3107
- if (split.children.length === 1) {
3108
- return this.replaceNodeInTree(root, split, split.children[0]);
3109
- }
3110
- if (split.children.length === 0) {
3111
- const parentInfo = this.findParentSplit(root, split);
3112
- if (!parentInfo) {
3113
- return null;
3114
- }
3115
- const parent = parentInfo.parent;
3116
- const index = parent.children.indexOf(split);
3117
- if (index !== -1) {
3118
- parent.children.splice(index, 1);
3119
- if (Array.isArray(parent.sizes)) {
3120
- parent.sizes.splice(index, 1);
3121
- }
3122
- this.normalizeSplitNode(parent);
3123
- return this.cleanupSplitIfNecessary(root, parent);
3124
- }
3125
- }
3126
- return root;
3127
- }
3128
3287
  dockNodeBeside(root, targetNode, newNode, zone) {
3129
3288
  const orientation = zone === 'left' || zone === 'right' ? 'horizontal' : 'vertical';
3130
3289
  const placeBefore = zone === 'left' || zone === 'top';
@@ -3345,21 +3504,6 @@ class MintDockManagerElement extends LitElement {
3345
3504
  }
3346
3505
  }
3347
3506
  }
3348
- cleanupLocation(location) {
3349
- if (location.context === 'docked') {
3350
- this.rootLayout = this.cleanupEmptyStackInTree(this.rootLayout, location.node);
3351
- }
3352
- else {
3353
- const floating = this.floatingLayouts[location.index];
3354
- if (!floating) {
3355
- return;
3356
- }
3357
- floating.root = this.cleanupEmptyStackInTree(floating.root, location.node);
3358
- if (!floating.root) {
3359
- this.removeFloatingAt(location.index);
3360
- }
3361
- }
3362
- }
3363
3507
  reorderPaneInLocation(location, pane) {
3364
3508
  const panes = location.node.panes;
3365
3509
  const index = panes.indexOf(pane);
@@ -3415,10 +3559,7 @@ class MintDockManagerElement extends LitElement {
3415
3559
  if (skipCleanup) {
3416
3560
  return true;
3417
3561
  }
3418
- floating.root = this.cleanupEmptyStackInTree(floating.root, node);
3419
- if (!floating.root) {
3420
- this.removeFloatingAt(index);
3421
- }
3562
+ this.normalizeAllLayouts();
3422
3563
  return true;
3423
3564
  }
3424
3565
  normalizeSizesArray(sizes, count) {
@@ -3438,6 +3579,115 @@ class MintDockManagerElement extends LitElement {
3438
3579
  normalizeSplitNode(split) {
3439
3580
  split.sizes = this.normalizeSizesArray(split.sizes, split.children.length);
3440
3581
  }
3582
+ /**
3583
+ * Bottom-up layout sanitizer. Returns a normalized version of `node` where:
3584
+ * - Empty stacks (panes.length === 0) are dropped (returned as null).
3585
+ * - A stack's `activePane` is repaired if it no longer references one of `panes`.
3586
+ * - Splits whose direction matches a child split are flattened, with sizes
3587
+ * combined multiplicatively so the resulting on-screen pixel layout is
3588
+ * identical to the pre-merge one.
3589
+ * - Splits with 0 children become null. Splits with 1 child are unwrapped.
3590
+ *
3591
+ * Idempotent: passing the result back through this method yields the same
3592
+ * structure. Mutates the input tree in place but only returns nodes that
3593
+ * remain part of the layout.
3594
+ */
3595
+ normalizeLayoutNode(node) {
3596
+ if (!node)
3597
+ return null;
3598
+ if (node.kind === 'stack') {
3599
+ if (node.panes.length === 0)
3600
+ return null;
3601
+ if (!node.activePane || !node.panes.includes(node.activePane)) {
3602
+ node.activePane = node.panes[0];
3603
+ }
3604
+ return node;
3605
+ }
3606
+ const slotSizes = this.normalizeSizesArray(node.sizes, node.children.length);
3607
+ // Pair each child with its slot weight, drop nulls, then expand any
3608
+ // same-direction child split into its grandchildren with sizes scaled
3609
+ // multiplicatively. A 0.4 slot containing [0.3, 0.7] becomes [0.12, 0.28].
3610
+ const survivors = node.children
3611
+ .map((child, i) => ({ child: this.normalizeLayoutNode(child), slot: slotSizes[i] }))
3612
+ .filter((p) => p.child !== null)
3613
+ .flatMap(({ child, slot }) => {
3614
+ if (child.kind === 'split' && child.direction === node.direction) {
3615
+ const innerSizes = this.normalizeSizesArray(child.sizes, child.children.length);
3616
+ return child.children.map((grandchild, idx) => ({
3617
+ child: grandchild,
3618
+ slot: slot * innerSizes[idx],
3619
+ }));
3620
+ }
3621
+ return [{ child, slot }];
3622
+ });
3623
+ if (survivors.length === 0)
3624
+ return null;
3625
+ if (survivors.length === 1)
3626
+ return survivors[0].child;
3627
+ node.children = survivors.map((s) => s.child);
3628
+ node.sizes = this.normalizeSizesArray(survivors.map((s) => s.slot), survivors.length);
3629
+ return node;
3630
+ }
3631
+ /**
3632
+ * Apply `normalizeLayoutNode` to `rootLayout` and every floating window's
3633
+ * root, drop floating windows whose root collapses to null, and repair
3634
+ * stale `activePane` references on each floating window. Run this at the
3635
+ * end of every public mutation entry point (drop handlers, layout setter,
3636
+ * pane removal) so the tree the renderer sees is always in canonical form.
3637
+ */
3638
+ normalizeAllLayouts() {
3639
+ this.rootLayout = this.normalizeLayoutNode(this.rootLayout);
3640
+ this.floatingLayouts = this.floatingLayouts
3641
+ .map((floating) => {
3642
+ floating.root = this.normalizeLayoutNode(floating.root);
3643
+ if (!floating.root)
3644
+ return null;
3645
+ const panes = this.collectPaneNames(floating.root);
3646
+ if (!floating.activePane || !panes.includes(floating.activePane)) {
3647
+ const fallback = this.findFirstPaneName(floating.root);
3648
+ if (fallback) {
3649
+ floating.activePane = fallback;
3650
+ }
3651
+ else {
3652
+ delete floating.activePane;
3653
+ }
3654
+ }
3655
+ return floating;
3656
+ })
3657
+ .filter((f) => f !== null);
3658
+ }
3659
+ /**
3660
+ * Dev-mode integrity guard: walks every pane referenced by the current
3661
+ * layout and asserts that the rendered shadow DOM contains a matching
3662
+ * `<slot name="${pane}">`. A missing slot means the layout tree got into
3663
+ * a state the renderer can't display — typically a missed normalize() call
3664
+ * or a render bug. Opt in via the `debug-layout-integrity` attribute or
3665
+ * the `debugLayoutIntegrity` property; off by default.
3666
+ */
3667
+ verifyProjectionSlots() {
3668
+ if (!this.debugLayoutIntegrity)
3669
+ return;
3670
+ const root = this.shadowRoot;
3671
+ if (!root)
3672
+ return;
3673
+ // Collect every slot the renderer produced. Walking the rendered DOM
3674
+ // (instead of building a CSS selector per pane) sidesteps environment
3675
+ // differences — e.g. jsdom does not expose `CSS.escape`, which would
3676
+ // crash the guard during unit tests before the assertion runs.
3677
+ const slotNames = new Set(Array.from(root.querySelectorAll('slot'))
3678
+ .map((slot) => slot.getAttribute('name'))
3679
+ .filter((name) => !!name));
3680
+ const panes = [
3681
+ ...this.collectPaneNames(this.rootLayout),
3682
+ ...this.floatingLayouts.flatMap((f) => this.collectPaneNames(f.root)),
3683
+ ];
3684
+ const missing = panes.find((pane) => !slotNames.has(pane));
3685
+ if (missing) {
3686
+ throw new Error(`mint-dock-manager: pane "${missing}" has no projection slot in the shadow DOM. ` +
3687
+ `The layout tree got into a state the renderer can't display — likely a ` +
3688
+ `missing normalize() call or a render bug.`);
3689
+ }
3690
+ }
3441
3691
  dispatchLayoutChanged() {
3442
3692
  this.dispatchEvent(new CustomEvent('dock-layout-changed', {
3443
3693
  detail: this.snapshot,
@@ -3469,6 +3719,14 @@ class BsDockManagerComponent {
3469
3719
  }
3470
3720
  constructor() {
3471
3721
  this.layout = input(null, ...(ngDevMode ? [{ debugName: "layout" }] : /* istanbul ignore next */ []));
3722
+ /**
3723
+ * Dev-mode integrity guard. When `true`, the inner web component throws
3724
+ * after each render if any registered pane has no projection slot in the
3725
+ * shadow DOM — a signal that the layout tree got corrupted. Off by default;
3726
+ * enable in development to catch layout-logic bugs loudly.
3727
+ */
3728
+ this.debugLayoutIntegrity = input(false, ...(ngDevMode ? [{ debugName: "debugLayoutIntegrity" }] : /* istanbul ignore next */ []));
3729
+ this.debugLayoutIntegrityAttr = computed(() => this.debugLayoutIntegrity() ? '' : null, ...(ngDevMode ? [{ debugName: "debugLayoutIntegrityAttr" }] : /* istanbul ignore next */ []));
3472
3730
  this.layoutChange = output();
3473
3731
  this.layoutSnapshotChange = output();
3474
3732
  this.layoutString = signal(null, ...(ngDevMode ? [{ debugName: "layoutString" }] : /* istanbul ignore next */ []));
@@ -3537,12 +3795,12 @@ class BsDockManagerComponent {
3537
3795
  return JSON.parse(JSON.stringify(layout));
3538
3796
  }
3539
3797
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: BsDockManagerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
3540
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.11", type: BsDockManagerComponent, isStandalone: true, selector: "bs-dock-manager", inputs: { layout: { classPropertyName: "layout", publicName: "layout", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { layoutChange: "layoutChange", layoutSnapshotChange: "layoutSnapshotChange" }, queries: [{ propertyName: "panes", predicate: BsDockPaneComponent, isSignal: true }], viewQueries: [{ propertyName: "managerRef", first: true, predicate: ["manager"], descendants: true, isSignal: true }], ngImport: i0, template: "<mint-dock-manager\n #manager\n class=\"bs-dock-manager\"\n [attr.layout]=\"layoutString()\"\n (dock-layout-changed)=\"onLayoutChanged($event)\"\n >\n @for (pane of panes(); track trackByPane($index, pane)) {\n <div class=\"bs-dock-pane\" [attr.slot]=\"pane.name()\">\n <ng-container *ngTemplateOutlet=\"pane.template()\"></ng-container>\n </div>\n }\n</mint-dock-manager>\n", styles: [":host{display:block;width:100%;height:100%}.bs-dock-manager{display:block;width:100%;height:100%}.bs-dock-pane{display:contents}\n"], dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
3798
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.11", type: BsDockManagerComponent, isStandalone: true, selector: "bs-dock-manager", inputs: { layout: { classPropertyName: "layout", publicName: "layout", isSignal: true, isRequired: false, transformFunction: null }, debugLayoutIntegrity: { classPropertyName: "debugLayoutIntegrity", publicName: "debugLayoutIntegrity", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { layoutChange: "layoutChange", layoutSnapshotChange: "layoutSnapshotChange" }, queries: [{ propertyName: "panes", predicate: BsDockPaneComponent, isSignal: true }], viewQueries: [{ propertyName: "managerRef", first: true, predicate: ["manager"], descendants: true, isSignal: true }], ngImport: i0, template: "<mint-dock-manager\n #manager\n class=\"bs-dock-manager\"\n [attr.layout]=\"layoutString()\"\n [attr.debug-layout-integrity]=\"debugLayoutIntegrityAttr()\"\n (dock-layout-changed)=\"onLayoutChanged($event)\"\n >\n @for (pane of panes(); track trackByPane($index, pane)) {\n <div class=\"bs-dock-pane\" [attr.slot]=\"pane.name()\">\n <ng-container *ngTemplateOutlet=\"pane.template()\"></ng-container>\n </div>\n }\n</mint-dock-manager>\n", styles: [":host{display:block;width:100%;height:100%}.bs-dock-manager{display:block;width:100%;height:100%}.bs-dock-pane{display:contents}\n"], dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
3541
3799
  }
3542
3800
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: BsDockManagerComponent, decorators: [{
3543
3801
  type: Component,
3544
- args: [{ selector: 'bs-dock-manager', imports: [NgTemplateOutlet], schemas: [CUSTOM_ELEMENTS_SCHEMA], changeDetection: ChangeDetectionStrategy.OnPush, template: "<mint-dock-manager\n #manager\n class=\"bs-dock-manager\"\n [attr.layout]=\"layoutString()\"\n (dock-layout-changed)=\"onLayoutChanged($event)\"\n >\n @for (pane of panes(); track trackByPane($index, pane)) {\n <div class=\"bs-dock-pane\" [attr.slot]=\"pane.name()\">\n <ng-container *ngTemplateOutlet=\"pane.template()\"></ng-container>\n </div>\n }\n</mint-dock-manager>\n", styles: [":host{display:block;width:100%;height:100%}.bs-dock-manager{display:block;width:100%;height:100%}.bs-dock-pane{display:contents}\n"] }]
3545
- }], ctorParameters: () => [], propDecorators: { layout: [{ type: i0.Input, args: [{ isSignal: true, alias: "layout", required: false }] }], layoutChange: [{ type: i0.Output, args: ["layoutChange"] }], layoutSnapshotChange: [{ type: i0.Output, args: ["layoutSnapshotChange"] }], panes: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => BsDockPaneComponent), { isSignal: true }] }], managerRef: [{ type: i0.ViewChild, args: ['manager', { isSignal: true }] }] } });
3802
+ args: [{ selector: 'bs-dock-manager', imports: [NgTemplateOutlet], schemas: [CUSTOM_ELEMENTS_SCHEMA], changeDetection: ChangeDetectionStrategy.OnPush, template: "<mint-dock-manager\n #manager\n class=\"bs-dock-manager\"\n [attr.layout]=\"layoutString()\"\n [attr.debug-layout-integrity]=\"debugLayoutIntegrityAttr()\"\n (dock-layout-changed)=\"onLayoutChanged($event)\"\n >\n @for (pane of panes(); track trackByPane($index, pane)) {\n <div class=\"bs-dock-pane\" [attr.slot]=\"pane.name()\">\n <ng-container *ngTemplateOutlet=\"pane.template()\"></ng-container>\n </div>\n }\n</mint-dock-manager>\n", styles: [":host{display:block;width:100%;height:100%}.bs-dock-manager{display:block;width:100%;height:100%}.bs-dock-pane{display:contents}\n"] }]
3803
+ }], ctorParameters: () => [], propDecorators: { layout: [{ type: i0.Input, args: [{ isSignal: true, alias: "layout", required: false }] }], debugLayoutIntegrity: [{ type: i0.Input, args: [{ isSignal: true, alias: "debugLayoutIntegrity", required: false }] }], layoutChange: [{ type: i0.Output, args: ["layoutChange"] }], layoutSnapshotChange: [{ type: i0.Output, args: ["layoutSnapshotChange"] }], panes: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => BsDockPaneComponent), { isSignal: true }] }], managerRef: [{ type: i0.ViewChild, args: ['manager', { isSignal: true }] }] } });
3546
3804
 
3547
3805
  /**
3548
3806
  * Generated bundle index. Do not edit.