mn-angular-lib 1.0.139 → 1.0.141

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 { InjectionToken, Injectable, inject, HostBinding, Input, Component, ApplicationRef, APP_INITIALIZER, Pipe, ChangeDetectionStrategy, signal, DestroyRef, Optional, SkipSelf, Attribute, Directive, ElementRef, EventEmitter, computed, Output, input, output, Injector, viewChildren, linkedSignal, afterNextRender, Renderer2, ChangeDetectorRef, HostListener, ViewChild, ViewContainerRef, forwardRef, afterEveryRender, TemplateRef, ViewChildren, viewChild, EnvironmentInjector, createComponent, isSignal, effect, untracked, ViewEncapsulation } from '@angular/core';
2
+ import { InjectionToken, Injectable, inject, HostBinding, Input, Component, ApplicationRef, APP_INITIALIZER, Pipe, ChangeDetectionStrategy, signal, DestroyRef, Optional, SkipSelf, Attribute, Directive, ElementRef, EventEmitter, computed, Output, input, output, Injector, viewChildren, linkedSignal, afterNextRender, ChangeDetectorRef, viewChild, Renderer2, HostListener, ViewChild, ViewContainerRef, forwardRef, afterEveryRender, TemplateRef, ViewChildren, EnvironmentInjector, createComponent, isSignal, effect, untracked, ViewEncapsulation } from '@angular/core';
3
3
  export { TemplateRef, Type } from '@angular/core';
4
4
  import { BehaviorSubject, firstValueFrom, skip, Subject, debounceTime, of, takeUntil, map, catchError } from 'rxjs';
5
5
  import * as i1 from '@angular/common';
@@ -3491,6 +3491,272 @@ const mnMultiSelectVariants = tv({
3491
3491
  },
3492
3492
  });
3493
3493
 
3494
+ /**
3495
+ * A viewport-anchored bottom sheet — the mobile presentation shared by the modal
3496
+ * shell and the multi-select dropdown.
3497
+ *
3498
+ * It owns only the sheet *chrome and gestures*: a bottom-anchored, full-width,
3499
+ * rounded-top surface with an optional dimming backdrop and drag grabber, a
3500
+ * slide-up entrance, swipe/flick-to-dismiss, and a promise-based slide-down exit.
3501
+ * It is deliberately presentational: the body is projected via `<ng-content>`, and
3502
+ * dismissal is reported through {@link dismiss} for the host to act on (run a close
3503
+ * guard, tear down its overlay, …) rather than being handled here.
3504
+ *
3505
+ * Positioning is `position: fixed` against the viewport, so a consumer whose sheet
3506
+ * lives inside a `transform`/`filter` ancestor (which would otherwise become the
3507
+ * containing block) must relocate this host to `document.body` — as the
3508
+ * multi-select does with its portal helper.
3509
+ */
3510
+ class MnBottomSheet {
3511
+ /** Tailwind's `sm` breakpoint — at or below this the swipe gesture is armed.
3512
+ * Kept in step with the same constant in the modal shell and multi-select. */
3513
+ static SHEET_MAX_WIDTH = 639.98;
3514
+ /** Drag distance (px) past which a release dismisses regardless of speed. */
3515
+ static SWIPE_DISMISS_THRESHOLD = 150;
3516
+ /** Downward release speed (px/ms) above which a short drag still dismisses — a "flick". */
3517
+ static FLICK_VELOCITY = 0.5;
3518
+ /** Minimum drag distance (px) a flick must cover, so an incidental fast tap never dismisses. */
3519
+ static FLICK_MIN_DISTANCE = 32;
3520
+ /** Upper bound for the exit wait if no `transitionend` fires (e.g. animation suppressed). */
3521
+ static CLOSE_FALLBACK_MS = 700;
3522
+ /** Whether to render the dimming backdrop behind the sheet (default: true).
3523
+ * A host that already paints its own backdrop (the modal shell) sets this false. */
3524
+ showBackdrop = true;
3525
+ /** Whether to render the drag grabber handle that arms swipe-to-dismiss (default: true). */
3526
+ showGrabber = true;
3527
+ /** Whether the sheet can be dismissed by the user via swipe/flick or backdrop tap
3528
+ * (default: true). When false the gestures are inert and the backdrop is non-closing. */
3529
+ dismissible = true;
3530
+ /** Optional `min-height` floor (px) for the container, so filtering its content
3531
+ * shorter cannot shrink the sheet mid-interaction. Null leaves it content-sized. */
3532
+ minHeightPx = null;
3533
+ /** Cap on the sheet height as a fraction of the viewport, in vh (default: 80). */
3534
+ maxHeightVh = 80;
3535
+ /** Extra class(es) applied to the sheet container, so a host can attach the hooks
3536
+ * its own CSS depends on (e.g. the modal shell's `modal-container`). */
3537
+ containerClass = '';
3538
+ /** Accessible name for the sheet dialog. */
3539
+ ariaLabel;
3540
+ /** Id of the element that labels this dialog (takes precedence over `ariaLabel`). */
3541
+ ariaLabelledby;
3542
+ /**
3543
+ * When true, the sheet grows to its `maxHeightVh` while the host app marks the soft
3544
+ * keyboard open (a `.mn-keyboard-open` class on a document ancestor), guaranteeing
3545
+ * scroll room to lift a focused field above an overlaying keyboard. Off by default so
3546
+ * a keyboard opened over an unrelated sheet (a multi-select search) does not resize it.
3547
+ */
3548
+ growWithKeyboard = false;
3549
+ /**
3550
+ * Optional async gate consulted before a user-initiated dismissal (swipe/flick/backdrop
3551
+ * tap) is committed. Resolving false aborts the dismissal and springs the sheet back —
3552
+ * used by the modal to run its close guard (e.g. an unsaved-changes prompt). A
3553
+ * programmatic {@link startClosing} bypasses it.
3554
+ */
3555
+ dismissGuard;
3556
+ /**
3557
+ * Emitted once the user has dismissed the sheet — after the slide-down exit has
3558
+ * finished, so the host can remove the sheet from the DOM without cutting the
3559
+ * animation short. The host decides what dismissal means (close, run a guard, …).
3560
+ */
3561
+ dismiss = new EventEmitter();
3562
+ /** Current downward drag offset (px) applied to the sheet while swiping. */
3563
+ sheetDragY = 0;
3564
+ /** True while the user is actively dragging the grabber (disables the snap transition). */
3565
+ isDraggingSheet = false;
3566
+ /** True once a dismissal has committed — the sheet glides off-screen via its transition. */
3567
+ isDismissing = false;
3568
+ cdr = inject(ChangeDetectorRef);
3569
+ el = inject(ElementRef);
3570
+ /** The sheet container element, used to measure its height and drive the exit. */
3571
+ containerRef = viewChild('container', ...(ngDevMode ? [{ debugName: "containerRef" }] : []));
3572
+ dragStartY = 0;
3573
+ /** The two most recent (y, timestamp) pointer samples, for estimating flick velocity.
3574
+ * `t` uses the event timestamp (monotonic), so no wall-clock is read. */
3575
+ lastSample = null;
3576
+ prevSample = null;
3577
+ /** In-flight exit animation, so a swipe-dismiss and a follow-up programmatic
3578
+ * {@link startClosing} share one glide instead of re-triggering it. */
3579
+ exitPromise = null;
3580
+ get hostClasses() {
3581
+ return `mn-bottom-sheet${this.isDismissing ? ' is-dismissing' : ''}`
3582
+ + `${this.growWithKeyboard ? ' grow-with-keyboard' : ''}`;
3583
+ }
3584
+ /** Whether the viewport is currently narrow enough for the sheet to accept a swipe. */
3585
+ get isNarrow() {
3586
+ return typeof window === 'undefined' || window.innerWidth <= MnBottomSheet.SHEET_MAX_WIDTH;
3587
+ }
3588
+ onSheetPointerDown(event) {
3589
+ if (!this.dismissible || !this.isNarrow)
3590
+ return;
3591
+ // Don't hijack drags that begin on an interactive control inside the sheet.
3592
+ if (event.target.closest('button, input, textarea, select, a'))
3593
+ return;
3594
+ this.isDraggingSheet = true;
3595
+ this.dragStartY = event.clientY;
3596
+ // Seed the velocity window so a fast flick that releases on the first move still measures.
3597
+ this.lastSample = { y: event.clientY, t: event.timeStamp };
3598
+ this.prevSample = this.lastSample;
3599
+ event.target.setPointerCapture(event.pointerId);
3600
+ }
3601
+ onSheetPointerMove(event) {
3602
+ if (!this.isDraggingSheet)
3603
+ return;
3604
+ // Only track downward movement.
3605
+ this.sheetDragY = Math.max(0, event.clientY - this.dragStartY);
3606
+ this.prevSample = this.lastSample;
3607
+ this.lastSample = { y: event.clientY, t: event.timeStamp };
3608
+ }
3609
+ onSheetPointerUp() {
3610
+ if (!this.isDraggingSheet)
3611
+ return;
3612
+ this.isDraggingSheet = false;
3613
+ if (this.shouldDismiss()) {
3614
+ void this.attemptDismiss();
3615
+ }
3616
+ else {
3617
+ this.snapBack();
3618
+ }
3619
+ this.lastSample = null;
3620
+ this.prevSample = null;
3621
+ }
3622
+ onBackdropClick() {
3623
+ void this.attemptDismiss();
3624
+ }
3625
+ /**
3626
+ * Plays the slide-down exit and resolves once it has finished. Exposed so a host that
3627
+ * dismisses the sheet programmatically (not via a gesture) can await the same exit
3628
+ * before tearing the sheet down. Idempotent: a swipe-dismiss already in flight and a
3629
+ * subsequent programmatic close share the one glide rather than restarting it.
3630
+ */
3631
+ startClosing() {
3632
+ return this.playExit();
3633
+ }
3634
+ /**
3635
+ * Runs the optional {@link dismissGuard}, then either commits the dismissal (glide out
3636
+ * + emit) or springs the sheet back if the guard rejects. A non-dismissible sheet never
3637
+ * gets here from a gesture, but the guard is still short-circuited defensively.
3638
+ */
3639
+ async attemptDismiss() {
3640
+ if (!this.dismissible)
3641
+ return;
3642
+ if (this.dismissGuard) {
3643
+ const allowed = await this.dismissGuard();
3644
+ if (!allowed) {
3645
+ this.snapBack();
3646
+ this.cdr.detectChanges();
3647
+ return;
3648
+ }
3649
+ }
3650
+ this.commitDismiss();
3651
+ }
3652
+ /** Whether the release should dismiss: a long-enough drag OR a fast downward flick. */
3653
+ shouldDismiss() {
3654
+ if (this.sheetDragY > MnBottomSheet.SWIPE_DISMISS_THRESHOLD) {
3655
+ return true;
3656
+ }
3657
+ return this.releaseVelocity() > MnBottomSheet.FLICK_VELOCITY
3658
+ && this.sheetDragY > MnBottomSheet.FLICK_MIN_DISTANCE;
3659
+ }
3660
+ /** Downward release speed (px/ms) from the last two pointer samples; 0 when unusable. */
3661
+ releaseVelocity() {
3662
+ if (!this.lastSample || !this.prevSample)
3663
+ return 0;
3664
+ const dt = this.lastSample.t - this.prevSample.t;
3665
+ if (dt <= 0)
3666
+ return 0;
3667
+ return (this.lastSample.y - this.prevSample.y) / dt;
3668
+ }
3669
+ /** Springs the sheet back to its resting position after a drag that didn't dismiss. */
3670
+ snapBack() {
3671
+ this.sheetDragY = 0;
3672
+ }
3673
+ /**
3674
+ * Commits a dismissal: glides the sheet the rest of the way off-screen, then emits
3675
+ * {@link dismiss} once the exit animation settles. Continuing the gesture (rather than
3676
+ * snapping back to 0 first) keeps a swipe feeling like one unbroken motion.
3677
+ */
3678
+ commitDismiss() {
3679
+ void this.playExit().then(() => this.dismiss.emit());
3680
+ }
3681
+ /** Commits the exit animation exactly once and returns the shared in-flight promise.
3682
+ * Short-circuits under reduced motion and falls back to a timeout if no event fires. */
3683
+ playExit() {
3684
+ if (this.exitPromise)
3685
+ return this.exitPromise;
3686
+ this.isDismissing = true;
3687
+ this.sheetDragY = window.innerHeight;
3688
+ this.cdr.detectChanges();
3689
+ this.exitPromise = this.awaitExit();
3690
+ return this.exitPromise;
3691
+ }
3692
+ /** Waits for the container's exit transition to end, with a reduced-motion short-circuit
3693
+ * and a fallback timeout so it always resolves. */
3694
+ awaitExit() {
3695
+ return new Promise(resolve => {
3696
+ if (this.prefersReducedMotion()) {
3697
+ resolve();
3698
+ return;
3699
+ }
3700
+ const container = this.containerRef()?.nativeElement
3701
+ ?? this.el.nativeElement.querySelector('.mn-sheet-container');
3702
+ if (!container) {
3703
+ resolve();
3704
+ return;
3705
+ }
3706
+ let settled = false;
3707
+ const done = (event) => {
3708
+ // Ignore end events bubbling up from descendant transitions.
3709
+ if (event && event.target !== container)
3710
+ return;
3711
+ if (settled)
3712
+ return;
3713
+ settled = true;
3714
+ container.removeEventListener('transitionend', done);
3715
+ clearTimeout(fallback);
3716
+ resolve();
3717
+ };
3718
+ container.addEventListener('transitionend', done);
3719
+ const fallback = setTimeout(done, MnBottomSheet.CLOSE_FALLBACK_MS);
3720
+ });
3721
+ }
3722
+ prefersReducedMotion() {
3723
+ return typeof window !== 'undefined'
3724
+ && typeof window.matchMedia === 'function'
3725
+ && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
3726
+ }
3727
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnBottomSheet, deps: [], target: i0.ɵɵFactoryTarget.Component });
3728
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnBottomSheet, isStandalone: true, selector: "mn-bottom-sheet", inputs: { showBackdrop: "showBackdrop", showGrabber: "showGrabber", dismissible: "dismissible", minHeightPx: "minHeightPx", maxHeightVh: "maxHeightVh", containerClass: "containerClass", ariaLabel: "ariaLabel", ariaLabelledby: "ariaLabelledby", growWithKeyboard: "growWithKeyboard", dismissGuard: "dismissGuard" }, outputs: { dismiss: "dismiss" }, host: { properties: { "class": "this.hostClasses" } }, viewQueries: [{ propertyName: "containerRef", first: true, predicate: ["container"], descendants: true, isSignal: true }], ngImport: i0, template: "@if (showBackdrop) {\n <!-- Dims the page behind the sheet. Tapping it dismisses when the sheet is dismissible. -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div (click)=\"onBackdropClick()\" class=\"mn-sheet-backdrop fixed inset-0 z-9998 bg-black/40\"></div>\n}\n\n<div\n #container\n [attr.aria-label]=\"ariaLabelledby ? null : (ariaLabel || null)\"\n [attr.aria-labelledby]=\"ariaLabelledby || null\"\n [class.sheet-dragging]=\"isDraggingSheet\"\n [ngClass]=\"containerClass\"\n [style.--mn-sheet-max]=\"maxHeightVh + 'vh'\"\n [style.max-height.vh]=\"maxHeightVh\"\n [style.min-height.px]=\"minHeightPx\"\n [style.transform]=\"sheetDragY ? 'translateY(' + sheetDragY + 'px)' : null\"\n aria-modal=\"true\"\n class=\"mn-sheet-container fixed inset-x-0 bottom-0 z-9999 flex flex-col bg-base-100 border-t border-base-300 rounded-t-2xl shadow-lg\"\n role=\"dialog\"\n tabindex=\"-1\"\n>\n @if (showGrabber) {\n <!-- Drag handle for swipe-to-dismiss. The gesture is armed on the whole handle;\n drags starting on a control inside the projected content are ignored. -->\n <div\n (pointercancel)=\"onSheetPointerUp()\"\n (pointerdown)=\"onSheetPointerDown($event)\"\n (pointermove)=\"onSheetPointerMove($event)\"\n (pointerup)=\"onSheetPointerUp()\"\n class=\"flex justify-center pt-2 pb-1 touch-none cursor-grab shrink-0\"\n >\n <div class=\"h-1.5 w-10 rounded-full bg-base-300\"></div>\n </div>\n }\n\n <ng-content></ng-content>\n</div>\n", styles: [":host{--mn-sheet-ease: cubic-bezier(.32, .72, 0, 1);display:contents}.mn-sheet-container{padding-bottom:env(safe-area-inset-bottom);min-height:0;transition:transform .35s var(--mn-sheet-ease),min-height .25s var(--mn-sheet-ease);animation:mn-sheet-in .35s var(--mn-sheet-ease)}:host(.grow-with-keyboard):host-context(.mn-keyboard-open) .mn-sheet-container{min-height:var(--mn-sheet-max, 92vh)}.mn-sheet-container.sheet-dragging{transition:none}.mn-sheet-backdrop{animation:mn-sheet-backdrop-in .2s ease-out}:host(.is-dismissing) .mn-sheet-container{animation:none}@keyframes mn-sheet-in{0%{transform:translateY(100%)}to{transform:translateY(0)}}@keyframes mn-sheet-backdrop-in{0%{opacity:0}to{opacity:1}}@media(prefers-reduced-motion:reduce){.mn-sheet-container,.mn-sheet-backdrop{animation-duration:.01ms!important;transition-duration:.01ms!important}}\n"], dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }] });
3729
+ }
3730
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnBottomSheet, decorators: [{
3731
+ type: Component,
3732
+ args: [{ selector: 'mn-bottom-sheet', standalone: true, imports: [NgClass], template: "@if (showBackdrop) {\n <!-- Dims the page behind the sheet. Tapping it dismisses when the sheet is dismissible. -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div (click)=\"onBackdropClick()\" class=\"mn-sheet-backdrop fixed inset-0 z-9998 bg-black/40\"></div>\n}\n\n<div\n #container\n [attr.aria-label]=\"ariaLabelledby ? null : (ariaLabel || null)\"\n [attr.aria-labelledby]=\"ariaLabelledby || null\"\n [class.sheet-dragging]=\"isDraggingSheet\"\n [ngClass]=\"containerClass\"\n [style.--mn-sheet-max]=\"maxHeightVh + 'vh'\"\n [style.max-height.vh]=\"maxHeightVh\"\n [style.min-height.px]=\"minHeightPx\"\n [style.transform]=\"sheetDragY ? 'translateY(' + sheetDragY + 'px)' : null\"\n aria-modal=\"true\"\n class=\"mn-sheet-container fixed inset-x-0 bottom-0 z-9999 flex flex-col bg-base-100 border-t border-base-300 rounded-t-2xl shadow-lg\"\n role=\"dialog\"\n tabindex=\"-1\"\n>\n @if (showGrabber) {\n <!-- Drag handle for swipe-to-dismiss. The gesture is armed on the whole handle;\n drags starting on a control inside the projected content are ignored. -->\n <div\n (pointercancel)=\"onSheetPointerUp()\"\n (pointerdown)=\"onSheetPointerDown($event)\"\n (pointermove)=\"onSheetPointerMove($event)\"\n (pointerup)=\"onSheetPointerUp()\"\n class=\"flex justify-center pt-2 pb-1 touch-none cursor-grab shrink-0\"\n >\n <div class=\"h-1.5 w-10 rounded-full bg-base-300\"></div>\n </div>\n }\n\n <ng-content></ng-content>\n</div>\n", styles: [":host{--mn-sheet-ease: cubic-bezier(.32, .72, 0, 1);display:contents}.mn-sheet-container{padding-bottom:env(safe-area-inset-bottom);min-height:0;transition:transform .35s var(--mn-sheet-ease),min-height .25s var(--mn-sheet-ease);animation:mn-sheet-in .35s var(--mn-sheet-ease)}:host(.grow-with-keyboard):host-context(.mn-keyboard-open) .mn-sheet-container{min-height:var(--mn-sheet-max, 92vh)}.mn-sheet-container.sheet-dragging{transition:none}.mn-sheet-backdrop{animation:mn-sheet-backdrop-in .2s ease-out}:host(.is-dismissing) .mn-sheet-container{animation:none}@keyframes mn-sheet-in{0%{transform:translateY(100%)}to{transform:translateY(0)}}@keyframes mn-sheet-backdrop-in{0%{opacity:0}to{opacity:1}}@media(prefers-reduced-motion:reduce){.mn-sheet-container,.mn-sheet-backdrop{animation-duration:.01ms!important;transition-duration:.01ms!important}}\n"] }]
3733
+ }], propDecorators: { showBackdrop: [{
3734
+ type: Input
3735
+ }], showGrabber: [{
3736
+ type: Input
3737
+ }], dismissible: [{
3738
+ type: Input
3739
+ }], minHeightPx: [{
3740
+ type: Input
3741
+ }], maxHeightVh: [{
3742
+ type: Input
3743
+ }], containerClass: [{
3744
+ type: Input
3745
+ }], ariaLabel: [{
3746
+ type: Input
3747
+ }], ariaLabelledby: [{
3748
+ type: Input
3749
+ }], growWithKeyboard: [{
3750
+ type: Input
3751
+ }], dismissGuard: [{
3752
+ type: Input
3753
+ }], dismiss: [{
3754
+ type: Output
3755
+ }], containerRef: [{ type: i0.ViewChild, args: ['container', { isSignal: true }] }], hostClasses: [{
3756
+ type: HostBinding,
3757
+ args: ['class']
3758
+ }] } });
3759
+
3494
3760
  const MN_MULTI_SELECT_CONFIG = new InjectionToken('MN_MULTI_SELECT_CONFIG');
3495
3761
  class MnMultiSelect {
3496
3762
  ngControl = inject(NgControl, { optional: true, self: true });
@@ -3506,8 +3772,30 @@ class MnMultiSelect {
3506
3772
  cdr = inject(ChangeDetectorRef);
3507
3773
  /** Reference to the trigger element for positioning the dropdown */
3508
3774
  triggerRef;
3509
- /** The panel element currently moved into `document.body`, if any. */
3775
+ /** Layout classes for the anchored popover panel. The mobile sheet is rendered by
3776
+ * mn-bottom-sheet instead, so it no longer needs a branch here. */
3777
+ panelClasses = 'fixed z-9999 bg-base-100 border border-base-300 rounded-md shadow-lg max-h-60 overflow-auto';
3778
+ /** The anchored popover panel currently moved into `document.body`, if any. */
3510
3779
  movedPanel = null;
3780
+ /** Option count at which the search input auto-enables when `searchable` is unset. */
3781
+ static DEFAULT_SEARCH_THRESHOLD = 8;
3782
+ /** Tailwind's `sm` breakpoint — below this the panel renders as a bottom sheet.
3783
+ * Kept in step with the same constant in `MnModalShellComponent`. */
3784
+ static SHEET_MAX_WIDTH = 639.98;
3785
+ /** Whether the viewport is currently narrow enough for the sheet layout. */
3786
+ isNarrowViewport = false;
3787
+ /** Live breakpoint match, so rotating the device re-evaluates the layout. */
3788
+ sheetMedia = null;
3789
+ /** The listener registered on `sheetMedia`, retained for teardown. */
3790
+ sheetMediaListener = null;
3791
+ /** `document.body`'s inline `overflow` before the sheet locked it, restored on close. */
3792
+ previousBodyOverflow = null;
3793
+ /**
3794
+ * The sheet's height (px) captured the moment it opened, before any search. Re-applied
3795
+ * as a `min-height` floor so filtering the option list shorter cannot shrink the sheet
3796
+ * mid-type. Null while anchored or closed, so the popover and desktop path are untouched.
3797
+ */
3798
+ sheetFloorPx = null;
3511
3799
  /**
3512
3800
  * Watches the trigger while the panel is open. The panel lives in `document.body`,
3513
3801
  * so it survives its own trigger being hidden by an ancestor — e.g. a wizard step
@@ -3521,6 +3809,8 @@ class MnMultiSelect {
3521
3809
  * card) used to leave the portalled panel floating at its stale coordinates.
3522
3810
  */
3523
3811
  scrollCapture = null;
3812
+ /** The bottom-sheet host currently moved into `document.body`, if any. */
3813
+ movedSheet = null;
3524
3814
  /**
3525
3815
  * The dropdown panel element, queried while it is rendered by the `@if` block.
3526
3816
  * The setter relocates the panel to `document.body` so that its `position: fixed`
@@ -3530,7 +3820,7 @@ class MnMultiSelect {
3530
3820
  * broken on iOS). Cleanup is handled when the query clears on close/destroy.
3531
3821
  */
3532
3822
  set dropdownRef(ref) {
3533
- this.relocateDropdown(ref?.nativeElement ?? null);
3823
+ this.movedPanel = this.portal(ref?.nativeElement ?? null, this.movedPanel);
3534
3824
  }
3535
3825
  /** Currently selected values */
3536
3826
  selectedValues = [];
@@ -3549,28 +3839,65 @@ class MnMultiSelect {
3549
3839
  if (this.ngControl)
3550
3840
  this.ngControl.valueAccessor = this;
3551
3841
  }
3842
+ /**
3843
+ * The bottom-sheet host, read as an `ElementRef` so it can be relocated to
3844
+ * `document.body` — its `position: fixed` children (backdrop + container) must anchor
3845
+ * to the viewport, not to any transformed/filtered ancestor of this component. On open
3846
+ * its container height is captured as the sheet's `min-height` floor.
3847
+ */
3848
+ set sheetRef(ref) {
3849
+ const el = ref?.nativeElement ?? null;
3850
+ this.movedSheet = this.portal(el, this.movedSheet);
3851
+ if (el) {
3852
+ this.captureSheetFloor(el);
3853
+ }
3854
+ else {
3855
+ this.sheetFloorPx = null;
3856
+ }
3857
+ }
3858
+ /**
3859
+ * Tracks the sheet breakpoint through `matchMedia` rather than reading `innerWidth`
3860
+ * once, so rotating the device switches layout instead of leaving a panel positioned
3861
+ * for the previous orientation. An open panel is closed on the switch — its anchored
3862
+ * coordinates and its sheet layout are not interchangeable.
3863
+ */
3864
+ startWatchingViewport() {
3865
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
3866
+ return;
3867
+ this.sheetMedia = window.matchMedia(`(max-width: ${MnMultiSelect.SHEET_MAX_WIDTH}px)`);
3868
+ this.isNarrowViewport = this.sheetMedia.matches;
3869
+ this.sheetMediaListener = (event) => {
3870
+ this.isNarrowViewport = event.matches;
3871
+ this.close();
3872
+ // The listener fires outside Angular, so a zoneless app needs an explicit nudge.
3873
+ this.cdr.markForCheck();
3874
+ };
3875
+ this.sheetMedia.addEventListener('change', this.sheetMediaListener);
3876
+ }
3877
+ /** Tears down the breakpoint listener. Idempotent. */
3878
+ stopWatchingViewport() {
3879
+ if (this.sheetMedia && this.sheetMediaListener) {
3880
+ this.sheetMedia.removeEventListener('change', this.sheetMediaListener);
3881
+ }
3882
+ this.sheetMedia = null;
3883
+ this.sheetMediaListener = null;
3884
+ }
3552
3885
  ngOnInit() {
3553
3886
  this.resolveConfig();
3887
+ this.startWatchingViewport();
3554
3888
  const sub = this.lang.locale$.pipe(skip(1)).subscribe(() => {
3555
3889
  this.resolveConfig();
3556
3890
  });
3557
3891
  this.destroyRef.onDestroy(() => {
3558
3892
  sub.unsubscribe();
3559
3893
  this.stopWatchingTrigger();
3560
- // Guarantee the portalled panel never outlives the component.
3561
- this.relocateDropdown(null);
3894
+ this.stopWatchingViewport();
3895
+ this.unlockBodyScroll();
3896
+ // Guarantee the portalled elements never outlive the component.
3897
+ this.movedPanel = this.portal(null, this.movedPanel);
3898
+ this.movedSheet = this.portal(null, this.movedSheet);
3562
3899
  });
3563
3900
  }
3564
- onDocumentClick(event) {
3565
- const target = event.target;
3566
- // The panel lives at the body root once open, so it is not a descendant of the
3567
- // host element — treat clicks inside the portalled panel as "inside" too.
3568
- const insideHost = !!target && this.elRef.nativeElement.contains(target);
3569
- const insidePanel = !!target && !!this.movedPanel && this.movedPanel.contains(target);
3570
- if (!insideHost && !insidePanel) {
3571
- this.close();
3572
- }
3573
- }
3574
3901
  resolveConfig() {
3575
3902
  const instanceId = this.explicitInstanceId || `mn-multi-select-${this.props.id}`;
3576
3903
  this.uiConfig = this.configService.resolve('mn-multi-select', this.sectionPath, instanceId);
@@ -3603,15 +3930,84 @@ class MnMultiSelect {
3603
3930
  return;
3604
3931
  }
3605
3932
  this.isOpen = true;
3933
+ if (this.isSheet) {
3934
+ // A sheet is anchored to the viewport, so it needs no trigger tracking — only a
3935
+ // scroll lock so the page behind it stays put while the list is scrolled.
3936
+ this.lockBodyScroll();
3937
+ return;
3938
+ }
3606
3939
  this.updateDropdownPosition();
3607
3940
  this.startWatchingTrigger();
3608
3941
  }
3942
+ /** Whether the panel should currently render as a bottom sheet. */
3943
+ get isSheet() {
3944
+ return this.props.mobileSheet !== false && this.isNarrowViewport;
3945
+ }
3946
+ /**
3947
+ * Whether the search input is shown: the explicit `searchable` prop when set,
3948
+ * otherwise auto-enabled once the option count reaches the threshold.
3949
+ */
3950
+ get isSearchable() {
3951
+ if (this.props.searchable !== undefined)
3952
+ return this.props.searchable;
3953
+ const threshold = this.props.searchThreshold ?? MnMultiSelect.DEFAULT_SEARCH_THRESHOLD;
3954
+ return this.props.options.length >= threshold;
3955
+ }
3956
+ onDocumentClick(event) {
3957
+ const target = event.target;
3958
+ // The panel lives at the body root once open, so it is not a descendant of the
3959
+ // host element — treat clicks inside the portalled panel as "inside" too.
3960
+ const insideHost = !!target && this.elRef.nativeElement.contains(target);
3961
+ const insidePanel = !!target && !!this.movedPanel && this.movedPanel.contains(target);
3962
+ // In sheet mode the backdrop tap is handled by mn-bottom-sheet's own (dismiss); the
3963
+ // sheet host counts as "inside" here so this listener never double-fires the close.
3964
+ const insideSheet = !!target && !!this.movedSheet && this.movedSheet.contains(target);
3965
+ if (!insideHost && !insidePanel && !insideSheet) {
3966
+ this.close();
3967
+ }
3968
+ }
3969
+ /**
3970
+ * Records the sheet's opened height as its `min-height` floor. Measured on the next
3971
+ * frame so the read reflects the fully-rendered, unfiltered list (the search box is
3972
+ * empty on open) and never forces a reflow mid change-detection. The floor equals the
3973
+ * content height at that instant, so applying it triggers no resize — it only stops a
3974
+ * later, shorter filtered list from pulling the sheet down.
3975
+ *
3976
+ * `hostEl` is the portalled mn-bottom-sheet host (`display: contents`), so the height
3977
+ * is read from its `.mn-sheet-container` child rather than the host itself.
3978
+ */
3979
+ captureSheetFloor(hostEl) {
3980
+ const measure = () => {
3981
+ const container = hostEl.querySelector('.mn-sheet-container');
3982
+ return container?.offsetHeight ?? hostEl.offsetHeight;
3983
+ };
3984
+ if (typeof requestAnimationFrame !== 'function') {
3985
+ this.sheetFloorPx = measure();
3986
+ return;
3987
+ }
3988
+ requestAnimationFrame(() => {
3989
+ // The sheet may have closed before the frame ran; don't strand a stale floor.
3990
+ if (!this.isOpen || this.movedSheet !== hostEl)
3991
+ return;
3992
+ this.sheetFloorPx = measure();
3993
+ this.cdr.markForCheck();
3994
+ });
3995
+ }
3609
3996
  /** Closes the dropdown on Escape for keyboard accessibility. */
3610
3997
  onEscape() {
3611
3998
  this.close();
3612
3999
  }
3613
- /** Closes the dropdown when the page or a scrollable parent is scrolled */
4000
+ /**
4001
+ * Closes the dropdown when the page or a scrollable parent is scrolled.
4002
+ *
4003
+ * Skipped for a sheet: it is anchored to the viewport, not to the trigger, so it has
4004
+ * no stale position to escape. Crucially, opening the soft keyboard fires a `resize`
4005
+ * on Android — closing on that would dismiss the sheet the instant search is focused.
4006
+ * A genuine layout switch is handled by the `matchMedia` listener instead.
4007
+ */
3614
4008
  onWindowScrollOrResize() {
4009
+ if (this.isSheet)
4010
+ return;
3615
4011
  this.close();
3616
4012
  }
3617
4013
  /**
@@ -3625,6 +4021,29 @@ class MnMultiSelect {
3625
4021
  this.isOpen = false;
3626
4022
  this.searchTerm = '';
3627
4023
  this.stopWatchingTrigger();
4024
+ this.unlockBodyScroll();
4025
+ }
4026
+ /**
4027
+ * Freezes the page behind an open sheet. The previous inline value is captured and
4028
+ * restored verbatim so a surrounding modal that set its own lock is left intact.
4029
+ */
4030
+ lockBodyScroll() {
4031
+ if (this.previousBodyOverflow !== null)
4032
+ return;
4033
+ this.previousBodyOverflow = document.body.style.overflow;
4034
+ this.renderer.setStyle(document.body, 'overflow', 'hidden');
4035
+ }
4036
+ /** Restores the pre-lock `overflow`. Idempotent. */
4037
+ unlockBodyScroll() {
4038
+ if (this.previousBodyOverflow === null)
4039
+ return;
4040
+ if (this.previousBodyOverflow) {
4041
+ this.renderer.setStyle(document.body, 'overflow', this.previousBodyOverflow);
4042
+ }
4043
+ else {
4044
+ this.renderer.removeStyle(document.body, 'overflow');
4045
+ }
4046
+ this.previousBodyOverflow = null;
3628
4047
  }
3629
4048
  /** Calculates the fixed position for the dropdown based on the trigger element */
3630
4049
  updateDropdownPosition() {
@@ -3667,27 +4086,29 @@ class MnMultiSelect {
3667
4086
  document.addEventListener('scroll', this.scrollCapture, true);
3668
4087
  }
3669
4088
  /**
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`.
4089
+ * Move an overlay element to `document.body` when it appears, and detach it when the
4090
+ * query clears. Appending to the body root makes the element immune to ancestor
4091
+ * `transform`/`filter`/`will-change`, so `position: fixed` anchors to the viewport
4092
+ * without this the panel lands mid-screen (and breaks outright on iOS).
4093
+ *
4094
+ * Returns the element now portalled, so the caller can store it. Idempotent and safe
4095
+ * to call with `null`.
3674
4096
  */
3675
- relocateDropdown(el) {
4097
+ portal(el, current) {
3676
4098
  if (el) {
3677
- if (this.movedPanel === el)
3678
- return;
4099
+ if (current === el)
4100
+ return current;
3679
4101
  this.renderer.appendChild(document.body, el);
3680
- this.movedPanel = el;
3681
- return;
4102
+ return el;
3682
4103
  }
3683
- if (this.movedPanel) {
4104
+ if (current) {
3684
4105
  // Angular's view teardown may already have removed it; only detach if still attached.
3685
- const parent = this.movedPanel.parentNode;
4106
+ const parent = current.parentNode;
3686
4107
  if (parent) {
3687
- this.renderer.removeChild(parent, this.movedPanel);
4108
+ this.renderer.removeChild(parent, current);
3688
4109
  }
3689
- this.movedPanel = null;
3690
4110
  }
4111
+ return null;
3691
4112
  }
3692
4113
  /** Tears down the watchers installed by `startWatchingTrigger`. Idempotent. */
3693
4114
  stopWatchingTrigger() {
@@ -3843,11 +4264,11 @@ class MnMultiSelect {
3843
4264
  });
3844
4265
  }
3845
4266
  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]" }] });
4267
+ 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: "sheetRef", first: true, predicate: ["sheet"], descendants: true, read: ElementRef }], 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 @if (isSheet) {\n <!-- On mobile the panel is presented as a shared bottom sheet: the sheet chrome\n (backdrop, grabber, swipe/flick-to-dismiss, slide animation) lives in\n mn-bottom-sheet; this component only projects the field's content into it.\n The sheet host is portalled to document.body (see the `sheet` ViewChild) so\n its `position: fixed` anchors to the viewport, not a transformed ancestor. -->\n <mn-bottom-sheet\n #sheet\n (dismiss)=\"close()\"\n [ariaLabel]=\"uiConfig.ariaLabel || uiConfig.label || props.label || uiConfig.placeholder || props.placeholder\"\n [maxHeightVh]=\"80\"\n [minHeightPx]=\"sheetFloorPx\"\n >\n <div\n [id]=\"resolvedId + '-listbox'\"\n aria-multiselectable=\"true\"\n class=\"flex flex-col flex-1 min-h-0 overflow-hidden\"\n role=\"listbox\"\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 <div class=\"flex items-center justify-between gap-x-2 px-4 pt-1 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 <ng-container [ngTemplateOutlet]=\"panelBody\"></ng-container>\n </div>\n </mn-bottom-sheet>\n } @else {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n #dropdown\n (click)=\"$event.stopPropagation()\"\n [id]=\"resolvedId + '-listbox'\"\n [ngClass]=\"panelClasses\"\n [style.left]=\"dropdownStyle.left\"\n [style.top]=\"dropdownStyle.top\"\n [style.width]=\"dropdownStyle.width\"\n aria-multiselectable=\"true\"\n role=\"listbox\"\n >\n <ng-container [ngTemplateOutlet]=\"panelBody\"></ng-container>\n </div>\n }\n }\n\n <!-- The search box + option list, shared verbatim by the sheet and the anchored\n popover. `isSheet` only tunes spacing/sizing and which element scrolls: in sheet\n mode the list is the flex scroller; anchored, the popover itself scrolls. -->\n <ng-template #panelBody>\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 (click)=\"$event.stopPropagation()\"\n [ngClass]=\"isSheet ? 'px-4 py-2' : 'p-2'\"\n class=\"border-b border-base-300 shrink-0\"\n >\n <mn-lib-input-field\n (ngModelChange)=\"onSearch($event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n [ngModel]=\"searchTerm\"\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 <div [ngClass]=\"isSheet ? 'flex-1 overflow-auto overscroll-contain' : ''\">\n @for (opt of filteredOptions; track opt.value) {\n <div\n (click)=\"toggleOption(opt); $event.stopPropagation()\"\n (keyup.enter)=\"toggleOption(opt)\"\n (keyup.space)=\"toggleOption(opt)\"\n [attr.aria-selected]=\"isSelected(opt)\"\n [class.opacity-50]=\"opt.disabled || isMaxReached(opt)\"\n [class.pointer-events-none]=\"opt.disabled || isMaxReached(opt)\"\n [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\"\n class=\"flex items-center gap-x-2 cursor-pointer text-base-content hover:bg-base-200\"\n role=\"option\"\n tabindex=\"0\"\n >\n <input\n [checked]=\"isSelected(opt)\"\n [disabled]=\"opt.disabled || isMaxReached(opt)\"\n class=\"w-4 h-4 accent-primary pointer-events-none shrink-0\"\n tabindex=\"-1\"\n type=\"checkbox\"\n />\n <span>{{ opt.label }}</span>\n </div>\n }\n @if (filteredOptions.length === 0) {\n <div [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\" class=\"text-base-content/50\">\n {{ uiConfig.noOptionsFound || 'No options found' }}\n </div>\n }\n </div>\n </ng-template>\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: [""], dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { 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: MnBottomSheet, selector: "mn-bottom-sheet", inputs: ["showBackdrop", "showGrabber", "dismissible", "minHeightPx", "maxHeightVh", "containerClass", "ariaLabel", "ariaLabelledby", "growWithKeyboard", "dismissGuard"], outputs: ["dismiss"] }, { kind: "component", type: LucideX, selector: "svg[lucideX]" }, { kind: "component", type: LucideChevronDown, selector: "svg[lucideChevronDown]" }] });
3847
4268
  }
3848
4269
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnMultiSelect, decorators: [{
3849
4270
  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" }]
4271
+ args: [{ selector: 'mn-lib-multi-select', standalone: true, imports: [NgClass, NgTemplateOutlet, FormsModule, MnErrorMessage, MnButton, MnInputField, MnBottomSheet, 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 @if (isSheet) {\n <!-- On mobile the panel is presented as a shared bottom sheet: the sheet chrome\n (backdrop, grabber, swipe/flick-to-dismiss, slide animation) lives in\n mn-bottom-sheet; this component only projects the field's content into it.\n The sheet host is portalled to document.body (see the `sheet` ViewChild) so\n its `position: fixed` anchors to the viewport, not a transformed ancestor. -->\n <mn-bottom-sheet\n #sheet\n (dismiss)=\"close()\"\n [ariaLabel]=\"uiConfig.ariaLabel || uiConfig.label || props.label || uiConfig.placeholder || props.placeholder\"\n [maxHeightVh]=\"80\"\n [minHeightPx]=\"sheetFloorPx\"\n >\n <div\n [id]=\"resolvedId + '-listbox'\"\n aria-multiselectable=\"true\"\n class=\"flex flex-col flex-1 min-h-0 overflow-hidden\"\n role=\"listbox\"\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 <div class=\"flex items-center justify-between gap-x-2 px-4 pt-1 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 <ng-container [ngTemplateOutlet]=\"panelBody\"></ng-container>\n </div>\n </mn-bottom-sheet>\n } @else {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n #dropdown\n (click)=\"$event.stopPropagation()\"\n [id]=\"resolvedId + '-listbox'\"\n [ngClass]=\"panelClasses\"\n [style.left]=\"dropdownStyle.left\"\n [style.top]=\"dropdownStyle.top\"\n [style.width]=\"dropdownStyle.width\"\n aria-multiselectable=\"true\"\n role=\"listbox\"\n >\n <ng-container [ngTemplateOutlet]=\"panelBody\"></ng-container>\n </div>\n }\n }\n\n <!-- The search box + option list, shared verbatim by the sheet and the anchored\n popover. `isSheet` only tunes spacing/sizing and which element scrolls: in sheet\n mode the list is the flex scroller; anchored, the popover itself scrolls. -->\n <ng-template #panelBody>\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 (click)=\"$event.stopPropagation()\"\n [ngClass]=\"isSheet ? 'px-4 py-2' : 'p-2'\"\n class=\"border-b border-base-300 shrink-0\"\n >\n <mn-lib-input-field\n (ngModelChange)=\"onSearch($event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n [ngModel]=\"searchTerm\"\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 <div [ngClass]=\"isSheet ? 'flex-1 overflow-auto overscroll-contain' : ''\">\n @for (opt of filteredOptions; track opt.value) {\n <div\n (click)=\"toggleOption(opt); $event.stopPropagation()\"\n (keyup.enter)=\"toggleOption(opt)\"\n (keyup.space)=\"toggleOption(opt)\"\n [attr.aria-selected]=\"isSelected(opt)\"\n [class.opacity-50]=\"opt.disabled || isMaxReached(opt)\"\n [class.pointer-events-none]=\"opt.disabled || isMaxReached(opt)\"\n [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\"\n class=\"flex items-center gap-x-2 cursor-pointer text-base-content hover:bg-base-200\"\n role=\"option\"\n tabindex=\"0\"\n >\n <input\n [checked]=\"isSelected(opt)\"\n [disabled]=\"opt.disabled || isMaxReached(opt)\"\n class=\"w-4 h-4 accent-primary pointer-events-none shrink-0\"\n tabindex=\"-1\"\n type=\"checkbox\"\n />\n <span>{{ opt.label }}</span>\n </div>\n }\n @if (filteredOptions.length === 0) {\n <div [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\" class=\"text-base-content/50\">\n {{ uiConfig.noOptionsFound || 'No options found' }}\n </div>\n }\n </div>\n </ng-template>\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" }]
3851
4272
  }], ctorParameters: () => [], propDecorators: { props: [{
3852
4273
  type: Input,
3853
4274
  args: [{ required: true }]
@@ -3857,6 +4278,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImpor
3857
4278
  }], dropdownRef: [{
3858
4279
  type: ViewChild,
3859
4280
  args: ['dropdown', { static: false }]
4281
+ }], sheetRef: [{
4282
+ type: ViewChild,
4283
+ args: ['sheet', { static: false, read: ElementRef }]
3860
4284
  }], onDocumentClick: [{
3861
4285
  type: HostListener,
3862
4286
  args: ['document:click', ['$event']]
@@ -8412,9 +8836,6 @@ class MnModalShellComponent {
8412
8836
  }
8413
8837
  el = inject(ElementRef);
8414
8838
  cdr = inject(ChangeDetectorRef);
8415
- /** Downward release speed (px/ms) above which a short drag still dismisses — a "flick".
8416
- * Native sheets dismiss on a quick flick regardless of distance, not just a long drag. */
8417
- static FLICK_VELOCITY = 0.5;
8418
8839
  config;
8419
8840
  modalRef;
8420
8841
  isClosing = false;
@@ -8429,6 +8850,8 @@ class MnModalShellComponent {
8429
8850
  ModalKind = ModalKind;
8430
8851
  /** The rendered wizard body, when this modal is a wizard — used to read the active step title. */
8431
8852
  wizardBody = viewChild(MnWizardBodyComponent, ...(ngDevMode ? [{ debugName: "wizardBody" }] : []));
8853
+ /** Tailwind's `sm` breakpoint — below this the modal presents as a bottom sheet. */
8854
+ static SHEET_MAX_WIDTH = 639.98;
8432
8855
  /**
8433
8856
  * Title of the wizard's current step, or undefined for non-wizard modals.
8434
8857
  * The template appends it to the modal title on small screens, where the
@@ -8439,19 +8862,42 @@ class MnModalShellComponent {
8439
8862
  focusTrapListener = null;
8440
8863
  pollingTimer = null;
8441
8864
  pollAttempts = 0;
8442
- ngOnInit() {
8443
- this.startPollingIfConfigured();
8865
+ /** Upper bound for the close wait if no animation/transition end event fires
8866
+ * (e.g. an animation was suppressed). Must stay longer than the slowest close
8867
+ * path so it never preempts. */
8868
+ static CLOSE_FALLBACK_MS = 700;
8869
+ /** Live match of the sheet breakpoint, so the modal switches between the centered dialog
8870
+ * and the bottom sheet when the viewport crosses it (e.g. an orientation change). */
8871
+ isNarrow = signal(false, ...(ngDevMode ? [{ debugName: "isNarrow" }] : []));
8872
+ /** The bottom sheet presenting this modal on mobile, absent on the desktop dialog path. */
8873
+ bottomSheet = viewChild(MnBottomSheet, ...(ngDevMode ? [{ debugName: "bottomSheet" }] : []));
8874
+ /** Optional native haptic engine. Absent on the web — every call is null-guarded. */
8875
+ haptics = inject(MN_HAPTICS, { optional: true });
8876
+ sheetMedia = null;
8877
+ sheetMediaListener = null;
8878
+ /** Whether this modal is allowed to present as a bottom sheet on small screens (default: true). */
8879
+ get isMobileSheet() {
8880
+ return this.config.mobileBottomSheet !== false;
8444
8881
  }
8445
- /** Minimum drag distance (px) that must accompany a flick, so an incidental fast tap
8446
- * on the grabber never dismisses. Below the distance threshold, only a flick dismisses. */
8447
- static FLICK_MIN_DISTANCE = 32;
8448
- ngOnDestroy() {
8449
- this.removeFocusTrap();
8450
- this.stopPolling();
8451
- // Restore focus to previously focused element
8452
- if (this.previouslyFocusedElement && typeof this.previouslyFocusedElement.focus === 'function') {
8453
- this.previouslyFocusedElement.focus();
8454
- }
8882
+ /** Whether the modal should currently render as a bottom sheet (mobile) rather than the
8883
+ * centered dialog (desktop). */
8884
+ get showMobileSheet() {
8885
+ return this.isMobileSheet && this.isNarrow();
8886
+ }
8887
+ get hostClasses() {
8888
+ const size = this.config.sizeWidth || ModalSize.MD;
8889
+ // `closing` is intentionally NOT derived here. startClosing() adds the
8890
+ // `.closing` class imperatively (classList.add) for reliable, zoneless-safe
8891
+ // application. Deriving it here as well flips the host class string after the
8892
+ // view has been checked (NG0100). Angular's class binding only manages the tokens
8893
+ // it emits, so it leaves the imperatively added `.closing` untouched.
8894
+ const animType = typeof this.config.animation === 'string'
8895
+ ? this.config.animation
8896
+ : this.config.animation?.type || 'slide';
8897
+ const animation = ` anim-${animType}`;
8898
+ const stacked = this.isStacked() ? ' is-stacked' : '';
8899
+ const mobileSheet = this.showMobileSheet ? ' mobile-sheet' : '';
8900
+ return `modal-shell modal-${size}${animation}${stacked}${mobileSheet}`;
8455
8901
  }
8456
8902
  setupFocusTrap() {
8457
8903
  this.focusTrapListener = (e) => {
@@ -8495,45 +8941,54 @@ class MnModalShellComponent {
8495
8941
  asCustom(config) {
8496
8942
  return config;
8497
8943
  }
8498
- static SWIPE_DISMISS_THRESHOLD = 150;
8499
- /** Optional native haptic engine. Absent on the web — every call is null-guarded. */
8500
- haptics = inject(MN_HAPTICS, { optional: true });
8501
- /** The two most recent (y, timestamp) pointer samples, used to estimate the release
8502
- * velocity for flick-to-dismiss. `t` uses the event timestamp (monotonic, no Date). */
8503
- lastSample = null;
8504
- /** Upper bound for the close wait if no animation/transition end event fires
8505
- * (e.g. an animation was suppressed). Must stay longer than the slowest close
8506
- * path (mobile sheet slide-down 0.45s, swipe glide 0.3s) so it never preempts. */
8507
- static CLOSE_FALLBACK_MS = 700;
8508
- /** Whether this modal renders as a bottom sheet on small screens (default: true). */
8509
- get isMobileSheet() {
8510
- return this.config.mobileBottomSheet !== false;
8944
+ /** Whether the modal can be dismissed at all (drives the sheet's swipe/backdrop arming). */
8945
+ get canClose() {
8946
+ return this.config.closeMode !== CloseMode.DISABLED;
8947
+ }
8948
+ ngOnInit() {
8949
+ this.startWatchingViewport();
8950
+ this.startPollingIfConfigured();
8951
+ }
8952
+ ngOnDestroy() {
8953
+ this.removeFocusTrap();
8954
+ this.stopPolling();
8955
+ this.stopWatchingViewport();
8956
+ // Restore focus to previously focused element
8957
+ if (this.previouslyFocusedElement && typeof this.previouslyFocusedElement.focus === 'function') {
8958
+ this.previouslyFocusedElement.focus();
8959
+ }
8511
8960
  }
8512
8961
  /**
8513
8962
  * Triggers the closing animation and resolves once it has actually finished.
8514
8963
  *
8515
- * Deferred via setTimeout to avoid NG0100 when called during a CD cycle.
8516
- * Rather than guess a fixed duration (the old hardcoded 150ms truncated the
8517
- * mobile slide-down, which runs 250ms and the swipe glide, 300ms), we wait
8518
- * for the container's `animationend`/`transitionend` and tear down then. A
8519
- * fallback timeout guarantees resolution if no such event fires, and we
8520
- * short-circuit entirely under reduced motion (the CSS collapses to instant).
8964
+ * Deferred via setTimeout to avoid NG0100 when called during a CD cycle. On mobile the
8965
+ * exit is owned by the bottom sheet, so we delegate to its `startClosing()` (idempotent
8966
+ * with a swipe-dismiss already in flight); on desktop we wait for the dialog container's
8967
+ * `animationend`/`transitionend`. A fallback timeout guarantees resolution if no event
8968
+ * fires, and we short-circuit under reduced motion (the CSS collapses to instant).
8521
8969
  */
8522
8970
  startClosing() {
8523
8971
  return new Promise(resolve => {
8524
- setTimeout(() => {
8972
+ setTimeout(async () => {
8525
8973
  this.isClosing = true;
8526
8974
  // @HostBinding('class') updates are flushed when the host view is checked
8527
8975
  // (appRef.tick), not by a bare detectChanges() on this dynamically-created
8528
- // root component. Relying on CD alone means the `.closing` class and thus
8529
- // the slide-down animation — never lands in a zoneless app and is timing-
8530
- // fragile elsewhere. Apply it directly so the close animation is reliable.
8976
+ // root component. Apply the `.closing` class directly so the backdrop fade is
8977
+ // reliable in a zoneless app.
8531
8978
  this.el.nativeElement.classList.add('closing');
8532
8979
  this.cdr.detectChanges();
8533
8980
  if (this.prefersReducedMotion()) {
8534
8981
  resolve();
8535
8982
  return;
8536
8983
  }
8984
+ // Mobile: the bottom sheet drives the slide-down exit.
8985
+ const sheet = this.bottomSheet();
8986
+ if (sheet) {
8987
+ await sheet.startClosing();
8988
+ resolve();
8989
+ return;
8990
+ }
8991
+ // Desktop: wait for the centered dialog container's own close animation.
8537
8992
  const container = this.el.nativeElement.querySelector('.modal-container');
8538
8993
  if (!container) {
8539
8994
  resolve();
@@ -8552,11 +9007,8 @@ class MnModalShellComponent {
8552
9007
  clearTimeout(fallback);
8553
9008
  resolve();
8554
9009
  };
8555
- // Normal close ends via a keyframe (animationend); the swipe-dismiss
8556
- // glide ends via the transform transition (transitionend).
8557
9010
  container.addEventListener('animationend', done);
8558
9011
  container.addEventListener('transitionend', done);
8559
- // `done` only runs asynchronously, after this assignment completes.
8560
9012
  const fallback = setTimeout(done, MnModalShellComponent.CLOSE_FALLBACK_MS);
8561
9013
  });
8562
9014
  });
@@ -8584,123 +9036,54 @@ class MnModalShellComponent {
8584
9036
  onCloseButtonClick() {
8585
9037
  this.handleClose(ModalCloseReason.DISMISSED);
8586
9038
  }
8587
- /** True once a swipe has crossed the dismiss threshold — slides the sheet off-screen
8588
- * via the transform transition instead of replaying the slide-up keyframe. */
8589
- swipeDismissing = false;
8590
- // =========================
8591
- // Mobile bottom-sheet swipe-to-dismiss (via the grabber handle)
8592
- // =========================
8593
- /** Current downward drag offset (px) applied to the sheet while swiping. */
8594
- sheetDragY = 0;
8595
- /** True while the user is actively dragging the grabber (disables snap transition). */
8596
- isDraggingSheet = false;
8597
- prevSample = null;
8598
- get hostClasses() {
8599
- const size = this.config.sizeWidth || ModalSize.MD;
8600
- // `closing` is intentionally NOT derived here. startClosing() adds the
8601
- // `.closing` class imperatively (classList.add) for reliable, zoneless-safe
8602
- // application. Deriving it from `isClosing` in this getter as well makes the
8603
- // host class string flip value after the view has been checked, which throws
8604
- // NG0100 (ExpressionChangedAfterItHasBeenCheckedError) in dev. Angular's class
8605
- // binding only manages the tokens it emits, so it leaves the imperatively
8606
- // added `.closing` untouched.
8607
- const animType = typeof this.config.animation === 'string'
8608
- ? this.config.animation
8609
- : this.config.animation?.type || 'slide';
8610
- const animation = ` anim-${animType}`;
8611
- const stacked = this.isStacked() ? ' is-stacked' : '';
8612
- const mobileSheet = this.isMobileSheet ? ' mobile-sheet' : '';
8613
- const swiping = this.swipeDismissing ? ' swipe-dismissing' : '';
8614
- return `modal-shell modal-${size}${animation}${stacked}${mobileSheet}${swiping}`;
8615
- }
8616
- dragStartY = 0;
8617
- /** Whether the sheet can be dismissed at all (drives whether the swipe is armed). */
8618
- get canClose() {
8619
- return this.config.closeMode !== CloseMode.DISABLED;
9039
+ /**
9040
+ * Guard consulted by the bottom sheet before it commits a swipe/flick/backdrop dismissal.
9041
+ * Mirrors the DISABLED/GUARDED rules of {@link handleClose} so a swipe cannot escape a
9042
+ * modal that a button close could not. Bound as a field so the template passes it directly.
9043
+ */
9044
+ sheetDismissGuard = async () => {
9045
+ if (this.config.closeMode === CloseMode.DISABLED) {
9046
+ return false;
9047
+ }
9048
+ if (this.config.closeMode === CloseMode.GUARDED && this.config.closeGuard) {
9049
+ return await this.config.closeGuard();
9050
+ }
9051
+ return true;
9052
+ };
9053
+ /**
9054
+ * Handles the sheet's `(dismiss)` emitted only after its guard passed and its exit
9055
+ * animation finished. Dismisses the modal (no re-guard) with a confirming haptic.
9056
+ */
9057
+ onSheetDismiss() {
9058
+ this.haptics?.impact('medium');
9059
+ this.modalRef.dismiss(ModalCloseReason.DISMISSED);
8620
9060
  }
8621
- /** Tailwind's `sm` breakpoint — below this the modal renders as a bottom sheet. */
8622
- static SHEET_MAX_WIDTH = 639.98;
8623
9061
  ngAfterViewInit() {
8624
9062
  this.previouslyFocusedElement = document.activeElement;
8625
9063
  this.setupFocusTrap();
8626
- // Focus the modal container
9064
+ // Focus the modal container (the centered dialog, or the sheet's container on mobile).
8627
9065
  const container = this.el.nativeElement.querySelector('.modal-container');
8628
9066
  if (container) {
8629
9067
  container.focus();
8630
9068
  }
8631
9069
  }
8632
- onSheetPointerDown(event) {
8633
- if (!this.isMobileSheet || !this.canClose)
8634
- return;
8635
- // Only a bottom sheet (mobile-width viewport) can be swiped away.
8636
- if (window.innerWidth > MnModalShellComponent.SHEET_MAX_WIDTH)
9070
+ /** Tracks the sheet breakpoint through `matchMedia` so the dialog/sheet fork re-renders
9071
+ * when the viewport crosses it. */
9072
+ startWatchingViewport() {
9073
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
8637
9074
  return;
8638
- // Don't hijack drags that begin on an interactive control (e.g. the close button).
8639
- if (event.target.closest('button'))
8640
- return;
8641
- this.isDraggingSheet = true;
8642
- this.dragStartY = event.clientY;
8643
- // Seed the velocity samples so a fast flick that releases on the first move still
8644
- // has a baseline to measure against.
8645
- this.lastSample = { y: event.clientY, t: event.timeStamp };
8646
- this.prevSample = this.lastSample;
8647
- event.target.setPointerCapture(event.pointerId);
9075
+ this.sheetMedia = window.matchMedia(`(max-width: ${MnModalShellComponent.SHEET_MAX_WIDTH}px)`);
9076
+ this.isNarrow.set(this.sheetMedia.matches);
9077
+ this.sheetMediaListener = (event) => this.isNarrow.set(event.matches);
9078
+ this.sheetMedia.addEventListener('change', this.sheetMediaListener);
8648
9079
  }
8649
- onSheetPointerMove(event) {
8650
- if (!this.isDraggingSheet)
8651
- return;
8652
- // Only track downward movement.
8653
- this.sheetDragY = Math.max(0, event.clientY - this.dragStartY);
8654
- // Roll the sample window forward so pointer-up can read the latest instantaneous speed.
8655
- this.prevSample = this.lastSample;
8656
- this.lastSample = { y: event.clientY, t: event.timeStamp };
8657
- }
8658
- async onSheetPointerUp() {
8659
- if (!this.isDraggingSheet)
8660
- return;
8661
- this.isDraggingSheet = false;
8662
- if (this.shouldDismissSheet()) {
8663
- const closed = await this.handleClose(ModalCloseReason.DISMISSED);
8664
- if (closed) {
8665
- // A confirmed dismissal gets a slightly firmer tick than the open tap.
8666
- this.haptics?.impact('medium');
8667
- // Continue the gesture: glide the sheet the rest of the way down rather than
8668
- // snapping back to 0 and replaying the slide-up keyframe (which looked un-animated).
8669
- this.swipeDismissing = true;
8670
- this.sheetDragY = window.innerHeight;
8671
- this.cdr.detectChanges();
8672
- }
8673
- else {
8674
- this.snapBack(); // guard rejected — spring back
8675
- }
8676
- }
8677
- else {
8678
- this.snapBack(); // not far enough / not a flick — spring back
9080
+ /** Tears down the breakpoint listener. Idempotent. */
9081
+ stopWatchingViewport() {
9082
+ if (this.sheetMedia && this.sheetMediaListener) {
9083
+ this.sheetMedia.removeEventListener('change', this.sheetMediaListener);
8679
9084
  }
8680
- this.lastSample = null;
8681
- this.prevSample = null;
8682
- }
8683
- /** Whether the release should dismiss: a long-enough drag OR a fast downward flick. */
8684
- shouldDismissSheet() {
8685
- if (this.sheetDragY > MnModalShellComponent.SWIPE_DISMISS_THRESHOLD) {
8686
- return true;
8687
- }
8688
- return this.releaseVelocity() > MnModalShellComponent.FLICK_VELOCITY
8689
- && this.sheetDragY > MnModalShellComponent.FLICK_MIN_DISTANCE;
8690
- }
8691
- /** Downward release speed (px/ms) from the last two pointer samples. Positive means
8692
- * moving down. Returns 0 when there is no usable sample window. */
8693
- releaseVelocity() {
8694
- if (!this.lastSample || !this.prevSample)
8695
- return 0;
8696
- const dt = this.lastSample.t - this.prevSample.t;
8697
- if (dt <= 0)
8698
- return 0;
8699
- return (this.lastSample.y - this.prevSample.y) / dt;
8700
- }
8701
- /** Springs the sheet back to its resting position after a drag that didn't dismiss. */
8702
- snapBack() {
8703
- this.sheetDragY = 0;
9085
+ this.sheetMedia = null;
9086
+ this.sheetMediaListener = null;
8704
9087
  }
8705
9088
  /** Attempts to dismiss the modal. Resolves true if it was actually dismissed,
8706
9089
  * false if blocked by a DISABLED close mode or a rejected close guard. */
@@ -8830,7 +9213,7 @@ class MnModalShellComponent {
8830
9213
  }
8831
9214
  }
8832
9215
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnModalShellComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
8833
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnModalShellComponent, isStandalone: true, selector: "mn-modal-shell", inputs: { config: "config", modalRef: "modalRef" }, host: { listeners: { "document:keydown.escape": "onEscapeKey($event)" }, properties: { "class": "this.hostClasses" } }, viewQueries: [{ propertyName: "wizardBody", first: true, predicate: MnWizardBodyComponent, descendants: true, isSignal: true }], ngImport: i0, template: "@if (showBackdrop) {\n <!-- The backdrop is a visual overlay \u2014 keyboard dismiss is handled at component level via Escape key -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div class=\"modal-backdrop absolute inset-0 bg-black/50 animate-[fadeIn_0.2s_ease-in-out]\" (click)=\"onBackdropClick()\"></div>\n}\n\n<!--\n The container deliberately does NOT stop click propagation. The backdrop is a\n *sibling* element (absolutely positioned behind this one), never an ancestor, so\n clicks in here can't reach `onBackdropClick()` anyway. Swallowing them instead\n broke every \"click outside me\" handler inside a modal \u2014 component-level\n `document:click` listeners (the multi-select panel, the table filter popover)\n never fired, so those overlays stayed open.\n-->\n<div\n class=\"modal-container relative bg-base-100 rounded-lg shadow-xl max-h-[90vh] overflow-hidden flex flex-col\"\n [class.sheet-dragging]=\"isDraggingSheet\"\n [ngClass]=\"containerSizeClass\"\n [style.height]=\"containerHeightStyle\"\n [style.transform]=\"sheetDragY ? 'translateY(' + sheetDragY + 'px)' : null\"\n role=\"dialog\"\n aria-modal=\"true\"\n [attr.aria-labelledby]=\"config.title ? 'mn-modal-title' : null\"\n [attr.aria-describedby]=\"config.description ? 'mn-modal-description' : null\"\n tabindex=\"-1\"\n>\n @if (isMobileSheet) {\n <!-- Drag handle for swipe-to-dismiss, visible only on mobile bottom sheets -->\n <div\n (pointercancel)=\"onSheetPointerUp()\"\n (pointerdown)=\"onSheetPointerDown($event)\"\n (pointermove)=\"onSheetPointerMove($event)\"\n (pointerup)=\"onSheetPointerUp()\"\n class=\"sm:hidden flex justify-center pt-2 pb-1 touch-none cursor-grab\"\n >\n <div class=\"h-1.5 w-10 rounded-full bg-base-300\"></div>\n </div>\n }\n\n <!-- On mobile the whole header doubles as a swipe-to-dismiss surface (drags that\n start on the close button are ignored). The grabber above is the visual cue. -->\n <div [class.border-b]=\"config.kind !== ModalKind.WIZARD\"\n [class.border-base-300]=\"config.kind !== ModalKind.WIZARD\"\n (pointercancel)=\"onSheetPointerUp()\"\n (pointerdown)=\"onSheetPointerDown($event)\"\n (pointermove)=\"onSheetPointerMove($event)\"\n (pointerup)=\"onSheetPointerUp()\"\n [class.cursor-grab]=\"isMobileSheet\"\n [class.sm:cursor-auto]=\"isMobileSheet\"\n [class.sm:touch-auto]=\"isMobileSheet\"\n [class.touch-none]=\"isMobileSheet\"\n class=\"flex items-center justify-between p-6\">\n <div class=\"flex flex-col gap-0.5\">\n @if (config.title) {\n <!-- On small screens the wizard's step labels (under the progress circles) are hidden,\n so surface the active step name here as \"Title - Step\" instead. -->\n <h2 class=\"m-0 text-xl font-semibold text-base-content\"\n id=\"mn-modal-title\">{{ config.title }}@if (config.kind === ModalKind.WIZARD && wizardStepTitle()) {\n <span class=\"sm:hidden font-normal text-base-content/60\"> - {{ wizardStepTitle() }}</span>\n }</h2>\n }\n @if (config.subtitle) {\n <p class=\"m-0 text-sm text-base-content/60 font-normal\">{{ config.subtitle }}</p>\n }\n </div>\n @if (showCloseButton) {\n <div [class]=\"isMobileSheet ? 'hidden sm:flex' : ''\">\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'gray', hover: true, borderRadius: 'md' }\"\n type=\"button\"\n class=\"w-8 h-8\"\n (click)=\"onCloseButtonClick()\"\n [attr.aria-label]=\"closeModalLabel\"\n >\n <svg lucideX [size]=\"18\"></svg>\n </button>\n </div>\n }\n </div>\n @if (config.description) {\n <p class=\"m-0 px-6 text-sm text-base-content/60 leading-relaxed\" id=\"mn-modal-description\">{{ config.description }}</p>\n }\n\n @if (config.kind === ModalKind.WIZARD) {\n <!-- Wizard manages its own internal scrolling so the steps header and footer\n stay fixed while only the step body scrolls. No padding/scroll here. -->\n <div class=\"flex-auto min-h-0 overflow-hidden flex flex-col\">\n <mn-wizard-body\n [config]=\"asWizard(config)\"\n [modalRef]=\"$any(modalRef)\"\n class=\"flex-auto min-h-0 flex flex-col\"\n ></mn-wizard-body>\n </div>\n } @else {\n <div class=\"flex-1 overflow-y-auto px-6 pt-6\">\n @if (config.kind === ModalKind.FORM) {\n <mn-form-body\n [config]=\"asForm(config)\"\n [modalRef]=\"$any(modalRef)\"\n class=\"block h-full\"\n ></mn-form-body>\n }\n\n @if (config.kind === ModalKind.CONFIRMATION) {\n <mn-confirmation-body\n [config]=\"asConfirmation(config)\"\n [modalRef]=\"$any(modalRef)\"\n ></mn-confirmation-body>\n }\n\n @if (config.kind === ModalKind.CUSTOM) {\n <mn-custom-body-host\n [config]=\"asCustom(config)\"\n [modalRef]=\"$any(modalRef)\"\n class=\"block pb-6\"\n ></mn-custom-body-host>\n }\n </div>\n }\n\n <!-- Custom Footer Actions (not for wizard modals, they render their own) -->\n @if (hasCustomFooterActions && config.kind !== ModalKind.WIZARD) {\n <div class=\"flex gap-3 p-6 border-t border-base-300\">\n <mn-footer-actions\n [actions]=\"config.footerActions || []\"\n [showIcons]=\"config.showActionIcons !== false\"\n (actionClick)=\"onFooterAction($event)\"\n ></mn-footer-actions>\n </div>\n }\n</div>\n", styles: [":host{--mn-sheet-ease: cubic-bezier(.32, .72, 0, 1);position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;transition:transform .3s ease-in-out,filter .3s ease-in-out,opacity .3s ease-in-out}:host(.is-stacked){transform:scale(.96) translateY(-1rem);filter:brightness(.9) blur(1px);pointer-events:none;opacity:.8}.modal-container{transition:transform .35s var(--mn-sheet-ease),min-height .25s var(--mn-sheet-ease)}.modal-container.sheet-dragging{transition:none}:host(.swipe-dismissing) .modal-container,:host(.swipe-dismissing).closing .modal-container{animation:none!important;transition:transform .3s var(--mn-sheet-ease)}@keyframes fadeIn{0%{opacity:0}to{opacity:1}}@keyframes slideIn{0%{opacity:0;transform:translateY(-1rem)}to{opacity:1;transform:translateY(0)}}@keyframes zoomIn{0%{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}@keyframes fadeOut{0%{opacity:1}to{opacity:0}}@keyframes slideOut{0%{opacity:1;transform:translateY(0)}to{opacity:0;transform:translateY(1rem)}}@keyframes zoomOut{0%{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.95)}}@keyframes slideUpIn{0%{opacity:0;transform:translateY(100%)}to{opacity:1;transform:translateY(0)}}:host(.anim-slide) .modal-container{animation:slideIn .2s ease-in-out}:host(.anim-fade) .modal-container{animation:fadeIn .2s ease-in-out}:host(.anim-zoom) .modal-container{animation:zoomIn .2s ease-in-out}:host(.closing) .modal-backdrop{animation:fadeOut .15s ease-in-out forwards}:host(.closing).anim-slide .modal-container{animation:slideOut .15s ease-in-out forwards}:host(.closing).anim-fade .modal-container{animation:fadeOut .15s ease-in-out forwards}:host(.closing).anim-zoom .modal-container{animation:zoomOut .15s ease-in-out forwards}@media(max-width:639.98px){:host(.mobile-sheet){align-items:flex-end}:host(.mobile-sheet) .modal-container{width:100%;max-width:100%;min-height:0;max-height:92vh;padding-bottom:env(safe-area-inset-bottom);border-radius:1rem 1rem 0 0;animation:slideUpIn .45s var(--mn-sheet-ease)}:host-context(.mn-keyboard-open).mobile-sheet .modal-container{min-height:92vh}:host(.mobile-sheet).anim-slide .modal-container,:host(.mobile-sheet).anim-fade .modal-container,:host(.mobile-sheet).anim-zoom .modal-container{animation:slideUpIn .45s var(--mn-sheet-ease)}:host(.mobile-sheet).closing .modal-container,:host(.mobile-sheet).closing.anim-slide .modal-container,:host(.mobile-sheet).closing.anim-fade .modal-container,:host(.mobile-sheet).closing.anim-zoom .modal-container{animation:none!important;transform:translateY(100%);opacity:0;transition:transform .45s var(--mn-sheet-ease),opacity .45s var(--mn-sheet-ease)}}@media(prefers-reduced-motion:reduce){:host,:host .modal-backdrop,:host .modal-container{animation-duration:.01ms!important;animation-delay:0ms!important;transition-duration:.01ms!important}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "component", type: MnWizardBodyComponent, selector: "mn-wizard-body", inputs: ["config", "modalRef"] }, { kind: "component", type: MnFormBodyComponent, selector: "mn-form-body", inputs: ["config", "modalRef", "hideFooter", "hideCustomBody"], outputs: ["formStatusChange"] }, { kind: "component", type: MnConfirmationBodyComponent, selector: "mn-confirmation-body", inputs: ["config", "modalRef"] }, { kind: "component", type: MnCustomBodyHostComponent, selector: "mn-custom-body-host", inputs: ["config", "modalRef"] }, { kind: "component", type: MnFooterActionsComponent, selector: "mn-footer-actions", inputs: ["actions", "showIcons"], outputs: ["actionClick"] }, { kind: "component", type: MnButton, selector: "button[mnButton], a[mnButton]", inputs: ["data"] }, { kind: "component", type: LucideX, selector: "svg[lucideX]" }] });
9216
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnModalShellComponent, isStandalone: true, selector: "mn-modal-shell", inputs: { config: "config", modalRef: "modalRef" }, host: { listeners: { "document:keydown.escape": "onEscapeKey($event)" }, properties: { "class": "this.hostClasses" } }, viewQueries: [{ propertyName: "wizardBody", first: true, predicate: MnWizardBodyComponent, descendants: true, isSignal: true }, { propertyName: "bottomSheet", first: true, predicate: MnBottomSheet, descendants: true, isSignal: true }], ngImport: i0, template: "@if (showBackdrop) {\n <!-- The backdrop is a visual overlay \u2014 keyboard dismiss is handled at component level via Escape key -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div class=\"modal-backdrop absolute inset-0 bg-black/50 animate-[fadeIn_0.2s_ease-in-out]\" (click)=\"onBackdropClick()\"></div>\n}\n\n@if (showMobileSheet) {\n <!-- Mobile: the shared bottom sheet owns the anchoring, grabber, swipe/flick-to-dismiss\n and slide animation. The shell keeps its own backdrop (so the sheet renders none),\n and its close animation delegates to the sheet's startClosing(). `modal-container`\n is kept as a class hook so the host app's keyboard-lift CSS still matches. -->\n <mn-bottom-sheet\n (dismiss)=\"onSheetDismiss()\"\n [ariaLabelledby]=\"config.title ? 'mn-modal-title' : undefined\"\n [containerClass]=\"'modal-container'\"\n [dismissGuard]=\"sheetDismissGuard\"\n [dismissible]=\"canClose\"\n [growWithKeyboard]=\"true\"\n [maxHeightVh]=\"92\"\n [showBackdrop]=\"false\"\n >\n <ng-container [ngTemplateOutlet]=\"modalBody\"></ng-container>\n </mn-bottom-sheet>\n} @else {\n <!--\n Desktop centered dialog. The container deliberately does NOT stop click propagation.\n The backdrop is a *sibling* element (absolutely positioned behind this one), never an\n ancestor, so clicks in here can't reach `onBackdropClick()` anyway. Swallowing them\n instead broke every \"click outside me\" handler inside a modal.\n -->\n <div\n [attr.aria-describedby]=\"config.description ? 'mn-modal-description' : null\"\n [attr.aria-labelledby]=\"config.title ? 'mn-modal-title' : null\"\n [ngClass]=\"containerSizeClass\"\n [style.height]=\"containerHeightStyle\"\n aria-modal=\"true\"\n class=\"modal-container relative bg-base-100 rounded-lg shadow-xl max-h-[90vh] overflow-hidden flex flex-col\"\n role=\"dialog\"\n tabindex=\"-1\"\n >\n <ng-container [ngTemplateOutlet]=\"modalBody\"></ng-container>\n </div>\n}\n\n<ng-template #modalBody>\n <div [class.border-b]=\"config.kind !== ModalKind.WIZARD\"\n [class.border-base-300]=\"config.kind !== ModalKind.WIZARD\"\n class=\"flex items-center justify-between p-6\">\n <div class=\"flex flex-col gap-0.5\">\n @if (config.title) {\n <!-- On small screens the wizard's step labels (under the progress circles) are hidden,\n so surface the active step name here as \"Title - Step\" instead. -->\n <h2 class=\"m-0 text-xl font-semibold text-base-content\"\n id=\"mn-modal-title\">{{ config.title }}@if (config.kind === ModalKind.WIZARD && wizardStepTitle()) {\n <span class=\"sm:hidden font-normal text-base-content/60\"> - {{ wizardStepTitle() }}</span>\n }</h2>\n }\n @if (config.subtitle) {\n <p class=\"m-0 text-sm text-base-content/60 font-normal\">{{ config.subtitle }}</p>\n }\n </div>\n @if (showCloseButton) {\n <div [class]=\"showMobileSheet ? 'hidden sm:flex' : ''\">\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'gray', hover: true, borderRadius: 'md' }\"\n type=\"button\"\n class=\"w-8 h-8\"\n (click)=\"onCloseButtonClick()\"\n [attr.aria-label]=\"closeModalLabel\"\n >\n <svg lucideX [size]=\"18\"></svg>\n </button>\n </div>\n }\n </div>\n @if (config.description) {\n <p class=\"m-0 px-6 text-sm text-base-content/60 leading-relaxed\" id=\"mn-modal-description\">{{ config.description }}</p>\n }\n\n @if (config.kind === ModalKind.WIZARD) {\n <!-- Wizard manages its own internal scrolling so the steps header and footer\n stay fixed while only the step body scrolls. No padding/scroll here. -->\n <div class=\"flex-auto min-h-0 overflow-hidden flex flex-col\">\n <mn-wizard-body\n [config]=\"asWizard(config)\"\n [modalRef]=\"$any(modalRef)\"\n class=\"flex-auto min-h-0 flex flex-col\"\n ></mn-wizard-body>\n </div>\n } @else {\n <div class=\"flex-1 overflow-y-auto px-6 pt-6\">\n @if (config.kind === ModalKind.FORM) {\n <mn-form-body\n [config]=\"asForm(config)\"\n [modalRef]=\"$any(modalRef)\"\n class=\"block h-full\"\n ></mn-form-body>\n }\n\n @if (config.kind === ModalKind.CONFIRMATION) {\n <mn-confirmation-body\n [config]=\"asConfirmation(config)\"\n [modalRef]=\"$any(modalRef)\"\n ></mn-confirmation-body>\n }\n\n @if (config.kind === ModalKind.CUSTOM) {\n <mn-custom-body-host\n [config]=\"asCustom(config)\"\n [modalRef]=\"$any(modalRef)\"\n class=\"block pb-6\"\n ></mn-custom-body-host>\n }\n </div>\n }\n\n <!-- Custom Footer Actions (not for wizard modals, they render their own) -->\n @if (hasCustomFooterActions && config.kind !== ModalKind.WIZARD) {\n <div class=\"flex gap-3 p-6 border-t border-base-300\">\n <mn-footer-actions\n [actions]=\"config.footerActions || []\"\n [showIcons]=\"config.showActionIcons !== false\"\n (actionClick)=\"onFooterAction($event)\"\n ></mn-footer-actions>\n </div>\n }\n</ng-template>\n", styles: [":host{position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;transition:transform .3s ease-in-out,filter .3s ease-in-out,opacity .3s ease-in-out}:host(.mobile-sheet){align-items:flex-end}:host(.is-stacked){transform:scale(.96) translateY(-1rem);filter:brightness(.9) blur(1px);pointer-events:none;opacity:.8}@keyframes fadeIn{0%{opacity:0}to{opacity:1}}@keyframes slideIn{0%{opacity:0;transform:translateY(-1rem)}to{opacity:1;transform:translateY(0)}}@keyframes zoomIn{0%{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}@keyframes fadeOut{0%{opacity:1}to{opacity:0}}@keyframes slideOut{0%{opacity:1;transform:translateY(0)}to{opacity:0;transform:translateY(1rem)}}@keyframes zoomOut{0%{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.95)}}:host(.anim-slide) .modal-container{animation:slideIn .2s ease-in-out}:host(.anim-fade) .modal-container{animation:fadeIn .2s ease-in-out}:host(.anim-zoom) .modal-container{animation:zoomIn .2s ease-in-out}:host(.closing) .modal-backdrop{animation:fadeOut .15s ease-in-out forwards}:host(.closing).anim-slide .modal-container{animation:slideOut .15s ease-in-out forwards}:host(.closing).anim-fade .modal-container{animation:fadeOut .15s ease-in-out forwards}:host(.closing).anim-zoom .modal-container{animation:zoomOut .15s ease-in-out forwards}@media(prefers-reduced-motion:reduce){:host,:host .modal-backdrop,:host .modal-container{animation-duration:.01ms!important;animation-delay:0ms!important;transition-duration:.01ms!important}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: MnWizardBodyComponent, selector: "mn-wizard-body", inputs: ["config", "modalRef"] }, { kind: "component", type: MnFormBodyComponent, selector: "mn-form-body", inputs: ["config", "modalRef", "hideFooter", "hideCustomBody"], outputs: ["formStatusChange"] }, { kind: "component", type: MnConfirmationBodyComponent, selector: "mn-confirmation-body", inputs: ["config", "modalRef"] }, { kind: "component", type: MnCustomBodyHostComponent, selector: "mn-custom-body-host", inputs: ["config", "modalRef"] }, { kind: "component", type: MnFooterActionsComponent, selector: "mn-footer-actions", inputs: ["actions", "showIcons"], outputs: ["actionClick"] }, { kind: "component", type: MnButton, selector: "button[mnButton], a[mnButton]", inputs: ["data"] }, { kind: "component", type: MnBottomSheet, selector: "mn-bottom-sheet", inputs: ["showBackdrop", "showGrabber", "dismissible", "minHeightPx", "maxHeightVh", "containerClass", "ariaLabel", "ariaLabelledby", "growWithKeyboard", "dismissGuard"], outputs: ["dismiss"] }, { kind: "component", type: LucideX, selector: "svg[lucideX]" }] });
8834
9217
  }
8835
9218
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnModalShellComponent, decorators: [{
8836
9219
  type: Component,
@@ -8842,18 +9225,19 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImpor
8842
9225
  MnCustomBodyHostComponent,
8843
9226
  MnFooterActionsComponent,
8844
9227
  MnButton,
9228
+ MnBottomSheet,
8845
9229
  LucideX,
8846
- ], template: "@if (showBackdrop) {\n <!-- The backdrop is a visual overlay \u2014 keyboard dismiss is handled at component level via Escape key -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div class=\"modal-backdrop absolute inset-0 bg-black/50 animate-[fadeIn_0.2s_ease-in-out]\" (click)=\"onBackdropClick()\"></div>\n}\n\n<!--\n The container deliberately does NOT stop click propagation. The backdrop is a\n *sibling* element (absolutely positioned behind this one), never an ancestor, so\n clicks in here can't reach `onBackdropClick()` anyway. Swallowing them instead\n broke every \"click outside me\" handler inside a modal \u2014 component-level\n `document:click` listeners (the multi-select panel, the table filter popover)\n never fired, so those overlays stayed open.\n-->\n<div\n class=\"modal-container relative bg-base-100 rounded-lg shadow-xl max-h-[90vh] overflow-hidden flex flex-col\"\n [class.sheet-dragging]=\"isDraggingSheet\"\n [ngClass]=\"containerSizeClass\"\n [style.height]=\"containerHeightStyle\"\n [style.transform]=\"sheetDragY ? 'translateY(' + sheetDragY + 'px)' : null\"\n role=\"dialog\"\n aria-modal=\"true\"\n [attr.aria-labelledby]=\"config.title ? 'mn-modal-title' : null\"\n [attr.aria-describedby]=\"config.description ? 'mn-modal-description' : null\"\n tabindex=\"-1\"\n>\n @if (isMobileSheet) {\n <!-- Drag handle for swipe-to-dismiss, visible only on mobile bottom sheets -->\n <div\n (pointercancel)=\"onSheetPointerUp()\"\n (pointerdown)=\"onSheetPointerDown($event)\"\n (pointermove)=\"onSheetPointerMove($event)\"\n (pointerup)=\"onSheetPointerUp()\"\n class=\"sm:hidden flex justify-center pt-2 pb-1 touch-none cursor-grab\"\n >\n <div class=\"h-1.5 w-10 rounded-full bg-base-300\"></div>\n </div>\n }\n\n <!-- On mobile the whole header doubles as a swipe-to-dismiss surface (drags that\n start on the close button are ignored). The grabber above is the visual cue. -->\n <div [class.border-b]=\"config.kind !== ModalKind.WIZARD\"\n [class.border-base-300]=\"config.kind !== ModalKind.WIZARD\"\n (pointercancel)=\"onSheetPointerUp()\"\n (pointerdown)=\"onSheetPointerDown($event)\"\n (pointermove)=\"onSheetPointerMove($event)\"\n (pointerup)=\"onSheetPointerUp()\"\n [class.cursor-grab]=\"isMobileSheet\"\n [class.sm:cursor-auto]=\"isMobileSheet\"\n [class.sm:touch-auto]=\"isMobileSheet\"\n [class.touch-none]=\"isMobileSheet\"\n class=\"flex items-center justify-between p-6\">\n <div class=\"flex flex-col gap-0.5\">\n @if (config.title) {\n <!-- On small screens the wizard's step labels (under the progress circles) are hidden,\n so surface the active step name here as \"Title - Step\" instead. -->\n <h2 class=\"m-0 text-xl font-semibold text-base-content\"\n id=\"mn-modal-title\">{{ config.title }}@if (config.kind === ModalKind.WIZARD && wizardStepTitle()) {\n <span class=\"sm:hidden font-normal text-base-content/60\"> - {{ wizardStepTitle() }}</span>\n }</h2>\n }\n @if (config.subtitle) {\n <p class=\"m-0 text-sm text-base-content/60 font-normal\">{{ config.subtitle }}</p>\n }\n </div>\n @if (showCloseButton) {\n <div [class]=\"isMobileSheet ? 'hidden sm:flex' : ''\">\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'gray', hover: true, borderRadius: 'md' }\"\n type=\"button\"\n class=\"w-8 h-8\"\n (click)=\"onCloseButtonClick()\"\n [attr.aria-label]=\"closeModalLabel\"\n >\n <svg lucideX [size]=\"18\"></svg>\n </button>\n </div>\n }\n </div>\n @if (config.description) {\n <p class=\"m-0 px-6 text-sm text-base-content/60 leading-relaxed\" id=\"mn-modal-description\">{{ config.description }}</p>\n }\n\n @if (config.kind === ModalKind.WIZARD) {\n <!-- Wizard manages its own internal scrolling so the steps header and footer\n stay fixed while only the step body scrolls. No padding/scroll here. -->\n <div class=\"flex-auto min-h-0 overflow-hidden flex flex-col\">\n <mn-wizard-body\n [config]=\"asWizard(config)\"\n [modalRef]=\"$any(modalRef)\"\n class=\"flex-auto min-h-0 flex flex-col\"\n ></mn-wizard-body>\n </div>\n } @else {\n <div class=\"flex-1 overflow-y-auto px-6 pt-6\">\n @if (config.kind === ModalKind.FORM) {\n <mn-form-body\n [config]=\"asForm(config)\"\n [modalRef]=\"$any(modalRef)\"\n class=\"block h-full\"\n ></mn-form-body>\n }\n\n @if (config.kind === ModalKind.CONFIRMATION) {\n <mn-confirmation-body\n [config]=\"asConfirmation(config)\"\n [modalRef]=\"$any(modalRef)\"\n ></mn-confirmation-body>\n }\n\n @if (config.kind === ModalKind.CUSTOM) {\n <mn-custom-body-host\n [config]=\"asCustom(config)\"\n [modalRef]=\"$any(modalRef)\"\n class=\"block pb-6\"\n ></mn-custom-body-host>\n }\n </div>\n }\n\n <!-- Custom Footer Actions (not for wizard modals, they render their own) -->\n @if (hasCustomFooterActions && config.kind !== ModalKind.WIZARD) {\n <div class=\"flex gap-3 p-6 border-t border-base-300\">\n <mn-footer-actions\n [actions]=\"config.footerActions || []\"\n [showIcons]=\"config.showActionIcons !== false\"\n (actionClick)=\"onFooterAction($event)\"\n ></mn-footer-actions>\n </div>\n }\n</div>\n", styles: [":host{--mn-sheet-ease: cubic-bezier(.32, .72, 0, 1);position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;transition:transform .3s ease-in-out,filter .3s ease-in-out,opacity .3s ease-in-out}:host(.is-stacked){transform:scale(.96) translateY(-1rem);filter:brightness(.9) blur(1px);pointer-events:none;opacity:.8}.modal-container{transition:transform .35s var(--mn-sheet-ease),min-height .25s var(--mn-sheet-ease)}.modal-container.sheet-dragging{transition:none}:host(.swipe-dismissing) .modal-container,:host(.swipe-dismissing).closing .modal-container{animation:none!important;transition:transform .3s var(--mn-sheet-ease)}@keyframes fadeIn{0%{opacity:0}to{opacity:1}}@keyframes slideIn{0%{opacity:0;transform:translateY(-1rem)}to{opacity:1;transform:translateY(0)}}@keyframes zoomIn{0%{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}@keyframes fadeOut{0%{opacity:1}to{opacity:0}}@keyframes slideOut{0%{opacity:1;transform:translateY(0)}to{opacity:0;transform:translateY(1rem)}}@keyframes zoomOut{0%{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.95)}}@keyframes slideUpIn{0%{opacity:0;transform:translateY(100%)}to{opacity:1;transform:translateY(0)}}:host(.anim-slide) .modal-container{animation:slideIn .2s ease-in-out}:host(.anim-fade) .modal-container{animation:fadeIn .2s ease-in-out}:host(.anim-zoom) .modal-container{animation:zoomIn .2s ease-in-out}:host(.closing) .modal-backdrop{animation:fadeOut .15s ease-in-out forwards}:host(.closing).anim-slide .modal-container{animation:slideOut .15s ease-in-out forwards}:host(.closing).anim-fade .modal-container{animation:fadeOut .15s ease-in-out forwards}:host(.closing).anim-zoom .modal-container{animation:zoomOut .15s ease-in-out forwards}@media(max-width:639.98px){:host(.mobile-sheet){align-items:flex-end}:host(.mobile-sheet) .modal-container{width:100%;max-width:100%;min-height:0;max-height:92vh;padding-bottom:env(safe-area-inset-bottom);border-radius:1rem 1rem 0 0;animation:slideUpIn .45s var(--mn-sheet-ease)}:host-context(.mn-keyboard-open).mobile-sheet .modal-container{min-height:92vh}:host(.mobile-sheet).anim-slide .modal-container,:host(.mobile-sheet).anim-fade .modal-container,:host(.mobile-sheet).anim-zoom .modal-container{animation:slideUpIn .45s var(--mn-sheet-ease)}:host(.mobile-sheet).closing .modal-container,:host(.mobile-sheet).closing.anim-slide .modal-container,:host(.mobile-sheet).closing.anim-fade .modal-container,:host(.mobile-sheet).closing.anim-zoom .modal-container{animation:none!important;transform:translateY(100%);opacity:0;transition:transform .45s var(--mn-sheet-ease),opacity .45s var(--mn-sheet-ease)}}@media(prefers-reduced-motion:reduce){:host,:host .modal-backdrop,:host .modal-container{animation-duration:.01ms!important;animation-delay:0ms!important;transition-duration:.01ms!important}}\n"] }]
9230
+ ], template: "@if (showBackdrop) {\n <!-- The backdrop is a visual overlay \u2014 keyboard dismiss is handled at component level via Escape key -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div class=\"modal-backdrop absolute inset-0 bg-black/50 animate-[fadeIn_0.2s_ease-in-out]\" (click)=\"onBackdropClick()\"></div>\n}\n\n@if (showMobileSheet) {\n <!-- Mobile: the shared bottom sheet owns the anchoring, grabber, swipe/flick-to-dismiss\n and slide animation. The shell keeps its own backdrop (so the sheet renders none),\n and its close animation delegates to the sheet's startClosing(). `modal-container`\n is kept as a class hook so the host app's keyboard-lift CSS still matches. -->\n <mn-bottom-sheet\n (dismiss)=\"onSheetDismiss()\"\n [ariaLabelledby]=\"config.title ? 'mn-modal-title' : undefined\"\n [containerClass]=\"'modal-container'\"\n [dismissGuard]=\"sheetDismissGuard\"\n [dismissible]=\"canClose\"\n [growWithKeyboard]=\"true\"\n [maxHeightVh]=\"92\"\n [showBackdrop]=\"false\"\n >\n <ng-container [ngTemplateOutlet]=\"modalBody\"></ng-container>\n </mn-bottom-sheet>\n} @else {\n <!--\n Desktop centered dialog. The container deliberately does NOT stop click propagation.\n The backdrop is a *sibling* element (absolutely positioned behind this one), never an\n ancestor, so clicks in here can't reach `onBackdropClick()` anyway. Swallowing them\n instead broke every \"click outside me\" handler inside a modal.\n -->\n <div\n [attr.aria-describedby]=\"config.description ? 'mn-modal-description' : null\"\n [attr.aria-labelledby]=\"config.title ? 'mn-modal-title' : null\"\n [ngClass]=\"containerSizeClass\"\n [style.height]=\"containerHeightStyle\"\n aria-modal=\"true\"\n class=\"modal-container relative bg-base-100 rounded-lg shadow-xl max-h-[90vh] overflow-hidden flex flex-col\"\n role=\"dialog\"\n tabindex=\"-1\"\n >\n <ng-container [ngTemplateOutlet]=\"modalBody\"></ng-container>\n </div>\n}\n\n<ng-template #modalBody>\n <div [class.border-b]=\"config.kind !== ModalKind.WIZARD\"\n [class.border-base-300]=\"config.kind !== ModalKind.WIZARD\"\n class=\"flex items-center justify-between p-6\">\n <div class=\"flex flex-col gap-0.5\">\n @if (config.title) {\n <!-- On small screens the wizard's step labels (under the progress circles) are hidden,\n so surface the active step name here as \"Title - Step\" instead. -->\n <h2 class=\"m-0 text-xl font-semibold text-base-content\"\n id=\"mn-modal-title\">{{ config.title }}@if (config.kind === ModalKind.WIZARD && wizardStepTitle()) {\n <span class=\"sm:hidden font-normal text-base-content/60\"> - {{ wizardStepTitle() }}</span>\n }</h2>\n }\n @if (config.subtitle) {\n <p class=\"m-0 text-sm text-base-content/60 font-normal\">{{ config.subtitle }}</p>\n }\n </div>\n @if (showCloseButton) {\n <div [class]=\"showMobileSheet ? 'hidden sm:flex' : ''\">\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'gray', hover: true, borderRadius: 'md' }\"\n type=\"button\"\n class=\"w-8 h-8\"\n (click)=\"onCloseButtonClick()\"\n [attr.aria-label]=\"closeModalLabel\"\n >\n <svg lucideX [size]=\"18\"></svg>\n </button>\n </div>\n }\n </div>\n @if (config.description) {\n <p class=\"m-0 px-6 text-sm text-base-content/60 leading-relaxed\" id=\"mn-modal-description\">{{ config.description }}</p>\n }\n\n @if (config.kind === ModalKind.WIZARD) {\n <!-- Wizard manages its own internal scrolling so the steps header and footer\n stay fixed while only the step body scrolls. No padding/scroll here. -->\n <div class=\"flex-auto min-h-0 overflow-hidden flex flex-col\">\n <mn-wizard-body\n [config]=\"asWizard(config)\"\n [modalRef]=\"$any(modalRef)\"\n class=\"flex-auto min-h-0 flex flex-col\"\n ></mn-wizard-body>\n </div>\n } @else {\n <div class=\"flex-1 overflow-y-auto px-6 pt-6\">\n @if (config.kind === ModalKind.FORM) {\n <mn-form-body\n [config]=\"asForm(config)\"\n [modalRef]=\"$any(modalRef)\"\n class=\"block h-full\"\n ></mn-form-body>\n }\n\n @if (config.kind === ModalKind.CONFIRMATION) {\n <mn-confirmation-body\n [config]=\"asConfirmation(config)\"\n [modalRef]=\"$any(modalRef)\"\n ></mn-confirmation-body>\n }\n\n @if (config.kind === ModalKind.CUSTOM) {\n <mn-custom-body-host\n [config]=\"asCustom(config)\"\n [modalRef]=\"$any(modalRef)\"\n class=\"block pb-6\"\n ></mn-custom-body-host>\n }\n </div>\n }\n\n <!-- Custom Footer Actions (not for wizard modals, they render their own) -->\n @if (hasCustomFooterActions && config.kind !== ModalKind.WIZARD) {\n <div class=\"flex gap-3 p-6 border-t border-base-300\">\n <mn-footer-actions\n [actions]=\"config.footerActions || []\"\n [showIcons]=\"config.showActionIcons !== false\"\n (actionClick)=\"onFooterAction($event)\"\n ></mn-footer-actions>\n </div>\n }\n</ng-template>\n", styles: [":host{position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;transition:transform .3s ease-in-out,filter .3s ease-in-out,opacity .3s ease-in-out}:host(.mobile-sheet){align-items:flex-end}:host(.is-stacked){transform:scale(.96) translateY(-1rem);filter:brightness(.9) blur(1px);pointer-events:none;opacity:.8}@keyframes fadeIn{0%{opacity:0}to{opacity:1}}@keyframes slideIn{0%{opacity:0;transform:translateY(-1rem)}to{opacity:1;transform:translateY(0)}}@keyframes zoomIn{0%{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}@keyframes fadeOut{0%{opacity:1}to{opacity:0}}@keyframes slideOut{0%{opacity:1;transform:translateY(0)}to{opacity:0;transform:translateY(1rem)}}@keyframes zoomOut{0%{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.95)}}:host(.anim-slide) .modal-container{animation:slideIn .2s ease-in-out}:host(.anim-fade) .modal-container{animation:fadeIn .2s ease-in-out}:host(.anim-zoom) .modal-container{animation:zoomIn .2s ease-in-out}:host(.closing) .modal-backdrop{animation:fadeOut .15s ease-in-out forwards}:host(.closing).anim-slide .modal-container{animation:slideOut .15s ease-in-out forwards}:host(.closing).anim-fade .modal-container{animation:fadeOut .15s ease-in-out forwards}:host(.closing).anim-zoom .modal-container{animation:zoomOut .15s ease-in-out forwards}@media(prefers-reduced-motion:reduce){:host,:host .modal-backdrop,:host .modal-container{animation-duration:.01ms!important;animation-delay:0ms!important;transition-duration:.01ms!important}}\n"] }]
8847
9231
  }], propDecorators: { config: [{
8848
9232
  type: Input
8849
9233
  }], modalRef: [{
8850
9234
  type: Input
8851
- }], wizardBody: [{ type: i0.ViewChild, args: [i0.forwardRef(() => MnWizardBodyComponent), { isSignal: true }] }], onEscapeKey: [{
8852
- type: HostListener,
8853
- args: ['document:keydown.escape', ['$event']]
8854
- }], hostClasses: [{
9235
+ }], wizardBody: [{ type: i0.ViewChild, args: [i0.forwardRef(() => MnWizardBodyComponent), { isSignal: true }] }], bottomSheet: [{ type: i0.ViewChild, args: [i0.forwardRef(() => MnBottomSheet), { isSignal: true }] }], hostClasses: [{
8855
9236
  type: HostBinding,
8856
9237
  args: ['class']
9238
+ }], onEscapeKey: [{
9239
+ type: HostListener,
9240
+ args: ['document:keydown.escape', ['$event']]
8857
9241
  }] } });
8858
9242
 
8859
9243
  class MnModalService {
@@ -11685,5 +12069,5 @@ function enableMnPreviewMode(configService, langService, allowedOrigins) {
11685
12069
  * Generated bundle index. Do not edit.
11686
12070
  */
11687
12071
 
11688
- export { API_BASE_URL, ActionStyle, BackdropMode, BaseModalBuilder, CALENDAR_CONFIG, CALENDAR_DATE_FORMATTER, CalendarDayComponent, CalendarEventComponent, CalendarEventDefaultComponent, CalendarEventLayoutService, CalendarMonthComponent, CalendarUtility, CalendarView, CalendarViewComponent, CalendarWeekComponent, CloseMode, ColumnSortType, ConfirmationModalBuilder, ConfirmationTone, CrudService, CustomModalBuilder, DEFAULT_CALENDAR_CONFIG, DEFAULT_MN_ALERT_CONFIG, DefaultCalendarDateFormatter, FieldAppearance, FieldKind, FormLayoutMode, FormModalBuilder, KeyboardMode, MN_ALERT_CONFIG, MN_CALENDAR_COMPONENT_NAME, MN_CALENDAR_CONFIG, MN_CHECKBOX_CONFIG, MN_DATETIME_CONFIG, MN_HAPTICS, MN_ICON_MAP, MN_INPUT_FIELD_CONFIG, MN_INSTANCE_ID, MN_LIB_DUAL_HORIZONTAL_IMAGE, MN_MODAL_ACTION_ICONS, MN_MULTI_SELECT_CONFIG, MN_SECTION_PATH, MN_SELECT_CONFIG, MN_TEXTAREA_CONFIG, MODAL_ACTION_ICON_SIZE, MODAL_ACTION_ICON_SIZE_SM, MnAlertOutletComponent, MnAlertService, MnAlertStore, MnBadge, MnButton, MnCheckbox, MnCollectionBase, MnCollectionPagination, MnCollectionState, MnConfigService, MnConfirmationBodyComponent, MnCustomBodyHostComponent, MnDateSelectorBar, MnDatetime, MnDualHorizontalImage, MnFileInput, MnFormBodyComponent, MnGrid, MnHiddenBelowDirective, MnHttpService, MnIcon, MnIconAttributes, MnInformationCard, MnInputField, MnInstanceDirective, MnLanguageService, MnList, MnModalRef, MnModalService, MnModalShellComponent, MnMultiSelect, MnRichTextEditor, MnSectionDirective, MnSelect, MnSelectableCollectionBase, MnShowAboveDirective, MnShowBelowDirective, MnSkeleton, MnTabComponent, MnTable, MnTextarea, MnTranslatePipe, MnWizardBodyComponent, ModalBuilder, ModalCloseReason, ModalIntent, ModalKind, ModalSize, NavigationDirection, OptionState, SelectionMode, StepBuilder, StepState, SubmitMode, UpcomingEventRowComponent, UpcomingEventsComponent, ValidationCode, ValidationStatus, WizardFlowMode, WizardModalBuilder, dateTimeAdapter, defaultFilterPredicate, defaultIconForStyle, defaultTextAdapter, emptyFilterValue, enableMnPreviewMode, isFilterValueActive, isTranslatable, matchesColumnFilter, mnAlertVariants, mnBadgeVariants, mnButtonVariants, mnCheckboxVariants, mnCheckboxWrapperVariants, mnDatetimeVariants, mnFileInputVariants, mnIconVariants, mnInformationCardVariants, mnInputFieldVariants, mnMultiSelectVariants, mnSelectVariants, mnSkeletonVariants, mnTextareaVariants, numberAdapter, pickAdapter, provideMnAlerts, provideMnCalendarConfig, provideMnComponentConfig, provideMnConfig, provideMnLanguage, resolveCalendarConfig, resolveFilterableValue };
12072
+ export { API_BASE_URL, ActionStyle, BackdropMode, BaseModalBuilder, CALENDAR_CONFIG, CALENDAR_DATE_FORMATTER, CalendarDayComponent, CalendarEventComponent, CalendarEventDefaultComponent, CalendarEventLayoutService, CalendarMonthComponent, CalendarUtility, CalendarView, CalendarViewComponent, CalendarWeekComponent, CloseMode, ColumnSortType, ConfirmationModalBuilder, ConfirmationTone, CrudService, CustomModalBuilder, DEFAULT_CALENDAR_CONFIG, DEFAULT_MN_ALERT_CONFIG, DefaultCalendarDateFormatter, FieldAppearance, FieldKind, FormLayoutMode, FormModalBuilder, KeyboardMode, MN_ALERT_CONFIG, MN_CALENDAR_COMPONENT_NAME, MN_CALENDAR_CONFIG, MN_CHECKBOX_CONFIG, MN_DATETIME_CONFIG, MN_HAPTICS, MN_ICON_MAP, MN_INPUT_FIELD_CONFIG, MN_INSTANCE_ID, MN_LIB_DUAL_HORIZONTAL_IMAGE, MN_MODAL_ACTION_ICONS, MN_MULTI_SELECT_CONFIG, MN_SECTION_PATH, MN_SELECT_CONFIG, MN_TEXTAREA_CONFIG, MODAL_ACTION_ICON_SIZE, MODAL_ACTION_ICON_SIZE_SM, MnAlertOutletComponent, MnAlertService, MnAlertStore, MnBadge, MnBottomSheet, MnButton, MnCheckbox, MnCollectionBase, MnCollectionPagination, MnCollectionState, MnConfigService, MnConfirmationBodyComponent, MnCustomBodyHostComponent, MnDateSelectorBar, MnDatetime, MnDualHorizontalImage, MnFileInput, MnFormBodyComponent, MnGrid, MnHiddenBelowDirective, MnHttpService, MnIcon, MnIconAttributes, MnInformationCard, MnInputField, MnInstanceDirective, MnLanguageService, MnList, MnModalRef, MnModalService, MnModalShellComponent, MnMultiSelect, MnRichTextEditor, MnSectionDirective, MnSelect, MnSelectableCollectionBase, MnShowAboveDirective, MnShowBelowDirective, MnSkeleton, MnTabComponent, MnTable, MnTextarea, MnTranslatePipe, MnWizardBodyComponent, ModalBuilder, ModalCloseReason, ModalIntent, ModalKind, ModalSize, NavigationDirection, OptionState, SelectionMode, StepBuilder, StepState, SubmitMode, UpcomingEventRowComponent, UpcomingEventsComponent, ValidationCode, ValidationStatus, WizardFlowMode, WizardModalBuilder, dateTimeAdapter, defaultFilterPredicate, defaultIconForStyle, defaultTextAdapter, emptyFilterValue, enableMnPreviewMode, isFilterValueActive, isTranslatable, matchesColumnFilter, mnAlertVariants, mnBadgeVariants, mnButtonVariants, mnCheckboxVariants, mnCheckboxWrapperVariants, mnDatetimeVariants, mnFileInputVariants, mnIconVariants, mnInformationCardVariants, mnInputFieldVariants, mnMultiSelectVariants, mnSelectVariants, mnSkeletonVariants, mnTextareaVariants, numberAdapter, pickAdapter, provideMnAlerts, provideMnCalendarConfig, provideMnComponentConfig, provideMnConfig, provideMnLanguage, resolveCalendarConfig, resolveFilterableValue };
11689
12073
  //# sourceMappingURL=mn-angular-lib.mjs.map