mn-angular-lib 1.0.161 → 1.0.163

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) {
@@ -4671,9 +4753,47 @@ class MnMultiSelect {
4671
4753
  */
4672
4754
  get collapseSummaryText() {
4673
4755
  const allSelectedTemplate = this.allSelected ? this.props.allSelectedPlaceholder : undefined;
4674
- const template = allSelectedTemplate ?? this.props.collapsePlaceholder ?? '{count} selected';
4756
+ const template = allSelectedTemplate ??
4757
+ this.props.collapsePlaceholder ??
4758
+ this.resolveLabel(undefined, 'mnMultiSelect.selectedCount', '{count} selected');
4675
4759
  return template.replace(/\{count}/g, String(this.selectedOptions.length));
4676
4760
  }
4761
+ /** Trigger text shown while nothing is selected. */
4762
+ get placeholderLabel() {
4763
+ return this.resolveLabel(this.props.placeholder, 'mnMultiSelect.placeholder', 'Select...', this.uiConfig.placeholder);
4764
+ }
4765
+ /**
4766
+ * Placeholder and accessible name of the dropdown's search input.
4767
+ *
4768
+ * Search auto-enables at `searchThreshold` options, so this box appears without any
4769
+ * call site opting in — which is exactly why it must be translatable without one.
4770
+ */
4771
+ get searchPlaceholderLabel() {
4772
+ return this.resolveLabel(this.props.searchPlaceholder, 'mnMultiSelect.search', 'Search...', this.uiConfig.searchPlaceholder);
4773
+ }
4774
+ /** Empty text shown when the search filters every option away. */
4775
+ get noOptionsLabel() {
4776
+ return this.resolveLabel(undefined, 'mnMultiSelect.noOptions', 'No options found', this.uiConfig.noOptionsFound);
4777
+ }
4778
+ /**
4779
+ * Resolves one of the component's own labels, preferring what the caller gave it
4780
+ * and falling back through the config layer, a conventional translation key and
4781
+ * finally a readable English default.
4782
+ *
4783
+ * Mirrors `MnCollectionBase.resolveLabel`. Every string this component puts on
4784
+ * screen that is not caller data goes through here: without the key step a
4785
+ * consumer could only translate these by repeating the same literal at every call
4786
+ * site, which is how "Search..." ends up in English on an otherwise Dutch page.
4787
+ *
4788
+ * @param explicit The label the caller passed through `props`, if any.
4789
+ * @param key The conventional translation key to try next.
4790
+ * @param fallback The English text used when neither resolves.
4791
+ * @param configured The value the config layer resolved, if any.
4792
+ * @returns The resolved label.
4793
+ */
4794
+ resolveLabel(explicit, key, fallback, configured) {
4795
+ return explicit ?? configured ?? this.lang.translateIfPresent(key) ?? fallback;
4796
+ }
4677
4797
  handleBlur() {
4678
4798
  this.onTouched();
4679
4799
  }
@@ -4746,11 +4866,11 @@ class MnMultiSelect {
4746
4866
  });
4747
4867
  }
4748
4868
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnMultiSelect, deps: [], target: i0.ɵɵFactoryTarget.Component });
4749
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnMultiSelect, isStandalone: true, selector: "mn-lib-multi-select", inputs: { props: "props" }, host: { listeners: { "document:click": "onDocumentClick($event)", "document:keydown.escape": "onEscape()", "window:scroll": "onWindowScrollOrResize()", "window:resize": "onWindowScrollOrResize()" } }, viewQueries: [{ propertyName: "triggerRef", first: true, predicate: ["trigger"], descendants: true }, { propertyName: "dropdownRef", first: true, predicate: ["dropdown"], descendants: true }, { propertyName: "shieldRef", first: true, predicate: ["shield"], descendants: true }, { propertyName: "sheetRef", first: true, predicate: ["sheet"], descendants: true, read: ElementRef }], ngImport: i0, template: "<div class=\"flex flex-col h-full\" [class.is-fullwidth]=\"props.fullWidth\">\n @if (uiConfig.label || props.label) {\n <label class=\"pl-2 pb-1 flex flex-row gap-x-0.5! text-base!\" [attr.for]=\"resolvedId\">\n <p>{{ uiConfig.label || props.label }}</p>\n @if (isRequired()) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n }\n\n <!-- Trigger -->\n <div\n #trigger\n [id]=\"resolvedId\"\n [ngClass]=\"triggerClasses\"\n class=\"relative\"\n [attr.aria-label]=\"uiConfig.ariaLabel || uiConfig.label || props.label || null\"\n [attr.aria-invalid]=\"showError || null\"\n [attr.aria-describedby]=\"showError ? resolvedId + '-error' : null\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-controls]=\"isOpen ? resolvedId + '-listbox' : null\"\n role=\"combobox\"\n tabindex=\"0\"\n (click)=\"toggle()\"\n (keydown.enter)=\"toggle()\"\n (keydown.space)=\"toggle(); $event.preventDefault()\"\n (blur)=\"handleBlur()\"\n >\n <!-- `pr-6` reserves the gutter the caret is absolutely positioned in (right-2 +\n w-4), so a value can never render underneath it. `min-w-0` lets the chips\n shrink below their content width, which is what makes truncation possible. -->\n <div class=\"flex flex-row items-center gap-x-2 flex-wrap min-h-6 min-w-0 pr-6\">\n @if (selectedOptions.length === 0) {\n <span class=\"text-base-content/50\">{{ uiConfig.placeholder || props.placeholder || 'Select...' }}</span>\n } @else if (isCollapsed) {\n <span\n class=\"inline-flex items-center max-w-full truncate bg-base-200 border border-accent text-base-content text-xs px-2 py-0.5 rounded-md\">\n {{ collapseSummaryText }}\n </span>\n } @else {\n @for (opt of selectedOptions; track opt.value) {\n <!-- Only the \u00D7 removes. The chip body deliberately carries no handler, so a click\n anywhere on it bubbles to the trigger and just opens/closes the panel \u2014 clicking\n the trigger to dismiss the dropdown must never silently delete a selection. -->\n <span\n class=\"inline-flex items-center gap-x-1 max-w-full min-w-0 bg-base-200 border border-accent text-base-content text-xs pl-2 py-0.5 rounded-md\">\n <span [attr.title]=\"opt.label\" class=\"truncate\">{{ opt.label }}</span>\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'secondary', hover: false }\"\n type=\"button\"\n class=\"text-base-content/50 cursor-pointer shrink-0\"\n (click)=\"removeOption(opt, $event)\"\n [attr.aria-label]=\"'Remove ' + opt.label\"\n ><svg lucideX [size]=\"18\"></svg></button>\n </span>\n }\n }\n </div>\n <div class=\"absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none\">\n <svg [size]=\"16\" class=\"text-base-content/50\" lucideChevronDown></svg>\n </div>\n </div>\n\n <!-- Dropdown -->\n @if (isOpen) {\n @if (isSheet) {\n <!-- On mobile the panel is presented as a shared bottom sheet: the sheet chrome\n (backdrop, grabber, swipe/flick-to-dismiss, slide animation) lives in\n mn-bottom-sheet; this component only projects the field's content into it.\n The sheet host is portalled to document.body (see the `sheet` ViewChild) so\n its `position: fixed` anchors to the viewport, not a transformed ancestor. -->\n <mn-bottom-sheet\n #sheet\n (dismiss)=\"close()\"\n [ariaLabel]=\"uiConfig.ariaLabel || uiConfig.label || props.label || uiConfig.placeholder || props.placeholder\"\n [maxHeightVh]=\"80\"\n [minHeightPx]=\"sheetFloorPx\"\n >\n <div\n [id]=\"resolvedId + '-listbox'\"\n aria-multiselectable=\"true\"\n class=\"flex flex-col flex-1 min-h-0 overflow-hidden\"\n role=\"listbox\"\n >\n <!-- The sheet covers its own trigger, so it 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\n dismiss\" is literally true. It also *consumes* that click: without it the click\n reaches whatever sits underneath \u2014 inside a modal that is the modal's own\n backdrop, so dismissing the dropdown would tear down the whole modal with it.\n Portalled to document.body for the same reason the panel is. It is aria-hidden\n and unfocusable: 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 aria-multiselectable=\"true\"\n role=\"listbox\"\n >\n <ng-container [ngTemplateOutlet]=\"panelBody\"></ng-container>\n </div>\n }\n }\n\n <!-- The search box + option list, shared verbatim by the sheet and the anchored\n popover. `isSheet` only tunes spacing/sizing and which element scrolls: in sheet\n mode the list is the flex scroller; anchored, the popover itself scrolls. -->\n <ng-template #panelBody>\n @if (isSearchable) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n (click)=\"$event.stopPropagation()\"\n [ngClass]=\"isSheet ? 'px-4 py-2' : 'p-2'\"\n class=\"border-b border-base-300 shrink-0\"\n >\n <mn-lib-input-field\n (ngModelChange)=\"onSearch($event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n [ngModel]=\"searchTerm\"\n [props]=\"{\n id: resolvedId + '-search',\n type: 'search',\n placeholder: props.searchPlaceholder || 'Search...',\n ariaLabel: props.searchPlaceholder || 'Search...',\n fullWidth: true,\n size: 'sm'\n }\"\n ></mn-lib-input-field>\n </div>\n }\n <div [ngClass]=\"isSheet ? 'flex-1 overflow-auto overscroll-contain' : ''\">\n @for (opt of filteredOptions; track opt.value) {\n <div\n (click)=\"toggleOption(opt); $event.stopPropagation()\"\n (keyup.enter)=\"toggleOption(opt)\"\n (keyup.space)=\"toggleOption(opt)\"\n [attr.aria-selected]=\"isSelected(opt)\"\n [class.opacity-50]=\"opt.disabled || isMaxReached(opt)\"\n [class.pointer-events-none]=\"opt.disabled || isMaxReached(opt)\"\n [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\"\n class=\"flex items-center gap-x-2 cursor-pointer text-base-content hover:bg-base-200\"\n role=\"option\"\n tabindex=\"0\"\n >\n <input\n [checked]=\"isSelected(opt)\"\n [disabled]=\"opt.disabled || isMaxReached(opt)\"\n class=\"w-4 h-4 accent-primary pointer-events-none shrink-0\"\n tabindex=\"-1\"\n type=\"checkbox\"\n />\n <span>{{ opt.label }}</span>\n </div>\n }\n @if (filteredOptions.length === 0) {\n <div [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\" class=\"text-base-content/50\">\n {{ uiConfig.noOptionsFound || 'No options found' }}\n </div>\n }\n </div>\n </ng-template>\n\n @if (showError) {\n @if (props.showAllErrors) {\n <div class=\"flex flex-col gap-y-1 mt-1\">\n @for (error of errorMessages; track $index) {\n <mn-error-message [errorMessage]=\"error\" [id]=\"resolvedId + '-' + $index\"></mn-error-message>\n }\n </div>\n } @else {\n @if (errorMessage !== null) {\n <mn-error-message [errorMessage]=\"errorMessage\" [id]=\"resolvedId\"></mn-error-message>\n }\n }\n }\n</div>\n", styles: [""], dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: MnErrorMessage, selector: "mn-error-message", inputs: ["errorMessage", "id"] }, { kind: "component", type: MnButton, selector: "button[mnButton], a[mnButton]", inputs: ["data"] }, { kind: "component", type: MnInputField, selector: "mn-lib-input-field", inputs: ["props"] }, { kind: "component", type: MnBottomSheet, selector: "mn-bottom-sheet", inputs: ["showBackdrop", "showGrabber", "dismissible", "minHeightPx", "maxHeightVh", "containerClass", "ariaLabel", "ariaLabelledby", "growWithKeyboard", "dismissGuard"], outputs: ["dismiss"] }, { kind: "component", type: LucideX, selector: "svg[lucideX]" }, { kind: "component", type: LucideChevronDown, selector: "svg[lucideChevronDown]" }] });
4869
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnMultiSelect, isStandalone: true, selector: "mn-lib-multi-select", inputs: { props: "props" }, host: { listeners: { "document:click": "onDocumentClick($event)", "document:keydown.escape": "onEscape()", "window:scroll": "onWindowScrollOrResize()", "window:resize": "onWindowScrollOrResize()" } }, viewQueries: [{ propertyName: "triggerRef", first: true, predicate: ["trigger"], descendants: true }, { propertyName: "dropdownRef", first: true, predicate: ["dropdown"], descendants: true }, { propertyName: "shieldRef", first: true, predicate: ["shield"], descendants: true }, { propertyName: "sheetRef", first: true, predicate: ["sheet"], descendants: true, read: ElementRef }], ngImport: i0, template: "<div class=\"flex flex-col h-full\" [class.is-fullwidth]=\"props.fullWidth\">\n @if (uiConfig.label || props.label) {\n <label class=\"pl-2 pb-1 flex flex-row gap-x-0.5! text-base!\" [attr.for]=\"resolvedId\">\n <p>{{ uiConfig.label || props.label }}</p>\n @if (isRequired()) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n }\n\n <!-- Trigger -->\n <div\n #trigger\n [id]=\"resolvedId\"\n [ngClass]=\"triggerClasses\"\n class=\"relative\"\n [attr.aria-label]=\"uiConfig.ariaLabel || uiConfig.label || props.label || null\"\n [attr.aria-invalid]=\"showError || null\"\n [attr.aria-describedby]=\"showError ? resolvedId + '-error' : null\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-controls]=\"isOpen ? resolvedId + '-listbox' : null\"\n role=\"combobox\"\n tabindex=\"0\"\n (click)=\"toggle()\"\n (keydown.enter)=\"toggle()\"\n (keydown.space)=\"toggle(); $event.preventDefault()\"\n (blur)=\"handleBlur()\"\n >\n <!-- `pr-6` reserves the gutter the caret is absolutely positioned in (right-2 +\n w-4), so a value can never render underneath it. `min-w-0` lets the chips\n shrink below their content width, which is what makes truncation possible. -->\n <div class=\"flex flex-row items-center gap-x-2 flex-wrap min-h-6 min-w-0 pr-6\">\n @if (selectedOptions.length === 0) {\n <span class=\"text-base-content/50\">{{ placeholderLabel }}</span>\n } @else if (isCollapsed) {\n <span\n class=\"inline-flex items-center max-w-full truncate bg-base-200 border border-accent text-base-content text-xs px-2 py-0.5 rounded-md\">\n {{ collapseSummaryText }}\n </span>\n } @else {\n @for (opt of selectedOptions; track opt.value) {\n <!-- Only the \u00D7 removes. The chip body deliberately carries no handler, so a click\n anywhere on it bubbles to the trigger and just opens/closes the panel \u2014 clicking\n the trigger to dismiss the dropdown must never silently delete a selection. -->\n <span\n class=\"inline-flex items-center gap-x-1 max-w-full min-w-0 bg-base-200 border border-accent text-base-content text-xs pl-2 py-0.5 rounded-md\">\n <span [attr.title]=\"opt.label\" class=\"truncate\">{{ opt.label }}</span>\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'secondary', hover: false }\"\n type=\"button\"\n class=\"text-base-content/50 cursor-pointer shrink-0\"\n (click)=\"removeOption(opt, $event)\"\n [attr.aria-label]=\"'Remove ' + opt.label\"\n ><svg lucideX [size]=\"18\"></svg></button>\n </span>\n }\n }\n </div>\n <div class=\"absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none\">\n <svg [size]=\"16\" class=\"text-base-content/50\" lucideChevronDown></svg>\n </div>\n </div>\n\n <!-- Dropdown -->\n @if (isOpen) {\n @if (isSheet) {\n <!-- On mobile the panel is presented as a shared bottom sheet: the sheet chrome\n (backdrop, grabber, swipe/flick-to-dismiss, slide animation) lives in\n mn-bottom-sheet; this component only projects the field's content into it.\n The sheet host is portalled to document.body (see the `sheet` ViewChild) so\n its `position: fixed` anchors to the viewport, not a transformed ancestor. -->\n <mn-bottom-sheet\n #sheet\n (dismiss)=\"close()\"\n [ariaLabel]=\"uiConfig.ariaLabel || uiConfig.label || props.label || uiConfig.placeholder || props.placeholder\"\n [maxHeightVh]=\"80\"\n [minHeightPx]=\"sheetFloorPx\"\n >\n <div\n [id]=\"resolvedId + '-listbox'\"\n aria-multiselectable=\"true\"\n class=\"flex flex-col flex-1 min-h-0 overflow-hidden\"\n role=\"listbox\"\n >\n <!-- The sheet covers its own trigger, so it 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\n dismiss\" is literally true. It also *consumes* that click: without it the click\n reaches whatever sits underneath \u2014 inside a modal that is the modal's own\n backdrop, so dismissing the dropdown would tear down the whole modal with it.\n Portalled to document.body for the same reason the panel is. It is aria-hidden\n and unfocusable: 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 aria-multiselectable=\"true\"\n role=\"listbox\"\n >\n <ng-container [ngTemplateOutlet]=\"panelBody\"></ng-container>\n </div>\n }\n }\n\n <!-- The search box + option list, shared verbatim by the sheet and the anchored\n popover. `isSheet` only tunes spacing/sizing and which element scrolls: in sheet\n mode the list is the flex scroller; anchored, the popover itself scrolls. -->\n <ng-template #panelBody>\n @if (isSearchable) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n (click)=\"$event.stopPropagation()\"\n [ngClass]=\"isSheet ? 'px-4 py-2' : 'p-2'\"\n class=\"border-b border-base-300 shrink-0\"\n >\n <mn-lib-input-field\n (ngModelChange)=\"onSearch($event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n [ngModel]=\"searchTerm\"\n [props]=\"{\n id: resolvedId + '-search',\n type: 'search',\n placeholder: searchPlaceholderLabel,\n ariaLabel: searchPlaceholderLabel,\n fullWidth: true,\n size: 'sm'\n }\"\n ></mn-lib-input-field>\n </div>\n }\n <div [ngClass]=\"isSheet ? 'flex-1 overflow-auto overscroll-contain' : ''\">\n @for (opt of filteredOptions; track opt.value) {\n <div\n (click)=\"toggleOption(opt); $event.stopPropagation()\"\n (keyup.enter)=\"toggleOption(opt)\"\n (keyup.space)=\"toggleOption(opt)\"\n [attr.aria-selected]=\"isSelected(opt)\"\n [class.opacity-50]=\"opt.disabled || isMaxReached(opt)\"\n [class.pointer-events-none]=\"opt.disabled || isMaxReached(opt)\"\n [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\"\n class=\"flex items-center gap-x-2 cursor-pointer text-base-content hover:bg-base-200\"\n role=\"option\"\n tabindex=\"0\"\n >\n <input\n [checked]=\"isSelected(opt)\"\n [disabled]=\"opt.disabled || isMaxReached(opt)\"\n class=\"w-4 h-4 accent-primary pointer-events-none shrink-0\"\n tabindex=\"-1\"\n type=\"checkbox\"\n />\n <span>{{ opt.label }}</span>\n </div>\n }\n @if (filteredOptions.length === 0) {\n <div [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\" class=\"text-base-content/50\">\n {{ noOptionsLabel }}\n </div>\n }\n </div>\n </ng-template>\n\n @if (showError) {\n @if (props.showAllErrors) {\n <div class=\"flex flex-col gap-y-1 mt-1\">\n @for (error of errorMessages; track $index) {\n <mn-error-message [errorMessage]=\"error\" [id]=\"resolvedId + '-' + $index\"></mn-error-message>\n }\n </div>\n } @else {\n @if (errorMessage !== null) {\n <mn-error-message [errorMessage]=\"errorMessage\" [id]=\"resolvedId\"></mn-error-message>\n }\n }\n }\n</div>\n", styles: [""], dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: MnErrorMessage, selector: "mn-error-message", inputs: ["errorMessage", "id"] }, { kind: "component", type: MnButton, selector: "button[mnButton], a[mnButton]", inputs: ["data"] }, { kind: "component", type: MnInputField, selector: "mn-lib-input-field", inputs: ["props"] }, { kind: "component", type: MnBottomSheet, selector: "mn-bottom-sheet", inputs: ["showBackdrop", "showGrabber", "dismissible", "minHeightPx", "maxHeightVh", "containerClass", "ariaLabel", "ariaLabelledby", "growWithKeyboard", "dismissGuard"], outputs: ["dismiss"] }, { kind: "component", type: LucideX, selector: "svg[lucideX]" }, { kind: "component", type: LucideChevronDown, selector: "svg[lucideChevronDown]" }] });
4750
4870
  }
4751
4871
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnMultiSelect, decorators: [{
4752
4872
  type: Component,
4753
- args: [{ selector: 'mn-lib-multi-select', standalone: true, imports: [NgClass, NgTemplateOutlet, FormsModule, MnErrorMessage, MnButton, MnInputField, MnBottomSheet, LucideX, LucideChevronDown], template: "<div class=\"flex flex-col h-full\" [class.is-fullwidth]=\"props.fullWidth\">\n @if (uiConfig.label || props.label) {\n <label class=\"pl-2 pb-1 flex flex-row gap-x-0.5! text-base!\" [attr.for]=\"resolvedId\">\n <p>{{ uiConfig.label || props.label }}</p>\n @if (isRequired()) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n }\n\n <!-- Trigger -->\n <div\n #trigger\n [id]=\"resolvedId\"\n [ngClass]=\"triggerClasses\"\n class=\"relative\"\n [attr.aria-label]=\"uiConfig.ariaLabel || uiConfig.label || props.label || null\"\n [attr.aria-invalid]=\"showError || null\"\n [attr.aria-describedby]=\"showError ? resolvedId + '-error' : null\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-controls]=\"isOpen ? resolvedId + '-listbox' : null\"\n role=\"combobox\"\n tabindex=\"0\"\n (click)=\"toggle()\"\n (keydown.enter)=\"toggle()\"\n (keydown.space)=\"toggle(); $event.preventDefault()\"\n (blur)=\"handleBlur()\"\n >\n <!-- `pr-6` reserves the gutter the caret is absolutely positioned in (right-2 +\n w-4), so a value can never render underneath it. `min-w-0` lets the chips\n shrink below their content width, which is what makes truncation possible. -->\n <div class=\"flex flex-row items-center gap-x-2 flex-wrap min-h-6 min-w-0 pr-6\">\n @if (selectedOptions.length === 0) {\n <span class=\"text-base-content/50\">{{ uiConfig.placeholder || props.placeholder || 'Select...' }}</span>\n } @else if (isCollapsed) {\n <span\n class=\"inline-flex items-center max-w-full truncate bg-base-200 border border-accent text-base-content text-xs px-2 py-0.5 rounded-md\">\n {{ collapseSummaryText }}\n </span>\n } @else {\n @for (opt of selectedOptions; track opt.value) {\n <!-- Only the \u00D7 removes. The chip body deliberately carries no handler, so a click\n anywhere on it bubbles to the trigger and just opens/closes the panel \u2014 clicking\n the trigger to dismiss the dropdown must never silently delete a selection. -->\n <span\n class=\"inline-flex items-center gap-x-1 max-w-full min-w-0 bg-base-200 border border-accent text-base-content text-xs pl-2 py-0.5 rounded-md\">\n <span [attr.title]=\"opt.label\" class=\"truncate\">{{ opt.label }}</span>\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'secondary', hover: false }\"\n type=\"button\"\n class=\"text-base-content/50 cursor-pointer shrink-0\"\n (click)=\"removeOption(opt, $event)\"\n [attr.aria-label]=\"'Remove ' + opt.label\"\n ><svg lucideX [size]=\"18\"></svg></button>\n </span>\n }\n }\n </div>\n <div class=\"absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none\">\n <svg [size]=\"16\" class=\"text-base-content/50\" lucideChevronDown></svg>\n </div>\n </div>\n\n <!-- Dropdown -->\n @if (isOpen) {\n @if (isSheet) {\n <!-- On mobile the panel is presented as a shared bottom sheet: the sheet chrome\n (backdrop, grabber, swipe/flick-to-dismiss, slide animation) lives in\n mn-bottom-sheet; this component only projects the field's content into it.\n The sheet host is portalled to document.body (see the `sheet` ViewChild) so\n its `position: fixed` anchors to the viewport, not a transformed ancestor. -->\n <mn-bottom-sheet\n #sheet\n (dismiss)=\"close()\"\n [ariaLabel]=\"uiConfig.ariaLabel || uiConfig.label || props.label || uiConfig.placeholder || props.placeholder\"\n [maxHeightVh]=\"80\"\n [minHeightPx]=\"sheetFloorPx\"\n >\n <div\n [id]=\"resolvedId + '-listbox'\"\n aria-multiselectable=\"true\"\n class=\"flex flex-col flex-1 min-h-0 overflow-hidden\"\n role=\"listbox\"\n >\n <!-- The sheet covers its own trigger, so it 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\n dismiss\" is literally true. It also *consumes* that click: without it the click\n reaches whatever sits underneath \u2014 inside a modal that is the modal's own\n backdrop, so dismissing the dropdown would tear down the whole modal with it.\n Portalled to document.body for the same reason the panel is. It is aria-hidden\n and unfocusable: 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 aria-multiselectable=\"true\"\n role=\"listbox\"\n >\n <ng-container [ngTemplateOutlet]=\"panelBody\"></ng-container>\n </div>\n }\n }\n\n <!-- The search box + option list, shared verbatim by the sheet and the anchored\n popover. `isSheet` only tunes spacing/sizing and which element scrolls: in sheet\n mode the list is the flex scroller; anchored, the popover itself scrolls. -->\n <ng-template #panelBody>\n @if (isSearchable) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n (click)=\"$event.stopPropagation()\"\n [ngClass]=\"isSheet ? 'px-4 py-2' : 'p-2'\"\n class=\"border-b border-base-300 shrink-0\"\n >\n <mn-lib-input-field\n (ngModelChange)=\"onSearch($event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n [ngModel]=\"searchTerm\"\n [props]=\"{\n id: resolvedId + '-search',\n type: 'search',\n placeholder: props.searchPlaceholder || 'Search...',\n ariaLabel: props.searchPlaceholder || 'Search...',\n fullWidth: true,\n size: 'sm'\n }\"\n ></mn-lib-input-field>\n </div>\n }\n <div [ngClass]=\"isSheet ? 'flex-1 overflow-auto overscroll-contain' : ''\">\n @for (opt of filteredOptions; track opt.value) {\n <div\n (click)=\"toggleOption(opt); $event.stopPropagation()\"\n (keyup.enter)=\"toggleOption(opt)\"\n (keyup.space)=\"toggleOption(opt)\"\n [attr.aria-selected]=\"isSelected(opt)\"\n [class.opacity-50]=\"opt.disabled || isMaxReached(opt)\"\n [class.pointer-events-none]=\"opt.disabled || isMaxReached(opt)\"\n [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\"\n class=\"flex items-center gap-x-2 cursor-pointer text-base-content hover:bg-base-200\"\n role=\"option\"\n tabindex=\"0\"\n >\n <input\n [checked]=\"isSelected(opt)\"\n [disabled]=\"opt.disabled || isMaxReached(opt)\"\n class=\"w-4 h-4 accent-primary pointer-events-none shrink-0\"\n tabindex=\"-1\"\n type=\"checkbox\"\n />\n <span>{{ opt.label }}</span>\n </div>\n }\n @if (filteredOptions.length === 0) {\n <div [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\" class=\"text-base-content/50\">\n {{ uiConfig.noOptionsFound || 'No options found' }}\n </div>\n }\n </div>\n </ng-template>\n\n @if (showError) {\n @if (props.showAllErrors) {\n <div class=\"flex flex-col gap-y-1 mt-1\">\n @for (error of errorMessages; track $index) {\n <mn-error-message [errorMessage]=\"error\" [id]=\"resolvedId + '-' + $index\"></mn-error-message>\n }\n </div>\n } @else {\n @if (errorMessage !== null) {\n <mn-error-message [errorMessage]=\"errorMessage\" [id]=\"resolvedId\"></mn-error-message>\n }\n }\n }\n</div>\n" }]
4873
+ args: [{ selector: 'mn-lib-multi-select', standalone: true, imports: [NgClass, NgTemplateOutlet, FormsModule, MnErrorMessage, MnButton, MnInputField, MnBottomSheet, LucideX, LucideChevronDown], template: "<div class=\"flex flex-col h-full\" [class.is-fullwidth]=\"props.fullWidth\">\n @if (uiConfig.label || props.label) {\n <label class=\"pl-2 pb-1 flex flex-row gap-x-0.5! text-base!\" [attr.for]=\"resolvedId\">\n <p>{{ uiConfig.label || props.label }}</p>\n @if (isRequired()) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n }\n\n <!-- Trigger -->\n <div\n #trigger\n [id]=\"resolvedId\"\n [ngClass]=\"triggerClasses\"\n class=\"relative\"\n [attr.aria-label]=\"uiConfig.ariaLabel || uiConfig.label || props.label || null\"\n [attr.aria-invalid]=\"showError || null\"\n [attr.aria-describedby]=\"showError ? resolvedId + '-error' : null\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-controls]=\"isOpen ? resolvedId + '-listbox' : null\"\n role=\"combobox\"\n tabindex=\"0\"\n (click)=\"toggle()\"\n (keydown.enter)=\"toggle()\"\n (keydown.space)=\"toggle(); $event.preventDefault()\"\n (blur)=\"handleBlur()\"\n >\n <!-- `pr-6` reserves the gutter the caret is absolutely positioned in (right-2 +\n w-4), so a value can never render underneath it. `min-w-0` lets the chips\n shrink below their content width, which is what makes truncation possible. -->\n <div class=\"flex flex-row items-center gap-x-2 flex-wrap min-h-6 min-w-0 pr-6\">\n @if (selectedOptions.length === 0) {\n <span class=\"text-base-content/50\">{{ placeholderLabel }}</span>\n } @else if (isCollapsed) {\n <span\n class=\"inline-flex items-center max-w-full truncate bg-base-200 border border-accent text-base-content text-xs px-2 py-0.5 rounded-md\">\n {{ collapseSummaryText }}\n </span>\n } @else {\n @for (opt of selectedOptions; track opt.value) {\n <!-- Only the \u00D7 removes. The chip body deliberately carries no handler, so a click\n anywhere on it bubbles to the trigger and just opens/closes the panel \u2014 clicking\n the trigger to dismiss the dropdown must never silently delete a selection. -->\n <span\n class=\"inline-flex items-center gap-x-1 max-w-full min-w-0 bg-base-200 border border-accent text-base-content text-xs pl-2 py-0.5 rounded-md\">\n <span [attr.title]=\"opt.label\" class=\"truncate\">{{ opt.label }}</span>\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'secondary', hover: false }\"\n type=\"button\"\n class=\"text-base-content/50 cursor-pointer shrink-0\"\n (click)=\"removeOption(opt, $event)\"\n [attr.aria-label]=\"'Remove ' + opt.label\"\n ><svg lucideX [size]=\"18\"></svg></button>\n </span>\n }\n }\n </div>\n <div class=\"absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none\">\n <svg [size]=\"16\" class=\"text-base-content/50\" lucideChevronDown></svg>\n </div>\n </div>\n\n <!-- Dropdown -->\n @if (isOpen) {\n @if (isSheet) {\n <!-- On mobile the panel is presented as a shared bottom sheet: the sheet chrome\n (backdrop, grabber, swipe/flick-to-dismiss, slide animation) lives in\n mn-bottom-sheet; this component only projects the field's content into it.\n The sheet host is portalled to document.body (see the `sheet` ViewChild) so\n its `position: fixed` anchors to the viewport, not a transformed ancestor. -->\n <mn-bottom-sheet\n #sheet\n (dismiss)=\"close()\"\n [ariaLabel]=\"uiConfig.ariaLabel || uiConfig.label || props.label || uiConfig.placeholder || props.placeholder\"\n [maxHeightVh]=\"80\"\n [minHeightPx]=\"sheetFloorPx\"\n >\n <div\n [id]=\"resolvedId + '-listbox'\"\n aria-multiselectable=\"true\"\n class=\"flex flex-col flex-1 min-h-0 overflow-hidden\"\n role=\"listbox\"\n >\n <!-- The sheet covers its own trigger, so it 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\n dismiss\" is literally true. It also *consumes* that click: without it the click\n reaches whatever sits underneath \u2014 inside a modal that is the modal's own\n backdrop, so dismissing the dropdown would tear down the whole modal with it.\n Portalled to document.body for the same reason the panel is. It is aria-hidden\n and unfocusable: 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 aria-multiselectable=\"true\"\n role=\"listbox\"\n >\n <ng-container [ngTemplateOutlet]=\"panelBody\"></ng-container>\n </div>\n }\n }\n\n <!-- The search box + option list, shared verbatim by the sheet and the anchored\n popover. `isSheet` only tunes spacing/sizing and which element scrolls: in sheet\n mode the list is the flex scroller; anchored, the popover itself scrolls. -->\n <ng-template #panelBody>\n @if (isSearchable) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n (click)=\"$event.stopPropagation()\"\n [ngClass]=\"isSheet ? 'px-4 py-2' : 'p-2'\"\n class=\"border-b border-base-300 shrink-0\"\n >\n <mn-lib-input-field\n (ngModelChange)=\"onSearch($event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n [ngModel]=\"searchTerm\"\n [props]=\"{\n id: resolvedId + '-search',\n type: 'search',\n placeholder: searchPlaceholderLabel,\n ariaLabel: searchPlaceholderLabel,\n fullWidth: true,\n size: 'sm'\n }\"\n ></mn-lib-input-field>\n </div>\n }\n <div [ngClass]=\"isSheet ? 'flex-1 overflow-auto overscroll-contain' : ''\">\n @for (opt of filteredOptions; track opt.value) {\n <div\n (click)=\"toggleOption(opt); $event.stopPropagation()\"\n (keyup.enter)=\"toggleOption(opt)\"\n (keyup.space)=\"toggleOption(opt)\"\n [attr.aria-selected]=\"isSelected(opt)\"\n [class.opacity-50]=\"opt.disabled || isMaxReached(opt)\"\n [class.pointer-events-none]=\"opt.disabled || isMaxReached(opt)\"\n [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\"\n class=\"flex items-center gap-x-2 cursor-pointer text-base-content hover:bg-base-200\"\n role=\"option\"\n tabindex=\"0\"\n >\n <input\n [checked]=\"isSelected(opt)\"\n [disabled]=\"opt.disabled || isMaxReached(opt)\"\n class=\"w-4 h-4 accent-primary pointer-events-none shrink-0\"\n tabindex=\"-1\"\n type=\"checkbox\"\n />\n <span>{{ opt.label }}</span>\n </div>\n }\n @if (filteredOptions.length === 0) {\n <div [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\" class=\"text-base-content/50\">\n {{ noOptionsLabel }}\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" }]
4754
4874
  }], ctorParameters: () => [], propDecorators: { props: [{
4755
4875
  type: Input,
4756
4876
  args: [{ required: true }]
@@ -5599,18 +5719,89 @@ const mnSelectVariants = tv({
5599
5719
  });
5600
5720
 
5601
5721
  const MN_SELECT_CONFIG = new InjectionToken('MN_SELECT_CONFIG');
5722
+ /**
5723
+ * A single-value picker. The trigger opens a `role="listbox"` of {@link MnSelectOption}s;
5724
+ * choosing one sets the value and closes — this is the value-picker twin of the ⋯
5725
+ * command menu mn-dropdown, so it *is* a ControlValueAccessor.
5726
+ *
5727
+ * Presentation mirrors mn-multi-select: one custom field trigger at every size, an
5728
+ * anchored popover on desktop and the shared {@link MnBottomSheet} on mobile (< 640px) —
5729
+ * the same sheet mn-dropdown itself wraps. Both the popover and the sheet host are
5730
+ * portalled to `document.body` so their `position: fixed` anchors to the viewport rather
5731
+ * than any transformed/filtered ancestor (a table cell, a card) — the same root-cause fix
5732
+ * the multi-select applies.
5733
+ */
5602
5734
  class MnSelect {
5603
5735
  ngControl = inject(NgControl, { optional: true, self: true });
5604
5736
  props;
5605
5737
  /** Currently selected value */
5606
5738
  selectedValue = null;
5739
+ isOpen = false;
5607
5740
  isDisabled = false;
5741
+ searchTerm = '';
5608
5742
  uiConfig = {};
5609
5743
  configService = inject(MnConfigService);
5610
5744
  sectionPath = inject(MN_SECTION_PATH, { optional: true }) ?? [];
5611
5745
  explicitInstanceId = inject(MN_INSTANCE_ID, { optional: true });
5746
+ elRef = inject(ElementRef);
5612
5747
  lang = inject(MnLanguageService);
5613
5748
  destroyRef = inject(DestroyRef);
5749
+ renderer = inject(Renderer2);
5750
+ cdr = inject(ChangeDetectorRef);
5751
+ /** Lucide data for the trailing check shown on the selected row. */
5752
+ checkIcon = LucideCheck.icon;
5753
+ /** Reference to the trigger element for positioning the dropdown. */
5754
+ triggerRef;
5755
+ /** Layout classes for the anchored popover panel. The mobile sheet is rendered by
5756
+ * mn-bottom-sheet instead, so it no longer needs a branch here. */
5757
+ panelClasses = 'fixed z-9999 bg-base-100 border border-base-300 rounded-md shadow-lg max-h-60 overflow-auto';
5758
+ /** Layout classes for the invisible click shield rendered under the anchored panel.
5759
+ * One step below the panel's z-index so the panel itself stays clickable, and above
5760
+ * any modal/drawer chrome (which tops out well under 9998). */
5761
+ shieldClasses = 'fixed inset-0 z-9998';
5762
+ /** Option count at which the search input auto-enables when `searchable` is unset. */
5763
+ static DEFAULT_SEARCH_THRESHOLD = 8;
5764
+ /** Tailwind's `sm` breakpoint — below this the panel renders as a bottom sheet.
5765
+ * Kept in step with the same constant in mn-bottom-sheet / mn-multi-select. */
5766
+ static SHEET_MAX_WIDTH = 639.98;
5767
+ /** The anchored popover panel currently moved into `document.body`, if any. */
5768
+ movedPanel = null;
5769
+ /** The click shield currently moved into `document.body`, if any. */
5770
+ movedShield = null;
5771
+ /** The bottom-sheet host, for outside-click tests. The sheet owns its own placement. */
5772
+ sheetHost = null;
5773
+ /** Whether the viewport is currently narrow enough for the sheet layout. */
5774
+ isNarrowViewport = false;
5775
+ /** Live breakpoint match, so rotating the device re-evaluates the layout. */
5776
+ sheetMedia = null;
5777
+ /** The listener registered on `sheetMedia`, retained for teardown. */
5778
+ sheetMediaListener = null;
5779
+ /** `document.body`'s inline `overflow` before the sheet locked it, restored on close. */
5780
+ previousBodyOverflow = null;
5781
+ /**
5782
+ * The sheet's height (px) captured the moment it opened, before any search. Re-applied
5783
+ * as a `min-height` floor so filtering the option list shorter cannot shrink the sheet
5784
+ * mid-type. Null while anchored or closed, so the popover and desktop path are untouched.
5785
+ */
5786
+ sheetFloorPx = null;
5787
+ /**
5788
+ * Watches the trigger while the panel is open. The panel lives in `document.body`, so it
5789
+ * survives its own trigger being hidden by an ancestor — a wizard step or a tab switched
5790
+ * away with `display: none`. When the trigger stops being visible the panel goes with it.
5791
+ */
5792
+ visibilityObserver = null;
5793
+ /**
5794
+ * Capture-phase scroll listener installed while open. `window:scroll` only fires for the
5795
+ * document scroller, so scrolling an inner container (a modal body, a scrollable card)
5796
+ * would otherwise leave the portalled panel floating at its stale coordinates.
5797
+ */
5798
+ scrollCapture = null;
5799
+ /** Dropdown position calculated from the trigger's bounding rect. */
5800
+ dropdownStyle = { top: '0px', left: '0px', width: '0px' };
5801
+ onChange = () => {
5802
+ };
5803
+ onTouched = () => {
5804
+ };
5614
5805
  builtInErrorMessages = {
5615
5806
  required: 'Please select an option',
5616
5807
  };
@@ -5618,12 +5809,80 @@ class MnSelect {
5618
5809
  if (this.ngControl)
5619
5810
  this.ngControl.valueAccessor = this;
5620
5811
  }
5621
- get selectedOption() {
5622
- return this.props.options.find(o => o.value === this.selectedValue);
5812
+ /**
5813
+ * The dropdown panel element, queried while it is rendered by the `@if` block. The setter
5814
+ * relocates the panel to `document.body` so that its `position: fixed` coordinates resolve
5815
+ * against the viewport rather than any transformed/filtered ancestor (which would otherwise
5816
+ * become the containing block and push the panel to the middle of the screen — also broken
5817
+ * on iOS). Cleanup is handled when the query clears on close/destroy.
5818
+ */
5819
+ set dropdownRef(ref) {
5820
+ this.movedPanel = this.portal(ref?.nativeElement ?? null, this.movedPanel);
5821
+ }
5822
+ /**
5823
+ * The click shield sitting under the anchored panel, portalled alongside it for the same
5824
+ * reason: `position: fixed` must resolve against the viewport, not a transformed ancestor.
5825
+ */
5826
+ set shieldRef(ref) {
5827
+ this.movedShield = this.portal(ref?.nativeElement ?? null, this.movedShield);
5828
+ }
5829
+ /**
5830
+ * The bottom-sheet host, kept as a reference for outside-click tests. The sheet relocates
5831
+ * itself to `document.body`, so nothing is moved here. On open its container height is
5832
+ * captured as the sheet's `min-height` floor.
5833
+ */
5834
+ set sheetRef(ref) {
5835
+ const el = ref?.nativeElement ?? null;
5836
+ this.sheetHost = el;
5837
+ if (el) {
5838
+ this.captureSheetFloor(el);
5839
+ }
5840
+ else {
5841
+ this.sheetFloorPx = null;
5842
+ }
5623
5843
  }
5624
5844
  get control() {
5625
5845
  return this.ngControl?.control ?? null;
5626
5846
  }
5847
+ get selectedOption() {
5848
+ return this.props.options.find(o => o.value === this.selectedValue);
5849
+ }
5850
+ /** The label shown in the trigger: the selected option, else the placeholder. */
5851
+ get displayText() {
5852
+ return this.selectedOption?.label ?? this.placeholderLabel;
5853
+ }
5854
+ /** Trigger text shown while no option is selected. */
5855
+ get placeholderLabel() {
5856
+ return this.resolveLabel(this.props.placeholder, 'mnSelect.placeholder', 'Select...', this.uiConfig.placeholder);
5857
+ }
5858
+ /** Placeholder and accessible name of the dropdown's search input. */
5859
+ get searchPlaceholderLabel() {
5860
+ return this.resolveLabel(this.props.searchPlaceholder, 'mnSelect.search', 'Search...', this.uiConfig.searchPlaceholder);
5861
+ }
5862
+ /** Empty text shown when the search filters every option away. */
5863
+ get noOptionsLabel() {
5864
+ return this.resolveLabel(undefined, 'mnSelect.noOptions', 'No options found', this.uiConfig.noOptionsFound);
5865
+ }
5866
+ /**
5867
+ * Resolves one of the component's own labels, preferring what the caller gave it
5868
+ * and falling back through the config layer, a conventional translation key and
5869
+ * finally a readable English default.
5870
+ *
5871
+ * Mirrors `MnCollectionBase.resolveLabel` and its twin in `MnMultiSelect`. Without
5872
+ * the key step a consumer could only translate these by repeating the same literal
5873
+ * at every call site, and the search box in particular auto-enables on option
5874
+ * count — it appears without anyone asking for it, so it must be translatable
5875
+ * without anyone asking either.
5876
+ *
5877
+ * @param explicit The label the caller passed through `props`, if any.
5878
+ * @param key The conventional translation key to try next.
5879
+ * @param fallback The English text used when neither resolves.
5880
+ * @param configured The value the config layer resolved, if any.
5881
+ * @returns The resolved label.
5882
+ */
5883
+ resolveLabel(explicit, key, fallback, configured) {
5884
+ return explicit ?? configured ?? this.lang.translateIfPresent(key) ?? fallback;
5885
+ }
5627
5886
  get showError() {
5628
5887
  const c = this.control;
5629
5888
  return !!c && c.invalid && (c.touched || c.dirty);
@@ -5634,7 +5893,6 @@ class MnSelect {
5634
5893
  return [];
5635
5894
  return Object.keys(errors).map(key => this.resolveErrorMessageForKey(key, errors));
5636
5895
  }
5637
- // ========== ControlValueAccessor Implementation ==========
5638
5896
  get errorMessage() {
5639
5897
  const errors = this.control?.errors;
5640
5898
  if (!errors)
@@ -5648,7 +5906,7 @@ class MnSelect {
5648
5906
  get resolvedName() {
5649
5907
  return this.props?.name ?? null;
5650
5908
  }
5651
- get selectClasses() {
5909
+ get triggerClasses() {
5652
5910
  return mnSelectVariants({
5653
5911
  size: this.props.size,
5654
5912
  borderRadius: this.props.borderRadius,
@@ -5656,16 +5914,47 @@ class MnSelect {
5656
5914
  fullWidth: this.props.fullWidth,
5657
5915
  });
5658
5916
  }
5659
- // ========== Select Logic ==========
5917
+ /** Whether the panel should currently render as a bottom sheet. */
5918
+ get isSheet() {
5919
+ return this.props.mobileSheet !== false && this.isNarrowViewport;
5920
+ }
5921
+ /**
5922
+ * Whether the search input is shown: the explicit `searchable` prop when set, otherwise
5923
+ * auto-enabled once the option count reaches the threshold.
5924
+ */
5925
+ get isSearchable() {
5926
+ if (this.props.searchable !== undefined)
5927
+ return this.props.searchable;
5928
+ const threshold = this.props.searchThreshold ?? MnSelect.DEFAULT_SEARCH_THRESHOLD;
5929
+ return this.props.options.length >= threshold;
5930
+ }
5931
+ get filteredOptions() {
5932
+ if (!this.searchTerm)
5933
+ return this.props.options;
5934
+ const lower = this.searchTerm.toLowerCase();
5935
+ return this.props.options.filter(o => o.label.toLowerCase().includes(lower));
5936
+ }
5937
+ // ========== Lifecycle ==========
5660
5938
  ngOnInit() {
5661
5939
  this.resolveConfig();
5940
+ this.startWatchingViewport();
5662
5941
  const sub = this.lang.locale$.pipe(skip(1)).subscribe(() => {
5663
5942
  this.resolveConfig();
5664
5943
  });
5665
- this.destroyRef.onDestroy(() => sub.unsubscribe());
5944
+ this.destroyRef.onDestroy(() => {
5945
+ sub.unsubscribe();
5946
+ this.stopWatchingTrigger();
5947
+ this.stopWatchingViewport();
5948
+ this.unlockBodyScroll();
5949
+ // Guarantee the portalled elements never outlive the component.
5950
+ this.movedPanel = this.portal(null, this.movedPanel);
5951
+ this.movedShield = this.portal(null, this.movedShield);
5952
+ this.sheetHost = null;
5953
+ });
5666
5954
  }
5955
+ // ========== ControlValueAccessor Implementation ==========
5667
5956
  writeValue(val) {
5668
- // Treat empty string as null so the placeholder is shown and the control stays properly invalid
5957
+ // Treat empty string as null so the placeholder is shown and the control stays properly invalid.
5669
5958
  this.selectedValue = (val === '' || val == null) ? null : val;
5670
5959
  }
5671
5960
  registerOnChange(fn) {
@@ -5677,42 +5966,258 @@ class MnSelect {
5677
5966
  setDisabledState(isDisabled) {
5678
5967
  this.isDisabled = isDisabled;
5679
5968
  }
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);
5969
+ // ========== Dropdown Logic ==========
5970
+ toggle() {
5971
+ if (this.isDisabled)
5972
+ return;
5973
+ if (this.isOpen) {
5974
+ this.close();
5692
5975
  return;
5693
5976
  }
5694
- const option = this.props.options[index];
5977
+ this.isOpen = true;
5978
+ if (this.isSheet) {
5979
+ // A sheet is anchored to the viewport, so it needs no trigger tracking — only a
5980
+ // scroll lock so the page behind it stays put while the list is scrolled.
5981
+ this.lockBodyScroll();
5982
+ return;
5983
+ }
5984
+ this.updateDropdownPosition();
5985
+ this.startWatchingTrigger();
5986
+ }
5987
+ /** Selects an option, notifies the form and closes — a single choice ends the interaction. */
5988
+ selectOption(option) {
5695
5989
  if (option.disabled)
5696
5990
  return;
5697
5991
  this.selectedValue = option.value;
5698
5992
  this.onChange(this.selectedValue);
5993
+ this.close();
5699
5994
  }
5700
5995
  isSelected(option) {
5701
5996
  return this.selectedValue === option.value;
5702
5997
  }
5998
+ onSearch(term) {
5999
+ this.searchTerm = term ?? '';
6000
+ }
6001
+ /**
6002
+ * The single close path. Every trigger (outside click, Escape, scroll, resize, the trigger
6003
+ * being hidden, a choice) funnels through here so the open-only listeners are always torn
6004
+ * down with the panel and never leak.
6005
+ */
6006
+ close() {
6007
+ if (!this.isOpen)
6008
+ return;
6009
+ this.isOpen = false;
6010
+ this.searchTerm = '';
6011
+ this.stopWatchingTrigger();
6012
+ this.unlockBodyScroll();
6013
+ }
5703
6014
  handleBlur() {
5704
6015
  this.onTouched();
5705
6016
  }
6017
+ /**
6018
+ * Dismisses the anchored panel from a shield click, and stops the event there.
6019
+ *
6020
+ * Swallowing it is the point: the shield spans the viewport, so the click would otherwise
6021
+ * land on whatever the panel was floating over. Inside a modal that is the modal's own
6022
+ * backdrop, and "close the dropdown" would double as "throw away the modal". A first click
6023
+ * that only dismisses the overlay is also how native selects and menus behave.
6024
+ */
6025
+ onShieldClick(event) {
6026
+ event.stopPropagation();
6027
+ event.preventDefault();
6028
+ this.close();
6029
+ }
6030
+ onDocumentClick(event) {
6031
+ const target = event.target;
6032
+ // The panel lives at the body root once open, so it is not a descendant of the host
6033
+ // element — treat clicks inside the portalled panel as "inside" too.
6034
+ const insideHost = !!target && this.elRef.nativeElement.contains(target);
6035
+ const insidePanel = !!target && !!this.movedPanel && this.movedPanel.contains(target);
6036
+ // In sheet mode the backdrop tap is handled by mn-bottom-sheet's own (dismiss); the
6037
+ // sheet host counts as "inside" here so this listener never double-fires the close.
6038
+ const insideSheet = !!target && !!this.sheetHost && this.sheetHost.contains(target);
6039
+ if (!insideHost && !insidePanel && !insideSheet) {
6040
+ this.close();
6041
+ }
6042
+ }
6043
+ /** Closes the dropdown on Escape for keyboard accessibility. */
6044
+ onEscape() {
6045
+ this.close();
6046
+ }
6047
+ /**
6048
+ * Closes the dropdown when the page or a scrollable parent is scrolled.
6049
+ *
6050
+ * Skipped for a sheet: it is anchored to the viewport, not to the trigger, so it has no
6051
+ * stale position to escape. Crucially, opening the soft keyboard fires a `resize` on
6052
+ * Android — closing on that would dismiss the sheet the instant search is focused. A
6053
+ * genuine layout switch is handled by the `matchMedia` listener instead.
6054
+ */
6055
+ onWindowScrollOrResize() {
6056
+ if (this.isSheet)
6057
+ return;
6058
+ this.close();
6059
+ }
5706
6060
  isRequired() {
5707
6061
  if (!this.control)
5708
6062
  return false;
5709
6063
  return this.control.hasValidator(Validators.required);
5710
6064
  }
5711
- onChange = () => {
5712
- };
5713
- onTouched = () => {
5714
- };
5715
- // ========== Resolved Properties ==========
6065
+ // ========== Viewport / breakpoint watching ==========
6066
+ /**
6067
+ * Tracks the sheet breakpoint through `matchMedia` rather than reading `innerWidth` once,
6068
+ * so rotating the device switches layout instead of leaving a panel positioned for the
6069
+ * previous orientation. An open panel is closed on the switch — its anchored coordinates
6070
+ * and its sheet layout are not interchangeable.
6071
+ */
6072
+ startWatchingViewport() {
6073
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
6074
+ return;
6075
+ this.sheetMedia = window.matchMedia(`(max-width: ${MnSelect.SHEET_MAX_WIDTH}px)`);
6076
+ this.isNarrowViewport = this.sheetMedia.matches;
6077
+ this.sheetMediaListener = (event) => {
6078
+ this.isNarrowViewport = event.matches;
6079
+ this.close();
6080
+ // The listener fires outside Angular, so a zoneless app needs an explicit nudge.
6081
+ this.cdr.markForCheck();
6082
+ };
6083
+ this.sheetMedia.addEventListener('change', this.sheetMediaListener);
6084
+ }
6085
+ /** Tears down the breakpoint listener. Idempotent. */
6086
+ stopWatchingViewport() {
6087
+ if (this.sheetMedia && this.sheetMediaListener) {
6088
+ this.sheetMedia.removeEventListener('change', this.sheetMediaListener);
6089
+ }
6090
+ this.sheetMedia = null;
6091
+ this.sheetMediaListener = null;
6092
+ }
6093
+ // ========== Body scroll lock (sheet only) ==========
6094
+ /**
6095
+ * Freezes the page behind an open sheet. The previous inline value is captured and restored
6096
+ * verbatim so a surrounding modal that set its own lock is left intact.
6097
+ */
6098
+ lockBodyScroll() {
6099
+ if (this.previousBodyOverflow !== null)
6100
+ return;
6101
+ this.previousBodyOverflow = document.body.style.overflow;
6102
+ this.renderer.setStyle(document.body, 'overflow', 'hidden');
6103
+ }
6104
+ /** Restores the pre-lock `overflow`. Idempotent. */
6105
+ unlockBodyScroll() {
6106
+ if (this.previousBodyOverflow === null)
6107
+ return;
6108
+ if (this.previousBodyOverflow) {
6109
+ this.renderer.setStyle(document.body, 'overflow', this.previousBodyOverflow);
6110
+ }
6111
+ else {
6112
+ this.renderer.removeStyle(document.body, 'overflow');
6113
+ }
6114
+ this.previousBodyOverflow = null;
6115
+ }
6116
+ // ========== Positioning ==========
6117
+ /** Calculates the fixed position for the dropdown based on the trigger element. */
6118
+ updateDropdownPosition() {
6119
+ if (!this.triggerRef)
6120
+ return;
6121
+ const rect = this.triggerRef.nativeElement.getBoundingClientRect();
6122
+ this.dropdownStyle = {
6123
+ top: `${rect.bottom}px`,
6124
+ left: `${rect.left}px`,
6125
+ width: `${rect.width}px`,
6126
+ };
6127
+ }
6128
+ /**
6129
+ * Starts the open-only watchers: an `IntersectionObserver` on the trigger (closes the panel
6130
+ * as soon as the trigger stops being rendered/visible) and a capture-phase `scroll` listener
6131
+ * (closes it when any ancestor scroller moves under it). Scrolls that originate inside the
6132
+ * panel's own option list are ignored.
6133
+ */
6134
+ startWatchingTrigger() {
6135
+ this.stopWatchingTrigger();
6136
+ const trigger = this.triggerRef?.nativeElement;
6137
+ if (trigger && typeof IntersectionObserver !== 'undefined') {
6138
+ this.visibilityObserver = new IntersectionObserver(entries => {
6139
+ if (!entries.some(entry => !entry.isIntersecting))
6140
+ return;
6141
+ this.close();
6142
+ // The observer fires outside Angular, so a zoneless app needs an explicit nudge.
6143
+ this.cdr.markForCheck();
6144
+ });
6145
+ this.visibilityObserver.observe(trigger);
6146
+ }
6147
+ this.scrollCapture = (event) => {
6148
+ const target = event.target;
6149
+ if (target && this.movedPanel && (this.movedPanel === target || this.movedPanel.contains(target))) {
6150
+ return;
6151
+ }
6152
+ this.close();
6153
+ this.cdr.markForCheck();
6154
+ };
6155
+ document.addEventListener('scroll', this.scrollCapture, true);
6156
+ }
6157
+ /** Tears down the watchers installed by `startWatchingTrigger`. Idempotent. */
6158
+ stopWatchingTrigger() {
6159
+ this.visibilityObserver?.disconnect();
6160
+ this.visibilityObserver = null;
6161
+ if (this.scrollCapture) {
6162
+ document.removeEventListener('scroll', this.scrollCapture, true);
6163
+ this.scrollCapture = null;
6164
+ }
6165
+ }
6166
+ // ========== Sheet height floor ==========
6167
+ /**
6168
+ * Records the sheet's opened height as its `min-height` floor. Measured on the next frame
6169
+ * so the read reflects the fully-rendered, unfiltered list (the search box is empty on
6170
+ * open) and never forces a reflow mid change-detection. The floor equals the content height
6171
+ * at that instant, so applying it triggers no resize — it only stops a later, shorter
6172
+ * filtered list from pulling the sheet down.
6173
+ *
6174
+ * `hostEl` is the portalled mn-bottom-sheet host (`display: contents`), so the height is
6175
+ * read from its `.mn-sheet-container` child rather than the host itself.
6176
+ */
6177
+ captureSheetFloor(hostEl) {
6178
+ const measure = () => {
6179
+ const container = hostEl.querySelector('.mn-sheet-container');
6180
+ return container?.offsetHeight ?? hostEl.offsetHeight;
6181
+ };
6182
+ if (typeof requestAnimationFrame !== 'function') {
6183
+ this.sheetFloorPx = measure();
6184
+ return;
6185
+ }
6186
+ requestAnimationFrame(() => {
6187
+ // The sheet may have closed before the frame ran; don't strand a stale floor.
6188
+ if (!this.isOpen || this.sheetHost !== hostEl)
6189
+ return;
6190
+ this.sheetFloorPx = measure();
6191
+ this.cdr.markForCheck();
6192
+ });
6193
+ }
6194
+ // ========== Portal helper (see mn-multi-select for the full rationale) ==========
6195
+ /**
6196
+ * Move an overlay element to `document.body` when it appears, and detach it when the query
6197
+ * clears. Appending to the body root makes the element immune to ancestor
6198
+ * `transform`/`filter`/`will-change`, so `position: fixed` anchors to the viewport — without
6199
+ * this the panel lands mid-screen (and breaks outright on iOS).
6200
+ *
6201
+ * Returns the element now portalled, so the caller can store it. Idempotent and safe to
6202
+ * call with `null`.
6203
+ */
6204
+ portal(el, current) {
6205
+ if (el) {
6206
+ if (current === el)
6207
+ return current;
6208
+ this.renderer.appendChild(document.body, el);
6209
+ return el;
6210
+ }
6211
+ if (current) {
6212
+ // Angular's view teardown may already have removed it; only detach if still attached.
6213
+ const parent = current.parentNode;
6214
+ if (parent) {
6215
+ this.renderer.removeChild(parent, current);
6216
+ }
6217
+ }
6218
+ return null;
6219
+ }
6220
+ // ========== Config / Error Handling ==========
5716
6221
  resolveConfig() {
5717
6222
  const instanceId = this.explicitInstanceId || `mn-select-${this.props.id}`;
5718
6223
  this.uiConfig = this.configService.resolve('mn-select', this.sectionPath, instanceId);
@@ -5750,20 +6255,44 @@ class MnSelect {
5750
6255
  return msgDef;
5751
6256
  }
5752
6257
  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"] }] });
6258
+ 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: searchPlaceholderLabel,\n ariaLabel: searchPlaceholderLabel,\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 {{ noOptionsLabel }}\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
6259
  }
5755
6260
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnSelect, decorators: [{
5756
6261
  type: Component,
5757
- args: [{ selector: 'mn-lib-select', standalone: true, imports: [NgClass, MnErrorMessage], host: {
6262
+ args: [{ selector: 'mn-lib-select', standalone: true, imports: [NgClass, NgTemplateOutlet, FormsModule, MnErrorMessage, MnInputField, MnBottomSheet, LucideDynamicIcon, LucideChevronDown], host: {
5758
6263
  // 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
6264
+ // the trigger's `w-full` (width:100%) resolves against a content-sized box and fails
5760
6265
  // to fill the parent. Give the host a real width when fullWidth is requested.
5761
6266
  '[style.display]': "props?.fullWidth ? 'block' : null",
5762
6267
  '[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" }]
6268
+ }, 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: searchPlaceholderLabel,\n ariaLabel: searchPlaceholderLabel,\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 {{ noOptionsLabel }}\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
6269
  }], ctorParameters: () => [], propDecorators: { props: [{
5765
6270
  type: Input,
5766
6271
  args: [{ required: true }]
6272
+ }], triggerRef: [{
6273
+ type: ViewChild,
6274
+ args: ['trigger', { static: false }]
6275
+ }], dropdownRef: [{
6276
+ type: ViewChild,
6277
+ args: ['dropdown', { static: false }]
6278
+ }], shieldRef: [{
6279
+ type: ViewChild,
6280
+ args: ['shield', { static: false }]
6281
+ }], sheetRef: [{
6282
+ type: ViewChild,
6283
+ args: ['sheet', { static: false, read: ElementRef }]
6284
+ }], onDocumentClick: [{
6285
+ type: HostListener,
6286
+ args: ['document:click', ['$event']]
6287
+ }], onEscape: [{
6288
+ type: HostListener,
6289
+ args: ['document:keydown.escape']
6290
+ }], onWindowScrollOrResize: [{
6291
+ type: HostListener,
6292
+ args: ['window:scroll', []]
6293
+ }, {
6294
+ type: HostListener,
6295
+ args: ['window:resize', []]
5767
6296
  }] } });
5768
6297
 
5769
6298
  // =========================
@@ -10731,12 +11260,16 @@ class MnList extends MnSelectableCollectionBase {
10731
11260
  get listRegionLabel() {
10732
11261
  return this.resolveLabel(undefined, 'mnCollection.dataList', 'Data list');
10733
11262
  }
11263
+ /** Label on the header checkbox that selects or clears every visible row. */
11264
+ get selectAllLabel() {
11265
+ return this.resolveLabel(undefined, 'mnCollection.selectAll', 'Select all');
11266
+ }
10734
11267
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnList, deps: null, target: i0.ɵɵFactoryTarget.Component });
10735
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnList, isStandalone: true, selector: "mn-list", outputs: { itemClick: "itemClick" }, viewQueries: [{ propertyName: "collectionBody", first: true, predicate: ["collectionBody"], descendants: true }], usesInheritance: true, ngImport: i0, template: "<!-- Toolbar: left template, then search + right template. Below 375px each group\n takes its own full-width row; from 375px up the left group sits at the start\n and the search group at the end. Structure and classes mirror mn-table so all\n collections behave identically at every width. -->\n@if (\n dataSource.canSearch ||\n dataSource.toolbarLeftTemplate ||\n dataSource.toolbarRightTemplate ||\n dataSource.toolbarTemplate\n) {\n <div class=\"flex flex-col min-[375px]:flex-row min-[375px]:items-center justify-between gap-2 mb-3\">\n <div class=\"flex items-center gap-2 w-full min-[375px]:w-auto\">\n @if (dataSource.toolbarLeftTemplate) {\n <div class=\"flex flex-col w-full min-[375px]:w-auto min-[375px]:flex-row\">\n <ng-container [ngTemplateOutlet]=\"dataSource.toolbarLeftTemplate\"></ng-container>\n </div>\n }\n </div>\n <div\n class=\"flex flex-col min-[375px]:flex-row min-[375px]:items-center gap-2 w-full min-[375px]:flex-1 min-[480px]:flex-none min-[480px]:w-auto\">\n @if (dataSource.canSearch) {\n <!-- Enter must not submit a surrounding form: the search box is the\n collection's own chrome, not a field of whatever form it sits in. -->\n <mn-lib-input-field\n (keydown.enter)=\"$event.preventDefault()\"\n (ngModelChange)=\"onSearch($event)\"\n [ngModel]=\"searchValue\"\n [props]=\"{\n id: 'mn-list-search',\n type: 'search',\n label: '',\n ariaLabel: dataSource.searchPlaceholder ?? 'Search...',\n placeholder: dataSource.searchPlaceholder ?? 'Search...',\n size: 'sm',\n borderRadius: 'md',\n fullWidth: true\n }\"\n class=\"w-full min-[375px]:flex-1 min-[480px]:max-w-64\"\n ></mn-lib-input-field>\n }\n <!-- `toolbarTemplate` is the deprecated name for this slot; honouring it here\n keeps existing callers rendering exactly where they used to. -->\n @if (dataSource.toolbarRightTemplate ?? dataSource.toolbarTemplate; as rightTemplate) {\n <div class=\"flex flex-col w-full min-[375px]:w-auto min-[375px]:flex-row\">\n <ng-container [ngTemplateOutlet]=\"rightTemplate\"></ng-container>\n </div>\n }\n </div>\n </div>\n}\n\n<!-- List wrapper -->\n<div\n #collectionBody\n [style.min-height.px]=\"bodyMinHeight\"\n class=\"w-full\"\n [class.border]=\"dataSource.appearance?.bordered\"\n [class.border-base-300]=\"dataSource.appearance?.bordered\"\n [class.rounded]=\"dataSource.appearance?.bordered\"\n role=\"list\"\n [attr.aria-label]=\"listRegionLabel\"\n>\n <!-- Loading state -->\n @if (isLoadingState) {\n @for (_ of skeletonRows; track $index) {\n <div [class.py-2]=\"dataSource.appearance?.compact\" class=\"px-4 py-3\" role=\"listitem\">\n @if (isTemplateRef(dataSource.skeleton)) {\n <ng-container [ngTemplateOutlet]=\"$any(dataSource.skeleton)\"></ng-container>\n } @else {\n <div class=\"flex flex-col gap-1\">\n @for (line of skeletonLines; track $index) {\n <mn-skeleton [data]=\"line\"></mn-skeleton>\n }\n </div>\n }\n </div>\n @if (!$last && (dataSource.appearance?.dividers !== false)) {\n <div class=\"border-b border-base-300\"></div>\n }\n }\n } @else if (isErrorState) {\n <!-- Error state -->\n <div class=\"text-center text-xs py-8\" role=\"listitem\">\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 </div>\n } @else {\n <!-- Empty state -->\n @if (filteredItems.length === 0) {\n <div class=\"text-center text-xs py-8\" role=\"listitem\">\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 </div>\n }\n\n <!-- Select all (multi-select) -->\n @if (isMultiSelect && filteredItems.length > 0) {\n <div class=\"flex items-center gap-2 px-4 py-2 bg-base-200 text-sm\">\n <mn-lib-checkbox\n (checkedChange)=\"toggleAll()\"\n [checked]=\"allSelected\"\n [props]=\"{ id: 'mn-list-select-all', label: 'Select all', size: 'sm' }\"\n ></mn-lib-checkbox>\n </div>\n @if (dataSource.appearance?.dividers !== false) {\n <div class=\"border-b border-base-300\"></div>\n }\n }\n\n <!-- Data items -->\n @for (item of paginatedItems; track trackByID($index, item); let odd = $odd; let last = $last) {\n <div\n class=\"flex items-center gap-2 bg-base-100 transition-colors duration-150\"\n [ngClass]=\"{'bg-primary/10': isSelected(item)}\"\n [class.bg-base-200]=\"!isSelected(item) && odd && dataSource.appearance?.dividers !== false\"\n [class.hover:bg-base-200]=\"dataSource.appearance?.hover !== false\"\n [class.cursor-pointer]=\"!!dataSource.onItemClick\"\n [class.px-4]=\"true\"\n [class.py-3]=\"!dataSource.appearance?.compact\"\n [class.py-2]=\"dataSource.appearance?.compact\"\n role=\"listitem\"\n (keyup.enter)=\"onItemClick(item)\"\n (click)=\"onItemClick(item)\"\n [attr.tabindex]=\"dataSource.onItemClick ? 0 : null\"\n >\n <!-- Selection checkbox -->\n @if (hasSelection) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div (click)=\"$event.stopPropagation()\" class=\"shrink-0\">\n <mn-lib-checkbox\n (checkedChange)=\"toggle(item)\"\n [checked]=\"isSelected(item)\"\n [props]=\"{ id: 'mn-list-item-' + $index, size: 'sm' }\"\n ></mn-lib-checkbox>\n </div>\n }\n\n <!-- Item content via template -->\n <div class=\"flex-1 min-w-0\">\n <ng-container\n [ngTemplateOutlet]=\"dataSource.itemTemplate\"\n [ngTemplateOutletContext]=\"{ $implicit: item, data: item }\"\n ></ng-container>\n </div>\n </div>\n @if (!last && (dataSource.appearance?.dividers !== false)) {\n <div class=\"border-b border-base-300\"></div>\n }\n }\n }\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-list\"\n></mn-collection-pagination>\n", styles: [""], dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: MnCheckbox, selector: "mn-lib-checkbox", inputs: ["props", "checked"], outputs: ["checkedChange"] }, { kind: "component", type: MnInputField, selector: "mn-lib-input-field", inputs: ["props"] }, { kind: "component", type: MnSkeleton, selector: "mn-skeleton", inputs: ["data"] }, { 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: LucideDynamicIcon, selector: "svg[lucideIcon]", inputs: ["lucideIcon"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
11268
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnList, isStandalone: true, selector: "mn-list", outputs: { itemClick: "itemClick" }, viewQueries: [{ propertyName: "collectionBody", first: true, predicate: ["collectionBody"], descendants: true }], usesInheritance: true, ngImport: i0, template: "<!-- Toolbar: left template, then search + right template. Below 375px each group\n takes its own full-width row; from 375px up the left group sits at the start\n and the search group at the end. Structure and classes mirror mn-table so all\n collections behave identically at every width. -->\n@if (\n dataSource.canSearch ||\n dataSource.toolbarLeftTemplate ||\n dataSource.toolbarRightTemplate ||\n dataSource.toolbarTemplate\n) {\n <div class=\"flex flex-col min-[375px]:flex-row min-[375px]:items-center justify-between gap-2 mb-3\">\n <div class=\"flex items-center gap-2 w-full min-[375px]:w-auto\">\n @if (dataSource.toolbarLeftTemplate) {\n <div class=\"flex flex-col w-full min-[375px]:w-auto min-[375px]:flex-row\">\n <ng-container [ngTemplateOutlet]=\"dataSource.toolbarLeftTemplate\"></ng-container>\n </div>\n }\n </div>\n <div\n class=\"flex flex-col min-[375px]:flex-row min-[375px]:items-center gap-2 w-full min-[375px]:flex-1 min-[480px]:flex-none min-[480px]:w-auto\">\n @if (dataSource.canSearch) {\n <!-- Enter must not submit a surrounding form: the search box is the\n collection's own chrome, not a field of whatever form it sits in. -->\n <mn-lib-input-field\n (keydown.enter)=\"$event.preventDefault()\"\n (ngModelChange)=\"onSearch($event)\"\n [ngModel]=\"searchValue\"\n [props]=\"{\n id: 'mn-list-search',\n type: 'search',\n label: '',\n ariaLabel: dataSource.searchPlaceholder ?? 'Search...',\n placeholder: dataSource.searchPlaceholder ?? 'Search...',\n size: 'sm',\n borderRadius: 'md',\n fullWidth: true\n }\"\n class=\"w-full min-[375px]:flex-1 min-[480px]:max-w-64\"\n ></mn-lib-input-field>\n }\n <!-- `toolbarTemplate` is the deprecated name for this slot; honouring it here\n keeps existing callers rendering exactly where they used to. -->\n @if (dataSource.toolbarRightTemplate ?? dataSource.toolbarTemplate; as rightTemplate) {\n <div class=\"flex flex-col w-full min-[375px]:w-auto min-[375px]:flex-row\">\n <ng-container [ngTemplateOutlet]=\"rightTemplate\"></ng-container>\n </div>\n }\n </div>\n </div>\n}\n\n<!-- List wrapper -->\n<div\n #collectionBody\n [style.min-height.px]=\"bodyMinHeight\"\n class=\"w-full\"\n [class.border]=\"dataSource.appearance?.bordered\"\n [class.border-base-300]=\"dataSource.appearance?.bordered\"\n [class.rounded]=\"dataSource.appearance?.bordered\"\n role=\"list\"\n [attr.aria-label]=\"listRegionLabel\"\n>\n <!-- Loading state -->\n @if (isLoadingState) {\n @for (_ of skeletonRows; track $index) {\n <div [class.py-2]=\"dataSource.appearance?.compact\" class=\"px-4 py-3\" role=\"listitem\">\n @if (isTemplateRef(dataSource.skeleton)) {\n <ng-container [ngTemplateOutlet]=\"$any(dataSource.skeleton)\"></ng-container>\n } @else {\n <div class=\"flex flex-col gap-1\">\n @for (line of skeletonLines; track $index) {\n <mn-skeleton [data]=\"line\"></mn-skeleton>\n }\n </div>\n }\n </div>\n @if (!$last && (dataSource.appearance?.dividers !== false)) {\n <div class=\"border-b border-base-300\"></div>\n }\n }\n } @else if (isErrorState) {\n <!-- Error state -->\n <div class=\"text-center text-xs py-8\" role=\"listitem\">\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 </div>\n } @else {\n <!-- Empty state -->\n @if (filteredItems.length === 0) {\n <div class=\"text-center text-xs py-8\" role=\"listitem\">\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 </div>\n }\n\n <!-- Select all (multi-select) -->\n @if (isMultiSelect && filteredItems.length > 0) {\n <div class=\"flex items-center gap-2 px-4 py-2 bg-base-200 text-sm\">\n <mn-lib-checkbox\n (checkedChange)=\"toggleAll()\"\n [checked]=\"allSelected\"\n [props]=\"{ id: 'mn-list-select-all', label: selectAllLabel, size: 'sm' }\"\n ></mn-lib-checkbox>\n </div>\n @if (dataSource.appearance?.dividers !== false) {\n <div class=\"border-b border-base-300\"></div>\n }\n }\n\n <!-- Data items -->\n @for (item of paginatedItems; track trackByID($index, item); let odd = $odd; let last = $last) {\n <div\n class=\"flex items-center gap-2 bg-base-100 transition-colors duration-150\"\n [ngClass]=\"{'bg-primary/10': isSelected(item)}\"\n [class.bg-base-200]=\"!isSelected(item) && odd && dataSource.appearance?.dividers !== false\"\n [class.hover:bg-base-200]=\"dataSource.appearance?.hover !== false\"\n [class.cursor-pointer]=\"!!dataSource.onItemClick\"\n [class.px-4]=\"true\"\n [class.py-3]=\"!dataSource.appearance?.compact\"\n [class.py-2]=\"dataSource.appearance?.compact\"\n role=\"listitem\"\n (keyup.enter)=\"onItemClick(item)\"\n (click)=\"onItemClick(item)\"\n [attr.tabindex]=\"dataSource.onItemClick ? 0 : null\"\n >\n <!-- Selection checkbox -->\n @if (hasSelection) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div (click)=\"$event.stopPropagation()\" class=\"shrink-0\">\n <mn-lib-checkbox\n (checkedChange)=\"toggle(item)\"\n [checked]=\"isSelected(item)\"\n [props]=\"{ id: 'mn-list-item-' + $index, size: 'sm' }\"\n ></mn-lib-checkbox>\n </div>\n }\n\n <!-- Item content via template -->\n <div class=\"flex-1 min-w-0\">\n <ng-container\n [ngTemplateOutlet]=\"dataSource.itemTemplate\"\n [ngTemplateOutletContext]=\"{ $implicit: item, data: item }\"\n ></ng-container>\n </div>\n </div>\n @if (!last && (dataSource.appearance?.dividers !== false)) {\n <div class=\"border-b border-base-300\"></div>\n }\n }\n }\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-list\"\n></mn-collection-pagination>\n", styles: [""], dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: MnCheckbox, selector: "mn-lib-checkbox", inputs: ["props", "checked"], outputs: ["checkedChange"] }, { kind: "component", type: MnInputField, selector: "mn-lib-input-field", inputs: ["props"] }, { kind: "component", type: MnSkeleton, selector: "mn-skeleton", inputs: ["data"] }, { 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: LucideDynamicIcon, selector: "svg[lucideIcon]", inputs: ["lucideIcon"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
10736
11269
  }
10737
11270
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnList, decorators: [{
10738
11271
  type: Component,
10739
- args: [{ selector: 'mn-list', standalone: true, imports: [NgClass, NgTemplateOutlet, FormsModule, MnCheckbox, MnInputField, MnSkeleton, MnCollectionPagination, LucideDynamicIcon], changeDetection: ChangeDetectionStrategy.OnPush, template: "<!-- Toolbar: left template, then search + right template. Below 375px each group\n takes its own full-width row; from 375px up the left group sits at the start\n and the search group at the end. Structure and classes mirror mn-table so all\n collections behave identically at every width. -->\n@if (\n dataSource.canSearch ||\n dataSource.toolbarLeftTemplate ||\n dataSource.toolbarRightTemplate ||\n dataSource.toolbarTemplate\n) {\n <div class=\"flex flex-col min-[375px]:flex-row min-[375px]:items-center justify-between gap-2 mb-3\">\n <div class=\"flex items-center gap-2 w-full min-[375px]:w-auto\">\n @if (dataSource.toolbarLeftTemplate) {\n <div class=\"flex flex-col w-full min-[375px]:w-auto min-[375px]:flex-row\">\n <ng-container [ngTemplateOutlet]=\"dataSource.toolbarLeftTemplate\"></ng-container>\n </div>\n }\n </div>\n <div\n class=\"flex flex-col min-[375px]:flex-row min-[375px]:items-center gap-2 w-full min-[375px]:flex-1 min-[480px]:flex-none min-[480px]:w-auto\">\n @if (dataSource.canSearch) {\n <!-- Enter must not submit a surrounding form: the search box is the\n collection's own chrome, not a field of whatever form it sits in. -->\n <mn-lib-input-field\n (keydown.enter)=\"$event.preventDefault()\"\n (ngModelChange)=\"onSearch($event)\"\n [ngModel]=\"searchValue\"\n [props]=\"{\n id: 'mn-list-search',\n type: 'search',\n label: '',\n ariaLabel: dataSource.searchPlaceholder ?? 'Search...',\n placeholder: dataSource.searchPlaceholder ?? 'Search...',\n size: 'sm',\n borderRadius: 'md',\n fullWidth: true\n }\"\n class=\"w-full min-[375px]:flex-1 min-[480px]:max-w-64\"\n ></mn-lib-input-field>\n }\n <!-- `toolbarTemplate` is the deprecated name for this slot; honouring it here\n keeps existing callers rendering exactly where they used to. -->\n @if (dataSource.toolbarRightTemplate ?? dataSource.toolbarTemplate; as rightTemplate) {\n <div class=\"flex flex-col w-full min-[375px]:w-auto min-[375px]:flex-row\">\n <ng-container [ngTemplateOutlet]=\"rightTemplate\"></ng-container>\n </div>\n }\n </div>\n </div>\n}\n\n<!-- List wrapper -->\n<div\n #collectionBody\n [style.min-height.px]=\"bodyMinHeight\"\n class=\"w-full\"\n [class.border]=\"dataSource.appearance?.bordered\"\n [class.border-base-300]=\"dataSource.appearance?.bordered\"\n [class.rounded]=\"dataSource.appearance?.bordered\"\n role=\"list\"\n [attr.aria-label]=\"listRegionLabel\"\n>\n <!-- Loading state -->\n @if (isLoadingState) {\n @for (_ of skeletonRows; track $index) {\n <div [class.py-2]=\"dataSource.appearance?.compact\" class=\"px-4 py-3\" role=\"listitem\">\n @if (isTemplateRef(dataSource.skeleton)) {\n <ng-container [ngTemplateOutlet]=\"$any(dataSource.skeleton)\"></ng-container>\n } @else {\n <div class=\"flex flex-col gap-1\">\n @for (line of skeletonLines; track $index) {\n <mn-skeleton [data]=\"line\"></mn-skeleton>\n }\n </div>\n }\n </div>\n @if (!$last && (dataSource.appearance?.dividers !== false)) {\n <div class=\"border-b border-base-300\"></div>\n }\n }\n } @else if (isErrorState) {\n <!-- Error state -->\n <div class=\"text-center text-xs py-8\" role=\"listitem\">\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 </div>\n } @else {\n <!-- Empty state -->\n @if (filteredItems.length === 0) {\n <div class=\"text-center text-xs py-8\" role=\"listitem\">\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 </div>\n }\n\n <!-- Select all (multi-select) -->\n @if (isMultiSelect && filteredItems.length > 0) {\n <div class=\"flex items-center gap-2 px-4 py-2 bg-base-200 text-sm\">\n <mn-lib-checkbox\n (checkedChange)=\"toggleAll()\"\n [checked]=\"allSelected\"\n [props]=\"{ id: 'mn-list-select-all', label: 'Select all', size: 'sm' }\"\n ></mn-lib-checkbox>\n </div>\n @if (dataSource.appearance?.dividers !== false) {\n <div class=\"border-b border-base-300\"></div>\n }\n }\n\n <!-- Data items -->\n @for (item of paginatedItems; track trackByID($index, item); let odd = $odd; let last = $last) {\n <div\n class=\"flex items-center gap-2 bg-base-100 transition-colors duration-150\"\n [ngClass]=\"{'bg-primary/10': isSelected(item)}\"\n [class.bg-base-200]=\"!isSelected(item) && odd && dataSource.appearance?.dividers !== false\"\n [class.hover:bg-base-200]=\"dataSource.appearance?.hover !== false\"\n [class.cursor-pointer]=\"!!dataSource.onItemClick\"\n [class.px-4]=\"true\"\n [class.py-3]=\"!dataSource.appearance?.compact\"\n [class.py-2]=\"dataSource.appearance?.compact\"\n role=\"listitem\"\n (keyup.enter)=\"onItemClick(item)\"\n (click)=\"onItemClick(item)\"\n [attr.tabindex]=\"dataSource.onItemClick ? 0 : null\"\n >\n <!-- Selection checkbox -->\n @if (hasSelection) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div (click)=\"$event.stopPropagation()\" class=\"shrink-0\">\n <mn-lib-checkbox\n (checkedChange)=\"toggle(item)\"\n [checked]=\"isSelected(item)\"\n [props]=\"{ id: 'mn-list-item-' + $index, size: 'sm' }\"\n ></mn-lib-checkbox>\n </div>\n }\n\n <!-- Item content via template -->\n <div class=\"flex-1 min-w-0\">\n <ng-container\n [ngTemplateOutlet]=\"dataSource.itemTemplate\"\n [ngTemplateOutletContext]=\"{ $implicit: item, data: item }\"\n ></ng-container>\n </div>\n </div>\n @if (!last && (dataSource.appearance?.dividers !== false)) {\n <div class=\"border-b border-base-300\"></div>\n }\n }\n }\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-list\"\n></mn-collection-pagination>\n" }]
11272
+ args: [{ selector: 'mn-list', standalone: true, imports: [NgClass, NgTemplateOutlet, FormsModule, MnCheckbox, MnInputField, MnSkeleton, MnCollectionPagination, LucideDynamicIcon], changeDetection: ChangeDetectionStrategy.OnPush, template: "<!-- Toolbar: left template, then search + right template. Below 375px each group\n takes its own full-width row; from 375px up the left group sits at the start\n and the search group at the end. Structure and classes mirror mn-table so all\n collections behave identically at every width. -->\n@if (\n dataSource.canSearch ||\n dataSource.toolbarLeftTemplate ||\n dataSource.toolbarRightTemplate ||\n dataSource.toolbarTemplate\n) {\n <div class=\"flex flex-col min-[375px]:flex-row min-[375px]:items-center justify-between gap-2 mb-3\">\n <div class=\"flex items-center gap-2 w-full min-[375px]:w-auto\">\n @if (dataSource.toolbarLeftTemplate) {\n <div class=\"flex flex-col w-full min-[375px]:w-auto min-[375px]:flex-row\">\n <ng-container [ngTemplateOutlet]=\"dataSource.toolbarLeftTemplate\"></ng-container>\n </div>\n }\n </div>\n <div\n class=\"flex flex-col min-[375px]:flex-row min-[375px]:items-center gap-2 w-full min-[375px]:flex-1 min-[480px]:flex-none min-[480px]:w-auto\">\n @if (dataSource.canSearch) {\n <!-- Enter must not submit a surrounding form: the search box is the\n collection's own chrome, not a field of whatever form it sits in. -->\n <mn-lib-input-field\n (keydown.enter)=\"$event.preventDefault()\"\n (ngModelChange)=\"onSearch($event)\"\n [ngModel]=\"searchValue\"\n [props]=\"{\n id: 'mn-list-search',\n type: 'search',\n label: '',\n ariaLabel: dataSource.searchPlaceholder ?? 'Search...',\n placeholder: dataSource.searchPlaceholder ?? 'Search...',\n size: 'sm',\n borderRadius: 'md',\n fullWidth: true\n }\"\n class=\"w-full min-[375px]:flex-1 min-[480px]:max-w-64\"\n ></mn-lib-input-field>\n }\n <!-- `toolbarTemplate` is the deprecated name for this slot; honouring it here\n keeps existing callers rendering exactly where they used to. -->\n @if (dataSource.toolbarRightTemplate ?? dataSource.toolbarTemplate; as rightTemplate) {\n <div class=\"flex flex-col w-full min-[375px]:w-auto min-[375px]:flex-row\">\n <ng-container [ngTemplateOutlet]=\"rightTemplate\"></ng-container>\n </div>\n }\n </div>\n </div>\n}\n\n<!-- List wrapper -->\n<div\n #collectionBody\n [style.min-height.px]=\"bodyMinHeight\"\n class=\"w-full\"\n [class.border]=\"dataSource.appearance?.bordered\"\n [class.border-base-300]=\"dataSource.appearance?.bordered\"\n [class.rounded]=\"dataSource.appearance?.bordered\"\n role=\"list\"\n [attr.aria-label]=\"listRegionLabel\"\n>\n <!-- Loading state -->\n @if (isLoadingState) {\n @for (_ of skeletonRows; track $index) {\n <div [class.py-2]=\"dataSource.appearance?.compact\" class=\"px-4 py-3\" role=\"listitem\">\n @if (isTemplateRef(dataSource.skeleton)) {\n <ng-container [ngTemplateOutlet]=\"$any(dataSource.skeleton)\"></ng-container>\n } @else {\n <div class=\"flex flex-col gap-1\">\n @for (line of skeletonLines; track $index) {\n <mn-skeleton [data]=\"line\"></mn-skeleton>\n }\n </div>\n }\n </div>\n @if (!$last && (dataSource.appearance?.dividers !== false)) {\n <div class=\"border-b border-base-300\"></div>\n }\n }\n } @else if (isErrorState) {\n <!-- Error state -->\n <div class=\"text-center text-xs py-8\" role=\"listitem\">\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 </div>\n } @else {\n <!-- Empty state -->\n @if (filteredItems.length === 0) {\n <div class=\"text-center text-xs py-8\" role=\"listitem\">\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 </div>\n }\n\n <!-- Select all (multi-select) -->\n @if (isMultiSelect && filteredItems.length > 0) {\n <div class=\"flex items-center gap-2 px-4 py-2 bg-base-200 text-sm\">\n <mn-lib-checkbox\n (checkedChange)=\"toggleAll()\"\n [checked]=\"allSelected\"\n [props]=\"{ id: 'mn-list-select-all', label: selectAllLabel, size: 'sm' }\"\n ></mn-lib-checkbox>\n </div>\n @if (dataSource.appearance?.dividers !== false) {\n <div class=\"border-b border-base-300\"></div>\n }\n }\n\n <!-- Data items -->\n @for (item of paginatedItems; track trackByID($index, item); let odd = $odd; let last = $last) {\n <div\n class=\"flex items-center gap-2 bg-base-100 transition-colors duration-150\"\n [ngClass]=\"{'bg-primary/10': isSelected(item)}\"\n [class.bg-base-200]=\"!isSelected(item) && odd && dataSource.appearance?.dividers !== false\"\n [class.hover:bg-base-200]=\"dataSource.appearance?.hover !== false\"\n [class.cursor-pointer]=\"!!dataSource.onItemClick\"\n [class.px-4]=\"true\"\n [class.py-3]=\"!dataSource.appearance?.compact\"\n [class.py-2]=\"dataSource.appearance?.compact\"\n role=\"listitem\"\n (keyup.enter)=\"onItemClick(item)\"\n (click)=\"onItemClick(item)\"\n [attr.tabindex]=\"dataSource.onItemClick ? 0 : null\"\n >\n <!-- Selection checkbox -->\n @if (hasSelection) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div (click)=\"$event.stopPropagation()\" class=\"shrink-0\">\n <mn-lib-checkbox\n (checkedChange)=\"toggle(item)\"\n [checked]=\"isSelected(item)\"\n [props]=\"{ id: 'mn-list-item-' + $index, size: 'sm' }\"\n ></mn-lib-checkbox>\n </div>\n }\n\n <!-- Item content via template -->\n <div class=\"flex-1 min-w-0\">\n <ng-container\n [ngTemplateOutlet]=\"dataSource.itemTemplate\"\n [ngTemplateOutletContext]=\"{ $implicit: item, data: item }\"\n ></ng-container>\n </div>\n </div>\n @if (!last && (dataSource.appearance?.dividers !== false)) {\n <div class=\"border-b border-base-300\"></div>\n }\n }\n }\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-list\"\n></mn-collection-pagination>\n" }]
10740
11273
  }], propDecorators: { itemClick: [{
10741
11274
  type: Output
10742
11275
  }], collectionBody: [{
@@ -13220,19 +13753,44 @@ class MnBreadcrumbs {
13220
13753
  crumbClick = new EventEmitter();
13221
13754
  /** Emits when the fallback "Back" control is activated. */
13222
13755
  back = new EventEmitter();
13223
- /** Default translation key / literal for the Back control's label. */
13224
- static DEFAULT_BACK_LABEL = 'back';
13756
+ /** Conventional key an app defines to translate the Back control's label. */
13757
+ static BACK_LABEL_KEY = 'mnBreadcrumbs.back';
13758
+ /** Conventional key an app defines to name the navigation landmark. */
13759
+ static NAV_LABEL_KEY = 'mnBreadcrumbs.label';
13760
+ /** Resolves this component's own labels against the app's bundle. */
13761
+ lang = inject(MnLanguageService);
13225
13762
  /** Resolved tailwind-variants slot functions for the current size. */
13226
13763
  get styles() {
13227
13764
  return mnBreadcrumbsVariants({ size: this.data.size });
13228
13765
  }
13766
+ /**
13767
+ * Accessible name of the `<nav>` landmark.
13768
+ *
13769
+ * A landmark's name is announced verbatim, so leaving it as a hardcoded English
13770
+ * "Breadcrumb" put one English word into every page of a translated app — in the
13771
+ * one place only screen-reader users hear.
13772
+ */
13773
+ get navLabel() {
13774
+ return this.lang.translateIfPresent(MnBreadcrumbs.NAV_LABEL_KEY) ?? 'Breadcrumb';
13775
+ }
13229
13776
  /** Whether a linkable trail should render (vs the Back fallback). */
13230
13777
  get hasTrail() {
13231
13778
  return (this.data.items?.length ?? 0) > 0;
13232
13779
  }
13233
- /** Label for the Back control — the configured key/literal, or the default. */
13780
+ /**
13781
+ * Text of the Back control, already translated.
13782
+ *
13783
+ * `data.backLabel` is a key (or a literal, which `translate` passes through
13784
+ * unchanged). Without one this falls back to the conventional key and then to
13785
+ * English — never to a raw key: the old default was the bare key `'back'`, which
13786
+ * the template's translate pipe echoed as lowercase "back" in every app that had
13787
+ * not happened to define it.
13788
+ */
13234
13789
  get backLabel() {
13235
- return this.data.backLabel ?? MnBreadcrumbs.DEFAULT_BACK_LABEL;
13790
+ if (this.data.backLabel) {
13791
+ return this.lang.translate(this.data.backLabel);
13792
+ }
13793
+ return this.lang.translateIfPresent(MnBreadcrumbs.BACK_LABEL_KEY) ?? 'Back';
13236
13794
  }
13237
13795
  /** The last crumb is the current page and is rendered as plain text. */
13238
13796
  isCurrent(index) {
@@ -13254,11 +13812,11 @@ class MnBreadcrumbs {
13254
13812
  }
13255
13813
  }
13256
13814
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnBreadcrumbs, deps: [], target: i0.ɵɵFactoryTarget.Component });
13257
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnBreadcrumbs, isStandalone: true, selector: "mn-breadcrumbs", inputs: { data: "data" }, outputs: { crumbClick: "crumbClick", back: "back" }, ngImport: i0, template: "<nav aria-label=\"Breadcrumb\" [class]=\"styles.root()\">\n @if (hasTrail) {\n <ol [class]=\"styles.list()\">\n @for (item of data.items; track $index; let first = $first) {\n @if (!first) {\n <li aria-hidden=\"true\" [class]=\"styles.separator()\">\n <svg [size]=\"14\" lucideChevronRight></svg>\n </li>\n }\n <li class=\"inline-flex items-center\">\n @if (isCurrent($index)) {\n <span aria-current=\"page\" [class]=\"styles.current()\">{{ item.label | mnTranslate }}</span>\n } @else if (item.href) {\n <a [attr.href]=\"item.href\" [class]=\"styles.link()\" (click)=\"onCrumb(item)\">{{ item.label | mnTranslate }}</a>\n } @else {\n <button type=\"button\" [class]=\"styles.link()\" (click)=\"onCrumb(item)\">{{ item.label | mnTranslate }}</button>\n }\n </li>\n }\n </ol>\n } @else if (data.backHref) {\n <a [attr.href]=\"data.backHref\" [class]=\"styles.back()\" (click)=\"onBack()\">\n <svg [size]=\"16\" lucideChevronLeft></svg>\n {{ backLabel | mnTranslate }}\n </a>\n } @else {\n <button type=\"button\" [class]=\"styles.back()\" (click)=\"onBack()\">\n <svg [size]=\"16\" lucideChevronLeft></svg>\n {{ backLabel | mnTranslate }}\n </button>\n }\n</nav>\n", dependencies: [{ kind: "component", type: LucideChevronLeft, selector: "svg[lucideChevronLeft]" }, { kind: "component", type: LucideChevronRight, selector: "svg[lucideChevronRight]" }, { kind: "pipe", type: MnTranslatePipe, name: "mnTranslate" }] });
13815
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnBreadcrumbs, isStandalone: true, selector: "mn-breadcrumbs", inputs: { data: "data" }, outputs: { crumbClick: "crumbClick", back: "back" }, ngImport: i0, template: "<nav [attr.aria-label]=\"navLabel\" [class]=\"styles.root()\">\n @if (hasTrail) {\n <ol [class]=\"styles.list()\">\n @for (item of data.items; track $index; let first = $first) {\n @if (!first) {\n <li aria-hidden=\"true\" [class]=\"styles.separator()\">\n <svg [size]=\"14\" lucideChevronRight></svg>\n </li>\n }\n <li class=\"inline-flex items-center\">\n @if (isCurrent($index)) {\n <span aria-current=\"page\" [class]=\"styles.current()\">{{ item.label | mnTranslate }}</span>\n } @else if (item.href) {\n <a [attr.href]=\"item.href\" [class]=\"styles.link()\" (click)=\"onCrumb(item)\">{{ item.label | mnTranslate }}</a>\n } @else {\n <button type=\"button\" [class]=\"styles.link()\" (click)=\"onCrumb(item)\">{{ item.label | mnTranslate }}</button>\n }\n </li>\n }\n </ol>\n } @else if (data.backHref) {\n <a [attr.href]=\"data.backHref\" [class]=\"styles.back()\" (click)=\"onBack()\">\n <svg [size]=\"16\" lucideChevronLeft></svg>\n {{ backLabel }}\n </a>\n } @else {\n <button type=\"button\" [class]=\"styles.back()\" (click)=\"onBack()\">\n <svg [size]=\"16\" lucideChevronLeft></svg>\n {{ backLabel }}\n </button>\n }\n</nav>\n", dependencies: [{ kind: "component", type: LucideChevronLeft, selector: "svg[lucideChevronLeft]" }, { kind: "component", type: LucideChevronRight, selector: "svg[lucideChevronRight]" }, { kind: "pipe", type: MnTranslatePipe, name: "mnTranslate" }] });
13258
13816
  }
13259
13817
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnBreadcrumbs, decorators: [{
13260
13818
  type: Component,
13261
- args: [{ selector: 'mn-breadcrumbs', standalone: true, imports: [MnTranslatePipe, LucideChevronLeft, LucideChevronRight], template: "<nav aria-label=\"Breadcrumb\" [class]=\"styles.root()\">\n @if (hasTrail) {\n <ol [class]=\"styles.list()\">\n @for (item of data.items; track $index; let first = $first) {\n @if (!first) {\n <li aria-hidden=\"true\" [class]=\"styles.separator()\">\n <svg [size]=\"14\" lucideChevronRight></svg>\n </li>\n }\n <li class=\"inline-flex items-center\">\n @if (isCurrent($index)) {\n <span aria-current=\"page\" [class]=\"styles.current()\">{{ item.label | mnTranslate }}</span>\n } @else if (item.href) {\n <a [attr.href]=\"item.href\" [class]=\"styles.link()\" (click)=\"onCrumb(item)\">{{ item.label | mnTranslate }}</a>\n } @else {\n <button type=\"button\" [class]=\"styles.link()\" (click)=\"onCrumb(item)\">{{ item.label | mnTranslate }}</button>\n }\n </li>\n }\n </ol>\n } @else if (data.backHref) {\n <a [attr.href]=\"data.backHref\" [class]=\"styles.back()\" (click)=\"onBack()\">\n <svg [size]=\"16\" lucideChevronLeft></svg>\n {{ backLabel | mnTranslate }}\n </a>\n } @else {\n <button type=\"button\" [class]=\"styles.back()\" (click)=\"onBack()\">\n <svg [size]=\"16\" lucideChevronLeft></svg>\n {{ backLabel | mnTranslate }}\n </button>\n }\n</nav>\n" }]
13819
+ args: [{ selector: 'mn-breadcrumbs', standalone: true, imports: [MnTranslatePipe, LucideChevronLeft, LucideChevronRight], template: "<nav [attr.aria-label]=\"navLabel\" [class]=\"styles.root()\">\n @if (hasTrail) {\n <ol [class]=\"styles.list()\">\n @for (item of data.items; track $index; let first = $first) {\n @if (!first) {\n <li aria-hidden=\"true\" [class]=\"styles.separator()\">\n <svg [size]=\"14\" lucideChevronRight></svg>\n </li>\n }\n <li class=\"inline-flex items-center\">\n @if (isCurrent($index)) {\n <span aria-current=\"page\" [class]=\"styles.current()\">{{ item.label | mnTranslate }}</span>\n } @else if (item.href) {\n <a [attr.href]=\"item.href\" [class]=\"styles.link()\" (click)=\"onCrumb(item)\">{{ item.label | mnTranslate }}</a>\n } @else {\n <button type=\"button\" [class]=\"styles.link()\" (click)=\"onCrumb(item)\">{{ item.label | mnTranslate }}</button>\n }\n </li>\n }\n </ol>\n } @else if (data.backHref) {\n <a [attr.href]=\"data.backHref\" [class]=\"styles.back()\" (click)=\"onBack()\">\n <svg [size]=\"16\" lucideChevronLeft></svg>\n {{ backLabel }}\n </a>\n } @else {\n <button type=\"button\" [class]=\"styles.back()\" (click)=\"onBack()\">\n <svg [size]=\"16\" lucideChevronLeft></svg>\n {{ backLabel }}\n </button>\n }\n</nav>\n" }]
13262
13820
  }], propDecorators: { data: [{
13263
13821
  type: Input
13264
13822
  }], crumbClick: [{