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.
- package/fesm2022/mn-angular-lib.mjs +601 -43
- package/fesm2022/mn-angular-lib.mjs.map +1 -1
- package/package.json +1 -1
- package/types/mn-angular-lib.d.ts +334 -14
|
@@ -483,6 +483,19 @@ function isTranslatable(value) {
|
|
|
483
483
|
typeof value['$translate'] === 'string');
|
|
484
484
|
}
|
|
485
485
|
|
|
486
|
+
/**
|
|
487
|
+
* Key suffix per CLDR plural category.
|
|
488
|
+
*
|
|
489
|
+
* Only `one` earns a suffix: every locale shipped so far (`en`, `nl`) has exactly the two
|
|
490
|
+
* categories `one` and `other`, and `other` keeps the bare key. A locale with `few`/`many`
|
|
491
|
+
* (Polish, Russian, Arabic) resolves those to the plural until an entry is added here —
|
|
492
|
+
* adding one is the whole change, since the lookup is category-driven already.
|
|
493
|
+
*/
|
|
494
|
+
const PLURAL_SUFFIX = {
|
|
495
|
+
one: 'One',
|
|
496
|
+
};
|
|
497
|
+
/** Params key whose presence turns a translation into a plural-aware lookup. */
|
|
498
|
+
const COUNT_PARAM = 'count';
|
|
486
499
|
class MnLanguageService {
|
|
487
500
|
http = inject(HttpClient);
|
|
488
501
|
appRef = inject(ApplicationRef);
|
|
@@ -490,6 +503,11 @@ class MnLanguageService {
|
|
|
490
503
|
_locale$ = new BehaviorSubject('en');
|
|
491
504
|
_urlPattern = null;
|
|
492
505
|
_debug = false;
|
|
506
|
+
/**
|
|
507
|
+
* `Intl.PluralRules` per locale. Cached because {@link translate} runs on every change
|
|
508
|
+
* detection through the impure `mnTranslate` pipe, and constructing one is not cheap.
|
|
509
|
+
*/
|
|
510
|
+
_pluralRules = new Map();
|
|
493
511
|
/** Observable of the current active locale. */
|
|
494
512
|
locale$ = this._locale$.asObservable();
|
|
495
513
|
/** Current active locale. */
|
|
@@ -567,10 +585,22 @@ class MnLanguageService {
|
|
|
567
585
|
* Falls back to the key itself if no translation is found.
|
|
568
586
|
*
|
|
569
587
|
* Interpolation replaces `{{paramName}}` with the provided value.
|
|
588
|
+
*
|
|
589
|
+
* A `count` param additionally selects the wording that agrees with it: the key is
|
|
590
|
+
* resolved against its CLDR plural category first (`key` + `One`/`Two`/`Few`/`Many`/
|
|
591
|
+
* `Zero`), falling back to `key` when that sibling is undefined. Nothing has to opt in —
|
|
592
|
+
* a key with no sibling behaves exactly as before.
|
|
593
|
+
*
|
|
594
|
+
* ```ts
|
|
595
|
+
* // 'shift.asked' → '{{count}} members are notified'
|
|
596
|
+
* // 'shift.askedOne' → '{{count}} member is notified'
|
|
597
|
+
* lang.translate('shift.asked', { count: 3 }); // 3 members are notified
|
|
598
|
+
* lang.translate('shift.asked', { count: 1 }); // 1 member is notified
|
|
599
|
+
* ```
|
|
570
600
|
*/
|
|
571
601
|
translate(key, params) {
|
|
572
602
|
const map = this._translations[this.locale] ?? {};
|
|
573
|
-
let value = this.getValueFromMap(map, key);
|
|
603
|
+
let value = this.getValueFromMap(map, this.resolvePluralKey(map, key, params));
|
|
574
604
|
if (value === undefined) {
|
|
575
605
|
if (this._debug) {
|
|
576
606
|
console.warn(`[MnLanguage] Missing translation for key: "${key}" in locale: "${this.locale}"`);
|
|
@@ -584,12 +614,64 @@ class MnLanguageService {
|
|
|
584
614
|
}
|
|
585
615
|
return value;
|
|
586
616
|
}
|
|
617
|
+
/**
|
|
618
|
+
* Picks the wording that agrees with a `count` param.
|
|
619
|
+
*
|
|
620
|
+
* A key carrying a count resolves against its CLDR plural category first, so
|
|
621
|
+
* `askedMessage` + `askedMessageOne` render "3 leden krijgen bericht" and "1 lid krijgt
|
|
622
|
+
* bericht" off the same call. Both languages change the verb as well as the noun, which
|
|
623
|
+
* is why each form is a whole sentence under its own key rather than a swapped noun.
|
|
624
|
+
*
|
|
625
|
+
* Falls back to `key` whenever the sibling is undefined, so a key that never needed a
|
|
626
|
+
* plural — or an app that has not written one yet — behaves exactly as it did before.
|
|
627
|
+
* @param map The active locale's translations.
|
|
628
|
+
* @param key The dot-notated translation key.
|
|
629
|
+
* @param params The interpolation values, inspected for `count`.
|
|
630
|
+
* @returns The key to look up: the plural sibling, or `key` itself.
|
|
631
|
+
*/
|
|
632
|
+
resolvePluralKey(map, key, params) {
|
|
633
|
+
const raw = params?.[COUNT_PARAM];
|
|
634
|
+
if (raw === undefined)
|
|
635
|
+
return key;
|
|
636
|
+
// A count off a JSON payload arrives as a string often enough that comparing it
|
|
637
|
+
// strictly would silently pick the plural for a count of one.
|
|
638
|
+
const count = Number(raw);
|
|
639
|
+
if (!Number.isFinite(count))
|
|
640
|
+
return key;
|
|
641
|
+
const suffix = PLURAL_SUFFIX[this.pluralCategory(count)];
|
|
642
|
+
if (suffix === undefined)
|
|
643
|
+
return key;
|
|
644
|
+
const variant = key + suffix;
|
|
645
|
+
return this.getValueFromMap(map, variant) !== undefined ? variant : key;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* The CLDR plural category of a count in the active locale.
|
|
649
|
+
* @param count The count being quoted.
|
|
650
|
+
* @returns The category, falling back to English rules for an unusable locale.
|
|
651
|
+
*/
|
|
652
|
+
pluralCategory(count) {
|
|
653
|
+
if (!this._pluralRules.has(this.locale)) {
|
|
654
|
+
try {
|
|
655
|
+
this._pluralRules.set(this.locale, new Intl.PluralRules(this.locale));
|
|
656
|
+
}
|
|
657
|
+
catch {
|
|
658
|
+
// An unknown or malformed locale tag: fall back rather than break every string.
|
|
659
|
+
this._pluralRules.set(this.locale, null);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
const rules = this._pluralRules.get(this.locale);
|
|
663
|
+
if (!rules)
|
|
664
|
+
return count === 1 ? 'one' : 'other';
|
|
665
|
+
return rules.select(count);
|
|
666
|
+
}
|
|
587
667
|
/**
|
|
588
668
|
* Helper to retrieve a value from a potentially nested translation map using a dot-notated key.
|
|
589
669
|
*/
|
|
590
670
|
getValueFromMap(map, key) {
|
|
591
|
-
|
|
592
|
-
|
|
671
|
+
// A flattened bundle holds the dotted key verbatim; a nested one is walked below.
|
|
672
|
+
const direct = map[key];
|
|
673
|
+
if (typeof direct === 'string')
|
|
674
|
+
return direct;
|
|
593
675
|
const parts = key.split('.');
|
|
594
676
|
let current = map;
|
|
595
677
|
for (const part of parts) {
|
|
@@ -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 ??
|
|
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\">{{
|
|
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
|
-
|
|
5622
|
-
|
|
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
|
|
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
|
-
|
|
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(() =>
|
|
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
|
-
// ==========
|
|
5681
|
-
|
|
5682
|
-
|
|
5683
|
-
|
|
5684
|
-
|
|
5685
|
-
|
|
5686
|
-
onSelectChange(event) {
|
|
5687
|
-
const target = event.target;
|
|
5688
|
-
const index = parseInt(target.value, 10);
|
|
5689
|
-
if (isNaN(index) || index < 0 || index >= this.props.options.length) {
|
|
5690
|
-
this.selectedValue = null;
|
|
5691
|
-
this.onChange(null);
|
|
5969
|
+
// ========== Dropdown Logic ==========
|
|
5970
|
+
toggle() {
|
|
5971
|
+
if (this.isDisabled)
|
|
5972
|
+
return;
|
|
5973
|
+
if (this.isOpen) {
|
|
5974
|
+
this.close();
|
|
5692
5975
|
return;
|
|
5693
5976
|
}
|
|
5694
|
-
|
|
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
|
-
|
|
5712
|
-
|
|
5713
|
-
|
|
5714
|
-
|
|
5715
|
-
|
|
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
|
|
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 <
|
|
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:
|
|
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:
|
|
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
|
-
/**
|
|
13224
|
-
static
|
|
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
|
-
/**
|
|
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
|
-
|
|
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=\"
|
|
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=\"
|
|
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: [{
|