mn-angular-lib 1.0.140 → 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,10 +3772,11 @@ 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;
3511
- /** The sheet backdrop element currently moved into `document.body`, if any. */
3512
- movedBackdrop = null;
3513
3780
  /** Option count at which the search input auto-enables when `searchable` is unset. */
3514
3781
  static DEFAULT_SEARCH_THRESHOLD = 8;
3515
3782
  /** Tailwind's `sm` breakpoint — below this the panel renders as a bottom sheet.
@@ -3542,6 +3809,8 @@ class MnMultiSelect {
3542
3809
  * card) used to leave the portalled panel floating at its stale coordinates.
3543
3810
  */
3544
3811
  scrollCapture = null;
3812
+ /** The bottom-sheet host currently moved into `document.body`, if any. */
3813
+ movedSheet = null;
3545
3814
  /**
3546
3815
  * The dropdown panel element, queried while it is rendered by the `@if` block.
3547
3816
  * The setter relocates the panel to `document.body` so that its `position: fixed`
@@ -3551,22 +3820,7 @@ class MnMultiSelect {
3551
3820
  * broken on iOS). Cleanup is handled when the query clears on close/destroy.
3552
3821
  */
3553
3822
  set dropdownRef(ref) {
3554
- const el = ref?.nativeElement ?? null;
3555
- this.movedPanel = this.portal(el, this.movedPanel);
3556
- if (el && this.isSheet) {
3557
- this.captureSheetFloor(el);
3558
- }
3559
- else if (!el) {
3560
- this.sheetFloorPx = null;
3561
- }
3562
- }
3563
- /**
3564
- * The dimming backdrop rendered behind the mobile sheet. Portalled alongside the
3565
- * panel for the same reason — a `position: fixed` backdrop left inside a transformed
3566
- * ancestor would cover that ancestor rather than the viewport.
3567
- */
3568
- set sheetBackdropRef(ref) {
3569
- this.movedBackdrop = this.portal(ref?.nativeElement ?? null, this.movedBackdrop);
3823
+ this.movedPanel = this.portal(ref?.nativeElement ?? null, this.movedPanel);
3570
3824
  }
3571
3825
  /** Currently selected values */
3572
3826
  selectedValues = [];
@@ -3585,21 +3839,21 @@ class MnMultiSelect {
3585
3839
  if (this.ngControl)
3586
3840
  this.ngControl.valueAccessor = this;
3587
3841
  }
3588
- ngOnInit() {
3589
- this.resolveConfig();
3590
- this.startWatchingViewport();
3591
- const sub = this.lang.locale$.pipe(skip(1)).subscribe(() => {
3592
- this.resolveConfig();
3593
- });
3594
- this.destroyRef.onDestroy(() => {
3595
- sub.unsubscribe();
3596
- this.stopWatchingTrigger();
3597
- this.stopWatchingViewport();
3598
- this.unlockBodyScroll();
3599
- // Guarantee the portalled elements never outlive the component.
3600
- this.movedPanel = this.portal(null, this.movedPanel);
3601
- this.movedBackdrop = this.portal(null, this.movedBackdrop);
3602
- });
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
+ }
3603
3857
  }
3604
3858
  /**
3605
3859
  * Tracks the sheet breakpoint through `matchMedia` rather than reading `innerWidth`
@@ -3628,15 +3882,21 @@ class MnMultiSelect {
3628
3882
  this.sheetMedia = null;
3629
3883
  this.sheetMediaListener = null;
3630
3884
  }
3631
- onDocumentClick(event) {
3632
- const target = event.target;
3633
- // The panel lives at the body root once open, so it is not a descendant of the
3634
- // host element treat clicks inside the portalled panel as "inside" too.
3635
- const insideHost = !!target && this.elRef.nativeElement.contains(target);
3636
- const insidePanel = !!target && !!this.movedPanel && this.movedPanel.contains(target);
3637
- if (!insideHost && !insidePanel) {
3638
- this.close();
3639
- }
3885
+ ngOnInit() {
3886
+ this.resolveConfig();
3887
+ this.startWatchingViewport();
3888
+ const sub = this.lang.locale$.pipe(skip(1)).subscribe(() => {
3889
+ this.resolveConfig();
3890
+ });
3891
+ this.destroyRef.onDestroy(() => {
3892
+ sub.unsubscribe();
3893
+ this.stopWatchingTrigger();
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);
3899
+ });
3640
3900
  }
3641
3901
  resolveConfig() {
3642
3902
  const instanceId = this.explicitInstanceId || `mn-multi-select-${this.props.id}`;
@@ -3693,15 +3953,18 @@ class MnMultiSelect {
3693
3953
  const threshold = this.props.searchThreshold ?? MnMultiSelect.DEFAULT_SEARCH_THRESHOLD;
3694
3954
  return this.props.options.length >= threshold;
3695
3955
  }
3696
- /** Layout classes for the panel — a bottom-anchored sheet, or the trigger-anchored popover. */
3697
- get panelClasses() {
3698
- // The sheet sizes to its content (capped at 80vh), so a short list gets a short
3699
- // sheet. To stop it collapsing upward as the search filters options away, the height
3700
- // it opens at is captured once and re-applied as a `min-height` floor (see
3701
- // `sheetFloorPx`): the `flex-1` list then keeps that frame and just shows fewer rows.
3702
- return this.isSheet
3703
- ? 'mn-ms-sheet fixed inset-x-0 bottom-0 z-9999 flex flex-col bg-base-100 border-t border-base-300 rounded-t-2xl shadow-lg max-h-[80vh]'
3704
- : 'fixed z-9999 bg-base-100 border border-base-300 rounded-md shadow-lg max-h-60 overflow-auto';
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
+ }
3705
3968
  }
3706
3969
  /**
3707
3970
  * Records the sheet's opened height as its `min-height` floor. Measured on the next
@@ -3709,17 +3972,24 @@ class MnMultiSelect {
3709
3972
  * empty on open) and never forces a reflow mid change-detection. The floor equals the
3710
3973
  * content height at that instant, so applying it triggers no resize — it only stops a
3711
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.
3712
3978
  */
3713
- captureSheetFloor(panel) {
3979
+ captureSheetFloor(hostEl) {
3980
+ const measure = () => {
3981
+ const container = hostEl.querySelector('.mn-sheet-container');
3982
+ return container?.offsetHeight ?? hostEl.offsetHeight;
3983
+ };
3714
3984
  if (typeof requestAnimationFrame !== 'function') {
3715
- this.sheetFloorPx = panel.offsetHeight;
3985
+ this.sheetFloorPx = measure();
3716
3986
  return;
3717
3987
  }
3718
3988
  requestAnimationFrame(() => {
3719
- // The panel may have closed before the frame ran; don't strand a stale floor.
3720
- if (!this.isOpen || this.movedPanel !== panel)
3989
+ // The sheet may have closed before the frame ran; don't strand a stale floor.
3990
+ if (!this.isOpen || this.movedSheet !== hostEl)
3721
3991
  return;
3722
- this.sheetFloorPx = panel.offsetHeight;
3992
+ this.sheetFloorPx = measure();
3723
3993
  this.cdr.markForCheck();
3724
3994
  });
3725
3995
  }
@@ -3994,11 +4264,11 @@ class MnMultiSelect {
3994
4264
  });
3995
4265
  }
3996
4266
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnMultiSelect, deps: [], target: i0.ɵɵFactoryTarget.Component });
3997
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnMultiSelect, isStandalone: true, selector: "mn-lib-multi-select", inputs: { props: "props" }, host: { listeners: { "document:click": "onDocumentClick($event)", "document:keydown.escape": "onEscape()", "window:scroll": "onWindowScrollOrResize()", "window:resize": "onWindowScrollOrResize()" } }, viewQueries: [{ propertyName: "triggerRef", first: true, predicate: ["trigger"], descendants: true }, { propertyName: "dropdownRef", first: true, predicate: ["dropdown"], descendants: true }, { propertyName: "sheetBackdropRef", first: true, predicate: ["sheetBackdrop"], descendants: true }], ngImport: i0, template: "<div class=\"flex flex-col h-full\" [class.is-fullwidth]=\"props.fullWidth\">\n @if (uiConfig.label || props.label) {\n <label class=\"pl-2 pb-1 flex flex-row gap-x-0.5! text-base!\" [attr.for]=\"resolvedId\">\n <p>{{ uiConfig.label || props.label }}</p>\n @if (isRequired()) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n }\n\n <!-- Trigger -->\n <div\n #trigger\n [id]=\"resolvedId\"\n [ngClass]=\"triggerClasses\"\n class=\"relative\"\n [attr.aria-label]=\"uiConfig.ariaLabel || uiConfig.label || props.label || null\"\n [attr.aria-invalid]=\"showError || null\"\n [attr.aria-describedby]=\"showError ? resolvedId + '-error' : null\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-controls]=\"isOpen ? resolvedId + '-listbox' : null\"\n role=\"combobox\"\n tabindex=\"0\"\n (click)=\"toggle()\"\n (keydown.enter)=\"toggle()\"\n (keydown.space)=\"toggle(); $event.preventDefault()\"\n (blur)=\"handleBlur()\"\n >\n <!-- `pr-6` reserves the gutter the caret is absolutely positioned in (right-2 +\n w-4), so a value can never render underneath it. `min-w-0` lets the chips\n shrink below their content width, which is what makes truncation possible. -->\n <div class=\"flex flex-row items-center gap-x-2 flex-wrap min-h-6 min-w-0 pr-6\">\n @if (selectedOptions.length === 0) {\n <span class=\"text-base-content/50\">{{ uiConfig.placeholder || props.placeholder || 'Select...' }}</span>\n } @else if (isCollapsed) {\n <span\n class=\"inline-flex items-center max-w-full truncate bg-base-200 border border-accent text-base-content text-xs px-2 py-0.5 rounded-md\">\n {{ collapseSummaryText }}\n </span>\n } @else {\n @for (opt of selectedOptions; track opt.value) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <span\n class=\"inline-flex items-center gap-x-1 max-w-full min-w-0 bg-base-200 border border-accent text-base-content text-xs pl-2 py-0.5 rounded-md cursor-pointer\"\n (click)=\"removeOption(opt, $event)\">\n <span [attr.title]=\"opt.label\" class=\"truncate\">{{ opt.label }}</span>\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'secondary', hover: false }\"\n type=\"button\"\n class=\"text-base-content/50 cursor-pointer shrink-0\"\n (click)=\"removeOption(opt, $event)\"\n [attr.aria-label]=\"'Remove ' + opt.label\"\n ><svg lucideX [size]=\"18\"></svg></button>\n </span>\n }\n }\n </div>\n <div class=\"absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none\">\n <svg [size]=\"16\" class=\"text-base-content/50\" lucideChevronDown></svg>\n </div>\n </div>\n\n <!-- Dropdown -->\n @if (isOpen) {\n <!-- Dims the page behind the sheet. Deliberately has no click handler: the host's\n `document:click` listener already treats anything outside the panel as a\n dismissal, so a second handler would only duplicate that path. -->\n @if (isSheet) {\n <div #sheetBackdrop class=\"mn-ms-sheet-backdrop fixed inset-0 z-9998 bg-black/40\"></div>\n }\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n #dropdown\n [id]=\"resolvedId + '-listbox'\"\n aria-multiselectable=\"true\"\n role=\"listbox\"\n [ngClass]=\"panelClasses\"\n [style.top]=\"isSheet ? null : dropdownStyle.top\"\n [style.left]=\"isSheet ? null : dropdownStyle.left\"\n [style.width]=\"isSheet ? null : dropdownStyle.width\"\n [style.min-height.px]=\"isSheet ? sheetFloorPx : null\"\n (click)=\"$event.stopPropagation()\"\n >\n <!-- The sheet covers its own trigger, so it needs a header to name the field and\n an explicit way out \u2014 tapping the trigger again is not reachable here. -->\n @if (isSheet) {\n <div class=\"flex items-center justify-between gap-x-2 px-4 pt-4 pb-2 shrink-0\">\n <p class=\"text-base font-medium text-base-content truncate\">\n {{ uiConfig.label || props.label || uiConfig.placeholder || props.placeholder || '' }}\n </p>\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'secondary' }\"\n type=\"button\"\n class=\"shrink-0 cursor-pointer\"\n (click)=\"close()\"\n [attr.aria-label]=\"uiConfig.closeLabel || 'Close'\"\n ><svg lucideX [size]=\"20\"></svg></button>\n </div>\n }\n @if (isSearchable) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n class=\"border-b border-base-300 shrink-0\"\n [ngClass]=\"isSheet ? 'px-4 py-2' : 'p-2'\"\n (click)=\"$event.stopPropagation()\"\n >\n <mn-lib-input-field\n [ngModel]=\"searchTerm\"\n [ngModelOptions]=\"{ standalone: true }\"\n (ngModelChange)=\"onSearch($event)\"\n [props]=\"{\n id: resolvedId + '-search',\n type: 'search',\n placeholder: props.searchPlaceholder || 'Search...',\n ariaLabel: props.searchPlaceholder || 'Search...',\n fullWidth: true,\n size: 'sm'\n }\"\n ></mn-lib-input-field>\n </div>\n }\n <!-- In sheet mode the panel is the flex column and this list is the scroller;\n anchored, the panel itself scrolls and this is a passthrough wrapper. -->\n <div [ngClass]=\"isSheet ? 'flex-1 overflow-auto overscroll-contain' : ''\">\n @for (opt of filteredOptions; track opt.value) {\n <div\n (keyup.enter)=\"toggleOption(opt)\"\n (keyup.space)=\"toggleOption(opt)\"\n [attr.aria-selected]=\"isSelected(opt)\"\n class=\"flex items-center gap-x-2 cursor-pointer text-base-content hover:bg-base-200\"\n [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\"\n [class.opacity-50]=\"opt.disabled || isMaxReached(opt)\"\n [class.pointer-events-none]=\"opt.disabled || isMaxReached(opt)\"\n (click)=\"toggleOption(opt); $event.stopPropagation()\"\n role=\"option\"\n tabindex=\"0\"\n >\n <input\n type=\"checkbox\"\n class=\"w-4 h-4 accent-primary pointer-events-none shrink-0\"\n [checked]=\"isSelected(opt)\"\n [disabled]=\"opt.disabled || isMaxReached(opt)\"\n tabindex=\"-1\"\n />\n <span>{{ opt.label }}</span>\n </div>\n }\n @if (filteredOptions.length === 0) {\n <div class=\"text-base-content/50\" [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\">\n {{ uiConfig.noOptionsFound || 'No options found' }}\n </div>\n }\n </div>\n </div>\n }\n\n @if (showError) {\n @if (props.showAllErrors) {\n <div class=\"flex flex-col gap-y-1 mt-1\">\n @for (error of errorMessages; track $index) {\n <mn-error-message [errorMessage]=\"error\" [id]=\"resolvedId + '-' + $index\"></mn-error-message>\n }\n </div>\n } @else {\n @if (errorMessage !== null) {\n <mn-error-message [errorMessage]=\"errorMessage\" [id]=\"resolvedId\"></mn-error-message>\n }\n }\n }\n</div>\n", styles: [".mn-ms-sheet{--mn-sheet-ease: cubic-bezier(.32, .72, 0, 1);padding-bottom:env(safe-area-inset-bottom);animation:mn-ms-sheet-in .3s var(--mn-sheet-ease)}.mn-ms-sheet-backdrop{animation:mn-ms-backdrop-in .2s ease-out}@keyframes mn-ms-sheet-in{0%{transform:translateY(100%)}to{transform:translateY(0)}}@keyframes mn-ms-backdrop-in{0%{opacity:0}to{opacity:1}}@media(prefers-reduced-motion:reduce){.mn-ms-sheet,.mn-ms-sheet-backdrop{animation-duration:.01ms!important}}\n"], dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: MnErrorMessage, selector: "mn-error-message", inputs: ["errorMessage", "id"] }, { kind: "component", type: MnButton, selector: "button[mnButton], a[mnButton]", inputs: ["data"] }, { kind: "component", type: MnInputField, selector: "mn-lib-input-field", inputs: ["props"] }, { kind: "component", type: LucideX, selector: "svg[lucideX]" }, { kind: "component", type: LucideChevronDown, selector: "svg[lucideChevronDown]" }] });
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]" }] });
3998
4268
  }
3999
4269
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnMultiSelect, decorators: [{
4000
4270
  type: Component,
4001
- args: [{ selector: 'mn-lib-multi-select', standalone: true, imports: [NgClass, FormsModule, MnErrorMessage, MnButton, MnInputField, LucideX, LucideChevronDown], template: "<div class=\"flex flex-col h-full\" [class.is-fullwidth]=\"props.fullWidth\">\n @if (uiConfig.label || props.label) {\n <label class=\"pl-2 pb-1 flex flex-row gap-x-0.5! text-base!\" [attr.for]=\"resolvedId\">\n <p>{{ uiConfig.label || props.label }}</p>\n @if (isRequired()) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n }\n\n <!-- Trigger -->\n <div\n #trigger\n [id]=\"resolvedId\"\n [ngClass]=\"triggerClasses\"\n class=\"relative\"\n [attr.aria-label]=\"uiConfig.ariaLabel || uiConfig.label || props.label || null\"\n [attr.aria-invalid]=\"showError || null\"\n [attr.aria-describedby]=\"showError ? resolvedId + '-error' : null\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-controls]=\"isOpen ? resolvedId + '-listbox' : null\"\n role=\"combobox\"\n tabindex=\"0\"\n (click)=\"toggle()\"\n (keydown.enter)=\"toggle()\"\n (keydown.space)=\"toggle(); $event.preventDefault()\"\n (blur)=\"handleBlur()\"\n >\n <!-- `pr-6` reserves the gutter the caret is absolutely positioned in (right-2 +\n w-4), so a value can never render underneath it. `min-w-0` lets the chips\n shrink below their content width, which is what makes truncation possible. -->\n <div class=\"flex flex-row items-center gap-x-2 flex-wrap min-h-6 min-w-0 pr-6\">\n @if (selectedOptions.length === 0) {\n <span class=\"text-base-content/50\">{{ uiConfig.placeholder || props.placeholder || 'Select...' }}</span>\n } @else if (isCollapsed) {\n <span\n class=\"inline-flex items-center max-w-full truncate bg-base-200 border border-accent text-base-content text-xs px-2 py-0.5 rounded-md\">\n {{ collapseSummaryText }}\n </span>\n } @else {\n @for (opt of selectedOptions; track opt.value) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <span\n class=\"inline-flex items-center gap-x-1 max-w-full min-w-0 bg-base-200 border border-accent text-base-content text-xs pl-2 py-0.5 rounded-md cursor-pointer\"\n (click)=\"removeOption(opt, $event)\">\n <span [attr.title]=\"opt.label\" class=\"truncate\">{{ opt.label }}</span>\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'secondary', hover: false }\"\n type=\"button\"\n class=\"text-base-content/50 cursor-pointer shrink-0\"\n (click)=\"removeOption(opt, $event)\"\n [attr.aria-label]=\"'Remove ' + opt.label\"\n ><svg lucideX [size]=\"18\"></svg></button>\n </span>\n }\n }\n </div>\n <div class=\"absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none\">\n <svg [size]=\"16\" class=\"text-base-content/50\" lucideChevronDown></svg>\n </div>\n </div>\n\n <!-- Dropdown -->\n @if (isOpen) {\n <!-- Dims the page behind the sheet. Deliberately has no click handler: the host's\n `document:click` listener already treats anything outside the panel as a\n dismissal, so a second handler would only duplicate that path. -->\n @if (isSheet) {\n <div #sheetBackdrop class=\"mn-ms-sheet-backdrop fixed inset-0 z-9998 bg-black/40\"></div>\n }\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n #dropdown\n [id]=\"resolvedId + '-listbox'\"\n aria-multiselectable=\"true\"\n role=\"listbox\"\n [ngClass]=\"panelClasses\"\n [style.top]=\"isSheet ? null : dropdownStyle.top\"\n [style.left]=\"isSheet ? null : dropdownStyle.left\"\n [style.width]=\"isSheet ? null : dropdownStyle.width\"\n [style.min-height.px]=\"isSheet ? sheetFloorPx : null\"\n (click)=\"$event.stopPropagation()\"\n >\n <!-- The sheet covers its own trigger, so it needs a header to name the field and\n an explicit way out \u2014 tapping the trigger again is not reachable here. -->\n @if (isSheet) {\n <div class=\"flex items-center justify-between gap-x-2 px-4 pt-4 pb-2 shrink-0\">\n <p class=\"text-base font-medium text-base-content truncate\">\n {{ uiConfig.label || props.label || uiConfig.placeholder || props.placeholder || '' }}\n </p>\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'secondary' }\"\n type=\"button\"\n class=\"shrink-0 cursor-pointer\"\n (click)=\"close()\"\n [attr.aria-label]=\"uiConfig.closeLabel || 'Close'\"\n ><svg lucideX [size]=\"20\"></svg></button>\n </div>\n }\n @if (isSearchable) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n class=\"border-b border-base-300 shrink-0\"\n [ngClass]=\"isSheet ? 'px-4 py-2' : 'p-2'\"\n (click)=\"$event.stopPropagation()\"\n >\n <mn-lib-input-field\n [ngModel]=\"searchTerm\"\n [ngModelOptions]=\"{ standalone: true }\"\n (ngModelChange)=\"onSearch($event)\"\n [props]=\"{\n id: resolvedId + '-search',\n type: 'search',\n placeholder: props.searchPlaceholder || 'Search...',\n ariaLabel: props.searchPlaceholder || 'Search...',\n fullWidth: true,\n size: 'sm'\n }\"\n ></mn-lib-input-field>\n </div>\n }\n <!-- In sheet mode the panel is the flex column and this list is the scroller;\n anchored, the panel itself scrolls and this is a passthrough wrapper. -->\n <div [ngClass]=\"isSheet ? 'flex-1 overflow-auto overscroll-contain' : ''\">\n @for (opt of filteredOptions; track opt.value) {\n <div\n (keyup.enter)=\"toggleOption(opt)\"\n (keyup.space)=\"toggleOption(opt)\"\n [attr.aria-selected]=\"isSelected(opt)\"\n class=\"flex items-center gap-x-2 cursor-pointer text-base-content hover:bg-base-200\"\n [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\"\n [class.opacity-50]=\"opt.disabled || isMaxReached(opt)\"\n [class.pointer-events-none]=\"opt.disabled || isMaxReached(opt)\"\n (click)=\"toggleOption(opt); $event.stopPropagation()\"\n role=\"option\"\n tabindex=\"0\"\n >\n <input\n type=\"checkbox\"\n class=\"w-4 h-4 accent-primary pointer-events-none shrink-0\"\n [checked]=\"isSelected(opt)\"\n [disabled]=\"opt.disabled || isMaxReached(opt)\"\n tabindex=\"-1\"\n />\n <span>{{ opt.label }}</span>\n </div>\n }\n @if (filteredOptions.length === 0) {\n <div class=\"text-base-content/50\" [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\">\n {{ uiConfig.noOptionsFound || 'No options found' }}\n </div>\n }\n </div>\n </div>\n }\n\n @if (showError) {\n @if (props.showAllErrors) {\n <div class=\"flex flex-col gap-y-1 mt-1\">\n @for (error of errorMessages; track $index) {\n <mn-error-message [errorMessage]=\"error\" [id]=\"resolvedId + '-' + $index\"></mn-error-message>\n }\n </div>\n } @else {\n @if (errorMessage !== null) {\n <mn-error-message [errorMessage]=\"errorMessage\" [id]=\"resolvedId\"></mn-error-message>\n }\n }\n }\n</div>\n", styles: [".mn-ms-sheet{--mn-sheet-ease: cubic-bezier(.32, .72, 0, 1);padding-bottom:env(safe-area-inset-bottom);animation:mn-ms-sheet-in .3s var(--mn-sheet-ease)}.mn-ms-sheet-backdrop{animation:mn-ms-backdrop-in .2s ease-out}@keyframes mn-ms-sheet-in{0%{transform:translateY(100%)}to{transform:translateY(0)}}@keyframes mn-ms-backdrop-in{0%{opacity:0}to{opacity:1}}@media(prefers-reduced-motion:reduce){.mn-ms-sheet,.mn-ms-sheet-backdrop{animation-duration:.01ms!important}}\n"] }]
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" }]
4002
4272
  }], ctorParameters: () => [], propDecorators: { props: [{
4003
4273
  type: Input,
4004
4274
  args: [{ required: true }]
@@ -4008,9 +4278,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImpor
4008
4278
  }], dropdownRef: [{
4009
4279
  type: ViewChild,
4010
4280
  args: ['dropdown', { static: false }]
4011
- }], sheetBackdropRef: [{
4281
+ }], sheetRef: [{
4012
4282
  type: ViewChild,
4013
- args: ['sheetBackdrop', { static: false }]
4283
+ args: ['sheet', { static: false, read: ElementRef }]
4014
4284
  }], onDocumentClick: [{
4015
4285
  type: HostListener,
4016
4286
  args: ['document:click', ['$event']]
@@ -8566,9 +8836,6 @@ class MnModalShellComponent {
8566
8836
  }
8567
8837
  el = inject(ElementRef);
8568
8838
  cdr = inject(ChangeDetectorRef);
8569
- /** Downward release speed (px/ms) above which a short drag still dismisses — a "flick".
8570
- * Native sheets dismiss on a quick flick regardless of distance, not just a long drag. */
8571
- static FLICK_VELOCITY = 0.5;
8572
8839
  config;
8573
8840
  modalRef;
8574
8841
  isClosing = false;
@@ -8583,6 +8850,8 @@ class MnModalShellComponent {
8583
8850
  ModalKind = ModalKind;
8584
8851
  /** The rendered wizard body, when this modal is a wizard — used to read the active step title. */
8585
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;
8586
8855
  /**
8587
8856
  * Title of the wizard's current step, or undefined for non-wizard modals.
8588
8857
  * The template appends it to the modal title on small screens, where the
@@ -8593,19 +8862,42 @@ class MnModalShellComponent {
8593
8862
  focusTrapListener = null;
8594
8863
  pollingTimer = null;
8595
8864
  pollAttempts = 0;
8596
- ngOnInit() {
8597
- 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;
8598
8881
  }
8599
- /** Minimum drag distance (px) that must accompany a flick, so an incidental fast tap
8600
- * on the grabber never dismisses. Below the distance threshold, only a flick dismisses. */
8601
- static FLICK_MIN_DISTANCE = 32;
8602
- ngOnDestroy() {
8603
- this.removeFocusTrap();
8604
- this.stopPolling();
8605
- // Restore focus to previously focused element
8606
- if (this.previouslyFocusedElement && typeof this.previouslyFocusedElement.focus === 'function') {
8607
- this.previouslyFocusedElement.focus();
8608
- }
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}`;
8609
8901
  }
8610
8902
  setupFocusTrap() {
8611
8903
  this.focusTrapListener = (e) => {
@@ -8649,45 +8941,54 @@ class MnModalShellComponent {
8649
8941
  asCustom(config) {
8650
8942
  return config;
8651
8943
  }
8652
- static SWIPE_DISMISS_THRESHOLD = 150;
8653
- /** Optional native haptic engine. Absent on the web — every call is null-guarded. */
8654
- haptics = inject(MN_HAPTICS, { optional: true });
8655
- /** The two most recent (y, timestamp) pointer samples, used to estimate the release
8656
- * velocity for flick-to-dismiss. `t` uses the event timestamp (monotonic, no Date). */
8657
- lastSample = null;
8658
- /** Upper bound for the close wait if no animation/transition end event fires
8659
- * (e.g. an animation was suppressed). Must stay longer than the slowest close
8660
- * path (mobile sheet slide-down 0.45s, swipe glide 0.3s) so it never preempts. */
8661
- static CLOSE_FALLBACK_MS = 700;
8662
- /** Whether this modal renders as a bottom sheet on small screens (default: true). */
8663
- get isMobileSheet() {
8664
- 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
+ }
8665
8960
  }
8666
8961
  /**
8667
8962
  * Triggers the closing animation and resolves once it has actually finished.
8668
8963
  *
8669
- * Deferred via setTimeout to avoid NG0100 when called during a CD cycle.
8670
- * Rather than guess a fixed duration (the old hardcoded 150ms truncated the
8671
- * mobile slide-down, which runs 250ms and the swipe glide, 300ms), we wait
8672
- * for the container's `animationend`/`transitionend` and tear down then. A
8673
- * fallback timeout guarantees resolution if no such event fires, and we
8674
- * 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).
8675
8969
  */
8676
8970
  startClosing() {
8677
8971
  return new Promise(resolve => {
8678
- setTimeout(() => {
8972
+ setTimeout(async () => {
8679
8973
  this.isClosing = true;
8680
8974
  // @HostBinding('class') updates are flushed when the host view is checked
8681
8975
  // (appRef.tick), not by a bare detectChanges() on this dynamically-created
8682
- // root component. Relying on CD alone means the `.closing` class and thus
8683
- // the slide-down animation — never lands in a zoneless app and is timing-
8684
- // 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.
8685
8978
  this.el.nativeElement.classList.add('closing');
8686
8979
  this.cdr.detectChanges();
8687
8980
  if (this.prefersReducedMotion()) {
8688
8981
  resolve();
8689
8982
  return;
8690
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.
8691
8992
  const container = this.el.nativeElement.querySelector('.modal-container');
8692
8993
  if (!container) {
8693
8994
  resolve();
@@ -8706,11 +9007,8 @@ class MnModalShellComponent {
8706
9007
  clearTimeout(fallback);
8707
9008
  resolve();
8708
9009
  };
8709
- // Normal close ends via a keyframe (animationend); the swipe-dismiss
8710
- // glide ends via the transform transition (transitionend).
8711
9010
  container.addEventListener('animationend', done);
8712
9011
  container.addEventListener('transitionend', done);
8713
- // `done` only runs asynchronously, after this assignment completes.
8714
9012
  const fallback = setTimeout(done, MnModalShellComponent.CLOSE_FALLBACK_MS);
8715
9013
  });
8716
9014
  });
@@ -8738,123 +9036,54 @@ class MnModalShellComponent {
8738
9036
  onCloseButtonClick() {
8739
9037
  this.handleClose(ModalCloseReason.DISMISSED);
8740
9038
  }
8741
- /** True once a swipe has crossed the dismiss threshold — slides the sheet off-screen
8742
- * via the transform transition instead of replaying the slide-up keyframe. */
8743
- swipeDismissing = false;
8744
- // =========================
8745
- // Mobile bottom-sheet swipe-to-dismiss (via the grabber handle)
8746
- // =========================
8747
- /** Current downward drag offset (px) applied to the sheet while swiping. */
8748
- sheetDragY = 0;
8749
- /** True while the user is actively dragging the grabber (disables snap transition). */
8750
- isDraggingSheet = false;
8751
- prevSample = null;
8752
- get hostClasses() {
8753
- const size = this.config.sizeWidth || ModalSize.MD;
8754
- // `closing` is intentionally NOT derived here. startClosing() adds the
8755
- // `.closing` class imperatively (classList.add) for reliable, zoneless-safe
8756
- // application. Deriving it from `isClosing` in this getter as well makes the
8757
- // host class string flip value after the view has been checked, which throws
8758
- // NG0100 (ExpressionChangedAfterItHasBeenCheckedError) in dev. Angular's class
8759
- // binding only manages the tokens it emits, so it leaves the imperatively
8760
- // added `.closing` untouched.
8761
- const animType = typeof this.config.animation === 'string'
8762
- ? this.config.animation
8763
- : this.config.animation?.type || 'slide';
8764
- const animation = ` anim-${animType}`;
8765
- const stacked = this.isStacked() ? ' is-stacked' : '';
8766
- const mobileSheet = this.isMobileSheet ? ' mobile-sheet' : '';
8767
- const swiping = this.swipeDismissing ? ' swipe-dismissing' : '';
8768
- return `modal-shell modal-${size}${animation}${stacked}${mobileSheet}${swiping}`;
8769
- }
8770
- dragStartY = 0;
8771
- /** Whether the sheet can be dismissed at all (drives whether the swipe is armed). */
8772
- get canClose() {
8773
- 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);
8774
9060
  }
8775
- /** Tailwind's `sm` breakpoint — below this the modal renders as a bottom sheet. */
8776
- static SHEET_MAX_WIDTH = 639.98;
8777
9061
  ngAfterViewInit() {
8778
9062
  this.previouslyFocusedElement = document.activeElement;
8779
9063
  this.setupFocusTrap();
8780
- // Focus the modal container
9064
+ // Focus the modal container (the centered dialog, or the sheet's container on mobile).
8781
9065
  const container = this.el.nativeElement.querySelector('.modal-container');
8782
9066
  if (container) {
8783
9067
  container.focus();
8784
9068
  }
8785
9069
  }
8786
- onSheetPointerDown(event) {
8787
- if (!this.isMobileSheet || !this.canClose)
8788
- return;
8789
- // Only a bottom sheet (mobile-width viewport) can be swiped away.
8790
- if (window.innerWidth > MnModalShellComponent.SHEET_MAX_WIDTH)
8791
- return;
8792
- // Don't hijack drags that begin on an interactive control (e.g. the close button).
8793
- if (event.target.closest('button'))
8794
- return;
8795
- this.isDraggingSheet = true;
8796
- this.dragStartY = event.clientY;
8797
- // Seed the velocity samples so a fast flick that releases on the first move still
8798
- // has a baseline to measure against.
8799
- this.lastSample = { y: event.clientY, t: event.timeStamp };
8800
- this.prevSample = this.lastSample;
8801
- event.target.setPointerCapture(event.pointerId);
8802
- }
8803
- onSheetPointerMove(event) {
8804
- if (!this.isDraggingSheet)
8805
- return;
8806
- // Only track downward movement.
8807
- this.sheetDragY = Math.max(0, event.clientY - this.dragStartY);
8808
- // Roll the sample window forward so pointer-up can read the latest instantaneous speed.
8809
- this.prevSample = this.lastSample;
8810
- this.lastSample = { y: event.clientY, t: event.timeStamp };
8811
- }
8812
- async onSheetPointerUp() {
8813
- if (!this.isDraggingSheet)
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')
8814
9074
  return;
8815
- this.isDraggingSheet = false;
8816
- if (this.shouldDismissSheet()) {
8817
- const closed = await this.handleClose(ModalCloseReason.DISMISSED);
8818
- if (closed) {
8819
- // A confirmed dismissal gets a slightly firmer tick than the open tap.
8820
- this.haptics?.impact('medium');
8821
- // Continue the gesture: glide the sheet the rest of the way down rather than
8822
- // snapping back to 0 and replaying the slide-up keyframe (which looked un-animated).
8823
- this.swipeDismissing = true;
8824
- this.sheetDragY = window.innerHeight;
8825
- this.cdr.detectChanges();
8826
- }
8827
- else {
8828
- this.snapBack(); // guard rejected — spring back
8829
- }
8830
- }
8831
- else {
8832
- this.snapBack(); // not far enough / not a flick — spring back
8833
- }
8834
- this.lastSample = null;
8835
- this.prevSample = null;
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);
8836
9079
  }
8837
- /** Whether the release should dismiss: a long-enough drag OR a fast downward flick. */
8838
- shouldDismissSheet() {
8839
- if (this.sheetDragY > MnModalShellComponent.SWIPE_DISMISS_THRESHOLD) {
8840
- return true;
9080
+ /** Tears down the breakpoint listener. Idempotent. */
9081
+ stopWatchingViewport() {
9082
+ if (this.sheetMedia && this.sheetMediaListener) {
9083
+ this.sheetMedia.removeEventListener('change', this.sheetMediaListener);
8841
9084
  }
8842
- return this.releaseVelocity() > MnModalShellComponent.FLICK_VELOCITY
8843
- && this.sheetDragY > MnModalShellComponent.FLICK_MIN_DISTANCE;
8844
- }
8845
- /** Downward release speed (px/ms) from the last two pointer samples. Positive means
8846
- * moving down. Returns 0 when there is no usable sample window. */
8847
- releaseVelocity() {
8848
- if (!this.lastSample || !this.prevSample)
8849
- return 0;
8850
- const dt = this.lastSample.t - this.prevSample.t;
8851
- if (dt <= 0)
8852
- return 0;
8853
- return (this.lastSample.y - this.prevSample.y) / dt;
8854
- }
8855
- /** Springs the sheet back to its resting position after a drag that didn't dismiss. */
8856
- snapBack() {
8857
- this.sheetDragY = 0;
9085
+ this.sheetMedia = null;
9086
+ this.sheetMediaListener = null;
8858
9087
  }
8859
9088
  /** Attempts to dismiss the modal. Resolves true if it was actually dismissed,
8860
9089
  * false if blocked by a DISABLED close mode or a rejected close guard. */
@@ -8984,7 +9213,7 @@ class MnModalShellComponent {
8984
9213
  }
8985
9214
  }
8986
9215
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnModalShellComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
8987
- 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]" }] });
8988
9217
  }
8989
9218
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnModalShellComponent, decorators: [{
8990
9219
  type: Component,
@@ -8996,18 +9225,19 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImpor
8996
9225
  MnCustomBodyHostComponent,
8997
9226
  MnFooterActionsComponent,
8998
9227
  MnButton,
9228
+ MnBottomSheet,
8999
9229
  LucideX,
9000
- ], 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"] }]
9001
9231
  }], propDecorators: { config: [{
9002
9232
  type: Input
9003
9233
  }], modalRef: [{
9004
9234
  type: Input
9005
- }], wizardBody: [{ type: i0.ViewChild, args: [i0.forwardRef(() => MnWizardBodyComponent), { isSignal: true }] }], onEscapeKey: [{
9006
- type: HostListener,
9007
- args: ['document:keydown.escape', ['$event']]
9008
- }], hostClasses: [{
9235
+ }], wizardBody: [{ type: i0.ViewChild, args: [i0.forwardRef(() => MnWizardBodyComponent), { isSignal: true }] }], bottomSheet: [{ type: i0.ViewChild, args: [i0.forwardRef(() => MnBottomSheet), { isSignal: true }] }], hostClasses: [{
9009
9236
  type: HostBinding,
9010
9237
  args: ['class']
9238
+ }], onEscapeKey: [{
9239
+ type: HostListener,
9240
+ args: ['document:keydown.escape', ['$event']]
9011
9241
  }] } });
9012
9242
 
9013
9243
  class MnModalService {
@@ -11839,5 +12069,5 @@ function enableMnPreviewMode(configService, langService, allowedOrigins) {
11839
12069
  * Generated bundle index. Do not edit.
11840
12070
  */
11841
12071
 
11842
- 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 };
11843
12073
  //# sourceMappingURL=mn-angular-lib.mjs.map