mn-angular-lib 1.0.139 → 1.0.140
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.
|
@@ -3508,6 +3508,27 @@ class MnMultiSelect {
|
|
|
3508
3508
|
triggerRef;
|
|
3509
3509
|
/** The panel element currently moved into `document.body`, if any. */
|
|
3510
3510
|
movedPanel = null;
|
|
3511
|
+
/** The sheet backdrop element currently moved into `document.body`, if any. */
|
|
3512
|
+
movedBackdrop = null;
|
|
3513
|
+
/** Option count at which the search input auto-enables when `searchable` is unset. */
|
|
3514
|
+
static DEFAULT_SEARCH_THRESHOLD = 8;
|
|
3515
|
+
/** Tailwind's `sm` breakpoint — below this the panel renders as a bottom sheet.
|
|
3516
|
+
* Kept in step with the same constant in `MnModalShellComponent`. */
|
|
3517
|
+
static SHEET_MAX_WIDTH = 639.98;
|
|
3518
|
+
/** Whether the viewport is currently narrow enough for the sheet layout. */
|
|
3519
|
+
isNarrowViewport = false;
|
|
3520
|
+
/** Live breakpoint match, so rotating the device re-evaluates the layout. */
|
|
3521
|
+
sheetMedia = null;
|
|
3522
|
+
/** The listener registered on `sheetMedia`, retained for teardown. */
|
|
3523
|
+
sheetMediaListener = null;
|
|
3524
|
+
/** `document.body`'s inline `overflow` before the sheet locked it, restored on close. */
|
|
3525
|
+
previousBodyOverflow = null;
|
|
3526
|
+
/**
|
|
3527
|
+
* The sheet's height (px) captured the moment it opened, before any search. Re-applied
|
|
3528
|
+
* as a `min-height` floor so filtering the option list shorter cannot shrink the sheet
|
|
3529
|
+
* mid-type. Null while anchored or closed, so the popover and desktop path are untouched.
|
|
3530
|
+
*/
|
|
3531
|
+
sheetFloorPx = null;
|
|
3511
3532
|
/**
|
|
3512
3533
|
* Watches the trigger while the panel is open. The panel lives in `document.body`,
|
|
3513
3534
|
* so it survives its own trigger being hidden by an ancestor — e.g. a wizard step
|
|
@@ -3530,7 +3551,22 @@ class MnMultiSelect {
|
|
|
3530
3551
|
* broken on iOS). Cleanup is handled when the query clears on close/destroy.
|
|
3531
3552
|
*/
|
|
3532
3553
|
set dropdownRef(ref) {
|
|
3533
|
-
|
|
3554
|
+
const el = ref?.nativeElement ?? null;
|
|
3555
|
+
this.movedPanel = this.portal(el, this.movedPanel);
|
|
3556
|
+
if (el && this.isSheet) {
|
|
3557
|
+
this.captureSheetFloor(el);
|
|
3558
|
+
}
|
|
3559
|
+
else if (!el) {
|
|
3560
|
+
this.sheetFloorPx = null;
|
|
3561
|
+
}
|
|
3562
|
+
}
|
|
3563
|
+
/**
|
|
3564
|
+
* The dimming backdrop rendered behind the mobile sheet. Portalled alongside the
|
|
3565
|
+
* panel for the same reason — a `position: fixed` backdrop left inside a transformed
|
|
3566
|
+
* ancestor would cover that ancestor rather than the viewport.
|
|
3567
|
+
*/
|
|
3568
|
+
set sheetBackdropRef(ref) {
|
|
3569
|
+
this.movedBackdrop = this.portal(ref?.nativeElement ?? null, this.movedBackdrop);
|
|
3534
3570
|
}
|
|
3535
3571
|
/** Currently selected values */
|
|
3536
3572
|
selectedValues = [];
|
|
@@ -3551,16 +3587,47 @@ class MnMultiSelect {
|
|
|
3551
3587
|
}
|
|
3552
3588
|
ngOnInit() {
|
|
3553
3589
|
this.resolveConfig();
|
|
3590
|
+
this.startWatchingViewport();
|
|
3554
3591
|
const sub = this.lang.locale$.pipe(skip(1)).subscribe(() => {
|
|
3555
3592
|
this.resolveConfig();
|
|
3556
3593
|
});
|
|
3557
3594
|
this.destroyRef.onDestroy(() => {
|
|
3558
3595
|
sub.unsubscribe();
|
|
3559
3596
|
this.stopWatchingTrigger();
|
|
3560
|
-
|
|
3561
|
-
this.
|
|
3597
|
+
this.stopWatchingViewport();
|
|
3598
|
+
this.unlockBodyScroll();
|
|
3599
|
+
// Guarantee the portalled elements never outlive the component.
|
|
3600
|
+
this.movedPanel = this.portal(null, this.movedPanel);
|
|
3601
|
+
this.movedBackdrop = this.portal(null, this.movedBackdrop);
|
|
3562
3602
|
});
|
|
3563
3603
|
}
|
|
3604
|
+
/**
|
|
3605
|
+
* Tracks the sheet breakpoint through `matchMedia` rather than reading `innerWidth`
|
|
3606
|
+
* once, so rotating the device switches layout instead of leaving a panel positioned
|
|
3607
|
+
* for the previous orientation. An open panel is closed on the switch — its anchored
|
|
3608
|
+
* coordinates and its sheet layout are not interchangeable.
|
|
3609
|
+
*/
|
|
3610
|
+
startWatchingViewport() {
|
|
3611
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
|
|
3612
|
+
return;
|
|
3613
|
+
this.sheetMedia = window.matchMedia(`(max-width: ${MnMultiSelect.SHEET_MAX_WIDTH}px)`);
|
|
3614
|
+
this.isNarrowViewport = this.sheetMedia.matches;
|
|
3615
|
+
this.sheetMediaListener = (event) => {
|
|
3616
|
+
this.isNarrowViewport = event.matches;
|
|
3617
|
+
this.close();
|
|
3618
|
+
// The listener fires outside Angular, so a zoneless app needs an explicit nudge.
|
|
3619
|
+
this.cdr.markForCheck();
|
|
3620
|
+
};
|
|
3621
|
+
this.sheetMedia.addEventListener('change', this.sheetMediaListener);
|
|
3622
|
+
}
|
|
3623
|
+
/** Tears down the breakpoint listener. Idempotent. */
|
|
3624
|
+
stopWatchingViewport() {
|
|
3625
|
+
if (this.sheetMedia && this.sheetMediaListener) {
|
|
3626
|
+
this.sheetMedia.removeEventListener('change', this.sheetMediaListener);
|
|
3627
|
+
}
|
|
3628
|
+
this.sheetMedia = null;
|
|
3629
|
+
this.sheetMediaListener = null;
|
|
3630
|
+
}
|
|
3564
3631
|
onDocumentClick(event) {
|
|
3565
3632
|
const target = event.target;
|
|
3566
3633
|
// The panel lives at the body root once open, so it is not a descendant of the
|
|
@@ -3603,15 +3670,74 @@ class MnMultiSelect {
|
|
|
3603
3670
|
return;
|
|
3604
3671
|
}
|
|
3605
3672
|
this.isOpen = true;
|
|
3673
|
+
if (this.isSheet) {
|
|
3674
|
+
// A sheet is anchored to the viewport, so it needs no trigger tracking — only a
|
|
3675
|
+
// scroll lock so the page behind it stays put while the list is scrolled.
|
|
3676
|
+
this.lockBodyScroll();
|
|
3677
|
+
return;
|
|
3678
|
+
}
|
|
3606
3679
|
this.updateDropdownPosition();
|
|
3607
3680
|
this.startWatchingTrigger();
|
|
3608
3681
|
}
|
|
3682
|
+
/** Whether the panel should currently render as a bottom sheet. */
|
|
3683
|
+
get isSheet() {
|
|
3684
|
+
return this.props.mobileSheet !== false && this.isNarrowViewport;
|
|
3685
|
+
}
|
|
3686
|
+
/**
|
|
3687
|
+
* Whether the search input is shown: the explicit `searchable` prop when set,
|
|
3688
|
+
* otherwise auto-enabled once the option count reaches the threshold.
|
|
3689
|
+
*/
|
|
3690
|
+
get isSearchable() {
|
|
3691
|
+
if (this.props.searchable !== undefined)
|
|
3692
|
+
return this.props.searchable;
|
|
3693
|
+
const threshold = this.props.searchThreshold ?? MnMultiSelect.DEFAULT_SEARCH_THRESHOLD;
|
|
3694
|
+
return this.props.options.length >= threshold;
|
|
3695
|
+
}
|
|
3696
|
+
/** Layout classes for the panel — a bottom-anchored sheet, or the trigger-anchored popover. */
|
|
3697
|
+
get panelClasses() {
|
|
3698
|
+
// The sheet sizes to its content (capped at 80vh), so a short list gets a short
|
|
3699
|
+
// sheet. To stop it collapsing upward as the search filters options away, the height
|
|
3700
|
+
// it opens at is captured once and re-applied as a `min-height` floor (see
|
|
3701
|
+
// `sheetFloorPx`): the `flex-1` list then keeps that frame and just shows fewer rows.
|
|
3702
|
+
return this.isSheet
|
|
3703
|
+
? 'mn-ms-sheet fixed inset-x-0 bottom-0 z-9999 flex flex-col bg-base-100 border-t border-base-300 rounded-t-2xl shadow-lg max-h-[80vh]'
|
|
3704
|
+
: 'fixed z-9999 bg-base-100 border border-base-300 rounded-md shadow-lg max-h-60 overflow-auto';
|
|
3705
|
+
}
|
|
3706
|
+
/**
|
|
3707
|
+
* Records the sheet's opened height as its `min-height` floor. Measured on the next
|
|
3708
|
+
* frame so the read reflects the fully-rendered, unfiltered list (the search box is
|
|
3709
|
+
* empty on open) and never forces a reflow mid change-detection. The floor equals the
|
|
3710
|
+
* content height at that instant, so applying it triggers no resize — it only stops a
|
|
3711
|
+
* later, shorter filtered list from pulling the sheet down.
|
|
3712
|
+
*/
|
|
3713
|
+
captureSheetFloor(panel) {
|
|
3714
|
+
if (typeof requestAnimationFrame !== 'function') {
|
|
3715
|
+
this.sheetFloorPx = panel.offsetHeight;
|
|
3716
|
+
return;
|
|
3717
|
+
}
|
|
3718
|
+
requestAnimationFrame(() => {
|
|
3719
|
+
// The panel may have closed before the frame ran; don't strand a stale floor.
|
|
3720
|
+
if (!this.isOpen || this.movedPanel !== panel)
|
|
3721
|
+
return;
|
|
3722
|
+
this.sheetFloorPx = panel.offsetHeight;
|
|
3723
|
+
this.cdr.markForCheck();
|
|
3724
|
+
});
|
|
3725
|
+
}
|
|
3609
3726
|
/** Closes the dropdown on Escape for keyboard accessibility. */
|
|
3610
3727
|
onEscape() {
|
|
3611
3728
|
this.close();
|
|
3612
3729
|
}
|
|
3613
|
-
/**
|
|
3730
|
+
/**
|
|
3731
|
+
* Closes the dropdown when the page or a scrollable parent is scrolled.
|
|
3732
|
+
*
|
|
3733
|
+
* Skipped for a sheet: it is anchored to the viewport, not to the trigger, so it has
|
|
3734
|
+
* no stale position to escape. Crucially, opening the soft keyboard fires a `resize`
|
|
3735
|
+
* on Android — closing on that would dismiss the sheet the instant search is focused.
|
|
3736
|
+
* A genuine layout switch is handled by the `matchMedia` listener instead.
|
|
3737
|
+
*/
|
|
3614
3738
|
onWindowScrollOrResize() {
|
|
3739
|
+
if (this.isSheet)
|
|
3740
|
+
return;
|
|
3615
3741
|
this.close();
|
|
3616
3742
|
}
|
|
3617
3743
|
/**
|
|
@@ -3625,6 +3751,29 @@ class MnMultiSelect {
|
|
|
3625
3751
|
this.isOpen = false;
|
|
3626
3752
|
this.searchTerm = '';
|
|
3627
3753
|
this.stopWatchingTrigger();
|
|
3754
|
+
this.unlockBodyScroll();
|
|
3755
|
+
}
|
|
3756
|
+
/**
|
|
3757
|
+
* Freezes the page behind an open sheet. The previous inline value is captured and
|
|
3758
|
+
* restored verbatim so a surrounding modal that set its own lock is left intact.
|
|
3759
|
+
*/
|
|
3760
|
+
lockBodyScroll() {
|
|
3761
|
+
if (this.previousBodyOverflow !== null)
|
|
3762
|
+
return;
|
|
3763
|
+
this.previousBodyOverflow = document.body.style.overflow;
|
|
3764
|
+
this.renderer.setStyle(document.body, 'overflow', 'hidden');
|
|
3765
|
+
}
|
|
3766
|
+
/** Restores the pre-lock `overflow`. Idempotent. */
|
|
3767
|
+
unlockBodyScroll() {
|
|
3768
|
+
if (this.previousBodyOverflow === null)
|
|
3769
|
+
return;
|
|
3770
|
+
if (this.previousBodyOverflow) {
|
|
3771
|
+
this.renderer.setStyle(document.body, 'overflow', this.previousBodyOverflow);
|
|
3772
|
+
}
|
|
3773
|
+
else {
|
|
3774
|
+
this.renderer.removeStyle(document.body, 'overflow');
|
|
3775
|
+
}
|
|
3776
|
+
this.previousBodyOverflow = null;
|
|
3628
3777
|
}
|
|
3629
3778
|
/** Calculates the fixed position for the dropdown based on the trigger element */
|
|
3630
3779
|
updateDropdownPosition() {
|
|
@@ -3667,27 +3816,29 @@ class MnMultiSelect {
|
|
|
3667
3816
|
document.addEventListener('scroll', this.scrollCapture, true);
|
|
3668
3817
|
}
|
|
3669
3818
|
/**
|
|
3670
|
-
* Move
|
|
3671
|
-
*
|
|
3672
|
-
* `transform`/`filter`/`will-change`, so `position: fixed` anchors to the viewport
|
|
3673
|
-
*
|
|
3819
|
+
* Move an overlay element to `document.body` when it appears, and detach it when the
|
|
3820
|
+
* query clears. Appending to the body root makes the element immune to ancestor
|
|
3821
|
+
* `transform`/`filter`/`will-change`, so `position: fixed` anchors to the viewport —
|
|
3822
|
+
* without this the panel lands mid-screen (and breaks outright on iOS).
|
|
3823
|
+
*
|
|
3824
|
+
* Returns the element now portalled, so the caller can store it. Idempotent and safe
|
|
3825
|
+
* to call with `null`.
|
|
3674
3826
|
*/
|
|
3675
|
-
|
|
3827
|
+
portal(el, current) {
|
|
3676
3828
|
if (el) {
|
|
3677
|
-
if (
|
|
3678
|
-
return;
|
|
3829
|
+
if (current === el)
|
|
3830
|
+
return current;
|
|
3679
3831
|
this.renderer.appendChild(document.body, el);
|
|
3680
|
-
|
|
3681
|
-
return;
|
|
3832
|
+
return el;
|
|
3682
3833
|
}
|
|
3683
|
-
if (
|
|
3834
|
+
if (current) {
|
|
3684
3835
|
// Angular's view teardown may already have removed it; only detach if still attached.
|
|
3685
|
-
const parent =
|
|
3836
|
+
const parent = current.parentNode;
|
|
3686
3837
|
if (parent) {
|
|
3687
|
-
this.renderer.removeChild(parent,
|
|
3838
|
+
this.renderer.removeChild(parent, current);
|
|
3688
3839
|
}
|
|
3689
|
-
this.movedPanel = null;
|
|
3690
3840
|
}
|
|
3841
|
+
return null;
|
|
3691
3842
|
}
|
|
3692
3843
|
/** Tears down the watchers installed by `startWatchingTrigger`. Idempotent. */
|
|
3693
3844
|
stopWatchingTrigger() {
|
|
@@ -3843,11 +3994,11 @@ class MnMultiSelect {
|
|
|
3843
3994
|
});
|
|
3844
3995
|
}
|
|
3845
3996
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnMultiSelect, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
3846
|
-
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]" }] });
|
|
3997
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnMultiSelect, isStandalone: true, selector: "mn-lib-multi-select", inputs: { props: "props" }, host: { listeners: { "document:click": "onDocumentClick($event)", "document:keydown.escape": "onEscape()", "window:scroll": "onWindowScrollOrResize()", "window:resize": "onWindowScrollOrResize()" } }, viewQueries: [{ propertyName: "triggerRef", first: true, predicate: ["trigger"], descendants: true }, { propertyName: "dropdownRef", first: true, predicate: ["dropdown"], descendants: true }, { propertyName: "sheetBackdropRef", first: true, predicate: ["sheetBackdrop"], 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 <!-- Dims the page behind the sheet. Deliberately has no click handler: the host's\n `document:click` listener already treats anything outside the panel as a\n dismissal, so a second handler would only duplicate that path. -->\n @if (isSheet) {\n <div #sheetBackdrop class=\"mn-ms-sheet-backdrop fixed inset-0 z-9998 bg-black/40\"></div>\n }\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 [ngClass]=\"panelClasses\"\n [style.top]=\"isSheet ? null : dropdownStyle.top\"\n [style.left]=\"isSheet ? null : dropdownStyle.left\"\n [style.width]=\"isSheet ? null : dropdownStyle.width\"\n [style.min-height.px]=\"isSheet ? sheetFloorPx : null\"\n (click)=\"$event.stopPropagation()\"\n >\n <!-- The sheet covers its own trigger, so it needs a header to name the field and\n an explicit way out \u2014 tapping the trigger again is not reachable here. -->\n @if (isSheet) {\n <div class=\"flex items-center justify-between gap-x-2 px-4 pt-4 pb-2 shrink-0\">\n <p class=\"text-base font-medium text-base-content truncate\">\n {{ uiConfig.label || props.label || uiConfig.placeholder || props.placeholder || '' }}\n </p>\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'secondary' }\"\n type=\"button\"\n class=\"shrink-0 cursor-pointer\"\n (click)=\"close()\"\n [attr.aria-label]=\"uiConfig.closeLabel || 'Close'\"\n ><svg lucideX [size]=\"20\"></svg></button>\n </div>\n }\n @if (isSearchable) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n class=\"border-b border-base-300 shrink-0\"\n [ngClass]=\"isSheet ? 'px-4 py-2' : 'p-2'\"\n (click)=\"$event.stopPropagation()\"\n >\n <mn-lib-input-field\n [ngModel]=\"searchTerm\"\n [ngModelOptions]=\"{ standalone: true }\"\n (ngModelChange)=\"onSearch($event)\"\n [props]=\"{\n id: resolvedId + '-search',\n type: 'search',\n placeholder: props.searchPlaceholder || 'Search...',\n ariaLabel: props.searchPlaceholder || 'Search...',\n fullWidth: true,\n size: 'sm'\n }\"\n ></mn-lib-input-field>\n </div>\n }\n <!-- In sheet mode the panel is the flex column and this list is the scroller;\n anchored, the panel itself scrolls and this is a passthrough wrapper. -->\n <div [ngClass]=\"isSheet ? 'flex-1 overflow-auto overscroll-contain' : ''\">\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 cursor-pointer text-base-content hover:bg-base-200\"\n [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\"\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 shrink-0\"\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=\"text-base-content/50\" [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\">\n {{ uiConfig.noOptionsFound || 'No options found' }}\n </div>\n }\n </div>\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", styles: [".mn-ms-sheet{--mn-sheet-ease: cubic-bezier(.32, .72, 0, 1);padding-bottom:env(safe-area-inset-bottom);animation:mn-ms-sheet-in .3s var(--mn-sheet-ease)}.mn-ms-sheet-backdrop{animation:mn-ms-backdrop-in .2s ease-out}@keyframes mn-ms-sheet-in{0%{transform:translateY(100%)}to{transform:translateY(0)}}@keyframes mn-ms-backdrop-in{0%{opacity:0}to{opacity:1}}@media(prefers-reduced-motion:reduce){.mn-ms-sheet,.mn-ms-sheet-backdrop{animation-duration:.01ms!important}}\n"], dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: MnErrorMessage, selector: "mn-error-message", inputs: ["errorMessage", "id"] }, { kind: "component", type: MnButton, selector: "button[mnButton], a[mnButton]", inputs: ["data"] }, { kind: "component", type: MnInputField, selector: "mn-lib-input-field", inputs: ["props"] }, { kind: "component", type: LucideX, selector: "svg[lucideX]" }, { kind: "component", type: LucideChevronDown, selector: "svg[lucideChevronDown]" }] });
|
|
3847
3998
|
}
|
|
3848
3999
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnMultiSelect, decorators: [{
|
|
3849
4000
|
type: Component,
|
|
3850
|
-
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
|
|
4001
|
+
args: [{ selector: 'mn-lib-multi-select', standalone: true, imports: [NgClass, FormsModule, MnErrorMessage, MnButton, MnInputField, 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 <!-- Dims the page behind the sheet. Deliberately has no click handler: the host's\n `document:click` listener already treats anything outside the panel as a\n dismissal, so a second handler would only duplicate that path. -->\n @if (isSheet) {\n <div #sheetBackdrop class=\"mn-ms-sheet-backdrop fixed inset-0 z-9998 bg-black/40\"></div>\n }\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 [ngClass]=\"panelClasses\"\n [style.top]=\"isSheet ? null : dropdownStyle.top\"\n [style.left]=\"isSheet ? null : dropdownStyle.left\"\n [style.width]=\"isSheet ? null : dropdownStyle.width\"\n [style.min-height.px]=\"isSheet ? sheetFloorPx : null\"\n (click)=\"$event.stopPropagation()\"\n >\n <!-- The sheet covers its own trigger, so it needs a header to name the field and\n an explicit way out \u2014 tapping the trigger again is not reachable here. -->\n @if (isSheet) {\n <div class=\"flex items-center justify-between gap-x-2 px-4 pt-4 pb-2 shrink-0\">\n <p class=\"text-base font-medium text-base-content truncate\">\n {{ uiConfig.label || props.label || uiConfig.placeholder || props.placeholder || '' }}\n </p>\n <button\n mnButton\n [data]=\"{ size: 'sm', variant: 'text', color: 'secondary' }\"\n type=\"button\"\n class=\"shrink-0 cursor-pointer\"\n (click)=\"close()\"\n [attr.aria-label]=\"uiConfig.closeLabel || 'Close'\"\n ><svg lucideX [size]=\"20\"></svg></button>\n </div>\n }\n @if (isSearchable) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n class=\"border-b border-base-300 shrink-0\"\n [ngClass]=\"isSheet ? 'px-4 py-2' : 'p-2'\"\n (click)=\"$event.stopPropagation()\"\n >\n <mn-lib-input-field\n [ngModel]=\"searchTerm\"\n [ngModelOptions]=\"{ standalone: true }\"\n (ngModelChange)=\"onSearch($event)\"\n [props]=\"{\n id: resolvedId + '-search',\n type: 'search',\n placeholder: props.searchPlaceholder || 'Search...',\n ariaLabel: props.searchPlaceholder || 'Search...',\n fullWidth: true,\n size: 'sm'\n }\"\n ></mn-lib-input-field>\n </div>\n }\n <!-- In sheet mode the panel is the flex column and this list is the scroller;\n anchored, the panel itself scrolls and this is a passthrough wrapper. -->\n <div [ngClass]=\"isSheet ? 'flex-1 overflow-auto overscroll-contain' : ''\">\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 cursor-pointer text-base-content hover:bg-base-200\"\n [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\"\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 shrink-0\"\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=\"text-base-content/50\" [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\">\n {{ uiConfig.noOptionsFound || 'No options found' }}\n </div>\n }\n </div>\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", styles: [".mn-ms-sheet{--mn-sheet-ease: cubic-bezier(.32, .72, 0, 1);padding-bottom:env(safe-area-inset-bottom);animation:mn-ms-sheet-in .3s var(--mn-sheet-ease)}.mn-ms-sheet-backdrop{animation:mn-ms-backdrop-in .2s ease-out}@keyframes mn-ms-sheet-in{0%{transform:translateY(100%)}to{transform:translateY(0)}}@keyframes mn-ms-backdrop-in{0%{opacity:0}to{opacity:1}}@media(prefers-reduced-motion:reduce){.mn-ms-sheet,.mn-ms-sheet-backdrop{animation-duration:.01ms!important}}\n"] }]
|
|
3851
4002
|
}], ctorParameters: () => [], propDecorators: { props: [{
|
|
3852
4003
|
type: Input,
|
|
3853
4004
|
args: [{ required: true }]
|
|
@@ -3857,6 +4008,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImpor
|
|
|
3857
4008
|
}], dropdownRef: [{
|
|
3858
4009
|
type: ViewChild,
|
|
3859
4010
|
args: ['dropdown', { static: false }]
|
|
4011
|
+
}], sheetBackdropRef: [{
|
|
4012
|
+
type: ViewChild,
|
|
4013
|
+
args: ['sheetBackdrop', { static: false }]
|
|
3860
4014
|
}], onDocumentClick: [{
|
|
3861
4015
|
type: HostListener,
|
|
3862
4016
|
args: ['document:click', ['$event']]
|