mn-angular-lib 1.0.148 → 1.0.150
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,7 +1,8 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { InjectionToken, Injectable, inject, HostBinding, Input, Component, ApplicationRef, APP_INITIALIZER, Pipe,
|
|
2
|
+
import { InjectionToken, Injectable, inject, HostBinding, Input, Component, ApplicationRef, APP_INITIALIZER, Pipe, DestroyRef, signal, ChangeDetectionStrategy, Optional, SkipSelf, Attribute, Directive, ElementRef, EventEmitter, computed, Output, input, output, Injector, viewChildren, linkedSignal, afterNextRender, ChangeDetectorRef, viewChild, Renderer2, HostListener, ViewChild, TemplateRef, ViewContainerRef, forwardRef, afterEveryRender, 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
|
+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
5
6
|
import * as i1 from '@angular/common';
|
|
6
7
|
import { CommonModule, NgClass, NgOptimizedImage, NgTemplateOutlet } from '@angular/common';
|
|
7
8
|
import { tv } from 'tailwind-variants';
|
|
@@ -9,8 +10,7 @@ import { HttpClient, HttpErrorResponse, HttpStatusCode, HttpParams } from '@angu
|
|
|
9
10
|
import * as i1$1 from '@angular/forms';
|
|
10
11
|
import { NgControl, Validators, FormsModule, NG_VALUE_ACCESSOR, FormBuilder, ReactiveFormsModule } from '@angular/forms';
|
|
11
12
|
import JSON5 from 'json5';
|
|
12
|
-
import { LucideFile, LucideImagePlus, LucideTrash2, LucideUpload, LucideX, LucideCalendarDays, LucideChevronLeft, LucideChevronRight, LucideChevronDown, LucideEllipsisVertical,
|
|
13
|
-
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
13
|
+
import { LucideFile, LucideImagePlus, LucideTrash2, LucideUpload, LucideX, LucideCalendarDays, LucideChevronLeft, LucideChevronRight, LucideChevronDown, LucideEllipsisVertical, LucideSearchX, LucideDynamicIcon, LucideArrowLeft, LucideArrowRight, LucideCheck, LucideInbox, LucideFilter, LucideFunnel, LucideTriangleAlert, LucideCircleAlert } from '@lucide/angular';
|
|
14
14
|
import { DomSanitizer } from '@angular/platform-browser';
|
|
15
15
|
|
|
16
16
|
// projects/mn-angular-lib/src/lib/mn-mn-alert/mn-mn-alert.tokens.ts
|
|
@@ -671,6 +671,44 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImpor
|
|
|
671
671
|
|
|
672
672
|
class MnAlertOutletComponent {
|
|
673
673
|
lang = inject(MnLanguageService);
|
|
674
|
+
store = inject(MnAlertStore);
|
|
675
|
+
destroyRef = inject(DestroyRef);
|
|
676
|
+
/** Drag distance (px) past which a release dismisses regardless of speed. */
|
|
677
|
+
static SWIPE_DISMISS_THRESHOLD = 96;
|
|
678
|
+
/** Release speed (px/ms) above which a short drag still dismisses — a "flick". */
|
|
679
|
+
static FLICK_VELOCITY = 0.4;
|
|
680
|
+
/** Minimum drag distance (px) a flick must cover, so an incidental fast tap never dismisses. */
|
|
681
|
+
static FLICK_MIN_DISTANCE = 24;
|
|
682
|
+
/** Upper bound for the leave wait; kept a touch longer than the CSS exit so removal never
|
|
683
|
+
* preempts the animation. Mirrored by the `--mn-alert-exit` duration in the stylesheet. */
|
|
684
|
+
static EXIT_MS = 320;
|
|
685
|
+
/** Opacity floor a card fades to at the dismiss threshold, for drag feedback. */
|
|
686
|
+
static DRAG_OPACITY_FLOOR = 0.35;
|
|
687
|
+
/**
|
|
688
|
+
* How the dismiss swipe is oriented:
|
|
689
|
+
* - `'auto'` (default) matches the host platform's convention — Apple platforms
|
|
690
|
+
* (iOS/iPadOS/macOS) flick a top banner *upward*, so we swipe vertically; Android (and
|
|
691
|
+
* every other platform) dismisses toasts *to the right*, so we swipe horizontally.
|
|
692
|
+
*
|
|
693
|
+
* Either axis is one-directional: only an upward (vertical) or rightward (horizontal)
|
|
694
|
+
* drag dismisses; dragging the opposite way springs the card back.
|
|
695
|
+
* - `'vertical'` / `'horizontal'` force the axis regardless of platform. Useful when the
|
|
696
|
+
* host can't be sniffed reliably (e.g. Chrome DevTools *Responsive* mode does not spoof
|
|
697
|
+
* the user agent, so `'auto'` there resolves to the desktop/Android horizontal axis).
|
|
698
|
+
*/
|
|
699
|
+
swipeAxis = 'auto';
|
|
700
|
+
/** Whether the running device is an Apple platform. Sniffed once from `navigator` at
|
|
701
|
+
* construction (the UA is read at page load and does not change mid-session). */
|
|
702
|
+
isApple = this.detectApple();
|
|
703
|
+
/** The resolved swipe axis: the explicit {@link swipeAxis} override, or the platform
|
|
704
|
+
* default when left on `'auto'`. */
|
|
705
|
+
get vertical() {
|
|
706
|
+
if (this.swipeAxis === 'vertical')
|
|
707
|
+
return true;
|
|
708
|
+
if (this.swipeAxis === 'horizontal')
|
|
709
|
+
return false;
|
|
710
|
+
return this.isApple;
|
|
711
|
+
}
|
|
674
712
|
/**
|
|
675
713
|
* Accessible name for this control. Resolved through the conventional
|
|
676
714
|
* `mnAlert.close` key so an app can translate it, falling back to English when the
|
|
@@ -680,13 +718,34 @@ class MnAlertOutletComponent {
|
|
|
680
718
|
return this.lang.translateIfPresent('mnAlert.close') ?? 'Close';
|
|
681
719
|
}
|
|
682
720
|
template;
|
|
683
|
-
store
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
721
|
+
/** The view-layer mirror of the store's alerts, retaining alerts mid-leave. A signal so
|
|
722
|
+
* writes from the (external) store subscription and leave timers schedule change
|
|
723
|
+
* detection in a zoneless app. */
|
|
724
|
+
views = signal([], ...(ngDevMode ? [{ debugName: "views" }] : []));
|
|
725
|
+
/**
|
|
726
|
+
* `touch-action` for the swipe wrapper: reserve the swipe axis for our gesture while
|
|
727
|
+
* leaving the cross-axis to the browser. Vertical swipe (Apple) claims the Y axis
|
|
728
|
+
* (`pan-x`); horizontal swipe (Android/other) claims the X axis (`pan-y`).
|
|
729
|
+
*/
|
|
730
|
+
get touchAction() {
|
|
731
|
+
return this.vertical ? 'pan-x' : 'pan-y';
|
|
732
|
+
}
|
|
733
|
+
/** In-flight leave-removal timers, keyed by alert id, cleared on destroy so a fired
|
|
734
|
+
* timer never touches a torn-down view. */
|
|
735
|
+
exitTimers = new Map();
|
|
736
|
+
/** The single active swipe, or null. Only one card is dragged at a time. `startCoord`
|
|
737
|
+
* and the samples are taken along the active axis (Y when vertical, else X). */
|
|
738
|
+
activeDrag = null;
|
|
739
|
+
constructor() {
|
|
740
|
+
this.store.alerts$
|
|
741
|
+
.pipe(takeUntilDestroyed())
|
|
742
|
+
.subscribe(alerts => this.sync(alerts));
|
|
743
|
+
this.destroyRef.onDestroy(() => {
|
|
744
|
+
this.exitTimers.forEach(t => clearTimeout(t));
|
|
745
|
+
this.exitTimers.clear();
|
|
746
|
+
});
|
|
688
747
|
}
|
|
689
|
-
trackById = (_,
|
|
748
|
+
trackById = (_, v) => v.alert.id;
|
|
690
749
|
getAlertClasses(a) {
|
|
691
750
|
return mnAlertVariants({ kind: a.kind, variant: a.variant });
|
|
692
751
|
}
|
|
@@ -697,13 +756,182 @@ class MnAlertOutletComponent {
|
|
|
697
756
|
dismiss: () => this.dismissAlert(a.id)
|
|
698
757
|
};
|
|
699
758
|
}
|
|
759
|
+
/** Dismisses via the store; the store's removal is turned into a leave animation by
|
|
760
|
+
* {@link sync}. Used by the close button and the custom-template `dismiss` context. */
|
|
761
|
+
dismissAlert(id) {
|
|
762
|
+
this.store.dismiss(id);
|
|
763
|
+
}
|
|
764
|
+
// =========================
|
|
765
|
+
// Store → view reconciliation
|
|
766
|
+
// =========================
|
|
767
|
+
/** Reconciles the view list against the store: appends new alerts, updates the alert
|
|
768
|
+
* reference for still-present ones, and marks vanished ones as leaving (they stay in the
|
|
769
|
+
* list, animating out, until their removal timer fires). */
|
|
770
|
+
sync(alerts) {
|
|
771
|
+
const current = this.views();
|
|
772
|
+
const existing = new Set(current.map(v => v.alert.id));
|
|
773
|
+
const storeById = new Map(alerts.map(a => [a.id, a]));
|
|
774
|
+
const next = [];
|
|
775
|
+
for (const v of current) {
|
|
776
|
+
const fresh = storeById.get(v.alert.id);
|
|
777
|
+
if (fresh) {
|
|
778
|
+
v.alert = fresh;
|
|
779
|
+
}
|
|
780
|
+
else if (!v.leaving) {
|
|
781
|
+
this.beginLeave(v);
|
|
782
|
+
}
|
|
783
|
+
next.push(v);
|
|
784
|
+
}
|
|
785
|
+
for (const a of alerts) {
|
|
786
|
+
if (!existing.has(a.id)) {
|
|
787
|
+
next.push({ alert: a, leaving: false, dragging: false, drag: 0, fling: 0 });
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
this.views.set(next);
|
|
791
|
+
}
|
|
792
|
+
/** Flags a view as leaving and schedules its removal once the exit animation has run.
|
|
793
|
+
* Idempotent: a card already leaving (e.g. flung by a swipe) is left untouched, so a
|
|
794
|
+
* synchronous store re-emit cannot overwrite the swipe direction with the default fade. */
|
|
795
|
+
beginLeave(v, fling) {
|
|
796
|
+
if (v.leaving)
|
|
797
|
+
return;
|
|
798
|
+
v.leaving = true;
|
|
799
|
+
v.dragging = false;
|
|
800
|
+
if (fling !== undefined)
|
|
801
|
+
v.fling = fling;
|
|
802
|
+
const delay = this.prefersReducedMotion() ? 0 : MnAlertOutletComponent.EXIT_MS;
|
|
803
|
+
const timer = setTimeout(() => this.removeView(v.alert.id), delay);
|
|
804
|
+
this.exitTimers.set(v.alert.id, timer);
|
|
805
|
+
}
|
|
806
|
+
removeView(id) {
|
|
807
|
+
const timer = this.exitTimers.get(id);
|
|
808
|
+
if (timer) {
|
|
809
|
+
clearTimeout(timer);
|
|
810
|
+
this.exitTimers.delete(id);
|
|
811
|
+
}
|
|
812
|
+
this.views.update(list => list.filter(v => v.alert.id !== id));
|
|
813
|
+
}
|
|
814
|
+
// =========================
|
|
815
|
+
// Swipe-to-dismiss
|
|
816
|
+
// =========================
|
|
817
|
+
onPointerDown(event, v) {
|
|
818
|
+
if (v.leaving)
|
|
819
|
+
return;
|
|
820
|
+
// Don't hijack a press that begins on an interactive control inside the alert.
|
|
821
|
+
if (event.target.closest('button, a, input, textarea, select'))
|
|
822
|
+
return;
|
|
823
|
+
if (event.button !== undefined && event.button !== 0)
|
|
824
|
+
return;
|
|
825
|
+
const coord = this.axisCoord(event);
|
|
826
|
+
this.activeDrag = {
|
|
827
|
+
view: v,
|
|
828
|
+
startCoord: coord,
|
|
829
|
+
lastSample: { c: coord, t: event.timeStamp },
|
|
830
|
+
prevSample: { c: coord, t: event.timeStamp },
|
|
831
|
+
};
|
|
832
|
+
v.dragging = true;
|
|
833
|
+
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
834
|
+
}
|
|
835
|
+
onPointerMove(event, v) {
|
|
836
|
+
const drag = this.activeDrag;
|
|
837
|
+
if (!drag || drag.view !== v)
|
|
838
|
+
return;
|
|
839
|
+
const delta = this.axisCoord(event) - drag.startCoord;
|
|
840
|
+
// Dismissal is one-directional: vertical (Apple) tracks upward only (clamp ≤ 0),
|
|
841
|
+
// horizontal (Android/other) tracks rightward only (clamp ≥ 0). A drag the other way
|
|
842
|
+
// rests at 0 and springs back.
|
|
843
|
+
v.drag = this.vertical ? Math.min(0, delta) : Math.max(0, delta);
|
|
844
|
+
drag.prevSample = drag.lastSample;
|
|
845
|
+
drag.lastSample = { c: this.axisCoord(event), t: event.timeStamp };
|
|
846
|
+
}
|
|
847
|
+
onPointerUp(event, v) {
|
|
848
|
+
const drag = this.activeDrag;
|
|
849
|
+
if (!drag || drag.view !== v)
|
|
850
|
+
return;
|
|
851
|
+
this.activeDrag = null;
|
|
852
|
+
v.dragging = false;
|
|
853
|
+
if (this.shouldDismiss(drag, v)) {
|
|
854
|
+
// Fling the card the rest of the way off its edge, then dismiss through the store.
|
|
855
|
+
// Vertical flings upward (off the top); horizontal flings rightward (off the right).
|
|
856
|
+
const extent = this.viewportExtent() + Math.abs(v.drag);
|
|
857
|
+
const fling = this.vertical ? -extent : extent;
|
|
858
|
+
this.beginLeave(v, fling);
|
|
859
|
+
this.store.dismiss(v.alert.id);
|
|
860
|
+
}
|
|
861
|
+
else {
|
|
862
|
+
// Snap back to rest; the wrapper's transition eases the offset → 0.
|
|
863
|
+
v.drag = 0;
|
|
864
|
+
}
|
|
865
|
+
// Notify: pointerup is template-bound (schedules CD), but the leave/snap mutations live
|
|
866
|
+
// on the view object — re-set the signal so the new transform is reflected reliably.
|
|
867
|
+
this.views.update(list => [...list]);
|
|
868
|
+
}
|
|
869
|
+
shouldDismiss(drag, v) {
|
|
870
|
+
if (Math.abs(v.drag) > MnAlertOutletComponent.SWIPE_DISMISS_THRESHOLD)
|
|
871
|
+
return true;
|
|
872
|
+
const dt = drag.lastSample.t - drag.prevSample.t;
|
|
873
|
+
const velocity = dt > 0 ? Math.abs(drag.lastSample.c - drag.prevSample.c) / dt : 0;
|
|
874
|
+
return velocity > MnAlertOutletComponent.FLICK_VELOCITY
|
|
875
|
+
&& Math.abs(v.drag) > MnAlertOutletComponent.FLICK_MIN_DISTANCE;
|
|
876
|
+
}
|
|
877
|
+
// =========================
|
|
878
|
+
// Template style helpers
|
|
879
|
+
// =========================
|
|
880
|
+
/** Offset transform for a card: the fling target while leaving via swipe, otherwise the
|
|
881
|
+
* live drag offset, translated along the active axis. Returns null when neither applies,
|
|
882
|
+
* letting the stylesheet own the default (fade-in-place) leave. */
|
|
883
|
+
itemTransform(v) {
|
|
884
|
+
if (v.leaving) {
|
|
885
|
+
return v.fling !== 0 ? this.translate(v.fling) : null;
|
|
886
|
+
}
|
|
887
|
+
return v.drag !== 0 ? this.translate(v.drag) : null;
|
|
888
|
+
}
|
|
889
|
+
/** Fades a card toward a floor as it is dragged toward the dismiss threshold. Null (CSS
|
|
890
|
+
* owns opacity) when at rest or leaving. */
|
|
891
|
+
itemOpacity(v) {
|
|
892
|
+
if (v.leaving || !v.dragging || v.drag === 0)
|
|
893
|
+
return null;
|
|
894
|
+
const progress = Math.min(Math.abs(v.drag) / MnAlertOutletComponent.SWIPE_DISMISS_THRESHOLD, 1);
|
|
895
|
+
return 1 - progress * (1 - MnAlertOutletComponent.DRAG_OPACITY_FLOOR);
|
|
896
|
+
}
|
|
897
|
+
translate(px) {
|
|
898
|
+
return this.vertical ? `translateY(${px}px)` : `translateX(${px}px)`;
|
|
899
|
+
}
|
|
900
|
+
axisCoord(event) {
|
|
901
|
+
return this.vertical ? event.clientY : event.clientX;
|
|
902
|
+
}
|
|
903
|
+
viewportExtent() {
|
|
904
|
+
if (typeof window === 'undefined')
|
|
905
|
+
return 400;
|
|
906
|
+
return this.vertical ? window.innerHeight : window.innerWidth;
|
|
907
|
+
}
|
|
908
|
+
/** Whether the current device runs an Apple OS (iOS/iPadOS/macOS). iPadOS 13+ masquerades
|
|
909
|
+
* as a Mac, so it is caught by the touch-capable-Mac branch. */
|
|
910
|
+
detectApple() {
|
|
911
|
+
if (typeof navigator === 'undefined')
|
|
912
|
+
return false;
|
|
913
|
+
const ua = navigator.userAgent ?? '';
|
|
914
|
+
const platform = navigator.platform ?? '';
|
|
915
|
+
const isIOS = /iP(hone|ad|od)/.test(platform) || /iPad|iPhone|iPod/.test(ua);
|
|
916
|
+
const isTouchMac = /Mac/.test(platform + ' ' + ua)
|
|
917
|
+
&& typeof document !== 'undefined' && 'ontouchend' in document;
|
|
918
|
+
const isMac = /Mac/.test(ua);
|
|
919
|
+
return isIOS || isTouchMac || isMac;
|
|
920
|
+
}
|
|
921
|
+
prefersReducedMotion() {
|
|
922
|
+
return typeof window !== 'undefined'
|
|
923
|
+
&& typeof window.matchMedia === 'function'
|
|
924
|
+
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
925
|
+
}
|
|
700
926
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnAlertOutletComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
701
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnAlertOutletComponent, isStandalone: true, selector: "mn-alert-outlet", inputs: { template: "template" }, ngImport: i0, template: "
|
|
927
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnAlertOutletComponent, isStandalone: true, selector: "mn-alert-outlet", inputs: { swipeAxis: "swipeAxis", template: "template" }, ngImport: i0, template: "<div class=\"fixed top-4 left-0 right-0 z-[9999] flex flex-col w-full px-2 sm:px-0 sm:left-auto sm:right-4 sm:max-w-sm\">\n @for (v of views(); track trackById($index, v)) {\n <div\n class=\"mn-alert-item\"\n [class.dragging]=\"v.dragging\"\n [class.leaving]=\"v.leaving\"\n [style.touch-action]=\"touchAction\"\n [style.transform]=\"itemTransform(v)\"\n [style.opacity]=\"itemOpacity(v)\"\n (pointerdown)=\"onPointerDown($event, v)\"\n (pointermove)=\"onPointerMove($event, v)\"\n (pointerup)=\"onPointerUp($event, v)\"\n (pointercancel)=\"onPointerUp($event, v)\"\n >\n <div class=\"mn-alert-inner\">\n @if (template) {\n <ng-container\n [ngTemplateOutlet]=\"template\"\n [ngTemplateOutletContext]=\"contextFor(v.alert)\">\n </ng-container>\n } @else {\n <div [class]=\"getAlertClasses(v.alert)\" [class.extra]=\"v.alert.cssClass\" class=\"relative\">\n <div class=\"flex-1 min-w-0 pr-8\">\n <h4 class=\"font-semibold text-sm\">{{ v.alert.title }}</h4>\n @if (v.alert.subTitle) {\n <p class=\"text-sm mt-1 opacity-90 leading-tight\">{{ v.alert.subTitle }}</p>\n }\n </div>\n <button\n [attr.aria-label]=\"closeLabel\"\n mnButton\n [data]=\"{ size: 'md', variant: 'text' }\"\n (click)=\"dismissAlert(v.alert.id)\"\n class=\"absolute top-2 right-2 shrink-0 !text-current hover:!bg-current/10\"\n type=\"button\"\n >\n ×\n </button>\n </div>\n }\n </div>\n </div>\n }\n</div>\n", styles: [":host{--mn-alert-exit: .32s}.mn-alert-item{display:grid;grid-template-rows:1fr;margin-bottom:.75rem;touch-action:pan-y;cursor:grab;transition:transform .28s cubic-bezier(.22,1,.36,1),opacity var(--mn-alert-exit) ease,grid-template-rows var(--mn-alert-exit) cubic-bezier(.4,0,.2,1),margin-bottom var(--mn-alert-exit) cubic-bezier(.4,0,.2,1)}.mn-alert-item:last-child{margin-bottom:0}.mn-alert-item.dragging{transition:none;cursor:grabbing;-webkit-user-select:none;user-select:none}.mn-alert-inner{min-height:0}.mn-alert-item.leaving{grid-template-rows:0fr;opacity:0;margin-bottom:0;pointer-events:none}.mn-alert-item.leaving .mn-alert-inner{overflow:hidden}@media(prefers-reduced-motion:reduce){.mn-alert-item{transition:none}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: MnButton, selector: "button[mnButton], a[mnButton]", inputs: ["data"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
702
928
|
}
|
|
703
929
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnAlertOutletComponent, decorators: [{
|
|
704
930
|
type: Component,
|
|
705
|
-
args: [{ selector: 'mn-alert-outlet', standalone: true, imports: [CommonModule, MnButton], changeDetection: ChangeDetectionStrategy.OnPush, template: "
|
|
706
|
-
}], ctorParameters: () => [], propDecorators: {
|
|
931
|
+
args: [{ selector: 'mn-alert-outlet', standalone: true, imports: [CommonModule, MnButton], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"fixed top-4 left-0 right-0 z-[9999] flex flex-col w-full px-2 sm:px-0 sm:left-auto sm:right-4 sm:max-w-sm\">\n @for (v of views(); track trackById($index, v)) {\n <div\n class=\"mn-alert-item\"\n [class.dragging]=\"v.dragging\"\n [class.leaving]=\"v.leaving\"\n [style.touch-action]=\"touchAction\"\n [style.transform]=\"itemTransform(v)\"\n [style.opacity]=\"itemOpacity(v)\"\n (pointerdown)=\"onPointerDown($event, v)\"\n (pointermove)=\"onPointerMove($event, v)\"\n (pointerup)=\"onPointerUp($event, v)\"\n (pointercancel)=\"onPointerUp($event, v)\"\n >\n <div class=\"mn-alert-inner\">\n @if (template) {\n <ng-container\n [ngTemplateOutlet]=\"template\"\n [ngTemplateOutletContext]=\"contextFor(v.alert)\">\n </ng-container>\n } @else {\n <div [class]=\"getAlertClasses(v.alert)\" [class.extra]=\"v.alert.cssClass\" class=\"relative\">\n <div class=\"flex-1 min-w-0 pr-8\">\n <h4 class=\"font-semibold text-sm\">{{ v.alert.title }}</h4>\n @if (v.alert.subTitle) {\n <p class=\"text-sm mt-1 opacity-90 leading-tight\">{{ v.alert.subTitle }}</p>\n }\n </div>\n <button\n [attr.aria-label]=\"closeLabel\"\n mnButton\n [data]=\"{ size: 'md', variant: 'text' }\"\n (click)=\"dismissAlert(v.alert.id)\"\n class=\"absolute top-2 right-2 shrink-0 !text-current hover:!bg-current/10\"\n type=\"button\"\n >\n ×\n </button>\n </div>\n }\n </div>\n </div>\n }\n</div>\n", styles: [":host{--mn-alert-exit: .32s}.mn-alert-item{display:grid;grid-template-rows:1fr;margin-bottom:.75rem;touch-action:pan-y;cursor:grab;transition:transform .28s cubic-bezier(.22,1,.36,1),opacity var(--mn-alert-exit) ease,grid-template-rows var(--mn-alert-exit) cubic-bezier(.4,0,.2,1),margin-bottom var(--mn-alert-exit) cubic-bezier(.4,0,.2,1)}.mn-alert-item:last-child{margin-bottom:0}.mn-alert-item.dragging{transition:none;cursor:grabbing;-webkit-user-select:none;user-select:none}.mn-alert-inner{min-height:0}.mn-alert-item.leaving{grid-template-rows:0fr;opacity:0;margin-bottom:0;pointer-events:none}.mn-alert-item.leaving .mn-alert-inner{overflow:hidden}@media(prefers-reduced-motion:reduce){.mn-alert-item{transition:none}}\n"] }]
|
|
932
|
+
}], ctorParameters: () => [], propDecorators: { swipeAxis: [{
|
|
933
|
+
type: Input
|
|
934
|
+
}], template: [{
|
|
707
935
|
type: Input
|
|
708
936
|
}] } });
|
|
709
937
|
|
|
@@ -1469,8 +1697,10 @@ class MnInputField {
|
|
|
1469
1697
|
*/
|
|
1470
1698
|
focus() {
|
|
1471
1699
|
const input = this.el.nativeElement.querySelector('input');
|
|
1700
|
+
// `preventScroll` so autofocusing inside a popover (e.g. mn-dropdown's search) never
|
|
1701
|
+
// scrolls the input into view — a scroll would trip the host's scroll-to-close logic.
|
|
1472
1702
|
if (input)
|
|
1473
|
-
input.focus();
|
|
1703
|
+
input.focus({ preventScroll: true });
|
|
1474
1704
|
}
|
|
1475
1705
|
resolveConfig() {
|
|
1476
1706
|
const instanceId = this.explicitInstanceId || `mn-input-${this.props.id}`;
|
|
@@ -4340,6 +4570,8 @@ const mnDropdownTriggerVariants = tv({
|
|
|
4340
4570
|
});
|
|
4341
4571
|
|
|
4342
4572
|
const MN_DROPDOWN_CONFIG = new InjectionToken('MN_DROPDOWN_CONFIG');
|
|
4573
|
+
/** Counter backing the auto-generated {@link MnDropdownProps.id} when a caller omits one. */
|
|
4574
|
+
let nextDropdownId = 0;
|
|
4343
4575
|
/** Foreground class per action colour token, matching mn-button's text variants. */
|
|
4344
4576
|
const ACTION_COLOR_CLASS = {
|
|
4345
4577
|
primary: 'text-primary',
|
|
@@ -4362,7 +4594,7 @@ const ACTION_COLOR_CLASS = {
|
|
|
4362
4594
|
* the multi-select applies.
|
|
4363
4595
|
*/
|
|
4364
4596
|
class MnDropdown {
|
|
4365
|
-
|
|
4597
|
+
datasource;
|
|
4366
4598
|
uiConfig = {};
|
|
4367
4599
|
configService = inject(MnConfigService);
|
|
4368
4600
|
sectionPath = inject(MN_SECTION_PATH, { optional: true }) ?? [];
|
|
@@ -4376,9 +4608,17 @@ class MnDropdown {
|
|
|
4376
4608
|
* `ElementRef` because `button[mnButton]` is a component — the default query would
|
|
4377
4609
|
* otherwise return the MnButton instance, which has no `nativeElement`. */
|
|
4378
4610
|
triggerRef;
|
|
4379
|
-
/**
|
|
4380
|
-
*
|
|
4381
|
-
|
|
4611
|
+
/**
|
|
4612
|
+
* Layout classes for the anchored popover panel. Searchable menus become a flex column
|
|
4613
|
+
* so the search box can be pinned (`shrink-0`) above a single scrolling list region —
|
|
4614
|
+
* paired with {@link panelFloorPx}, that keeps the popover a fixed height while the
|
|
4615
|
+
* filter runs, instead of the panel resizing on every keystroke. The mobile sheet is
|
|
4616
|
+
* rendered by mn-bottom-sheet instead, so it needs no branch here.
|
|
4617
|
+
*/
|
|
4618
|
+
get panelClasses() {
|
|
4619
|
+
const base = 'fixed z-9999 min-w-48 max-w-[min(20rem,90vw)] bg-base-100 border border-base-300 rounded-md shadow-lg py-1 max-h-[60vh] -translate-x-full';
|
|
4620
|
+
return this.isSearchable ? `${base} flex flex-col overflow-hidden` : `${base} overflow-auto`;
|
|
4621
|
+
}
|
|
4382
4622
|
/** Tailwind's `sm` breakpoint — below this the menu renders as a bottom sheet.
|
|
4383
4623
|
* Kept in step with the same constant in mn-bottom-sheet / mn-multi-select. */
|
|
4384
4624
|
static SHEET_MAX_WIDTH = 639.98;
|
|
@@ -4394,11 +4634,30 @@ class MnDropdown {
|
|
|
4394
4634
|
sheetMediaListener = null;
|
|
4395
4635
|
/** `document.body`'s inline `overflow` before the sheet locked it, restored on close. */
|
|
4396
4636
|
previousBodyOverflow = null;
|
|
4637
|
+
/**
|
|
4638
|
+
* The anchored popover's opened height, locked so a shorter filtered list cannot resize
|
|
4639
|
+
* it mid-type. Captured on the frame after the panel appears (with the full, unfiltered
|
|
4640
|
+
* list), so applying it is jump-free — it only stops a later shrink. Null while closed
|
|
4641
|
+
* or when the menu is not searchable, leaving the plain content-height popover untouched.
|
|
4642
|
+
*/
|
|
4643
|
+
panelFloorPx = null;
|
|
4644
|
+
/**
|
|
4645
|
+
* The mobile sheet's opened height, applied as a `min-height` floor for the same reason
|
|
4646
|
+
* as {@link panelFloorPx} — mirroring mn-multi-select's sheet floor. Null while anchored,
|
|
4647
|
+
* closed, or non-searchable.
|
|
4648
|
+
*/
|
|
4649
|
+
sheetFloorPx = null;
|
|
4397
4650
|
/** Watches the trigger while open, so the panel closes if the trigger is hidden. */
|
|
4398
4651
|
visibilityObserver = null;
|
|
4399
4652
|
/** Capture-phase scroll listener installed while open, closing on any ancestor scroll. */
|
|
4400
4653
|
scrollCapture = null;
|
|
4401
4654
|
isOpen = false;
|
|
4655
|
+
/** Stable fallback id, used when {@link MnDropdownProps.id} is omitted. Generated once per
|
|
4656
|
+
* instance so the a11y wiring (menu id, `aria-controls`, the search input) stays valid. */
|
|
4657
|
+
autoId = `mn-dropdown-${++nextDropdownId}`;
|
|
4658
|
+
/** Current text in the search input, cleared on close. Only meaningful when the menu
|
|
4659
|
+
* is {@link MnDropdownProps.searchable}. */
|
|
4660
|
+
searchTerm = '';
|
|
4402
4661
|
/** Popover position computed from the trigger's bounding rect. */
|
|
4403
4662
|
dropdownStyle = { top: '0px', left: '0px' };
|
|
4404
4663
|
/**
|
|
@@ -4406,14 +4665,28 @@ class MnDropdown {
|
|
|
4406
4665
|
* portal rationale) and detached when the query clears on close/destroy.
|
|
4407
4666
|
*/
|
|
4408
4667
|
set dropdownRef(ref) {
|
|
4409
|
-
|
|
4668
|
+
const el = ref?.nativeElement ?? null;
|
|
4669
|
+
this.movedPanel = this.portal(el, this.movedPanel);
|
|
4670
|
+
if (el && this.isSearchable) {
|
|
4671
|
+
this.capturePanelFloor(el);
|
|
4672
|
+
}
|
|
4673
|
+
else if (!el) {
|
|
4674
|
+
this.panelFloorPx = null;
|
|
4675
|
+
}
|
|
4410
4676
|
}
|
|
4411
4677
|
/**
|
|
4412
4678
|
* The bottom-sheet host, relocated to `document.body` so its `position: fixed`
|
|
4413
4679
|
* children anchor to the viewport rather than a transformed ancestor.
|
|
4414
4680
|
*/
|
|
4415
4681
|
set sheetRef(ref) {
|
|
4416
|
-
|
|
4682
|
+
const el = ref?.nativeElement ?? null;
|
|
4683
|
+
this.movedSheet = this.portal(el, this.movedSheet);
|
|
4684
|
+
if (el && this.isSearchable) {
|
|
4685
|
+
this.captureSheetFloor(el);
|
|
4686
|
+
}
|
|
4687
|
+
else if (!el) {
|
|
4688
|
+
this.sheetFloorPx = null;
|
|
4689
|
+
}
|
|
4417
4690
|
}
|
|
4418
4691
|
ngOnInit() {
|
|
4419
4692
|
this.resolveConfig();
|
|
@@ -4433,7 +4706,7 @@ class MnDropdown {
|
|
|
4433
4706
|
});
|
|
4434
4707
|
}
|
|
4435
4708
|
resolveConfig() {
|
|
4436
|
-
const instanceId = this.explicitInstanceId || `mn-dropdown-${this.
|
|
4709
|
+
const instanceId = this.explicitInstanceId || `mn-dropdown-${this.resolvedId}`;
|
|
4437
4710
|
this.uiConfig = this.configService.resolve('mn-dropdown', this.sectionPath, instanceId);
|
|
4438
4711
|
}
|
|
4439
4712
|
// ── Breakpoint watching ──
|
|
@@ -4463,7 +4736,7 @@ class MnDropdown {
|
|
|
4463
4736
|
this.close();
|
|
4464
4737
|
return;
|
|
4465
4738
|
}
|
|
4466
|
-
if (this.
|
|
4739
|
+
if (this.datasource.actions.length === 0)
|
|
4467
4740
|
return;
|
|
4468
4741
|
this.isOpen = true;
|
|
4469
4742
|
if (this.isSheet) {
|
|
@@ -4480,12 +4753,15 @@ class MnDropdown {
|
|
|
4480
4753
|
if (!this.isOpen)
|
|
4481
4754
|
return;
|
|
4482
4755
|
this.isOpen = false;
|
|
4756
|
+
this.searchTerm = '';
|
|
4757
|
+
this.panelFloorPx = null;
|
|
4758
|
+
this.sheetFloorPx = null;
|
|
4483
4759
|
this.stopWatchingTrigger();
|
|
4484
4760
|
this.unlockBodyScroll();
|
|
4485
4761
|
}
|
|
4486
4762
|
/** Whether the menu should currently render as a bottom sheet. */
|
|
4487
4763
|
get isSheet() {
|
|
4488
|
-
return this.
|
|
4764
|
+
return this.datasource.mobileSheet !== false && this.isNarrowViewport;
|
|
4489
4765
|
}
|
|
4490
4766
|
/** Fires an action and closes. Ignores disabled items defensively. */
|
|
4491
4767
|
select(action) {
|
|
@@ -4494,6 +4770,47 @@ class MnDropdown {
|
|
|
4494
4770
|
this.close();
|
|
4495
4771
|
action.run();
|
|
4496
4772
|
}
|
|
4773
|
+
// ── Search ──
|
|
4774
|
+
/** Whether the filter input is shown — the explicit `searchable` prop, off by default. */
|
|
4775
|
+
get isSearchable() {
|
|
4776
|
+
return this.datasource.searchable === true;
|
|
4777
|
+
}
|
|
4778
|
+
/**
|
|
4779
|
+
* Records the current filter text as the search input changes. The input's
|
|
4780
|
+
* ControlValueAccessor emits `null` for an empty field (its text adapter maps `''` to
|
|
4781
|
+
* `null`), so coerce to `''` — otherwise clearing or backspacing the box would leave
|
|
4782
|
+
* `searchTerm` null and {@link filteredActions}'s `.trim()` would throw, freezing the menu.
|
|
4783
|
+
*/
|
|
4784
|
+
onSearch(term) {
|
|
4785
|
+
this.searchTerm = term ?? '';
|
|
4786
|
+
}
|
|
4787
|
+
/**
|
|
4788
|
+
* The actions currently passing the filter, in their declared order. Every action when
|
|
4789
|
+
* the menu is not searchable or the box is empty; otherwise those whose resolved label
|
|
4790
|
+
* or {@link MnDropdownAction.keywords} contain the (case-insensitive) query.
|
|
4791
|
+
*/
|
|
4792
|
+
get filteredActions() {
|
|
4793
|
+
const term = (this.searchTerm ?? '').trim().toLowerCase();
|
|
4794
|
+
if (!this.isSearchable || !term)
|
|
4795
|
+
return this.datasource.actions;
|
|
4796
|
+
// While filtering, separators are dropped — a divider stranded between or after hidden
|
|
4797
|
+
// results is meaningless — so match only real actions.
|
|
4798
|
+
return this.datasource.actions.filter(item => {
|
|
4799
|
+
if (this.isSeparator(item))
|
|
4800
|
+
return false;
|
|
4801
|
+
const haystack = `${this.actionLabel(item)} ${item.keywords ?? ''}`.toLowerCase();
|
|
4802
|
+
return haystack.includes(term);
|
|
4803
|
+
});
|
|
4804
|
+
}
|
|
4805
|
+
/**
|
|
4806
|
+
* Runs the first still-visible, enabled action — the Enter key's target, matching a
|
|
4807
|
+
* help search where Enter opens the top hit. Skips separators. No-op when nothing matches.
|
|
4808
|
+
*/
|
|
4809
|
+
selectFirstVisible() {
|
|
4810
|
+
const first = this.filteredActions.find((item) => !this.isSeparator(item) && !item.disabled);
|
|
4811
|
+
if (first)
|
|
4812
|
+
this.select(first);
|
|
4813
|
+
}
|
|
4497
4814
|
// ── Positioning ──
|
|
4498
4815
|
updateDropdownPosition() {
|
|
4499
4816
|
if (!this.triggerRef)
|
|
@@ -4579,6 +4896,48 @@ class MnDropdown {
|
|
|
4579
4896
|
}
|
|
4580
4897
|
this.previousBodyOverflow = null;
|
|
4581
4898
|
}
|
|
4899
|
+
// ── Height floors (searchable only) ──
|
|
4900
|
+
/**
|
|
4901
|
+
* Records the anchored popover's opened height and locks it via {@link panelFloorPx}.
|
|
4902
|
+
* Measured on the next frame so the read reflects the fully-rendered, unfiltered list
|
|
4903
|
+
* (the search box is empty on open) and never forces a reflow mid change-detection. The
|
|
4904
|
+
* value equals the current height, so applying it is jump-free — it only stops a later,
|
|
4905
|
+
* shorter filtered list from shrinking the panel.
|
|
4906
|
+
*/
|
|
4907
|
+
capturePanelFloor(panelEl) {
|
|
4908
|
+
if (typeof requestAnimationFrame !== 'function') {
|
|
4909
|
+
this.panelFloorPx = panelEl.offsetHeight;
|
|
4910
|
+
return;
|
|
4911
|
+
}
|
|
4912
|
+
requestAnimationFrame(() => {
|
|
4913
|
+
// The panel may have closed (or the query cleared) before the frame ran.
|
|
4914
|
+
if (!this.isOpen || this.movedPanel !== panelEl)
|
|
4915
|
+
return;
|
|
4916
|
+
this.panelFloorPx = panelEl.offsetHeight;
|
|
4917
|
+
this.cdr.markForCheck();
|
|
4918
|
+
});
|
|
4919
|
+
}
|
|
4920
|
+
/**
|
|
4921
|
+
* Records the sheet's opened height as its `min-height` floor, on the same next-frame
|
|
4922
|
+
* basis as {@link capturePanelFloor}. `hostEl` is the portalled mn-bottom-sheet host
|
|
4923
|
+
* (`display: contents`), so the height is read from its `.mn-sheet-container` child.
|
|
4924
|
+
*/
|
|
4925
|
+
captureSheetFloor(hostEl) {
|
|
4926
|
+
const measure = () => {
|
|
4927
|
+
const container = hostEl.querySelector('.mn-sheet-container');
|
|
4928
|
+
return container?.offsetHeight ?? hostEl.offsetHeight;
|
|
4929
|
+
};
|
|
4930
|
+
if (typeof requestAnimationFrame !== 'function') {
|
|
4931
|
+
this.sheetFloorPx = measure();
|
|
4932
|
+
return;
|
|
4933
|
+
}
|
|
4934
|
+
requestAnimationFrame(() => {
|
|
4935
|
+
if (!this.isOpen || this.movedSheet !== hostEl)
|
|
4936
|
+
return;
|
|
4937
|
+
this.sheetFloorPx = measure();
|
|
4938
|
+
this.cdr.markForCheck();
|
|
4939
|
+
});
|
|
4940
|
+
}
|
|
4582
4941
|
// ── Portal helper (see mn-multi-select for the full rationale) ──
|
|
4583
4942
|
portal(el, current) {
|
|
4584
4943
|
if (el) {
|
|
@@ -4611,6 +4970,20 @@ class MnDropdown {
|
|
|
4611
4970
|
isTemplateRef(value) {
|
|
4612
4971
|
return value instanceof TemplateRef;
|
|
4613
4972
|
}
|
|
4973
|
+
/** Whether a list entry is a {@link MnDropdownSeparator} rather than a command. */
|
|
4974
|
+
isSeparator(item) {
|
|
4975
|
+
return item.separator === true;
|
|
4976
|
+
}
|
|
4977
|
+
/**
|
|
4978
|
+
* Narrows a list entry to a command, or null for a separator. Used as `@if (asAction(item);
|
|
4979
|
+
* as action)` in the template so the item loop gets a reliably-typed {@link MnDropdownAction}
|
|
4980
|
+
* without depending on template narrowing of the {@link isSeparator} guard.
|
|
4981
|
+
* @param item The list entry to narrow.
|
|
4982
|
+
* @returns The command, or null when the entry is a separator.
|
|
4983
|
+
*/
|
|
4984
|
+
asAction(item) {
|
|
4985
|
+
return this.isSeparator(item) ? null : item;
|
|
4986
|
+
}
|
|
4614
4987
|
/**
|
|
4615
4988
|
* Foreground class for an item: an explicit {@link MnDropdownAction.color}, else the
|
|
4616
4989
|
* destructive red for a {@link MnDropdownAction.danger} item, else the default text.
|
|
@@ -4624,45 +4997,108 @@ class MnDropdown {
|
|
|
4624
4997
|
}
|
|
4625
4998
|
/** Accessible name for the ⋯ trigger button. */
|
|
4626
4999
|
get triggerAriaLabel() {
|
|
4627
|
-
const translated = this.
|
|
4628
|
-
return translated ?? this.
|
|
5000
|
+
const translated = this.datasource.ariaLabelKey ? this.lang.translateIfPresent(this.datasource.ariaLabelKey) : undefined;
|
|
5001
|
+
return translated ?? this.datasource.ariaLabel ?? this.uiConfig.ariaLabel ?? 'Actions';
|
|
4629
5002
|
}
|
|
4630
5003
|
/** The visible text on the trigger, or null for an icon-only ⋯ trigger. */
|
|
4631
5004
|
get triggerLabelText() {
|
|
4632
|
-
const translated = this.
|
|
4633
|
-
return translated ?? this.
|
|
5005
|
+
const translated = this.datasource.triggerLabelKey ? this.lang.translateIfPresent(this.datasource.triggerLabelKey) : undefined;
|
|
5006
|
+
return translated ?? this.datasource.triggerLabel ?? null;
|
|
4634
5007
|
}
|
|
4635
|
-
/**
|
|
4636
|
-
*
|
|
4637
|
-
|
|
4638
|
-
|
|
5008
|
+
/**
|
|
5009
|
+
* The trigger's glyph, normalised to a single representation so the template renders it
|
|
5010
|
+
* one way — the same template-or-lucide-data path the menu items use — with no per-preset
|
|
5011
|
+
* switch. Resolves, in order: an explicit `'none'` (no glyph); a caller's custom template
|
|
5012
|
+
* or lucide data ({@link MnActionIcon}); otherwise a built-in preset mapped to its own
|
|
5013
|
+
* lucide data (a labelled trigger defaults to the chevron, an icon-only one to the dots).
|
|
5014
|
+
* @returns A template glyph, an icon-data glyph with its render size, or null for none.
|
|
5015
|
+
*/
|
|
5016
|
+
resolveTriggerGlyph() {
|
|
5017
|
+
const icon = this.datasource.triggerIcon;
|
|
5018
|
+
if (icon === 'none')
|
|
5019
|
+
return null;
|
|
5020
|
+
if (icon instanceof TemplateRef)
|
|
5021
|
+
return { template: icon };
|
|
5022
|
+
if (icon != null && typeof icon !== 'string')
|
|
5023
|
+
return { data: icon, size: 18, dim: false };
|
|
5024
|
+
const preset = typeof icon === 'string' ? icon : this.triggerLabelText ? 'chevron' : 'dots-vertical';
|
|
5025
|
+
// The chevron is a touch smaller and dimmed, matching a select's trailing affordance.
|
|
5026
|
+
return preset === 'chevron'
|
|
5027
|
+
? { data: LucideChevronDown.icon, size: 16, dim: true }
|
|
5028
|
+
: { data: LucideEllipsisVertical.icon, size: 18, dim: false };
|
|
5029
|
+
}
|
|
5030
|
+
/** The trigger glyph when it is a caller's template, else null. Split from
|
|
5031
|
+
* {@link triggerIconData} so the template narrows without a discriminated union. */
|
|
5032
|
+
get triggerIconTemplate() {
|
|
5033
|
+
const glyph = this.resolveTriggerGlyph();
|
|
5034
|
+
return glyph && 'template' in glyph ? glyph.template : null;
|
|
5035
|
+
}
|
|
5036
|
+
/** The trigger glyph when it is lucide data (a preset or caller data), with its render
|
|
5037
|
+
* size and dim flag, else null. */
|
|
5038
|
+
get triggerIconData() {
|
|
5039
|
+
const glyph = this.resolveTriggerGlyph();
|
|
5040
|
+
return glyph && 'data' in glyph ? glyph : null;
|
|
4639
5041
|
}
|
|
4640
5042
|
/** Heading shown above the menu/sheet, or null when none is configured. */
|
|
4641
5043
|
get menuLabel() {
|
|
4642
|
-
const translated = this.
|
|
4643
|
-
return translated ?? this.
|
|
5044
|
+
const translated = this.datasource.menuLabelKey ? this.lang.translateIfPresent(this.datasource.menuLabelKey) : undefined;
|
|
5045
|
+
return translated ?? this.datasource.menuLabel ?? this.uiConfig.menuLabel ?? null;
|
|
4644
5046
|
}
|
|
4645
5047
|
/** Accessible label for the sheet's close button. */
|
|
4646
5048
|
get closeLabel() {
|
|
4647
5049
|
return this.uiConfig.closeLabel ?? 'Close';
|
|
4648
5050
|
}
|
|
5051
|
+
/** Placeholder shown in the search input, preferring a resolved translation key. */
|
|
5052
|
+
get searchPlaceholder() {
|
|
5053
|
+
const translated = this.datasource.searchPlaceholderKey ? this.lang.translateIfPresent(this.datasource.searchPlaceholderKey) : undefined;
|
|
5054
|
+
return translated ?? this.datasource.searchPlaceholder ?? this.uiConfig.searchPlaceholder ?? 'Search...';
|
|
5055
|
+
}
|
|
5056
|
+
/** Text shown in place of the list when the filter matches no actions. */
|
|
5057
|
+
get searchEmptyLabel() {
|
|
5058
|
+
const translated = this.datasource.searchEmptyLabelKey ? this.lang.translateIfPresent(this.datasource.searchEmptyLabelKey) : undefined;
|
|
5059
|
+
return translated ?? this.datasource.searchEmptyLabel ?? this.uiConfig.searchEmptyLabel ?? 'No results';
|
|
5060
|
+
}
|
|
5061
|
+
/** The ghost look the trigger has always used; a bare or partial `triggerButton` merges
|
|
5062
|
+
* over this, so opting in without overriding anything keeps the current appearance. */
|
|
5063
|
+
static DEFAULT_TRIGGER_BUTTON = {
|
|
5064
|
+
size: 'sm',
|
|
5065
|
+
variant: 'text',
|
|
5066
|
+
color: 'gray',
|
|
5067
|
+
};
|
|
5068
|
+
/** mn-button config for the trigger: the ghost default, overlaid with any
|
|
5069
|
+
* {@link MnDropdownProps.triggerButton} the caller supplied. */
|
|
5070
|
+
get triggerData() {
|
|
5071
|
+
return { ...MnDropdown.DEFAULT_TRIGGER_BUTTON, ...this.datasource.triggerButton };
|
|
5072
|
+
}
|
|
4649
5073
|
get triggerClasses() {
|
|
5074
|
+
// Button mode: mn-button owns the entire look (fill/outline/size/radius/shape). The
|
|
5075
|
+
// trigger's square-box variants would fight that — `h-7 w-7` overrides its padding,
|
|
5076
|
+
// `text-base-content/80` its text colour — so keep only the label↔icon gap, which
|
|
5077
|
+
// mn-button's base does not provide.
|
|
5078
|
+
if (this.datasource.triggerButton)
|
|
5079
|
+
return 'gap-x-1.5';
|
|
5080
|
+
// A caller's template can be any size (an avatar, a badge) and sizes the trigger itself,
|
|
5081
|
+
// so skip the preset square-box — otherwise its `h-7 w-7` would clip the content, which
|
|
5082
|
+
// is why custom triggers used to need a `triggerButton` just to undo it. (A lucide-data
|
|
5083
|
+
// glyph is preset-sized, so it keeps the box for a consistent tap target.)
|
|
5084
|
+
if (this.datasource.triggerIcon instanceof TemplateRef)
|
|
5085
|
+
return 'gap-x-1.5';
|
|
4650
5086
|
return mnDropdownTriggerVariants({
|
|
4651
|
-
size: this.
|
|
4652
|
-
borderRadius: this.
|
|
5087
|
+
size: this.datasource.size,
|
|
5088
|
+
borderRadius: this.datasource.borderRadius,
|
|
4653
5089
|
labeled: !!this.triggerLabelText,
|
|
4654
5090
|
});
|
|
4655
5091
|
}
|
|
4656
5092
|
get resolvedId() {
|
|
4657
|
-
return this.
|
|
5093
|
+
return this.datasource.id ?? this.autoId;
|
|
4658
5094
|
}
|
|
4659
5095
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnDropdown, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
4660
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnDropdown, isStandalone: true, selector: "mn-lib-dropdown", inputs: {
|
|
5096
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnDropdown, isStandalone: true, selector: "mn-lib-dropdown", inputs: { datasource: "datasource" }, 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, read: ElementRef }, { propertyName: "dropdownRef", first: true, predicate: ["dropdown"], descendants: true }, { propertyName: "sheetRef", first: true, predicate: ["sheet"], descendants: true, read: ElementRef }], ngImport: i0, template: "<div class=\"relative inline-flex\">\n <!-- Trigger -->\n <button\n #trigger\n mnButton\n type=\"button\"\n [id]=\"resolvedId\"\n [data]=\"triggerData\"\n [ngClass]=\"triggerClasses\"\n [attr.aria-label]=\"triggerLabelText ? null : triggerAriaLabel\"\n [attr.aria-haspopup]=\"'menu'\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-controls]=\"isOpen ? resolvedId + '-menu' : null\"\n (click)=\"$event.stopPropagation(); toggle()\"\n >\n @if (triggerLabelText) {\n <span class=\"truncate\">{{ triggerLabelText }}</span>\n }\n <!-- One glyph, rendered like a menu item's icon: a caller's template, else lucide data\n (a preset resolves to its own data). No per-preset switch. -->\n @if (triggerIconTemplate; as tpl) {\n <span class=\"shrink-0 inline-flex items-center\">\n <ng-container [ngTemplateOutlet]=\"tpl\"></ng-container>\n </span>\n } @else if (triggerIconData; as glyph) {\n <span class=\"shrink-0 inline-flex items-center\">\n <svg [lucideIcon]=\"glyph.data\" [size]=\"glyph.size\" [class.opacity-70]=\"glyph.dim\"></svg>\n </span>\n }\n </button>\n\n <!-- Menu -->\n @if (isOpen) {\n @if (isSheet) {\n <!-- On mobile the menu is presented as the shared bottom sheet: chrome (backdrop,\n grabber, swipe-to-dismiss, slide animation) lives in mn-bottom-sheet; this\n component only projects the item list into it. The host is portalled to\n document.body (see the `sheet` ViewChild). -->\n <mn-bottom-sheet\n #sheet\n (dismiss)=\"close()\"\n [ariaLabel]=\"menuLabel || triggerAriaLabel\"\n [maxHeightVh]=\"80\"\n [minHeightPx]=\"sheetFloorPx\"\n >\n <div [id]=\"resolvedId + '-menu'\" role=\"menu\" class=\"flex flex-col flex-1 min-h-0 overflow-hidden\">\n <!-- The sheet covers its own trigger, so it needs a header and an explicit way out. -->\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-bold text-base-content truncate\">{{ menuLabel || triggerAriaLabel }}</p>\n <button\n mnButton\n type=\"button\"\n [data]=\"{ size: 'sm', variant: 'text', color: 'secondary' }\"\n class=\"shrink-0 cursor-pointer\"\n (click)=\"close()\"\n [attr.aria-label]=\"closeLabel\"\n ><svg lucideX [size]=\"20\"></svg></button>\n </div>\n <ng-container [ngTemplateOutlet]=\"searchBox\" [ngTemplateOutletContext]=\"{ sheet: true }\"></ng-container>\n <div class=\"flex-1 flex flex-col overflow-auto overscroll-contain\">\n <ng-container [ngTemplateOutlet]=\"items\" [ngTemplateOutletContext]=\"{ sheet: true }\"></ng-container>\n </div>\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 + '-menu'\"\n [ngClass]=\"panelClasses\"\n [style.left]=\"dropdownStyle.left\"\n [style.top]=\"dropdownStyle.top\"\n [style.height.px]=\"panelFloorPx\"\n role=\"menu\"\n >\n @if (menuLabel) {\n <p class=\"px-3 pt-1 pb-1.5 text-xs font-medium text-base-content/50 truncate shrink-0\">{{ menuLabel }}</p>\n }\n <ng-container [ngTemplateOutlet]=\"searchBox\" [ngTemplateOutletContext]=\"{ sheet: false }\"></ng-container>\n @if (isSearchable) {\n <!-- Its own scroll region so the pinned search box above stays fixed and the\n locked panel height (panelFloorPx) doesn't change as the list filters. The\n flex column lets the empty state fill and centre in the reserved space. -->\n <div class=\"flex-1 min-h-0 flex flex-col overflow-auto\">\n <ng-container [ngTemplateOutlet]=\"items\" [ngTemplateOutletContext]=\"{ sheet: false }\"></ng-container>\n </div>\n } @else {\n <ng-container [ngTemplateOutlet]=\"items\" [ngTemplateOutletContext]=\"{ sheet: false }\"></ng-container>\n }\n </div>\n }\n }\n\n <!-- The item list, shared verbatim by the sheet and the anchored popover. `sheet`\n only tunes the spacing so touch targets are roomier on mobile. -->\n <ng-template #items let-sheet=\"sheet\">\n @for (item of filteredActions; track $index) {\n @if (asAction(item); as action) {\n <button\n type=\"button\"\n role=\"menuitem\"\n [disabled]=\"action.disabled\"\n [class.opacity-50]=\"action.disabled\"\n [class.pointer-events-none]=\"action.disabled\"\n [ngClass]=\"[actionColorClass(action), sheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm']\"\n class=\"flex w-full shrink-0 items-center gap-x-2.5 text-left cursor-pointer hover:bg-base-200 focus-visible:bg-base-200 focus:outline-none transition-colors\"\n (click)=\"select(action)\"\n >\n @if (action.icon) {\n <span class=\"shrink-0 inline-flex items-center\">\n @if (isTemplateRef(action.icon)) {\n <ng-container [ngTemplateOutlet]=\"action.icon\"></ng-container>\n } @else {\n <!-- Data icon: rendered here so the item sizes it to match its own text\n (larger in the sheet, where the rows are touch-sized). -->\n <svg [lucideIcon]=\"$any(action.icon)\" [size]=\"sheet ? 18 : 16\"></svg>\n }\n </span>\n }\n <span class=\"truncate\">{{ actionLabel(action) }}</span>\n </button>\n } @else {\n <hr role=\"separator\" [ngClass]=\"sheet ? 'mx-4 my-1.5' : 'mx-2 my-1'\" class=\"shrink-0 border-t border-base-300\" />\n }\n }\n @if (isSearchable && filteredActions.length === 0) {\n <div class=\"flex-1 flex flex-col items-center justify-center gap-2 px-4 py-6 text-center text-base-content/50\">\n <svg lucideSearchX [size]=\"sheet ? 28 : 24\" class=\"opacity-60\"></svg>\n <span [ngClass]=\"sheet ? 'text-base' : 'text-sm'\">{{ searchEmptyLabel }}</span>\n </div>\n }\n </ng-template>\n\n <!-- The filter input, shared by the sheet and the anchored popover. Autofocused on\n desktop (`!sheet`) so typing starts immediately; deliberately not on mobile, where\n it would pop the soft keyboard and fight the viewport-anchored sheet. Enter runs\n the first visible action via the wrapper's bubbled keydown. -->\n <ng-template #searchBox let-sheet=\"sheet\">\n @if (isSearchable) {\n <!-- eslint-disable-next-line @angular-eslint/template/interactive-supports-focus -->\n <div\n (click)=\"$event.stopPropagation()\"\n (keydown.enter)=\"selectFirstVisible()\"\n [ngClass]=\"sheet ? '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: searchPlaceholder,\n ariaLabel: searchPlaceholder,\n fullWidth: true,\n size: 'sm',\n autoFocus: !sheet\n }\"\n ></mn-lib-input-field>\n </div>\n }\n </ng-template>\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: 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: MnInputField, selector: "mn-lib-input-field", inputs: ["props"] }, { kind: "component", type: LucideX, selector: "svg[lucideX]" }, { kind: "component", type: LucideSearchX, selector: "svg[lucideSearchX]" }, { kind: "component", type: LucideDynamicIcon, selector: "svg[lucideIcon]", inputs: ["lucideIcon"] }] });
|
|
4661
5097
|
}
|
|
4662
5098
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnDropdown, decorators: [{
|
|
4663
5099
|
type: Component,
|
|
4664
|
-
args: [{ selector: 'mn-lib-dropdown', standalone: true, imports: [NgClass, NgTemplateOutlet, MnButton, MnBottomSheet,
|
|
4665
|
-
}], propDecorators: {
|
|
5100
|
+
args: [{ selector: 'mn-lib-dropdown', standalone: true, imports: [NgClass, NgTemplateOutlet, FormsModule, MnButton, MnBottomSheet, MnInputField, LucideX, LucideSearchX, LucideDynamicIcon], template: "<div class=\"relative inline-flex\">\n <!-- Trigger -->\n <button\n #trigger\n mnButton\n type=\"button\"\n [id]=\"resolvedId\"\n [data]=\"triggerData\"\n [ngClass]=\"triggerClasses\"\n [attr.aria-label]=\"triggerLabelText ? null : triggerAriaLabel\"\n [attr.aria-haspopup]=\"'menu'\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-controls]=\"isOpen ? resolvedId + '-menu' : null\"\n (click)=\"$event.stopPropagation(); toggle()\"\n >\n @if (triggerLabelText) {\n <span class=\"truncate\">{{ triggerLabelText }}</span>\n }\n <!-- One glyph, rendered like a menu item's icon: a caller's template, else lucide data\n (a preset resolves to its own data). No per-preset switch. -->\n @if (triggerIconTemplate; as tpl) {\n <span class=\"shrink-0 inline-flex items-center\">\n <ng-container [ngTemplateOutlet]=\"tpl\"></ng-container>\n </span>\n } @else if (triggerIconData; as glyph) {\n <span class=\"shrink-0 inline-flex items-center\">\n <svg [lucideIcon]=\"glyph.data\" [size]=\"glyph.size\" [class.opacity-70]=\"glyph.dim\"></svg>\n </span>\n }\n </button>\n\n <!-- Menu -->\n @if (isOpen) {\n @if (isSheet) {\n <!-- On mobile the menu is presented as the shared bottom sheet: chrome (backdrop,\n grabber, swipe-to-dismiss, slide animation) lives in mn-bottom-sheet; this\n component only projects the item list into it. The host is portalled to\n document.body (see the `sheet` ViewChild). -->\n <mn-bottom-sheet\n #sheet\n (dismiss)=\"close()\"\n [ariaLabel]=\"menuLabel || triggerAriaLabel\"\n [maxHeightVh]=\"80\"\n [minHeightPx]=\"sheetFloorPx\"\n >\n <div [id]=\"resolvedId + '-menu'\" role=\"menu\" class=\"flex flex-col flex-1 min-h-0 overflow-hidden\">\n <!-- The sheet covers its own trigger, so it needs a header and an explicit way out. -->\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-bold text-base-content truncate\">{{ menuLabel || triggerAriaLabel }}</p>\n <button\n mnButton\n type=\"button\"\n [data]=\"{ size: 'sm', variant: 'text', color: 'secondary' }\"\n class=\"shrink-0 cursor-pointer\"\n (click)=\"close()\"\n [attr.aria-label]=\"closeLabel\"\n ><svg lucideX [size]=\"20\"></svg></button>\n </div>\n <ng-container [ngTemplateOutlet]=\"searchBox\" [ngTemplateOutletContext]=\"{ sheet: true }\"></ng-container>\n <div class=\"flex-1 flex flex-col overflow-auto overscroll-contain\">\n <ng-container [ngTemplateOutlet]=\"items\" [ngTemplateOutletContext]=\"{ sheet: true }\"></ng-container>\n </div>\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 + '-menu'\"\n [ngClass]=\"panelClasses\"\n [style.left]=\"dropdownStyle.left\"\n [style.top]=\"dropdownStyle.top\"\n [style.height.px]=\"panelFloorPx\"\n role=\"menu\"\n >\n @if (menuLabel) {\n <p class=\"px-3 pt-1 pb-1.5 text-xs font-medium text-base-content/50 truncate shrink-0\">{{ menuLabel }}</p>\n }\n <ng-container [ngTemplateOutlet]=\"searchBox\" [ngTemplateOutletContext]=\"{ sheet: false }\"></ng-container>\n @if (isSearchable) {\n <!-- Its own scroll region so the pinned search box above stays fixed and the\n locked panel height (panelFloorPx) doesn't change as the list filters. The\n flex column lets the empty state fill and centre in the reserved space. -->\n <div class=\"flex-1 min-h-0 flex flex-col overflow-auto\">\n <ng-container [ngTemplateOutlet]=\"items\" [ngTemplateOutletContext]=\"{ sheet: false }\"></ng-container>\n </div>\n } @else {\n <ng-container [ngTemplateOutlet]=\"items\" [ngTemplateOutletContext]=\"{ sheet: false }\"></ng-container>\n }\n </div>\n }\n }\n\n <!-- The item list, shared verbatim by the sheet and the anchored popover. `sheet`\n only tunes the spacing so touch targets are roomier on mobile. -->\n <ng-template #items let-sheet=\"sheet\">\n @for (item of filteredActions; track $index) {\n @if (asAction(item); as action) {\n <button\n type=\"button\"\n role=\"menuitem\"\n [disabled]=\"action.disabled\"\n [class.opacity-50]=\"action.disabled\"\n [class.pointer-events-none]=\"action.disabled\"\n [ngClass]=\"[actionColorClass(action), sheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm']\"\n class=\"flex w-full shrink-0 items-center gap-x-2.5 text-left cursor-pointer hover:bg-base-200 focus-visible:bg-base-200 focus:outline-none transition-colors\"\n (click)=\"select(action)\"\n >\n @if (action.icon) {\n <span class=\"shrink-0 inline-flex items-center\">\n @if (isTemplateRef(action.icon)) {\n <ng-container [ngTemplateOutlet]=\"action.icon\"></ng-container>\n } @else {\n <!-- Data icon: rendered here so the item sizes it to match its own text\n (larger in the sheet, where the rows are touch-sized). -->\n <svg [lucideIcon]=\"$any(action.icon)\" [size]=\"sheet ? 18 : 16\"></svg>\n }\n </span>\n }\n <span class=\"truncate\">{{ actionLabel(action) }}</span>\n </button>\n } @else {\n <hr role=\"separator\" [ngClass]=\"sheet ? 'mx-4 my-1.5' : 'mx-2 my-1'\" class=\"shrink-0 border-t border-base-300\" />\n }\n }\n @if (isSearchable && filteredActions.length === 0) {\n <div class=\"flex-1 flex flex-col items-center justify-center gap-2 px-4 py-6 text-center text-base-content/50\">\n <svg lucideSearchX [size]=\"sheet ? 28 : 24\" class=\"opacity-60\"></svg>\n <span [ngClass]=\"sheet ? 'text-base' : 'text-sm'\">{{ searchEmptyLabel }}</span>\n </div>\n }\n </ng-template>\n\n <!-- The filter input, shared by the sheet and the anchored popover. Autofocused on\n desktop (`!sheet`) so typing starts immediately; deliberately not on mobile, where\n it would pop the soft keyboard and fight the viewport-anchored sheet. Enter runs\n the first visible action via the wrapper's bubbled keydown. -->\n <ng-template #searchBox let-sheet=\"sheet\">\n @if (isSearchable) {\n <!-- eslint-disable-next-line @angular-eslint/template/interactive-supports-focus -->\n <div\n (click)=\"$event.stopPropagation()\"\n (keydown.enter)=\"selectFirstVisible()\"\n [ngClass]=\"sheet ? '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: searchPlaceholder,\n ariaLabel: searchPlaceholder,\n fullWidth: true,\n size: 'sm',\n autoFocus: !sheet\n }\"\n ></mn-lib-input-field>\n </div>\n }\n </ng-template>\n</div>\n" }]
|
|
5101
|
+
}], propDecorators: { datasource: [{
|
|
4666
5102
|
type: Input,
|
|
4667
5103
|
args: [{ required: true }]
|
|
4668
5104
|
}], triggerRef: [{
|
|
@@ -7576,16 +8012,6 @@ class MnTable extends MnSelectableCollectionBase {
|
|
|
7576
8012
|
hasRowActions(column, row) {
|
|
7577
8013
|
return this.visibleRowActions(column, row).length > 0;
|
|
7578
8014
|
}
|
|
7579
|
-
/**
|
|
7580
|
-
* Whether a row's actions should offer the collapsing ⋯ variant: only when it has at
|
|
7581
|
-
* least its threshold's worth of *visible* actions (default 3). Below that, the inline
|
|
7582
|
-
* buttons stay put at every width — one or two fit on a phone. The width switch itself
|
|
7583
|
-
* is a pure container query in the template; this only decides whether to render the ⋯
|
|
7584
|
-
* path at all, per row.
|
|
7585
|
-
*/
|
|
7586
|
-
shouldCollapseActions(column, row) {
|
|
7587
|
-
return this.visibleRowActions(column, row).length >= (column.actionsCollapseThreshold ?? 3);
|
|
7588
|
-
}
|
|
7589
8015
|
/**
|
|
7590
8016
|
* Resolves a {@link MnRowValue}: either the fixed value, or the accessor applied to
|
|
7591
8017
|
* the row. Every per-row presentation field goes through here so the fixed and derived
|
|
@@ -7825,11 +8251,11 @@ class MnTable extends MnSelectableCollectionBase {
|
|
|
7825
8251
|
return template.replace('{{label}}', this.selectionLabelFor(row));
|
|
7826
8252
|
}
|
|
7827
8253
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnTable, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
7828
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnTable, isStandalone: true, selector: "mn-table", outputs: { sortChange: "sortChange", rowClick: "rowClick" }, host: { listeners: { "window:resize": "onWindowResize()" }, classAttribute: "block" }, viewQueries: [{ propertyName: "collectionBody", first: true, predicate: ["collectionBody"], descendants: true }], usesInheritance: true, ngImport: i0, template: "<!-- Everything that reflows lives inside a @container, so the breakpoints below\n measure the table's own width rather than the window's. A table in a modal,\n a sidebar or a narrow grid cell is far narrower than the viewport, and\n viewport breakpoints would hand it a desktop layout it has no room for. -->\n<div class=\"@container\">\n <!-- Selection summary: the whole selection, never paged, filtered or sorted. The\n table below answers \"what could I pick?\", which is why it is searched and paged;\n this answers \"what did I pick?\", which a paginated list cannot without hiding\n most of the answer on some other page. -->\n @if (showSelectionSummary) {\n <div class=\"flex flex-col gap-2 rounded-md border border-base-300 bg-base-200/50 p-3 mb-3\">\n <div class=\"flex items-center justify-between gap-2\">\n <span class=\"text-sm font-medium text-base-content\">{{ selectionSummaryTitle }}</span>\n <button\n (click)=\"clearSelection()\"\n [data]=\"{ variant: 'text', color: 'gray', size: 'sm', borderRadius: 'md', hover: true }\"\n class=\"gap-1 shrink-0\"\n mnButton\n type=\"button\"\n >\n <svg [size]=\"14\" lucideX></svg>\n <span>{{ selectionClearAllLabel }}</span>\n </button>\n </div>\n <!-- Only the first few tags render; the rest collapse behind a \"+N more\" control.\n The heading's count always states the true total, so this hides tags, never\n information. Expanded, the list is height-capped and scrolls, so even a\n selection of hundreds cannot push the table off screen. -->\n <ul\n [class.max-h-28]=\"selectionSummaryExpanded\"\n [class.overflow-y-auto]=\"selectionSummaryExpanded\"\n class=\"flex flex-wrap gap-1.5 list-none m-0 p-0\"\n >\n @for (row of visibleSelectionRows; track dataSource.getID(row)) {\n <li\n class=\"inline-flex items-center gap-1 rounded-full bg-base-100 border border-base-300 pl-2.5 pr-1 py-0.5 text-xs text-base-content max-w-full\">\n <span [attr.title]=\"selectionLabelFor(row)\" class=\"truncate\">{{ selectionLabelFor(row) }}</span>\n <button\n (click)=\"removeSelection(row)\"\n [attr.aria-label]=\"selectionRemoveLabel(row)\"\n class=\"shrink-0 rounded-full p-0.5 hover:bg-base-300 transition-colors cursor-pointer\"\n type=\"button\"\n >\n <svg [size]=\"12\" lucideX></svg>\n </button>\n </li>\n }\n @if (hiddenSelectionCount > 0 || selectionSummaryExpanded) {\n <li>\n <button\n (click)=\"toggleSelectionSummary()\"\n [attr.aria-expanded]=\"selectionSummaryExpanded\"\n [data]=\"{ variant: 'text', color: 'primary', size: 'sm', borderRadius: 'md', hover: true }\"\n class=\"text-xs\"\n mnButton\n type=\"button\"\n >\n {{ selectionSummaryToggleLabel }}\n </button>\n </li>\n }\n </ul>\n </div>\n }\n\n <!-- Toolbar: custom toolbar template + search. Only rendered when it has content,\n so an unsearchable table without toolbar templates emits no empty spacer. -->\n@if (dataSource.canSearch || dataSource.toolbarLeftTemplate || dataSource.toolbarRightTemplate || (hasColumnFilters && filtersCollapsed)) {\n <div class=\"flex flex-col @min-[420px]:flex-row @min-[420px]:items-center justify-between gap-2 mb-3\">\n <div class=\"flex items-center gap-2 w-full @min-[420px]:w-auto\">\n @if (dataSource.toolbarLeftTemplate) {\n <div class=\"flex flex-col w-full @min-[420px]:w-auto @min-[420px]:flex-row\">\n <ng-container [ngTemplateOutlet]=\"dataSource.toolbarLeftTemplate\"></ng-container>\n </div>\n }\n </div>\n <div\n class=\"flex flex-col @min-[420px]:flex-row @min-[420px]:items-center gap-2 w-full @min-[420px]:flex-1 @min-[560px]:flex-none @min-[560px]:w-auto\">\n @if (dataSource.canSearch) {\n <!-- Enter must not submit a surrounding form: the search box belongs to the\n table's chrome, not to whatever form the table happens to sit in. -->\n <mn-lib-input-field\n (keydown.enter)=\"$event.preventDefault()\"\n class=\"w-full @min-[420px]:flex-1 @min-[560px]:max-w-64\"\n [props]=\"{\n id: 'mn-table-search',\n type: 'search',\n label: '',\n ariaLabel: searchPlaceholderLabel,\n placeholder: searchPlaceholderLabel,\n size: 'sm',\n borderRadius: 'md',\n fullWidth: true\n }\"\n [ngModel]=\"searchValue\"\n (ngModelChange)=\"onSearch($event)\"\n ></mn-lib-input-field>\n }\n @if (dataSource.toolbarRightTemplate) {\n <div class=\"flex flex-col w-full @min-[420px]:w-auto @min-[420px]:flex-row\">\n <ng-container [ngTemplateOutlet]=\"dataSource.toolbarRightTemplate\"></ng-container>\n </div>\n }\n <!-- Small-screen filter toggle: replaces the inline per-column filter row below 640px -->\n @if (hasColumnFilters && filtersCollapsed) {\n <button\n type=\"button\"\n mnButton\n [data]=\"{ variant: 'outline', color: 'primary', size: 'md', borderRadius: 'md', hover: true }\"\n class=\"w-full @min-[420px]:w-auto gap-1.5\"\n [attr.aria-expanded]=\"filtersPanelOpen\"\n aria-controls=\"mn-table-filters-panel\"\n (click)=\"toggleFiltersPanel()\"\n >\n <svg lucideFunnel [size]=\"15\"></svg>\n <span>{{ filtersButtonLabel }}</span>\n </button>\n }\n </div>\n</div>\n}\n\n<!-- Small-screen filter panel: stacked, full-width fields decoupled from column widths -->\n@if (hasColumnFilters && filtersCollapsed) {\n <div\n id=\"mn-table-filters-panel\"\n class=\"grid transition-all duration-200 ease-out motion-reduce:transition-none\"\n [style.grid-template-rows]=\"filtersPanelOpen ? '1fr' : '0fr'\"\n >\n <div class=\"overflow-hidden\" [attr.inert]=\"filtersPanelOpen ? null : ''\">\n <div class=\"flex flex-col gap-3 rounded-md border border-base-300 bg-base-100 p-3 mb-3\">\n @for (column of dataSource.columns; track column.key) {\n @if (column.filterable) {\n <div class=\"flex flex-col gap-1\">\n <label\n class=\"text-xs font-medium text-base-content/70\"\n [attr.for]=\"'mn-table-filter-' + column.key\"\n >\n @if (isTemplateRef(column.header)) {\n <ng-container [ngTemplateOutlet]=\"$any(column.header)\"></ng-container>\n } @else {\n {{ column.header }}\n }\n </label>\n <ng-container\n [ngTemplateOutletContext]=\"{ $implicit: column, idScope: 'panel' }\"\n [ngTemplateOutlet]=\"filterField\"\n ></ng-container>\n </div>\n }\n }\n @if (hasActiveFilters) {\n <button\n type=\"button\"\n mnButton\n [data]=\"{ variant: 'text', color: 'gray', size: 'sm', borderRadius: 'md', hover: true }\"\n class=\"self-start gap-1\"\n (click)=\"clearAllFilters()\"\n >\n <svg lucideX [size]=\"14\"></svg>\n <span>{{ clearFiltersButtonLabel }}</span>\n </button>\n }\n </div>\n </div>\n </div>\n}\n\n<!-- Table wrapper with horizontal scroll -->\n <div #collectionBody [attr.aria-label]=\"tableRegionLabel\" [style.min-height.px]=\"bodyMinHeight\"\n class=\"overflow-x-auto\"\n role=\"region\">\n <table [class]=\"tableClasses\">\n <thead>\n <tr class=\"bg-base-100\">\n <!-- Selection checkbox column header -->\n @if (hasSelection) {\n <th class=\"w-10 text-center text-sm px-2 py-2\">\n @if (isMultiSelect) {\n <mn-lib-checkbox\n (checkedChange)=\"toggleAll()\"\n [checked]=\"allSelected\"\n [props]=\"{ id: 'mn-table-select-all', size: 'sm' }\"\n ></mn-lib-checkbox>\n }\n </th>\n }\n\n <!-- Data columns -->\n @for (column of dataSource.columns; track column.key) {\n <th\n [attr.data-column-key]=\"column.key\"\n [class.truncate]=\"widthsArePinned\"\n [class.cursor-pointer]=\"isSortable(column)\"\n [class.select-none]=\"isSortable(column)\"\n [class.hover:bg-base-200]=\"isSortable(column)\"\n [class.text-left]=\"(column.align ?? 'left') === 'left'\"\n [class.text-center]=\"column.align === 'center'\"\n [class.text-right]=\"column.align === 'right'\"\n [mnHiddenBelow]=\"column.hiddenBelow\"\n [style.width]=\"columnWidth(column)\"\n [attr.aria-sort]=\"currentSort?.columnKey === column.key ? (currentSort!.direction === 'asc' ? 'ascending' : 'descending') : null\"\n class=\"text-sm px-2 py-1 @min-[640px]:px-4 @min-[640px]:py-2 whitespace-nowrap\"\n (click)=\"sort(column)\"\n >\n <span class=\"inline-flex items-center gap-1\">\n @if (isTemplateRef(column.header)) {\n <ng-container [ngTemplateOutlet]=\"$any(column.header)\"></ng-container>\n } @else {\n <span>{{ column.header }}</span>\n }\n @if (isSortable(column)) {\n <span class=\"text-[0.65rem] opacity-70 min-w-3 inline-block\">{{ getSortIcon(column) }}</span>\n }\n </span>\n </th>\n }\n\n </tr>\n\n <!-- Per-column filter row (wide screens only; collapses into a panel below 640px).\n `font-normal` on the cells is load-bearing: these are `th` elements, which the\n browser renders bold, and the filter inputs and selects inside them inherit\n that. Filter controls are form fields, not headings, and must not look bold.\n The stacked filter panel renders outside any `th`, so it is unaffected. -->\n @if (hasColumnFilters && !filtersCollapsed) {\n <tr class=\"bg-base-100 border-b border-base-300 font-normal\">\n @if (hasSelection) {\n <th class=\"px-2 py-1 font-normal\"></th>\n }\n @for (column of dataSource.columns; track column.key) {\n <th\n class=\"px-4 py-2 font-normal\"\n [mnHiddenBelow]=\"column.hiddenBelow\"\n >\n @if (column.filterable) {\n <!-- Every filter renders as an ordinary control right under its\n header. The rich types used to hide behind a button that opened\n a floating panel, which cost a click to discover, a click to\n apply, and hid whether a column was even filterable. -->\n <ng-container\n [ngTemplateOutletContext]=\"{ $implicit: column, idScope: 'inline' }\"\n [ngTemplateOutlet]=\"filterField\"\n ></ng-container>\n }\n </th>\n }\n </tr>\n }\n </thead>\n\n <tbody>\n <!-- Loading state -->\n @if (isLoadingState) {\n @for (_ of skeletonRows; track $index) {\n <tr>\n @if (hasSelection) {\n <td class=\"px-2 py-3\">\n <mn-skeleton [data]=\"{ shape: 'rectangle', width: '1rem', height: '1rem' }\"></mn-skeleton>\n </td>\n }\n @for (column of dataSource.columns; track column.key) {\n <td class=\"px-4 py-3\"\n [mnHiddenBelow]=\"column.hiddenBelow\"\n [style.width]=\"columnWidth(column)\"\n >\n @if (isTemplateRef(column.skeleton)) {\n <ng-container [ngTemplateOutlet]=\"$any(column.skeleton)\"></ng-container>\n } @else {\n <mn-skeleton [data]=\"getColumnSkeletonData(column)\"></mn-skeleton>\n }\n </td>\n }\n </tr>\n }\n } @else if (isErrorState) {\n <!-- Error state -->\n <tr class=\"bg-base-100\">\n <td [attr.colspan]=\"totalColumnCount\" class=\"text-center text-xs py-8\">\n @if (dataSource.errorTemplate) {\n <ng-container [ngTemplateOutlet]=\"dataSource.errorTemplate\"></ng-container>\n } @else {\n <div class=\"flex flex-col items-center gap-2 text-error\">\n <p class=\"text-sm\">{{ dataSource.errorMessage }}</p>\n </div>\n }\n </td>\n </tr>\n } @else {\n <!-- Empty state -->\n @if (filteredItems.length === 0) {\n <tr class=\"bg-base-100\">\n <td [attr.colspan]=\"totalColumnCount\" class=\"text-center text-xs py-8\">\n @if (dataSource.emptyTemplate) {\n <ng-container [ngTemplateOutlet]=\"dataSource.emptyTemplate\"></ng-container>\n } @else {\n <div class=\"flex flex-col items-center gap-2 text-base-content/50\">\n @if (dataSource.emptyIcon !== null) {\n <svg [lucideIcon]=\"dataSource.emptyIcon ?? defaultEmptyIcon\" [size]=\"32\" [strokeWidth]=\"1.5\"></svg>\n }\n <p class=\"text-sm\">{{ dataSource.emptyMessage }}</p>\n </div>\n }\n </td>\n </tr>\n }\n\n <!-- Data rows -->\n @for (row of paginatedItems; track trackByID($index, row); let odd = $odd; let last = $last) {\n <tr\n class=\"bg-base-100 transition-colors duration-150 hover:cursor-pointer\"\n [ngClass]=\"{'bg-primary/10': isSelected(row)}\"\n [class.bg-base-200]=\"!isSelected(row) && odd && dataSource.appearance?.striped\"\n [class.hover:bg-base-200]=\"dataSource.appearance?.hover !== false\"\n [class.cursor-pointer]=\"!!dataSource.onRowClick\"\n [class.border-b]=\"!last\"\n [class.border-base-300]=\"!last\"\n [class.border-b-1]=\"last\"\n [class.border-black]=\"last\"\n [class.shadow-3xl]=\"last\"\n (click)=\"onRowClick(row)\"\n >\n <!-- Selection checkbox -->\n @if (hasSelection) {\n <td (click)=\"$event.stopPropagation()\" class=\"w-10 text-center px-2 py-2\">\n <mn-lib-checkbox\n (checkedChange)=\"toggle(row)\"\n [checked]=\"isSelected(row)\"\n [props]=\"{ id: 'mn-table-row-' + $index, size: 'sm' }\"\n ></mn-lib-checkbox>\n </td>\n }\n\n <!-- Data cells -->\n @for (column of dataSource.columns; track column.key) {\n <td\n [attr.title]=\"cellTitle(column, row)\"\n [class.text-left]=\"(column.align ?? 'left') === 'left'\"\n [class.text-center]=\"column.align === 'center'\"\n [class.text-right]=\"column.align === 'right'\"\n [class.truncate]=\"widthsArePinned\"\n [mnHiddenBelow]=\"column.hiddenBelow\"\n [style.width]=\"columnWidth(column)\"\n class=\"text-xs px-2 py-1 @min-[640px]:px-4 @min-[640px]:py-2\"\n >\n @if (column.actions) {\n <!-- Actions column: inline command buttons that collapse into a \u22EF menu\n once the table is narrower than 450px (container query), but only\n when there are enough of them to warrant it. A row with no visible\n actions renders nothing \u2014 no buttons, no \u22EF. -->\n @if (hasRowActions(column, row)) {\n <div class=\"inline-flex items-center gap-1\"\n [class.justify-end]=\"(column.align ?? 'left') === 'right'\">\n @if (shouldCollapseActions(column, row)) {\n <span class=\"hidden items-center gap-1 @min-[450px]:inline-flex\">\n <ng-container [ngTemplateOutletContext]=\"{ column: column, row: row }\"\n [ngTemplateOutlet]=\"actionButtons\"></ng-container>\n </span>\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <span class=\"@min-[450px]:hidden\" (click)=\"$event.stopPropagation()\">\n <mn-lib-dropdown [props]=\"{\n id: actionsDropdownId(column, row),\n actions: rowDropdownActions(column, row),\n menuLabel: $any(column.header),\n size: 'sm'\n }\"></mn-lib-dropdown>\n </span>\n } @else {\n <ng-container [ngTemplateOutletContext]=\"{ column: column, row: row }\"\n [ngTemplateOutlet]=\"actionButtons\"></ng-container>\n }\n </div>\n }\n } @else if (column.cellSm) {\n <!-- Default cell: hidden below the cellSm breakpoint -->\n <span [mnShowAbove]=\"column.cellSm.below\">\n @if (isTemplateRef(column.cell)) {\n <ng-container [ngTemplateOutletContext]=\"{ $implicit: row, data: row }\"\n [ngTemplateOutlet]=\"$any(column.cell)\"></ng-container>\n } @else {\n {{ getCellValue(column, row) }}\n }\n </span>\n <!-- Small cell: shown only below the cellSm breakpoint -->\n <span [mnShowBelow]=\"column.cellSm.below\">\n @if (isTemplateRef(column.cellSm.cell)) {\n <ng-container [ngTemplateOutletContext]=\"{ $implicit: row, data: row }\"\n [ngTemplateOutlet]=\"$any(column.cellSm.cell)\"></ng-container>\n } @else {\n {{ getCellSmValue(column, row) }}\n }\n </span>\n } @else {\n @if (isTemplateRef(column.cell)) {\n <ng-container [ngTemplateOutletContext]=\"{ $implicit: row, data: row }\"\n [ngTemplateOutlet]=\"$any(column.cell)\"></ng-container>\n } @else {\n {{ getCellValue(column, row) }}\n }\n }\n </td>\n }\n\n </tr>\n }\n }\n </tbody>\n </table>\n</div>\n\n<!-- Load more + pagination -->\n<mn-collection-pagination\n (loadMore)=\"loadMoreRows()\"\n (pageChange)=\"goToPage($event)\"\n (pageSizeChange)=\"onPageSizeChange($event)\"\n [currentPage]=\"currentPage\"\n [isPaginated]=\"isPaginated\"\n [isServerPaginated]=\"isServerPaginated\"\n [labels]=\"dataSource.labels\"\n [loadingMoreRows]=\"loadingMoreRows\"\n [pageSizeSelectOptions]=\"pageSizeSelectOptions\"\n [pageSize]=\"pageSize\"\n [showLoadMore]=\"showLoadMore\"\n [totalItemCount]=\"totalItemCount\"\n [totalPages]=\"totalPages\"\n [visiblePages]=\"visiblePages\"\n idPrefix=\"mn-table\"\n></mn-collection-pagination>\n</div>\n\n<!-- Single source of truth for every filter control, reused by the inline header\n row and the small-screen panel. `idScope` keeps element ids unique across\n both placements. -->\n<ng-template #filterField let-column let-idScope=\"idScope\">\n @switch (filterTypeOf(column)) {\n @case ('select') {\n <mn-lib-select\n (ngModelChange)=\"onColumnFilter(column, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"textFilterValue(column)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key,\n options: getFilterSelectOptions(column),\n size: 'sm',\n fullWidth: true\n }\"\n ></mn-lib-select>\n }\n @case ('multi-select') {\n <mn-lib-multi-select\n (ngModelChange)=\"onColumnFilter(column, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"multiFilterValue(column)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key,\n options: getFilterMultiSelectOptions(column),\n placeholder: column.filterPlaceholder ?? '',\n collapsePlaceholder: filterSelectedLabel,\n collapseThreshold: 1,\n size: 'sm',\n fullWidth: true\n }\"\n ></mn-lib-multi-select>\n }\n @case ('boolean') {\n <mn-lib-select\n (ngModelChange)=\"onBooleanFilter(column, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"booleanFilterValue(column)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key,\n options: getBooleanFilterOptions(column),\n size: 'sm',\n fullWidth: true\n }\"\n ></mn-lib-select>\n }\n @default {\n <mn-lib-input-field\n (keydown.enter)=\"$event.preventDefault()\"\n (ngModelChange)=\"onColumnFilter(column, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"textFilterValue(column)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key,\n type: 'text',\n label: '',\n placeholder: column.filterPlaceholder ?? '',\n ariaLabel: column.filterPlaceholder ?? '',\n autocomplete: column.filterAutocomplete ?? undefined,\n size: 'sm',\n borderRadius: 'md',\n fullWidth: true,\n hover: true\n }\"\n ></mn-lib-input-field>\n }\n }\n</ng-template>\n\n<!-- Inline action buttons for an actions column, shared by the wide-table layout and\n (implicitly) mirrored by the \u22EF menu below 450px. -->\n<ng-template #actionButtons let-column=\"column\" let-row=\"row\">\n @for (action of visibleRowActions(column, row); track $index) {\n <button\n mnButton\n type=\"button\"\n [data]=\"{\n size: 'sm',\n variant: 'text',\n color: rowActionColor(action, row),\n disabled: isRowActionDisabled(action, row)\n }\"\n class=\"cursor-pointer\"\n (click)=\"$event.stopPropagation(); runRowAction(action, row)\"\n [attr.aria-label]=\"rowActionLabel(action, row)\"\n [attr.title]=\"showActionLabel(column, action, row) ? null : rowActionLabel(action, row)\"\n >\n @if (showActionIcon(column, action, row)) {\n <span class=\"inline-flex items-center shrink-0\">\n @let icon = rowActionIcon(action, row);\n @if (isTemplateRef(icon)) {\n <ng-container [ngTemplateOutlet]=\"icon\"></ng-container>\n } @else {\n <!-- Data icon: sized here to match the sm button's text, so a caller can\n declare the action in TypeScript without owning a template. -->\n <svg [lucideIcon]=\"$any(icon)\" [size]=\"17\"></svg>\n }\n </span>\n }\n @if (showActionLabel(column, action, row)) {\n <span>{{ rowActionLabel(action, row) }}</span>\n }\n </button>\n }\n</ng-template>\n", dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: MnCheckbox, selector: "mn-lib-checkbox", inputs: ["props", "checked"], outputs: ["checkedChange"] }, { kind: "directive", type: MnHiddenBelowDirective, selector: "[mnHiddenBelow]", inputs: ["mnHiddenBelow"] }, { kind: "directive", type: MnShowAboveDirective, selector: "[mnShowAbove]", inputs: ["mnShowAbove"] }, { kind: "directive", type: MnShowBelowDirective, selector: "[mnShowBelow]", inputs: ["mnShowBelow"] }, { kind: "component", type: MnInputField, selector: "mn-lib-input-field", inputs: ["props"] }, { kind: "component", type: MnSelect, selector: "mn-lib-select", inputs: ["props"] }, { kind: "component", type: MnMultiSelect, selector: "mn-lib-multi-select", inputs: ["props"] }, { kind: "component", type: MnDropdown, selector: "mn-lib-dropdown", inputs: ["props"] }, { kind: "component", type: MnSkeleton, selector: "mn-skeleton", inputs: ["data"] }, { 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: MnCollectionPagination, selector: "mn-collection-pagination", inputs: ["idPrefix", "isPaginated", "isServerPaginated", "showLoadMore", "loadingMoreRows", "currentPage", "pageSize", "totalPages", "totalItemCount", "visiblePages", "pageSizeSelectOptions", "labels"], outputs: ["loadMore", "pageChange", "pageSizeChange"] }, { kind: "component", type: MnButton, selector: "button[mnButton], a[mnButton]", inputs: ["data"] }, { kind: "component", type: LucideFilter, selector: "svg[lucideFunnel], svg[lucideFilter]" }, { kind: "component", type: LucideX, selector: "svg[lucideX]" }, { kind: "component", type: LucideDynamicIcon, selector: "svg[lucideIcon]", inputs: ["lucideIcon"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
8254
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnTable, isStandalone: true, selector: "mn-table", outputs: { sortChange: "sortChange", rowClick: "rowClick" }, host: { listeners: { "window:resize": "onWindowResize()" }, classAttribute: "block" }, viewQueries: [{ propertyName: "collectionBody", first: true, predicate: ["collectionBody"], descendants: true }], usesInheritance: true, ngImport: i0, template: "<!-- Everything that reflows lives inside a @container, so the breakpoints below\n measure the table's own width rather than the window's. A table in a modal,\n a sidebar or a narrow grid cell is far narrower than the viewport, and\n viewport breakpoints would hand it a desktop layout it has no room for. -->\n<div class=\"@container\">\n <!-- Selection summary: the whole selection, never paged, filtered or sorted. The\n table below answers \"what could I pick?\", which is why it is searched and paged;\n this answers \"what did I pick?\", which a paginated list cannot without hiding\n most of the answer on some other page. -->\n @if (showSelectionSummary) {\n <div class=\"flex flex-col gap-2 rounded-md border border-base-300 bg-base-200/50 p-3 mb-3\">\n <div class=\"flex items-center justify-between gap-2\">\n <span class=\"text-sm font-medium text-base-content\">{{ selectionSummaryTitle }}</span>\n <button\n (click)=\"clearSelection()\"\n [data]=\"{ variant: 'text', color: 'gray', size: 'sm', borderRadius: 'md', hover: true }\"\n class=\"gap-1 shrink-0\"\n mnButton\n type=\"button\"\n >\n <svg [size]=\"14\" lucideX></svg>\n <span>{{ selectionClearAllLabel }}</span>\n </button>\n </div>\n <!-- Only the first few tags render; the rest collapse behind a \"+N more\" control.\n The heading's count always states the true total, so this hides tags, never\n information. Expanded, the list is height-capped and scrolls, so even a\n selection of hundreds cannot push the table off screen. -->\n <ul\n [class.max-h-28]=\"selectionSummaryExpanded\"\n [class.overflow-y-auto]=\"selectionSummaryExpanded\"\n class=\"flex flex-wrap gap-1.5 list-none m-0 p-0\"\n >\n @for (row of visibleSelectionRows; track dataSource.getID(row)) {\n <li\n class=\"inline-flex items-center gap-1 rounded-full bg-base-100 border border-base-300 pl-2.5 pr-1 py-0.5 text-xs text-base-content max-w-full\">\n <span [attr.title]=\"selectionLabelFor(row)\" class=\"truncate\">{{ selectionLabelFor(row) }}</span>\n <button\n (click)=\"removeSelection(row)\"\n [attr.aria-label]=\"selectionRemoveLabel(row)\"\n class=\"shrink-0 rounded-full p-0.5 hover:bg-base-300 transition-colors cursor-pointer\"\n type=\"button\"\n >\n <svg [size]=\"12\" lucideX></svg>\n </button>\n </li>\n }\n @if (hiddenSelectionCount > 0 || selectionSummaryExpanded) {\n <li>\n <button\n (click)=\"toggleSelectionSummary()\"\n [attr.aria-expanded]=\"selectionSummaryExpanded\"\n [data]=\"{ variant: 'text', color: 'primary', size: 'sm', borderRadius: 'md', hover: true }\"\n class=\"text-xs\"\n mnButton\n type=\"button\"\n >\n {{ selectionSummaryToggleLabel }}\n </button>\n </li>\n }\n </ul>\n </div>\n }\n\n <!-- Toolbar: custom toolbar template + search. Only rendered when it has content,\n so an unsearchable table without toolbar templates emits no empty spacer. -->\n@if (dataSource.canSearch || dataSource.toolbarLeftTemplate || dataSource.toolbarRightTemplate || (hasColumnFilters && filtersCollapsed)) {\n <div class=\"flex flex-col @min-[420px]:flex-row @min-[420px]:items-center justify-between gap-2 mb-3\">\n <div class=\"flex items-center gap-2 w-full @min-[420px]:w-auto\">\n @if (dataSource.toolbarLeftTemplate) {\n <div class=\"flex flex-col w-full @min-[420px]:w-auto @min-[420px]:flex-row\">\n <ng-container [ngTemplateOutlet]=\"dataSource.toolbarLeftTemplate\"></ng-container>\n </div>\n }\n </div>\n <div\n class=\"flex flex-col @min-[420px]:flex-row @min-[420px]:items-center gap-2 w-full @min-[420px]:flex-1 @min-[560px]:flex-none @min-[560px]:w-auto\">\n @if (dataSource.canSearch) {\n <!-- Enter must not submit a surrounding form: the search box belongs to the\n table's chrome, not to whatever form the table happens to sit in. -->\n <mn-lib-input-field\n (keydown.enter)=\"$event.preventDefault()\"\n class=\"w-full @min-[420px]:flex-1 @min-[560px]:max-w-64\"\n [props]=\"{\n id: 'mn-table-search',\n type: 'search',\n label: '',\n ariaLabel: searchPlaceholderLabel,\n placeholder: searchPlaceholderLabel,\n size: 'sm',\n borderRadius: 'md',\n fullWidth: true\n }\"\n [ngModel]=\"searchValue\"\n (ngModelChange)=\"onSearch($event)\"\n ></mn-lib-input-field>\n }\n @if (dataSource.toolbarRightTemplate) {\n <div class=\"flex flex-col w-full @min-[420px]:w-auto @min-[420px]:flex-row\">\n <ng-container [ngTemplateOutlet]=\"dataSource.toolbarRightTemplate\"></ng-container>\n </div>\n }\n <!-- Small-screen filter toggle: replaces the inline per-column filter row below 640px -->\n @if (hasColumnFilters && filtersCollapsed) {\n <button\n type=\"button\"\n mnButton\n [data]=\"{ variant: 'outline', color: 'primary', size: 'md', borderRadius: 'md', hover: true }\"\n class=\"w-full @min-[420px]:w-auto gap-1.5\"\n [attr.aria-expanded]=\"filtersPanelOpen\"\n aria-controls=\"mn-table-filters-panel\"\n (click)=\"toggleFiltersPanel()\"\n >\n <svg lucideFunnel [size]=\"15\"></svg>\n <span>{{ filtersButtonLabel }}</span>\n </button>\n }\n </div>\n</div>\n}\n\n<!-- Small-screen filter panel: stacked, full-width fields decoupled from column widths -->\n@if (hasColumnFilters && filtersCollapsed) {\n <div\n id=\"mn-table-filters-panel\"\n class=\"grid transition-all duration-200 ease-out motion-reduce:transition-none\"\n [style.grid-template-rows]=\"filtersPanelOpen ? '1fr' : '0fr'\"\n >\n <div class=\"overflow-hidden\" [attr.inert]=\"filtersPanelOpen ? null : ''\">\n <div class=\"flex flex-col gap-3 rounded-md border border-base-300 bg-base-100 p-3 mb-3\">\n @for (column of dataSource.columns; track column.key) {\n @if (column.filterable) {\n <div class=\"flex flex-col gap-1\">\n <label\n class=\"text-xs font-medium text-base-content/70\"\n [attr.for]=\"'mn-table-filter-' + column.key\"\n >\n @if (isTemplateRef(column.header)) {\n <ng-container [ngTemplateOutlet]=\"$any(column.header)\"></ng-container>\n } @else {\n {{ column.header }}\n }\n </label>\n <ng-container\n [ngTemplateOutletContext]=\"{ $implicit: column, idScope: 'panel' }\"\n [ngTemplateOutlet]=\"filterField\"\n ></ng-container>\n </div>\n }\n }\n @if (hasActiveFilters) {\n <button\n type=\"button\"\n mnButton\n [data]=\"{ variant: 'text', color: 'gray', size: 'sm', borderRadius: 'md', hover: true }\"\n class=\"self-start gap-1\"\n (click)=\"clearAllFilters()\"\n >\n <svg lucideX [size]=\"14\"></svg>\n <span>{{ clearFiltersButtonLabel }}</span>\n </button>\n }\n </div>\n </div>\n </div>\n}\n\n<!-- Table wrapper with horizontal scroll -->\n <div #collectionBody [attr.aria-label]=\"tableRegionLabel\" [style.min-height.px]=\"bodyMinHeight\"\n class=\"overflow-x-auto\"\n role=\"region\">\n <table [class]=\"tableClasses\">\n <thead>\n <tr class=\"bg-base-100\">\n <!-- Selection checkbox column header -->\n @if (hasSelection) {\n <th class=\"w-10 text-center text-sm px-2 py-2\">\n @if (isMultiSelect) {\n <mn-lib-checkbox\n (checkedChange)=\"toggleAll()\"\n [checked]=\"allSelected\"\n [props]=\"{ id: 'mn-table-select-all', size: 'sm' }\"\n ></mn-lib-checkbox>\n }\n </th>\n }\n\n <!-- Data columns -->\n @for (column of dataSource.columns; track column.key) {\n <th\n [attr.data-column-key]=\"column.key\"\n [class.truncate]=\"widthsArePinned\"\n [class.cursor-pointer]=\"isSortable(column)\"\n [class.select-none]=\"isSortable(column)\"\n [class.hover:bg-base-200]=\"isSortable(column)\"\n [class.text-left]=\"(column.align ?? 'left') === 'left'\"\n [class.text-center]=\"column.align === 'center'\"\n [class.text-right]=\"column.align === 'right'\"\n [mnHiddenBelow]=\"column.hiddenBelow\"\n [style.width]=\"columnWidth(column)\"\n [attr.aria-sort]=\"currentSort?.columnKey === column.key ? (currentSort!.direction === 'asc' ? 'ascending' : 'descending') : null\"\n class=\"text-sm px-2 py-1 @min-[640px]:px-4 @min-[640px]:py-2 whitespace-nowrap\"\n (click)=\"sort(column)\"\n >\n <span class=\"inline-flex items-center gap-1\">\n @if (isTemplateRef(column.header)) {\n <ng-container [ngTemplateOutlet]=\"$any(column.header)\"></ng-container>\n } @else {\n <span>{{ column.header }}</span>\n }\n @if (isSortable(column)) {\n <span class=\"text-[0.65rem] opacity-70 min-w-3 inline-block\">{{ getSortIcon(column) }}</span>\n }\n </span>\n </th>\n }\n\n </tr>\n\n <!-- Per-column filter row (wide screens only; collapses into a panel below 640px).\n `font-normal` on the cells is load-bearing: these are `th` elements, which the\n browser renders bold, and the filter inputs and selects inside them inherit\n that. Filter controls are form fields, not headings, and must not look bold.\n The stacked filter panel renders outside any `th`, so it is unaffected. -->\n @if (hasColumnFilters && !filtersCollapsed) {\n <tr class=\"bg-base-100 border-b border-base-300 font-normal\">\n @if (hasSelection) {\n <th class=\"px-2 py-1 font-normal\"></th>\n }\n @for (column of dataSource.columns; track column.key) {\n <th\n class=\"px-4 py-2 font-normal\"\n [mnHiddenBelow]=\"column.hiddenBelow\"\n >\n @if (column.filterable) {\n <!-- Every filter renders as an ordinary control right under its\n header. The rich types used to hide behind a button that opened\n a floating panel, which cost a click to discover, a click to\n apply, and hid whether a column was even filterable. -->\n <ng-container\n [ngTemplateOutletContext]=\"{ $implicit: column, idScope: 'inline' }\"\n [ngTemplateOutlet]=\"filterField\"\n ></ng-container>\n }\n </th>\n }\n </tr>\n }\n </thead>\n\n <tbody>\n <!-- Loading state -->\n @if (isLoadingState) {\n @for (_ of skeletonRows; track $index) {\n <tr>\n @if (hasSelection) {\n <td class=\"px-2 py-3\">\n <mn-skeleton [data]=\"{ shape: 'rectangle', width: '1rem', height: '1rem' }\"></mn-skeleton>\n </td>\n }\n @for (column of dataSource.columns; track column.key) {\n <td class=\"px-4 py-3\"\n [mnHiddenBelow]=\"column.hiddenBelow\"\n [style.width]=\"columnWidth(column)\"\n >\n @if (isTemplateRef(column.skeleton)) {\n <ng-container [ngTemplateOutlet]=\"$any(column.skeleton)\"></ng-container>\n } @else {\n <mn-skeleton [data]=\"getColumnSkeletonData(column)\"></mn-skeleton>\n }\n </td>\n }\n </tr>\n }\n } @else if (isErrorState) {\n <!-- Error state -->\n <tr class=\"bg-base-100\">\n <td [attr.colspan]=\"totalColumnCount\" class=\"text-center text-xs py-8\">\n @if (dataSource.errorTemplate) {\n <ng-container [ngTemplateOutlet]=\"dataSource.errorTemplate\"></ng-container>\n } @else {\n <div class=\"flex flex-col items-center gap-2 text-error\">\n <p class=\"text-sm\">{{ dataSource.errorMessage }}</p>\n </div>\n }\n </td>\n </tr>\n } @else {\n <!-- Empty state -->\n @if (filteredItems.length === 0) {\n <tr class=\"bg-base-100\">\n <td [attr.colspan]=\"totalColumnCount\" class=\"text-center text-xs py-8\">\n @if (dataSource.emptyTemplate) {\n <ng-container [ngTemplateOutlet]=\"dataSource.emptyTemplate\"></ng-container>\n } @else {\n <div class=\"flex flex-col items-center gap-2 text-base-content/50\">\n @if (dataSource.emptyIcon !== null) {\n <svg [lucideIcon]=\"dataSource.emptyIcon ?? defaultEmptyIcon\" [size]=\"32\" [strokeWidth]=\"1.5\"></svg>\n }\n <p class=\"text-sm\">{{ dataSource.emptyMessage }}</p>\n </div>\n }\n </td>\n </tr>\n }\n\n <!-- Data rows -->\n @for (row of paginatedItems; track trackByID($index, row); let odd = $odd; let last = $last) {\n <tr\n class=\"bg-base-100 transition-colors duration-150 hover:cursor-pointer\"\n [ngClass]=\"{'bg-primary/10': isSelected(row)}\"\n [class.bg-base-200]=\"!isSelected(row) && odd && dataSource.appearance?.striped\"\n [class.hover:bg-base-200]=\"dataSource.appearance?.hover !== false\"\n [class.cursor-pointer]=\"!!dataSource.onRowClick\"\n [class.border-b]=\"!last\"\n [class.border-base-300]=\"!last\"\n [class.border-b-1]=\"last\"\n [class.border-black]=\"last\"\n [class.shadow-3xl]=\"last\"\n (click)=\"onRowClick(row)\"\n >\n <!-- Selection checkbox -->\n @if (hasSelection) {\n <td (click)=\"$event.stopPropagation()\" class=\"w-10 text-center px-2 py-2\">\n <mn-lib-checkbox\n (checkedChange)=\"toggle(row)\"\n [checked]=\"isSelected(row)\"\n [props]=\"{ id: 'mn-table-row-' + $index, size: 'sm' }\"\n ></mn-lib-checkbox>\n </td>\n }\n\n <!-- Data cells -->\n @for (column of dataSource.columns; track column.key) {\n <td\n [attr.title]=\"cellTitle(column, row)\"\n [class.text-left]=\"(column.align ?? 'left') === 'left'\"\n [class.text-center]=\"column.align === 'center'\"\n [class.text-right]=\"column.align === 'right'\"\n [class.truncate]=\"widthsArePinned\"\n [mnHiddenBelow]=\"column.hiddenBelow\"\n [style.width]=\"columnWidth(column)\"\n class=\"text-xs px-2 py-1 @min-[640px]:px-4 @min-[640px]:py-2\"\n >\n @if (column.actions) {\n <!-- Actions column: inline command buttons that collapse into a \u22EF menu\n once the table is narrower than 450px (container query), for every\n row with actions. A row with no visible actions renders nothing. -->\n @if (hasRowActions(column, row)) {\n <div class=\"inline-flex items-center gap-1\"\n [class.justify-end]=\"(column.align ?? 'left') === 'right'\">\n <span class=\"hidden items-center gap-1 @min-[450px]:inline-flex\">\n <ng-container [ngTemplateOutletContext]=\"{ column: column, row: row }\"\n [ngTemplateOutlet]=\"actionButtons\"></ng-container>\n </span>\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <span class=\"@min-[450px]:hidden\" (click)=\"$event.stopPropagation()\">\n <mn-lib-dropdown [datasource]=\"{\n id: actionsDropdownId(column, row),\n actions: rowDropdownActions(column, row),\n menuLabel: $any(column.header),\n size: 'sm'\n }\"></mn-lib-dropdown>\n </span>\n </div>\n }\n } @else if (column.cellSm) {\n <!-- Default cell: hidden below the cellSm breakpoint -->\n <span [mnShowAbove]=\"column.cellSm.below\">\n @if (isTemplateRef(column.cell)) {\n <ng-container [ngTemplateOutletContext]=\"{ $implicit: row, data: row }\"\n [ngTemplateOutlet]=\"$any(column.cell)\"></ng-container>\n } @else {\n {{ getCellValue(column, row) }}\n }\n </span>\n <!-- Small cell: shown only below the cellSm breakpoint -->\n <span [mnShowBelow]=\"column.cellSm.below\">\n @if (isTemplateRef(column.cellSm.cell)) {\n <ng-container [ngTemplateOutletContext]=\"{ $implicit: row, data: row }\"\n [ngTemplateOutlet]=\"$any(column.cellSm.cell)\"></ng-container>\n } @else {\n {{ getCellSmValue(column, row) }}\n }\n </span>\n } @else {\n @if (isTemplateRef(column.cell)) {\n <ng-container [ngTemplateOutletContext]=\"{ $implicit: row, data: row }\"\n [ngTemplateOutlet]=\"$any(column.cell)\"></ng-container>\n } @else {\n {{ getCellValue(column, row) }}\n }\n }\n </td>\n }\n\n </tr>\n }\n }\n </tbody>\n </table>\n</div>\n\n<!-- Load more + pagination -->\n<mn-collection-pagination\n (loadMore)=\"loadMoreRows()\"\n (pageChange)=\"goToPage($event)\"\n (pageSizeChange)=\"onPageSizeChange($event)\"\n [currentPage]=\"currentPage\"\n [isPaginated]=\"isPaginated\"\n [isServerPaginated]=\"isServerPaginated\"\n [labels]=\"dataSource.labels\"\n [loadingMoreRows]=\"loadingMoreRows\"\n [pageSizeSelectOptions]=\"pageSizeSelectOptions\"\n [pageSize]=\"pageSize\"\n [showLoadMore]=\"showLoadMore\"\n [totalItemCount]=\"totalItemCount\"\n [totalPages]=\"totalPages\"\n [visiblePages]=\"visiblePages\"\n idPrefix=\"mn-table\"\n></mn-collection-pagination>\n</div>\n\n<!-- Single source of truth for every filter control, reused by the inline header\n row and the small-screen panel. `idScope` keeps element ids unique across\n both placements. -->\n<ng-template #filterField let-column let-idScope=\"idScope\">\n @switch (filterTypeOf(column)) {\n @case ('select') {\n <mn-lib-select\n (ngModelChange)=\"onColumnFilter(column, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"textFilterValue(column)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key,\n options: getFilterSelectOptions(column),\n size: 'sm',\n fullWidth: true\n }\"\n ></mn-lib-select>\n }\n @case ('multi-select') {\n <mn-lib-multi-select\n (ngModelChange)=\"onColumnFilter(column, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"multiFilterValue(column)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key,\n options: getFilterMultiSelectOptions(column),\n placeholder: column.filterPlaceholder ?? '',\n collapsePlaceholder: filterSelectedLabel,\n collapseThreshold: 1,\n size: 'sm',\n fullWidth: true\n }\"\n ></mn-lib-multi-select>\n }\n @case ('boolean') {\n <mn-lib-select\n (ngModelChange)=\"onBooleanFilter(column, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"booleanFilterValue(column)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key,\n options: getBooleanFilterOptions(column),\n size: 'sm',\n fullWidth: true\n }\"\n ></mn-lib-select>\n }\n @default {\n <mn-lib-input-field\n (keydown.enter)=\"$event.preventDefault()\"\n (ngModelChange)=\"onColumnFilter(column, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"textFilterValue(column)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key,\n type: 'text',\n label: '',\n placeholder: column.filterPlaceholder ?? '',\n ariaLabel: column.filterPlaceholder ?? '',\n autocomplete: column.filterAutocomplete ?? undefined,\n size: 'sm',\n borderRadius: 'md',\n fullWidth: true,\n hover: true\n }\"\n ></mn-lib-input-field>\n }\n }\n</ng-template>\n\n<!-- Inline action buttons for an actions column, shared by the wide-table layout and\n (implicitly) mirrored by the \u22EF menu below 450px. -->\n<ng-template #actionButtons let-column=\"column\" let-row=\"row\">\n @for (action of visibleRowActions(column, row); track $index) {\n <button\n mnButton\n type=\"button\"\n [data]=\"{\n size: 'sm',\n variant: 'text',\n color: rowActionColor(action, row),\n disabled: isRowActionDisabled(action, row)\n }\"\n class=\"cursor-pointer\"\n (click)=\"$event.stopPropagation(); runRowAction(action, row)\"\n [attr.aria-label]=\"rowActionLabel(action, row)\"\n [attr.title]=\"showActionLabel(column, action, row) ? null : rowActionLabel(action, row)\"\n >\n @if (showActionIcon(column, action, row)) {\n <span class=\"inline-flex items-center shrink-0\">\n @let icon = rowActionIcon(action, row);\n @if (isTemplateRef(icon)) {\n <ng-container [ngTemplateOutlet]=\"icon\"></ng-container>\n } @else {\n <!-- Data icon: sized here to match the sm button's text, so a caller can\n declare the action in TypeScript without owning a template. -->\n <svg [lucideIcon]=\"$any(icon)\" [size]=\"17\"></svg>\n }\n </span>\n }\n @if (showActionLabel(column, action, row)) {\n <span>{{ rowActionLabel(action, row) }}</span>\n }\n </button>\n }\n</ng-template>\n", dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: MnCheckbox, selector: "mn-lib-checkbox", inputs: ["props", "checked"], outputs: ["checkedChange"] }, { kind: "directive", type: MnHiddenBelowDirective, selector: "[mnHiddenBelow]", inputs: ["mnHiddenBelow"] }, { kind: "directive", type: MnShowAboveDirective, selector: "[mnShowAbove]", inputs: ["mnShowAbove"] }, { kind: "directive", type: MnShowBelowDirective, selector: "[mnShowBelow]", inputs: ["mnShowBelow"] }, { kind: "component", type: MnInputField, selector: "mn-lib-input-field", inputs: ["props"] }, { kind: "component", type: MnSelect, selector: "mn-lib-select", inputs: ["props"] }, { kind: "component", type: MnMultiSelect, selector: "mn-lib-multi-select", inputs: ["props"] }, { kind: "component", type: MnDropdown, selector: "mn-lib-dropdown", inputs: ["datasource"] }, { kind: "component", type: MnSkeleton, selector: "mn-skeleton", inputs: ["data"] }, { 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: MnCollectionPagination, selector: "mn-collection-pagination", inputs: ["idPrefix", "isPaginated", "isServerPaginated", "showLoadMore", "loadingMoreRows", "currentPage", "pageSize", "totalPages", "totalItemCount", "visiblePages", "pageSizeSelectOptions", "labels"], outputs: ["loadMore", "pageChange", "pageSizeChange"] }, { kind: "component", type: MnButton, selector: "button[mnButton], a[mnButton]", inputs: ["data"] }, { kind: "component", type: LucideFilter, selector: "svg[lucideFunnel], svg[lucideFilter]" }, { kind: "component", type: LucideX, selector: "svg[lucideX]" }, { kind: "component", type: LucideDynamicIcon, selector: "svg[lucideIcon]", inputs: ["lucideIcon"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
7829
8255
|
}
|
|
7830
8256
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnTable, decorators: [{
|
|
7831
8257
|
type: Component,
|
|
7832
|
-
args: [{ selector: 'mn-table', standalone: true, imports: [NgClass, NgTemplateOutlet, MnCheckbox, MnHiddenBelowDirective, MnShowAboveDirective, MnShowBelowDirective, MnInputField, MnSelect, MnMultiSelect, MnDropdown, MnSkeleton, FormsModule, MnCollectionPagination, MnButton, LucideFilter, LucideX, LucideFunnel, LucideDynamicIcon], changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'block' }, template: "<!-- Everything that reflows lives inside a @container, so the breakpoints below\n measure the table's own width rather than the window's. A table in a modal,\n a sidebar or a narrow grid cell is far narrower than the viewport, and\n viewport breakpoints would hand it a desktop layout it has no room for. -->\n<div class=\"@container\">\n <!-- Selection summary: the whole selection, never paged, filtered or sorted. The\n table below answers \"what could I pick?\", which is why it is searched and paged;\n this answers \"what did I pick?\", which a paginated list cannot without hiding\n most of the answer on some other page. -->\n @if (showSelectionSummary) {\n <div class=\"flex flex-col gap-2 rounded-md border border-base-300 bg-base-200/50 p-3 mb-3\">\n <div class=\"flex items-center justify-between gap-2\">\n <span class=\"text-sm font-medium text-base-content\">{{ selectionSummaryTitle }}</span>\n <button\n (click)=\"clearSelection()\"\n [data]=\"{ variant: 'text', color: 'gray', size: 'sm', borderRadius: 'md', hover: true }\"\n class=\"gap-1 shrink-0\"\n mnButton\n type=\"button\"\n >\n <svg [size]=\"14\" lucideX></svg>\n <span>{{ selectionClearAllLabel }}</span>\n </button>\n </div>\n <!-- Only the first few tags render; the rest collapse behind a \"+N more\" control.\n The heading's count always states the true total, so this hides tags, never\n information. Expanded, the list is height-capped and scrolls, so even a\n selection of hundreds cannot push the table off screen. -->\n <ul\n [class.max-h-28]=\"selectionSummaryExpanded\"\n [class.overflow-y-auto]=\"selectionSummaryExpanded\"\n class=\"flex flex-wrap gap-1.5 list-none m-0 p-0\"\n >\n @for (row of visibleSelectionRows; track dataSource.getID(row)) {\n <li\n class=\"inline-flex items-center gap-1 rounded-full bg-base-100 border border-base-300 pl-2.5 pr-1 py-0.5 text-xs text-base-content max-w-full\">\n <span [attr.title]=\"selectionLabelFor(row)\" class=\"truncate\">{{ selectionLabelFor(row) }}</span>\n <button\n (click)=\"removeSelection(row)\"\n [attr.aria-label]=\"selectionRemoveLabel(row)\"\n class=\"shrink-0 rounded-full p-0.5 hover:bg-base-300 transition-colors cursor-pointer\"\n type=\"button\"\n >\n <svg [size]=\"12\" lucideX></svg>\n </button>\n </li>\n }\n @if (hiddenSelectionCount > 0 || selectionSummaryExpanded) {\n <li>\n <button\n (click)=\"toggleSelectionSummary()\"\n [attr.aria-expanded]=\"selectionSummaryExpanded\"\n [data]=\"{ variant: 'text', color: 'primary', size: 'sm', borderRadius: 'md', hover: true }\"\n class=\"text-xs\"\n mnButton\n type=\"button\"\n >\n {{ selectionSummaryToggleLabel }}\n </button>\n </li>\n }\n </ul>\n </div>\n }\n\n <!-- Toolbar: custom toolbar template + search. Only rendered when it has content,\n so an unsearchable table without toolbar templates emits no empty spacer. -->\n@if (dataSource.canSearch || dataSource.toolbarLeftTemplate || dataSource.toolbarRightTemplate || (hasColumnFilters && filtersCollapsed)) {\n <div class=\"flex flex-col @min-[420px]:flex-row @min-[420px]:items-center justify-between gap-2 mb-3\">\n <div class=\"flex items-center gap-2 w-full @min-[420px]:w-auto\">\n @if (dataSource.toolbarLeftTemplate) {\n <div class=\"flex flex-col w-full @min-[420px]:w-auto @min-[420px]:flex-row\">\n <ng-container [ngTemplateOutlet]=\"dataSource.toolbarLeftTemplate\"></ng-container>\n </div>\n }\n </div>\n <div\n class=\"flex flex-col @min-[420px]:flex-row @min-[420px]:items-center gap-2 w-full @min-[420px]:flex-1 @min-[560px]:flex-none @min-[560px]:w-auto\">\n @if (dataSource.canSearch) {\n <!-- Enter must not submit a surrounding form: the search box belongs to the\n table's chrome, not to whatever form the table happens to sit in. -->\n <mn-lib-input-field\n (keydown.enter)=\"$event.preventDefault()\"\n class=\"w-full @min-[420px]:flex-1 @min-[560px]:max-w-64\"\n [props]=\"{\n id: 'mn-table-search',\n type: 'search',\n label: '',\n ariaLabel: searchPlaceholderLabel,\n placeholder: searchPlaceholderLabel,\n size: 'sm',\n borderRadius: 'md',\n fullWidth: true\n }\"\n [ngModel]=\"searchValue\"\n (ngModelChange)=\"onSearch($event)\"\n ></mn-lib-input-field>\n }\n @if (dataSource.toolbarRightTemplate) {\n <div class=\"flex flex-col w-full @min-[420px]:w-auto @min-[420px]:flex-row\">\n <ng-container [ngTemplateOutlet]=\"dataSource.toolbarRightTemplate\"></ng-container>\n </div>\n }\n <!-- Small-screen filter toggle: replaces the inline per-column filter row below 640px -->\n @if (hasColumnFilters && filtersCollapsed) {\n <button\n type=\"button\"\n mnButton\n [data]=\"{ variant: 'outline', color: 'primary', size: 'md', borderRadius: 'md', hover: true }\"\n class=\"w-full @min-[420px]:w-auto gap-1.5\"\n [attr.aria-expanded]=\"filtersPanelOpen\"\n aria-controls=\"mn-table-filters-panel\"\n (click)=\"toggleFiltersPanel()\"\n >\n <svg lucideFunnel [size]=\"15\"></svg>\n <span>{{ filtersButtonLabel }}</span>\n </button>\n }\n </div>\n</div>\n}\n\n<!-- Small-screen filter panel: stacked, full-width fields decoupled from column widths -->\n@if (hasColumnFilters && filtersCollapsed) {\n <div\n id=\"mn-table-filters-panel\"\n class=\"grid transition-all duration-200 ease-out motion-reduce:transition-none\"\n [style.grid-template-rows]=\"filtersPanelOpen ? '1fr' : '0fr'\"\n >\n <div class=\"overflow-hidden\" [attr.inert]=\"filtersPanelOpen ? null : ''\">\n <div class=\"flex flex-col gap-3 rounded-md border border-base-300 bg-base-100 p-3 mb-3\">\n @for (column of dataSource.columns; track column.key) {\n @if (column.filterable) {\n <div class=\"flex flex-col gap-1\">\n <label\n class=\"text-xs font-medium text-base-content/70\"\n [attr.for]=\"'mn-table-filter-' + column.key\"\n >\n @if (isTemplateRef(column.header)) {\n <ng-container [ngTemplateOutlet]=\"$any(column.header)\"></ng-container>\n } @else {\n {{ column.header }}\n }\n </label>\n <ng-container\n [ngTemplateOutletContext]=\"{ $implicit: column, idScope: 'panel' }\"\n [ngTemplateOutlet]=\"filterField\"\n ></ng-container>\n </div>\n }\n }\n @if (hasActiveFilters) {\n <button\n type=\"button\"\n mnButton\n [data]=\"{ variant: 'text', color: 'gray', size: 'sm', borderRadius: 'md', hover: true }\"\n class=\"self-start gap-1\"\n (click)=\"clearAllFilters()\"\n >\n <svg lucideX [size]=\"14\"></svg>\n <span>{{ clearFiltersButtonLabel }}</span>\n </button>\n }\n </div>\n </div>\n </div>\n}\n\n<!-- Table wrapper with horizontal scroll -->\n <div #collectionBody [attr.aria-label]=\"tableRegionLabel\" [style.min-height.px]=\"bodyMinHeight\"\n class=\"overflow-x-auto\"\n role=\"region\">\n <table [class]=\"tableClasses\">\n <thead>\n <tr class=\"bg-base-100\">\n <!-- Selection checkbox column header -->\n @if (hasSelection) {\n <th class=\"w-10 text-center text-sm px-2 py-2\">\n @if (isMultiSelect) {\n <mn-lib-checkbox\n (checkedChange)=\"toggleAll()\"\n [checked]=\"allSelected\"\n [props]=\"{ id: 'mn-table-select-all', size: 'sm' }\"\n ></mn-lib-checkbox>\n }\n </th>\n }\n\n <!-- Data columns -->\n @for (column of dataSource.columns; track column.key) {\n <th\n [attr.data-column-key]=\"column.key\"\n [class.truncate]=\"widthsArePinned\"\n [class.cursor-pointer]=\"isSortable(column)\"\n [class.select-none]=\"isSortable(column)\"\n [class.hover:bg-base-200]=\"isSortable(column)\"\n [class.text-left]=\"(column.align ?? 'left') === 'left'\"\n [class.text-center]=\"column.align === 'center'\"\n [class.text-right]=\"column.align === 'right'\"\n [mnHiddenBelow]=\"column.hiddenBelow\"\n [style.width]=\"columnWidth(column)\"\n [attr.aria-sort]=\"currentSort?.columnKey === column.key ? (currentSort!.direction === 'asc' ? 'ascending' : 'descending') : null\"\n class=\"text-sm px-2 py-1 @min-[640px]:px-4 @min-[640px]:py-2 whitespace-nowrap\"\n (click)=\"sort(column)\"\n >\n <span class=\"inline-flex items-center gap-1\">\n @if (isTemplateRef(column.header)) {\n <ng-container [ngTemplateOutlet]=\"$any(column.header)\"></ng-container>\n } @else {\n <span>{{ column.header }}</span>\n }\n @if (isSortable(column)) {\n <span class=\"text-[0.65rem] opacity-70 min-w-3 inline-block\">{{ getSortIcon(column) }}</span>\n }\n </span>\n </th>\n }\n\n </tr>\n\n <!-- Per-column filter row (wide screens only; collapses into a panel below 640px).\n `font-normal` on the cells is load-bearing: these are `th` elements, which the\n browser renders bold, and the filter inputs and selects inside them inherit\n that. Filter controls are form fields, not headings, and must not look bold.\n The stacked filter panel renders outside any `th`, so it is unaffected. -->\n @if (hasColumnFilters && !filtersCollapsed) {\n <tr class=\"bg-base-100 border-b border-base-300 font-normal\">\n @if (hasSelection) {\n <th class=\"px-2 py-1 font-normal\"></th>\n }\n @for (column of dataSource.columns; track column.key) {\n <th\n class=\"px-4 py-2 font-normal\"\n [mnHiddenBelow]=\"column.hiddenBelow\"\n >\n @if (column.filterable) {\n <!-- Every filter renders as an ordinary control right under its\n header. The rich types used to hide behind a button that opened\n a floating panel, which cost a click to discover, a click to\n apply, and hid whether a column was even filterable. -->\n <ng-container\n [ngTemplateOutletContext]=\"{ $implicit: column, idScope: 'inline' }\"\n [ngTemplateOutlet]=\"filterField\"\n ></ng-container>\n }\n </th>\n }\n </tr>\n }\n </thead>\n\n <tbody>\n <!-- Loading state -->\n @if (isLoadingState) {\n @for (_ of skeletonRows; track $index) {\n <tr>\n @if (hasSelection) {\n <td class=\"px-2 py-3\">\n <mn-skeleton [data]=\"{ shape: 'rectangle', width: '1rem', height: '1rem' }\"></mn-skeleton>\n </td>\n }\n @for (column of dataSource.columns; track column.key) {\n <td class=\"px-4 py-3\"\n [mnHiddenBelow]=\"column.hiddenBelow\"\n [style.width]=\"columnWidth(column)\"\n >\n @if (isTemplateRef(column.skeleton)) {\n <ng-container [ngTemplateOutlet]=\"$any(column.skeleton)\"></ng-container>\n } @else {\n <mn-skeleton [data]=\"getColumnSkeletonData(column)\"></mn-skeleton>\n }\n </td>\n }\n </tr>\n }\n } @else if (isErrorState) {\n <!-- Error state -->\n <tr class=\"bg-base-100\">\n <td [attr.colspan]=\"totalColumnCount\" class=\"text-center text-xs py-8\">\n @if (dataSource.errorTemplate) {\n <ng-container [ngTemplateOutlet]=\"dataSource.errorTemplate\"></ng-container>\n } @else {\n <div class=\"flex flex-col items-center gap-2 text-error\">\n <p class=\"text-sm\">{{ dataSource.errorMessage }}</p>\n </div>\n }\n </td>\n </tr>\n } @else {\n <!-- Empty state -->\n @if (filteredItems.length === 0) {\n <tr class=\"bg-base-100\">\n <td [attr.colspan]=\"totalColumnCount\" class=\"text-center text-xs py-8\">\n @if (dataSource.emptyTemplate) {\n <ng-container [ngTemplateOutlet]=\"dataSource.emptyTemplate\"></ng-container>\n } @else {\n <div class=\"flex flex-col items-center gap-2 text-base-content/50\">\n @if (dataSource.emptyIcon !== null) {\n <svg [lucideIcon]=\"dataSource.emptyIcon ?? defaultEmptyIcon\" [size]=\"32\" [strokeWidth]=\"1.5\"></svg>\n }\n <p class=\"text-sm\">{{ dataSource.emptyMessage }}</p>\n </div>\n }\n </td>\n </tr>\n }\n\n <!-- Data rows -->\n @for (row of paginatedItems; track trackByID($index, row); let odd = $odd; let last = $last) {\n <tr\n class=\"bg-base-100 transition-colors duration-150 hover:cursor-pointer\"\n [ngClass]=\"{'bg-primary/10': isSelected(row)}\"\n [class.bg-base-200]=\"!isSelected(row) && odd && dataSource.appearance?.striped\"\n [class.hover:bg-base-200]=\"dataSource.appearance?.hover !== false\"\n [class.cursor-pointer]=\"!!dataSource.onRowClick\"\n [class.border-b]=\"!last\"\n [class.border-base-300]=\"!last\"\n [class.border-b-1]=\"last\"\n [class.border-black]=\"last\"\n [class.shadow-3xl]=\"last\"\n (click)=\"onRowClick(row)\"\n >\n <!-- Selection checkbox -->\n @if (hasSelection) {\n <td (click)=\"$event.stopPropagation()\" class=\"w-10 text-center px-2 py-2\">\n <mn-lib-checkbox\n (checkedChange)=\"toggle(row)\"\n [checked]=\"isSelected(row)\"\n [props]=\"{ id: 'mn-table-row-' + $index, size: 'sm' }\"\n ></mn-lib-checkbox>\n </td>\n }\n\n <!-- Data cells -->\n @for (column of dataSource.columns; track column.key) {\n <td\n [attr.title]=\"cellTitle(column, row)\"\n [class.text-left]=\"(column.align ?? 'left') === 'left'\"\n [class.text-center]=\"column.align === 'center'\"\n [class.text-right]=\"column.align === 'right'\"\n [class.truncate]=\"widthsArePinned\"\n [mnHiddenBelow]=\"column.hiddenBelow\"\n [style.width]=\"columnWidth(column)\"\n class=\"text-xs px-2 py-1 @min-[640px]:px-4 @min-[640px]:py-2\"\n >\n @if (column.actions) {\n <!-- Actions column: inline command buttons that collapse into a \u22EF menu\n once the table is narrower than 450px (container query), but only\n when there are enough of them to warrant it. A row with no visible\n actions renders nothing \u2014 no buttons, no \u22EF. -->\n @if (hasRowActions(column, row)) {\n <div class=\"inline-flex items-center gap-1\"\n [class.justify-end]=\"(column.align ?? 'left') === 'right'\">\n @if (shouldCollapseActions(column, row)) {\n <span class=\"hidden items-center gap-1 @min-[450px]:inline-flex\">\n <ng-container [ngTemplateOutletContext]=\"{ column: column, row: row }\"\n [ngTemplateOutlet]=\"actionButtons\"></ng-container>\n </span>\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <span class=\"@min-[450px]:hidden\" (click)=\"$event.stopPropagation()\">\n <mn-lib-dropdown [props]=\"{\n id: actionsDropdownId(column, row),\n actions: rowDropdownActions(column, row),\n menuLabel: $any(column.header),\n size: 'sm'\n }\"></mn-lib-dropdown>\n </span>\n } @else {\n <ng-container [ngTemplateOutletContext]=\"{ column: column, row: row }\"\n [ngTemplateOutlet]=\"actionButtons\"></ng-container>\n }\n </div>\n }\n } @else if (column.cellSm) {\n <!-- Default cell: hidden below the cellSm breakpoint -->\n <span [mnShowAbove]=\"column.cellSm.below\">\n @if (isTemplateRef(column.cell)) {\n <ng-container [ngTemplateOutletContext]=\"{ $implicit: row, data: row }\"\n [ngTemplateOutlet]=\"$any(column.cell)\"></ng-container>\n } @else {\n {{ getCellValue(column, row) }}\n }\n </span>\n <!-- Small cell: shown only below the cellSm breakpoint -->\n <span [mnShowBelow]=\"column.cellSm.below\">\n @if (isTemplateRef(column.cellSm.cell)) {\n <ng-container [ngTemplateOutletContext]=\"{ $implicit: row, data: row }\"\n [ngTemplateOutlet]=\"$any(column.cellSm.cell)\"></ng-container>\n } @else {\n {{ getCellSmValue(column, row) }}\n }\n </span>\n } @else {\n @if (isTemplateRef(column.cell)) {\n <ng-container [ngTemplateOutletContext]=\"{ $implicit: row, data: row }\"\n [ngTemplateOutlet]=\"$any(column.cell)\"></ng-container>\n } @else {\n {{ getCellValue(column, row) }}\n }\n }\n </td>\n }\n\n </tr>\n }\n }\n </tbody>\n </table>\n</div>\n\n<!-- Load more + pagination -->\n<mn-collection-pagination\n (loadMore)=\"loadMoreRows()\"\n (pageChange)=\"goToPage($event)\"\n (pageSizeChange)=\"onPageSizeChange($event)\"\n [currentPage]=\"currentPage\"\n [isPaginated]=\"isPaginated\"\n [isServerPaginated]=\"isServerPaginated\"\n [labels]=\"dataSource.labels\"\n [loadingMoreRows]=\"loadingMoreRows\"\n [pageSizeSelectOptions]=\"pageSizeSelectOptions\"\n [pageSize]=\"pageSize\"\n [showLoadMore]=\"showLoadMore\"\n [totalItemCount]=\"totalItemCount\"\n [totalPages]=\"totalPages\"\n [visiblePages]=\"visiblePages\"\n idPrefix=\"mn-table\"\n></mn-collection-pagination>\n</div>\n\n<!-- Single source of truth for every filter control, reused by the inline header\n row and the small-screen panel. `idScope` keeps element ids unique across\n both placements. -->\n<ng-template #filterField let-column let-idScope=\"idScope\">\n @switch (filterTypeOf(column)) {\n @case ('select') {\n <mn-lib-select\n (ngModelChange)=\"onColumnFilter(column, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"textFilterValue(column)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key,\n options: getFilterSelectOptions(column),\n size: 'sm',\n fullWidth: true\n }\"\n ></mn-lib-select>\n }\n @case ('multi-select') {\n <mn-lib-multi-select\n (ngModelChange)=\"onColumnFilter(column, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"multiFilterValue(column)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key,\n options: getFilterMultiSelectOptions(column),\n placeholder: column.filterPlaceholder ?? '',\n collapsePlaceholder: filterSelectedLabel,\n collapseThreshold: 1,\n size: 'sm',\n fullWidth: true\n }\"\n ></mn-lib-multi-select>\n }\n @case ('boolean') {\n <mn-lib-select\n (ngModelChange)=\"onBooleanFilter(column, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"booleanFilterValue(column)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key,\n options: getBooleanFilterOptions(column),\n size: 'sm',\n fullWidth: true\n }\"\n ></mn-lib-select>\n }\n @default {\n <mn-lib-input-field\n (keydown.enter)=\"$event.preventDefault()\"\n (ngModelChange)=\"onColumnFilter(column, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"textFilterValue(column)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key,\n type: 'text',\n label: '',\n placeholder: column.filterPlaceholder ?? '',\n ariaLabel: column.filterPlaceholder ?? '',\n autocomplete: column.filterAutocomplete ?? undefined,\n size: 'sm',\n borderRadius: 'md',\n fullWidth: true,\n hover: true\n }\"\n ></mn-lib-input-field>\n }\n }\n</ng-template>\n\n<!-- Inline action buttons for an actions column, shared by the wide-table layout and\n (implicitly) mirrored by the \u22EF menu below 450px. -->\n<ng-template #actionButtons let-column=\"column\" let-row=\"row\">\n @for (action of visibleRowActions(column, row); track $index) {\n <button\n mnButton\n type=\"button\"\n [data]=\"{\n size: 'sm',\n variant: 'text',\n color: rowActionColor(action, row),\n disabled: isRowActionDisabled(action, row)\n }\"\n class=\"cursor-pointer\"\n (click)=\"$event.stopPropagation(); runRowAction(action, row)\"\n [attr.aria-label]=\"rowActionLabel(action, row)\"\n [attr.title]=\"showActionLabel(column, action, row) ? null : rowActionLabel(action, row)\"\n >\n @if (showActionIcon(column, action, row)) {\n <span class=\"inline-flex items-center shrink-0\">\n @let icon = rowActionIcon(action, row);\n @if (isTemplateRef(icon)) {\n <ng-container [ngTemplateOutlet]=\"icon\"></ng-container>\n } @else {\n <!-- Data icon: sized here to match the sm button's text, so a caller can\n declare the action in TypeScript without owning a template. -->\n <svg [lucideIcon]=\"$any(icon)\" [size]=\"17\"></svg>\n }\n </span>\n }\n @if (showActionLabel(column, action, row)) {\n <span>{{ rowActionLabel(action, row) }}</span>\n }\n </button>\n }\n</ng-template>\n" }]
|
|
8258
|
+
args: [{ selector: 'mn-table', standalone: true, imports: [NgClass, NgTemplateOutlet, MnCheckbox, MnHiddenBelowDirective, MnShowAboveDirective, MnShowBelowDirective, MnInputField, MnSelect, MnMultiSelect, MnDropdown, MnSkeleton, FormsModule, MnCollectionPagination, MnButton, LucideFilter, LucideX, LucideFunnel, LucideDynamicIcon], changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'block' }, template: "<!-- Everything that reflows lives inside a @container, so the breakpoints below\n measure the table's own width rather than the window's. A table in a modal,\n a sidebar or a narrow grid cell is far narrower than the viewport, and\n viewport breakpoints would hand it a desktop layout it has no room for. -->\n<div class=\"@container\">\n <!-- Selection summary: the whole selection, never paged, filtered or sorted. The\n table below answers \"what could I pick?\", which is why it is searched and paged;\n this answers \"what did I pick?\", which a paginated list cannot without hiding\n most of the answer on some other page. -->\n @if (showSelectionSummary) {\n <div class=\"flex flex-col gap-2 rounded-md border border-base-300 bg-base-200/50 p-3 mb-3\">\n <div class=\"flex items-center justify-between gap-2\">\n <span class=\"text-sm font-medium text-base-content\">{{ selectionSummaryTitle }}</span>\n <button\n (click)=\"clearSelection()\"\n [data]=\"{ variant: 'text', color: 'gray', size: 'sm', borderRadius: 'md', hover: true }\"\n class=\"gap-1 shrink-0\"\n mnButton\n type=\"button\"\n >\n <svg [size]=\"14\" lucideX></svg>\n <span>{{ selectionClearAllLabel }}</span>\n </button>\n </div>\n <!-- Only the first few tags render; the rest collapse behind a \"+N more\" control.\n The heading's count always states the true total, so this hides tags, never\n information. Expanded, the list is height-capped and scrolls, so even a\n selection of hundreds cannot push the table off screen. -->\n <ul\n [class.max-h-28]=\"selectionSummaryExpanded\"\n [class.overflow-y-auto]=\"selectionSummaryExpanded\"\n class=\"flex flex-wrap gap-1.5 list-none m-0 p-0\"\n >\n @for (row of visibleSelectionRows; track dataSource.getID(row)) {\n <li\n class=\"inline-flex items-center gap-1 rounded-full bg-base-100 border border-base-300 pl-2.5 pr-1 py-0.5 text-xs text-base-content max-w-full\">\n <span [attr.title]=\"selectionLabelFor(row)\" class=\"truncate\">{{ selectionLabelFor(row) }}</span>\n <button\n (click)=\"removeSelection(row)\"\n [attr.aria-label]=\"selectionRemoveLabel(row)\"\n class=\"shrink-0 rounded-full p-0.5 hover:bg-base-300 transition-colors cursor-pointer\"\n type=\"button\"\n >\n <svg [size]=\"12\" lucideX></svg>\n </button>\n </li>\n }\n @if (hiddenSelectionCount > 0 || selectionSummaryExpanded) {\n <li>\n <button\n (click)=\"toggleSelectionSummary()\"\n [attr.aria-expanded]=\"selectionSummaryExpanded\"\n [data]=\"{ variant: 'text', color: 'primary', size: 'sm', borderRadius: 'md', hover: true }\"\n class=\"text-xs\"\n mnButton\n type=\"button\"\n >\n {{ selectionSummaryToggleLabel }}\n </button>\n </li>\n }\n </ul>\n </div>\n }\n\n <!-- Toolbar: custom toolbar template + search. Only rendered when it has content,\n so an unsearchable table without toolbar templates emits no empty spacer. -->\n@if (dataSource.canSearch || dataSource.toolbarLeftTemplate || dataSource.toolbarRightTemplate || (hasColumnFilters && filtersCollapsed)) {\n <div class=\"flex flex-col @min-[420px]:flex-row @min-[420px]:items-center justify-between gap-2 mb-3\">\n <div class=\"flex items-center gap-2 w-full @min-[420px]:w-auto\">\n @if (dataSource.toolbarLeftTemplate) {\n <div class=\"flex flex-col w-full @min-[420px]:w-auto @min-[420px]:flex-row\">\n <ng-container [ngTemplateOutlet]=\"dataSource.toolbarLeftTemplate\"></ng-container>\n </div>\n }\n </div>\n <div\n class=\"flex flex-col @min-[420px]:flex-row @min-[420px]:items-center gap-2 w-full @min-[420px]:flex-1 @min-[560px]:flex-none @min-[560px]:w-auto\">\n @if (dataSource.canSearch) {\n <!-- Enter must not submit a surrounding form: the search box belongs to the\n table's chrome, not to whatever form the table happens to sit in. -->\n <mn-lib-input-field\n (keydown.enter)=\"$event.preventDefault()\"\n class=\"w-full @min-[420px]:flex-1 @min-[560px]:max-w-64\"\n [props]=\"{\n id: 'mn-table-search',\n type: 'search',\n label: '',\n ariaLabel: searchPlaceholderLabel,\n placeholder: searchPlaceholderLabel,\n size: 'sm',\n borderRadius: 'md',\n fullWidth: true\n }\"\n [ngModel]=\"searchValue\"\n (ngModelChange)=\"onSearch($event)\"\n ></mn-lib-input-field>\n }\n @if (dataSource.toolbarRightTemplate) {\n <div class=\"flex flex-col w-full @min-[420px]:w-auto @min-[420px]:flex-row\">\n <ng-container [ngTemplateOutlet]=\"dataSource.toolbarRightTemplate\"></ng-container>\n </div>\n }\n <!-- Small-screen filter toggle: replaces the inline per-column filter row below 640px -->\n @if (hasColumnFilters && filtersCollapsed) {\n <button\n type=\"button\"\n mnButton\n [data]=\"{ variant: 'outline', color: 'primary', size: 'md', borderRadius: 'md', hover: true }\"\n class=\"w-full @min-[420px]:w-auto gap-1.5\"\n [attr.aria-expanded]=\"filtersPanelOpen\"\n aria-controls=\"mn-table-filters-panel\"\n (click)=\"toggleFiltersPanel()\"\n >\n <svg lucideFunnel [size]=\"15\"></svg>\n <span>{{ filtersButtonLabel }}</span>\n </button>\n }\n </div>\n</div>\n}\n\n<!-- Small-screen filter panel: stacked, full-width fields decoupled from column widths -->\n@if (hasColumnFilters && filtersCollapsed) {\n <div\n id=\"mn-table-filters-panel\"\n class=\"grid transition-all duration-200 ease-out motion-reduce:transition-none\"\n [style.grid-template-rows]=\"filtersPanelOpen ? '1fr' : '0fr'\"\n >\n <div class=\"overflow-hidden\" [attr.inert]=\"filtersPanelOpen ? null : ''\">\n <div class=\"flex flex-col gap-3 rounded-md border border-base-300 bg-base-100 p-3 mb-3\">\n @for (column of dataSource.columns; track column.key) {\n @if (column.filterable) {\n <div class=\"flex flex-col gap-1\">\n <label\n class=\"text-xs font-medium text-base-content/70\"\n [attr.for]=\"'mn-table-filter-' + column.key\"\n >\n @if (isTemplateRef(column.header)) {\n <ng-container [ngTemplateOutlet]=\"$any(column.header)\"></ng-container>\n } @else {\n {{ column.header }}\n }\n </label>\n <ng-container\n [ngTemplateOutletContext]=\"{ $implicit: column, idScope: 'panel' }\"\n [ngTemplateOutlet]=\"filterField\"\n ></ng-container>\n </div>\n }\n }\n @if (hasActiveFilters) {\n <button\n type=\"button\"\n mnButton\n [data]=\"{ variant: 'text', color: 'gray', size: 'sm', borderRadius: 'md', hover: true }\"\n class=\"self-start gap-1\"\n (click)=\"clearAllFilters()\"\n >\n <svg lucideX [size]=\"14\"></svg>\n <span>{{ clearFiltersButtonLabel }}</span>\n </button>\n }\n </div>\n </div>\n </div>\n}\n\n<!-- Table wrapper with horizontal scroll -->\n <div #collectionBody [attr.aria-label]=\"tableRegionLabel\" [style.min-height.px]=\"bodyMinHeight\"\n class=\"overflow-x-auto\"\n role=\"region\">\n <table [class]=\"tableClasses\">\n <thead>\n <tr class=\"bg-base-100\">\n <!-- Selection checkbox column header -->\n @if (hasSelection) {\n <th class=\"w-10 text-center text-sm px-2 py-2\">\n @if (isMultiSelect) {\n <mn-lib-checkbox\n (checkedChange)=\"toggleAll()\"\n [checked]=\"allSelected\"\n [props]=\"{ id: 'mn-table-select-all', size: 'sm' }\"\n ></mn-lib-checkbox>\n }\n </th>\n }\n\n <!-- Data columns -->\n @for (column of dataSource.columns; track column.key) {\n <th\n [attr.data-column-key]=\"column.key\"\n [class.truncate]=\"widthsArePinned\"\n [class.cursor-pointer]=\"isSortable(column)\"\n [class.select-none]=\"isSortable(column)\"\n [class.hover:bg-base-200]=\"isSortable(column)\"\n [class.text-left]=\"(column.align ?? 'left') === 'left'\"\n [class.text-center]=\"column.align === 'center'\"\n [class.text-right]=\"column.align === 'right'\"\n [mnHiddenBelow]=\"column.hiddenBelow\"\n [style.width]=\"columnWidth(column)\"\n [attr.aria-sort]=\"currentSort?.columnKey === column.key ? (currentSort!.direction === 'asc' ? 'ascending' : 'descending') : null\"\n class=\"text-sm px-2 py-1 @min-[640px]:px-4 @min-[640px]:py-2 whitespace-nowrap\"\n (click)=\"sort(column)\"\n >\n <span class=\"inline-flex items-center gap-1\">\n @if (isTemplateRef(column.header)) {\n <ng-container [ngTemplateOutlet]=\"$any(column.header)\"></ng-container>\n } @else {\n <span>{{ column.header }}</span>\n }\n @if (isSortable(column)) {\n <span class=\"text-[0.65rem] opacity-70 min-w-3 inline-block\">{{ getSortIcon(column) }}</span>\n }\n </span>\n </th>\n }\n\n </tr>\n\n <!-- Per-column filter row (wide screens only; collapses into a panel below 640px).\n `font-normal` on the cells is load-bearing: these are `th` elements, which the\n browser renders bold, and the filter inputs and selects inside them inherit\n that. Filter controls are form fields, not headings, and must not look bold.\n The stacked filter panel renders outside any `th`, so it is unaffected. -->\n @if (hasColumnFilters && !filtersCollapsed) {\n <tr class=\"bg-base-100 border-b border-base-300 font-normal\">\n @if (hasSelection) {\n <th class=\"px-2 py-1 font-normal\"></th>\n }\n @for (column of dataSource.columns; track column.key) {\n <th\n class=\"px-4 py-2 font-normal\"\n [mnHiddenBelow]=\"column.hiddenBelow\"\n >\n @if (column.filterable) {\n <!-- Every filter renders as an ordinary control right under its\n header. The rich types used to hide behind a button that opened\n a floating panel, which cost a click to discover, a click to\n apply, and hid whether a column was even filterable. -->\n <ng-container\n [ngTemplateOutletContext]=\"{ $implicit: column, idScope: 'inline' }\"\n [ngTemplateOutlet]=\"filterField\"\n ></ng-container>\n }\n </th>\n }\n </tr>\n }\n </thead>\n\n <tbody>\n <!-- Loading state -->\n @if (isLoadingState) {\n @for (_ of skeletonRows; track $index) {\n <tr>\n @if (hasSelection) {\n <td class=\"px-2 py-3\">\n <mn-skeleton [data]=\"{ shape: 'rectangle', width: '1rem', height: '1rem' }\"></mn-skeleton>\n </td>\n }\n @for (column of dataSource.columns; track column.key) {\n <td class=\"px-4 py-3\"\n [mnHiddenBelow]=\"column.hiddenBelow\"\n [style.width]=\"columnWidth(column)\"\n >\n @if (isTemplateRef(column.skeleton)) {\n <ng-container [ngTemplateOutlet]=\"$any(column.skeleton)\"></ng-container>\n } @else {\n <mn-skeleton [data]=\"getColumnSkeletonData(column)\"></mn-skeleton>\n }\n </td>\n }\n </tr>\n }\n } @else if (isErrorState) {\n <!-- Error state -->\n <tr class=\"bg-base-100\">\n <td [attr.colspan]=\"totalColumnCount\" class=\"text-center text-xs py-8\">\n @if (dataSource.errorTemplate) {\n <ng-container [ngTemplateOutlet]=\"dataSource.errorTemplate\"></ng-container>\n } @else {\n <div class=\"flex flex-col items-center gap-2 text-error\">\n <p class=\"text-sm\">{{ dataSource.errorMessage }}</p>\n </div>\n }\n </td>\n </tr>\n } @else {\n <!-- Empty state -->\n @if (filteredItems.length === 0) {\n <tr class=\"bg-base-100\">\n <td [attr.colspan]=\"totalColumnCount\" class=\"text-center text-xs py-8\">\n @if (dataSource.emptyTemplate) {\n <ng-container [ngTemplateOutlet]=\"dataSource.emptyTemplate\"></ng-container>\n } @else {\n <div class=\"flex flex-col items-center gap-2 text-base-content/50\">\n @if (dataSource.emptyIcon !== null) {\n <svg [lucideIcon]=\"dataSource.emptyIcon ?? defaultEmptyIcon\" [size]=\"32\" [strokeWidth]=\"1.5\"></svg>\n }\n <p class=\"text-sm\">{{ dataSource.emptyMessage }}</p>\n </div>\n }\n </td>\n </tr>\n }\n\n <!-- Data rows -->\n @for (row of paginatedItems; track trackByID($index, row); let odd = $odd; let last = $last) {\n <tr\n class=\"bg-base-100 transition-colors duration-150 hover:cursor-pointer\"\n [ngClass]=\"{'bg-primary/10': isSelected(row)}\"\n [class.bg-base-200]=\"!isSelected(row) && odd && dataSource.appearance?.striped\"\n [class.hover:bg-base-200]=\"dataSource.appearance?.hover !== false\"\n [class.cursor-pointer]=\"!!dataSource.onRowClick\"\n [class.border-b]=\"!last\"\n [class.border-base-300]=\"!last\"\n [class.border-b-1]=\"last\"\n [class.border-black]=\"last\"\n [class.shadow-3xl]=\"last\"\n (click)=\"onRowClick(row)\"\n >\n <!-- Selection checkbox -->\n @if (hasSelection) {\n <td (click)=\"$event.stopPropagation()\" class=\"w-10 text-center px-2 py-2\">\n <mn-lib-checkbox\n (checkedChange)=\"toggle(row)\"\n [checked]=\"isSelected(row)\"\n [props]=\"{ id: 'mn-table-row-' + $index, size: 'sm' }\"\n ></mn-lib-checkbox>\n </td>\n }\n\n <!-- Data cells -->\n @for (column of dataSource.columns; track column.key) {\n <td\n [attr.title]=\"cellTitle(column, row)\"\n [class.text-left]=\"(column.align ?? 'left') === 'left'\"\n [class.text-center]=\"column.align === 'center'\"\n [class.text-right]=\"column.align === 'right'\"\n [class.truncate]=\"widthsArePinned\"\n [mnHiddenBelow]=\"column.hiddenBelow\"\n [style.width]=\"columnWidth(column)\"\n class=\"text-xs px-2 py-1 @min-[640px]:px-4 @min-[640px]:py-2\"\n >\n @if (column.actions) {\n <!-- Actions column: inline command buttons that collapse into a \u22EF menu\n once the table is narrower than 450px (container query), for every\n row with actions. A row with no visible actions renders nothing. -->\n @if (hasRowActions(column, row)) {\n <div class=\"inline-flex items-center gap-1\"\n [class.justify-end]=\"(column.align ?? 'left') === 'right'\">\n <span class=\"hidden items-center gap-1 @min-[450px]:inline-flex\">\n <ng-container [ngTemplateOutletContext]=\"{ column: column, row: row }\"\n [ngTemplateOutlet]=\"actionButtons\"></ng-container>\n </span>\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <span class=\"@min-[450px]:hidden\" (click)=\"$event.stopPropagation()\">\n <mn-lib-dropdown [datasource]=\"{\n id: actionsDropdownId(column, row),\n actions: rowDropdownActions(column, row),\n menuLabel: $any(column.header),\n size: 'sm'\n }\"></mn-lib-dropdown>\n </span>\n </div>\n }\n } @else if (column.cellSm) {\n <!-- Default cell: hidden below the cellSm breakpoint -->\n <span [mnShowAbove]=\"column.cellSm.below\">\n @if (isTemplateRef(column.cell)) {\n <ng-container [ngTemplateOutletContext]=\"{ $implicit: row, data: row }\"\n [ngTemplateOutlet]=\"$any(column.cell)\"></ng-container>\n } @else {\n {{ getCellValue(column, row) }}\n }\n </span>\n <!-- Small cell: shown only below the cellSm breakpoint -->\n <span [mnShowBelow]=\"column.cellSm.below\">\n @if (isTemplateRef(column.cellSm.cell)) {\n <ng-container [ngTemplateOutletContext]=\"{ $implicit: row, data: row }\"\n [ngTemplateOutlet]=\"$any(column.cellSm.cell)\"></ng-container>\n } @else {\n {{ getCellSmValue(column, row) }}\n }\n </span>\n } @else {\n @if (isTemplateRef(column.cell)) {\n <ng-container [ngTemplateOutletContext]=\"{ $implicit: row, data: row }\"\n [ngTemplateOutlet]=\"$any(column.cell)\"></ng-container>\n } @else {\n {{ getCellValue(column, row) }}\n }\n }\n </td>\n }\n\n </tr>\n }\n }\n </tbody>\n </table>\n</div>\n\n<!-- Load more + pagination -->\n<mn-collection-pagination\n (loadMore)=\"loadMoreRows()\"\n (pageChange)=\"goToPage($event)\"\n (pageSizeChange)=\"onPageSizeChange($event)\"\n [currentPage]=\"currentPage\"\n [isPaginated]=\"isPaginated\"\n [isServerPaginated]=\"isServerPaginated\"\n [labels]=\"dataSource.labels\"\n [loadingMoreRows]=\"loadingMoreRows\"\n [pageSizeSelectOptions]=\"pageSizeSelectOptions\"\n [pageSize]=\"pageSize\"\n [showLoadMore]=\"showLoadMore\"\n [totalItemCount]=\"totalItemCount\"\n [totalPages]=\"totalPages\"\n [visiblePages]=\"visiblePages\"\n idPrefix=\"mn-table\"\n></mn-collection-pagination>\n</div>\n\n<!-- Single source of truth for every filter control, reused by the inline header\n row and the small-screen panel. `idScope` keeps element ids unique across\n both placements. -->\n<ng-template #filterField let-column let-idScope=\"idScope\">\n @switch (filterTypeOf(column)) {\n @case ('select') {\n <mn-lib-select\n (ngModelChange)=\"onColumnFilter(column, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"textFilterValue(column)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key,\n options: getFilterSelectOptions(column),\n size: 'sm',\n fullWidth: true\n }\"\n ></mn-lib-select>\n }\n @case ('multi-select') {\n <mn-lib-multi-select\n (ngModelChange)=\"onColumnFilter(column, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"multiFilterValue(column)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key,\n options: getFilterMultiSelectOptions(column),\n placeholder: column.filterPlaceholder ?? '',\n collapsePlaceholder: filterSelectedLabel,\n collapseThreshold: 1,\n size: 'sm',\n fullWidth: true\n }\"\n ></mn-lib-multi-select>\n }\n @case ('boolean') {\n <mn-lib-select\n (ngModelChange)=\"onBooleanFilter(column, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"booleanFilterValue(column)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key,\n options: getBooleanFilterOptions(column),\n size: 'sm',\n fullWidth: true\n }\"\n ></mn-lib-select>\n }\n @default {\n <mn-lib-input-field\n (keydown.enter)=\"$event.preventDefault()\"\n (ngModelChange)=\"onColumnFilter(column, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"textFilterValue(column)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key,\n type: 'text',\n label: '',\n placeholder: column.filterPlaceholder ?? '',\n ariaLabel: column.filterPlaceholder ?? '',\n autocomplete: column.filterAutocomplete ?? undefined,\n size: 'sm',\n borderRadius: 'md',\n fullWidth: true,\n hover: true\n }\"\n ></mn-lib-input-field>\n }\n }\n</ng-template>\n\n<!-- Inline action buttons for an actions column, shared by the wide-table layout and\n (implicitly) mirrored by the \u22EF menu below 450px. -->\n<ng-template #actionButtons let-column=\"column\" let-row=\"row\">\n @for (action of visibleRowActions(column, row); track $index) {\n <button\n mnButton\n type=\"button\"\n [data]=\"{\n size: 'sm',\n variant: 'text',\n color: rowActionColor(action, row),\n disabled: isRowActionDisabled(action, row)\n }\"\n class=\"cursor-pointer\"\n (click)=\"$event.stopPropagation(); runRowAction(action, row)\"\n [attr.aria-label]=\"rowActionLabel(action, row)\"\n [attr.title]=\"showActionLabel(column, action, row) ? null : rowActionLabel(action, row)\"\n >\n @if (showActionIcon(column, action, row)) {\n <span class=\"inline-flex items-center shrink-0\">\n @let icon = rowActionIcon(action, row);\n @if (isTemplateRef(icon)) {\n <ng-container [ngTemplateOutlet]=\"icon\"></ng-container>\n } @else {\n <!-- Data icon: sized here to match the sm button's text, so a caller can\n declare the action in TypeScript without owning a template. -->\n <svg [lucideIcon]=\"$any(icon)\" [size]=\"17\"></svg>\n }\n </span>\n }\n @if (showActionLabel(column, action, row)) {\n <span>{{ rowActionLabel(action, row) }}</span>\n }\n </button>\n }\n</ng-template>\n" }]
|
|
7833
8259
|
}], ctorParameters: () => [], propDecorators: { sortChange: [{
|
|
7834
8260
|
type: Output
|
|
7835
8261
|
}], rowClick: [{
|