mn-angular-lib 1.0.138 → 1.0.140

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.
@@ -3508,6 +3508,27 @@ class MnMultiSelect {
3508
3508
  triggerRef;
3509
3509
  /** The panel element currently moved into `document.body`, if any. */
3510
3510
  movedPanel = null;
3511
+ /** The sheet backdrop element currently moved into `document.body`, if any. */
3512
+ movedBackdrop = null;
3513
+ /** Option count at which the search input auto-enables when `searchable` is unset. */
3514
+ static DEFAULT_SEARCH_THRESHOLD = 8;
3515
+ /** Tailwind's `sm` breakpoint — below this the panel renders as a bottom sheet.
3516
+ * Kept in step with the same constant in `MnModalShellComponent`. */
3517
+ static SHEET_MAX_WIDTH = 639.98;
3518
+ /** Whether the viewport is currently narrow enough for the sheet layout. */
3519
+ isNarrowViewport = false;
3520
+ /** Live breakpoint match, so rotating the device re-evaluates the layout. */
3521
+ sheetMedia = null;
3522
+ /** The listener registered on `sheetMedia`, retained for teardown. */
3523
+ sheetMediaListener = null;
3524
+ /** `document.body`'s inline `overflow` before the sheet locked it, restored on close. */
3525
+ previousBodyOverflow = null;
3526
+ /**
3527
+ * The sheet's height (px) captured the moment it opened, before any search. Re-applied
3528
+ * as a `min-height` floor so filtering the option list shorter cannot shrink the sheet
3529
+ * mid-type. Null while anchored or closed, so the popover and desktop path are untouched.
3530
+ */
3531
+ sheetFloorPx = null;
3511
3532
  /**
3512
3533
  * Watches the trigger while the panel is open. The panel lives in `document.body`,
3513
3534
  * so it survives its own trigger being hidden by an ancestor — e.g. a wizard step
@@ -3530,7 +3551,22 @@ class MnMultiSelect {
3530
3551
  * broken on iOS). Cleanup is handled when the query clears on close/destroy.
3531
3552
  */
3532
3553
  set dropdownRef(ref) {
3533
- this.relocateDropdown(ref?.nativeElement ?? null);
3554
+ const el = ref?.nativeElement ?? null;
3555
+ this.movedPanel = this.portal(el, this.movedPanel);
3556
+ if (el && this.isSheet) {
3557
+ this.captureSheetFloor(el);
3558
+ }
3559
+ else if (!el) {
3560
+ this.sheetFloorPx = null;
3561
+ }
3562
+ }
3563
+ /**
3564
+ * The dimming backdrop rendered behind the mobile sheet. Portalled alongside the
3565
+ * panel for the same reason — a `position: fixed` backdrop left inside a transformed
3566
+ * ancestor would cover that ancestor rather than the viewport.
3567
+ */
3568
+ set sheetBackdropRef(ref) {
3569
+ this.movedBackdrop = this.portal(ref?.nativeElement ?? null, this.movedBackdrop);
3534
3570
  }
3535
3571
  /** Currently selected values */
3536
3572
  selectedValues = [];
@@ -3551,16 +3587,47 @@ class MnMultiSelect {
3551
3587
  }
3552
3588
  ngOnInit() {
3553
3589
  this.resolveConfig();
3590
+ this.startWatchingViewport();
3554
3591
  const sub = this.lang.locale$.pipe(skip(1)).subscribe(() => {
3555
3592
  this.resolveConfig();
3556
3593
  });
3557
3594
  this.destroyRef.onDestroy(() => {
3558
3595
  sub.unsubscribe();
3559
3596
  this.stopWatchingTrigger();
3560
- // Guarantee the portalled panel never outlives the component.
3561
- this.relocateDropdown(null);
3597
+ this.stopWatchingViewport();
3598
+ this.unlockBodyScroll();
3599
+ // Guarantee the portalled elements never outlive the component.
3600
+ this.movedPanel = this.portal(null, this.movedPanel);
3601
+ this.movedBackdrop = this.portal(null, this.movedBackdrop);
3562
3602
  });
3563
3603
  }
3604
+ /**
3605
+ * Tracks the sheet breakpoint through `matchMedia` rather than reading `innerWidth`
3606
+ * once, so rotating the device switches layout instead of leaving a panel positioned
3607
+ * for the previous orientation. An open panel is closed on the switch — its anchored
3608
+ * coordinates and its sheet layout are not interchangeable.
3609
+ */
3610
+ startWatchingViewport() {
3611
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
3612
+ return;
3613
+ this.sheetMedia = window.matchMedia(`(max-width: ${MnMultiSelect.SHEET_MAX_WIDTH}px)`);
3614
+ this.isNarrowViewport = this.sheetMedia.matches;
3615
+ this.sheetMediaListener = (event) => {
3616
+ this.isNarrowViewport = event.matches;
3617
+ this.close();
3618
+ // The listener fires outside Angular, so a zoneless app needs an explicit nudge.
3619
+ this.cdr.markForCheck();
3620
+ };
3621
+ this.sheetMedia.addEventListener('change', this.sheetMediaListener);
3622
+ }
3623
+ /** Tears down the breakpoint listener. Idempotent. */
3624
+ stopWatchingViewport() {
3625
+ if (this.sheetMedia && this.sheetMediaListener) {
3626
+ this.sheetMedia.removeEventListener('change', this.sheetMediaListener);
3627
+ }
3628
+ this.sheetMedia = null;
3629
+ this.sheetMediaListener = null;
3630
+ }
3564
3631
  onDocumentClick(event) {
3565
3632
  const target = event.target;
3566
3633
  // The panel lives at the body root once open, so it is not a descendant of the
@@ -3603,15 +3670,74 @@ class MnMultiSelect {
3603
3670
  return;
3604
3671
  }
3605
3672
  this.isOpen = true;
3673
+ if (this.isSheet) {
3674
+ // A sheet is anchored to the viewport, so it needs no trigger tracking — only a
3675
+ // scroll lock so the page behind it stays put while the list is scrolled.
3676
+ this.lockBodyScroll();
3677
+ return;
3678
+ }
3606
3679
  this.updateDropdownPosition();
3607
3680
  this.startWatchingTrigger();
3608
3681
  }
3682
+ /** Whether the panel should currently render as a bottom sheet. */
3683
+ get isSheet() {
3684
+ return this.props.mobileSheet !== false && this.isNarrowViewport;
3685
+ }
3686
+ /**
3687
+ * Whether the search input is shown: the explicit `searchable` prop when set,
3688
+ * otherwise auto-enabled once the option count reaches the threshold.
3689
+ */
3690
+ get isSearchable() {
3691
+ if (this.props.searchable !== undefined)
3692
+ return this.props.searchable;
3693
+ const threshold = this.props.searchThreshold ?? MnMultiSelect.DEFAULT_SEARCH_THRESHOLD;
3694
+ return this.props.options.length >= threshold;
3695
+ }
3696
+ /** Layout classes for the panel — a bottom-anchored sheet, or the trigger-anchored popover. */
3697
+ get panelClasses() {
3698
+ // The sheet sizes to its content (capped at 80vh), so a short list gets a short
3699
+ // sheet. To stop it collapsing upward as the search filters options away, the height
3700
+ // it opens at is captured once and re-applied as a `min-height` floor (see
3701
+ // `sheetFloorPx`): the `flex-1` list then keeps that frame and just shows fewer rows.
3702
+ return this.isSheet
3703
+ ? 'mn-ms-sheet fixed inset-x-0 bottom-0 z-9999 flex flex-col bg-base-100 border-t border-base-300 rounded-t-2xl shadow-lg max-h-[80vh]'
3704
+ : 'fixed z-9999 bg-base-100 border border-base-300 rounded-md shadow-lg max-h-60 overflow-auto';
3705
+ }
3706
+ /**
3707
+ * Records the sheet's opened height as its `min-height` floor. Measured on the next
3708
+ * frame so the read reflects the fully-rendered, unfiltered list (the search box is
3709
+ * empty on open) and never forces a reflow mid change-detection. The floor equals the
3710
+ * content height at that instant, so applying it triggers no resize — it only stops a
3711
+ * later, shorter filtered list from pulling the sheet down.
3712
+ */
3713
+ captureSheetFloor(panel) {
3714
+ if (typeof requestAnimationFrame !== 'function') {
3715
+ this.sheetFloorPx = panel.offsetHeight;
3716
+ return;
3717
+ }
3718
+ requestAnimationFrame(() => {
3719
+ // The panel may have closed before the frame ran; don't strand a stale floor.
3720
+ if (!this.isOpen || this.movedPanel !== panel)
3721
+ return;
3722
+ this.sheetFloorPx = panel.offsetHeight;
3723
+ this.cdr.markForCheck();
3724
+ });
3725
+ }
3609
3726
  /** Closes the dropdown on Escape for keyboard accessibility. */
3610
3727
  onEscape() {
3611
3728
  this.close();
3612
3729
  }
3613
- /** Closes the dropdown when the page or a scrollable parent is scrolled */
3730
+ /**
3731
+ * Closes the dropdown when the page or a scrollable parent is scrolled.
3732
+ *
3733
+ * Skipped for a sheet: it is anchored to the viewport, not to the trigger, so it has
3734
+ * no stale position to escape. Crucially, opening the soft keyboard fires a `resize`
3735
+ * on Android — closing on that would dismiss the sheet the instant search is focused.
3736
+ * A genuine layout switch is handled by the `matchMedia` listener instead.
3737
+ */
3614
3738
  onWindowScrollOrResize() {
3739
+ if (this.isSheet)
3740
+ return;
3615
3741
  this.close();
3616
3742
  }
3617
3743
  /**
@@ -3625,6 +3751,29 @@ class MnMultiSelect {
3625
3751
  this.isOpen = false;
3626
3752
  this.searchTerm = '';
3627
3753
  this.stopWatchingTrigger();
3754
+ this.unlockBodyScroll();
3755
+ }
3756
+ /**
3757
+ * Freezes the page behind an open sheet. The previous inline value is captured and
3758
+ * restored verbatim so a surrounding modal that set its own lock is left intact.
3759
+ */
3760
+ lockBodyScroll() {
3761
+ if (this.previousBodyOverflow !== null)
3762
+ return;
3763
+ this.previousBodyOverflow = document.body.style.overflow;
3764
+ this.renderer.setStyle(document.body, 'overflow', 'hidden');
3765
+ }
3766
+ /** Restores the pre-lock `overflow`. Idempotent. */
3767
+ unlockBodyScroll() {
3768
+ if (this.previousBodyOverflow === null)
3769
+ return;
3770
+ if (this.previousBodyOverflow) {
3771
+ this.renderer.setStyle(document.body, 'overflow', this.previousBodyOverflow);
3772
+ }
3773
+ else {
3774
+ this.renderer.removeStyle(document.body, 'overflow');
3775
+ }
3776
+ this.previousBodyOverflow = null;
3628
3777
  }
3629
3778
  /** Calculates the fixed position for the dropdown based on the trigger element */
3630
3779
  updateDropdownPosition() {
@@ -3667,27 +3816,29 @@ class MnMultiSelect {
3667
3816
  document.addEventListener('scroll', this.scrollCapture, true);
3668
3817
  }
3669
3818
  /**
3670
- * Move the dropdown panel to `document.body` when it appears, and detach it when
3671
- * the query clears. Appending to the body root makes the panel immune to ancestor
3672
- * `transform`/`filter`/`will-change`, so `position: fixed` anchors to the viewport
3673
- * and the panel stays under its trigger. Idempotent and safe to call with `null`.
3819
+ * Move an overlay element to `document.body` when it appears, and detach it when the
3820
+ * query clears. Appending to the body root makes the element immune to ancestor
3821
+ * `transform`/`filter`/`will-change`, so `position: fixed` anchors to the viewport
3822
+ * without this the panel lands mid-screen (and breaks outright on iOS).
3823
+ *
3824
+ * Returns the element now portalled, so the caller can store it. Idempotent and safe
3825
+ * to call with `null`.
3674
3826
  */
3675
- relocateDropdown(el) {
3827
+ portal(el, current) {
3676
3828
  if (el) {
3677
- if (this.movedPanel === el)
3678
- return;
3829
+ if (current === el)
3830
+ return current;
3679
3831
  this.renderer.appendChild(document.body, el);
3680
- this.movedPanel = el;
3681
- return;
3832
+ return el;
3682
3833
  }
3683
- if (this.movedPanel) {
3834
+ if (current) {
3684
3835
  // Angular's view teardown may already have removed it; only detach if still attached.
3685
- const parent = this.movedPanel.parentNode;
3836
+ const parent = current.parentNode;
3686
3837
  if (parent) {
3687
- this.renderer.removeChild(parent, this.movedPanel);
3838
+ this.renderer.removeChild(parent, current);
3688
3839
  }
3689
- this.movedPanel = null;
3690
3840
  }
3841
+ return null;
3691
3842
  }
3692
3843
  /** Tears down the watchers installed by `startWatchingTrigger`. Idempotent. */
3693
3844
  stopWatchingTrigger() {
@@ -3843,11 +3994,11 @@ class MnMultiSelect {
3843
3994
  });
3844
3995
  }
3845
3996
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnMultiSelect, deps: [], target: i0.ɵɵFactoryTarget.Component });
3846
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnMultiSelect, isStandalone: true, selector: "mn-lib-multi-select", inputs: { props: "props" }, host: { listeners: { "document:click": "onDocumentClick($event)", "document:keydown.escape": "onEscape()", "window:scroll": "onWindowScrollOrResize()", "window:resize": "onWindowScrollOrResize()" } }, viewQueries: [{ propertyName: "triggerRef", first: true, predicate: ["trigger"], descendants: true }, { propertyName: "dropdownRef", first: true, predicate: ["dropdown"], descendants: true }], ngImport: i0, template: "<div class=\"flex flex-col h-full\" [class.is-fullwidth]=\"props.fullWidth\">\n @if (uiConfig.label || props.label) {\n <label class=\"pl-2 pb-1 flex flex-row gap-x-0.5! text-base!\" [attr.for]=\"resolvedId\">\n <p>{{ uiConfig.label || props.label }}</p>\n @if (isRequired()) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n }\n\n <!-- Trigger -->\n <div\n #trigger\n [id]=\"resolvedId\"\n [ngClass]=\"triggerClasses\"\n class=\"relative\"\n [attr.aria-label]=\"uiConfig.ariaLabel || uiConfig.label || props.label || null\"\n [attr.aria-invalid]=\"showError || null\"\n [attr.aria-describedby]=\"showError ? resolvedId + '-error' : null\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-controls]=\"isOpen ? resolvedId + '-listbox' : null\"\n role=\"combobox\"\n tabindex=\"0\"\n (click)=\"toggle()\"\n (keydown.enter)=\"toggle()\"\n (keydown.space)=\"toggle(); $event.preventDefault()\"\n (blur)=\"handleBlur()\"\n >\n <!-- `pr-6` reserves the gutter the caret is absolutely positioned in (right-2 +\n w-4), so a value can never render underneath it. `min-w-0` lets the chips\n shrink below their content width, which is what makes truncation possible. -->\n <div class=\"flex flex-row items-center gap-x-2 flex-wrap min-h-6 min-w-0 pr-6\">\n @if (selectedOptions.length === 0) {\n <span class=\"text-base-content/50\">{{ uiConfig.placeholder || props.placeholder || 'Select...' }}</span>\n } @else if (isCollapsed) {\n <span\n class=\"inline-flex items-center max-w-full truncate bg-base-200 border border-accent text-base-content text-xs px-2 py-0.5 rounded-md\">\n {{ collapseSummaryText }}\n </span>\n } @else {\n @for (opt of selectedOptions; track opt.value) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <span\n class=\"inline-flex items-center gap-x-1 max-w-full min-w-0 bg-base-200 border border-accent text-base-content text-xs pl-2 py-0.5 rounded-md cursor-pointer\"\n (click)=\"removeOption(opt, $event)\">\n <span [attr.title]=\"opt.label\" class=\"truncate\">{{ opt.label }}</span>\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'secondary', hover: false }\"\n type=\"button\"\n class=\"text-base-content/50 cursor-pointer shrink-0\"\n (click)=\"removeOption(opt, $event)\"\n [attr.aria-label]=\"'Remove ' + opt.label\"\n ><svg lucideX [size]=\"18\"></svg></button>\n </span>\n }\n }\n </div>\n <div class=\"absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none\">\n <svg [size]=\"16\" class=\"text-base-content/50\" lucideChevronDown></svg>\n </div>\n </div>\n\n <!-- Dropdown -->\n @if (isOpen) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n #dropdown\n [id]=\"resolvedId + '-listbox'\"\n aria-multiselectable=\"true\"\n role=\"listbox\"\n class=\"fixed z-9999 bg-base-100 border border-base-300 rounded-md shadow-lg max-h-60 overflow-auto\"\n [style.top]=\"dropdownStyle.top\"\n [style.left]=\"dropdownStyle.left\"\n [style.width]=\"dropdownStyle.width\"\n (click)=\"$event.stopPropagation()\"\n >\n @if (props.searchable) {\n <div class=\"p-2 border-b border-base-300\">\n <input\n type=\"text\"\n class=\"w-full p-1.5 text-sm border border-base-300 rounded-md outline-none focus:border-primary-500 bg-base-200 text-base-content placeholder-base-content/50\"\n [placeholder]=\"props.searchPlaceholder || 'Search...'\"\n [value]=\"searchTerm\"\n (input)=\"onSearch(($any($event.target)).value)\"\n (click)=\"$event.stopPropagation()\"\n />\n </div>\n }\n @for (opt of filteredOptions; track opt.value) {\n <div\n (keyup.enter)=\"toggleOption(opt)\"\n (keyup.space)=\"toggleOption(opt)\"\n [attr.aria-selected]=\"isSelected(opt)\"\n class=\"flex items-center gap-x-2 px-3 py-2 text-sm cursor-pointer text-base-content hover:bg-base-200\"\n [class.opacity-50]=\"opt.disabled || isMaxReached(opt)\"\n [class.pointer-events-none]=\"opt.disabled || isMaxReached(opt)\"\n (click)=\"toggleOption(opt); $event.stopPropagation()\"\n role=\"option\"\n tabindex=\"0\"\n >\n <input\n type=\"checkbox\"\n class=\"w-4 h-4 accent-primary pointer-events-none\"\n [checked]=\"isSelected(opt)\"\n [disabled]=\"opt.disabled || isMaxReached(opt)\"\n tabindex=\"-1\"\n />\n <span>{{ opt.label }}</span>\n </div>\n }\n @if (filteredOptions.length === 0) {\n <div class=\"px-3 py-2 text-sm text-base-content/50\">{{ uiConfig.noOptionsFound || 'No options found' }}</div>\n }\n </div>\n }\n\n @if (showError) {\n @if (props.showAllErrors) {\n <div class=\"flex flex-col gap-y-1 mt-1\">\n @for (error of errorMessages; track $index) {\n <mn-error-message [errorMessage]=\"error\" [id]=\"resolvedId + '-' + $index\"></mn-error-message>\n }\n </div>\n } @else {\n @if (errorMessage !== null) {\n <mn-error-message [errorMessage]=\"errorMessage\" [id]=\"resolvedId\"></mn-error-message>\n }\n }\n }\n</div>\n", dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "component", type: MnErrorMessage, selector: "mn-error-message", inputs: ["errorMessage", "id"] }, { kind: "component", type: MnButton, selector: "button[mnButton], a[mnButton]", inputs: ["data"] }, { kind: "component", type: LucideX, selector: "svg[lucideX]" }, { kind: "component", type: LucideChevronDown, selector: "svg[lucideChevronDown]" }] });
3997
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnMultiSelect, isStandalone: true, selector: "mn-lib-multi-select", inputs: { props: "props" }, host: { listeners: { "document:click": "onDocumentClick($event)", "document:keydown.escape": "onEscape()", "window:scroll": "onWindowScrollOrResize()", "window:resize": "onWindowScrollOrResize()" } }, viewQueries: [{ propertyName: "triggerRef", first: true, predicate: ["trigger"], descendants: true }, { propertyName: "dropdownRef", first: true, predicate: ["dropdown"], descendants: true }, { propertyName: "sheetBackdropRef", first: true, predicate: ["sheetBackdrop"], descendants: true }], ngImport: i0, template: "<div class=\"flex flex-col h-full\" [class.is-fullwidth]=\"props.fullWidth\">\n @if (uiConfig.label || props.label) {\n <label class=\"pl-2 pb-1 flex flex-row gap-x-0.5! text-base!\" [attr.for]=\"resolvedId\">\n <p>{{ uiConfig.label || props.label }}</p>\n @if (isRequired()) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n }\n\n <!-- Trigger -->\n <div\n #trigger\n [id]=\"resolvedId\"\n [ngClass]=\"triggerClasses\"\n class=\"relative\"\n [attr.aria-label]=\"uiConfig.ariaLabel || uiConfig.label || props.label || null\"\n [attr.aria-invalid]=\"showError || null\"\n [attr.aria-describedby]=\"showError ? resolvedId + '-error' : null\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-controls]=\"isOpen ? resolvedId + '-listbox' : null\"\n role=\"combobox\"\n tabindex=\"0\"\n (click)=\"toggle()\"\n (keydown.enter)=\"toggle()\"\n (keydown.space)=\"toggle(); $event.preventDefault()\"\n (blur)=\"handleBlur()\"\n >\n <!-- `pr-6` reserves the gutter the caret is absolutely positioned in (right-2 +\n w-4), so a value can never render underneath it. `min-w-0` lets the chips\n shrink below their content width, which is what makes truncation possible. -->\n <div class=\"flex flex-row items-center gap-x-2 flex-wrap min-h-6 min-w-0 pr-6\">\n @if (selectedOptions.length === 0) {\n <span class=\"text-base-content/50\">{{ uiConfig.placeholder || props.placeholder || 'Select...' }}</span>\n } @else if (isCollapsed) {\n <span\n class=\"inline-flex items-center max-w-full truncate bg-base-200 border border-accent text-base-content text-xs px-2 py-0.5 rounded-md\">\n {{ collapseSummaryText }}\n </span>\n } @else {\n @for (opt of selectedOptions; track opt.value) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <span\n class=\"inline-flex items-center gap-x-1 max-w-full min-w-0 bg-base-200 border border-accent text-base-content text-xs pl-2 py-0.5 rounded-md cursor-pointer\"\n (click)=\"removeOption(opt, $event)\">\n <span [attr.title]=\"opt.label\" class=\"truncate\">{{ opt.label }}</span>\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'secondary', hover: false }\"\n type=\"button\"\n class=\"text-base-content/50 cursor-pointer shrink-0\"\n (click)=\"removeOption(opt, $event)\"\n [attr.aria-label]=\"'Remove ' + opt.label\"\n ><svg lucideX [size]=\"18\"></svg></button>\n </span>\n }\n }\n </div>\n <div class=\"absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none\">\n <svg [size]=\"16\" class=\"text-base-content/50\" lucideChevronDown></svg>\n </div>\n </div>\n\n <!-- Dropdown -->\n @if (isOpen) {\n <!-- Dims the page behind the sheet. Deliberately has no click handler: the host's\n `document:click` listener already treats anything outside the panel as a\n dismissal, so a second handler would only duplicate that path. -->\n @if (isSheet) {\n <div #sheetBackdrop class=\"mn-ms-sheet-backdrop fixed inset-0 z-9998 bg-black/40\"></div>\n }\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n #dropdown\n [id]=\"resolvedId + '-listbox'\"\n aria-multiselectable=\"true\"\n role=\"listbox\"\n [ngClass]=\"panelClasses\"\n [style.top]=\"isSheet ? null : dropdownStyle.top\"\n [style.left]=\"isSheet ? null : dropdownStyle.left\"\n [style.width]=\"isSheet ? null : dropdownStyle.width\"\n [style.min-height.px]=\"isSheet ? sheetFloorPx : null\"\n (click)=\"$event.stopPropagation()\"\n >\n <!-- The sheet covers its own trigger, so it needs a header to name the field and\n an explicit way out \u2014 tapping the trigger again is not reachable here. -->\n @if (isSheet) {\n <div class=\"flex items-center justify-between gap-x-2 px-4 pt-4 pb-2 shrink-0\">\n <p class=\"text-base font-medium text-base-content truncate\">\n {{ uiConfig.label || props.label || uiConfig.placeholder || props.placeholder || '' }}\n </p>\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'secondary' }\"\n type=\"button\"\n class=\"shrink-0 cursor-pointer\"\n (click)=\"close()\"\n [attr.aria-label]=\"uiConfig.closeLabel || 'Close'\"\n ><svg lucideX [size]=\"20\"></svg></button>\n </div>\n }\n @if (isSearchable) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n class=\"border-b border-base-300 shrink-0\"\n [ngClass]=\"isSheet ? 'px-4 py-2' : 'p-2'\"\n (click)=\"$event.stopPropagation()\"\n >\n <mn-lib-input-field\n [ngModel]=\"searchTerm\"\n [ngModelOptions]=\"{ standalone: true }\"\n (ngModelChange)=\"onSearch($event)\"\n [props]=\"{\n id: resolvedId + '-search',\n type: 'search',\n placeholder: props.searchPlaceholder || 'Search...',\n ariaLabel: props.searchPlaceholder || 'Search...',\n fullWidth: true,\n size: 'sm'\n }\"\n ></mn-lib-input-field>\n </div>\n }\n <!-- In sheet mode the panel is the flex column and this list is the scroller;\n anchored, the panel itself scrolls and this is a passthrough wrapper. -->\n <div [ngClass]=\"isSheet ? 'flex-1 overflow-auto overscroll-contain' : ''\">\n @for (opt of filteredOptions; track opt.value) {\n <div\n (keyup.enter)=\"toggleOption(opt)\"\n (keyup.space)=\"toggleOption(opt)\"\n [attr.aria-selected]=\"isSelected(opt)\"\n class=\"flex items-center gap-x-2 cursor-pointer text-base-content hover:bg-base-200\"\n [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\"\n [class.opacity-50]=\"opt.disabled || isMaxReached(opt)\"\n [class.pointer-events-none]=\"opt.disabled || isMaxReached(opt)\"\n (click)=\"toggleOption(opt); $event.stopPropagation()\"\n role=\"option\"\n tabindex=\"0\"\n >\n <input\n type=\"checkbox\"\n class=\"w-4 h-4 accent-primary pointer-events-none shrink-0\"\n [checked]=\"isSelected(opt)\"\n [disabled]=\"opt.disabled || isMaxReached(opt)\"\n tabindex=\"-1\"\n />\n <span>{{ opt.label }}</span>\n </div>\n }\n @if (filteredOptions.length === 0) {\n <div class=\"text-base-content/50\" [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\">\n {{ uiConfig.noOptionsFound || 'No options found' }}\n </div>\n }\n </div>\n </div>\n }\n\n @if (showError) {\n @if (props.showAllErrors) {\n <div class=\"flex flex-col gap-y-1 mt-1\">\n @for (error of errorMessages; track $index) {\n <mn-error-message [errorMessage]=\"error\" [id]=\"resolvedId + '-' + $index\"></mn-error-message>\n }\n </div>\n } @else {\n @if (errorMessage !== null) {\n <mn-error-message [errorMessage]=\"errorMessage\" [id]=\"resolvedId\"></mn-error-message>\n }\n }\n }\n</div>\n", styles: [".mn-ms-sheet{--mn-sheet-ease: cubic-bezier(.32, .72, 0, 1);padding-bottom:env(safe-area-inset-bottom);animation:mn-ms-sheet-in .3s var(--mn-sheet-ease)}.mn-ms-sheet-backdrop{animation:mn-ms-backdrop-in .2s ease-out}@keyframes mn-ms-sheet-in{0%{transform:translateY(100%)}to{transform:translateY(0)}}@keyframes mn-ms-backdrop-in{0%{opacity:0}to{opacity:1}}@media(prefers-reduced-motion:reduce){.mn-ms-sheet,.mn-ms-sheet-backdrop{animation-duration:.01ms!important}}\n"], dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: MnErrorMessage, selector: "mn-error-message", inputs: ["errorMessage", "id"] }, { kind: "component", type: MnButton, selector: "button[mnButton], a[mnButton]", inputs: ["data"] }, { kind: "component", type: MnInputField, selector: "mn-lib-input-field", inputs: ["props"] }, { kind: "component", type: LucideX, selector: "svg[lucideX]" }, { kind: "component", type: LucideChevronDown, selector: "svg[lucideChevronDown]" }] });
3847
3998
  }
3848
3999
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnMultiSelect, decorators: [{
3849
4000
  type: Component,
3850
- args: [{ selector: 'mn-lib-multi-select', standalone: true, imports: [NgClass, MnErrorMessage, MnButton, LucideX, LucideChevronDown], template: "<div class=\"flex flex-col h-full\" [class.is-fullwidth]=\"props.fullWidth\">\n @if (uiConfig.label || props.label) {\n <label class=\"pl-2 pb-1 flex flex-row gap-x-0.5! text-base!\" [attr.for]=\"resolvedId\">\n <p>{{ uiConfig.label || props.label }}</p>\n @if (isRequired()) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n }\n\n <!-- Trigger -->\n <div\n #trigger\n [id]=\"resolvedId\"\n [ngClass]=\"triggerClasses\"\n class=\"relative\"\n [attr.aria-label]=\"uiConfig.ariaLabel || uiConfig.label || props.label || null\"\n [attr.aria-invalid]=\"showError || null\"\n [attr.aria-describedby]=\"showError ? resolvedId + '-error' : null\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-controls]=\"isOpen ? resolvedId + '-listbox' : null\"\n role=\"combobox\"\n tabindex=\"0\"\n (click)=\"toggle()\"\n (keydown.enter)=\"toggle()\"\n (keydown.space)=\"toggle(); $event.preventDefault()\"\n (blur)=\"handleBlur()\"\n >\n <!-- `pr-6` reserves the gutter the caret is absolutely positioned in (right-2 +\n w-4), so a value can never render underneath it. `min-w-0` lets the chips\n shrink below their content width, which is what makes truncation possible. -->\n <div class=\"flex flex-row items-center gap-x-2 flex-wrap min-h-6 min-w-0 pr-6\">\n @if (selectedOptions.length === 0) {\n <span class=\"text-base-content/50\">{{ uiConfig.placeholder || props.placeholder || 'Select...' }}</span>\n } @else if (isCollapsed) {\n <span\n class=\"inline-flex items-center max-w-full truncate bg-base-200 border border-accent text-base-content text-xs px-2 py-0.5 rounded-md\">\n {{ collapseSummaryText }}\n </span>\n } @else {\n @for (opt of selectedOptions; track opt.value) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <span\n class=\"inline-flex items-center gap-x-1 max-w-full min-w-0 bg-base-200 border border-accent text-base-content text-xs pl-2 py-0.5 rounded-md cursor-pointer\"\n (click)=\"removeOption(opt, $event)\">\n <span [attr.title]=\"opt.label\" class=\"truncate\">{{ opt.label }}</span>\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'secondary', hover: false }\"\n type=\"button\"\n class=\"text-base-content/50 cursor-pointer shrink-0\"\n (click)=\"removeOption(opt, $event)\"\n [attr.aria-label]=\"'Remove ' + opt.label\"\n ><svg lucideX [size]=\"18\"></svg></button>\n </span>\n }\n }\n </div>\n <div class=\"absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none\">\n <svg [size]=\"16\" class=\"text-base-content/50\" lucideChevronDown></svg>\n </div>\n </div>\n\n <!-- Dropdown -->\n @if (isOpen) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n #dropdown\n [id]=\"resolvedId + '-listbox'\"\n aria-multiselectable=\"true\"\n role=\"listbox\"\n class=\"fixed z-9999 bg-base-100 border border-base-300 rounded-md shadow-lg max-h-60 overflow-auto\"\n [style.top]=\"dropdownStyle.top\"\n [style.left]=\"dropdownStyle.left\"\n [style.width]=\"dropdownStyle.width\"\n (click)=\"$event.stopPropagation()\"\n >\n @if (props.searchable) {\n <div class=\"p-2 border-b border-base-300\">\n <input\n type=\"text\"\n class=\"w-full p-1.5 text-sm border border-base-300 rounded-md outline-none focus:border-primary-500 bg-base-200 text-base-content placeholder-base-content/50\"\n [placeholder]=\"props.searchPlaceholder || 'Search...'\"\n [value]=\"searchTerm\"\n (input)=\"onSearch(($any($event.target)).value)\"\n (click)=\"$event.stopPropagation()\"\n />\n </div>\n }\n @for (opt of filteredOptions; track opt.value) {\n <div\n (keyup.enter)=\"toggleOption(opt)\"\n (keyup.space)=\"toggleOption(opt)\"\n [attr.aria-selected]=\"isSelected(opt)\"\n class=\"flex items-center gap-x-2 px-3 py-2 text-sm cursor-pointer text-base-content hover:bg-base-200\"\n [class.opacity-50]=\"opt.disabled || isMaxReached(opt)\"\n [class.pointer-events-none]=\"opt.disabled || isMaxReached(opt)\"\n (click)=\"toggleOption(opt); $event.stopPropagation()\"\n role=\"option\"\n tabindex=\"0\"\n >\n <input\n type=\"checkbox\"\n class=\"w-4 h-4 accent-primary pointer-events-none\"\n [checked]=\"isSelected(opt)\"\n [disabled]=\"opt.disabled || isMaxReached(opt)\"\n tabindex=\"-1\"\n />\n <span>{{ opt.label }}</span>\n </div>\n }\n @if (filteredOptions.length === 0) {\n <div class=\"px-3 py-2 text-sm text-base-content/50\">{{ uiConfig.noOptionsFound || 'No options found' }}</div>\n }\n </div>\n }\n\n @if (showError) {\n @if (props.showAllErrors) {\n <div class=\"flex flex-col gap-y-1 mt-1\">\n @for (error of errorMessages; track $index) {\n <mn-error-message [errorMessage]=\"error\" [id]=\"resolvedId + '-' + $index\"></mn-error-message>\n }\n </div>\n } @else {\n @if (errorMessage !== null) {\n <mn-error-message [errorMessage]=\"errorMessage\" [id]=\"resolvedId\"></mn-error-message>\n }\n }\n }\n</div>\n" }]
4001
+ args: [{ selector: 'mn-lib-multi-select', standalone: true, imports: [NgClass, FormsModule, MnErrorMessage, MnButton, MnInputField, LucideX, LucideChevronDown], template: "<div class=\"flex flex-col h-full\" [class.is-fullwidth]=\"props.fullWidth\">\n @if (uiConfig.label || props.label) {\n <label class=\"pl-2 pb-1 flex flex-row gap-x-0.5! text-base!\" [attr.for]=\"resolvedId\">\n <p>{{ uiConfig.label || props.label }}</p>\n @if (isRequired()) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n }\n\n <!-- Trigger -->\n <div\n #trigger\n [id]=\"resolvedId\"\n [ngClass]=\"triggerClasses\"\n class=\"relative\"\n [attr.aria-label]=\"uiConfig.ariaLabel || uiConfig.label || props.label || null\"\n [attr.aria-invalid]=\"showError || null\"\n [attr.aria-describedby]=\"showError ? resolvedId + '-error' : null\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-controls]=\"isOpen ? resolvedId + '-listbox' : null\"\n role=\"combobox\"\n tabindex=\"0\"\n (click)=\"toggle()\"\n (keydown.enter)=\"toggle()\"\n (keydown.space)=\"toggle(); $event.preventDefault()\"\n (blur)=\"handleBlur()\"\n >\n <!-- `pr-6` reserves the gutter the caret is absolutely positioned in (right-2 +\n w-4), so a value can never render underneath it. `min-w-0` lets the chips\n shrink below their content width, which is what makes truncation possible. -->\n <div class=\"flex flex-row items-center gap-x-2 flex-wrap min-h-6 min-w-0 pr-6\">\n @if (selectedOptions.length === 0) {\n <span class=\"text-base-content/50\">{{ uiConfig.placeholder || props.placeholder || 'Select...' }}</span>\n } @else if (isCollapsed) {\n <span\n class=\"inline-flex items-center max-w-full truncate bg-base-200 border border-accent text-base-content text-xs px-2 py-0.5 rounded-md\">\n {{ collapseSummaryText }}\n </span>\n } @else {\n @for (opt of selectedOptions; track opt.value) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <span\n class=\"inline-flex items-center gap-x-1 max-w-full min-w-0 bg-base-200 border border-accent text-base-content text-xs pl-2 py-0.5 rounded-md cursor-pointer\"\n (click)=\"removeOption(opt, $event)\">\n <span [attr.title]=\"opt.label\" class=\"truncate\">{{ opt.label }}</span>\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'secondary', hover: false }\"\n type=\"button\"\n class=\"text-base-content/50 cursor-pointer shrink-0\"\n (click)=\"removeOption(opt, $event)\"\n [attr.aria-label]=\"'Remove ' + opt.label\"\n ><svg lucideX [size]=\"18\"></svg></button>\n </span>\n }\n }\n </div>\n <div class=\"absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none\">\n <svg [size]=\"16\" class=\"text-base-content/50\" lucideChevronDown></svg>\n </div>\n </div>\n\n <!-- Dropdown -->\n @if (isOpen) {\n <!-- Dims the page behind the sheet. Deliberately has no click handler: the host's\n `document:click` listener already treats anything outside the panel as a\n dismissal, so a second handler would only duplicate that path. -->\n @if (isSheet) {\n <div #sheetBackdrop class=\"mn-ms-sheet-backdrop fixed inset-0 z-9998 bg-black/40\"></div>\n }\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n #dropdown\n [id]=\"resolvedId + '-listbox'\"\n aria-multiselectable=\"true\"\n role=\"listbox\"\n [ngClass]=\"panelClasses\"\n [style.top]=\"isSheet ? null : dropdownStyle.top\"\n [style.left]=\"isSheet ? null : dropdownStyle.left\"\n [style.width]=\"isSheet ? null : dropdownStyle.width\"\n [style.min-height.px]=\"isSheet ? sheetFloorPx : null\"\n (click)=\"$event.stopPropagation()\"\n >\n <!-- The sheet covers its own trigger, so it needs a header to name the field and\n an explicit way out \u2014 tapping the trigger again is not reachable here. -->\n @if (isSheet) {\n <div class=\"flex items-center justify-between gap-x-2 px-4 pt-4 pb-2 shrink-0\">\n <p class=\"text-base font-medium text-base-content truncate\">\n {{ uiConfig.label || props.label || uiConfig.placeholder || props.placeholder || '' }}\n </p>\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'secondary' }\"\n type=\"button\"\n class=\"shrink-0 cursor-pointer\"\n (click)=\"close()\"\n [attr.aria-label]=\"uiConfig.closeLabel || 'Close'\"\n ><svg lucideX [size]=\"20\"></svg></button>\n </div>\n }\n @if (isSearchable) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n class=\"border-b border-base-300 shrink-0\"\n [ngClass]=\"isSheet ? 'px-4 py-2' : 'p-2'\"\n (click)=\"$event.stopPropagation()\"\n >\n <mn-lib-input-field\n [ngModel]=\"searchTerm\"\n [ngModelOptions]=\"{ standalone: true }\"\n (ngModelChange)=\"onSearch($event)\"\n [props]=\"{\n id: resolvedId + '-search',\n type: 'search',\n placeholder: props.searchPlaceholder || 'Search...',\n ariaLabel: props.searchPlaceholder || 'Search...',\n fullWidth: true,\n size: 'sm'\n }\"\n ></mn-lib-input-field>\n </div>\n }\n <!-- In sheet mode the panel is the flex column and this list is the scroller;\n anchored, the panel itself scrolls and this is a passthrough wrapper. -->\n <div [ngClass]=\"isSheet ? 'flex-1 overflow-auto overscroll-contain' : ''\">\n @for (opt of filteredOptions; track opt.value) {\n <div\n (keyup.enter)=\"toggleOption(opt)\"\n (keyup.space)=\"toggleOption(opt)\"\n [attr.aria-selected]=\"isSelected(opt)\"\n class=\"flex items-center gap-x-2 cursor-pointer text-base-content hover:bg-base-200\"\n [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\"\n [class.opacity-50]=\"opt.disabled || isMaxReached(opt)\"\n [class.pointer-events-none]=\"opt.disabled || isMaxReached(opt)\"\n (click)=\"toggleOption(opt); $event.stopPropagation()\"\n role=\"option\"\n tabindex=\"0\"\n >\n <input\n type=\"checkbox\"\n class=\"w-4 h-4 accent-primary pointer-events-none shrink-0\"\n [checked]=\"isSelected(opt)\"\n [disabled]=\"opt.disabled || isMaxReached(opt)\"\n tabindex=\"-1\"\n />\n <span>{{ opt.label }}</span>\n </div>\n }\n @if (filteredOptions.length === 0) {\n <div class=\"text-base-content/50\" [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\">\n {{ uiConfig.noOptionsFound || 'No options found' }}\n </div>\n }\n </div>\n </div>\n }\n\n @if (showError) {\n @if (props.showAllErrors) {\n <div class=\"flex flex-col gap-y-1 mt-1\">\n @for (error of errorMessages; track $index) {\n <mn-error-message [errorMessage]=\"error\" [id]=\"resolvedId + '-' + $index\"></mn-error-message>\n }\n </div>\n } @else {\n @if (errorMessage !== null) {\n <mn-error-message [errorMessage]=\"errorMessage\" [id]=\"resolvedId\"></mn-error-message>\n }\n }\n }\n</div>\n", styles: [".mn-ms-sheet{--mn-sheet-ease: cubic-bezier(.32, .72, 0, 1);padding-bottom:env(safe-area-inset-bottom);animation:mn-ms-sheet-in .3s var(--mn-sheet-ease)}.mn-ms-sheet-backdrop{animation:mn-ms-backdrop-in .2s ease-out}@keyframes mn-ms-sheet-in{0%{transform:translateY(100%)}to{transform:translateY(0)}}@keyframes mn-ms-backdrop-in{0%{opacity:0}to{opacity:1}}@media(prefers-reduced-motion:reduce){.mn-ms-sheet,.mn-ms-sheet-backdrop{animation-duration:.01ms!important}}\n"] }]
3851
4002
  }], ctorParameters: () => [], propDecorators: { props: [{
3852
4003
  type: Input,
3853
4004
  args: [{ required: true }]
@@ -3857,6 +4008,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImpor
3857
4008
  }], dropdownRef: [{
3858
4009
  type: ViewChild,
3859
4010
  args: ['dropdown', { static: false }]
4011
+ }], sheetBackdropRef: [{
4012
+ type: ViewChild,
4013
+ args: ['sheetBackdrop', { static: false }]
3860
4014
  }], onDocumentClick: [{
3861
4015
  type: HostListener,
3862
4016
  args: ['document:click', ['$event']]
@@ -10548,6 +10702,20 @@ class MnTabComponent {
10548
10702
  indicator;
10549
10703
  /** Pending indicator remeasure, cancelled on destroy so a post-destroy frame can't read a detached ref. */
10550
10704
  indicatorFrame;
10705
+ /**
10706
+ * True while a click-initiated slide is animating. The active tab's
10707
+ * `font-bold` widens the row, which fires {@link resizeObserver}; without
10708
+ * this guard the observer's snap ({@link updateIndicator} with `animate:
10709
+ * false`) would land the indicator at its target the same frame the slide
10710
+ * starts, so the transition never paints. Set synchronously in
10711
+ * {@link setActive} — before the frame runs — so the guard doesn't depend on
10712
+ * rAF-vs-ResizeObserver callback ordering.
10713
+ */
10714
+ sliding = false;
10715
+ /** Clears {@link sliding} after the slide finishes; re-armed per click, cancelled on destroy. */
10716
+ slidingTimer;
10717
+ /** Slide duration in ms; matches the indicator's `duration-300` transition. */
10718
+ static SLIDE_MS = 300;
10551
10719
  /** How far the fade reaches in from each overflowing edge. */
10552
10720
  static FADE = '2rem';
10553
10721
  /** Data source containing tab items and default active index. */
@@ -10615,7 +10783,10 @@ class MnTabComponent {
10615
10783
  this.updateEdgeFades();
10616
10784
  // Tabs may have reflowed (viewport change, justified widths); snap the
10617
10785
  // indicator to the new geometry — animating a resize tick reads as jank.
10618
- this.updateIndicator(false);
10786
+ // But skip the snap mid-slide: a click's own `font-bold` resizes the row
10787
+ // and fires this observer, and snapping there kills the slide it triggered.
10788
+ if (!this.sliding)
10789
+ this.updateIndicator(false);
10619
10790
  });
10620
10791
  this.resizeObserver.observe(el);
10621
10792
  if (el.firstElementChild)
@@ -10628,6 +10799,8 @@ class MnTabComponent {
10628
10799
  this.resizeObserver?.disconnect();
10629
10800
  if (this.indicatorFrame !== undefined)
10630
10801
  cancelAnimationFrame(this.indicatorFrame);
10802
+ if (this.slidingTimer !== undefined)
10803
+ clearTimeout(this.slidingTimer);
10631
10804
  }
10632
10805
  /**
10633
10806
  * Paints a fade over whichever edge has tabs scrolled out of view — a soft
@@ -10669,9 +10842,25 @@ class MnTabComponent {
10669
10842
  this.activeChange.emit(item);
10670
10843
  // Slide the underline to the new tab. Measure on the next frame, after
10671
10844
  // change detection has applied the active tab's `font-bold` (which widens
10672
- // it) so the indicator lands on the final, bolded geometry.
10845
+ // it) so the indicator lands on the final, bolded geometry. Guard the slide
10846
+ // against the resize snap the same `font-bold` triggers (see {@link sliding}).
10847
+ this.beginSlide();
10673
10848
  this.scheduleIndicator(true);
10674
10849
  }
10850
+ /**
10851
+ * Marks a click-driven slide as in progress and schedules the guard to lift
10852
+ * once the transition has finished. A timeout (not `transitionend`) so the
10853
+ * flag still clears under `motion-reduce`, where no transition event fires.
10854
+ */
10855
+ beginSlide() {
10856
+ this.sliding = true;
10857
+ if (this.slidingTimer !== undefined)
10858
+ clearTimeout(this.slidingTimer);
10859
+ this.slidingTimer = setTimeout(() => {
10860
+ this.slidingTimer = undefined;
10861
+ this.sliding = false;
10862
+ }, MnTabComponent.SLIDE_MS);
10863
+ }
10675
10864
  /**
10676
10865
  * Moves the shared underline to the active tab. When `animate` is false the
10677
10866
  * move is snapped (no slide) by disabling the transition for one reflow —