mn-angular-lib 1.0.160 → 1.0.162
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.
- package/fesm2022/mn-angular-lib.mjs +513 -38
- package/fesm2022/mn-angular-lib.mjs.map +1 -1
- package/package.json +1 -1
- package/types/mn-angular-lib.d.ts +262 -14
|
@@ -483,6 +483,19 @@ function isTranslatable(value) {
|
|
|
483
483
|
typeof value['$translate'] === 'string');
|
|
484
484
|
}
|
|
485
485
|
|
|
486
|
+
/**
|
|
487
|
+
* Key suffix per CLDR plural category.
|
|
488
|
+
*
|
|
489
|
+
* Only `one` earns a suffix: every locale shipped so far (`en`, `nl`) has exactly the two
|
|
490
|
+
* categories `one` and `other`, and `other` keeps the bare key. A locale with `few`/`many`
|
|
491
|
+
* (Polish, Russian, Arabic) resolves those to the plural until an entry is added here —
|
|
492
|
+
* adding one is the whole change, since the lookup is category-driven already.
|
|
493
|
+
*/
|
|
494
|
+
const PLURAL_SUFFIX = {
|
|
495
|
+
one: 'One',
|
|
496
|
+
};
|
|
497
|
+
/** Params key whose presence turns a translation into a plural-aware lookup. */
|
|
498
|
+
const COUNT_PARAM = 'count';
|
|
486
499
|
class MnLanguageService {
|
|
487
500
|
http = inject(HttpClient);
|
|
488
501
|
appRef = inject(ApplicationRef);
|
|
@@ -490,6 +503,11 @@ class MnLanguageService {
|
|
|
490
503
|
_locale$ = new BehaviorSubject('en');
|
|
491
504
|
_urlPattern = null;
|
|
492
505
|
_debug = false;
|
|
506
|
+
/**
|
|
507
|
+
* `Intl.PluralRules` per locale. Cached because {@link translate} runs on every change
|
|
508
|
+
* detection through the impure `mnTranslate` pipe, and constructing one is not cheap.
|
|
509
|
+
*/
|
|
510
|
+
_pluralRules = new Map();
|
|
493
511
|
/** Observable of the current active locale. */
|
|
494
512
|
locale$ = this._locale$.asObservable();
|
|
495
513
|
/** Current active locale. */
|
|
@@ -567,10 +585,22 @@ class MnLanguageService {
|
|
|
567
585
|
* Falls back to the key itself if no translation is found.
|
|
568
586
|
*
|
|
569
587
|
* Interpolation replaces `{{paramName}}` with the provided value.
|
|
588
|
+
*
|
|
589
|
+
* A `count` param additionally selects the wording that agrees with it: the key is
|
|
590
|
+
* resolved against its CLDR plural category first (`key` + `One`/`Two`/`Few`/`Many`/
|
|
591
|
+
* `Zero`), falling back to `key` when that sibling is undefined. Nothing has to opt in —
|
|
592
|
+
* a key with no sibling behaves exactly as before.
|
|
593
|
+
*
|
|
594
|
+
* ```ts
|
|
595
|
+
* // 'shift.asked' → '{{count}} members are notified'
|
|
596
|
+
* // 'shift.askedOne' → '{{count}} member is notified'
|
|
597
|
+
* lang.translate('shift.asked', { count: 3 }); // 3 members are notified
|
|
598
|
+
* lang.translate('shift.asked', { count: 1 }); // 1 member is notified
|
|
599
|
+
* ```
|
|
570
600
|
*/
|
|
571
601
|
translate(key, params) {
|
|
572
602
|
const map = this._translations[this.locale] ?? {};
|
|
573
|
-
let value = this.getValueFromMap(map, key);
|
|
603
|
+
let value = this.getValueFromMap(map, this.resolvePluralKey(map, key, params));
|
|
574
604
|
if (value === undefined) {
|
|
575
605
|
if (this._debug) {
|
|
576
606
|
console.warn(`[MnLanguage] Missing translation for key: "${key}" in locale: "${this.locale}"`);
|
|
@@ -584,12 +614,64 @@ class MnLanguageService {
|
|
|
584
614
|
}
|
|
585
615
|
return value;
|
|
586
616
|
}
|
|
617
|
+
/**
|
|
618
|
+
* Picks the wording that agrees with a `count` param.
|
|
619
|
+
*
|
|
620
|
+
* A key carrying a count resolves against its CLDR plural category first, so
|
|
621
|
+
* `askedMessage` + `askedMessageOne` render "3 leden krijgen bericht" and "1 lid krijgt
|
|
622
|
+
* bericht" off the same call. Both languages change the verb as well as the noun, which
|
|
623
|
+
* is why each form is a whole sentence under its own key rather than a swapped noun.
|
|
624
|
+
*
|
|
625
|
+
* Falls back to `key` whenever the sibling is undefined, so a key that never needed a
|
|
626
|
+
* plural — or an app that has not written one yet — behaves exactly as it did before.
|
|
627
|
+
* @param map The active locale's translations.
|
|
628
|
+
* @param key The dot-notated translation key.
|
|
629
|
+
* @param params The interpolation values, inspected for `count`.
|
|
630
|
+
* @returns The key to look up: the plural sibling, or `key` itself.
|
|
631
|
+
*/
|
|
632
|
+
resolvePluralKey(map, key, params) {
|
|
633
|
+
const raw = params?.[COUNT_PARAM];
|
|
634
|
+
if (raw === undefined)
|
|
635
|
+
return key;
|
|
636
|
+
// A count off a JSON payload arrives as a string often enough that comparing it
|
|
637
|
+
// strictly would silently pick the plural for a count of one.
|
|
638
|
+
const count = Number(raw);
|
|
639
|
+
if (!Number.isFinite(count))
|
|
640
|
+
return key;
|
|
641
|
+
const suffix = PLURAL_SUFFIX[this.pluralCategory(count)];
|
|
642
|
+
if (suffix === undefined)
|
|
643
|
+
return key;
|
|
644
|
+
const variant = key + suffix;
|
|
645
|
+
return this.getValueFromMap(map, variant) !== undefined ? variant : key;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* The CLDR plural category of a count in the active locale.
|
|
649
|
+
* @param count The count being quoted.
|
|
650
|
+
* @returns The category, falling back to English rules for an unusable locale.
|
|
651
|
+
*/
|
|
652
|
+
pluralCategory(count) {
|
|
653
|
+
if (!this._pluralRules.has(this.locale)) {
|
|
654
|
+
try {
|
|
655
|
+
this._pluralRules.set(this.locale, new Intl.PluralRules(this.locale));
|
|
656
|
+
}
|
|
657
|
+
catch {
|
|
658
|
+
// An unknown or malformed locale tag: fall back rather than break every string.
|
|
659
|
+
this._pluralRules.set(this.locale, null);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
const rules = this._pluralRules.get(this.locale);
|
|
663
|
+
if (!rules)
|
|
664
|
+
return count === 1 ? 'one' : 'other';
|
|
665
|
+
return rules.select(count);
|
|
666
|
+
}
|
|
587
667
|
/**
|
|
588
668
|
* Helper to retrieve a value from a potentially nested translation map using a dot-notated key.
|
|
589
669
|
*/
|
|
590
670
|
getValueFromMap(map, key) {
|
|
591
|
-
|
|
592
|
-
|
|
671
|
+
// A flattened bundle holds the dotted key verbatim; a nested one is walked below.
|
|
672
|
+
const direct = map[key];
|
|
673
|
+
if (typeof direct === 'string')
|
|
674
|
+
return direct;
|
|
593
675
|
const parts = key.split('.');
|
|
594
676
|
let current = map;
|
|
595
677
|
for (const part of parts) {
|
|
@@ -5599,18 +5681,89 @@ const mnSelectVariants = tv({
|
|
|
5599
5681
|
});
|
|
5600
5682
|
|
|
5601
5683
|
const MN_SELECT_CONFIG = new InjectionToken('MN_SELECT_CONFIG');
|
|
5684
|
+
/**
|
|
5685
|
+
* A single-value picker. The trigger opens a `role="listbox"` of {@link MnSelectOption}s;
|
|
5686
|
+
* choosing one sets the value and closes — this is the value-picker twin of the ⋯
|
|
5687
|
+
* command menu mn-dropdown, so it *is* a ControlValueAccessor.
|
|
5688
|
+
*
|
|
5689
|
+
* Presentation mirrors mn-multi-select: one custom field trigger at every size, an
|
|
5690
|
+
* anchored popover on desktop and the shared {@link MnBottomSheet} on mobile (< 640px) —
|
|
5691
|
+
* the same sheet mn-dropdown itself wraps. Both the popover and the sheet host are
|
|
5692
|
+
* portalled to `document.body` so their `position: fixed` anchors to the viewport rather
|
|
5693
|
+
* than any transformed/filtered ancestor (a table cell, a card) — the same root-cause fix
|
|
5694
|
+
* the multi-select applies.
|
|
5695
|
+
*/
|
|
5602
5696
|
class MnSelect {
|
|
5603
5697
|
ngControl = inject(NgControl, { optional: true, self: true });
|
|
5604
5698
|
props;
|
|
5605
5699
|
/** Currently selected value */
|
|
5606
5700
|
selectedValue = null;
|
|
5701
|
+
isOpen = false;
|
|
5607
5702
|
isDisabled = false;
|
|
5703
|
+
searchTerm = '';
|
|
5608
5704
|
uiConfig = {};
|
|
5609
5705
|
configService = inject(MnConfigService);
|
|
5610
5706
|
sectionPath = inject(MN_SECTION_PATH, { optional: true }) ?? [];
|
|
5611
5707
|
explicitInstanceId = inject(MN_INSTANCE_ID, { optional: true });
|
|
5708
|
+
elRef = inject(ElementRef);
|
|
5612
5709
|
lang = inject(MnLanguageService);
|
|
5613
5710
|
destroyRef = inject(DestroyRef);
|
|
5711
|
+
renderer = inject(Renderer2);
|
|
5712
|
+
cdr = inject(ChangeDetectorRef);
|
|
5713
|
+
/** Lucide data for the trailing check shown on the selected row. */
|
|
5714
|
+
checkIcon = LucideCheck.icon;
|
|
5715
|
+
/** Reference to the trigger element for positioning the dropdown. */
|
|
5716
|
+
triggerRef;
|
|
5717
|
+
/** Layout classes for the anchored popover panel. The mobile sheet is rendered by
|
|
5718
|
+
* mn-bottom-sheet instead, so it no longer needs a branch here. */
|
|
5719
|
+
panelClasses = 'fixed z-9999 bg-base-100 border border-base-300 rounded-md shadow-lg max-h-60 overflow-auto';
|
|
5720
|
+
/** Layout classes for the invisible click shield rendered under the anchored panel.
|
|
5721
|
+
* One step below the panel's z-index so the panel itself stays clickable, and above
|
|
5722
|
+
* any modal/drawer chrome (which tops out well under 9998). */
|
|
5723
|
+
shieldClasses = 'fixed inset-0 z-9998';
|
|
5724
|
+
/** Option count at which the search input auto-enables when `searchable` is unset. */
|
|
5725
|
+
static DEFAULT_SEARCH_THRESHOLD = 8;
|
|
5726
|
+
/** Tailwind's `sm` breakpoint — below this the panel renders as a bottom sheet.
|
|
5727
|
+
* Kept in step with the same constant in mn-bottom-sheet / mn-multi-select. */
|
|
5728
|
+
static SHEET_MAX_WIDTH = 639.98;
|
|
5729
|
+
/** The anchored popover panel currently moved into `document.body`, if any. */
|
|
5730
|
+
movedPanel = null;
|
|
5731
|
+
/** The click shield currently moved into `document.body`, if any. */
|
|
5732
|
+
movedShield = null;
|
|
5733
|
+
/** The bottom-sheet host, for outside-click tests. The sheet owns its own placement. */
|
|
5734
|
+
sheetHost = null;
|
|
5735
|
+
/** Whether the viewport is currently narrow enough for the sheet layout. */
|
|
5736
|
+
isNarrowViewport = false;
|
|
5737
|
+
/** Live breakpoint match, so rotating the device re-evaluates the layout. */
|
|
5738
|
+
sheetMedia = null;
|
|
5739
|
+
/** The listener registered on `sheetMedia`, retained for teardown. */
|
|
5740
|
+
sheetMediaListener = null;
|
|
5741
|
+
/** `document.body`'s inline `overflow` before the sheet locked it, restored on close. */
|
|
5742
|
+
previousBodyOverflow = null;
|
|
5743
|
+
/**
|
|
5744
|
+
* The sheet's height (px) captured the moment it opened, before any search. Re-applied
|
|
5745
|
+
* as a `min-height` floor so filtering the option list shorter cannot shrink the sheet
|
|
5746
|
+
* mid-type. Null while anchored or closed, so the popover and desktop path are untouched.
|
|
5747
|
+
*/
|
|
5748
|
+
sheetFloorPx = null;
|
|
5749
|
+
/**
|
|
5750
|
+
* Watches the trigger while the panel is open. The panel lives in `document.body`, so it
|
|
5751
|
+
* survives its own trigger being hidden by an ancestor — a wizard step or a tab switched
|
|
5752
|
+
* away with `display: none`. When the trigger stops being visible the panel goes with it.
|
|
5753
|
+
*/
|
|
5754
|
+
visibilityObserver = null;
|
|
5755
|
+
/**
|
|
5756
|
+
* Capture-phase scroll listener installed while open. `window:scroll` only fires for the
|
|
5757
|
+
* document scroller, so scrolling an inner container (a modal body, a scrollable card)
|
|
5758
|
+
* would otherwise leave the portalled panel floating at its stale coordinates.
|
|
5759
|
+
*/
|
|
5760
|
+
scrollCapture = null;
|
|
5761
|
+
/** Dropdown position calculated from the trigger's bounding rect. */
|
|
5762
|
+
dropdownStyle = { top: '0px', left: '0px', width: '0px' };
|
|
5763
|
+
onChange = () => {
|
|
5764
|
+
};
|
|
5765
|
+
onTouched = () => {
|
|
5766
|
+
};
|
|
5614
5767
|
builtInErrorMessages = {
|
|
5615
5768
|
required: 'Please select an option',
|
|
5616
5769
|
};
|
|
@@ -5618,12 +5771,49 @@ class MnSelect {
|
|
|
5618
5771
|
if (this.ngControl)
|
|
5619
5772
|
this.ngControl.valueAccessor = this;
|
|
5620
5773
|
}
|
|
5621
|
-
|
|
5622
|
-
|
|
5774
|
+
/**
|
|
5775
|
+
* The dropdown panel element, queried while it is rendered by the `@if` block. The setter
|
|
5776
|
+
* relocates the panel to `document.body` so that its `position: fixed` coordinates resolve
|
|
5777
|
+
* against the viewport rather than any transformed/filtered ancestor (which would otherwise
|
|
5778
|
+
* become the containing block and push the panel to the middle of the screen — also broken
|
|
5779
|
+
* on iOS). Cleanup is handled when the query clears on close/destroy.
|
|
5780
|
+
*/
|
|
5781
|
+
set dropdownRef(ref) {
|
|
5782
|
+
this.movedPanel = this.portal(ref?.nativeElement ?? null, this.movedPanel);
|
|
5783
|
+
}
|
|
5784
|
+
/**
|
|
5785
|
+
* The click shield sitting under the anchored panel, portalled alongside it for the same
|
|
5786
|
+
* reason: `position: fixed` must resolve against the viewport, not a transformed ancestor.
|
|
5787
|
+
*/
|
|
5788
|
+
set shieldRef(ref) {
|
|
5789
|
+
this.movedShield = this.portal(ref?.nativeElement ?? null, this.movedShield);
|
|
5790
|
+
}
|
|
5791
|
+
/**
|
|
5792
|
+
* The bottom-sheet host, kept as a reference for outside-click tests. The sheet relocates
|
|
5793
|
+
* itself to `document.body`, so nothing is moved here. On open its container height is
|
|
5794
|
+
* captured as the sheet's `min-height` floor.
|
|
5795
|
+
*/
|
|
5796
|
+
set sheetRef(ref) {
|
|
5797
|
+
const el = ref?.nativeElement ?? null;
|
|
5798
|
+
this.sheetHost = el;
|
|
5799
|
+
if (el) {
|
|
5800
|
+
this.captureSheetFloor(el);
|
|
5801
|
+
}
|
|
5802
|
+
else {
|
|
5803
|
+
this.sheetFloorPx = null;
|
|
5804
|
+
}
|
|
5623
5805
|
}
|
|
5624
5806
|
get control() {
|
|
5625
5807
|
return this.ngControl?.control ?? null;
|
|
5626
5808
|
}
|
|
5809
|
+
get selectedOption() {
|
|
5810
|
+
return this.props.options.find(o => o.value === this.selectedValue);
|
|
5811
|
+
}
|
|
5812
|
+
/** The label shown in the trigger: the selected option, else the placeholder. */
|
|
5813
|
+
get displayText() {
|
|
5814
|
+
return this.selectedOption?.label
|
|
5815
|
+
?? this.uiConfig.placeholder ?? this.props.placeholder ?? 'Select...';
|
|
5816
|
+
}
|
|
5627
5817
|
get showError() {
|
|
5628
5818
|
const c = this.control;
|
|
5629
5819
|
return !!c && c.invalid && (c.touched || c.dirty);
|
|
@@ -5634,7 +5824,6 @@ class MnSelect {
|
|
|
5634
5824
|
return [];
|
|
5635
5825
|
return Object.keys(errors).map(key => this.resolveErrorMessageForKey(key, errors));
|
|
5636
5826
|
}
|
|
5637
|
-
// ========== ControlValueAccessor Implementation ==========
|
|
5638
5827
|
get errorMessage() {
|
|
5639
5828
|
const errors = this.control?.errors;
|
|
5640
5829
|
if (!errors)
|
|
@@ -5648,7 +5837,7 @@ class MnSelect {
|
|
|
5648
5837
|
get resolvedName() {
|
|
5649
5838
|
return this.props?.name ?? null;
|
|
5650
5839
|
}
|
|
5651
|
-
get
|
|
5840
|
+
get triggerClasses() {
|
|
5652
5841
|
return mnSelectVariants({
|
|
5653
5842
|
size: this.props.size,
|
|
5654
5843
|
borderRadius: this.props.borderRadius,
|
|
@@ -5656,16 +5845,47 @@ class MnSelect {
|
|
|
5656
5845
|
fullWidth: this.props.fullWidth,
|
|
5657
5846
|
});
|
|
5658
5847
|
}
|
|
5659
|
-
|
|
5848
|
+
/** Whether the panel should currently render as a bottom sheet. */
|
|
5849
|
+
get isSheet() {
|
|
5850
|
+
return this.props.mobileSheet !== false && this.isNarrowViewport;
|
|
5851
|
+
}
|
|
5852
|
+
/**
|
|
5853
|
+
* Whether the search input is shown: the explicit `searchable` prop when set, otherwise
|
|
5854
|
+
* auto-enabled once the option count reaches the threshold.
|
|
5855
|
+
*/
|
|
5856
|
+
get isSearchable() {
|
|
5857
|
+
if (this.props.searchable !== undefined)
|
|
5858
|
+
return this.props.searchable;
|
|
5859
|
+
const threshold = this.props.searchThreshold ?? MnSelect.DEFAULT_SEARCH_THRESHOLD;
|
|
5860
|
+
return this.props.options.length >= threshold;
|
|
5861
|
+
}
|
|
5862
|
+
get filteredOptions() {
|
|
5863
|
+
if (!this.searchTerm)
|
|
5864
|
+
return this.props.options;
|
|
5865
|
+
const lower = this.searchTerm.toLowerCase();
|
|
5866
|
+
return this.props.options.filter(o => o.label.toLowerCase().includes(lower));
|
|
5867
|
+
}
|
|
5868
|
+
// ========== Lifecycle ==========
|
|
5660
5869
|
ngOnInit() {
|
|
5661
5870
|
this.resolveConfig();
|
|
5871
|
+
this.startWatchingViewport();
|
|
5662
5872
|
const sub = this.lang.locale$.pipe(skip(1)).subscribe(() => {
|
|
5663
5873
|
this.resolveConfig();
|
|
5664
5874
|
});
|
|
5665
|
-
this.destroyRef.onDestroy(() =>
|
|
5875
|
+
this.destroyRef.onDestroy(() => {
|
|
5876
|
+
sub.unsubscribe();
|
|
5877
|
+
this.stopWatchingTrigger();
|
|
5878
|
+
this.stopWatchingViewport();
|
|
5879
|
+
this.unlockBodyScroll();
|
|
5880
|
+
// Guarantee the portalled elements never outlive the component.
|
|
5881
|
+
this.movedPanel = this.portal(null, this.movedPanel);
|
|
5882
|
+
this.movedShield = this.portal(null, this.movedShield);
|
|
5883
|
+
this.sheetHost = null;
|
|
5884
|
+
});
|
|
5666
5885
|
}
|
|
5886
|
+
// ========== ControlValueAccessor Implementation ==========
|
|
5667
5887
|
writeValue(val) {
|
|
5668
|
-
// Treat empty string as null so the placeholder is shown and the control stays properly invalid
|
|
5888
|
+
// Treat empty string as null so the placeholder is shown and the control stays properly invalid.
|
|
5669
5889
|
this.selectedValue = (val === '' || val == null) ? null : val;
|
|
5670
5890
|
}
|
|
5671
5891
|
registerOnChange(fn) {
|
|
@@ -5677,42 +5897,258 @@ class MnSelect {
|
|
|
5677
5897
|
setDisabledState(isDisabled) {
|
|
5678
5898
|
this.isDisabled = isDisabled;
|
|
5679
5899
|
}
|
|
5680
|
-
// ==========
|
|
5681
|
-
|
|
5682
|
-
|
|
5683
|
-
|
|
5684
|
-
|
|
5685
|
-
|
|
5686
|
-
onSelectChange(event) {
|
|
5687
|
-
const target = event.target;
|
|
5688
|
-
const index = parseInt(target.value, 10);
|
|
5689
|
-
if (isNaN(index) || index < 0 || index >= this.props.options.length) {
|
|
5690
|
-
this.selectedValue = null;
|
|
5691
|
-
this.onChange(null);
|
|
5900
|
+
// ========== Dropdown Logic ==========
|
|
5901
|
+
toggle() {
|
|
5902
|
+
if (this.isDisabled)
|
|
5903
|
+
return;
|
|
5904
|
+
if (this.isOpen) {
|
|
5905
|
+
this.close();
|
|
5692
5906
|
return;
|
|
5693
5907
|
}
|
|
5694
|
-
|
|
5908
|
+
this.isOpen = true;
|
|
5909
|
+
if (this.isSheet) {
|
|
5910
|
+
// A sheet is anchored to the viewport, so it needs no trigger tracking — only a
|
|
5911
|
+
// scroll lock so the page behind it stays put while the list is scrolled.
|
|
5912
|
+
this.lockBodyScroll();
|
|
5913
|
+
return;
|
|
5914
|
+
}
|
|
5915
|
+
this.updateDropdownPosition();
|
|
5916
|
+
this.startWatchingTrigger();
|
|
5917
|
+
}
|
|
5918
|
+
/** Selects an option, notifies the form and closes — a single choice ends the interaction. */
|
|
5919
|
+
selectOption(option) {
|
|
5695
5920
|
if (option.disabled)
|
|
5696
5921
|
return;
|
|
5697
5922
|
this.selectedValue = option.value;
|
|
5698
5923
|
this.onChange(this.selectedValue);
|
|
5924
|
+
this.close();
|
|
5699
5925
|
}
|
|
5700
5926
|
isSelected(option) {
|
|
5701
5927
|
return this.selectedValue === option.value;
|
|
5702
5928
|
}
|
|
5929
|
+
onSearch(term) {
|
|
5930
|
+
this.searchTerm = term ?? '';
|
|
5931
|
+
}
|
|
5932
|
+
/**
|
|
5933
|
+
* The single close path. Every trigger (outside click, Escape, scroll, resize, the trigger
|
|
5934
|
+
* being hidden, a choice) funnels through here so the open-only listeners are always torn
|
|
5935
|
+
* down with the panel and never leak.
|
|
5936
|
+
*/
|
|
5937
|
+
close() {
|
|
5938
|
+
if (!this.isOpen)
|
|
5939
|
+
return;
|
|
5940
|
+
this.isOpen = false;
|
|
5941
|
+
this.searchTerm = '';
|
|
5942
|
+
this.stopWatchingTrigger();
|
|
5943
|
+
this.unlockBodyScroll();
|
|
5944
|
+
}
|
|
5703
5945
|
handleBlur() {
|
|
5704
5946
|
this.onTouched();
|
|
5705
5947
|
}
|
|
5948
|
+
/**
|
|
5949
|
+
* Dismisses the anchored panel from a shield click, and stops the event there.
|
|
5950
|
+
*
|
|
5951
|
+
* Swallowing it is the point: the shield spans the viewport, so the click would otherwise
|
|
5952
|
+
* land on whatever the panel was floating over. Inside a modal that is the modal's own
|
|
5953
|
+
* backdrop, and "close the dropdown" would double as "throw away the modal". A first click
|
|
5954
|
+
* that only dismisses the overlay is also how native selects and menus behave.
|
|
5955
|
+
*/
|
|
5956
|
+
onShieldClick(event) {
|
|
5957
|
+
event.stopPropagation();
|
|
5958
|
+
event.preventDefault();
|
|
5959
|
+
this.close();
|
|
5960
|
+
}
|
|
5961
|
+
onDocumentClick(event) {
|
|
5962
|
+
const target = event.target;
|
|
5963
|
+
// The panel lives at the body root once open, so it is not a descendant of the host
|
|
5964
|
+
// element — treat clicks inside the portalled panel as "inside" too.
|
|
5965
|
+
const insideHost = !!target && this.elRef.nativeElement.contains(target);
|
|
5966
|
+
const insidePanel = !!target && !!this.movedPanel && this.movedPanel.contains(target);
|
|
5967
|
+
// In sheet mode the backdrop tap is handled by mn-bottom-sheet's own (dismiss); the
|
|
5968
|
+
// sheet host counts as "inside" here so this listener never double-fires the close.
|
|
5969
|
+
const insideSheet = !!target && !!this.sheetHost && this.sheetHost.contains(target);
|
|
5970
|
+
if (!insideHost && !insidePanel && !insideSheet) {
|
|
5971
|
+
this.close();
|
|
5972
|
+
}
|
|
5973
|
+
}
|
|
5974
|
+
/** Closes the dropdown on Escape for keyboard accessibility. */
|
|
5975
|
+
onEscape() {
|
|
5976
|
+
this.close();
|
|
5977
|
+
}
|
|
5978
|
+
/**
|
|
5979
|
+
* Closes the dropdown when the page or a scrollable parent is scrolled.
|
|
5980
|
+
*
|
|
5981
|
+
* Skipped for a sheet: it is anchored to the viewport, not to the trigger, so it has no
|
|
5982
|
+
* stale position to escape. Crucially, opening the soft keyboard fires a `resize` on
|
|
5983
|
+
* Android — closing on that would dismiss the sheet the instant search is focused. A
|
|
5984
|
+
* genuine layout switch is handled by the `matchMedia` listener instead.
|
|
5985
|
+
*/
|
|
5986
|
+
onWindowScrollOrResize() {
|
|
5987
|
+
if (this.isSheet)
|
|
5988
|
+
return;
|
|
5989
|
+
this.close();
|
|
5990
|
+
}
|
|
5706
5991
|
isRequired() {
|
|
5707
5992
|
if (!this.control)
|
|
5708
5993
|
return false;
|
|
5709
5994
|
return this.control.hasValidator(Validators.required);
|
|
5710
5995
|
}
|
|
5711
|
-
|
|
5712
|
-
|
|
5713
|
-
|
|
5714
|
-
|
|
5715
|
-
|
|
5996
|
+
// ========== Viewport / breakpoint watching ==========
|
|
5997
|
+
/**
|
|
5998
|
+
* Tracks the sheet breakpoint through `matchMedia` rather than reading `innerWidth` once,
|
|
5999
|
+
* so rotating the device switches layout instead of leaving a panel positioned for the
|
|
6000
|
+
* previous orientation. An open panel is closed on the switch — its anchored coordinates
|
|
6001
|
+
* and its sheet layout are not interchangeable.
|
|
6002
|
+
*/
|
|
6003
|
+
startWatchingViewport() {
|
|
6004
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
|
|
6005
|
+
return;
|
|
6006
|
+
this.sheetMedia = window.matchMedia(`(max-width: ${MnSelect.SHEET_MAX_WIDTH}px)`);
|
|
6007
|
+
this.isNarrowViewport = this.sheetMedia.matches;
|
|
6008
|
+
this.sheetMediaListener = (event) => {
|
|
6009
|
+
this.isNarrowViewport = event.matches;
|
|
6010
|
+
this.close();
|
|
6011
|
+
// The listener fires outside Angular, so a zoneless app needs an explicit nudge.
|
|
6012
|
+
this.cdr.markForCheck();
|
|
6013
|
+
};
|
|
6014
|
+
this.sheetMedia.addEventListener('change', this.sheetMediaListener);
|
|
6015
|
+
}
|
|
6016
|
+
/** Tears down the breakpoint listener. Idempotent. */
|
|
6017
|
+
stopWatchingViewport() {
|
|
6018
|
+
if (this.sheetMedia && this.sheetMediaListener) {
|
|
6019
|
+
this.sheetMedia.removeEventListener('change', this.sheetMediaListener);
|
|
6020
|
+
}
|
|
6021
|
+
this.sheetMedia = null;
|
|
6022
|
+
this.sheetMediaListener = null;
|
|
6023
|
+
}
|
|
6024
|
+
// ========== Body scroll lock (sheet only) ==========
|
|
6025
|
+
/**
|
|
6026
|
+
* Freezes the page behind an open sheet. The previous inline value is captured and restored
|
|
6027
|
+
* verbatim so a surrounding modal that set its own lock is left intact.
|
|
6028
|
+
*/
|
|
6029
|
+
lockBodyScroll() {
|
|
6030
|
+
if (this.previousBodyOverflow !== null)
|
|
6031
|
+
return;
|
|
6032
|
+
this.previousBodyOverflow = document.body.style.overflow;
|
|
6033
|
+
this.renderer.setStyle(document.body, 'overflow', 'hidden');
|
|
6034
|
+
}
|
|
6035
|
+
/** Restores the pre-lock `overflow`. Idempotent. */
|
|
6036
|
+
unlockBodyScroll() {
|
|
6037
|
+
if (this.previousBodyOverflow === null)
|
|
6038
|
+
return;
|
|
6039
|
+
if (this.previousBodyOverflow) {
|
|
6040
|
+
this.renderer.setStyle(document.body, 'overflow', this.previousBodyOverflow);
|
|
6041
|
+
}
|
|
6042
|
+
else {
|
|
6043
|
+
this.renderer.removeStyle(document.body, 'overflow');
|
|
6044
|
+
}
|
|
6045
|
+
this.previousBodyOverflow = null;
|
|
6046
|
+
}
|
|
6047
|
+
// ========== Positioning ==========
|
|
6048
|
+
/** Calculates the fixed position for the dropdown based on the trigger element. */
|
|
6049
|
+
updateDropdownPosition() {
|
|
6050
|
+
if (!this.triggerRef)
|
|
6051
|
+
return;
|
|
6052
|
+
const rect = this.triggerRef.nativeElement.getBoundingClientRect();
|
|
6053
|
+
this.dropdownStyle = {
|
|
6054
|
+
top: `${rect.bottom}px`,
|
|
6055
|
+
left: `${rect.left}px`,
|
|
6056
|
+
width: `${rect.width}px`,
|
|
6057
|
+
};
|
|
6058
|
+
}
|
|
6059
|
+
/**
|
|
6060
|
+
* Starts the open-only watchers: an `IntersectionObserver` on the trigger (closes the panel
|
|
6061
|
+
* as soon as the trigger stops being rendered/visible) and a capture-phase `scroll` listener
|
|
6062
|
+
* (closes it when any ancestor scroller moves under it). Scrolls that originate inside the
|
|
6063
|
+
* panel's own option list are ignored.
|
|
6064
|
+
*/
|
|
6065
|
+
startWatchingTrigger() {
|
|
6066
|
+
this.stopWatchingTrigger();
|
|
6067
|
+
const trigger = this.triggerRef?.nativeElement;
|
|
6068
|
+
if (trigger && typeof IntersectionObserver !== 'undefined') {
|
|
6069
|
+
this.visibilityObserver = new IntersectionObserver(entries => {
|
|
6070
|
+
if (!entries.some(entry => !entry.isIntersecting))
|
|
6071
|
+
return;
|
|
6072
|
+
this.close();
|
|
6073
|
+
// The observer fires outside Angular, so a zoneless app needs an explicit nudge.
|
|
6074
|
+
this.cdr.markForCheck();
|
|
6075
|
+
});
|
|
6076
|
+
this.visibilityObserver.observe(trigger);
|
|
6077
|
+
}
|
|
6078
|
+
this.scrollCapture = (event) => {
|
|
6079
|
+
const target = event.target;
|
|
6080
|
+
if (target && this.movedPanel && (this.movedPanel === target || this.movedPanel.contains(target))) {
|
|
6081
|
+
return;
|
|
6082
|
+
}
|
|
6083
|
+
this.close();
|
|
6084
|
+
this.cdr.markForCheck();
|
|
6085
|
+
};
|
|
6086
|
+
document.addEventListener('scroll', this.scrollCapture, true);
|
|
6087
|
+
}
|
|
6088
|
+
/** Tears down the watchers installed by `startWatchingTrigger`. Idempotent. */
|
|
6089
|
+
stopWatchingTrigger() {
|
|
6090
|
+
this.visibilityObserver?.disconnect();
|
|
6091
|
+
this.visibilityObserver = null;
|
|
6092
|
+
if (this.scrollCapture) {
|
|
6093
|
+
document.removeEventListener('scroll', this.scrollCapture, true);
|
|
6094
|
+
this.scrollCapture = null;
|
|
6095
|
+
}
|
|
6096
|
+
}
|
|
6097
|
+
// ========== Sheet height floor ==========
|
|
6098
|
+
/**
|
|
6099
|
+
* Records the sheet's opened height as its `min-height` floor. Measured on the next frame
|
|
6100
|
+
* so the read reflects the fully-rendered, unfiltered list (the search box is empty on
|
|
6101
|
+
* open) and never forces a reflow mid change-detection. The floor equals the content height
|
|
6102
|
+
* at that instant, so applying it triggers no resize — it only stops a later, shorter
|
|
6103
|
+
* filtered list from pulling the sheet down.
|
|
6104
|
+
*
|
|
6105
|
+
* `hostEl` is the portalled mn-bottom-sheet host (`display: contents`), so the height is
|
|
6106
|
+
* read from its `.mn-sheet-container` child rather than the host itself.
|
|
6107
|
+
*/
|
|
6108
|
+
captureSheetFloor(hostEl) {
|
|
6109
|
+
const measure = () => {
|
|
6110
|
+
const container = hostEl.querySelector('.mn-sheet-container');
|
|
6111
|
+
return container?.offsetHeight ?? hostEl.offsetHeight;
|
|
6112
|
+
};
|
|
6113
|
+
if (typeof requestAnimationFrame !== 'function') {
|
|
6114
|
+
this.sheetFloorPx = measure();
|
|
6115
|
+
return;
|
|
6116
|
+
}
|
|
6117
|
+
requestAnimationFrame(() => {
|
|
6118
|
+
// The sheet may have closed before the frame ran; don't strand a stale floor.
|
|
6119
|
+
if (!this.isOpen || this.sheetHost !== hostEl)
|
|
6120
|
+
return;
|
|
6121
|
+
this.sheetFloorPx = measure();
|
|
6122
|
+
this.cdr.markForCheck();
|
|
6123
|
+
});
|
|
6124
|
+
}
|
|
6125
|
+
// ========== Portal helper (see mn-multi-select for the full rationale) ==========
|
|
6126
|
+
/**
|
|
6127
|
+
* Move an overlay element to `document.body` when it appears, and detach it when the query
|
|
6128
|
+
* clears. Appending to the body root makes the element immune to ancestor
|
|
6129
|
+
* `transform`/`filter`/`will-change`, so `position: fixed` anchors to the viewport — without
|
|
6130
|
+
* this the panel lands mid-screen (and breaks outright on iOS).
|
|
6131
|
+
*
|
|
6132
|
+
* Returns the element now portalled, so the caller can store it. Idempotent and safe to
|
|
6133
|
+
* call with `null`.
|
|
6134
|
+
*/
|
|
6135
|
+
portal(el, current) {
|
|
6136
|
+
if (el) {
|
|
6137
|
+
if (current === el)
|
|
6138
|
+
return current;
|
|
6139
|
+
this.renderer.appendChild(document.body, el);
|
|
6140
|
+
return el;
|
|
6141
|
+
}
|
|
6142
|
+
if (current) {
|
|
6143
|
+
// Angular's view teardown may already have removed it; only detach if still attached.
|
|
6144
|
+
const parent = current.parentNode;
|
|
6145
|
+
if (parent) {
|
|
6146
|
+
this.renderer.removeChild(parent, current);
|
|
6147
|
+
}
|
|
6148
|
+
}
|
|
6149
|
+
return null;
|
|
6150
|
+
}
|
|
6151
|
+
// ========== Config / Error Handling ==========
|
|
5716
6152
|
resolveConfig() {
|
|
5717
6153
|
const instanceId = this.explicitInstanceId || `mn-select-${this.props.id}`;
|
|
5718
6154
|
this.uiConfig = this.configService.resolve('mn-select', this.sectionPath, instanceId);
|
|
@@ -5750,20 +6186,44 @@ class MnSelect {
|
|
|
5750
6186
|
return msgDef;
|
|
5751
6187
|
}
|
|
5752
6188
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnSelect, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
5753
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnSelect, isStandalone: true, selector: "mn-lib-select", inputs: { props: "props" }, host: { properties: { "style.display": "props?.fullWidth ? 'block' : null", "style.width": "props?.fullWidth ? '100%' : null" } }, ngImport: i0, template: "<div [class.is-fullwidth]=\"props.fullWidth\" class=\"flex flex-col h-full\">\n @if (uiConfig.label || props.label) {\n <label [attr.for]=\"resolvedId\" class=\"pl-2 pb-1 flex flex-row gap-x-0.5! text-base!\">\n <p>{{ uiConfig.label || props.label }}</p>\n @if (isRequired()) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n }\n\n <select\n (blur)=\"handleBlur()\"\n (change)=\"onSelectChange($event)\"\n [attr.aria-describedby]=\"showError ? resolvedId + '-error' : null\"\n [attr.aria-invalid]=\"showError || null\"\n [attr.aria-label]=\"uiConfig.ariaLabel || uiConfig.label || props.label || null\"\n [disabled]=\"isDisabled\"\n [id]=\"resolvedId\"\n [name]=\"resolvedName\"\n [ngClass]=\"selectClasses\"\n >\n @if (props.placeholder || uiConfig.placeholder) {\n <option [selected]=\"selectedValue === null\" [value]=\"''\" disabled>\n {{ uiConfig.placeholder || props.placeholder }}\n </option>\n }\n @for (opt of props.options; track opt.value) {\n <option\n [disabled]=\"opt.disabled\"\n [selected]=\"isSelected(opt)\"\n [value]=\"optionIndex(opt)\"\n >{{ opt.label }}\n </option>\n }\n </select>\n\n @if (showError) {\n @if (props.showAllErrors) {\n <div class=\"flex flex-col gap-y-1 mt-1\">\n @for (error of errorMessages; track $index) {\n <mn-error-message [errorMessage]=\"error\" [id]=\"resolvedId + '-' + $index\"></mn-error-message>\n }\n </div>\n } @else {\n @if (errorMessage !== null) {\n <mn-error-message [errorMessage]=\"errorMessage\" [id]=\"resolvedId\"></mn-error-message>\n }\n }\n }\n</div>\n", dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "component", type: MnErrorMessage, selector: "mn-error-message", inputs: ["errorMessage", "id"] }] });
|
|
6189
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnSelect, isStandalone: true, selector: "mn-lib-select", inputs: { props: "props" }, host: { listeners: { "document:click": "onDocumentClick($event)", "document:keydown.escape": "onEscape()", "window:scroll": "onWindowScrollOrResize()", "window:resize": "onWindowScrollOrResize()" }, properties: { "style.display": "props?.fullWidth ? 'block' : null", "style.width": "props?.fullWidth ? '100%' : null" } }, viewQueries: [{ propertyName: "triggerRef", first: true, predicate: ["trigger"], descendants: true }, { propertyName: "dropdownRef", first: true, predicate: ["dropdown"], descendants: true }, { propertyName: "shieldRef", first: true, predicate: ["shield"], descendants: true }, { propertyName: "sheetRef", first: true, predicate: ["sheet"], descendants: true, read: ElementRef }], ngImport: i0, template: "<div [class.is-fullwidth]=\"props.fullWidth\" class=\"flex flex-col h-full\">\n @if (uiConfig.label || props.label) {\n <label [attr.for]=\"resolvedId\" class=\"pl-2 pb-1 flex flex-row gap-x-0.5! text-base!\">\n <p>{{ uiConfig.label || props.label }}</p>\n @if (isRequired()) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n }\n\n <!-- Trigger -->\n <div\n #trigger\n (blur)=\"handleBlur()\"\n (click)=\"toggle()\"\n (keydown.enter)=\"toggle()\"\n (keydown.space)=\"toggle(); $event.preventDefault()\"\n [attr.aria-controls]=\"isOpen ? resolvedId + '-listbox' : null\"\n [attr.aria-describedby]=\"showError ? resolvedId + '-error' : null\"\n [attr.aria-disabled]=\"isDisabled || null\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-invalid]=\"showError || null\"\n [attr.aria-label]=\"uiConfig.ariaLabel || uiConfig.label || props.label || null\"\n [class.cursor-not-allowed]=\"isDisabled\"\n [class.opacity-60]=\"isDisabled\"\n [id]=\"resolvedId\"\n [ngClass]=\"triggerClasses\"\n [tabindex]=\"isDisabled ? -1 : 0\"\n aria-haspopup=\"listbox\"\n class=\"relative\"\n role=\"combobox\"\n >\n <!-- `pr-6` reserves the gutter the caret is absolutely positioned in (right-2 + w-4),\n so the value can never render underneath it. `min-w-0` lets the label shrink below\n its content width, which is what makes truncation possible. -->\n <div class=\"flex flex-row items-center min-h-6 min-w-0 pr-6\">\n <span\n [attr.title]=\"selectedOption?.label\"\n [ngClass]=\"selectedOption ? 'text-base-content' : 'text-base-content/50'\"\n class=\"truncate\"\n >{{ displayText }}</span>\n </div>\n <div class=\"absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none\">\n <svg [size]=\"16\" class=\"text-base-content/50\" lucideChevronDown></svg>\n </div>\n </div>\n\n <!-- Dropdown -->\n @if (isOpen) {\n @if (isSheet) {\n <!-- On mobile the panel is presented as a shared bottom sheet: the sheet chrome\n (backdrop, grabber, swipe/flick-to-dismiss, slide animation) lives in\n mn-bottom-sheet; this component only projects the field's content into it. The\n sheet host is portalled to document.body (see the `sheet` ViewChild) so its\n `position: fixed` anchors to the viewport, not a transformed ancestor. -->\n <mn-bottom-sheet\n #sheet\n (dismiss)=\"close()\"\n [ariaLabel]=\"uiConfig.ariaLabel || uiConfig.label || props.label || uiConfig.placeholder || props.placeholder\"\n [maxHeightVh]=\"80\"\n [minHeightPx]=\"sheetFloorPx\"\n >\n <div\n [id]=\"resolvedId + '-listbox'\"\n class=\"flex flex-col flex-1 min-h-0 overflow-hidden\"\n role=\"listbox\"\n >\n <!-- The sheet covers its own trigger, so it carries a header to name the field; the\n way out is the grabber (swipe-to-dismiss) and a backdrop tap. -->\n <div class=\"px-4 pt-1 pb-2 shrink-0\">\n <p class=\"text-base font-medium text-base-content truncate\">\n {{ uiConfig.label || props.label || uiConfig.placeholder || props.placeholder || '' }}\n </p>\n </div>\n <ng-container [ngTemplateOutlet]=\"panelBody\"></ng-container>\n </div>\n </mn-bottom-sheet>\n } @else {\n <!-- A transparent full-viewport shield behind the panel, so \"click anywhere to dismiss\"\n is literally true. It also *consumes* that click: without it the click reaches\n whatever sits underneath \u2014 inside a modal that is the modal's own backdrop, so\n dismissing the dropdown would tear down the whole modal with it. Portalled to\n document.body for the same reason the panel is. It is aria-hidden and unfocusable:\n the keyboard equivalent of this click is Escape. -->\n <div\n #shield\n (click)=\"onShieldClick($event)\"\n [id]=\"resolvedId + '-shield'\"\n [ngClass]=\"shieldClasses\"\n aria-hidden=\"true\"\n ></div>\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n #dropdown\n (click)=\"$event.stopPropagation()\"\n [id]=\"resolvedId + '-listbox'\"\n [ngClass]=\"panelClasses\"\n [style.left]=\"dropdownStyle.left\"\n [style.top]=\"dropdownStyle.top\"\n [style.width]=\"dropdownStyle.width\"\n role=\"listbox\"\n >\n <ng-container [ngTemplateOutlet]=\"panelBody\"></ng-container>\n </div>\n }\n }\n\n <!-- The search box + option list, shared verbatim by the sheet and the anchored popover.\n `isSheet` only tunes spacing/sizing and which element scrolls: in sheet mode the list\n is the flex scroller; anchored, the popover itself scrolls. -->\n <ng-template #panelBody>\n @if (isSearchable) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n (click)=\"$event.stopPropagation()\"\n [ngClass]=\"isSheet ? 'px-4 py-2' : 'p-2'\"\n class=\"border-b border-base-300 shrink-0\"\n >\n <mn-lib-input-field\n (ngModelChange)=\"onSearch($event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n [ngModel]=\"searchTerm\"\n [props]=\"{\n id: resolvedId + '-search',\n type: 'search',\n placeholder: props.searchPlaceholder || 'Search...',\n ariaLabel: props.searchPlaceholder || 'Search...',\n fullWidth: true,\n size: 'sm',\n autoFocus: !isSheet\n }\"\n ></mn-lib-input-field>\n </div>\n }\n <div [ngClass]=\"isSheet ? 'flex-1 overflow-auto overscroll-contain' : ''\">\n @for (opt of filteredOptions; track opt.value) {\n <div\n (click)=\"selectOption(opt); $event.stopPropagation()\"\n (keyup.enter)=\"selectOption(opt)\"\n (keyup.space)=\"selectOption(opt)\"\n [attr.aria-selected]=\"isSelected(opt)\"\n [class.opacity-50]=\"opt.disabled\"\n [class.pointer-events-none]=\"opt.disabled\"\n [ngClass]=\"[isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm', isSelected(opt) ? 'bg-primary/10 font-medium' : '']\"\n class=\"flex items-center gap-x-2.5 cursor-pointer text-base-content hover:bg-base-200 transition-colors\"\n role=\"option\"\n tabindex=\"0\"\n >\n <span class=\"truncate min-w-0\">{{ opt.label }}</span>\n <!-- The current choice's marker. Decorative: the state is conveyed to assistive\n tech by `aria-selected` on the row. -->\n @if (isSelected(opt)) {\n <svg\n [lucideIcon]=\"checkIcon\"\n [size]=\"isSheet ? 18 : 16\"\n aria-hidden=\"true\"\n class=\"ml-auto shrink-0 text-primary\"\n ></svg>\n }\n </div>\n }\n @if (filteredOptions.length === 0) {\n <div [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\" class=\"text-base-content/50\">\n {{ uiConfig.noOptionsFound || 'No options found' }}\n </div>\n }\n </div>\n </ng-template>\n\n @if (showError) {\n @if (props.showAllErrors) {\n <div class=\"flex flex-col gap-y-1 mt-1\">\n @for (error of errorMessages; track $index) {\n <mn-error-message [errorMessage]=\"error\" [id]=\"resolvedId + '-' + $index\"></mn-error-message>\n }\n </div>\n } @else {\n @if (errorMessage !== null) {\n <mn-error-message [errorMessage]=\"errorMessage\" [id]=\"resolvedId\"></mn-error-message>\n }\n }\n }\n</div>\n", dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: MnErrorMessage, selector: "mn-error-message", inputs: ["errorMessage", "id"] }, { kind: "component", type: MnInputField, selector: "mn-lib-input-field", inputs: ["props"] }, { kind: "component", type: MnBottomSheet, selector: "mn-bottom-sheet", inputs: ["showBackdrop", "showGrabber", "dismissible", "minHeightPx", "maxHeightVh", "containerClass", "ariaLabel", "ariaLabelledby", "growWithKeyboard", "dismissGuard"], outputs: ["dismiss"] }, { kind: "component", type: LucideDynamicIcon, selector: "svg[lucideIcon]", inputs: ["lucideIcon"] }, { kind: "component", type: LucideChevronDown, selector: "svg[lucideChevronDown]" }] });
|
|
5754
6190
|
}
|
|
5755
6191
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnSelect, decorators: [{
|
|
5756
6192
|
type: Component,
|
|
5757
|
-
args: [{ selector: 'mn-lib-select', standalone: true, imports: [NgClass, MnErrorMessage], host: {
|
|
6193
|
+
args: [{ selector: 'mn-lib-select', standalone: true, imports: [NgClass, NgTemplateOutlet, FormsModule, MnErrorMessage, MnInputField, MnBottomSheet, LucideDynamicIcon, LucideChevronDown], host: {
|
|
5758
6194
|
// Without an explicit host width the inline host collapses to its content size, so
|
|
5759
|
-
// the
|
|
6195
|
+
// the trigger's `w-full` (width:100%) resolves against a content-sized box and fails
|
|
5760
6196
|
// to fill the parent. Give the host a real width when fullWidth is requested.
|
|
5761
6197
|
'[style.display]': "props?.fullWidth ? 'block' : null",
|
|
5762
6198
|
'[style.width]': "props?.fullWidth ? '100%' : null",
|
|
5763
|
-
}, template: "<div [class.is-fullwidth]=\"props.fullWidth\" class=\"flex flex-col h-full\">\n @if (uiConfig.label || props.label) {\n <label [attr.for]=\"resolvedId\" class=\"pl-2 pb-1 flex flex-row gap-x-0.5! text-base!\">\n <p>{{ uiConfig.label || props.label }}</p>\n @if (isRequired()) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n }\n\n <
|
|
6199
|
+
}, template: "<div [class.is-fullwidth]=\"props.fullWidth\" class=\"flex flex-col h-full\">\n @if (uiConfig.label || props.label) {\n <label [attr.for]=\"resolvedId\" class=\"pl-2 pb-1 flex flex-row gap-x-0.5! text-base!\">\n <p>{{ uiConfig.label || props.label }}</p>\n @if (isRequired()) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n }\n\n <!-- Trigger -->\n <div\n #trigger\n (blur)=\"handleBlur()\"\n (click)=\"toggle()\"\n (keydown.enter)=\"toggle()\"\n (keydown.space)=\"toggle(); $event.preventDefault()\"\n [attr.aria-controls]=\"isOpen ? resolvedId + '-listbox' : null\"\n [attr.aria-describedby]=\"showError ? resolvedId + '-error' : null\"\n [attr.aria-disabled]=\"isDisabled || null\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-invalid]=\"showError || null\"\n [attr.aria-label]=\"uiConfig.ariaLabel || uiConfig.label || props.label || null\"\n [class.cursor-not-allowed]=\"isDisabled\"\n [class.opacity-60]=\"isDisabled\"\n [id]=\"resolvedId\"\n [ngClass]=\"triggerClasses\"\n [tabindex]=\"isDisabled ? -1 : 0\"\n aria-haspopup=\"listbox\"\n class=\"relative\"\n role=\"combobox\"\n >\n <!-- `pr-6` reserves the gutter the caret is absolutely positioned in (right-2 + w-4),\n so the value can never render underneath it. `min-w-0` lets the label shrink below\n its content width, which is what makes truncation possible. -->\n <div class=\"flex flex-row items-center min-h-6 min-w-0 pr-6\">\n <span\n [attr.title]=\"selectedOption?.label\"\n [ngClass]=\"selectedOption ? 'text-base-content' : 'text-base-content/50'\"\n class=\"truncate\"\n >{{ displayText }}</span>\n </div>\n <div class=\"absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none\">\n <svg [size]=\"16\" class=\"text-base-content/50\" lucideChevronDown></svg>\n </div>\n </div>\n\n <!-- Dropdown -->\n @if (isOpen) {\n @if (isSheet) {\n <!-- On mobile the panel is presented as a shared bottom sheet: the sheet chrome\n (backdrop, grabber, swipe/flick-to-dismiss, slide animation) lives in\n mn-bottom-sheet; this component only projects the field's content into it. The\n sheet host is portalled to document.body (see the `sheet` ViewChild) so its\n `position: fixed` anchors to the viewport, not a transformed ancestor. -->\n <mn-bottom-sheet\n #sheet\n (dismiss)=\"close()\"\n [ariaLabel]=\"uiConfig.ariaLabel || uiConfig.label || props.label || uiConfig.placeholder || props.placeholder\"\n [maxHeightVh]=\"80\"\n [minHeightPx]=\"sheetFloorPx\"\n >\n <div\n [id]=\"resolvedId + '-listbox'\"\n class=\"flex flex-col flex-1 min-h-0 overflow-hidden\"\n role=\"listbox\"\n >\n <!-- The sheet covers its own trigger, so it carries a header to name the field; the\n way out is the grabber (swipe-to-dismiss) and a backdrop tap. -->\n <div class=\"px-4 pt-1 pb-2 shrink-0\">\n <p class=\"text-base font-medium text-base-content truncate\">\n {{ uiConfig.label || props.label || uiConfig.placeholder || props.placeholder || '' }}\n </p>\n </div>\n <ng-container [ngTemplateOutlet]=\"panelBody\"></ng-container>\n </div>\n </mn-bottom-sheet>\n } @else {\n <!-- A transparent full-viewport shield behind the panel, so \"click anywhere to dismiss\"\n is literally true. It also *consumes* that click: without it the click reaches\n whatever sits underneath \u2014 inside a modal that is the modal's own backdrop, so\n dismissing the dropdown would tear down the whole modal with it. Portalled to\n document.body for the same reason the panel is. It is aria-hidden and unfocusable:\n the keyboard equivalent of this click is Escape. -->\n <div\n #shield\n (click)=\"onShieldClick($event)\"\n [id]=\"resolvedId + '-shield'\"\n [ngClass]=\"shieldClasses\"\n aria-hidden=\"true\"\n ></div>\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n #dropdown\n (click)=\"$event.stopPropagation()\"\n [id]=\"resolvedId + '-listbox'\"\n [ngClass]=\"panelClasses\"\n [style.left]=\"dropdownStyle.left\"\n [style.top]=\"dropdownStyle.top\"\n [style.width]=\"dropdownStyle.width\"\n role=\"listbox\"\n >\n <ng-container [ngTemplateOutlet]=\"panelBody\"></ng-container>\n </div>\n }\n }\n\n <!-- The search box + option list, shared verbatim by the sheet and the anchored popover.\n `isSheet` only tunes spacing/sizing and which element scrolls: in sheet mode the list\n is the flex scroller; anchored, the popover itself scrolls. -->\n <ng-template #panelBody>\n @if (isSearchable) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n (click)=\"$event.stopPropagation()\"\n [ngClass]=\"isSheet ? 'px-4 py-2' : 'p-2'\"\n class=\"border-b border-base-300 shrink-0\"\n >\n <mn-lib-input-field\n (ngModelChange)=\"onSearch($event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n [ngModel]=\"searchTerm\"\n [props]=\"{\n id: resolvedId + '-search',\n type: 'search',\n placeholder: props.searchPlaceholder || 'Search...',\n ariaLabel: props.searchPlaceholder || 'Search...',\n fullWidth: true,\n size: 'sm',\n autoFocus: !isSheet\n }\"\n ></mn-lib-input-field>\n </div>\n }\n <div [ngClass]=\"isSheet ? 'flex-1 overflow-auto overscroll-contain' : ''\">\n @for (opt of filteredOptions; track opt.value) {\n <div\n (click)=\"selectOption(opt); $event.stopPropagation()\"\n (keyup.enter)=\"selectOption(opt)\"\n (keyup.space)=\"selectOption(opt)\"\n [attr.aria-selected]=\"isSelected(opt)\"\n [class.opacity-50]=\"opt.disabled\"\n [class.pointer-events-none]=\"opt.disabled\"\n [ngClass]=\"[isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm', isSelected(opt) ? 'bg-primary/10 font-medium' : '']\"\n class=\"flex items-center gap-x-2.5 cursor-pointer text-base-content hover:bg-base-200 transition-colors\"\n role=\"option\"\n tabindex=\"0\"\n >\n <span class=\"truncate min-w-0\">{{ opt.label }}</span>\n <!-- The current choice's marker. Decorative: the state is conveyed to assistive\n tech by `aria-selected` on the row. -->\n @if (isSelected(opt)) {\n <svg\n [lucideIcon]=\"checkIcon\"\n [size]=\"isSheet ? 18 : 16\"\n aria-hidden=\"true\"\n class=\"ml-auto shrink-0 text-primary\"\n ></svg>\n }\n </div>\n }\n @if (filteredOptions.length === 0) {\n <div [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\" class=\"text-base-content/50\">\n {{ uiConfig.noOptionsFound || 'No options found' }}\n </div>\n }\n </div>\n </ng-template>\n\n @if (showError) {\n @if (props.showAllErrors) {\n <div class=\"flex flex-col gap-y-1 mt-1\">\n @for (error of errorMessages; track $index) {\n <mn-error-message [errorMessage]=\"error\" [id]=\"resolvedId + '-' + $index\"></mn-error-message>\n }\n </div>\n } @else {\n @if (errorMessage !== null) {\n <mn-error-message [errorMessage]=\"errorMessage\" [id]=\"resolvedId\"></mn-error-message>\n }\n }\n }\n</div>\n" }]
|
|
5764
6200
|
}], ctorParameters: () => [], propDecorators: { props: [{
|
|
5765
6201
|
type: Input,
|
|
5766
6202
|
args: [{ required: true }]
|
|
6203
|
+
}], triggerRef: [{
|
|
6204
|
+
type: ViewChild,
|
|
6205
|
+
args: ['trigger', { static: false }]
|
|
6206
|
+
}], dropdownRef: [{
|
|
6207
|
+
type: ViewChild,
|
|
6208
|
+
args: ['dropdown', { static: false }]
|
|
6209
|
+
}], shieldRef: [{
|
|
6210
|
+
type: ViewChild,
|
|
6211
|
+
args: ['shield', { static: false }]
|
|
6212
|
+
}], sheetRef: [{
|
|
6213
|
+
type: ViewChild,
|
|
6214
|
+
args: ['sheet', { static: false, read: ElementRef }]
|
|
6215
|
+
}], onDocumentClick: [{
|
|
6216
|
+
type: HostListener,
|
|
6217
|
+
args: ['document:click', ['$event']]
|
|
6218
|
+
}], onEscape: [{
|
|
6219
|
+
type: HostListener,
|
|
6220
|
+
args: ['document:keydown.escape']
|
|
6221
|
+
}], onWindowScrollOrResize: [{
|
|
6222
|
+
type: HostListener,
|
|
6223
|
+
args: ['window:scroll', []]
|
|
6224
|
+
}, {
|
|
6225
|
+
type: HostListener,
|
|
6226
|
+
args: ['window:resize', []]
|
|
5767
6227
|
}] } });
|
|
5768
6228
|
|
|
5769
6229
|
// =========================
|
|
@@ -7943,8 +8403,10 @@ class MnTable extends MnSelectableCollectionBase {
|
|
|
7943
8403
|
* button and a stacked filter panel.
|
|
7944
8404
|
*/
|
|
7945
8405
|
filtersCollapsed = false;
|
|
7946
|
-
/** Whether the small-screen filter
|
|
8406
|
+
/** Whether the small-screen filter bottom sheet is currently open. */
|
|
7947
8407
|
filtersPanelOpen = false;
|
|
8408
|
+
/** Small-screen filter sheet, held so the close button can play its exit. */
|
|
8409
|
+
filtersSheet;
|
|
7948
8410
|
componentName = 'MnTable';
|
|
7949
8411
|
get trackedToolbarTemplate() {
|
|
7950
8412
|
return this.dataSource?.toolbarLeftTemplate;
|
|
@@ -8068,6 +8530,10 @@ class MnTable extends MnSelectableCollectionBase {
|
|
|
8068
8530
|
get clearFiltersButtonLabel() {
|
|
8069
8531
|
return this.resolveLabel(this.dataSource.clearFiltersLabelKey, 'mnCollection.clearAll', this.dataSource.clearFiltersLabel ?? 'Clear all');
|
|
8070
8532
|
}
|
|
8533
|
+
/** Accessible label for the filter sheet's close button. */
|
|
8534
|
+
get filtersCloseLabel() {
|
|
8535
|
+
return this.resolveLabel(undefined, 'mnCollection.close', 'Close');
|
|
8536
|
+
}
|
|
8071
8537
|
/** Heading for the selection summary, with the count filled in. */
|
|
8072
8538
|
get selectionSummaryTitle() {
|
|
8073
8539
|
const labels = this.dataSource.selectionSummaryLabels;
|
|
@@ -8079,9 +8545,15 @@ class MnTable extends MnSelectableCollectionBase {
|
|
|
8079
8545
|
const labels = this.dataSource.selectionSummaryLabels;
|
|
8080
8546
|
return this.resolveLabel(labels?.clearAllKey, 'mnCollection.clearAll', labels?.clearAll ?? 'Clear all');
|
|
8081
8547
|
}
|
|
8082
|
-
/** Opens
|
|
8083
|
-
|
|
8084
|
-
this.filtersPanelOpen =
|
|
8548
|
+
/** Opens the small-screen filter bottom sheet. */
|
|
8549
|
+
openFiltersPanel() {
|
|
8550
|
+
this.filtersPanelOpen = true;
|
|
8551
|
+
}
|
|
8552
|
+
/** Plays the sheet's slide-down exit, then unmounts it. */
|
|
8553
|
+
async closeFiltersPanel() {
|
|
8554
|
+
await this.filtersSheet?.startClosing();
|
|
8555
|
+
this.filtersPanelOpen = false;
|
|
8556
|
+
this.cdr.markForCheck();
|
|
8085
8557
|
}
|
|
8086
8558
|
baseTableClasses = 'w-full border-collapse overflow-y-hidden';
|
|
8087
8559
|
/**
|
|
@@ -8693,15 +9165,18 @@ class MnTable extends MnSelectableCollectionBase {
|
|
|
8693
9165
|
return template.replace('{{label}}', this.selectionLabelFor(row));
|
|
8694
9166
|
}
|
|
8695
9167
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnTable, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
8696
|
-
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 });
|
|
9168
|
+
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: "filtersSheet", first: true, predicate: ["filtersSheet"], descendants: true }, { 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)=\"openFiltersPanel()\"\n >\n <svg lucideFunnel [size]=\"15\"></svg>\n <span>{{ filtersButtonLabel }}</span>\n </button>\n }\n </div>\n</div>\n}\n\n<!-- Small-screen filters: full-width fields decoupled from column widths, presented\n as a bottom sheet so they overlay rather than push the table down. -->\n@if (hasColumnFilters && filtersCollapsed && filtersPanelOpen) {\n <mn-bottom-sheet\n #filtersSheet\n (dismiss)=\"filtersPanelOpen = false\"\n [ariaLabel]=\"filtersButtonLabel\"\n [growWithKeyboard]=\"true\"\n [maxHeightVh]=\"80\"\n >\n <div id=\"mn-table-filters-panel\" class=\"flex flex-col gap-3 px-4 pb-4\">\n <span class=\"text-base font-semibold text-base-content\">{{ filtersButtonLabel }}</span>\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-panel-' + 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 class=\"mt-1 flex min-[400px]:justify-end\">\n <button\n type=\"button\"\n mnButton\n [data]=\"{ variant: 'fill', color: 'primary', size: 'md', borderRadius: 'md', hover: true }\"\n class=\"w-full min-[400px]:w-auto\"\n (click)=\"closeFiltersPanel()\"\n >\n <span>{{ filtersCloseLabel }}</span>\n </button>\n </div>\n </div>\n </mn-bottom-sheet>\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: MnBottomSheet, selector: "mn-bottom-sheet", inputs: ["showBackdrop", "showGrabber", "dismissible", "minHeightPx", "maxHeightVh", "containerClass", "ariaLabel", "ariaLabelledby", "growWithKeyboard", "dismissGuard"], outputs: ["dismiss"] }, { 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 });
|
|
8697
9169
|
}
|
|
8698
9170
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnTable, decorators: [{
|
|
8699
9171
|
type: Component,
|
|
8700
|
-
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" }]
|
|
9172
|
+
args: [{ selector: 'mn-table', standalone: true, imports: [NgClass, NgTemplateOutlet, MnCheckbox, MnHiddenBelowDirective, MnShowAboveDirective, MnShowBelowDirective, MnInputField, MnSelect, MnMultiSelect, MnDropdown, MnSkeleton, FormsModule, MnCollectionPagination, MnButton, MnBottomSheet, 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)=\"openFiltersPanel()\"\n >\n <svg lucideFunnel [size]=\"15\"></svg>\n <span>{{ filtersButtonLabel }}</span>\n </button>\n }\n </div>\n</div>\n}\n\n<!-- Small-screen filters: full-width fields decoupled from column widths, presented\n as a bottom sheet so they overlay rather than push the table down. -->\n@if (hasColumnFilters && filtersCollapsed && filtersPanelOpen) {\n <mn-bottom-sheet\n #filtersSheet\n (dismiss)=\"filtersPanelOpen = false\"\n [ariaLabel]=\"filtersButtonLabel\"\n [growWithKeyboard]=\"true\"\n [maxHeightVh]=\"80\"\n >\n <div id=\"mn-table-filters-panel\" class=\"flex flex-col gap-3 px-4 pb-4\">\n <span class=\"text-base font-semibold text-base-content\">{{ filtersButtonLabel }}</span>\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-panel-' + 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 class=\"mt-1 flex min-[400px]:justify-end\">\n <button\n type=\"button\"\n mnButton\n [data]=\"{ variant: 'fill', color: 'primary', size: 'md', borderRadius: 'md', hover: true }\"\n class=\"w-full min-[400px]:w-auto\"\n (click)=\"closeFiltersPanel()\"\n >\n <span>{{ filtersCloseLabel }}</span>\n </button>\n </div>\n </div>\n </mn-bottom-sheet>\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" }]
|
|
8701
9173
|
}], ctorParameters: () => [], propDecorators: { sortChange: [{
|
|
8702
9174
|
type: Output
|
|
8703
9175
|
}], rowClick: [{
|
|
8704
9176
|
type: Output
|
|
9177
|
+
}], filtersSheet: [{
|
|
9178
|
+
type: ViewChild,
|
|
9179
|
+
args: ['filtersSheet']
|
|
8705
9180
|
}], collectionBody: [{
|
|
8706
9181
|
type: ViewChild,
|
|
8707
9182
|
args: ['collectionBody']
|