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.
@@ -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
- if (map[key] !== undefined)
592
- return map[key];
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
- get selectedOption() {
5622
- return this.props.options.find(o => o.value === this.selectedValue);
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 selectClasses() {
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
- // ========== Select Logic ==========
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(() => sub.unsubscribe());
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
- // ========== Error Handling ==========
5681
- /** Returns the index of an option, used as the <option> value attribute */
5682
- optionIndex(option) {
5683
- return String(this.props.options.indexOf(option));
5684
- }
5685
- /** Handles native select change event */
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
- const option = this.props.options[index];
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
- onChange = () => {
5712
- };
5713
- onTouched = () => {
5714
- };
5715
- // ========== Resolved Properties ==========
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 control's `w-full` (width:100%) resolves against a content-sized box and fails
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 <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" }]
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 panel is currently expanded. */
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/closes the stacked filter panel shown on small screens. */
8083
- toggleFiltersPanel() {
8084
- this.filtersPanelOpen = !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']