mn-angular-lib 1.0.133 → 1.0.134
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 +360 -165
- package/fesm2022/mn-angular-lib.mjs.map +1 -1
- package/package.json +1 -1
- package/types/mn-angular-lib.d.ts +208 -58
|
@@ -9,7 +9,7 @@ import * as i1$1 from '@angular/forms';
|
|
|
9
9
|
import { NgControl, Validators, FormsModule, NG_VALUE_ACCESSOR, FormBuilder, ReactiveFormsModule } from '@angular/forms';
|
|
10
10
|
import { HttpClient, HttpErrorResponse, HttpStatusCode, HttpParams } from '@angular/common/http';
|
|
11
11
|
import JSON5 from 'json5';
|
|
12
|
-
import { LucideFile, LucideImagePlus, LucideTrash2, LucideUpload, LucideX, LucideCalendarDays, LucideChevronLeft, LucideChevronRight, LucideArrowLeft, LucideArrowRight, LucideCheck, LucideInbox, LucideFilter, LucideDynamicIcon, LucideFunnel } from '@lucide/angular';
|
|
12
|
+
import { LucideFile, LucideImagePlus, LucideTrash2, LucideUpload, LucideX, LucideCalendarDays, LucideChevronLeft, LucideChevronRight, LucideChevronDown, LucideArrowLeft, LucideArrowRight, LucideCheck, LucideInbox, LucideFilter, LucideDynamicIcon, LucideFunnel, LucideTriangleAlert, LucideCircleAlert } from '@lucide/angular';
|
|
13
13
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
14
14
|
import { DomSanitizer } from '@angular/platform-browser';
|
|
15
15
|
|
|
@@ -3814,11 +3814,11 @@ class MnMultiSelect {
|
|
|
3814
3814
|
});
|
|
3815
3815
|
}
|
|
3816
3816
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnMultiSelect, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
3817
|
-
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 }], 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 <div class=\"flex flex-row items-center gap-x-2 flex-wrap min-h-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 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 <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <span
|
|
3817
|
+
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 }], 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 <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\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 cursor-pointer\"\n (click)=\"removeOption(opt, $event)\">\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 <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n #dropdown\n [id]=\"resolvedId + '-listbox'\"\n aria-multiselectable=\"true\"\n role=\"listbox\"\n class=\"fixed z-9999 bg-base-100 border border-base-300 rounded-md shadow-lg max-h-60 overflow-auto\"\n [style.top]=\"dropdownStyle.top\"\n [style.left]=\"dropdownStyle.left\"\n [style.width]=\"dropdownStyle.width\"\n (click)=\"$event.stopPropagation()\"\n >\n @if (props.searchable) {\n <div class=\"p-2 border-b border-base-300\">\n <input\n type=\"text\"\n class=\"w-full p-1.5 text-sm border border-base-300 rounded-md outline-none focus:border-primary-500 bg-base-200 text-base-content placeholder-base-content/50\"\n [placeholder]=\"props.searchPlaceholder || 'Search...'\"\n [value]=\"searchTerm\"\n (input)=\"onSearch(($any($event.target)).value)\"\n (click)=\"$event.stopPropagation()\"\n />\n </div>\n }\n @for (opt of filteredOptions; track opt.value) {\n <div\n (keyup.enter)=\"toggleOption(opt)\"\n (keyup.space)=\"toggleOption(opt)\"\n [attr.aria-selected]=\"isSelected(opt)\"\n class=\"flex items-center gap-x-2 px-3 py-2 text-sm cursor-pointer text-base-content hover:bg-base-200\"\n [class.opacity-50]=\"opt.disabled || isMaxReached(opt)\"\n [class.pointer-events-none]=\"opt.disabled || isMaxReached(opt)\"\n (click)=\"toggleOption(opt); $event.stopPropagation()\"\n role=\"option\"\n tabindex=\"0\"\n >\n <input\n type=\"checkbox\"\n class=\"w-4 h-4 accent-primary pointer-events-none\"\n [checked]=\"isSelected(opt)\"\n [disabled]=\"opt.disabled || isMaxReached(opt)\"\n tabindex=\"-1\"\n />\n <span>{{ opt.label }}</span>\n </div>\n }\n @if (filteredOptions.length === 0) {\n <div class=\"px-3 py-2 text-sm text-base-content/50\">{{ uiConfig.noOptionsFound || 'No options found' }}</div>\n }\n </div>\n }\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"] }, { kind: "component", type: MnButton, selector: "button[mnButton], a[mnButton]", inputs: ["data"] }, { kind: "component", type: LucideX, selector: "svg[lucideX]" }, { kind: "component", type: LucideChevronDown, selector: "svg[lucideChevronDown]" }] });
|
|
3818
3818
|
}
|
|
3819
3819
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnMultiSelect, decorators: [{
|
|
3820
3820
|
type: Component,
|
|
3821
|
-
args: [{ selector: 'mn-lib-multi-select', standalone: true, imports: [NgClass, MnErrorMessage, MnButton, LucideX], 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 <div class=\"flex flex-row items-center gap-x-2 flex-wrap min-h-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 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 <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <span
|
|
3821
|
+
args: [{ selector: 'mn-lib-multi-select', standalone: true, imports: [NgClass, MnErrorMessage, MnButton, LucideX, LucideChevronDown], template: "<div class=\"flex flex-col h-full\" [class.is-fullwidth]=\"props.fullWidth\">\n @if (uiConfig.label || props.label) {\n <label class=\"pl-2 pb-1 flex flex-row gap-x-0.5! text-base!\" [attr.for]=\"resolvedId\">\n <p>{{ uiConfig.label || props.label }}</p>\n @if (isRequired()) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n }\n\n <!-- Trigger -->\n <div\n #trigger\n [id]=\"resolvedId\"\n [ngClass]=\"triggerClasses\"\n class=\"relative\"\n [attr.aria-label]=\"uiConfig.ariaLabel || uiConfig.label || props.label || null\"\n [attr.aria-invalid]=\"showError || null\"\n [attr.aria-describedby]=\"showError ? resolvedId + '-error' : null\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-controls]=\"isOpen ? resolvedId + '-listbox' : null\"\n role=\"combobox\"\n tabindex=\"0\"\n (click)=\"toggle()\"\n (keydown.enter)=\"toggle()\"\n (keydown.space)=\"toggle(); $event.preventDefault()\"\n (blur)=\"handleBlur()\"\n >\n <!-- `pr-6` reserves the gutter the caret is absolutely positioned in (right-2 +\n w-4), so a value can never render underneath it. `min-w-0` lets the chips\n shrink below their content width, which is what makes truncation possible. -->\n <div class=\"flex flex-row items-center gap-x-2 flex-wrap min-h-6 min-w-0 pr-6\">\n @if (selectedOptions.length === 0) {\n <span class=\"text-base-content/50\">{{ uiConfig.placeholder || props.placeholder || 'Select...' }}</span>\n } @else if (isCollapsed) {\n <span\n class=\"inline-flex items-center max-w-full truncate bg-base-200 border border-accent text-base-content text-xs px-2 py-0.5 rounded-md\">\n {{ collapseSummaryText }}\n </span>\n } @else {\n @for (opt of selectedOptions; track opt.value) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\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 cursor-pointer\"\n (click)=\"removeOption(opt, $event)\">\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 <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n #dropdown\n [id]=\"resolvedId + '-listbox'\"\n aria-multiselectable=\"true\"\n role=\"listbox\"\n class=\"fixed z-9999 bg-base-100 border border-base-300 rounded-md shadow-lg max-h-60 overflow-auto\"\n [style.top]=\"dropdownStyle.top\"\n [style.left]=\"dropdownStyle.left\"\n [style.width]=\"dropdownStyle.width\"\n (click)=\"$event.stopPropagation()\"\n >\n @if (props.searchable) {\n <div class=\"p-2 border-b border-base-300\">\n <input\n type=\"text\"\n class=\"w-full p-1.5 text-sm border border-base-300 rounded-md outline-none focus:border-primary-500 bg-base-200 text-base-content placeholder-base-content/50\"\n [placeholder]=\"props.searchPlaceholder || 'Search...'\"\n [value]=\"searchTerm\"\n (input)=\"onSearch(($any($event.target)).value)\"\n (click)=\"$event.stopPropagation()\"\n />\n </div>\n }\n @for (opt of filteredOptions; track opt.value) {\n <div\n (keyup.enter)=\"toggleOption(opt)\"\n (keyup.space)=\"toggleOption(opt)\"\n [attr.aria-selected]=\"isSelected(opt)\"\n class=\"flex items-center gap-x-2 px-3 py-2 text-sm cursor-pointer text-base-content hover:bg-base-200\"\n [class.opacity-50]=\"opt.disabled || isMaxReached(opt)\"\n [class.pointer-events-none]=\"opt.disabled || isMaxReached(opt)\"\n (click)=\"toggleOption(opt); $event.stopPropagation()\"\n role=\"option\"\n tabindex=\"0\"\n >\n <input\n type=\"checkbox\"\n class=\"w-4 h-4 accent-primary pointer-events-none\"\n [checked]=\"isSelected(opt)\"\n [disabled]=\"opt.disabled || isMaxReached(opt)\"\n tabindex=\"-1\"\n />\n <span>{{ opt.label }}</span>\n </div>\n }\n @if (filteredOptions.length === 0) {\n <div class=\"px-3 py-2 text-sm text-base-content/50\">{{ uiConfig.noOptionsFound || 'No options found' }}</div>\n }\n </div>\n }\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" }]
|
|
3822
3822
|
}], ctorParameters: () => [], propDecorators: { props: [{
|
|
3823
3823
|
type: Input,
|
|
3824
3824
|
args: [{ required: true }]
|
|
@@ -5567,6 +5567,7 @@ class MnCollectionBase {
|
|
|
5567
5567
|
// Skip the initial BehaviorSubject emission (already handled above).
|
|
5568
5568
|
this.dataSubscription = this.dataSource.dataRows.pipe(skip(1)).subscribe(() => {
|
|
5569
5569
|
this.applyFilter(false);
|
|
5570
|
+
this.onRowsChanged();
|
|
5570
5571
|
this.cdr.markForCheck();
|
|
5571
5572
|
});
|
|
5572
5573
|
this.searchSubscription = this.searchSubject
|
|
@@ -5664,6 +5665,14 @@ class MnCollectionBase {
|
|
|
5664
5665
|
beforeInitialFilter() {
|
|
5665
5666
|
// no-op by default
|
|
5666
5667
|
}
|
|
5668
|
+
/**
|
|
5669
|
+
* Runs after a fresh batch of rows has been filtered in, for work that needs the
|
|
5670
|
+
* rows to exist. Subclasses override to react to data arriving late; call `super`
|
|
5671
|
+
* to keep the default (currently nothing).
|
|
5672
|
+
*/
|
|
5673
|
+
onRowsChanged() {
|
|
5674
|
+
// no-op by default
|
|
5675
|
+
}
|
|
5667
5676
|
/**
|
|
5668
5677
|
* Resolves translation keys to display strings via {@link MnLanguageService}.
|
|
5669
5678
|
* Subclasses override to resolve their own keys; call `super` to keep these.
|
|
@@ -5810,9 +5819,96 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImpor
|
|
|
5810
5819
|
class MnSelectableCollectionBase extends MnCollectionBase {
|
|
5811
5820
|
selectionChange = new EventEmitter();
|
|
5812
5821
|
selectedIds = new Set();
|
|
5822
|
+
/** Whether the summary is currently showing every tag rather than the first few. */
|
|
5823
|
+
selectionSummaryExpanded = false;
|
|
5813
5824
|
get allSelected() {
|
|
5814
5825
|
return this.filteredItems.length > 0 && this.selectedIds.size === this.filteredItems.length;
|
|
5815
5826
|
}
|
|
5827
|
+
/**
|
|
5828
|
+
* The row behind every selected id, kept so the selection survives the rows
|
|
5829
|
+
* themselves going away.
|
|
5830
|
+
*
|
|
5831
|
+
* {@link selectedIds} alone is enough to tick a checkbox, because that only ever
|
|
5832
|
+
* asks about a row already on screen. The summary asks the opposite question —
|
|
5833
|
+
* "what is selected, including what isn't on this page?" — and a server-paginated
|
|
5834
|
+
* collection has long since discarded those rows. Rows are captured as they are
|
|
5835
|
+
* selected and backfilled from {@link MnSelectableCollectionDataSource.initialSelectedRows}
|
|
5836
|
+
* and from each batch that loads.
|
|
5837
|
+
*/
|
|
5838
|
+
selectedRowsById = new Map();
|
|
5839
|
+
/**
|
|
5840
|
+
* Whether a seeded initial selection still has to be announced, because none of
|
|
5841
|
+
* its ids matched a loaded row yet. See {@link beforeInitialFilter}.
|
|
5842
|
+
*/
|
|
5843
|
+
pendingInitialEmit = false;
|
|
5844
|
+
/**
|
|
5845
|
+
* The ids that arrived pre-selected, kept apart from {@link selectedIds} so
|
|
5846
|
+
* {@link prioritizeInitialSelection} has a set that does **not** move as the user
|
|
5847
|
+
* clicks. Null when the collection opened with nothing selected.
|
|
5848
|
+
*/
|
|
5849
|
+
pinnedSelectionIds = null;
|
|
5850
|
+
/**
|
|
5851
|
+
* Every selected row, in selection order, for the summary. Ids whose row was
|
|
5852
|
+
* never seen are skipped rather than rendered as a bare id.
|
|
5853
|
+
*/
|
|
5854
|
+
get selectedSummaryRows() {
|
|
5855
|
+
const rows = [];
|
|
5856
|
+
for (const id of this.selectedIds) {
|
|
5857
|
+
const row = this.selectedRowsById.get(id);
|
|
5858
|
+
if (row !== undefined)
|
|
5859
|
+
rows.push(row);
|
|
5860
|
+
}
|
|
5861
|
+
return rows;
|
|
5862
|
+
}
|
|
5863
|
+
/** Whether the selection summary should render. */
|
|
5864
|
+
get showSelectionSummary() {
|
|
5865
|
+
return !!this.dataSource.selectionSummary && this.hasSelection && this.selectedIds.size > 0;
|
|
5866
|
+
}
|
|
5867
|
+
/** How many tags to show before collapsing the remainder. */
|
|
5868
|
+
get selectionSummaryLimit() {
|
|
5869
|
+
return this.dataSource.selectionSummaryLimit ?? 8;
|
|
5870
|
+
}
|
|
5871
|
+
/**
|
|
5872
|
+
* The tags to render: the first {@link selectionSummaryLimit} rows, or all of them
|
|
5873
|
+
* once expanded. Keeps a large selection from turning the header into the page.
|
|
5874
|
+
*/
|
|
5875
|
+
get visibleSelectionRows() {
|
|
5876
|
+
const rows = this.selectedSummaryRows;
|
|
5877
|
+
return this.selectionSummaryExpanded ? rows : rows.slice(0, this.selectionSummaryLimit);
|
|
5878
|
+
}
|
|
5879
|
+
/** How many selected rows are collapsed out of view; 0 when all are shown. */
|
|
5880
|
+
get hiddenSelectionCount() {
|
|
5881
|
+
if (this.selectionSummaryExpanded)
|
|
5882
|
+
return 0;
|
|
5883
|
+
return Math.max(0, this.selectedSummaryRows.length - this.selectionSummaryLimit);
|
|
5884
|
+
}
|
|
5885
|
+
/** Expands or re-collapses the summary's tag list. */
|
|
5886
|
+
toggleSelectionSummary() {
|
|
5887
|
+
this.selectionSummaryExpanded = !this.selectionSummaryExpanded;
|
|
5888
|
+
this.cdr.markForCheck();
|
|
5889
|
+
}
|
|
5890
|
+
/**
|
|
5891
|
+
* The label for a row in the summary: the consumer's {@link
|
|
5892
|
+
* MnSelectableCollectionDataSource.selectionLabel}, else the first column that
|
|
5893
|
+
* renders a plain string, else the row's id.
|
|
5894
|
+
* @param row The selected row.
|
|
5895
|
+
* @returns The text to show on the row's tag.
|
|
5896
|
+
*/
|
|
5897
|
+
selectionLabelFor(row) {
|
|
5898
|
+
const custom = this.dataSource.selectionLabel;
|
|
5899
|
+
if (custom)
|
|
5900
|
+
return custom(row);
|
|
5901
|
+
return this.defaultSelectionLabel(row) ?? this.dataSource.getID(row);
|
|
5902
|
+
}
|
|
5903
|
+
/** Removes one row from the selection, from its tag in the summary. */
|
|
5904
|
+
removeSelection(row) {
|
|
5905
|
+
const id = this.dataSource.getID(row);
|
|
5906
|
+
if (!this.selectedIds.delete(id))
|
|
5907
|
+
return;
|
|
5908
|
+
this.selectedRowsById.delete(id);
|
|
5909
|
+
this.emitSelection();
|
|
5910
|
+
this.cdr.markForCheck();
|
|
5911
|
+
}
|
|
5816
5912
|
get hasSelection() {
|
|
5817
5913
|
return (this.dataSource.selectionMode ?? 'none') !== 'none';
|
|
5818
5914
|
}
|
|
@@ -5822,19 +5918,32 @@ class MnSelectableCollectionBase extends MnCollectionBase {
|
|
|
5822
5918
|
isSelected(item) {
|
|
5823
5919
|
return this.selectedIds.has(this.dataSource.getID(item));
|
|
5824
5920
|
}
|
|
5921
|
+
/** Clears the whole selection, from the summary's clear-all action. */
|
|
5922
|
+
clearSelection() {
|
|
5923
|
+
if (this.selectedIds.size === 0)
|
|
5924
|
+
return;
|
|
5925
|
+
this.selectedIds.clear();
|
|
5926
|
+
this.selectedRowsById.clear();
|
|
5927
|
+
this.emitSelection();
|
|
5928
|
+
this.cdr.markForCheck();
|
|
5929
|
+
}
|
|
5825
5930
|
toggle(item) {
|
|
5826
5931
|
const id = this.dataSource.getID(item);
|
|
5827
5932
|
const mode = this.dataSource.selectionMode ?? 'none';
|
|
5828
5933
|
if (mode === 'single') {
|
|
5829
5934
|
this.selectedIds.clear();
|
|
5935
|
+
this.selectedRowsById.clear();
|
|
5830
5936
|
this.selectedIds.add(id);
|
|
5937
|
+
this.selectedRowsById.set(id, item);
|
|
5831
5938
|
}
|
|
5832
5939
|
else if (mode === 'multi') {
|
|
5833
5940
|
if (this.selectedIds.has(id)) {
|
|
5834
5941
|
this.selectedIds.delete(id);
|
|
5942
|
+
this.selectedRowsById.delete(id);
|
|
5835
5943
|
}
|
|
5836
5944
|
else {
|
|
5837
5945
|
this.selectedIds.add(id);
|
|
5946
|
+
this.selectedRowsById.set(id, item);
|
|
5838
5947
|
}
|
|
5839
5948
|
}
|
|
5840
5949
|
this.emitSelection();
|
|
@@ -5842,27 +5951,108 @@ class MnSelectableCollectionBase extends MnCollectionBase {
|
|
|
5842
5951
|
toggleAll() {
|
|
5843
5952
|
if (this.selectedIds.size === this.filteredItems.length) {
|
|
5844
5953
|
this.selectedIds.clear();
|
|
5954
|
+
this.selectedRowsById.clear();
|
|
5845
5955
|
}
|
|
5846
5956
|
else {
|
|
5847
|
-
this.filteredItems.forEach(item =>
|
|
5957
|
+
this.filteredItems.forEach(item => {
|
|
5958
|
+
const id = this.dataSource.getID(item);
|
|
5959
|
+
this.selectedIds.add(id);
|
|
5960
|
+
this.selectedRowsById.set(id, item);
|
|
5961
|
+
});
|
|
5848
5962
|
}
|
|
5849
5963
|
this.emitSelection();
|
|
5850
5964
|
}
|
|
5965
|
+
/**
|
|
5966
|
+
* Fallback label used when the data source declares no `selectionLabel`.
|
|
5967
|
+
* Subclasses that know how to render a row as text (a table knows its columns)
|
|
5968
|
+
* override this; the base has nothing to go on.
|
|
5969
|
+
* @returns The label, or null when none can be derived.
|
|
5970
|
+
*/
|
|
5971
|
+
defaultSelectionLabel(_row) {
|
|
5972
|
+
return null;
|
|
5973
|
+
}
|
|
5974
|
+
/**
|
|
5975
|
+
* Reorders rows so the ones that were already selected when the collection
|
|
5976
|
+
* opened come first, preserving the incoming order within each group.
|
|
5977
|
+
*
|
|
5978
|
+
* Deliberately keyed on the *initial* selection rather than the live one: pinning
|
|
5979
|
+
* what the user is currently ticking would make a row jump to the top the instant
|
|
5980
|
+
* it is clicked, moving the next row under the pointer mid-click. Freezing the set
|
|
5981
|
+
* answers the actual question — "what was already chosen?" — and leaves the list
|
|
5982
|
+
* still while it is being worked with. A row deselected during the session keeps
|
|
5983
|
+
* its place for the same reason.
|
|
5984
|
+
*
|
|
5985
|
+
* Callers apply this only when no explicit sort is active, so a sorted column
|
|
5986
|
+
* always wins.
|
|
5987
|
+
* @param items The rows in their current order.
|
|
5988
|
+
* @returns The rows with the initially-selected ones hoisted to the top.
|
|
5989
|
+
*/
|
|
5990
|
+
prioritizeInitialSelection(items) {
|
|
5991
|
+
const pinned = this.pinnedSelectionIds;
|
|
5992
|
+
if (!pinned?.size)
|
|
5993
|
+
return items;
|
|
5994
|
+
const selected = [];
|
|
5995
|
+
const rest = [];
|
|
5996
|
+
for (const item of items) {
|
|
5997
|
+
(pinned.has(this.dataSource.getID(item)) ? selected : rest).push(item);
|
|
5998
|
+
}
|
|
5999
|
+
// Nothing to hoist (e.g. the pinned rows are on another server-side page).
|
|
6000
|
+
return selected.length === 0 ? items : [...selected, ...rest];
|
|
6001
|
+
}
|
|
5851
6002
|
/** Seeds selection from `initialSelectedIds` before the first filter pass. */
|
|
5852
6003
|
beforeInitialFilter() {
|
|
5853
6004
|
super.beforeInitialFilter();
|
|
5854
|
-
if (this.dataSource.initialSelectedIds?.length)
|
|
5855
|
-
|
|
5856
|
-
|
|
5857
|
-
|
|
5858
|
-
|
|
6005
|
+
if (!this.dataSource.initialSelectedIds?.length)
|
|
6006
|
+
return;
|
|
6007
|
+
for (const id of this.dataSource.initialSelectedIds) {
|
|
6008
|
+
this.selectedIds.add(id);
|
|
6009
|
+
}
|
|
6010
|
+
this.pinnedSelectionIds = new Set(this.dataSource.initialSelectedIds);
|
|
6011
|
+
for (const row of this.dataSource.initialSelectedRows ?? []) {
|
|
6012
|
+
this.selectedRowsById.set(this.dataSource.getID(row), row);
|
|
6013
|
+
}
|
|
6014
|
+
this.captureSelectedRows();
|
|
6015
|
+
// The rows are commonly still in flight at this point (any server fetch), and
|
|
6016
|
+
// then no id resolves to a row yet. Emitting that would announce "nothing is
|
|
6017
|
+
// selected" and let the consumer — a form control bound to this table, say —
|
|
6018
|
+
// overwrite the very value it just seeded us with. Defer instead, and announce
|
|
6019
|
+
// it from {@link onRowsChanged} once the rows actually arrive.
|
|
6020
|
+
if (this.resolveSelectedRows().length === 0) {
|
|
6021
|
+
this.pendingInitialEmit = true;
|
|
6022
|
+
return;
|
|
5859
6023
|
}
|
|
6024
|
+
this.emitSelection();
|
|
6025
|
+
}
|
|
6026
|
+
/** Announces a deferred initial selection as soon as its rows are loaded. */
|
|
6027
|
+
onRowsChanged() {
|
|
6028
|
+
super.onRowsChanged();
|
|
6029
|
+
// A newly loaded page may hold rows for ids selected before it arrived.
|
|
6030
|
+
this.captureSelectedRows();
|
|
6031
|
+
if (!this.pendingInitialEmit || this.resolveSelectedRows().length === 0)
|
|
6032
|
+
return;
|
|
6033
|
+
this.pendingInitialEmit = false;
|
|
6034
|
+
this.emitSelection();
|
|
5860
6035
|
}
|
|
5861
6036
|
emitSelection() {
|
|
5862
|
-
|
|
6037
|
+
// A real user interaction supersedes any deferred initial announcement.
|
|
6038
|
+
this.pendingInitialEmit = false;
|
|
6039
|
+
const rows = this.resolveSelectedRows();
|
|
5863
6040
|
this.dataSource.selectedRows?.next(rows);
|
|
5864
6041
|
this.selectionChange.emit(rows);
|
|
5865
6042
|
}
|
|
6043
|
+
/** Records the row object for every loaded row that is currently selected. */
|
|
6044
|
+
captureSelectedRows() {
|
|
6045
|
+
for (const row of this.dataSource.dataRows.value ?? []) {
|
|
6046
|
+
const id = this.dataSource.getID(row);
|
|
6047
|
+
if (this.selectedIds.has(id))
|
|
6048
|
+
this.selectedRowsById.set(id, row);
|
|
6049
|
+
}
|
|
6050
|
+
}
|
|
6051
|
+
/** The currently loaded rows whose id is selected. */
|
|
6052
|
+
resolveSelectedRows() {
|
|
6053
|
+
return (this.dataSource.dataRows.value ?? [])
|
|
6054
|
+
.filter(r => this.selectedIds.has(this.dataSource.getID(r)));
|
|
6055
|
+
}
|
|
5866
6056
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnSelectableCollectionBase, deps: null, target: i0.ɵɵFactoryTarget.Directive });
|
|
5867
6057
|
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.1.3", type: MnSelectableCollectionBase, isStandalone: true, outputs: { selectionChange: "selectionChange" }, usesInheritance: true, ngImport: i0 });
|
|
5868
6058
|
}
|
|
@@ -5960,11 +6150,11 @@ class MnCollectionPagination {
|
|
|
5960
6150
|
return Object.entries(params).reduce((result, [key, value]) => result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), String(value)), template);
|
|
5961
6151
|
}
|
|
5962
6152
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnCollectionPagination, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
5963
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnCollectionPagination, isStandalone: true, selector: "mn-collection-pagination", inputs: { idPrefix: "idPrefix", isPaginated: "isPaginated", isServerPaginated: "isServerPaginated", showLoadMore: "showLoadMore", loadingMoreRows: "loadingMoreRows", currentPage: "currentPage", pageSize: "pageSize", totalPages: "totalPages", totalItemCount: "totalItemCount", visiblePages: "visiblePages", pageSizeSelectOptions: "pageSizeSelectOptions", labels: "labels" }, outputs: { loadMore: "loadMore", pageChange: "pageChange", pageSizeChange: "pageSizeChange" }, ngImport: i0, template: "<!-- Load more button -->\n@if (showLoadMore) {\n <div class=\"flex justify-center py-4\">\n <button\n (click)=\"loadMore.emit()\"\n [data]=\"{ size: 'sm', variant: 'outline', color: 'primary' }\"\n [disabled]=\"loadingMoreRows\"\n class=\"px-4 py-1.5 text-sm rounded border border-primary-500 text-primary-500 hover:bg-primary-100 transition-colors disabled:opacity-50\"\n mnButton\n >\n @if (loadingMoreRows) {\n <span\n class=\"inline-block w-3 h-3 border-2 border-primary-500 border-t-transparent rounded-full animate-spin mr-2\"></span>\n }\n {{ labels?.loadMore || 'Load more' }}\n </button>\n </div>\n}\n\n<!-- Pagination controls -->\n@if (showPagination) {\n <div class=\"flex items-center justify-between gap-2 px-2 py-3 text-sm text-base-content\">\n @if (pageSizeSelectOptions.length > 1) {\n <div class=\"hidden md:flex items-center gap-2\">\n <span>{{ labels?.rowsPerPage || 'Items per page:' }}</span>\n <mn-lib-select\n (ngModelChange)=\"pageSizeChange.emit($event)\"\n [ngModel]=\"pageSize\"\n [props]=\"{\n id: idPrefix + '-page-size',\n options: pageSizeSelectOptions,\n size: 'sm'\n }\"\n ></mn-lib-select>\n </div>\n } @else {\n <div class=\"hidden md:block\"></div>\n }\n\n <!-- Narrow viewports state the page position outright: the strip's last-page\n anchor only appears once the window stops reaching the end, so it can't\n be relied on to carry the total. -->\n <span aria-live=\"polite\" class=\"md:hidden text-xs opacity-70 whitespace-nowrap\">{{ pageIndicatorLabel }}</span>\n\n <div class=\"flex flex-wrap items-center justify-center gap-0.5 md:gap-1\">\n <span class=\"text-xs mr-2 hidden md:inline\">{{ itemRangeLabel }}</span>\n\n <button\n (click)=\"pageChange.emit(1)\"\n [data]=\"{ size: 'sm', variant: 'outline', color: 'secondary', disabled: currentPage === 1 }\"\n [disabled]=\"currentPage === 1\"\n aria-label=\"First page\"\n class=\"px-2 py-1 rounded border border-base-300 hover:bg-base-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed aspect-square leading-none\"\n mnButton\n >\u00AB\n </button>\n\n <button\n (click)=\"pageChange.emit(currentPage - 1)\"\n [data]=\"{ size: 'sm', variant: 'outline', color: 'secondary', disabled: currentPage === 1 }\"\n [disabled]=\"currentPage === 1\"\n aria-label=\"Previous page\"\n class=\"px-2 py-1 rounded border border-base-300 hover:bg-base-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed aspect-square leading-none\"\n mnButton\n >\u2039\n </button>\n\n @for (slot of pageSlots; track $index) {\n <span [class]=\"slotVisibility(slot)\">\n @if (slot.page !== null) {\n <button\n (click)=\"pageChange.emit(slot.page)\"\n [attr.aria-current]=\"slot.page === currentPage ? 'page' : null\"\n [attr.aria-label]=\"'Page ' + slot.page\"\n [data]=\"{ size: 'sm', variant: 'text', color: slot.page === currentPage ? 'primary' : 'gray' }\"\n class=\"px-1.5 md:px-2.5 py-1 rounded underline underline-offset-2 transition-colors text-xs\"\n mnButton\n >{{ slot.page }}\n </button>\n } @else {\n <span aria-hidden=\"true\" class=\"px-0.5 text-xs opacity-50 select-none\">\u2026</span>\n }\n </span>\n }\n\n <button\n (click)=\"pageChange.emit(currentPage + 1)\"\n [data]=\"{ size: 'sm', variant: 'outline', color: 'secondary', disabled: currentPage === totalPages }\"\n [disabled]=\"currentPage === totalPages\"\n aria-label=\"Next page\"\n class=\"px-2 py-1 rounded border border-base-300 hover:bg-base-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed aspect-square leading-none\"\n mnButton\n >\u203A\n </button>\n\n <button\n (click)=\"pageChange.emit(totalPages)\"\n [data]=\"{ size: 'sm', variant: 'outline', color: 'secondary', disabled: currentPage === totalPages }\"\n [disabled]=\"currentPage === totalPages\"\n aria-label=\"Last page\"\n class=\"px-2 py-1 rounded border border-base-300 hover:bg-base-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed aspect-square leading-none\"\n mnButton\n >\u00BB\n </button>\n </div>\n </div>\n}\n", dependencies: [{ kind: "component", type: MnButton, selector: "button[mnButton], a[mnButton]", inputs: ["data"] }, { kind: "component", type: MnSelect, selector: "mn-lib-select", inputs: ["props"] }, { 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"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
6153
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnCollectionPagination, isStandalone: true, selector: "mn-collection-pagination", inputs: { idPrefix: "idPrefix", isPaginated: "isPaginated", isServerPaginated: "isServerPaginated", showLoadMore: "showLoadMore", loadingMoreRows: "loadingMoreRows", currentPage: "currentPage", pageSize: "pageSize", totalPages: "totalPages", totalItemCount: "totalItemCount", visiblePages: "visiblePages", pageSizeSelectOptions: "pageSizeSelectOptions", labels: "labels" }, outputs: { loadMore: "loadMore", pageChange: "pageChange", pageSizeChange: "pageSizeChange" }, ngImport: i0, template: "<!-- Load more button -->\n@if (showLoadMore) {\n <div class=\"flex justify-center py-4\">\n <button\n (click)=\"loadMore.emit()\"\n [data]=\"{ size: 'sm', variant: 'outline', color: 'primary' }\"\n [disabled]=\"loadingMoreRows\"\n class=\"px-4 py-1.5 text-sm rounded border border-primary-500 text-primary-500 hover:bg-primary-100 transition-colors disabled:opacity-50\"\n mnButton\n type=\"button\"\n >\n @if (loadingMoreRows) {\n <span\n class=\"inline-block w-3 h-3 border-2 border-primary-500 border-t-transparent rounded-full animate-spin mr-2\"></span>\n }\n {{ labels?.loadMore || 'Load more' }}\n </button>\n </div>\n}\n\n<!-- Pagination controls -->\n@if (showPagination) {\n <div class=\"flex items-center justify-between gap-2 px-2 py-3 text-sm text-base-content\">\n @if (pageSizeSelectOptions.length > 1) {\n <div class=\"hidden md:flex items-center gap-2\">\n <span>{{ labels?.rowsPerPage || 'Items per page:' }}</span>\n <mn-lib-select\n (ngModelChange)=\"pageSizeChange.emit($event)\"\n [ngModel]=\"pageSize\"\n [props]=\"{\n id: idPrefix + '-page-size',\n options: pageSizeSelectOptions,\n size: 'sm'\n }\"\n ></mn-lib-select>\n </div>\n } @else {\n <div class=\"hidden md:block\"></div>\n }\n\n <!-- Narrow viewports state the page position outright: the strip's last-page\n anchor only appears once the window stops reaching the end, so it can't\n be relied on to carry the total. -->\n <span aria-live=\"polite\" class=\"md:hidden text-xs opacity-70 whitespace-nowrap\">{{ pageIndicatorLabel }}</span>\n\n <div class=\"flex flex-wrap items-center justify-center gap-0.5 md:gap-1\">\n <span class=\"text-xs mr-2 hidden md:inline\">{{ itemRangeLabel }}</span>\n\n <button\n (click)=\"pageChange.emit(1)\"\n [data]=\"{ size: 'sm', variant: 'outline', color: 'secondary', disabled: currentPage === 1 }\"\n [disabled]=\"currentPage === 1\"\n aria-label=\"First page\"\n class=\"px-2 py-1 rounded border border-base-300 hover:bg-base-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed aspect-square leading-none\"\n mnButton\n type=\"button\"\n >\u00AB\n </button>\n\n <button\n (click)=\"pageChange.emit(currentPage - 1)\"\n [data]=\"{ size: 'sm', variant: 'outline', color: 'secondary', disabled: currentPage === 1 }\"\n [disabled]=\"currentPage === 1\"\n aria-label=\"Previous page\"\n class=\"px-2 py-1 rounded border border-base-300 hover:bg-base-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed aspect-square leading-none\"\n mnButton\n type=\"button\"\n >\u2039\n </button>\n\n @for (slot of pageSlots; track $index) {\n <span [class]=\"slotVisibility(slot)\">\n @if (slot.page !== null) {\n <button\n (click)=\"pageChange.emit(slot.page)\"\n [attr.aria-current]=\"slot.page === currentPage ? 'page' : null\"\n [attr.aria-label]=\"'Page ' + slot.page\"\n [data]=\"{ size: 'sm', variant: 'text', color: slot.page === currentPage ? 'primary' : 'gray' }\"\n class=\"px-1.5 md:px-2.5 py-1 rounded underline underline-offset-2 transition-colors text-xs\"\n mnButton\n type=\"button\"\n >{{ slot.page }}\n </button>\n } @else {\n <span aria-hidden=\"true\" class=\"px-0.5 text-xs opacity-50 select-none\">\u2026</span>\n }\n </span>\n }\n\n <button\n (click)=\"pageChange.emit(currentPage + 1)\"\n [data]=\"{ size: 'sm', variant: 'outline', color: 'secondary', disabled: currentPage === totalPages }\"\n [disabled]=\"currentPage === totalPages\"\n aria-label=\"Next page\"\n class=\"px-2 py-1 rounded border border-base-300 hover:bg-base-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed aspect-square leading-none\"\n mnButton\n type=\"button\"\n >\u203A\n </button>\n\n <button\n (click)=\"pageChange.emit(totalPages)\"\n [data]=\"{ size: 'sm', variant: 'outline', color: 'secondary', disabled: currentPage === totalPages }\"\n [disabled]=\"currentPage === totalPages\"\n aria-label=\"Last page\"\n class=\"px-2 py-1 rounded border border-base-300 hover:bg-base-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed aspect-square leading-none\"\n mnButton\n type=\"button\"\n >\u00BB\n </button>\n </div>\n </div>\n}\n", dependencies: [{ kind: "component", type: MnButton, selector: "button[mnButton], a[mnButton]", inputs: ["data"] }, { kind: "component", type: MnSelect, selector: "mn-lib-select", inputs: ["props"] }, { 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"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
5964
6154
|
}
|
|
5965
6155
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnCollectionPagination, decorators: [{
|
|
5966
6156
|
type: Component,
|
|
5967
|
-
args: [{ selector: 'mn-collection-pagination', standalone: true, imports: [MnButton, MnSelect, FormsModule], changeDetection: ChangeDetectionStrategy.OnPush, template: "<!-- Load more button -->\n@if (showLoadMore) {\n <div class=\"flex justify-center py-4\">\n <button\n (click)=\"loadMore.emit()\"\n [data]=\"{ size: 'sm', variant: 'outline', color: 'primary' }\"\n [disabled]=\"loadingMoreRows\"\n class=\"px-4 py-1.5 text-sm rounded border border-primary-500 text-primary-500 hover:bg-primary-100 transition-colors disabled:opacity-50\"\n mnButton\n >\n @if (loadingMoreRows) {\n <span\n class=\"inline-block w-3 h-3 border-2 border-primary-500 border-t-transparent rounded-full animate-spin mr-2\"></span>\n }\n {{ labels?.loadMore || 'Load more' }}\n </button>\n </div>\n}\n\n<!-- Pagination controls -->\n@if (showPagination) {\n <div class=\"flex items-center justify-between gap-2 px-2 py-3 text-sm text-base-content\">\n @if (pageSizeSelectOptions.length > 1) {\n <div class=\"hidden md:flex items-center gap-2\">\n <span>{{ labels?.rowsPerPage || 'Items per page:' }}</span>\n <mn-lib-select\n (ngModelChange)=\"pageSizeChange.emit($event)\"\n [ngModel]=\"pageSize\"\n [props]=\"{\n id: idPrefix + '-page-size',\n options: pageSizeSelectOptions,\n size: 'sm'\n }\"\n ></mn-lib-select>\n </div>\n } @else {\n <div class=\"hidden md:block\"></div>\n }\n\n <!-- Narrow viewports state the page position outright: the strip's last-page\n anchor only appears once the window stops reaching the end, so it can't\n be relied on to carry the total. -->\n <span aria-live=\"polite\" class=\"md:hidden text-xs opacity-70 whitespace-nowrap\">{{ pageIndicatorLabel }}</span>\n\n <div class=\"flex flex-wrap items-center justify-center gap-0.5 md:gap-1\">\n <span class=\"text-xs mr-2 hidden md:inline\">{{ itemRangeLabel }}</span>\n\n <button\n (click)=\"pageChange.emit(1)\"\n [data]=\"{ size: 'sm', variant: 'outline', color: 'secondary', disabled: currentPage === 1 }\"\n [disabled]=\"currentPage === 1\"\n aria-label=\"First page\"\n class=\"px-2 py-1 rounded border border-base-300 hover:bg-base-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed aspect-square leading-none\"\n mnButton\n >\u00AB\n </button>\n\n <button\n (click)=\"pageChange.emit(currentPage - 1)\"\n [data]=\"{ size: 'sm', variant: 'outline', color: 'secondary', disabled: currentPage === 1 }\"\n [disabled]=\"currentPage === 1\"\n aria-label=\"Previous page\"\n class=\"px-2 py-1 rounded border border-base-300 hover:bg-base-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed aspect-square leading-none\"\n mnButton\n >\u2039\n </button>\n\n @for (slot of pageSlots; track $index) {\n <span [class]=\"slotVisibility(slot)\">\n @if (slot.page !== null) {\n <button\n (click)=\"pageChange.emit(slot.page)\"\n [attr.aria-current]=\"slot.page === currentPage ? 'page' : null\"\n [attr.aria-label]=\"'Page ' + slot.page\"\n [data]=\"{ size: 'sm', variant: 'text', color: slot.page === currentPage ? 'primary' : 'gray' }\"\n class=\"px-1.5 md:px-2.5 py-1 rounded underline underline-offset-2 transition-colors text-xs\"\n mnButton\n >{{ slot.page }}\n </button>\n } @else {\n <span aria-hidden=\"true\" class=\"px-0.5 text-xs opacity-50 select-none\">\u2026</span>\n }\n </span>\n }\n\n <button\n (click)=\"pageChange.emit(currentPage + 1)\"\n [data]=\"{ size: 'sm', variant: 'outline', color: 'secondary', disabled: currentPage === totalPages }\"\n [disabled]=\"currentPage === totalPages\"\n aria-label=\"Next page\"\n class=\"px-2 py-1 rounded border border-base-300 hover:bg-base-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed aspect-square leading-none\"\n mnButton\n >\u203A\n </button>\n\n <button\n (click)=\"pageChange.emit(totalPages)\"\n [data]=\"{ size: 'sm', variant: 'outline', color: 'secondary', disabled: currentPage === totalPages }\"\n [disabled]=\"currentPage === totalPages\"\n aria-label=\"Last page\"\n class=\"px-2 py-1 rounded border border-base-300 hover:bg-base-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed aspect-square leading-none\"\n mnButton\n >\u00BB\n </button>\n </div>\n </div>\n}\n" }]
|
|
6157
|
+
args: [{ selector: 'mn-collection-pagination', standalone: true, imports: [MnButton, MnSelect, FormsModule], changeDetection: ChangeDetectionStrategy.OnPush, template: "<!-- Load more button -->\n@if (showLoadMore) {\n <div class=\"flex justify-center py-4\">\n <button\n (click)=\"loadMore.emit()\"\n [data]=\"{ size: 'sm', variant: 'outline', color: 'primary' }\"\n [disabled]=\"loadingMoreRows\"\n class=\"px-4 py-1.5 text-sm rounded border border-primary-500 text-primary-500 hover:bg-primary-100 transition-colors disabled:opacity-50\"\n mnButton\n type=\"button\"\n >\n @if (loadingMoreRows) {\n <span\n class=\"inline-block w-3 h-3 border-2 border-primary-500 border-t-transparent rounded-full animate-spin mr-2\"></span>\n }\n {{ labels?.loadMore || 'Load more' }}\n </button>\n </div>\n}\n\n<!-- Pagination controls -->\n@if (showPagination) {\n <div class=\"flex items-center justify-between gap-2 px-2 py-3 text-sm text-base-content\">\n @if (pageSizeSelectOptions.length > 1) {\n <div class=\"hidden md:flex items-center gap-2\">\n <span>{{ labels?.rowsPerPage || 'Items per page:' }}</span>\n <mn-lib-select\n (ngModelChange)=\"pageSizeChange.emit($event)\"\n [ngModel]=\"pageSize\"\n [props]=\"{\n id: idPrefix + '-page-size',\n options: pageSizeSelectOptions,\n size: 'sm'\n }\"\n ></mn-lib-select>\n </div>\n } @else {\n <div class=\"hidden md:block\"></div>\n }\n\n <!-- Narrow viewports state the page position outright: the strip's last-page\n anchor only appears once the window stops reaching the end, so it can't\n be relied on to carry the total. -->\n <span aria-live=\"polite\" class=\"md:hidden text-xs opacity-70 whitespace-nowrap\">{{ pageIndicatorLabel }}</span>\n\n <div class=\"flex flex-wrap items-center justify-center gap-0.5 md:gap-1\">\n <span class=\"text-xs mr-2 hidden md:inline\">{{ itemRangeLabel }}</span>\n\n <button\n (click)=\"pageChange.emit(1)\"\n [data]=\"{ size: 'sm', variant: 'outline', color: 'secondary', disabled: currentPage === 1 }\"\n [disabled]=\"currentPage === 1\"\n aria-label=\"First page\"\n class=\"px-2 py-1 rounded border border-base-300 hover:bg-base-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed aspect-square leading-none\"\n mnButton\n type=\"button\"\n >\u00AB\n </button>\n\n <button\n (click)=\"pageChange.emit(currentPage - 1)\"\n [data]=\"{ size: 'sm', variant: 'outline', color: 'secondary', disabled: currentPage === 1 }\"\n [disabled]=\"currentPage === 1\"\n aria-label=\"Previous page\"\n class=\"px-2 py-1 rounded border border-base-300 hover:bg-base-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed aspect-square leading-none\"\n mnButton\n type=\"button\"\n >\u2039\n </button>\n\n @for (slot of pageSlots; track $index) {\n <span [class]=\"slotVisibility(slot)\">\n @if (slot.page !== null) {\n <button\n (click)=\"pageChange.emit(slot.page)\"\n [attr.aria-current]=\"slot.page === currentPage ? 'page' : null\"\n [attr.aria-label]=\"'Page ' + slot.page\"\n [data]=\"{ size: 'sm', variant: 'text', color: slot.page === currentPage ? 'primary' : 'gray' }\"\n class=\"px-1.5 md:px-2.5 py-1 rounded underline underline-offset-2 transition-colors text-xs\"\n mnButton\n type=\"button\"\n >{{ slot.page }}\n </button>\n } @else {\n <span aria-hidden=\"true\" class=\"px-0.5 text-xs opacity-50 select-none\">\u2026</span>\n }\n </span>\n }\n\n <button\n (click)=\"pageChange.emit(currentPage + 1)\"\n [data]=\"{ size: 'sm', variant: 'outline', color: 'secondary', disabled: currentPage === totalPages }\"\n [disabled]=\"currentPage === totalPages\"\n aria-label=\"Next page\"\n class=\"px-2 py-1 rounded border border-base-300 hover:bg-base-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed aspect-square leading-none\"\n mnButton\n type=\"button\"\n >\u203A\n </button>\n\n <button\n (click)=\"pageChange.emit(totalPages)\"\n [data]=\"{ size: 'sm', variant: 'outline', color: 'secondary', disabled: currentPage === totalPages }\"\n [disabled]=\"currentPage === totalPages\"\n aria-label=\"Last page\"\n class=\"px-2 py-1 rounded border border-base-300 hover:bg-base-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed aspect-square leading-none\"\n mnButton\n type=\"button\"\n >\u00BB\n </button>\n </div>\n </div>\n}\n" }]
|
|
5968
6158
|
}], propDecorators: { idPrefix: [{
|
|
5969
6159
|
type: Input
|
|
5970
6160
|
}], isPaginated: [{
|
|
@@ -6003,17 +6193,8 @@ class MnTable extends MnSelectableCollectionBase {
|
|
|
6003
6193
|
currentSort = null;
|
|
6004
6194
|
/** Per-column filter values keyed by column key. */
|
|
6005
6195
|
columnFilters = {};
|
|
6006
|
-
/** Filter types compact enough to render directly inside the header cell. */
|
|
6007
|
-
static INLINE_FILTER_TYPES = ['text', 'select', 'boolean'];
|
|
6008
|
-
/** Width (px) of the filter popover, mirrored from its `w-64` class for clamping. */
|
|
6009
|
-
static POPOVER_WIDTH = 256;
|
|
6010
6196
|
/** Viewport width (px) below which the inline filter row collapses into a panel. */
|
|
6011
6197
|
static FILTER_COLLAPSE_WIDTH = 640;
|
|
6012
|
-
/**
|
|
6013
|
-
* Key of the column whose filter popover is open, or `null` when none is.
|
|
6014
|
-
* Only the rich filter types ({@link isPopoverFilter}) use a popover.
|
|
6015
|
-
*/
|
|
6016
|
-
openFilterKey = null;
|
|
6017
6198
|
/** Bounds rendered by a number-range filter, in input order. */
|
|
6018
6199
|
numberBounds = ['min', 'max'];
|
|
6019
6200
|
/**
|
|
@@ -6032,12 +6213,8 @@ class MnTable extends MnSelectableCollectionBase {
|
|
|
6032
6213
|
// ── Column Filters ──
|
|
6033
6214
|
/** Bounds rendered by a date-range filter, in input order. */
|
|
6034
6215
|
dateBounds = ['from', 'to'];
|
|
6035
|
-
/** Viewport coordinates of the open filter popover. */
|
|
6036
|
-
popoverPosition = { top: 0, left: 0 };
|
|
6037
6216
|
/** Debounces server-side text filters so typing doesn't fire a request per keystroke. */
|
|
6038
6217
|
filterDebounce = new Subject();
|
|
6039
|
-
/** The open filter popover, used to tell inside clicks from outside ones. */
|
|
6040
|
-
filterPopover;
|
|
6041
6218
|
/**
|
|
6042
6219
|
* Most rows shown per page on mobile (< md). A **cap**, not an override: a data
|
|
6043
6220
|
* source asking for fewer rows keeps its own size. Raising a small page size on
|
|
@@ -6118,54 +6295,10 @@ class MnTable extends MnSelectableCollectionBase {
|
|
|
6118
6295
|
filterTypeOf(column) {
|
|
6119
6296
|
return column.filterType ?? 'text';
|
|
6120
6297
|
}
|
|
6121
|
-
/** Whether a column's filter renders inline in the header cell (vs. in a popover). */
|
|
6122
|
-
isInlineFilter(column) {
|
|
6123
|
-
return MnTable.INLINE_FILTER_TYPES.includes(this.filterTypeOf(column));
|
|
6124
|
-
}
|
|
6125
|
-
/** Whether a column's filter is rich enough to need the popover panel. */
|
|
6126
|
-
isPopoverFilter(column) {
|
|
6127
|
-
return !!column.filterable && !this.isInlineFilter(column);
|
|
6128
|
-
}
|
|
6129
6298
|
/** Whether a specific column's filter currently narrows the rows. */
|
|
6130
6299
|
isColumnFilterActive(column) {
|
|
6131
6300
|
return isFilterValueActive(this.columnFilters[column.key]);
|
|
6132
6301
|
}
|
|
6133
|
-
/** The column whose filter popover is open, or `null`. */
|
|
6134
|
-
openFilterColumn() {
|
|
6135
|
-
if (this.openFilterKey === null)
|
|
6136
|
-
return null;
|
|
6137
|
-
return this.dataSource.columns.find(col => col.key === this.openFilterKey) ?? null;
|
|
6138
|
-
}
|
|
6139
|
-
/**
|
|
6140
|
-
* Opens/closes a column's filter popover, closing any other that was open, and
|
|
6141
|
-
* anchors it under the trigger. The popover is positioned `fixed` from the
|
|
6142
|
-
* trigger's viewport rect because it renders outside the table's
|
|
6143
|
-
* `overflow-x-auto` wrapper, which would otherwise clip it.
|
|
6144
|
-
*/
|
|
6145
|
-
toggleFilterPopover(column, event) {
|
|
6146
|
-
if (this.openFilterKey === column.key) {
|
|
6147
|
-
this.openFilterKey = null;
|
|
6148
|
-
return;
|
|
6149
|
-
}
|
|
6150
|
-
const trigger = event.currentTarget;
|
|
6151
|
-
if (trigger) {
|
|
6152
|
-
const rect = trigger.getBoundingClientRect();
|
|
6153
|
-
const maxLeft = window.innerWidth - MnTable.POPOVER_WIDTH - 8;
|
|
6154
|
-
this.popoverPosition = { top: rect.bottom + 4, left: Math.max(8, Math.min(rect.left, maxLeft)) };
|
|
6155
|
-
}
|
|
6156
|
-
this.openFilterKey = column.key;
|
|
6157
|
-
}
|
|
6158
|
-
/** Resets a single column's filter (from its popover) and re-applies filtering. */
|
|
6159
|
-
clearColumnFilter(column) {
|
|
6160
|
-
this.onColumnFilter(column, emptyFilterValue(this.filterTypeOf(column)));
|
|
6161
|
-
}
|
|
6162
|
-
/** Closes the filter popover, if one is open. */
|
|
6163
|
-
closeFilterPopover() {
|
|
6164
|
-
if (this.openFilterKey === null)
|
|
6165
|
-
return;
|
|
6166
|
-
this.openFilterKey = null;
|
|
6167
|
-
this.cdr.markForCheck();
|
|
6168
|
-
}
|
|
6169
6302
|
/** Filter options formatted for mn-multi-select for a given column. */
|
|
6170
6303
|
getFilterMultiSelectOptions(column) {
|
|
6171
6304
|
return (column.filterOptions ?? []).map(opt => ({ label: opt.label, value: String(opt.value) }));
|
|
@@ -6234,31 +6367,21 @@ class MnTable extends MnSelectableCollectionBase {
|
|
|
6234
6367
|
}
|
|
6235
6368
|
this.cdr.markForCheck();
|
|
6236
6369
|
}
|
|
6237
|
-
/**
|
|
6238
|
-
* Closes the filter popover when the click landed outside it. Membership is
|
|
6239
|
-
* tested against the popover element rather than stopping propagation inside
|
|
6240
|
-
* it, so the popover's own controls stay ordinary, focusable elements.
|
|
6241
|
-
*/
|
|
6242
|
-
onDocumentClick(event) {
|
|
6243
|
-
if (this.openFilterKey === null)
|
|
6244
|
-
return;
|
|
6245
|
-
const target = event.target;
|
|
6246
|
-
if (target && this.filterPopover?.nativeElement.contains(target))
|
|
6247
|
-
return;
|
|
6248
|
-
this.closeFilterPopover();
|
|
6249
|
-
}
|
|
6250
6370
|
/** Whether any column has filtering enabled. */
|
|
6251
6371
|
get hasColumnFilters() {
|
|
6252
6372
|
return this.dataSource.columns.some(c => c.filterable);
|
|
6253
6373
|
}
|
|
6254
|
-
/** Closes the filter popover on Escape. */
|
|
6255
|
-
onEscape() {
|
|
6256
|
-
this.closeFilterPopover();
|
|
6257
|
-
}
|
|
6258
6374
|
/** Label for the small-screen filters toggle button. */
|
|
6259
6375
|
get filtersButtonLabel() {
|
|
6260
6376
|
return this.dataSource.filtersLabel ?? 'Filters';
|
|
6261
6377
|
}
|
|
6378
|
+
/**
|
|
6379
|
+
* Summary a multi-select filter collapses to once more than one option is picked.
|
|
6380
|
+
* Resolved with the `{count}` token intact for mn-multi-select to fill in.
|
|
6381
|
+
*/
|
|
6382
|
+
get filterSelectedLabel() {
|
|
6383
|
+
return this.dataSource.filterLabels?.selected ?? '{count} selected';
|
|
6384
|
+
}
|
|
6262
6385
|
/** Label for the "clear all filters" action in the small-screen panel. */
|
|
6263
6386
|
get clearFiltersButtonLabel() {
|
|
6264
6387
|
return this.dataSource.clearFiltersLabel ?? 'Clear all';
|
|
@@ -6394,6 +6517,37 @@ class MnTable extends MnSelectableCollectionBase {
|
|
|
6394
6517
|
}
|
|
6395
6518
|
/** Page size to use at/above the `md` breakpoint (consumer's pageSize, or the user's selection). */
|
|
6396
6519
|
desktopPageSize = 10;
|
|
6520
|
+
/** Heading for the selection summary, with the count filled in. */
|
|
6521
|
+
get selectionSummaryTitle() {
|
|
6522
|
+
const template = this.dataSource.selectionSummaryLabels?.title ?? 'Selected ({{count}})';
|
|
6523
|
+
return template.replace('{{count}}', String(this.selectedIds.size));
|
|
6524
|
+
}
|
|
6525
|
+
/** Label for the summary's clear-everything action. */
|
|
6526
|
+
get selectionClearAllLabel() {
|
|
6527
|
+
return this.dataSource.selectionSummaryLabels?.clearAll ?? 'Clear all';
|
|
6528
|
+
}
|
|
6529
|
+
/** Label for the summary's expand/collapse control. */
|
|
6530
|
+
get selectionSummaryToggleLabel() {
|
|
6531
|
+
const labels = this.dataSource.selectionSummaryLabels;
|
|
6532
|
+
if (this.selectionSummaryExpanded)
|
|
6533
|
+
return labels?.showLess ?? 'Show less';
|
|
6534
|
+
const template = labels?.showMore ?? '+{{count}} more';
|
|
6535
|
+
return template.replace('{{count}}', String(this.hiddenSelectionCount));
|
|
6536
|
+
}
|
|
6537
|
+
/**
|
|
6538
|
+
* Accessible label for a tag's remove button.
|
|
6539
|
+
* @param row The row the tag stands for.
|
|
6540
|
+
* @returns The label, naming the row so screen readers announce which one goes.
|
|
6541
|
+
*/
|
|
6542
|
+
selectionRemoveLabel(row) {
|
|
6543
|
+
const template = this.dataSource.selectionSummaryLabels?.remove ?? 'Remove {{label}}';
|
|
6544
|
+
return template.replace('{{label}}', this.selectionLabelFor(row));
|
|
6545
|
+
}
|
|
6546
|
+
/** Tracks the desktop page size when the user picks one (selector only shows at >= md). */
|
|
6547
|
+
onPageSizeChange(newSize) {
|
|
6548
|
+
this.desktopPageSize = newSize;
|
|
6549
|
+
super.onPageSizeChange(newSize);
|
|
6550
|
+
}
|
|
6397
6551
|
/** The effective column-width strategy, defaulting to `stable`. */
|
|
6398
6552
|
get layoutMode() {
|
|
6399
6553
|
return this.dataSource.appearance?.layout ?? 'stable';
|
|
@@ -6429,6 +6583,87 @@ class MnTable extends MnSelectableCollectionBase {
|
|
|
6429
6583
|
return null;
|
|
6430
6584
|
return column.cell(row) || null;
|
|
6431
6585
|
}
|
|
6586
|
+
/**
|
|
6587
|
+
* Falls back to the first column that renders a plain string, which is almost
|
|
6588
|
+
* always the name-like column a person would use to identify the row. Template
|
|
6589
|
+
* columns are skipped: they render markup this cannot flatten to a tag label.
|
|
6590
|
+
* @param row The selected row.
|
|
6591
|
+
* @returns The label, or null when every column renders a template.
|
|
6592
|
+
*/
|
|
6593
|
+
defaultSelectionLabel(row) {
|
|
6594
|
+
for (const column of this.dataSource.columns) {
|
|
6595
|
+
if (typeof column.cell !== 'function')
|
|
6596
|
+
continue;
|
|
6597
|
+
const value = column.cell(row);
|
|
6598
|
+
if (value)
|
|
6599
|
+
return value;
|
|
6600
|
+
}
|
|
6601
|
+
return null;
|
|
6602
|
+
}
|
|
6603
|
+
/**
|
|
6604
|
+
* Resolves table-specific translation keys (column headers/filters) plus the
|
|
6605
|
+
* shared keys handled by the base.
|
|
6606
|
+
*/
|
|
6607
|
+
resolveTranslationKeys() {
|
|
6608
|
+
super.resolveTranslationKeys();
|
|
6609
|
+
for (const col of this.dataSource.columns) {
|
|
6610
|
+
if (col.headerKey) {
|
|
6611
|
+
col.header = this.lang.t(col.headerKey);
|
|
6612
|
+
}
|
|
6613
|
+
if (col.filterPlaceholderKey) {
|
|
6614
|
+
col.filterPlaceholder = this.lang.t(col.filterPlaceholderKey);
|
|
6615
|
+
}
|
|
6616
|
+
}
|
|
6617
|
+
if (this.dataSource.filtersLabelKey) {
|
|
6618
|
+
this.dataSource.filtersLabel = this.lang.t(this.dataSource.filtersLabelKey);
|
|
6619
|
+
}
|
|
6620
|
+
if (this.dataSource.clearFiltersLabelKey) {
|
|
6621
|
+
this.dataSource.clearFiltersLabel = this.lang.t(this.dataSource.clearFiltersLabelKey);
|
|
6622
|
+
}
|
|
6623
|
+
this.resolveFilterLabelKeys();
|
|
6624
|
+
this.resolveSelectionSummaryKeys();
|
|
6625
|
+
}
|
|
6626
|
+
applyFilter(searchForItems) {
|
|
6627
|
+
let items = this.applySearchFilter(this.dataSource.dataRows.value ?? []);
|
|
6628
|
+
// Per-column filters. Skipped entirely when the consumer owns filtering: the
|
|
6629
|
+
// rows already are the filtered set, and re-filtering them locally would
|
|
6630
|
+
// narrow the current page a second time.
|
|
6631
|
+
if (!this.isServerFiltered) {
|
|
6632
|
+
for (const col of this.dataSource.columns) {
|
|
6633
|
+
const filterValue = this.columnFilters[col.key];
|
|
6634
|
+
if (!col.filterable || !isFilterValueActive(filterValue))
|
|
6635
|
+
continue;
|
|
6636
|
+
items = items.filter(row => matchesColumnFilter(col, row, filterValue));
|
|
6637
|
+
}
|
|
6638
|
+
}
|
|
6639
|
+
items = this.applySorting(items);
|
|
6640
|
+
// With no column sorted, the order carries no meaning the user chose, so spend
|
|
6641
|
+
// it on showing what is already selected. A sorted column always wins.
|
|
6642
|
+
if (!this.currentSort) {
|
|
6643
|
+
items = this.prioritizeInitialSelection(items);
|
|
6644
|
+
}
|
|
6645
|
+
this.filteredItems = items;
|
|
6646
|
+
this.applyPagination();
|
|
6647
|
+
if (searchForItems) {
|
|
6648
|
+
this.loadMoreRows();
|
|
6649
|
+
}
|
|
6650
|
+
}
|
|
6651
|
+
/**
|
|
6652
|
+
* Re-evaluate on a window resize too. The ResizeObserver covers every change to
|
|
6653
|
+
* the table's own box, but {@link isMobileViewport} reads the window, which can
|
|
6654
|
+
* change without the table's width following it (a fixed-width table, a modal
|
|
6655
|
+
* pinned to a max width).
|
|
6656
|
+
*/
|
|
6657
|
+
onWindowResize() {
|
|
6658
|
+
this.onHostResize();
|
|
6659
|
+
}
|
|
6660
|
+
/** Re-evaluate responsive page size and filter layout when the table is resized. */
|
|
6661
|
+
onHostResize() {
|
|
6662
|
+
// Pixel widths captured for the old box are meaningless in the new one.
|
|
6663
|
+
this.unpinColumnWidths();
|
|
6664
|
+
this.applyResponsivePageSize(true);
|
|
6665
|
+
this.updateFilterLayout(true);
|
|
6666
|
+
}
|
|
6432
6667
|
/**
|
|
6433
6668
|
* Captures the current, automatically-derived width of every visible column and
|
|
6434
6669
|
* pins it, which flips the table to `table-fixed` on the next render.
|
|
@@ -6468,6 +6703,11 @@ class MnTable extends MnSelectableCollectionBase {
|
|
|
6468
6703
|
}
|
|
6469
6704
|
this.pinnedWidths = pinned;
|
|
6470
6705
|
this.widthsPinned = true;
|
|
6706
|
+
// Pinning changes row heights: cells stop wrapping and start truncating, so a
|
|
6707
|
+
// full page becomes shorter than it was during the automatic pass. The reserved
|
|
6708
|
+
// full-page floor was measured against those taller rows and would otherwise
|
|
6709
|
+
// hold the body open, leaving dead space between the last row and the paginator.
|
|
6710
|
+
this.invalidatePageHeight();
|
|
6471
6711
|
this.cdr.markForCheck();
|
|
6472
6712
|
}
|
|
6473
6713
|
/**
|
|
@@ -6481,71 +6721,9 @@ class MnTable extends MnSelectableCollectionBase {
|
|
|
6481
6721
|
return;
|
|
6482
6722
|
this.pinnedWidths = new Map();
|
|
6483
6723
|
this.widthsPinned = false;
|
|
6484
|
-
|
|
6485
|
-
|
|
6486
|
-
|
|
6487
|
-
* the table's own box, but {@link isMobileViewport} reads the window, which can
|
|
6488
|
-
* change without the table's width following it (a fixed-width table, a modal
|
|
6489
|
-
* pinned to a max width).
|
|
6490
|
-
*/
|
|
6491
|
-
onWindowResize() {
|
|
6492
|
-
this.onHostResize();
|
|
6493
|
-
}
|
|
6494
|
-
/** Re-evaluate responsive page size and filter layout when the table is resized. */
|
|
6495
|
-
onHostResize() {
|
|
6496
|
-
// Pixel widths captured for the old box are meaningless in the new one.
|
|
6497
|
-
this.unpinColumnWidths();
|
|
6498
|
-
this.applyResponsivePageSize(true);
|
|
6499
|
-
this.updateFilterLayout(true);
|
|
6500
|
-
// The popover is anchored to a viewport rect that a resize invalidates.
|
|
6501
|
-
this.closeFilterPopover();
|
|
6502
|
-
}
|
|
6503
|
-
/**
|
|
6504
|
-
* Resolves table-specific translation keys (column headers/filters) plus the
|
|
6505
|
-
* shared keys handled by the base.
|
|
6506
|
-
*/
|
|
6507
|
-
resolveTranslationKeys() {
|
|
6508
|
-
super.resolveTranslationKeys();
|
|
6509
|
-
for (const col of this.dataSource.columns) {
|
|
6510
|
-
if (col.headerKey) {
|
|
6511
|
-
col.header = this.lang.t(col.headerKey);
|
|
6512
|
-
}
|
|
6513
|
-
if (col.filterPlaceholderKey) {
|
|
6514
|
-
col.filterPlaceholder = this.lang.t(col.filterPlaceholderKey);
|
|
6515
|
-
}
|
|
6516
|
-
}
|
|
6517
|
-
if (this.dataSource.filtersLabelKey) {
|
|
6518
|
-
this.dataSource.filtersLabel = this.lang.t(this.dataSource.filtersLabelKey);
|
|
6519
|
-
}
|
|
6520
|
-
if (this.dataSource.clearFiltersLabelKey) {
|
|
6521
|
-
this.dataSource.clearFiltersLabel = this.lang.t(this.dataSource.clearFiltersLabelKey);
|
|
6522
|
-
}
|
|
6523
|
-
this.resolveFilterLabelKeys();
|
|
6524
|
-
}
|
|
6525
|
-
/** Tracks the desktop page size when the user picks one (selector only shows at >= md). */
|
|
6526
|
-
onPageSizeChange(newSize) {
|
|
6527
|
-
this.desktopPageSize = newSize;
|
|
6528
|
-
super.onPageSizeChange(newSize);
|
|
6529
|
-
}
|
|
6530
|
-
applyFilter(searchForItems) {
|
|
6531
|
-
let items = this.applySearchFilter(this.dataSource.dataRows.value ?? []);
|
|
6532
|
-
// Per-column filters. Skipped entirely when the consumer owns filtering: the
|
|
6533
|
-
// rows already are the filtered set, and re-filtering them locally would
|
|
6534
|
-
// narrow the current page a second time.
|
|
6535
|
-
if (!this.isServerFiltered) {
|
|
6536
|
-
for (const col of this.dataSource.columns) {
|
|
6537
|
-
const filterValue = this.columnFilters[col.key];
|
|
6538
|
-
if (!col.filterable || !isFilterValueActive(filterValue))
|
|
6539
|
-
continue;
|
|
6540
|
-
items = items.filter(row => matchesColumnFilter(col, row, filterValue));
|
|
6541
|
-
}
|
|
6542
|
-
}
|
|
6543
|
-
items = this.applySorting(items);
|
|
6544
|
-
this.filteredItems = items;
|
|
6545
|
-
this.applyPagination();
|
|
6546
|
-
if (searchForItems) {
|
|
6547
|
-
this.loadMoreRows();
|
|
6548
|
-
}
|
|
6724
|
+
// Row heights are about to change back; the floor measured for the pinned
|
|
6725
|
+
// layout does not describe the automatic one.
|
|
6726
|
+
this.invalidatePageHeight();
|
|
6549
6727
|
}
|
|
6550
6728
|
// ── Template helpers ──
|
|
6551
6729
|
getCellValue(column, row) {
|
|
@@ -6645,6 +6823,26 @@ class MnTable extends MnSelectableCollectionBase {
|
|
|
6645
6823
|
}
|
|
6646
6824
|
}
|
|
6647
6825
|
// ── Filtering & sorting ──
|
|
6826
|
+
/**
|
|
6827
|
+
* Resolves the selection-summary labels from their translation keys. Resolved
|
|
6828
|
+
* without params so the `{{count}}` / `{{label}}` placeholders survive for the
|
|
6829
|
+
* getters to fill in per render.
|
|
6830
|
+
*/
|
|
6831
|
+
resolveSelectionSummaryKeys() {
|
|
6832
|
+
const labels = this.dataSource.selectionSummaryLabels;
|
|
6833
|
+
if (!labels)
|
|
6834
|
+
return;
|
|
6835
|
+
if (labels.titleKey)
|
|
6836
|
+
labels.title = this.lang.t(labels.titleKey);
|
|
6837
|
+
if (labels.clearAllKey)
|
|
6838
|
+
labels.clearAll = this.lang.t(labels.clearAllKey);
|
|
6839
|
+
if (labels.removeKey)
|
|
6840
|
+
labels.remove = this.lang.t(labels.removeKey);
|
|
6841
|
+
if (labels.showMoreKey)
|
|
6842
|
+
labels.showMore = this.lang.t(labels.showMoreKey);
|
|
6843
|
+
if (labels.showLessKey)
|
|
6844
|
+
labels.showLess = this.lang.t(labels.showLessKey);
|
|
6845
|
+
}
|
|
6648
6846
|
/** Resolves the range / boolean filter control labels from their translation keys. */
|
|
6649
6847
|
resolveFilterLabelKeys() {
|
|
6650
6848
|
const labels = this.dataSource.filterLabels;
|
|
@@ -6652,7 +6850,7 @@ class MnTable extends MnSelectableCollectionBase {
|
|
|
6652
6850
|
return;
|
|
6653
6851
|
const pairs = [
|
|
6654
6852
|
['minKey', 'min'], ['maxKey', 'max'], ['fromKey', 'from'], ['toKey', 'to'],
|
|
6655
|
-
['anyKey', 'any'], ['yesKey', 'yes'], ['noKey', 'no'],
|
|
6853
|
+
['anyKey', 'any'], ['yesKey', 'yes'], ['noKey', 'no'], ['selectedKey', 'selected'],
|
|
6656
6854
|
];
|
|
6657
6855
|
for (const [keyProp, labelProp] of pairs) {
|
|
6658
6856
|
const key = labels[keyProp];
|
|
@@ -6694,11 +6892,11 @@ class MnTable extends MnSelectableCollectionBase {
|
|
|
6694
6892
|
});
|
|
6695
6893
|
}
|
|
6696
6894
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnTable, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
6697
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnTable, isStandalone: true, selector: "mn-table", outputs: { sortChange: "sortChange", rowClick: "rowClick" }, host: { listeners: { "document:click": "onDocumentClick($event)", "document:keydown.escape": "onEscape()", "window:resize": "onWindowResize()" }, classAttribute: "block" }, viewQueries: [{ propertyName: "collectionBody", first: true, predicate: ["collectionBody"], descendants: true }, { propertyName: "filterPopover", first: true, predicate: ["filterPopover"], descendants: true }], usesInheritance: true, ngImport: i0, template: "<!-- Everything that reflows lives inside a @container, so the breakpoints below\n measure the table's own width rather than the window's. A table in a modal,\n a sidebar or a narrow grid cell is far narrower than the viewport, and\n viewport breakpoints would hand it a desktop layout it has no room for.\n The filter popover is deliberately left outside: `container-type` makes an\n element the containing block for `position: fixed` descendants, which would\n re-anchor the popover away from its trigger. -->\n<div class=\"@container\">\n<!-- Toolbar: custom toolbar template + search. Only rendered when it has content,\n so an unsearchable table without toolbar templates emits no empty spacer. -->\n@if (dataSource.canSearch || dataSource.toolbarLeftTemplate || dataSource.toolbarRightTemplate || (hasColumnFilters && filtersCollapsed)) {\n <div class=\"flex flex-col @min-[420px]:flex-row @min-[420px]:items-center justify-between gap-2 mb-3\">\n <div class=\"flex items-center gap-2 w-full @min-[420px]:w-auto\">\n @if (dataSource.toolbarLeftTemplate) {\n <div class=\"flex flex-col w-full @min-[420px]:w-auto @min-[420px]:flex-row\">\n <ng-container [ngTemplateOutlet]=\"dataSource.toolbarLeftTemplate\"></ng-container>\n </div>\n }\n </div>\n <div\n class=\"flex flex-col @min-[420px]:flex-row @min-[420px]:items-center gap-2 w-full @min-[420px]:flex-1 @min-[560px]:flex-none @min-[560px]:w-auto\">\n @if (dataSource.canSearch) {\n <mn-lib-input-field\n class=\"w-full @min-[420px]:flex-1 @min-[560px]:max-w-64\"\n [props]=\"{\n id: 'mn-table-search',\n type: 'search',\n label: '',\n ariaLabel: dataSource.searchPlaceholder ?? 'Search...',\n placeholder: dataSource.searchPlaceholder ?? 'Search...',\n size: 'sm',\n borderRadius: 'md',\n fullWidth: true\n }\"\n [ngModel]=\"searchValue\"\n (ngModelChange)=\"onSearch($event)\"\n ></mn-lib-input-field>\n }\n @if (dataSource.toolbarRightTemplate) {\n <div class=\"flex flex-col w-full @min-[420px]:w-auto @min-[420px]:flex-row\">\n <ng-container [ngTemplateOutlet]=\"dataSource.toolbarRightTemplate\"></ng-container>\n </div>\n }\n <!-- Small-screen filter toggle: replaces the inline per-column filter row below 640px -->\n @if (hasColumnFilters && filtersCollapsed) {\n <button\n type=\"button\"\n mnButton\n [data]=\"{ variant: 'outline', color: 'primary', size: 'md', borderRadius: 'md', hover: true }\"\n class=\"w-full @min-[420px]:w-auto gap-1.5\"\n [attr.aria-expanded]=\"filtersPanelOpen\"\n aria-controls=\"mn-table-filters-panel\"\n (click)=\"toggleFiltersPanel()\"\n >\n <svg lucideFunnel [size]=\"15\"></svg>\n <span>{{ filtersButtonLabel }}</span>\n </button>\n }\n </div>\n</div>\n}\n\n<!-- Small-screen filter panel: stacked, full-width fields decoupled from column widths -->\n@if (hasColumnFilters && filtersCollapsed) {\n <div\n id=\"mn-table-filters-panel\"\n class=\"grid transition-all duration-200 ease-out motion-reduce:transition-none\"\n [style.grid-template-rows]=\"filtersPanelOpen ? '1fr' : '0fr'\"\n >\n <div class=\"overflow-hidden\" [attr.inert]=\"filtersPanelOpen ? null : ''\">\n <div class=\"flex flex-col gap-3 rounded-md border border-base-300 bg-base-100 p-3 mb-3\">\n @for (column of dataSource.columns; track column.key) {\n @if (column.filterable) {\n <div class=\"flex flex-col gap-1\">\n <label\n class=\"text-xs font-medium text-base-content/70\"\n [attr.for]=\"'mn-table-filter-' + column.key\"\n >\n @if (isTemplateRef(column.header)) {\n <ng-container [ngTemplateOutlet]=\"$any(column.header)\"></ng-container>\n } @else {\n {{ column.header }}\n }\n </label>\n <ng-container\n [ngTemplateOutletContext]=\"{ $implicit: column, idScope: 'panel' }\"\n [ngTemplateOutlet]=\"filterField\"\n ></ng-container>\n </div>\n }\n }\n @if (hasActiveFilters) {\n <button\n type=\"button\"\n mnButton\n [data]=\"{ variant: 'text', color: 'gray', size: 'sm', borderRadius: 'md', hover: true }\"\n class=\"self-start gap-1\"\n (click)=\"clearAllFilters()\"\n >\n <svg lucideX [size]=\"14\"></svg>\n <span>{{ clearFiltersButtonLabel }}</span>\n </button>\n }\n </div>\n </div>\n </div>\n}\n\n<!-- Table wrapper with horizontal scroll -->\n<div #collectionBody (scroll)=\"closeFilterPopover()\" [style.min-height.px]=\"bodyMinHeight\" aria-label=\"Data table\"\n class=\"overflow-x-auto\"\n role=\"region\">\n <table [class]=\"tableClasses\">\n <thead>\n <tr class=\"bg-base-100\">\n <!-- Selection checkbox column header -->\n @if (hasSelection) {\n <th class=\"w-10 text-center text-sm bg-base-200 px-2 py-2\">\n @if (isMultiSelect) {\n <mn-lib-checkbox\n (checkedChange)=\"toggleAll()\"\n [checked]=\"allSelected\"\n [props]=\"{ id: 'mn-table-select-all', size: 'sm' }\"\n ></mn-lib-checkbox>\n }\n </th>\n }\n\n <!-- Data columns -->\n @for (column of dataSource.columns; track column.key) {\n <th\n [attr.data-column-key]=\"column.key\"\n [class.truncate]=\"widthsArePinned\"\n [class.cursor-pointer]=\"isSortable(column)\"\n [class.select-none]=\"isSortable(column)\"\n [class.hover:bg-base-200]=\"isSortable(column)\"\n [class.text-left]=\"(column.align ?? 'left') === 'left'\"\n [class.text-center]=\"column.align === 'center'\"\n [class.text-right]=\"column.align === 'right'\"\n [mnHiddenBelow]=\"column.hiddenBelow\"\n [style.width]=\"columnWidth(column)\"\n [attr.aria-sort]=\"currentSort?.columnKey === column.key ? (currentSort!.direction === 'asc' ? 'ascending' : 'descending') : null\"\n class=\"text-sm px-2 py-1 @min-[640px]:px-4 @min-[640px]:py-2 whitespace-nowrap\"\n (click)=\"sort(column)\"\n >\n <span class=\"inline-flex items-center gap-1\">\n @if (isTemplateRef(column.header)) {\n <ng-container [ngTemplateOutlet]=\"$any(column.header)\"></ng-container>\n } @else {\n <span>{{ column.header }}</span>\n }\n @if (isSortable(column)) {\n <span class=\"text-[0.65rem] opacity-70 min-w-3 inline-block\">{{ getSortIcon(column) }}</span>\n }\n </span>\n </th>\n }\n\n </tr>\n\n <!-- Per-column filter row (wide screens only; collapses into a panel below 640px).\n `font-normal` on the cells is load-bearing: these are `th` elements, which the\n browser renders bold, and the filter inputs and selects inside them inherit\n that. Filter controls are form fields, not headings, and must not look bold.\n The stacked panel and the popover render outside any `th`, so they are fine. -->\n @if (hasColumnFilters && !filtersCollapsed) {\n <tr class=\"bg-base-100 border-b border-base-300 font-normal\">\n @if (hasSelection) {\n <th class=\"px-2 py-1 font-normal\"></th>\n }\n @for (column of dataSource.columns; track column.key) {\n <th\n class=\"px-4 py-2 font-normal\"\n [mnHiddenBelow]=\"column.hiddenBelow\"\n >\n @if (column.filterable) {\n @if (isInlineFilter(column)) {\n <!-- Compact types render directly under the header -->\n <ng-container\n [ngTemplateOutletContext]=\"{ $implicit: column, idScope: 'inline' }\"\n [ngTemplateOutlet]=\"filterField\"\n ></ng-container>\n } @else {\n <!-- Ranges and multi-select don't fit a column width: trigger a popover -->\n <button\n (click)=\"$event.stopPropagation(); toggleFilterPopover(column, $event)\"\n [attr.aria-expanded]=\"openFilterKey === column.key\"\n [attr.aria-label]=\"column.filterPlaceholder ?? filtersButtonLabel\"\n [data]=\"{ variant: isColumnFilterActive(column) ? 'fill' : 'outline', color: 'primary', size: 'sm', borderRadius: 'md', hover: true }\"\n class=\"w-full gap-1.5\"\n mnButton\n type=\"button\"\n >\n <svg [size]=\"14\" lucideFilter></svg>\n @if (isColumnFilterActive(column)) {\n <span class=\"h-1.5 w-1.5 rounded-full bg-current\"></span>\n }\n </button>\n }\n }\n </th>\n }\n </tr>\n }\n </thead>\n\n <tbody>\n <!-- Loading state -->\n @if (isLoadingState) {\n @for (_ of skeletonRows; track $index) {\n <tr>\n @if (hasSelection) {\n <td class=\"px-2 py-3\">\n <mn-skeleton [data]=\"{ shape: 'rectangle', width: '1rem', height: '1rem' }\"></mn-skeleton>\n </td>\n }\n @for (column of dataSource.columns; track column.key) {\n <td class=\"px-4 py-3\"\n [mnHiddenBelow]=\"column.hiddenBelow\"\n [style.width]=\"columnWidth(column)\"\n >\n @if (isTemplateRef(column.skeleton)) {\n <ng-container [ngTemplateOutlet]=\"$any(column.skeleton)\"></ng-container>\n } @else {\n <mn-skeleton [data]=\"getColumnSkeletonData(column)\"></mn-skeleton>\n }\n </td>\n }\n </tr>\n }\n } @else if (isErrorState) {\n <!-- Error state -->\n <tr class=\"bg-base-100\">\n <td [attr.colspan]=\"totalColumnCount\" class=\"text-center text-xs py-8\">\n @if (dataSource.errorTemplate) {\n <ng-container [ngTemplateOutlet]=\"dataSource.errorTemplate\"></ng-container>\n } @else {\n <div class=\"flex flex-col items-center gap-2 text-error\">\n <p class=\"text-sm\">{{ dataSource.errorMessage }}</p>\n </div>\n }\n </td>\n </tr>\n } @else {\n <!-- Empty state -->\n @if (filteredItems.length === 0) {\n <tr class=\"bg-base-100\">\n <td [attr.colspan]=\"totalColumnCount\" class=\"text-center text-xs py-8\">\n @if (dataSource.emptyTemplate) {\n <ng-container [ngTemplateOutlet]=\"dataSource.emptyTemplate\"></ng-container>\n } @else {\n <div class=\"flex flex-col items-center gap-2 text-base-content/50\">\n @if (dataSource.emptyIcon !== null) {\n <svg [lucideIcon]=\"dataSource.emptyIcon ?? defaultEmptyIcon\" [size]=\"32\" [strokeWidth]=\"1.5\"></svg>\n }\n <p class=\"text-sm\">{{ dataSource.emptyMessage }}</p>\n </div>\n }\n </td>\n </tr>\n }\n\n <!-- Data rows -->\n @for (row of paginatedItems; track trackByID($index, row); let odd = $odd; let last = $last) {\n <tr\n class=\"bg-base-100 transition-colors duration-150 hover:cursor-pointer\"\n [ngClass]=\"{'bg-primary/10': isSelected(row)}\"\n [class.bg-base-200]=\"!isSelected(row) && odd && dataSource.appearance?.striped\"\n [class.hover:bg-base-200]=\"dataSource.appearance?.hover !== false\"\n [class.cursor-pointer]=\"!!dataSource.onRowClick\"\n [class.border-b]=\"!last\"\n [class.border-base-300]=\"!last\"\n [class.border-b-1]=\"last\"\n [class.border-black]=\"last\"\n [class.shadow-3xl]=\"last\"\n (click)=\"onRowClick(row)\"\n >\n <!-- Selection checkbox -->\n @if (hasSelection) {\n <td (click)=\"$event.stopPropagation()\" class=\"w-10 text-center px-2 py-2\">\n <mn-lib-checkbox\n (checkedChange)=\"toggle(row)\"\n [checked]=\"isSelected(row)\"\n [props]=\"{ id: 'mn-table-row-' + $index, size: 'sm' }\"\n ></mn-lib-checkbox>\n </td>\n }\n\n <!-- Data cells -->\n @for (column of dataSource.columns; track column.key) {\n <td\n [attr.title]=\"cellTitle(column, row)\"\n [class.text-left]=\"(column.align ?? 'left') === 'left'\"\n [class.text-center]=\"column.align === 'center'\"\n [class.text-right]=\"column.align === 'right'\"\n [class.truncate]=\"widthsArePinned\"\n [mnHiddenBelow]=\"column.hiddenBelow\"\n [style.width]=\"columnWidth(column)\"\n class=\"text-xs px-2 py-1 @min-[640px]:px-4 @min-[640px]:py-2\"\n >\n @if (column.cellSm) {\n <!-- Default cell: hidden below the cellSm breakpoint -->\n <span [mnShowAbove]=\"column.cellSm.below\">\n @if (isTemplateRef(column.cell)) {\n <ng-container [ngTemplateOutletContext]=\"{ $implicit: row, data: row }\"\n [ngTemplateOutlet]=\"$any(column.cell)\"></ng-container>\n } @else {\n {{ getCellValue(column, row) }}\n }\n </span>\n <!-- Small cell: shown only below the cellSm breakpoint -->\n <span [mnShowBelow]=\"column.cellSm.below\">\n @if (isTemplateRef(column.cellSm.cell)) {\n <ng-container [ngTemplateOutletContext]=\"{ $implicit: row, data: row }\"\n [ngTemplateOutlet]=\"$any(column.cellSm.cell)\"></ng-container>\n } @else {\n {{ getCellSmValue(column, row) }}\n }\n </span>\n } @else {\n @if (isTemplateRef(column.cell)) {\n <ng-container [ngTemplateOutletContext]=\"{ $implicit: row, data: row }\"\n [ngTemplateOutlet]=\"$any(column.cell)\"></ng-container>\n } @else {\n {{ getCellValue(column, row) }}\n }\n }\n </td>\n }\n\n </tr>\n }\n }\n </tbody>\n </table>\n</div>\n\n<!-- Load more + pagination -->\n<mn-collection-pagination\n (loadMore)=\"loadMoreRows()\"\n (pageChange)=\"goToPage($event)\"\n (pageSizeChange)=\"onPageSizeChange($event)\"\n [currentPage]=\"currentPage\"\n [isPaginated]=\"isPaginated\"\n [isServerPaginated]=\"isServerPaginated\"\n [labels]=\"dataSource.labels\"\n [loadingMoreRows]=\"loadingMoreRows\"\n [pageSizeSelectOptions]=\"pageSizeSelectOptions\"\n [pageSize]=\"pageSize\"\n [showLoadMore]=\"showLoadMore\"\n [totalItemCount]=\"totalItemCount\"\n [totalPages]=\"totalPages\"\n [visiblePages]=\"visiblePages\"\n idPrefix=\"mn-table\"\n></mn-collection-pagination>\n</div>\n\n<!-- Filter popover for the rich filter types. Rendered outside the table's\n overflow-x-auto wrapper and positioned fixed, so it can't be clipped or\n scrolled away by it. -->\n@if (openFilterColumn(); as popoverColumn) {\n <div\n #filterPopover\n [style.left.px]=\"popoverPosition.left\"\n [style.top.px]=\"popoverPosition.top\"\n class=\"fixed z-30 w-64 rounded-md border border-base-300 bg-base-100 p-3 shadow-lg\"\n role=\"dialog\"\n >\n <p class=\"mb-2 text-xs font-medium text-base-content/70\">\n @if (isTemplateRef(popoverColumn.header)) {\n <ng-container [ngTemplateOutlet]=\"$any(popoverColumn.header)\"></ng-container>\n } @else {\n {{ popoverColumn.header }}\n }\n </p>\n <ng-container\n [ngTemplateOutletContext]=\"{ $implicit: popoverColumn, idScope: 'popover' }\"\n [ngTemplateOutlet]=\"filterField\"\n ></ng-container>\n @if (isColumnFilterActive(popoverColumn)) {\n <button\n (click)=\"clearColumnFilter(popoverColumn)\"\n [data]=\"{ variant: 'text', color: 'gray', size: 'sm', borderRadius: 'md', hover: true }\"\n class=\"mt-2 gap-1\"\n mnButton\n type=\"button\"\n >\n <svg [size]=\"14\" lucideX></svg>\n <span>{{ clearFiltersButtonLabel }}</span>\n </button>\n }\n </div>\n}\n\n<!-- Single source of truth for every filter control, reused by the inline header\n row, the popover and the small-screen panel. `idScope` keeps element ids\n unique across the three placements. -->\n<ng-template #filterField let-column let-idScope=\"idScope\">\n @switch (filterTypeOf(column)) {\n @case ('select') {\n <mn-lib-select\n (ngModelChange)=\"onColumnFilter(column, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"textFilterValue(column)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key,\n options: getFilterSelectOptions(column),\n size: 'sm',\n fullWidth: true\n }\"\n ></mn-lib-select>\n }\n @case ('multi-select') {\n <mn-lib-multi-select\n (ngModelChange)=\"onColumnFilter(column, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"multiFilterValue(column)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key,\n options: getFilterMultiSelectOptions(column),\n placeholder: column.filterPlaceholder ?? '',\n collapseThreshold: 2,\n size: 'sm',\n fullWidth: true\n }\"\n ></mn-lib-multi-select>\n }\n @case ('boolean') {\n <mn-lib-select\n (ngModelChange)=\"onBooleanFilter(column, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"booleanFilterValue(column)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key,\n options: getBooleanFilterOptions(column),\n size: 'sm',\n fullWidth: true\n }\"\n ></mn-lib-select>\n }\n @case ('number-range') {\n <div class=\"flex items-end gap-2\">\n @for (bound of numberBounds; track bound) {\n <mn-lib-input-field\n (ngModelChange)=\"onRangeFilter(column, bound, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"rangeFilterValue(column, bound)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key + '-' + bound,\n type: 'number',\n label: rangeBoundLabel(bound),\n size: 'sm',\n borderRadius: 'md',\n fullWidth: true\n }\"\n class=\"flex-1\"\n ></mn-lib-input-field>\n }\n </div>\n }\n @case ('date-range') {\n <div class=\"flex flex-col gap-2\">\n @for (bound of dateBounds; track bound) {\n <mn-lib-datetime\n (ngModelChange)=\"onRangeFilter(column, bound, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"rangeFilterValue(column, bound)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key + '-' + bound,\n mode: 'date',\n label: rangeBoundLabel(bound),\n size: 'sm',\n borderRadius: 'md',\n fullWidth: true\n }\"\n ></mn-lib-datetime>\n }\n </div>\n }\n @default {\n <mn-lib-input-field\n (ngModelChange)=\"onColumnFilter(column, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"textFilterValue(column)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key,\n type: 'text',\n label: '',\n placeholder: column.filterPlaceholder ?? '',\n ariaLabel: column.filterPlaceholder ?? '',\n autocomplete: column.filterAutocomplete ?? undefined,\n size: 'sm',\n borderRadius: 'md',\n fullWidth: true,\n hover: true\n }\"\n ></mn-lib-input-field>\n }\n }\n</ng-template>\n", dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: MnCheckbox, selector: "mn-lib-checkbox", inputs: ["props", "checked"], outputs: ["checkedChange"] }, { kind: "directive", type: MnHiddenBelowDirective, selector: "[mnHiddenBelow]", inputs: ["mnHiddenBelow"] }, { kind: "directive", type: MnShowAboveDirective, selector: "[mnShowAbove]", inputs: ["mnShowAbove"] }, { kind: "directive", type: MnShowBelowDirective, selector: "[mnShowBelow]", inputs: ["mnShowBelow"] }, { kind: "component", type: MnInputField, selector: "mn-lib-input-field", inputs: ["props"] }, { kind: "component", type: MnSelect, selector: "mn-lib-select", inputs: ["props"] }, { kind: "component", type: MnMultiSelect, selector: "mn-lib-multi-select", inputs: ["props"] }, { kind: "component", type: MnDatetime, selector: "mn-lib-datetime", inputs: ["props"] }, { kind: "component", type: MnSkeleton, selector: "mn-skeleton", inputs: ["data"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: MnCollectionPagination, selector: "mn-collection-pagination", inputs: ["idPrefix", "isPaginated", "isServerPaginated", "showLoadMore", "loadingMoreRows", "currentPage", "pageSize", "totalPages", "totalItemCount", "visiblePages", "pageSizeSelectOptions", "labels"], outputs: ["loadMore", "pageChange", "pageSizeChange"] }, { kind: "component", type: MnButton, selector: "button[mnButton], a[mnButton]", inputs: ["data"] }, { kind: "component", type: LucideFilter, selector: "svg[lucideFunnel], svg[lucideFilter]" }, { kind: "component", type: LucideX, selector: "svg[lucideX]" }, { kind: "component", type: LucideDynamicIcon, selector: "svg[lucideIcon]", inputs: ["lucideIcon"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
6895
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnTable, isStandalone: true, selector: "mn-table", outputs: { sortChange: "sortChange", rowClick: "rowClick" }, host: { listeners: { "window:resize": "onWindowResize()" }, classAttribute: "block" }, viewQueries: [{ propertyName: "collectionBody", first: true, predicate: ["collectionBody"], descendants: true }], usesInheritance: true, ngImport: i0, template: "<!-- Everything that reflows lives inside a @container, so the breakpoints below\n measure the table's own width rather than the window's. A table in a modal,\n a sidebar or a narrow grid cell is far narrower than the viewport, and\n viewport breakpoints would hand it a desktop layout it has no room for. -->\n<div class=\"@container\">\n <!-- Selection summary: the whole selection, never paged, filtered or sorted. The\n table below answers \"what could I pick?\", which is why it is searched and paged;\n this answers \"what did I pick?\", which a paginated list cannot without hiding\n most of the answer on some other page. -->\n @if (showSelectionSummary) {\n <div class=\"flex flex-col gap-2 rounded-md border border-base-300 bg-base-200/50 p-3 mb-3\">\n <div class=\"flex items-center justify-between gap-2\">\n <span class=\"text-sm font-medium text-base-content\">{{ selectionSummaryTitle }}</span>\n <button\n (click)=\"clearSelection()\"\n [data]=\"{ variant: 'text', color: 'gray', size: 'sm', borderRadius: 'md', hover: true }\"\n class=\"gap-1 shrink-0\"\n mnButton\n type=\"button\"\n >\n <svg [size]=\"14\" lucideX></svg>\n <span>{{ selectionClearAllLabel }}</span>\n </button>\n </div>\n <!-- Only the first few tags render; the rest collapse behind a \"+N more\" control.\n The heading's count always states the true total, so this hides tags, never\n information. Expanded, the list is height-capped and scrolls, so even a\n selection of hundreds cannot push the table off screen. -->\n <ul\n [class.max-h-28]=\"selectionSummaryExpanded\"\n [class.overflow-y-auto]=\"selectionSummaryExpanded\"\n class=\"flex flex-wrap gap-1.5 list-none m-0 p-0\"\n >\n @for (row of visibleSelectionRows; track dataSource.getID(row)) {\n <li\n class=\"inline-flex items-center gap-1 rounded-full bg-base-100 border border-base-300 pl-2.5 pr-1 py-0.5 text-xs text-base-content max-w-full\">\n <span [attr.title]=\"selectionLabelFor(row)\" class=\"truncate\">{{ selectionLabelFor(row) }}</span>\n <button\n (click)=\"removeSelection(row)\"\n [attr.aria-label]=\"selectionRemoveLabel(row)\"\n class=\"shrink-0 rounded-full p-0.5 hover:bg-base-300 transition-colors cursor-pointer\"\n type=\"button\"\n >\n <svg [size]=\"12\" lucideX></svg>\n </button>\n </li>\n }\n @if (hiddenSelectionCount > 0 || selectionSummaryExpanded) {\n <li>\n <button\n (click)=\"toggleSelectionSummary()\"\n [attr.aria-expanded]=\"selectionSummaryExpanded\"\n [data]=\"{ variant: 'text', color: 'primary', size: 'sm', borderRadius: 'md', hover: true }\"\n class=\"text-xs\"\n mnButton\n type=\"button\"\n >\n {{ selectionSummaryToggleLabel }}\n </button>\n </li>\n }\n </ul>\n </div>\n }\n\n <!-- Toolbar: custom toolbar template + search. Only rendered when it has content,\n so an unsearchable table without toolbar templates emits no empty spacer. -->\n@if (dataSource.canSearch || dataSource.toolbarLeftTemplate || dataSource.toolbarRightTemplate || (hasColumnFilters && filtersCollapsed)) {\n <div class=\"flex flex-col @min-[420px]:flex-row @min-[420px]:items-center justify-between gap-2 mb-3\">\n <div class=\"flex items-center gap-2 w-full @min-[420px]:w-auto\">\n @if (dataSource.toolbarLeftTemplate) {\n <div class=\"flex flex-col w-full @min-[420px]:w-auto @min-[420px]:flex-row\">\n <ng-container [ngTemplateOutlet]=\"dataSource.toolbarLeftTemplate\"></ng-container>\n </div>\n }\n </div>\n <div\n class=\"flex flex-col @min-[420px]:flex-row @min-[420px]:items-center gap-2 w-full @min-[420px]:flex-1 @min-[560px]:flex-none @min-[560px]:w-auto\">\n @if (dataSource.canSearch) {\n <mn-lib-input-field\n class=\"w-full @min-[420px]:flex-1 @min-[560px]:max-w-64\"\n [props]=\"{\n id: 'mn-table-search',\n type: 'search',\n label: '',\n ariaLabel: dataSource.searchPlaceholder ?? 'Search...',\n placeholder: dataSource.searchPlaceholder ?? 'Search...',\n size: 'sm',\n borderRadius: 'md',\n fullWidth: true\n }\"\n [ngModel]=\"searchValue\"\n (ngModelChange)=\"onSearch($event)\"\n ></mn-lib-input-field>\n }\n @if (dataSource.toolbarRightTemplate) {\n <div class=\"flex flex-col w-full @min-[420px]:w-auto @min-[420px]:flex-row\">\n <ng-container [ngTemplateOutlet]=\"dataSource.toolbarRightTemplate\"></ng-container>\n </div>\n }\n <!-- Small-screen filter toggle: replaces the inline per-column filter row below 640px -->\n @if (hasColumnFilters && filtersCollapsed) {\n <button\n type=\"button\"\n mnButton\n [data]=\"{ variant: 'outline', color: 'primary', size: 'md', borderRadius: 'md', hover: true }\"\n class=\"w-full @min-[420px]:w-auto gap-1.5\"\n [attr.aria-expanded]=\"filtersPanelOpen\"\n aria-controls=\"mn-table-filters-panel\"\n (click)=\"toggleFiltersPanel()\"\n >\n <svg lucideFunnel [size]=\"15\"></svg>\n <span>{{ filtersButtonLabel }}</span>\n </button>\n }\n </div>\n</div>\n}\n\n<!-- Small-screen filter panel: stacked, full-width fields decoupled from column widths -->\n@if (hasColumnFilters && filtersCollapsed) {\n <div\n id=\"mn-table-filters-panel\"\n class=\"grid transition-all duration-200 ease-out motion-reduce:transition-none\"\n [style.grid-template-rows]=\"filtersPanelOpen ? '1fr' : '0fr'\"\n >\n <div class=\"overflow-hidden\" [attr.inert]=\"filtersPanelOpen ? null : ''\">\n <div class=\"flex flex-col gap-3 rounded-md border border-base-300 bg-base-100 p-3 mb-3\">\n @for (column of dataSource.columns; track column.key) {\n @if (column.filterable) {\n <div class=\"flex flex-col gap-1\">\n <label\n class=\"text-xs font-medium text-base-content/70\"\n [attr.for]=\"'mn-table-filter-' + column.key\"\n >\n @if (isTemplateRef(column.header)) {\n <ng-container [ngTemplateOutlet]=\"$any(column.header)\"></ng-container>\n } @else {\n {{ column.header }}\n }\n </label>\n <ng-container\n [ngTemplateOutletContext]=\"{ $implicit: column, idScope: 'panel' }\"\n [ngTemplateOutlet]=\"filterField\"\n ></ng-container>\n </div>\n }\n }\n @if (hasActiveFilters) {\n <button\n type=\"button\"\n mnButton\n [data]=\"{ variant: 'text', color: 'gray', size: 'sm', borderRadius: 'md', hover: true }\"\n class=\"self-start gap-1\"\n (click)=\"clearAllFilters()\"\n >\n <svg lucideX [size]=\"14\"></svg>\n <span>{{ clearFiltersButtonLabel }}</span>\n </button>\n }\n </div>\n </div>\n </div>\n}\n\n<!-- Table wrapper with horizontal scroll -->\n <div #collectionBody [style.min-height.px]=\"bodyMinHeight\" aria-label=\"Data table\"\n class=\"overflow-x-auto\"\n role=\"region\">\n <table [class]=\"tableClasses\">\n <thead>\n <tr class=\"bg-base-100\">\n <!-- Selection checkbox column header -->\n @if (hasSelection) {\n <th class=\"w-10 text-center text-sm bg-base-200 px-2 py-2\">\n @if (isMultiSelect) {\n <mn-lib-checkbox\n (checkedChange)=\"toggleAll()\"\n [checked]=\"allSelected\"\n [props]=\"{ id: 'mn-table-select-all', size: 'sm' }\"\n ></mn-lib-checkbox>\n }\n </th>\n }\n\n <!-- Data columns -->\n @for (column of dataSource.columns; track column.key) {\n <th\n [attr.data-column-key]=\"column.key\"\n [class.truncate]=\"widthsArePinned\"\n [class.cursor-pointer]=\"isSortable(column)\"\n [class.select-none]=\"isSortable(column)\"\n [class.hover:bg-base-200]=\"isSortable(column)\"\n [class.text-left]=\"(column.align ?? 'left') === 'left'\"\n [class.text-center]=\"column.align === 'center'\"\n [class.text-right]=\"column.align === 'right'\"\n [mnHiddenBelow]=\"column.hiddenBelow\"\n [style.width]=\"columnWidth(column)\"\n [attr.aria-sort]=\"currentSort?.columnKey === column.key ? (currentSort!.direction === 'asc' ? 'ascending' : 'descending') : null\"\n class=\"text-sm px-2 py-1 @min-[640px]:px-4 @min-[640px]:py-2 whitespace-nowrap\"\n (click)=\"sort(column)\"\n >\n <span class=\"inline-flex items-center gap-1\">\n @if (isTemplateRef(column.header)) {\n <ng-container [ngTemplateOutlet]=\"$any(column.header)\"></ng-container>\n } @else {\n <span>{{ column.header }}</span>\n }\n @if (isSortable(column)) {\n <span class=\"text-[0.65rem] opacity-70 min-w-3 inline-block\">{{ getSortIcon(column) }}</span>\n }\n </span>\n </th>\n }\n\n </tr>\n\n <!-- Per-column filter row (wide screens only; collapses into a panel below 640px).\n `font-normal` on the cells is load-bearing: these are `th` elements, which the\n browser renders bold, and the filter inputs and selects inside them inherit\n that. Filter controls are form fields, not headings, and must not look bold.\n The stacked filter panel renders outside any `th`, so it is unaffected. -->\n @if (hasColumnFilters && !filtersCollapsed) {\n <tr class=\"bg-base-100 border-b border-base-300 font-normal\">\n @if (hasSelection) {\n <th class=\"px-2 py-1 font-normal\"></th>\n }\n @for (column of dataSource.columns; track column.key) {\n <th\n class=\"px-4 py-2 font-normal\"\n [mnHiddenBelow]=\"column.hiddenBelow\"\n >\n @if (column.filterable) {\n <!-- Every filter renders as an ordinary control right under its\n header. The rich types used to hide behind a button that opened\n a floating panel, which cost a click to discover, a click to\n apply, and hid whether a column was even filterable. -->\n <ng-container\n [ngTemplateOutletContext]=\"{ $implicit: column, idScope: 'inline' }\"\n [ngTemplateOutlet]=\"filterField\"\n ></ng-container>\n }\n </th>\n }\n </tr>\n }\n </thead>\n\n <tbody>\n <!-- Loading state -->\n @if (isLoadingState) {\n @for (_ of skeletonRows; track $index) {\n <tr>\n @if (hasSelection) {\n <td class=\"px-2 py-3\">\n <mn-skeleton [data]=\"{ shape: 'rectangle', width: '1rem', height: '1rem' }\"></mn-skeleton>\n </td>\n }\n @for (column of dataSource.columns; track column.key) {\n <td class=\"px-4 py-3\"\n [mnHiddenBelow]=\"column.hiddenBelow\"\n [style.width]=\"columnWidth(column)\"\n >\n @if (isTemplateRef(column.skeleton)) {\n <ng-container [ngTemplateOutlet]=\"$any(column.skeleton)\"></ng-container>\n } @else {\n <mn-skeleton [data]=\"getColumnSkeletonData(column)\"></mn-skeleton>\n }\n </td>\n }\n </tr>\n }\n } @else if (isErrorState) {\n <!-- Error state -->\n <tr class=\"bg-base-100\">\n <td [attr.colspan]=\"totalColumnCount\" class=\"text-center text-xs py-8\">\n @if (dataSource.errorTemplate) {\n <ng-container [ngTemplateOutlet]=\"dataSource.errorTemplate\"></ng-container>\n } @else {\n <div class=\"flex flex-col items-center gap-2 text-error\">\n <p class=\"text-sm\">{{ dataSource.errorMessage }}</p>\n </div>\n }\n </td>\n </tr>\n } @else {\n <!-- Empty state -->\n @if (filteredItems.length === 0) {\n <tr class=\"bg-base-100\">\n <td [attr.colspan]=\"totalColumnCount\" class=\"text-center text-xs py-8\">\n @if (dataSource.emptyTemplate) {\n <ng-container [ngTemplateOutlet]=\"dataSource.emptyTemplate\"></ng-container>\n } @else {\n <div class=\"flex flex-col items-center gap-2 text-base-content/50\">\n @if (dataSource.emptyIcon !== null) {\n <svg [lucideIcon]=\"dataSource.emptyIcon ?? defaultEmptyIcon\" [size]=\"32\" [strokeWidth]=\"1.5\"></svg>\n }\n <p class=\"text-sm\">{{ dataSource.emptyMessage }}</p>\n </div>\n }\n </td>\n </tr>\n }\n\n <!-- Data rows -->\n @for (row of paginatedItems; track trackByID($index, row); let odd = $odd; let last = $last) {\n <tr\n class=\"bg-base-100 transition-colors duration-150 hover:cursor-pointer\"\n [ngClass]=\"{'bg-primary/10': isSelected(row)}\"\n [class.bg-base-200]=\"!isSelected(row) && odd && dataSource.appearance?.striped\"\n [class.hover:bg-base-200]=\"dataSource.appearance?.hover !== false\"\n [class.cursor-pointer]=\"!!dataSource.onRowClick\"\n [class.border-b]=\"!last\"\n [class.border-base-300]=\"!last\"\n [class.border-b-1]=\"last\"\n [class.border-black]=\"last\"\n [class.shadow-3xl]=\"last\"\n (click)=\"onRowClick(row)\"\n >\n <!-- Selection checkbox -->\n @if (hasSelection) {\n <td (click)=\"$event.stopPropagation()\" class=\"w-10 text-center px-2 py-2\">\n <mn-lib-checkbox\n (checkedChange)=\"toggle(row)\"\n [checked]=\"isSelected(row)\"\n [props]=\"{ id: 'mn-table-row-' + $index, size: 'sm' }\"\n ></mn-lib-checkbox>\n </td>\n }\n\n <!-- Data cells -->\n @for (column of dataSource.columns; track column.key) {\n <td\n [attr.title]=\"cellTitle(column, row)\"\n [class.text-left]=\"(column.align ?? 'left') === 'left'\"\n [class.text-center]=\"column.align === 'center'\"\n [class.text-right]=\"column.align === 'right'\"\n [class.truncate]=\"widthsArePinned\"\n [mnHiddenBelow]=\"column.hiddenBelow\"\n [style.width]=\"columnWidth(column)\"\n class=\"text-xs px-2 py-1 @min-[640px]:px-4 @min-[640px]:py-2\"\n >\n @if (column.cellSm) {\n <!-- Default cell: hidden below the cellSm breakpoint -->\n <span [mnShowAbove]=\"column.cellSm.below\">\n @if (isTemplateRef(column.cell)) {\n <ng-container [ngTemplateOutletContext]=\"{ $implicit: row, data: row }\"\n [ngTemplateOutlet]=\"$any(column.cell)\"></ng-container>\n } @else {\n {{ getCellValue(column, row) }}\n }\n </span>\n <!-- Small cell: shown only below the cellSm breakpoint -->\n <span [mnShowBelow]=\"column.cellSm.below\">\n @if (isTemplateRef(column.cellSm.cell)) {\n <ng-container [ngTemplateOutletContext]=\"{ $implicit: row, data: row }\"\n [ngTemplateOutlet]=\"$any(column.cellSm.cell)\"></ng-container>\n } @else {\n {{ getCellSmValue(column, row) }}\n }\n </span>\n } @else {\n @if (isTemplateRef(column.cell)) {\n <ng-container [ngTemplateOutletContext]=\"{ $implicit: row, data: row }\"\n [ngTemplateOutlet]=\"$any(column.cell)\"></ng-container>\n } @else {\n {{ getCellValue(column, row) }}\n }\n }\n </td>\n }\n\n </tr>\n }\n }\n </tbody>\n </table>\n</div>\n\n<!-- Load more + pagination -->\n<mn-collection-pagination\n (loadMore)=\"loadMoreRows()\"\n (pageChange)=\"goToPage($event)\"\n (pageSizeChange)=\"onPageSizeChange($event)\"\n [currentPage]=\"currentPage\"\n [isPaginated]=\"isPaginated\"\n [isServerPaginated]=\"isServerPaginated\"\n [labels]=\"dataSource.labels\"\n [loadingMoreRows]=\"loadingMoreRows\"\n [pageSizeSelectOptions]=\"pageSizeSelectOptions\"\n [pageSize]=\"pageSize\"\n [showLoadMore]=\"showLoadMore\"\n [totalItemCount]=\"totalItemCount\"\n [totalPages]=\"totalPages\"\n [visiblePages]=\"visiblePages\"\n idPrefix=\"mn-table\"\n></mn-collection-pagination>\n</div>\n\n<!-- Single source of truth for every filter control, reused by the inline header\n row and the small-screen panel. `idScope` keeps element ids unique across\n both placements. -->\n<ng-template #filterField let-column let-idScope=\"idScope\">\n @switch (filterTypeOf(column)) {\n @case ('select') {\n <mn-lib-select\n (ngModelChange)=\"onColumnFilter(column, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"textFilterValue(column)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key,\n options: getFilterSelectOptions(column),\n size: 'sm',\n fullWidth: true\n }\"\n ></mn-lib-select>\n }\n @case ('multi-select') {\n <mn-lib-multi-select\n (ngModelChange)=\"onColumnFilter(column, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"multiFilterValue(column)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key,\n options: getFilterMultiSelectOptions(column),\n placeholder: column.filterPlaceholder ?? '',\n collapsePlaceholder: filterSelectedLabel,\n collapseThreshold: 1,\n size: 'sm',\n fullWidth: true\n }\"\n ></mn-lib-multi-select>\n }\n @case ('boolean') {\n <mn-lib-select\n (ngModelChange)=\"onBooleanFilter(column, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"booleanFilterValue(column)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key,\n options: getBooleanFilterOptions(column),\n size: 'sm',\n fullWidth: true\n }\"\n ></mn-lib-select>\n }\n @case ('number-range') {\n <div class=\"flex items-end gap-2\">\n @for (bound of numberBounds; track bound) {\n <mn-lib-input-field\n (ngModelChange)=\"onRangeFilter(column, bound, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"rangeFilterValue(column, bound)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key + '-' + bound,\n type: 'number',\n label: rangeBoundLabel(bound),\n size: 'sm',\n borderRadius: 'md',\n fullWidth: true\n }\"\n class=\"flex-1\"\n ></mn-lib-input-field>\n }\n </div>\n }\n @case ('date-range') {\n <div class=\"flex flex-col gap-2\">\n @for (bound of dateBounds; track bound) {\n <mn-lib-datetime\n (ngModelChange)=\"onRangeFilter(column, bound, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"rangeFilterValue(column, bound)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key + '-' + bound,\n mode: 'date',\n label: rangeBoundLabel(bound),\n size: 'sm',\n borderRadius: 'md',\n fullWidth: true\n }\"\n ></mn-lib-datetime>\n }\n </div>\n }\n @default {\n <mn-lib-input-field\n (ngModelChange)=\"onColumnFilter(column, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"textFilterValue(column)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key,\n type: 'text',\n label: '',\n placeholder: column.filterPlaceholder ?? '',\n ariaLabel: column.filterPlaceholder ?? '',\n autocomplete: column.filterAutocomplete ?? undefined,\n size: 'sm',\n borderRadius: 'md',\n fullWidth: true,\n hover: true\n }\"\n ></mn-lib-input-field>\n }\n }\n</ng-template>\n", dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: MnCheckbox, selector: "mn-lib-checkbox", inputs: ["props", "checked"], outputs: ["checkedChange"] }, { kind: "directive", type: MnHiddenBelowDirective, selector: "[mnHiddenBelow]", inputs: ["mnHiddenBelow"] }, { kind: "directive", type: MnShowAboveDirective, selector: "[mnShowAbove]", inputs: ["mnShowAbove"] }, { kind: "directive", type: MnShowBelowDirective, selector: "[mnShowBelow]", inputs: ["mnShowBelow"] }, { kind: "component", type: MnInputField, selector: "mn-lib-input-field", inputs: ["props"] }, { kind: "component", type: MnSelect, selector: "mn-lib-select", inputs: ["props"] }, { kind: "component", type: MnMultiSelect, selector: "mn-lib-multi-select", inputs: ["props"] }, { kind: "component", type: MnDatetime, selector: "mn-lib-datetime", inputs: ["props"] }, { kind: "component", type: MnSkeleton, selector: "mn-skeleton", inputs: ["data"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: MnCollectionPagination, selector: "mn-collection-pagination", inputs: ["idPrefix", "isPaginated", "isServerPaginated", "showLoadMore", "loadingMoreRows", "currentPage", "pageSize", "totalPages", "totalItemCount", "visiblePages", "pageSizeSelectOptions", "labels"], outputs: ["loadMore", "pageChange", "pageSizeChange"] }, { kind: "component", type: MnButton, selector: "button[mnButton], a[mnButton]", inputs: ["data"] }, { kind: "component", type: LucideFilter, selector: "svg[lucideFunnel], svg[lucideFilter]" }, { kind: "component", type: LucideX, selector: "svg[lucideX]" }, { kind: "component", type: LucideDynamicIcon, selector: "svg[lucideIcon]", inputs: ["lucideIcon"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
6698
6896
|
}
|
|
6699
6897
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnTable, decorators: [{
|
|
6700
6898
|
type: Component,
|
|
6701
|
-
args: [{ selector: 'mn-table', standalone: true, imports: [NgClass, NgTemplateOutlet, MnCheckbox, MnHiddenBelowDirective, MnShowAboveDirective, MnShowBelowDirective, MnInputField, MnSelect, MnMultiSelect, MnDatetime, MnSkeleton, FormsModule, MnCollectionPagination, MnButton, LucideFilter, LucideX, LucideFunnel, LucideDynamicIcon], changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'block' }, template: "<!-- Everything that reflows lives inside a @container, so the breakpoints below\n measure the table's own width rather than the window's. A table in a modal,\n a sidebar or a narrow grid cell is far narrower than the viewport, and\n viewport breakpoints would hand it a desktop layout it has no room for.\n The filter popover is deliberately left outside: `container-type` makes an\n element the containing block for `position: fixed` descendants, which would\n re-anchor the popover away from its trigger. -->\n<div class=\"@container\">\n<!-- Toolbar: custom toolbar template + search. Only rendered when it has content,\n so an unsearchable table without toolbar templates emits no empty spacer. -->\n@if (dataSource.canSearch || dataSource.toolbarLeftTemplate || dataSource.toolbarRightTemplate || (hasColumnFilters && filtersCollapsed)) {\n <div class=\"flex flex-col @min-[420px]:flex-row @min-[420px]:items-center justify-between gap-2 mb-3\">\n <div class=\"flex items-center gap-2 w-full @min-[420px]:w-auto\">\n @if (dataSource.toolbarLeftTemplate) {\n <div class=\"flex flex-col w-full @min-[420px]:w-auto @min-[420px]:flex-row\">\n <ng-container [ngTemplateOutlet]=\"dataSource.toolbarLeftTemplate\"></ng-container>\n </div>\n }\n </div>\n <div\n class=\"flex flex-col @min-[420px]:flex-row @min-[420px]:items-center gap-2 w-full @min-[420px]:flex-1 @min-[560px]:flex-none @min-[560px]:w-auto\">\n @if (dataSource.canSearch) {\n <mn-lib-input-field\n class=\"w-full @min-[420px]:flex-1 @min-[560px]:max-w-64\"\n [props]=\"{\n id: 'mn-table-search',\n type: 'search',\n label: '',\n ariaLabel: dataSource.searchPlaceholder ?? 'Search...',\n placeholder: dataSource.searchPlaceholder ?? 'Search...',\n size: 'sm',\n borderRadius: 'md',\n fullWidth: true\n }\"\n [ngModel]=\"searchValue\"\n (ngModelChange)=\"onSearch($event)\"\n ></mn-lib-input-field>\n }\n @if (dataSource.toolbarRightTemplate) {\n <div class=\"flex flex-col w-full @min-[420px]:w-auto @min-[420px]:flex-row\">\n <ng-container [ngTemplateOutlet]=\"dataSource.toolbarRightTemplate\"></ng-container>\n </div>\n }\n <!-- Small-screen filter toggle: replaces the inline per-column filter row below 640px -->\n @if (hasColumnFilters && filtersCollapsed) {\n <button\n type=\"button\"\n mnButton\n [data]=\"{ variant: 'outline', color: 'primary', size: 'md', borderRadius: 'md', hover: true }\"\n class=\"w-full @min-[420px]:w-auto gap-1.5\"\n [attr.aria-expanded]=\"filtersPanelOpen\"\n aria-controls=\"mn-table-filters-panel\"\n (click)=\"toggleFiltersPanel()\"\n >\n <svg lucideFunnel [size]=\"15\"></svg>\n <span>{{ filtersButtonLabel }}</span>\n </button>\n }\n </div>\n</div>\n}\n\n<!-- Small-screen filter panel: stacked, full-width fields decoupled from column widths -->\n@if (hasColumnFilters && filtersCollapsed) {\n <div\n id=\"mn-table-filters-panel\"\n class=\"grid transition-all duration-200 ease-out motion-reduce:transition-none\"\n [style.grid-template-rows]=\"filtersPanelOpen ? '1fr' : '0fr'\"\n >\n <div class=\"overflow-hidden\" [attr.inert]=\"filtersPanelOpen ? null : ''\">\n <div class=\"flex flex-col gap-3 rounded-md border border-base-300 bg-base-100 p-3 mb-3\">\n @for (column of dataSource.columns; track column.key) {\n @if (column.filterable) {\n <div class=\"flex flex-col gap-1\">\n <label\n class=\"text-xs font-medium text-base-content/70\"\n [attr.for]=\"'mn-table-filter-' + column.key\"\n >\n @if (isTemplateRef(column.header)) {\n <ng-container [ngTemplateOutlet]=\"$any(column.header)\"></ng-container>\n } @else {\n {{ column.header }}\n }\n </label>\n <ng-container\n [ngTemplateOutletContext]=\"{ $implicit: column, idScope: 'panel' }\"\n [ngTemplateOutlet]=\"filterField\"\n ></ng-container>\n </div>\n }\n }\n @if (hasActiveFilters) {\n <button\n type=\"button\"\n mnButton\n [data]=\"{ variant: 'text', color: 'gray', size: 'sm', borderRadius: 'md', hover: true }\"\n class=\"self-start gap-1\"\n (click)=\"clearAllFilters()\"\n >\n <svg lucideX [size]=\"14\"></svg>\n <span>{{ clearFiltersButtonLabel }}</span>\n </button>\n }\n </div>\n </div>\n </div>\n}\n\n<!-- Table wrapper with horizontal scroll -->\n<div #collectionBody (scroll)=\"closeFilterPopover()\" [style.min-height.px]=\"bodyMinHeight\" aria-label=\"Data table\"\n class=\"overflow-x-auto\"\n role=\"region\">\n <table [class]=\"tableClasses\">\n <thead>\n <tr class=\"bg-base-100\">\n <!-- Selection checkbox column header -->\n @if (hasSelection) {\n <th class=\"w-10 text-center text-sm bg-base-200 px-2 py-2\">\n @if (isMultiSelect) {\n <mn-lib-checkbox\n (checkedChange)=\"toggleAll()\"\n [checked]=\"allSelected\"\n [props]=\"{ id: 'mn-table-select-all', size: 'sm' }\"\n ></mn-lib-checkbox>\n }\n </th>\n }\n\n <!-- Data columns -->\n @for (column of dataSource.columns; track column.key) {\n <th\n [attr.data-column-key]=\"column.key\"\n [class.truncate]=\"widthsArePinned\"\n [class.cursor-pointer]=\"isSortable(column)\"\n [class.select-none]=\"isSortable(column)\"\n [class.hover:bg-base-200]=\"isSortable(column)\"\n [class.text-left]=\"(column.align ?? 'left') === 'left'\"\n [class.text-center]=\"column.align === 'center'\"\n [class.text-right]=\"column.align === 'right'\"\n [mnHiddenBelow]=\"column.hiddenBelow\"\n [style.width]=\"columnWidth(column)\"\n [attr.aria-sort]=\"currentSort?.columnKey === column.key ? (currentSort!.direction === 'asc' ? 'ascending' : 'descending') : null\"\n class=\"text-sm px-2 py-1 @min-[640px]:px-4 @min-[640px]:py-2 whitespace-nowrap\"\n (click)=\"sort(column)\"\n >\n <span class=\"inline-flex items-center gap-1\">\n @if (isTemplateRef(column.header)) {\n <ng-container [ngTemplateOutlet]=\"$any(column.header)\"></ng-container>\n } @else {\n <span>{{ column.header }}</span>\n }\n @if (isSortable(column)) {\n <span class=\"text-[0.65rem] opacity-70 min-w-3 inline-block\">{{ getSortIcon(column) }}</span>\n }\n </span>\n </th>\n }\n\n </tr>\n\n <!-- Per-column filter row (wide screens only; collapses into a panel below 640px).\n `font-normal` on the cells is load-bearing: these are `th` elements, which the\n browser renders bold, and the filter inputs and selects inside them inherit\n that. Filter controls are form fields, not headings, and must not look bold.\n The stacked panel and the popover render outside any `th`, so they are fine. -->\n @if (hasColumnFilters && !filtersCollapsed) {\n <tr class=\"bg-base-100 border-b border-base-300 font-normal\">\n @if (hasSelection) {\n <th class=\"px-2 py-1 font-normal\"></th>\n }\n @for (column of dataSource.columns; track column.key) {\n <th\n class=\"px-4 py-2 font-normal\"\n [mnHiddenBelow]=\"column.hiddenBelow\"\n >\n @if (column.filterable) {\n @if (isInlineFilter(column)) {\n <!-- Compact types render directly under the header -->\n <ng-container\n [ngTemplateOutletContext]=\"{ $implicit: column, idScope: 'inline' }\"\n [ngTemplateOutlet]=\"filterField\"\n ></ng-container>\n } @else {\n <!-- Ranges and multi-select don't fit a column width: trigger a popover -->\n <button\n (click)=\"$event.stopPropagation(); toggleFilterPopover(column, $event)\"\n [attr.aria-expanded]=\"openFilterKey === column.key\"\n [attr.aria-label]=\"column.filterPlaceholder ?? filtersButtonLabel\"\n [data]=\"{ variant: isColumnFilterActive(column) ? 'fill' : 'outline', color: 'primary', size: 'sm', borderRadius: 'md', hover: true }\"\n class=\"w-full gap-1.5\"\n mnButton\n type=\"button\"\n >\n <svg [size]=\"14\" lucideFilter></svg>\n @if (isColumnFilterActive(column)) {\n <span class=\"h-1.5 w-1.5 rounded-full bg-current\"></span>\n }\n </button>\n }\n }\n </th>\n }\n </tr>\n }\n </thead>\n\n <tbody>\n <!-- Loading state -->\n @if (isLoadingState) {\n @for (_ of skeletonRows; track $index) {\n <tr>\n @if (hasSelection) {\n <td class=\"px-2 py-3\">\n <mn-skeleton [data]=\"{ shape: 'rectangle', width: '1rem', height: '1rem' }\"></mn-skeleton>\n </td>\n }\n @for (column of dataSource.columns; track column.key) {\n <td class=\"px-4 py-3\"\n [mnHiddenBelow]=\"column.hiddenBelow\"\n [style.width]=\"columnWidth(column)\"\n >\n @if (isTemplateRef(column.skeleton)) {\n <ng-container [ngTemplateOutlet]=\"$any(column.skeleton)\"></ng-container>\n } @else {\n <mn-skeleton [data]=\"getColumnSkeletonData(column)\"></mn-skeleton>\n }\n </td>\n }\n </tr>\n }\n } @else if (isErrorState) {\n <!-- Error state -->\n <tr class=\"bg-base-100\">\n <td [attr.colspan]=\"totalColumnCount\" class=\"text-center text-xs py-8\">\n @if (dataSource.errorTemplate) {\n <ng-container [ngTemplateOutlet]=\"dataSource.errorTemplate\"></ng-container>\n } @else {\n <div class=\"flex flex-col items-center gap-2 text-error\">\n <p class=\"text-sm\">{{ dataSource.errorMessage }}</p>\n </div>\n }\n </td>\n </tr>\n } @else {\n <!-- Empty state -->\n @if (filteredItems.length === 0) {\n <tr class=\"bg-base-100\">\n <td [attr.colspan]=\"totalColumnCount\" class=\"text-center text-xs py-8\">\n @if (dataSource.emptyTemplate) {\n <ng-container [ngTemplateOutlet]=\"dataSource.emptyTemplate\"></ng-container>\n } @else {\n <div class=\"flex flex-col items-center gap-2 text-base-content/50\">\n @if (dataSource.emptyIcon !== null) {\n <svg [lucideIcon]=\"dataSource.emptyIcon ?? defaultEmptyIcon\" [size]=\"32\" [strokeWidth]=\"1.5\"></svg>\n }\n <p class=\"text-sm\">{{ dataSource.emptyMessage }}</p>\n </div>\n }\n </td>\n </tr>\n }\n\n <!-- Data rows -->\n @for (row of paginatedItems; track trackByID($index, row); let odd = $odd; let last = $last) {\n <tr\n class=\"bg-base-100 transition-colors duration-150 hover:cursor-pointer\"\n [ngClass]=\"{'bg-primary/10': isSelected(row)}\"\n [class.bg-base-200]=\"!isSelected(row) && odd && dataSource.appearance?.striped\"\n [class.hover:bg-base-200]=\"dataSource.appearance?.hover !== false\"\n [class.cursor-pointer]=\"!!dataSource.onRowClick\"\n [class.border-b]=\"!last\"\n [class.border-base-300]=\"!last\"\n [class.border-b-1]=\"last\"\n [class.border-black]=\"last\"\n [class.shadow-3xl]=\"last\"\n (click)=\"onRowClick(row)\"\n >\n <!-- Selection checkbox -->\n @if (hasSelection) {\n <td (click)=\"$event.stopPropagation()\" class=\"w-10 text-center px-2 py-2\">\n <mn-lib-checkbox\n (checkedChange)=\"toggle(row)\"\n [checked]=\"isSelected(row)\"\n [props]=\"{ id: 'mn-table-row-' + $index, size: 'sm' }\"\n ></mn-lib-checkbox>\n </td>\n }\n\n <!-- Data cells -->\n @for (column of dataSource.columns; track column.key) {\n <td\n [attr.title]=\"cellTitle(column, row)\"\n [class.text-left]=\"(column.align ?? 'left') === 'left'\"\n [class.text-center]=\"column.align === 'center'\"\n [class.text-right]=\"column.align === 'right'\"\n [class.truncate]=\"widthsArePinned\"\n [mnHiddenBelow]=\"column.hiddenBelow\"\n [style.width]=\"columnWidth(column)\"\n class=\"text-xs px-2 py-1 @min-[640px]:px-4 @min-[640px]:py-2\"\n >\n @if (column.cellSm) {\n <!-- Default cell: hidden below the cellSm breakpoint -->\n <span [mnShowAbove]=\"column.cellSm.below\">\n @if (isTemplateRef(column.cell)) {\n <ng-container [ngTemplateOutletContext]=\"{ $implicit: row, data: row }\"\n [ngTemplateOutlet]=\"$any(column.cell)\"></ng-container>\n } @else {\n {{ getCellValue(column, row) }}\n }\n </span>\n <!-- Small cell: shown only below the cellSm breakpoint -->\n <span [mnShowBelow]=\"column.cellSm.below\">\n @if (isTemplateRef(column.cellSm.cell)) {\n <ng-container [ngTemplateOutletContext]=\"{ $implicit: row, data: row }\"\n [ngTemplateOutlet]=\"$any(column.cellSm.cell)\"></ng-container>\n } @else {\n {{ getCellSmValue(column, row) }}\n }\n </span>\n } @else {\n @if (isTemplateRef(column.cell)) {\n <ng-container [ngTemplateOutletContext]=\"{ $implicit: row, data: row }\"\n [ngTemplateOutlet]=\"$any(column.cell)\"></ng-container>\n } @else {\n {{ getCellValue(column, row) }}\n }\n }\n </td>\n }\n\n </tr>\n }\n }\n </tbody>\n </table>\n</div>\n\n<!-- Load more + pagination -->\n<mn-collection-pagination\n (loadMore)=\"loadMoreRows()\"\n (pageChange)=\"goToPage($event)\"\n (pageSizeChange)=\"onPageSizeChange($event)\"\n [currentPage]=\"currentPage\"\n [isPaginated]=\"isPaginated\"\n [isServerPaginated]=\"isServerPaginated\"\n [labels]=\"dataSource.labels\"\n [loadingMoreRows]=\"loadingMoreRows\"\n [pageSizeSelectOptions]=\"pageSizeSelectOptions\"\n [pageSize]=\"pageSize\"\n [showLoadMore]=\"showLoadMore\"\n [totalItemCount]=\"totalItemCount\"\n [totalPages]=\"totalPages\"\n [visiblePages]=\"visiblePages\"\n idPrefix=\"mn-table\"\n></mn-collection-pagination>\n</div>\n\n<!-- Filter popover for the rich filter types. Rendered outside the table's\n overflow-x-auto wrapper and positioned fixed, so it can't be clipped or\n scrolled away by it. -->\n@if (openFilterColumn(); as popoverColumn) {\n <div\n #filterPopover\n [style.left.px]=\"popoverPosition.left\"\n [style.top.px]=\"popoverPosition.top\"\n class=\"fixed z-30 w-64 rounded-md border border-base-300 bg-base-100 p-3 shadow-lg\"\n role=\"dialog\"\n >\n <p class=\"mb-2 text-xs font-medium text-base-content/70\">\n @if (isTemplateRef(popoverColumn.header)) {\n <ng-container [ngTemplateOutlet]=\"$any(popoverColumn.header)\"></ng-container>\n } @else {\n {{ popoverColumn.header }}\n }\n </p>\n <ng-container\n [ngTemplateOutletContext]=\"{ $implicit: popoverColumn, idScope: 'popover' }\"\n [ngTemplateOutlet]=\"filterField\"\n ></ng-container>\n @if (isColumnFilterActive(popoverColumn)) {\n <button\n (click)=\"clearColumnFilter(popoverColumn)\"\n [data]=\"{ variant: 'text', color: 'gray', size: 'sm', borderRadius: 'md', hover: true }\"\n class=\"mt-2 gap-1\"\n mnButton\n type=\"button\"\n >\n <svg [size]=\"14\" lucideX></svg>\n <span>{{ clearFiltersButtonLabel }}</span>\n </button>\n }\n </div>\n}\n\n<!-- Single source of truth for every filter control, reused by the inline header\n row, the popover and the small-screen panel. `idScope` keeps element ids\n unique across the three placements. -->\n<ng-template #filterField let-column let-idScope=\"idScope\">\n @switch (filterTypeOf(column)) {\n @case ('select') {\n <mn-lib-select\n (ngModelChange)=\"onColumnFilter(column, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"textFilterValue(column)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key,\n options: getFilterSelectOptions(column),\n size: 'sm',\n fullWidth: true\n }\"\n ></mn-lib-select>\n }\n @case ('multi-select') {\n <mn-lib-multi-select\n (ngModelChange)=\"onColumnFilter(column, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"multiFilterValue(column)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key,\n options: getFilterMultiSelectOptions(column),\n placeholder: column.filterPlaceholder ?? '',\n collapseThreshold: 2,\n size: 'sm',\n fullWidth: true\n }\"\n ></mn-lib-multi-select>\n }\n @case ('boolean') {\n <mn-lib-select\n (ngModelChange)=\"onBooleanFilter(column, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"booleanFilterValue(column)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key,\n options: getBooleanFilterOptions(column),\n size: 'sm',\n fullWidth: true\n }\"\n ></mn-lib-select>\n }\n @case ('number-range') {\n <div class=\"flex items-end gap-2\">\n @for (bound of numberBounds; track bound) {\n <mn-lib-input-field\n (ngModelChange)=\"onRangeFilter(column, bound, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"rangeFilterValue(column, bound)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key + '-' + bound,\n type: 'number',\n label: rangeBoundLabel(bound),\n size: 'sm',\n borderRadius: 'md',\n fullWidth: true\n }\"\n class=\"flex-1\"\n ></mn-lib-input-field>\n }\n </div>\n }\n @case ('date-range') {\n <div class=\"flex flex-col gap-2\">\n @for (bound of dateBounds; track bound) {\n <mn-lib-datetime\n (ngModelChange)=\"onRangeFilter(column, bound, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"rangeFilterValue(column, bound)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key + '-' + bound,\n mode: 'date',\n label: rangeBoundLabel(bound),\n size: 'sm',\n borderRadius: 'md',\n fullWidth: true\n }\"\n ></mn-lib-datetime>\n }\n </div>\n }\n @default {\n <mn-lib-input-field\n (ngModelChange)=\"onColumnFilter(column, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"textFilterValue(column)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key,\n type: 'text',\n label: '',\n placeholder: column.filterPlaceholder ?? '',\n ariaLabel: column.filterPlaceholder ?? '',\n autocomplete: column.filterAutocomplete ?? undefined,\n size: 'sm',\n borderRadius: 'md',\n fullWidth: true,\n hover: true\n }\"\n ></mn-lib-input-field>\n }\n }\n</ng-template>\n" }]
|
|
6899
|
+
args: [{ selector: 'mn-table', standalone: true, imports: [NgClass, NgTemplateOutlet, MnCheckbox, MnHiddenBelowDirective, MnShowAboveDirective, MnShowBelowDirective, MnInputField, MnSelect, MnMultiSelect, MnDatetime, MnSkeleton, FormsModule, MnCollectionPagination, MnButton, LucideFilter, LucideX, LucideFunnel, LucideDynamicIcon], changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'block' }, template: "<!-- Everything that reflows lives inside a @container, so the breakpoints below\n measure the table's own width rather than the window's. A table in a modal,\n a sidebar or a narrow grid cell is far narrower than the viewport, and\n viewport breakpoints would hand it a desktop layout it has no room for. -->\n<div class=\"@container\">\n <!-- Selection summary: the whole selection, never paged, filtered or sorted. The\n table below answers \"what could I pick?\", which is why it is searched and paged;\n this answers \"what did I pick?\", which a paginated list cannot without hiding\n most of the answer on some other page. -->\n @if (showSelectionSummary) {\n <div class=\"flex flex-col gap-2 rounded-md border border-base-300 bg-base-200/50 p-3 mb-3\">\n <div class=\"flex items-center justify-between gap-2\">\n <span class=\"text-sm font-medium text-base-content\">{{ selectionSummaryTitle }}</span>\n <button\n (click)=\"clearSelection()\"\n [data]=\"{ variant: 'text', color: 'gray', size: 'sm', borderRadius: 'md', hover: true }\"\n class=\"gap-1 shrink-0\"\n mnButton\n type=\"button\"\n >\n <svg [size]=\"14\" lucideX></svg>\n <span>{{ selectionClearAllLabel }}</span>\n </button>\n </div>\n <!-- Only the first few tags render; the rest collapse behind a \"+N more\" control.\n The heading's count always states the true total, so this hides tags, never\n information. Expanded, the list is height-capped and scrolls, so even a\n selection of hundreds cannot push the table off screen. -->\n <ul\n [class.max-h-28]=\"selectionSummaryExpanded\"\n [class.overflow-y-auto]=\"selectionSummaryExpanded\"\n class=\"flex flex-wrap gap-1.5 list-none m-0 p-0\"\n >\n @for (row of visibleSelectionRows; track dataSource.getID(row)) {\n <li\n class=\"inline-flex items-center gap-1 rounded-full bg-base-100 border border-base-300 pl-2.5 pr-1 py-0.5 text-xs text-base-content max-w-full\">\n <span [attr.title]=\"selectionLabelFor(row)\" class=\"truncate\">{{ selectionLabelFor(row) }}</span>\n <button\n (click)=\"removeSelection(row)\"\n [attr.aria-label]=\"selectionRemoveLabel(row)\"\n class=\"shrink-0 rounded-full p-0.5 hover:bg-base-300 transition-colors cursor-pointer\"\n type=\"button\"\n >\n <svg [size]=\"12\" lucideX></svg>\n </button>\n </li>\n }\n @if (hiddenSelectionCount > 0 || selectionSummaryExpanded) {\n <li>\n <button\n (click)=\"toggleSelectionSummary()\"\n [attr.aria-expanded]=\"selectionSummaryExpanded\"\n [data]=\"{ variant: 'text', color: 'primary', size: 'sm', borderRadius: 'md', hover: true }\"\n class=\"text-xs\"\n mnButton\n type=\"button\"\n >\n {{ selectionSummaryToggleLabel }}\n </button>\n </li>\n }\n </ul>\n </div>\n }\n\n <!-- Toolbar: custom toolbar template + search. Only rendered when it has content,\n so an unsearchable table without toolbar templates emits no empty spacer. -->\n@if (dataSource.canSearch || dataSource.toolbarLeftTemplate || dataSource.toolbarRightTemplate || (hasColumnFilters && filtersCollapsed)) {\n <div class=\"flex flex-col @min-[420px]:flex-row @min-[420px]:items-center justify-between gap-2 mb-3\">\n <div class=\"flex items-center gap-2 w-full @min-[420px]:w-auto\">\n @if (dataSource.toolbarLeftTemplate) {\n <div class=\"flex flex-col w-full @min-[420px]:w-auto @min-[420px]:flex-row\">\n <ng-container [ngTemplateOutlet]=\"dataSource.toolbarLeftTemplate\"></ng-container>\n </div>\n }\n </div>\n <div\n class=\"flex flex-col @min-[420px]:flex-row @min-[420px]:items-center gap-2 w-full @min-[420px]:flex-1 @min-[560px]:flex-none @min-[560px]:w-auto\">\n @if (dataSource.canSearch) {\n <mn-lib-input-field\n class=\"w-full @min-[420px]:flex-1 @min-[560px]:max-w-64\"\n [props]=\"{\n id: 'mn-table-search',\n type: 'search',\n label: '',\n ariaLabel: dataSource.searchPlaceholder ?? 'Search...',\n placeholder: dataSource.searchPlaceholder ?? 'Search...',\n size: 'sm',\n borderRadius: 'md',\n fullWidth: true\n }\"\n [ngModel]=\"searchValue\"\n (ngModelChange)=\"onSearch($event)\"\n ></mn-lib-input-field>\n }\n @if (dataSource.toolbarRightTemplate) {\n <div class=\"flex flex-col w-full @min-[420px]:w-auto @min-[420px]:flex-row\">\n <ng-container [ngTemplateOutlet]=\"dataSource.toolbarRightTemplate\"></ng-container>\n </div>\n }\n <!-- Small-screen filter toggle: replaces the inline per-column filter row below 640px -->\n @if (hasColumnFilters && filtersCollapsed) {\n <button\n type=\"button\"\n mnButton\n [data]=\"{ variant: 'outline', color: 'primary', size: 'md', borderRadius: 'md', hover: true }\"\n class=\"w-full @min-[420px]:w-auto gap-1.5\"\n [attr.aria-expanded]=\"filtersPanelOpen\"\n aria-controls=\"mn-table-filters-panel\"\n (click)=\"toggleFiltersPanel()\"\n >\n <svg lucideFunnel [size]=\"15\"></svg>\n <span>{{ filtersButtonLabel }}</span>\n </button>\n }\n </div>\n</div>\n}\n\n<!-- Small-screen filter panel: stacked, full-width fields decoupled from column widths -->\n@if (hasColumnFilters && filtersCollapsed) {\n <div\n id=\"mn-table-filters-panel\"\n class=\"grid transition-all duration-200 ease-out motion-reduce:transition-none\"\n [style.grid-template-rows]=\"filtersPanelOpen ? '1fr' : '0fr'\"\n >\n <div class=\"overflow-hidden\" [attr.inert]=\"filtersPanelOpen ? null : ''\">\n <div class=\"flex flex-col gap-3 rounded-md border border-base-300 bg-base-100 p-3 mb-3\">\n @for (column of dataSource.columns; track column.key) {\n @if (column.filterable) {\n <div class=\"flex flex-col gap-1\">\n <label\n class=\"text-xs font-medium text-base-content/70\"\n [attr.for]=\"'mn-table-filter-' + column.key\"\n >\n @if (isTemplateRef(column.header)) {\n <ng-container [ngTemplateOutlet]=\"$any(column.header)\"></ng-container>\n } @else {\n {{ column.header }}\n }\n </label>\n <ng-container\n [ngTemplateOutletContext]=\"{ $implicit: column, idScope: 'panel' }\"\n [ngTemplateOutlet]=\"filterField\"\n ></ng-container>\n </div>\n }\n }\n @if (hasActiveFilters) {\n <button\n type=\"button\"\n mnButton\n [data]=\"{ variant: 'text', color: 'gray', size: 'sm', borderRadius: 'md', hover: true }\"\n class=\"self-start gap-1\"\n (click)=\"clearAllFilters()\"\n >\n <svg lucideX [size]=\"14\"></svg>\n <span>{{ clearFiltersButtonLabel }}</span>\n </button>\n }\n </div>\n </div>\n </div>\n}\n\n<!-- Table wrapper with horizontal scroll -->\n <div #collectionBody [style.min-height.px]=\"bodyMinHeight\" aria-label=\"Data table\"\n class=\"overflow-x-auto\"\n role=\"region\">\n <table [class]=\"tableClasses\">\n <thead>\n <tr class=\"bg-base-100\">\n <!-- Selection checkbox column header -->\n @if (hasSelection) {\n <th class=\"w-10 text-center text-sm bg-base-200 px-2 py-2\">\n @if (isMultiSelect) {\n <mn-lib-checkbox\n (checkedChange)=\"toggleAll()\"\n [checked]=\"allSelected\"\n [props]=\"{ id: 'mn-table-select-all', size: 'sm' }\"\n ></mn-lib-checkbox>\n }\n </th>\n }\n\n <!-- Data columns -->\n @for (column of dataSource.columns; track column.key) {\n <th\n [attr.data-column-key]=\"column.key\"\n [class.truncate]=\"widthsArePinned\"\n [class.cursor-pointer]=\"isSortable(column)\"\n [class.select-none]=\"isSortable(column)\"\n [class.hover:bg-base-200]=\"isSortable(column)\"\n [class.text-left]=\"(column.align ?? 'left') === 'left'\"\n [class.text-center]=\"column.align === 'center'\"\n [class.text-right]=\"column.align === 'right'\"\n [mnHiddenBelow]=\"column.hiddenBelow\"\n [style.width]=\"columnWidth(column)\"\n [attr.aria-sort]=\"currentSort?.columnKey === column.key ? (currentSort!.direction === 'asc' ? 'ascending' : 'descending') : null\"\n class=\"text-sm px-2 py-1 @min-[640px]:px-4 @min-[640px]:py-2 whitespace-nowrap\"\n (click)=\"sort(column)\"\n >\n <span class=\"inline-flex items-center gap-1\">\n @if (isTemplateRef(column.header)) {\n <ng-container [ngTemplateOutlet]=\"$any(column.header)\"></ng-container>\n } @else {\n <span>{{ column.header }}</span>\n }\n @if (isSortable(column)) {\n <span class=\"text-[0.65rem] opacity-70 min-w-3 inline-block\">{{ getSortIcon(column) }}</span>\n }\n </span>\n </th>\n }\n\n </tr>\n\n <!-- Per-column filter row (wide screens only; collapses into a panel below 640px).\n `font-normal` on the cells is load-bearing: these are `th` elements, which the\n browser renders bold, and the filter inputs and selects inside them inherit\n that. Filter controls are form fields, not headings, and must not look bold.\n The stacked filter panel renders outside any `th`, so it is unaffected. -->\n @if (hasColumnFilters && !filtersCollapsed) {\n <tr class=\"bg-base-100 border-b border-base-300 font-normal\">\n @if (hasSelection) {\n <th class=\"px-2 py-1 font-normal\"></th>\n }\n @for (column of dataSource.columns; track column.key) {\n <th\n class=\"px-4 py-2 font-normal\"\n [mnHiddenBelow]=\"column.hiddenBelow\"\n >\n @if (column.filterable) {\n <!-- Every filter renders as an ordinary control right under its\n header. The rich types used to hide behind a button that opened\n a floating panel, which cost a click to discover, a click to\n apply, and hid whether a column was even filterable. -->\n <ng-container\n [ngTemplateOutletContext]=\"{ $implicit: column, idScope: 'inline' }\"\n [ngTemplateOutlet]=\"filterField\"\n ></ng-container>\n }\n </th>\n }\n </tr>\n }\n </thead>\n\n <tbody>\n <!-- Loading state -->\n @if (isLoadingState) {\n @for (_ of skeletonRows; track $index) {\n <tr>\n @if (hasSelection) {\n <td class=\"px-2 py-3\">\n <mn-skeleton [data]=\"{ shape: 'rectangle', width: '1rem', height: '1rem' }\"></mn-skeleton>\n </td>\n }\n @for (column of dataSource.columns; track column.key) {\n <td class=\"px-4 py-3\"\n [mnHiddenBelow]=\"column.hiddenBelow\"\n [style.width]=\"columnWidth(column)\"\n >\n @if (isTemplateRef(column.skeleton)) {\n <ng-container [ngTemplateOutlet]=\"$any(column.skeleton)\"></ng-container>\n } @else {\n <mn-skeleton [data]=\"getColumnSkeletonData(column)\"></mn-skeleton>\n }\n </td>\n }\n </tr>\n }\n } @else if (isErrorState) {\n <!-- Error state -->\n <tr class=\"bg-base-100\">\n <td [attr.colspan]=\"totalColumnCount\" class=\"text-center text-xs py-8\">\n @if (dataSource.errorTemplate) {\n <ng-container [ngTemplateOutlet]=\"dataSource.errorTemplate\"></ng-container>\n } @else {\n <div class=\"flex flex-col items-center gap-2 text-error\">\n <p class=\"text-sm\">{{ dataSource.errorMessage }}</p>\n </div>\n }\n </td>\n </tr>\n } @else {\n <!-- Empty state -->\n @if (filteredItems.length === 0) {\n <tr class=\"bg-base-100\">\n <td [attr.colspan]=\"totalColumnCount\" class=\"text-center text-xs py-8\">\n @if (dataSource.emptyTemplate) {\n <ng-container [ngTemplateOutlet]=\"dataSource.emptyTemplate\"></ng-container>\n } @else {\n <div class=\"flex flex-col items-center gap-2 text-base-content/50\">\n @if (dataSource.emptyIcon !== null) {\n <svg [lucideIcon]=\"dataSource.emptyIcon ?? defaultEmptyIcon\" [size]=\"32\" [strokeWidth]=\"1.5\"></svg>\n }\n <p class=\"text-sm\">{{ dataSource.emptyMessage }}</p>\n </div>\n }\n </td>\n </tr>\n }\n\n <!-- Data rows -->\n @for (row of paginatedItems; track trackByID($index, row); let odd = $odd; let last = $last) {\n <tr\n class=\"bg-base-100 transition-colors duration-150 hover:cursor-pointer\"\n [ngClass]=\"{'bg-primary/10': isSelected(row)}\"\n [class.bg-base-200]=\"!isSelected(row) && odd && dataSource.appearance?.striped\"\n [class.hover:bg-base-200]=\"dataSource.appearance?.hover !== false\"\n [class.cursor-pointer]=\"!!dataSource.onRowClick\"\n [class.border-b]=\"!last\"\n [class.border-base-300]=\"!last\"\n [class.border-b-1]=\"last\"\n [class.border-black]=\"last\"\n [class.shadow-3xl]=\"last\"\n (click)=\"onRowClick(row)\"\n >\n <!-- Selection checkbox -->\n @if (hasSelection) {\n <td (click)=\"$event.stopPropagation()\" class=\"w-10 text-center px-2 py-2\">\n <mn-lib-checkbox\n (checkedChange)=\"toggle(row)\"\n [checked]=\"isSelected(row)\"\n [props]=\"{ id: 'mn-table-row-' + $index, size: 'sm' }\"\n ></mn-lib-checkbox>\n </td>\n }\n\n <!-- Data cells -->\n @for (column of dataSource.columns; track column.key) {\n <td\n [attr.title]=\"cellTitle(column, row)\"\n [class.text-left]=\"(column.align ?? 'left') === 'left'\"\n [class.text-center]=\"column.align === 'center'\"\n [class.text-right]=\"column.align === 'right'\"\n [class.truncate]=\"widthsArePinned\"\n [mnHiddenBelow]=\"column.hiddenBelow\"\n [style.width]=\"columnWidth(column)\"\n class=\"text-xs px-2 py-1 @min-[640px]:px-4 @min-[640px]:py-2\"\n >\n @if (column.cellSm) {\n <!-- Default cell: hidden below the cellSm breakpoint -->\n <span [mnShowAbove]=\"column.cellSm.below\">\n @if (isTemplateRef(column.cell)) {\n <ng-container [ngTemplateOutletContext]=\"{ $implicit: row, data: row }\"\n [ngTemplateOutlet]=\"$any(column.cell)\"></ng-container>\n } @else {\n {{ getCellValue(column, row) }}\n }\n </span>\n <!-- Small cell: shown only below the cellSm breakpoint -->\n <span [mnShowBelow]=\"column.cellSm.below\">\n @if (isTemplateRef(column.cellSm.cell)) {\n <ng-container [ngTemplateOutletContext]=\"{ $implicit: row, data: row }\"\n [ngTemplateOutlet]=\"$any(column.cellSm.cell)\"></ng-container>\n } @else {\n {{ getCellSmValue(column, row) }}\n }\n </span>\n } @else {\n @if (isTemplateRef(column.cell)) {\n <ng-container [ngTemplateOutletContext]=\"{ $implicit: row, data: row }\"\n [ngTemplateOutlet]=\"$any(column.cell)\"></ng-container>\n } @else {\n {{ getCellValue(column, row) }}\n }\n }\n </td>\n }\n\n </tr>\n }\n }\n </tbody>\n </table>\n</div>\n\n<!-- Load more + pagination -->\n<mn-collection-pagination\n (loadMore)=\"loadMoreRows()\"\n (pageChange)=\"goToPage($event)\"\n (pageSizeChange)=\"onPageSizeChange($event)\"\n [currentPage]=\"currentPage\"\n [isPaginated]=\"isPaginated\"\n [isServerPaginated]=\"isServerPaginated\"\n [labels]=\"dataSource.labels\"\n [loadingMoreRows]=\"loadingMoreRows\"\n [pageSizeSelectOptions]=\"pageSizeSelectOptions\"\n [pageSize]=\"pageSize\"\n [showLoadMore]=\"showLoadMore\"\n [totalItemCount]=\"totalItemCount\"\n [totalPages]=\"totalPages\"\n [visiblePages]=\"visiblePages\"\n idPrefix=\"mn-table\"\n></mn-collection-pagination>\n</div>\n\n<!-- Single source of truth for every filter control, reused by the inline header\n row and the small-screen panel. `idScope` keeps element ids unique across\n both placements. -->\n<ng-template #filterField let-column let-idScope=\"idScope\">\n @switch (filterTypeOf(column)) {\n @case ('select') {\n <mn-lib-select\n (ngModelChange)=\"onColumnFilter(column, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"textFilterValue(column)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key,\n options: getFilterSelectOptions(column),\n size: 'sm',\n fullWidth: true\n }\"\n ></mn-lib-select>\n }\n @case ('multi-select') {\n <mn-lib-multi-select\n (ngModelChange)=\"onColumnFilter(column, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"multiFilterValue(column)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key,\n options: getFilterMultiSelectOptions(column),\n placeholder: column.filterPlaceholder ?? '',\n collapsePlaceholder: filterSelectedLabel,\n collapseThreshold: 1,\n size: 'sm',\n fullWidth: true\n }\"\n ></mn-lib-multi-select>\n }\n @case ('boolean') {\n <mn-lib-select\n (ngModelChange)=\"onBooleanFilter(column, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"booleanFilterValue(column)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key,\n options: getBooleanFilterOptions(column),\n size: 'sm',\n fullWidth: true\n }\"\n ></mn-lib-select>\n }\n @case ('number-range') {\n <div class=\"flex items-end gap-2\">\n @for (bound of numberBounds; track bound) {\n <mn-lib-input-field\n (ngModelChange)=\"onRangeFilter(column, bound, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"rangeFilterValue(column, bound)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key + '-' + bound,\n type: 'number',\n label: rangeBoundLabel(bound),\n size: 'sm',\n borderRadius: 'md',\n fullWidth: true\n }\"\n class=\"flex-1\"\n ></mn-lib-input-field>\n }\n </div>\n }\n @case ('date-range') {\n <div class=\"flex flex-col gap-2\">\n @for (bound of dateBounds; track bound) {\n <mn-lib-datetime\n (ngModelChange)=\"onRangeFilter(column, bound, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"rangeFilterValue(column, bound)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key + '-' + bound,\n mode: 'date',\n label: rangeBoundLabel(bound),\n size: 'sm',\n borderRadius: 'md',\n fullWidth: true\n }\"\n ></mn-lib-datetime>\n }\n </div>\n }\n @default {\n <mn-lib-input-field\n (ngModelChange)=\"onColumnFilter(column, $event)\"\n [disabled]=\"column.filterDisabled ?? false\"\n [ngModel]=\"textFilterValue(column)\"\n [props]=\"{\n id: 'mn-table-filter-' + idScope + '-' + column.key,\n type: 'text',\n label: '',\n placeholder: column.filterPlaceholder ?? '',\n ariaLabel: column.filterPlaceholder ?? '',\n autocomplete: column.filterAutocomplete ?? undefined,\n size: 'sm',\n borderRadius: 'md',\n fullWidth: true,\n hover: true\n }\"\n ></mn-lib-input-field>\n }\n }\n</ng-template>\n" }]
|
|
6702
6900
|
}], ctorParameters: () => [], propDecorators: { sortChange: [{
|
|
6703
6901
|
type: Output
|
|
6704
6902
|
}], rowClick: [{
|
|
@@ -6706,15 +6904,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImpor
|
|
|
6706
6904
|
}], collectionBody: [{
|
|
6707
6905
|
type: ViewChild,
|
|
6708
6906
|
args: ['collectionBody']
|
|
6709
|
-
}], filterPopover: [{
|
|
6710
|
-
type: ViewChild,
|
|
6711
|
-
args: ['filterPopover']
|
|
6712
|
-
}], onDocumentClick: [{
|
|
6713
|
-
type: HostListener,
|
|
6714
|
-
args: ['document:click', ['$event']]
|
|
6715
|
-
}], onEscape: [{
|
|
6716
|
-
type: HostListener,
|
|
6717
|
-
args: ['document:keydown.escape']
|
|
6718
6907
|
}], onWindowResize: [{
|
|
6719
6908
|
type: HostListener,
|
|
6720
6909
|
args: ['window:resize']
|
|
@@ -7232,6 +7421,12 @@ class MnFormBodyComponent {
|
|
|
7232
7421
|
const ds = tableField.tableDataSource;
|
|
7233
7422
|
// Force multi selection mode
|
|
7234
7423
|
ds.selectionMode = 'multi';
|
|
7424
|
+
// Default the selection summary on. A table used as a form field is always
|
|
7425
|
+
// inside a modal, where it is short, paged and searched — so the rows the
|
|
7426
|
+
// user has already picked are the ones most likely to be scrolled out of
|
|
7427
|
+
// sight, which is exactly what the summary exists to prevent. Set
|
|
7428
|
+
// `selectionSummary: false` on the data source to opt a field out.
|
|
7429
|
+
ds.selectionSummary ??= true;
|
|
7235
7430
|
// Pre-select rows from the form's initial value
|
|
7236
7431
|
const control = this.form.get(field.key);
|
|
7237
7432
|
if (control && Array.isArray(control.value) && control.value.length > 0) {
|
|
@@ -8160,11 +8355,11 @@ class MnConfirmationBodyComponent {
|
|
|
8160
8355
|
return this.config.cancel?.icon ?? MN_MODAL_ACTION_ICONS.cancel;
|
|
8161
8356
|
}
|
|
8162
8357
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnConfirmationBodyComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
8163
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnConfirmationBodyComponent, isStandalone: true, selector: "mn-confirmation-body", inputs: { config: "config", modalRef: "modalRef" }, viewQueries: [{ propertyName: "formBody", first: true, predicate: MnFormBodyComponent, descendants: true }], ngImport: i0, template: "<div class=\"flex flex-col gap-6\" [ngClass]=\"toneClass\">\n <!-- Custom Content (Component or Template) -->\n @if (config.component || config.template) {\n <mn-custom-body-host\n [config]=\"$any(config)\"\n [modalRef]=\"$any(modalRef)\"\n ></mn-custom-body-host>\n }\n\n <div class=\"flex flex-col items-center text-center gap-6\">\n @if (config.tone === 'warning' || config.tone === 'danger') {\n <div\n class=\"w-12 h-12 rounded-full flex items-center justify-center\"\n [ngClass]=\"{\n 'bg-blue-100 text-blue-500': config.tone !== 'warning' && config.tone !== 'danger',\n 'bg-amber-100 text-amber-500': config.tone === 'warning',\n 'bg-red-100 text-red-500': config.tone === 'danger'\n }\">\n @if (config.tone === 'warning') {\n <svg
|
|
8358
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnConfirmationBodyComponent, isStandalone: true, selector: "mn-confirmation-body", inputs: { config: "config", modalRef: "modalRef" }, viewQueries: [{ propertyName: "formBody", first: true, predicate: MnFormBodyComponent, descendants: true }], ngImport: i0, template: "<div class=\"flex flex-col gap-6\" [ngClass]=\"toneClass\">\n <!-- Custom Content (Component or Template) -->\n @if (config.component || config.template) {\n <mn-custom-body-host\n [config]=\"$any(config)\"\n [modalRef]=\"$any(modalRef)\"\n ></mn-custom-body-host>\n }\n\n <div class=\"flex flex-col items-center text-center gap-6\">\n @if (config.tone === 'warning' || config.tone === 'danger') {\n <div\n class=\"w-12 h-12 rounded-full flex items-center justify-center\"\n [ngClass]=\"{\n 'bg-blue-100 text-blue-500': config.tone !== 'warning' && config.tone !== 'danger',\n 'bg-amber-100 text-amber-500': config.tone === 'warning',\n 'bg-red-100 text-red-500': config.tone === 'danger'\n }\">\n @if (config.tone === 'warning') {\n <svg [size]=\"24\" lucideTriangleAlert></svg>\n }\n @if (config.tone === 'danger') {\n <svg [size]=\"24\" lucideCircleAlert></svg>\n }\n </div>\n }\n\n @if (config.message) {\n <div class=\"text-base text-base-content/80 leading-relaxed max-w-[28rem]\">\n {{ config.message }}\n </div>\n }\n </div>\n\n <!-- Form Fields / Rows -->\n @if (hasFormFields) {\n <mn-form-body\n [config]=\"$any(config)\"\n [modalRef]=\"$any(modalRef)\"\n [hideFooter]=\"true\"\n [hideCustomBody]=\"true\"\n (formStatusChange)=\"onFormStatusChange($event)\"\n ></mn-form-body>\n }\n\n <div class=\"flex gap-3 w-full pb-6 sticky bottom-0 bg-base-100 z-10\">\n <button\n mnButton\n [data]=\"{\n variant: getButtonVariant(cancelStyle),\n color: getButtonColor(cancelStyle)\n }\"\n (click)=\"cancel()\"\n >\n @if (cancelIcon; as icon) {\n <svg [lucideIcon]=\"icon\" [size]=\"actionIconSize\" class=\"mr-2\"></svg>\n }\n {{ cancelLabel }}\n </button>\n\n <div class=\"flex-1\"></div>\n\n <button\n mnButton\n [data]=\"{\n variant: getButtonVariant(confirmStyle),\n color: getButtonColor(confirmStyle),\n disabled: isConfirmDisabled\n }\"\n [disabled]=\"isConfirmDisabled\"\n (click)=\"confirm()\"\n >\n @if (confirmIcon; as icon) {\n <svg [lucideIcon]=\"icon\" [size]=\"actionIconSize\" class=\"mr-2\"></svg>\n }\n {{ confirmLabel }}\n </button>\n </div>\n</div>\n", styles: [""], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "component", type: MnButton, selector: "button[mnButton], a[mnButton]", inputs: ["data"] }, { kind: "component", type: MnFormBodyComponent, selector: "mn-form-body", inputs: ["config", "modalRef", "hideFooter", "hideCustomBody"], outputs: ["formStatusChange"] }, { kind: "component", type: MnCustomBodyHostComponent, selector: "mn-custom-body-host", inputs: ["config", "modalRef"] }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "component", type: LucideDynamicIcon, selector: "svg[lucideIcon]", inputs: ["lucideIcon"] }, { kind: "component", type: LucideTriangleAlert, selector: "svg[lucideTriangleAlert], svg[lucideAlertTriangle]" }, { kind: "component", type: LucideCircleAlert, selector: "svg[lucideCircleAlert], svg[lucideAlertCircle]" }] });
|
|
8164
8359
|
}
|
|
8165
8360
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnConfirmationBodyComponent, decorators: [{
|
|
8166
8361
|
type: Component,
|
|
8167
|
-
args: [{ selector: 'mn-confirmation-body', standalone: true, imports: [CommonModule, MnButton, MnFormBodyComponent, MnCustomBodyHostComponent, ReactiveFormsModule, LucideDynamicIcon], template: "<div class=\"flex flex-col gap-6\" [ngClass]=\"toneClass\">\n <!-- Custom Content (Component or Template) -->\n @if (config.component || config.template) {\n <mn-custom-body-host\n [config]=\"$any(config)\"\n [modalRef]=\"$any(modalRef)\"\n ></mn-custom-body-host>\n }\n\n <div class=\"flex flex-col items-center text-center gap-6\">\n @if (config.tone === 'warning' || config.tone === 'danger') {\n <div\n class=\"w-12 h-12 rounded-full flex items-center justify-center\"\n [ngClass]=\"{\n 'bg-blue-100 text-blue-500': config.tone !== 'warning' && config.tone !== 'danger',\n 'bg-amber-100 text-amber-500': config.tone === 'warning',\n 'bg-red-100 text-red-500': config.tone === 'danger'\n }\">\n @if (config.tone === 'warning') {\n <svg
|
|
8362
|
+
args: [{ selector: 'mn-confirmation-body', standalone: true, imports: [CommonModule, MnButton, MnFormBodyComponent, MnCustomBodyHostComponent, ReactiveFormsModule, LucideDynamicIcon, LucideTriangleAlert, LucideCircleAlert], template: "<div class=\"flex flex-col gap-6\" [ngClass]=\"toneClass\">\n <!-- Custom Content (Component or Template) -->\n @if (config.component || config.template) {\n <mn-custom-body-host\n [config]=\"$any(config)\"\n [modalRef]=\"$any(modalRef)\"\n ></mn-custom-body-host>\n }\n\n <div class=\"flex flex-col items-center text-center gap-6\">\n @if (config.tone === 'warning' || config.tone === 'danger') {\n <div\n class=\"w-12 h-12 rounded-full flex items-center justify-center\"\n [ngClass]=\"{\n 'bg-blue-100 text-blue-500': config.tone !== 'warning' && config.tone !== 'danger',\n 'bg-amber-100 text-amber-500': config.tone === 'warning',\n 'bg-red-100 text-red-500': config.tone === 'danger'\n }\">\n @if (config.tone === 'warning') {\n <svg [size]=\"24\" lucideTriangleAlert></svg>\n }\n @if (config.tone === 'danger') {\n <svg [size]=\"24\" lucideCircleAlert></svg>\n }\n </div>\n }\n\n @if (config.message) {\n <div class=\"text-base text-base-content/80 leading-relaxed max-w-[28rem]\">\n {{ config.message }}\n </div>\n }\n </div>\n\n <!-- Form Fields / Rows -->\n @if (hasFormFields) {\n <mn-form-body\n [config]=\"$any(config)\"\n [modalRef]=\"$any(modalRef)\"\n [hideFooter]=\"true\"\n [hideCustomBody]=\"true\"\n (formStatusChange)=\"onFormStatusChange($event)\"\n ></mn-form-body>\n }\n\n <div class=\"flex gap-3 w-full pb-6 sticky bottom-0 bg-base-100 z-10\">\n <button\n mnButton\n [data]=\"{\n variant: getButtonVariant(cancelStyle),\n color: getButtonColor(cancelStyle)\n }\"\n (click)=\"cancel()\"\n >\n @if (cancelIcon; as icon) {\n <svg [lucideIcon]=\"icon\" [size]=\"actionIconSize\" class=\"mr-2\"></svg>\n }\n {{ cancelLabel }}\n </button>\n\n <div class=\"flex-1\"></div>\n\n <button\n mnButton\n [data]=\"{\n variant: getButtonVariant(confirmStyle),\n color: getButtonColor(confirmStyle),\n disabled: isConfirmDisabled\n }\"\n [disabled]=\"isConfirmDisabled\"\n (click)=\"confirm()\"\n >\n @if (confirmIcon; as icon) {\n <svg [lucideIcon]=\"icon\" [size]=\"actionIconSize\" class=\"mr-2\"></svg>\n }\n {{ confirmLabel }}\n </button>\n </div>\n</div>\n" }]
|
|
8168
8363
|
}], propDecorators: { config: [{
|
|
8169
8364
|
type: Input
|
|
8170
8365
|
}], modalRef: [{
|