mn-angular-lib 1.0.161 → 1.0.162
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fesm2022/mn-angular-lib.mjs +492 -32
- package/fesm2022/mn-angular-lib.mjs.map +1 -1
- package/package.json +1 -1
- package/types/mn-angular-lib.d.ts +253 -11
|
@@ -483,6 +483,19 @@ function isTranslatable(value) {
|
|
|
483
483
|
typeof value['$translate'] === 'string');
|
|
484
484
|
}
|
|
485
485
|
|
|
486
|
+
/**
|
|
487
|
+
* Key suffix per CLDR plural category.
|
|
488
|
+
*
|
|
489
|
+
* Only `one` earns a suffix: every locale shipped so far (`en`, `nl`) has exactly the two
|
|
490
|
+
* categories `one` and `other`, and `other` keeps the bare key. A locale with `few`/`many`
|
|
491
|
+
* (Polish, Russian, Arabic) resolves those to the plural until an entry is added here —
|
|
492
|
+
* adding one is the whole change, since the lookup is category-driven already.
|
|
493
|
+
*/
|
|
494
|
+
const PLURAL_SUFFIX = {
|
|
495
|
+
one: 'One',
|
|
496
|
+
};
|
|
497
|
+
/** Params key whose presence turns a translation into a plural-aware lookup. */
|
|
498
|
+
const COUNT_PARAM = 'count';
|
|
486
499
|
class MnLanguageService {
|
|
487
500
|
http = inject(HttpClient);
|
|
488
501
|
appRef = inject(ApplicationRef);
|
|
@@ -490,6 +503,11 @@ class MnLanguageService {
|
|
|
490
503
|
_locale$ = new BehaviorSubject('en');
|
|
491
504
|
_urlPattern = null;
|
|
492
505
|
_debug = false;
|
|
506
|
+
/**
|
|
507
|
+
* `Intl.PluralRules` per locale. Cached because {@link translate} runs on every change
|
|
508
|
+
* detection through the impure `mnTranslate` pipe, and constructing one is not cheap.
|
|
509
|
+
*/
|
|
510
|
+
_pluralRules = new Map();
|
|
493
511
|
/** Observable of the current active locale. */
|
|
494
512
|
locale$ = this._locale$.asObservable();
|
|
495
513
|
/** Current active locale. */
|
|
@@ -567,10 +585,22 @@ class MnLanguageService {
|
|
|
567
585
|
* Falls back to the key itself if no translation is found.
|
|
568
586
|
*
|
|
569
587
|
* Interpolation replaces `{{paramName}}` with the provided value.
|
|
588
|
+
*
|
|
589
|
+
* A `count` param additionally selects the wording that agrees with it: the key is
|
|
590
|
+
* resolved against its CLDR plural category first (`key` + `One`/`Two`/`Few`/`Many`/
|
|
591
|
+
* `Zero`), falling back to `key` when that sibling is undefined. Nothing has to opt in —
|
|
592
|
+
* a key with no sibling behaves exactly as before.
|
|
593
|
+
*
|
|
594
|
+
* ```ts
|
|
595
|
+
* // 'shift.asked' → '{{count}} members are notified'
|
|
596
|
+
* // 'shift.askedOne' → '{{count}} member is notified'
|
|
597
|
+
* lang.translate('shift.asked', { count: 3 }); // 3 members are notified
|
|
598
|
+
* lang.translate('shift.asked', { count: 1 }); // 1 member is notified
|
|
599
|
+
* ```
|
|
570
600
|
*/
|
|
571
601
|
translate(key, params) {
|
|
572
602
|
const map = this._translations[this.locale] ?? {};
|
|
573
|
-
let value = this.getValueFromMap(map, key);
|
|
603
|
+
let value = this.getValueFromMap(map, this.resolvePluralKey(map, key, params));
|
|
574
604
|
if (value === undefined) {
|
|
575
605
|
if (this._debug) {
|
|
576
606
|
console.warn(`[MnLanguage] Missing translation for key: "${key}" in locale: "${this.locale}"`);
|
|
@@ -584,12 +614,64 @@ class MnLanguageService {
|
|
|
584
614
|
}
|
|
585
615
|
return value;
|
|
586
616
|
}
|
|
617
|
+
/**
|
|
618
|
+
* Picks the wording that agrees with a `count` param.
|
|
619
|
+
*
|
|
620
|
+
* A key carrying a count resolves against its CLDR plural category first, so
|
|
621
|
+
* `askedMessage` + `askedMessageOne` render "3 leden krijgen bericht" and "1 lid krijgt
|
|
622
|
+
* bericht" off the same call. Both languages change the verb as well as the noun, which
|
|
623
|
+
* is why each form is a whole sentence under its own key rather than a swapped noun.
|
|
624
|
+
*
|
|
625
|
+
* Falls back to `key` whenever the sibling is undefined, so a key that never needed a
|
|
626
|
+
* plural — or an app that has not written one yet — behaves exactly as it did before.
|
|
627
|
+
* @param map The active locale's translations.
|
|
628
|
+
* @param key The dot-notated translation key.
|
|
629
|
+
* @param params The interpolation values, inspected for `count`.
|
|
630
|
+
* @returns The key to look up: the plural sibling, or `key` itself.
|
|
631
|
+
*/
|
|
632
|
+
resolvePluralKey(map, key, params) {
|
|
633
|
+
const raw = params?.[COUNT_PARAM];
|
|
634
|
+
if (raw === undefined)
|
|
635
|
+
return key;
|
|
636
|
+
// A count off a JSON payload arrives as a string often enough that comparing it
|
|
637
|
+
// strictly would silently pick the plural for a count of one.
|
|
638
|
+
const count = Number(raw);
|
|
639
|
+
if (!Number.isFinite(count))
|
|
640
|
+
return key;
|
|
641
|
+
const suffix = PLURAL_SUFFIX[this.pluralCategory(count)];
|
|
642
|
+
if (suffix === undefined)
|
|
643
|
+
return key;
|
|
644
|
+
const variant = key + suffix;
|
|
645
|
+
return this.getValueFromMap(map, variant) !== undefined ? variant : key;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* The CLDR plural category of a count in the active locale.
|
|
649
|
+
* @param count The count being quoted.
|
|
650
|
+
* @returns The category, falling back to English rules for an unusable locale.
|
|
651
|
+
*/
|
|
652
|
+
pluralCategory(count) {
|
|
653
|
+
if (!this._pluralRules.has(this.locale)) {
|
|
654
|
+
try {
|
|
655
|
+
this._pluralRules.set(this.locale, new Intl.PluralRules(this.locale));
|
|
656
|
+
}
|
|
657
|
+
catch {
|
|
658
|
+
// An unknown or malformed locale tag: fall back rather than break every string.
|
|
659
|
+
this._pluralRules.set(this.locale, null);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
const rules = this._pluralRules.get(this.locale);
|
|
663
|
+
if (!rules)
|
|
664
|
+
return count === 1 ? 'one' : 'other';
|
|
665
|
+
return rules.select(count);
|
|
666
|
+
}
|
|
587
667
|
/**
|
|
588
668
|
* Helper to retrieve a value from a potentially nested translation map using a dot-notated key.
|
|
589
669
|
*/
|
|
590
670
|
getValueFromMap(map, key) {
|
|
591
|
-
|
|
592
|
-
|
|
671
|
+
// A flattened bundle holds the dotted key verbatim; a nested one is walked below.
|
|
672
|
+
const direct = map[key];
|
|
673
|
+
if (typeof direct === 'string')
|
|
674
|
+
return direct;
|
|
593
675
|
const parts = key.split('.');
|
|
594
676
|
let current = map;
|
|
595
677
|
for (const part of parts) {
|
|
@@ -5599,18 +5681,89 @@ const mnSelectVariants = tv({
|
|
|
5599
5681
|
});
|
|
5600
5682
|
|
|
5601
5683
|
const MN_SELECT_CONFIG = new InjectionToken('MN_SELECT_CONFIG');
|
|
5684
|
+
/**
|
|
5685
|
+
* A single-value picker. The trigger opens a `role="listbox"` of {@link MnSelectOption}s;
|
|
5686
|
+
* choosing one sets the value and closes — this is the value-picker twin of the ⋯
|
|
5687
|
+
* command menu mn-dropdown, so it *is* a ControlValueAccessor.
|
|
5688
|
+
*
|
|
5689
|
+
* Presentation mirrors mn-multi-select: one custom field trigger at every size, an
|
|
5690
|
+
* anchored popover on desktop and the shared {@link MnBottomSheet} on mobile (< 640px) —
|
|
5691
|
+
* the same sheet mn-dropdown itself wraps. Both the popover and the sheet host are
|
|
5692
|
+
* portalled to `document.body` so their `position: fixed` anchors to the viewport rather
|
|
5693
|
+
* than any transformed/filtered ancestor (a table cell, a card) — the same root-cause fix
|
|
5694
|
+
* the multi-select applies.
|
|
5695
|
+
*/
|
|
5602
5696
|
class MnSelect {
|
|
5603
5697
|
ngControl = inject(NgControl, { optional: true, self: true });
|
|
5604
5698
|
props;
|
|
5605
5699
|
/** Currently selected value */
|
|
5606
5700
|
selectedValue = null;
|
|
5701
|
+
isOpen = false;
|
|
5607
5702
|
isDisabled = false;
|
|
5703
|
+
searchTerm = '';
|
|
5608
5704
|
uiConfig = {};
|
|
5609
5705
|
configService = inject(MnConfigService);
|
|
5610
5706
|
sectionPath = inject(MN_SECTION_PATH, { optional: true }) ?? [];
|
|
5611
5707
|
explicitInstanceId = inject(MN_INSTANCE_ID, { optional: true });
|
|
5708
|
+
elRef = inject(ElementRef);
|
|
5612
5709
|
lang = inject(MnLanguageService);
|
|
5613
5710
|
destroyRef = inject(DestroyRef);
|
|
5711
|
+
renderer = inject(Renderer2);
|
|
5712
|
+
cdr = inject(ChangeDetectorRef);
|
|
5713
|
+
/** Lucide data for the trailing check shown on the selected row. */
|
|
5714
|
+
checkIcon = LucideCheck.icon;
|
|
5715
|
+
/** Reference to the trigger element for positioning the dropdown. */
|
|
5716
|
+
triggerRef;
|
|
5717
|
+
/** Layout classes for the anchored popover panel. The mobile sheet is rendered by
|
|
5718
|
+
* mn-bottom-sheet instead, so it no longer needs a branch here. */
|
|
5719
|
+
panelClasses = 'fixed z-9999 bg-base-100 border border-base-300 rounded-md shadow-lg max-h-60 overflow-auto';
|
|
5720
|
+
/** Layout classes for the invisible click shield rendered under the anchored panel.
|
|
5721
|
+
* One step below the panel's z-index so the panel itself stays clickable, and above
|
|
5722
|
+
* any modal/drawer chrome (which tops out well under 9998). */
|
|
5723
|
+
shieldClasses = 'fixed inset-0 z-9998';
|
|
5724
|
+
/** Option count at which the search input auto-enables when `searchable` is unset. */
|
|
5725
|
+
static DEFAULT_SEARCH_THRESHOLD = 8;
|
|
5726
|
+
/** Tailwind's `sm` breakpoint — below this the panel renders as a bottom sheet.
|
|
5727
|
+
* Kept in step with the same constant in mn-bottom-sheet / mn-multi-select. */
|
|
5728
|
+
static SHEET_MAX_WIDTH = 639.98;
|
|
5729
|
+
/** The anchored popover panel currently moved into `document.body`, if any. */
|
|
5730
|
+
movedPanel = null;
|
|
5731
|
+
/** The click shield currently moved into `document.body`, if any. */
|
|
5732
|
+
movedShield = null;
|
|
5733
|
+
/** The bottom-sheet host, for outside-click tests. The sheet owns its own placement. */
|
|
5734
|
+
sheetHost = null;
|
|
5735
|
+
/** Whether the viewport is currently narrow enough for the sheet layout. */
|
|
5736
|
+
isNarrowViewport = false;
|
|
5737
|
+
/** Live breakpoint match, so rotating the device re-evaluates the layout. */
|
|
5738
|
+
sheetMedia = null;
|
|
5739
|
+
/** The listener registered on `sheetMedia`, retained for teardown. */
|
|
5740
|
+
sheetMediaListener = null;
|
|
5741
|
+
/** `document.body`'s inline `overflow` before the sheet locked it, restored on close. */
|
|
5742
|
+
previousBodyOverflow = null;
|
|
5743
|
+
/**
|
|
5744
|
+
* The sheet's height (px) captured the moment it opened, before any search. Re-applied
|
|
5745
|
+
* as a `min-height` floor so filtering the option list shorter cannot shrink the sheet
|
|
5746
|
+
* mid-type. Null while anchored or closed, so the popover and desktop path are untouched.
|
|
5747
|
+
*/
|
|
5748
|
+
sheetFloorPx = null;
|
|
5749
|
+
/**
|
|
5750
|
+
* Watches the trigger while the panel is open. The panel lives in `document.body`, so it
|
|
5751
|
+
* survives its own trigger being hidden by an ancestor — a wizard step or a tab switched
|
|
5752
|
+
* away with `display: none`. When the trigger stops being visible the panel goes with it.
|
|
5753
|
+
*/
|
|
5754
|
+
visibilityObserver = null;
|
|
5755
|
+
/**
|
|
5756
|
+
* Capture-phase scroll listener installed while open. `window:scroll` only fires for the
|
|
5757
|
+
* document scroller, so scrolling an inner container (a modal body, a scrollable card)
|
|
5758
|
+
* would otherwise leave the portalled panel floating at its stale coordinates.
|
|
5759
|
+
*/
|
|
5760
|
+
scrollCapture = null;
|
|
5761
|
+
/** Dropdown position calculated from the trigger's bounding rect. */
|
|
5762
|
+
dropdownStyle = { top: '0px', left: '0px', width: '0px' };
|
|
5763
|
+
onChange = () => {
|
|
5764
|
+
};
|
|
5765
|
+
onTouched = () => {
|
|
5766
|
+
};
|
|
5614
5767
|
builtInErrorMessages = {
|
|
5615
5768
|
required: 'Please select an option',
|
|
5616
5769
|
};
|
|
@@ -5618,12 +5771,49 @@ class MnSelect {
|
|
|
5618
5771
|
if (this.ngControl)
|
|
5619
5772
|
this.ngControl.valueAccessor = this;
|
|
5620
5773
|
}
|
|
5621
|
-
|
|
5622
|
-
|
|
5774
|
+
/**
|
|
5775
|
+
* The dropdown panel element, queried while it is rendered by the `@if` block. The setter
|
|
5776
|
+
* relocates the panel to `document.body` so that its `position: fixed` coordinates resolve
|
|
5777
|
+
* against the viewport rather than any transformed/filtered ancestor (which would otherwise
|
|
5778
|
+
* become the containing block and push the panel to the middle of the screen — also broken
|
|
5779
|
+
* on iOS). Cleanup is handled when the query clears on close/destroy.
|
|
5780
|
+
*/
|
|
5781
|
+
set dropdownRef(ref) {
|
|
5782
|
+
this.movedPanel = this.portal(ref?.nativeElement ?? null, this.movedPanel);
|
|
5783
|
+
}
|
|
5784
|
+
/**
|
|
5785
|
+
* The click shield sitting under the anchored panel, portalled alongside it for the same
|
|
5786
|
+
* reason: `position: fixed` must resolve against the viewport, not a transformed ancestor.
|
|
5787
|
+
*/
|
|
5788
|
+
set shieldRef(ref) {
|
|
5789
|
+
this.movedShield = this.portal(ref?.nativeElement ?? null, this.movedShield);
|
|
5790
|
+
}
|
|
5791
|
+
/**
|
|
5792
|
+
* The bottom-sheet host, kept as a reference for outside-click tests. The sheet relocates
|
|
5793
|
+
* itself to `document.body`, so nothing is moved here. On open its container height is
|
|
5794
|
+
* captured as the sheet's `min-height` floor.
|
|
5795
|
+
*/
|
|
5796
|
+
set sheetRef(ref) {
|
|
5797
|
+
const el = ref?.nativeElement ?? null;
|
|
5798
|
+
this.sheetHost = el;
|
|
5799
|
+
if (el) {
|
|
5800
|
+
this.captureSheetFloor(el);
|
|
5801
|
+
}
|
|
5802
|
+
else {
|
|
5803
|
+
this.sheetFloorPx = null;
|
|
5804
|
+
}
|
|
5623
5805
|
}
|
|
5624
5806
|
get control() {
|
|
5625
5807
|
return this.ngControl?.control ?? null;
|
|
5626
5808
|
}
|
|
5809
|
+
get selectedOption() {
|
|
5810
|
+
return this.props.options.find(o => o.value === this.selectedValue);
|
|
5811
|
+
}
|
|
5812
|
+
/** The label shown in the trigger: the selected option, else the placeholder. */
|
|
5813
|
+
get displayText() {
|
|
5814
|
+
return this.selectedOption?.label
|
|
5815
|
+
?? this.uiConfig.placeholder ?? this.props.placeholder ?? 'Select...';
|
|
5816
|
+
}
|
|
5627
5817
|
get showError() {
|
|
5628
5818
|
const c = this.control;
|
|
5629
5819
|
return !!c && c.invalid && (c.touched || c.dirty);
|
|
@@ -5634,7 +5824,6 @@ class MnSelect {
|
|
|
5634
5824
|
return [];
|
|
5635
5825
|
return Object.keys(errors).map(key => this.resolveErrorMessageForKey(key, errors));
|
|
5636
5826
|
}
|
|
5637
|
-
// ========== ControlValueAccessor Implementation ==========
|
|
5638
5827
|
get errorMessage() {
|
|
5639
5828
|
const errors = this.control?.errors;
|
|
5640
5829
|
if (!errors)
|
|
@@ -5648,7 +5837,7 @@ class MnSelect {
|
|
|
5648
5837
|
get resolvedName() {
|
|
5649
5838
|
return this.props?.name ?? null;
|
|
5650
5839
|
}
|
|
5651
|
-
get
|
|
5840
|
+
get triggerClasses() {
|
|
5652
5841
|
return mnSelectVariants({
|
|
5653
5842
|
size: this.props.size,
|
|
5654
5843
|
borderRadius: this.props.borderRadius,
|
|
@@ -5656,16 +5845,47 @@ class MnSelect {
|
|
|
5656
5845
|
fullWidth: this.props.fullWidth,
|
|
5657
5846
|
});
|
|
5658
5847
|
}
|
|
5659
|
-
|
|
5848
|
+
/** Whether the panel should currently render as a bottom sheet. */
|
|
5849
|
+
get isSheet() {
|
|
5850
|
+
return this.props.mobileSheet !== false && this.isNarrowViewport;
|
|
5851
|
+
}
|
|
5852
|
+
/**
|
|
5853
|
+
* Whether the search input is shown: the explicit `searchable` prop when set, otherwise
|
|
5854
|
+
* auto-enabled once the option count reaches the threshold.
|
|
5855
|
+
*/
|
|
5856
|
+
get isSearchable() {
|
|
5857
|
+
if (this.props.searchable !== undefined)
|
|
5858
|
+
return this.props.searchable;
|
|
5859
|
+
const threshold = this.props.searchThreshold ?? MnSelect.DEFAULT_SEARCH_THRESHOLD;
|
|
5860
|
+
return this.props.options.length >= threshold;
|
|
5861
|
+
}
|
|
5862
|
+
get filteredOptions() {
|
|
5863
|
+
if (!this.searchTerm)
|
|
5864
|
+
return this.props.options;
|
|
5865
|
+
const lower = this.searchTerm.toLowerCase();
|
|
5866
|
+
return this.props.options.filter(o => o.label.toLowerCase().includes(lower));
|
|
5867
|
+
}
|
|
5868
|
+
// ========== Lifecycle ==========
|
|
5660
5869
|
ngOnInit() {
|
|
5661
5870
|
this.resolveConfig();
|
|
5871
|
+
this.startWatchingViewport();
|
|
5662
5872
|
const sub = this.lang.locale$.pipe(skip(1)).subscribe(() => {
|
|
5663
5873
|
this.resolveConfig();
|
|
5664
5874
|
});
|
|
5665
|
-
this.destroyRef.onDestroy(() =>
|
|
5875
|
+
this.destroyRef.onDestroy(() => {
|
|
5876
|
+
sub.unsubscribe();
|
|
5877
|
+
this.stopWatchingTrigger();
|
|
5878
|
+
this.stopWatchingViewport();
|
|
5879
|
+
this.unlockBodyScroll();
|
|
5880
|
+
// Guarantee the portalled elements never outlive the component.
|
|
5881
|
+
this.movedPanel = this.portal(null, this.movedPanel);
|
|
5882
|
+
this.movedShield = this.portal(null, this.movedShield);
|
|
5883
|
+
this.sheetHost = null;
|
|
5884
|
+
});
|
|
5666
5885
|
}
|
|
5886
|
+
// ========== ControlValueAccessor Implementation ==========
|
|
5667
5887
|
writeValue(val) {
|
|
5668
|
-
// Treat empty string as null so the placeholder is shown and the control stays properly invalid
|
|
5888
|
+
// Treat empty string as null so the placeholder is shown and the control stays properly invalid.
|
|
5669
5889
|
this.selectedValue = (val === '' || val == null) ? null : val;
|
|
5670
5890
|
}
|
|
5671
5891
|
registerOnChange(fn) {
|
|
@@ -5677,42 +5897,258 @@ class MnSelect {
|
|
|
5677
5897
|
setDisabledState(isDisabled) {
|
|
5678
5898
|
this.isDisabled = isDisabled;
|
|
5679
5899
|
}
|
|
5680
|
-
// ==========
|
|
5681
|
-
|
|
5682
|
-
|
|
5683
|
-
|
|
5684
|
-
|
|
5685
|
-
|
|
5686
|
-
onSelectChange(event) {
|
|
5687
|
-
const target = event.target;
|
|
5688
|
-
const index = parseInt(target.value, 10);
|
|
5689
|
-
if (isNaN(index) || index < 0 || index >= this.props.options.length) {
|
|
5690
|
-
this.selectedValue = null;
|
|
5691
|
-
this.onChange(null);
|
|
5900
|
+
// ========== Dropdown Logic ==========
|
|
5901
|
+
toggle() {
|
|
5902
|
+
if (this.isDisabled)
|
|
5903
|
+
return;
|
|
5904
|
+
if (this.isOpen) {
|
|
5905
|
+
this.close();
|
|
5692
5906
|
return;
|
|
5693
5907
|
}
|
|
5694
|
-
|
|
5908
|
+
this.isOpen = true;
|
|
5909
|
+
if (this.isSheet) {
|
|
5910
|
+
// A sheet is anchored to the viewport, so it needs no trigger tracking — only a
|
|
5911
|
+
// scroll lock so the page behind it stays put while the list is scrolled.
|
|
5912
|
+
this.lockBodyScroll();
|
|
5913
|
+
return;
|
|
5914
|
+
}
|
|
5915
|
+
this.updateDropdownPosition();
|
|
5916
|
+
this.startWatchingTrigger();
|
|
5917
|
+
}
|
|
5918
|
+
/** Selects an option, notifies the form and closes — a single choice ends the interaction. */
|
|
5919
|
+
selectOption(option) {
|
|
5695
5920
|
if (option.disabled)
|
|
5696
5921
|
return;
|
|
5697
5922
|
this.selectedValue = option.value;
|
|
5698
5923
|
this.onChange(this.selectedValue);
|
|
5924
|
+
this.close();
|
|
5699
5925
|
}
|
|
5700
5926
|
isSelected(option) {
|
|
5701
5927
|
return this.selectedValue === option.value;
|
|
5702
5928
|
}
|
|
5929
|
+
onSearch(term) {
|
|
5930
|
+
this.searchTerm = term ?? '';
|
|
5931
|
+
}
|
|
5932
|
+
/**
|
|
5933
|
+
* The single close path. Every trigger (outside click, Escape, scroll, resize, the trigger
|
|
5934
|
+
* being hidden, a choice) funnels through here so the open-only listeners are always torn
|
|
5935
|
+
* down with the panel and never leak.
|
|
5936
|
+
*/
|
|
5937
|
+
close() {
|
|
5938
|
+
if (!this.isOpen)
|
|
5939
|
+
return;
|
|
5940
|
+
this.isOpen = false;
|
|
5941
|
+
this.searchTerm = '';
|
|
5942
|
+
this.stopWatchingTrigger();
|
|
5943
|
+
this.unlockBodyScroll();
|
|
5944
|
+
}
|
|
5703
5945
|
handleBlur() {
|
|
5704
5946
|
this.onTouched();
|
|
5705
5947
|
}
|
|
5948
|
+
/**
|
|
5949
|
+
* Dismisses the anchored panel from a shield click, and stops the event there.
|
|
5950
|
+
*
|
|
5951
|
+
* Swallowing it is the point: the shield spans the viewport, so the click would otherwise
|
|
5952
|
+
* land on whatever the panel was floating over. Inside a modal that is the modal's own
|
|
5953
|
+
* backdrop, and "close the dropdown" would double as "throw away the modal". A first click
|
|
5954
|
+
* that only dismisses the overlay is also how native selects and menus behave.
|
|
5955
|
+
*/
|
|
5956
|
+
onShieldClick(event) {
|
|
5957
|
+
event.stopPropagation();
|
|
5958
|
+
event.preventDefault();
|
|
5959
|
+
this.close();
|
|
5960
|
+
}
|
|
5961
|
+
onDocumentClick(event) {
|
|
5962
|
+
const target = event.target;
|
|
5963
|
+
// The panel lives at the body root once open, so it is not a descendant of the host
|
|
5964
|
+
// element — treat clicks inside the portalled panel as "inside" too.
|
|
5965
|
+
const insideHost = !!target && this.elRef.nativeElement.contains(target);
|
|
5966
|
+
const insidePanel = !!target && !!this.movedPanel && this.movedPanel.contains(target);
|
|
5967
|
+
// In sheet mode the backdrop tap is handled by mn-bottom-sheet's own (dismiss); the
|
|
5968
|
+
// sheet host counts as "inside" here so this listener never double-fires the close.
|
|
5969
|
+
const insideSheet = !!target && !!this.sheetHost && this.sheetHost.contains(target);
|
|
5970
|
+
if (!insideHost && !insidePanel && !insideSheet) {
|
|
5971
|
+
this.close();
|
|
5972
|
+
}
|
|
5973
|
+
}
|
|
5974
|
+
/** Closes the dropdown on Escape for keyboard accessibility. */
|
|
5975
|
+
onEscape() {
|
|
5976
|
+
this.close();
|
|
5977
|
+
}
|
|
5978
|
+
/**
|
|
5979
|
+
* Closes the dropdown when the page or a scrollable parent is scrolled.
|
|
5980
|
+
*
|
|
5981
|
+
* Skipped for a sheet: it is anchored to the viewport, not to the trigger, so it has no
|
|
5982
|
+
* stale position to escape. Crucially, opening the soft keyboard fires a `resize` on
|
|
5983
|
+
* Android — closing on that would dismiss the sheet the instant search is focused. A
|
|
5984
|
+
* genuine layout switch is handled by the `matchMedia` listener instead.
|
|
5985
|
+
*/
|
|
5986
|
+
onWindowScrollOrResize() {
|
|
5987
|
+
if (this.isSheet)
|
|
5988
|
+
return;
|
|
5989
|
+
this.close();
|
|
5990
|
+
}
|
|
5706
5991
|
isRequired() {
|
|
5707
5992
|
if (!this.control)
|
|
5708
5993
|
return false;
|
|
5709
5994
|
return this.control.hasValidator(Validators.required);
|
|
5710
5995
|
}
|
|
5711
|
-
|
|
5712
|
-
|
|
5713
|
-
|
|
5714
|
-
|
|
5715
|
-
|
|
5996
|
+
// ========== Viewport / breakpoint watching ==========
|
|
5997
|
+
/**
|
|
5998
|
+
* Tracks the sheet breakpoint through `matchMedia` rather than reading `innerWidth` once,
|
|
5999
|
+
* so rotating the device switches layout instead of leaving a panel positioned for the
|
|
6000
|
+
* previous orientation. An open panel is closed on the switch — its anchored coordinates
|
|
6001
|
+
* and its sheet layout are not interchangeable.
|
|
6002
|
+
*/
|
|
6003
|
+
startWatchingViewport() {
|
|
6004
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
|
|
6005
|
+
return;
|
|
6006
|
+
this.sheetMedia = window.matchMedia(`(max-width: ${MnSelect.SHEET_MAX_WIDTH}px)`);
|
|
6007
|
+
this.isNarrowViewport = this.sheetMedia.matches;
|
|
6008
|
+
this.sheetMediaListener = (event) => {
|
|
6009
|
+
this.isNarrowViewport = event.matches;
|
|
6010
|
+
this.close();
|
|
6011
|
+
// The listener fires outside Angular, so a zoneless app needs an explicit nudge.
|
|
6012
|
+
this.cdr.markForCheck();
|
|
6013
|
+
};
|
|
6014
|
+
this.sheetMedia.addEventListener('change', this.sheetMediaListener);
|
|
6015
|
+
}
|
|
6016
|
+
/** Tears down the breakpoint listener. Idempotent. */
|
|
6017
|
+
stopWatchingViewport() {
|
|
6018
|
+
if (this.sheetMedia && this.sheetMediaListener) {
|
|
6019
|
+
this.sheetMedia.removeEventListener('change', this.sheetMediaListener);
|
|
6020
|
+
}
|
|
6021
|
+
this.sheetMedia = null;
|
|
6022
|
+
this.sheetMediaListener = null;
|
|
6023
|
+
}
|
|
6024
|
+
// ========== Body scroll lock (sheet only) ==========
|
|
6025
|
+
/**
|
|
6026
|
+
* Freezes the page behind an open sheet. The previous inline value is captured and restored
|
|
6027
|
+
* verbatim so a surrounding modal that set its own lock is left intact.
|
|
6028
|
+
*/
|
|
6029
|
+
lockBodyScroll() {
|
|
6030
|
+
if (this.previousBodyOverflow !== null)
|
|
6031
|
+
return;
|
|
6032
|
+
this.previousBodyOverflow = document.body.style.overflow;
|
|
6033
|
+
this.renderer.setStyle(document.body, 'overflow', 'hidden');
|
|
6034
|
+
}
|
|
6035
|
+
/** Restores the pre-lock `overflow`. Idempotent. */
|
|
6036
|
+
unlockBodyScroll() {
|
|
6037
|
+
if (this.previousBodyOverflow === null)
|
|
6038
|
+
return;
|
|
6039
|
+
if (this.previousBodyOverflow) {
|
|
6040
|
+
this.renderer.setStyle(document.body, 'overflow', this.previousBodyOverflow);
|
|
6041
|
+
}
|
|
6042
|
+
else {
|
|
6043
|
+
this.renderer.removeStyle(document.body, 'overflow');
|
|
6044
|
+
}
|
|
6045
|
+
this.previousBodyOverflow = null;
|
|
6046
|
+
}
|
|
6047
|
+
// ========== Positioning ==========
|
|
6048
|
+
/** Calculates the fixed position for the dropdown based on the trigger element. */
|
|
6049
|
+
updateDropdownPosition() {
|
|
6050
|
+
if (!this.triggerRef)
|
|
6051
|
+
return;
|
|
6052
|
+
const rect = this.triggerRef.nativeElement.getBoundingClientRect();
|
|
6053
|
+
this.dropdownStyle = {
|
|
6054
|
+
top: `${rect.bottom}px`,
|
|
6055
|
+
left: `${rect.left}px`,
|
|
6056
|
+
width: `${rect.width}px`,
|
|
6057
|
+
};
|
|
6058
|
+
}
|
|
6059
|
+
/**
|
|
6060
|
+
* Starts the open-only watchers: an `IntersectionObserver` on the trigger (closes the panel
|
|
6061
|
+
* as soon as the trigger stops being rendered/visible) and a capture-phase `scroll` listener
|
|
6062
|
+
* (closes it when any ancestor scroller moves under it). Scrolls that originate inside the
|
|
6063
|
+
* panel's own option list are ignored.
|
|
6064
|
+
*/
|
|
6065
|
+
startWatchingTrigger() {
|
|
6066
|
+
this.stopWatchingTrigger();
|
|
6067
|
+
const trigger = this.triggerRef?.nativeElement;
|
|
6068
|
+
if (trigger && typeof IntersectionObserver !== 'undefined') {
|
|
6069
|
+
this.visibilityObserver = new IntersectionObserver(entries => {
|
|
6070
|
+
if (!entries.some(entry => !entry.isIntersecting))
|
|
6071
|
+
return;
|
|
6072
|
+
this.close();
|
|
6073
|
+
// The observer fires outside Angular, so a zoneless app needs an explicit nudge.
|
|
6074
|
+
this.cdr.markForCheck();
|
|
6075
|
+
});
|
|
6076
|
+
this.visibilityObserver.observe(trigger);
|
|
6077
|
+
}
|
|
6078
|
+
this.scrollCapture = (event) => {
|
|
6079
|
+
const target = event.target;
|
|
6080
|
+
if (target && this.movedPanel && (this.movedPanel === target || this.movedPanel.contains(target))) {
|
|
6081
|
+
return;
|
|
6082
|
+
}
|
|
6083
|
+
this.close();
|
|
6084
|
+
this.cdr.markForCheck();
|
|
6085
|
+
};
|
|
6086
|
+
document.addEventListener('scroll', this.scrollCapture, true);
|
|
6087
|
+
}
|
|
6088
|
+
/** Tears down the watchers installed by `startWatchingTrigger`. Idempotent. */
|
|
6089
|
+
stopWatchingTrigger() {
|
|
6090
|
+
this.visibilityObserver?.disconnect();
|
|
6091
|
+
this.visibilityObserver = null;
|
|
6092
|
+
if (this.scrollCapture) {
|
|
6093
|
+
document.removeEventListener('scroll', this.scrollCapture, true);
|
|
6094
|
+
this.scrollCapture = null;
|
|
6095
|
+
}
|
|
6096
|
+
}
|
|
6097
|
+
// ========== Sheet height floor ==========
|
|
6098
|
+
/**
|
|
6099
|
+
* Records the sheet's opened height as its `min-height` floor. Measured on the next frame
|
|
6100
|
+
* so the read reflects the fully-rendered, unfiltered list (the search box is empty on
|
|
6101
|
+
* open) and never forces a reflow mid change-detection. The floor equals the content height
|
|
6102
|
+
* at that instant, so applying it triggers no resize — it only stops a later, shorter
|
|
6103
|
+
* filtered list from pulling the sheet down.
|
|
6104
|
+
*
|
|
6105
|
+
* `hostEl` is the portalled mn-bottom-sheet host (`display: contents`), so the height is
|
|
6106
|
+
* read from its `.mn-sheet-container` child rather than the host itself.
|
|
6107
|
+
*/
|
|
6108
|
+
captureSheetFloor(hostEl) {
|
|
6109
|
+
const measure = () => {
|
|
6110
|
+
const container = hostEl.querySelector('.mn-sheet-container');
|
|
6111
|
+
return container?.offsetHeight ?? hostEl.offsetHeight;
|
|
6112
|
+
};
|
|
6113
|
+
if (typeof requestAnimationFrame !== 'function') {
|
|
6114
|
+
this.sheetFloorPx = measure();
|
|
6115
|
+
return;
|
|
6116
|
+
}
|
|
6117
|
+
requestAnimationFrame(() => {
|
|
6118
|
+
// The sheet may have closed before the frame ran; don't strand a stale floor.
|
|
6119
|
+
if (!this.isOpen || this.sheetHost !== hostEl)
|
|
6120
|
+
return;
|
|
6121
|
+
this.sheetFloorPx = measure();
|
|
6122
|
+
this.cdr.markForCheck();
|
|
6123
|
+
});
|
|
6124
|
+
}
|
|
6125
|
+
// ========== Portal helper (see mn-multi-select for the full rationale) ==========
|
|
6126
|
+
/**
|
|
6127
|
+
* Move an overlay element to `document.body` when it appears, and detach it when the query
|
|
6128
|
+
* clears. Appending to the body root makes the element immune to ancestor
|
|
6129
|
+
* `transform`/`filter`/`will-change`, so `position: fixed` anchors to the viewport — without
|
|
6130
|
+
* this the panel lands mid-screen (and breaks outright on iOS).
|
|
6131
|
+
*
|
|
6132
|
+
* Returns the element now portalled, so the caller can store it. Idempotent and safe to
|
|
6133
|
+
* call with `null`.
|
|
6134
|
+
*/
|
|
6135
|
+
portal(el, current) {
|
|
6136
|
+
if (el) {
|
|
6137
|
+
if (current === el)
|
|
6138
|
+
return current;
|
|
6139
|
+
this.renderer.appendChild(document.body, el);
|
|
6140
|
+
return el;
|
|
6141
|
+
}
|
|
6142
|
+
if (current) {
|
|
6143
|
+
// Angular's view teardown may already have removed it; only detach if still attached.
|
|
6144
|
+
const parent = current.parentNode;
|
|
6145
|
+
if (parent) {
|
|
6146
|
+
this.renderer.removeChild(parent, current);
|
|
6147
|
+
}
|
|
6148
|
+
}
|
|
6149
|
+
return null;
|
|
6150
|
+
}
|
|
6151
|
+
// ========== Config / Error Handling ==========
|
|
5716
6152
|
resolveConfig() {
|
|
5717
6153
|
const instanceId = this.explicitInstanceId || `mn-select-${this.props.id}`;
|
|
5718
6154
|
this.uiConfig = this.configService.resolve('mn-select', this.sectionPath, instanceId);
|
|
@@ -5750,20 +6186,44 @@ class MnSelect {
|
|
|
5750
6186
|
return msgDef;
|
|
5751
6187
|
}
|
|
5752
6188
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnSelect, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
5753
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnSelect, isStandalone: true, selector: "mn-lib-select", inputs: { props: "props" }, host: { properties: { "style.display": "props?.fullWidth ? 'block' : null", "style.width": "props?.fullWidth ? '100%' : null" } }, ngImport: i0, template: "<div [class.is-fullwidth]=\"props.fullWidth\" class=\"flex flex-col h-full\">\n @if (uiConfig.label || props.label) {\n <label [attr.for]=\"resolvedId\" class=\"pl-2 pb-1 flex flex-row gap-x-0.5! text-base!\">\n <p>{{ uiConfig.label || props.label }}</p>\n @if (isRequired()) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n }\n\n <select\n (blur)=\"handleBlur()\"\n (change)=\"onSelectChange($event)\"\n [attr.aria-describedby]=\"showError ? resolvedId + '-error' : null\"\n [attr.aria-invalid]=\"showError || null\"\n [attr.aria-label]=\"uiConfig.ariaLabel || uiConfig.label || props.label || null\"\n [disabled]=\"isDisabled\"\n [id]=\"resolvedId\"\n [name]=\"resolvedName\"\n [ngClass]=\"selectClasses\"\n >\n @if (props.placeholder || uiConfig.placeholder) {\n <option [selected]=\"selectedValue === null\" [value]=\"''\" disabled>\n {{ uiConfig.placeholder || props.placeholder }}\n </option>\n }\n @for (opt of props.options; track opt.value) {\n <option\n [disabled]=\"opt.disabled\"\n [selected]=\"isSelected(opt)\"\n [value]=\"optionIndex(opt)\"\n >{{ opt.label }}\n </option>\n }\n </select>\n\n @if (showError) {\n @if (props.showAllErrors) {\n <div class=\"flex flex-col gap-y-1 mt-1\">\n @for (error of errorMessages; track $index) {\n <mn-error-message [errorMessage]=\"error\" [id]=\"resolvedId + '-' + $index\"></mn-error-message>\n }\n </div>\n } @else {\n @if (errorMessage !== null) {\n <mn-error-message [errorMessage]=\"errorMessage\" [id]=\"resolvedId\"></mn-error-message>\n }\n }\n }\n</div>\n", dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "component", type: MnErrorMessage, selector: "mn-error-message", inputs: ["errorMessage", "id"] }] });
|
|
6189
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.3", type: MnSelect, isStandalone: true, selector: "mn-lib-select", inputs: { props: "props" }, host: { listeners: { "document:click": "onDocumentClick($event)", "document:keydown.escape": "onEscape()", "window:scroll": "onWindowScrollOrResize()", "window:resize": "onWindowScrollOrResize()" }, properties: { "style.display": "props?.fullWidth ? 'block' : null", "style.width": "props?.fullWidth ? '100%' : null" } }, viewQueries: [{ propertyName: "triggerRef", first: true, predicate: ["trigger"], descendants: true }, { propertyName: "dropdownRef", first: true, predicate: ["dropdown"], descendants: true }, { propertyName: "shieldRef", first: true, predicate: ["shield"], descendants: true }, { propertyName: "sheetRef", first: true, predicate: ["sheet"], descendants: true, read: ElementRef }], ngImport: i0, template: "<div [class.is-fullwidth]=\"props.fullWidth\" class=\"flex flex-col h-full\">\n @if (uiConfig.label || props.label) {\n <label [attr.for]=\"resolvedId\" class=\"pl-2 pb-1 flex flex-row gap-x-0.5! text-base!\">\n <p>{{ uiConfig.label || props.label }}</p>\n @if (isRequired()) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n }\n\n <!-- Trigger -->\n <div\n #trigger\n (blur)=\"handleBlur()\"\n (click)=\"toggle()\"\n (keydown.enter)=\"toggle()\"\n (keydown.space)=\"toggle(); $event.preventDefault()\"\n [attr.aria-controls]=\"isOpen ? resolvedId + '-listbox' : null\"\n [attr.aria-describedby]=\"showError ? resolvedId + '-error' : null\"\n [attr.aria-disabled]=\"isDisabled || null\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-invalid]=\"showError || null\"\n [attr.aria-label]=\"uiConfig.ariaLabel || uiConfig.label || props.label || null\"\n [class.cursor-not-allowed]=\"isDisabled\"\n [class.opacity-60]=\"isDisabled\"\n [id]=\"resolvedId\"\n [ngClass]=\"triggerClasses\"\n [tabindex]=\"isDisabled ? -1 : 0\"\n aria-haspopup=\"listbox\"\n class=\"relative\"\n role=\"combobox\"\n >\n <!-- `pr-6` reserves the gutter the caret is absolutely positioned in (right-2 + w-4),\n so the value can never render underneath it. `min-w-0` lets the label shrink below\n its content width, which is what makes truncation possible. -->\n <div class=\"flex flex-row items-center min-h-6 min-w-0 pr-6\">\n <span\n [attr.title]=\"selectedOption?.label\"\n [ngClass]=\"selectedOption ? 'text-base-content' : 'text-base-content/50'\"\n class=\"truncate\"\n >{{ displayText }}</span>\n </div>\n <div class=\"absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none\">\n <svg [size]=\"16\" class=\"text-base-content/50\" lucideChevronDown></svg>\n </div>\n </div>\n\n <!-- Dropdown -->\n @if (isOpen) {\n @if (isSheet) {\n <!-- On mobile the panel is presented as a shared bottom sheet: the sheet chrome\n (backdrop, grabber, swipe/flick-to-dismiss, slide animation) lives in\n mn-bottom-sheet; this component only projects the field's content into it. The\n sheet host is portalled to document.body (see the `sheet` ViewChild) so its\n `position: fixed` anchors to the viewport, not a transformed ancestor. -->\n <mn-bottom-sheet\n #sheet\n (dismiss)=\"close()\"\n [ariaLabel]=\"uiConfig.ariaLabel || uiConfig.label || props.label || uiConfig.placeholder || props.placeholder\"\n [maxHeightVh]=\"80\"\n [minHeightPx]=\"sheetFloorPx\"\n >\n <div\n [id]=\"resolvedId + '-listbox'\"\n class=\"flex flex-col flex-1 min-h-0 overflow-hidden\"\n role=\"listbox\"\n >\n <!-- The sheet covers its own trigger, so it carries a header to name the field; the\n way out is the grabber (swipe-to-dismiss) and a backdrop tap. -->\n <div class=\"px-4 pt-1 pb-2 shrink-0\">\n <p class=\"text-base font-medium text-base-content truncate\">\n {{ uiConfig.label || props.label || uiConfig.placeholder || props.placeholder || '' }}\n </p>\n </div>\n <ng-container [ngTemplateOutlet]=\"panelBody\"></ng-container>\n </div>\n </mn-bottom-sheet>\n } @else {\n <!-- A transparent full-viewport shield behind the panel, so \"click anywhere to dismiss\"\n is literally true. It also *consumes* that click: without it the click reaches\n whatever sits underneath \u2014 inside a modal that is the modal's own backdrop, so\n dismissing the dropdown would tear down the whole modal with it. Portalled to\n document.body for the same reason the panel is. It is aria-hidden and unfocusable:\n the keyboard equivalent of this click is Escape. -->\n <div\n #shield\n (click)=\"onShieldClick($event)\"\n [id]=\"resolvedId + '-shield'\"\n [ngClass]=\"shieldClasses\"\n aria-hidden=\"true\"\n ></div>\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n #dropdown\n (click)=\"$event.stopPropagation()\"\n [id]=\"resolvedId + '-listbox'\"\n [ngClass]=\"panelClasses\"\n [style.left]=\"dropdownStyle.left\"\n [style.top]=\"dropdownStyle.top\"\n [style.width]=\"dropdownStyle.width\"\n role=\"listbox\"\n >\n <ng-container [ngTemplateOutlet]=\"panelBody\"></ng-container>\n </div>\n }\n }\n\n <!-- The search box + option list, shared verbatim by the sheet and the anchored popover.\n `isSheet` only tunes spacing/sizing and which element scrolls: in sheet mode the list\n is the flex scroller; anchored, the popover itself scrolls. -->\n <ng-template #panelBody>\n @if (isSearchable) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n (click)=\"$event.stopPropagation()\"\n [ngClass]=\"isSheet ? 'px-4 py-2' : 'p-2'\"\n class=\"border-b border-base-300 shrink-0\"\n >\n <mn-lib-input-field\n (ngModelChange)=\"onSearch($event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n [ngModel]=\"searchTerm\"\n [props]=\"{\n id: resolvedId + '-search',\n type: 'search',\n placeholder: props.searchPlaceholder || 'Search...',\n ariaLabel: props.searchPlaceholder || 'Search...',\n fullWidth: true,\n size: 'sm',\n autoFocus: !isSheet\n }\"\n ></mn-lib-input-field>\n </div>\n }\n <div [ngClass]=\"isSheet ? 'flex-1 overflow-auto overscroll-contain' : ''\">\n @for (opt of filteredOptions; track opt.value) {\n <div\n (click)=\"selectOption(opt); $event.stopPropagation()\"\n (keyup.enter)=\"selectOption(opt)\"\n (keyup.space)=\"selectOption(opt)\"\n [attr.aria-selected]=\"isSelected(opt)\"\n [class.opacity-50]=\"opt.disabled\"\n [class.pointer-events-none]=\"opt.disabled\"\n [ngClass]=\"[isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm', isSelected(opt) ? 'bg-primary/10 font-medium' : '']\"\n class=\"flex items-center gap-x-2.5 cursor-pointer text-base-content hover:bg-base-200 transition-colors\"\n role=\"option\"\n tabindex=\"0\"\n >\n <span class=\"truncate min-w-0\">{{ opt.label }}</span>\n <!-- The current choice's marker. Decorative: the state is conveyed to assistive\n tech by `aria-selected` on the row. -->\n @if (isSelected(opt)) {\n <svg\n [lucideIcon]=\"checkIcon\"\n [size]=\"isSheet ? 18 : 16\"\n aria-hidden=\"true\"\n class=\"ml-auto shrink-0 text-primary\"\n ></svg>\n }\n </div>\n }\n @if (filteredOptions.length === 0) {\n <div [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\" class=\"text-base-content/50\">\n {{ uiConfig.noOptionsFound || 'No options found' }}\n </div>\n }\n </div>\n </ng-template>\n\n @if (showError) {\n @if (props.showAllErrors) {\n <div class=\"flex flex-col gap-y-1 mt-1\">\n @for (error of errorMessages; track $index) {\n <mn-error-message [errorMessage]=\"error\" [id]=\"resolvedId + '-' + $index\"></mn-error-message>\n }\n </div>\n } @else {\n @if (errorMessage !== null) {\n <mn-error-message [errorMessage]=\"errorMessage\" [id]=\"resolvedId\"></mn-error-message>\n }\n }\n }\n</div>\n", dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: MnErrorMessage, selector: "mn-error-message", inputs: ["errorMessage", "id"] }, { kind: "component", type: MnInputField, selector: "mn-lib-input-field", inputs: ["props"] }, { kind: "component", type: MnBottomSheet, selector: "mn-bottom-sheet", inputs: ["showBackdrop", "showGrabber", "dismissible", "minHeightPx", "maxHeightVh", "containerClass", "ariaLabel", "ariaLabelledby", "growWithKeyboard", "dismissGuard"], outputs: ["dismiss"] }, { kind: "component", type: LucideDynamicIcon, selector: "svg[lucideIcon]", inputs: ["lucideIcon"] }, { kind: "component", type: LucideChevronDown, selector: "svg[lucideChevronDown]" }] });
|
|
5754
6190
|
}
|
|
5755
6191
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: MnSelect, decorators: [{
|
|
5756
6192
|
type: Component,
|
|
5757
|
-
args: [{ selector: 'mn-lib-select', standalone: true, imports: [NgClass, MnErrorMessage], host: {
|
|
6193
|
+
args: [{ selector: 'mn-lib-select', standalone: true, imports: [NgClass, NgTemplateOutlet, FormsModule, MnErrorMessage, MnInputField, MnBottomSheet, LucideDynamicIcon, LucideChevronDown], host: {
|
|
5758
6194
|
// Without an explicit host width the inline host collapses to its content size, so
|
|
5759
|
-
// the
|
|
6195
|
+
// the trigger's `w-full` (width:100%) resolves against a content-sized box and fails
|
|
5760
6196
|
// to fill the parent. Give the host a real width when fullWidth is requested.
|
|
5761
6197
|
'[style.display]': "props?.fullWidth ? 'block' : null",
|
|
5762
6198
|
'[style.width]': "props?.fullWidth ? '100%' : null",
|
|
5763
|
-
}, template: "<div [class.is-fullwidth]=\"props.fullWidth\" class=\"flex flex-col h-full\">\n @if (uiConfig.label || props.label) {\n <label [attr.for]=\"resolvedId\" class=\"pl-2 pb-1 flex flex-row gap-x-0.5! text-base!\">\n <p>{{ uiConfig.label || props.label }}</p>\n @if (isRequired()) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n }\n\n <
|
|
6199
|
+
}, template: "<div [class.is-fullwidth]=\"props.fullWidth\" class=\"flex flex-col h-full\">\n @if (uiConfig.label || props.label) {\n <label [attr.for]=\"resolvedId\" class=\"pl-2 pb-1 flex flex-row gap-x-0.5! text-base!\">\n <p>{{ uiConfig.label || props.label }}</p>\n @if (isRequired()) {\n <span class=\"text-red-500\">*</span>\n }\n </label>\n }\n\n <!-- Trigger -->\n <div\n #trigger\n (blur)=\"handleBlur()\"\n (click)=\"toggle()\"\n (keydown.enter)=\"toggle()\"\n (keydown.space)=\"toggle(); $event.preventDefault()\"\n [attr.aria-controls]=\"isOpen ? resolvedId + '-listbox' : null\"\n [attr.aria-describedby]=\"showError ? resolvedId + '-error' : null\"\n [attr.aria-disabled]=\"isDisabled || null\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-invalid]=\"showError || null\"\n [attr.aria-label]=\"uiConfig.ariaLabel || uiConfig.label || props.label || null\"\n [class.cursor-not-allowed]=\"isDisabled\"\n [class.opacity-60]=\"isDisabled\"\n [id]=\"resolvedId\"\n [ngClass]=\"triggerClasses\"\n [tabindex]=\"isDisabled ? -1 : 0\"\n aria-haspopup=\"listbox\"\n class=\"relative\"\n role=\"combobox\"\n >\n <!-- `pr-6` reserves the gutter the caret is absolutely positioned in (right-2 + w-4),\n so the value can never render underneath it. `min-w-0` lets the label shrink below\n its content width, which is what makes truncation possible. -->\n <div class=\"flex flex-row items-center min-h-6 min-w-0 pr-6\">\n <span\n [attr.title]=\"selectedOption?.label\"\n [ngClass]=\"selectedOption ? 'text-base-content' : 'text-base-content/50'\"\n class=\"truncate\"\n >{{ displayText }}</span>\n </div>\n <div class=\"absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none\">\n <svg [size]=\"16\" class=\"text-base-content/50\" lucideChevronDown></svg>\n </div>\n </div>\n\n <!-- Dropdown -->\n @if (isOpen) {\n @if (isSheet) {\n <!-- On mobile the panel is presented as a shared bottom sheet: the sheet chrome\n (backdrop, grabber, swipe/flick-to-dismiss, slide animation) lives in\n mn-bottom-sheet; this component only projects the field's content into it. The\n sheet host is portalled to document.body (see the `sheet` ViewChild) so its\n `position: fixed` anchors to the viewport, not a transformed ancestor. -->\n <mn-bottom-sheet\n #sheet\n (dismiss)=\"close()\"\n [ariaLabel]=\"uiConfig.ariaLabel || uiConfig.label || props.label || uiConfig.placeholder || props.placeholder\"\n [maxHeightVh]=\"80\"\n [minHeightPx]=\"sheetFloorPx\"\n >\n <div\n [id]=\"resolvedId + '-listbox'\"\n class=\"flex flex-col flex-1 min-h-0 overflow-hidden\"\n role=\"listbox\"\n >\n <!-- The sheet covers its own trigger, so it carries a header to name the field; the\n way out is the grabber (swipe-to-dismiss) and a backdrop tap. -->\n <div class=\"px-4 pt-1 pb-2 shrink-0\">\n <p class=\"text-base font-medium text-base-content truncate\">\n {{ uiConfig.label || props.label || uiConfig.placeholder || props.placeholder || '' }}\n </p>\n </div>\n <ng-container [ngTemplateOutlet]=\"panelBody\"></ng-container>\n </div>\n </mn-bottom-sheet>\n } @else {\n <!-- A transparent full-viewport shield behind the panel, so \"click anywhere to dismiss\"\n is literally true. It also *consumes* that click: without it the click reaches\n whatever sits underneath \u2014 inside a modal that is the modal's own backdrop, so\n dismissing the dropdown would tear down the whole modal with it. Portalled to\n document.body for the same reason the panel is. It is aria-hidden and unfocusable:\n the keyboard equivalent of this click is Escape. -->\n <div\n #shield\n (click)=\"onShieldClick($event)\"\n [id]=\"resolvedId + '-shield'\"\n [ngClass]=\"shieldClasses\"\n aria-hidden=\"true\"\n ></div>\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n #dropdown\n (click)=\"$event.stopPropagation()\"\n [id]=\"resolvedId + '-listbox'\"\n [ngClass]=\"panelClasses\"\n [style.left]=\"dropdownStyle.left\"\n [style.top]=\"dropdownStyle.top\"\n [style.width]=\"dropdownStyle.width\"\n role=\"listbox\"\n >\n <ng-container [ngTemplateOutlet]=\"panelBody\"></ng-container>\n </div>\n }\n }\n\n <!-- The search box + option list, shared verbatim by the sheet and the anchored popover.\n `isSheet` only tunes spacing/sizing and which element scrolls: in sheet mode the list\n is the flex scroller; anchored, the popover itself scrolls. -->\n <ng-template #panelBody>\n @if (isSearchable) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events,@angular-eslint/template/interactive-supports-focus -->\n <div\n (click)=\"$event.stopPropagation()\"\n [ngClass]=\"isSheet ? 'px-4 py-2' : 'p-2'\"\n class=\"border-b border-base-300 shrink-0\"\n >\n <mn-lib-input-field\n (ngModelChange)=\"onSearch($event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n [ngModel]=\"searchTerm\"\n [props]=\"{\n id: resolvedId + '-search',\n type: 'search',\n placeholder: props.searchPlaceholder || 'Search...',\n ariaLabel: props.searchPlaceholder || 'Search...',\n fullWidth: true,\n size: 'sm',\n autoFocus: !isSheet\n }\"\n ></mn-lib-input-field>\n </div>\n }\n <div [ngClass]=\"isSheet ? 'flex-1 overflow-auto overscroll-contain' : ''\">\n @for (opt of filteredOptions; track opt.value) {\n <div\n (click)=\"selectOption(opt); $event.stopPropagation()\"\n (keyup.enter)=\"selectOption(opt)\"\n (keyup.space)=\"selectOption(opt)\"\n [attr.aria-selected]=\"isSelected(opt)\"\n [class.opacity-50]=\"opt.disabled\"\n [class.pointer-events-none]=\"opt.disabled\"\n [ngClass]=\"[isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm', isSelected(opt) ? 'bg-primary/10 font-medium' : '']\"\n class=\"flex items-center gap-x-2.5 cursor-pointer text-base-content hover:bg-base-200 transition-colors\"\n role=\"option\"\n tabindex=\"0\"\n >\n <span class=\"truncate min-w-0\">{{ opt.label }}</span>\n <!-- The current choice's marker. Decorative: the state is conveyed to assistive\n tech by `aria-selected` on the row. -->\n @if (isSelected(opt)) {\n <svg\n [lucideIcon]=\"checkIcon\"\n [size]=\"isSheet ? 18 : 16\"\n aria-hidden=\"true\"\n class=\"ml-auto shrink-0 text-primary\"\n ></svg>\n }\n </div>\n }\n @if (filteredOptions.length === 0) {\n <div [ngClass]=\"isSheet ? 'px-4 py-3 text-base' : 'px-3 py-2 text-sm'\" class=\"text-base-content/50\">\n {{ uiConfig.noOptionsFound || 'No options found' }}\n </div>\n }\n </div>\n </ng-template>\n\n @if (showError) {\n @if (props.showAllErrors) {\n <div class=\"flex flex-col gap-y-1 mt-1\">\n @for (error of errorMessages; track $index) {\n <mn-error-message [errorMessage]=\"error\" [id]=\"resolvedId + '-' + $index\"></mn-error-message>\n }\n </div>\n } @else {\n @if (errorMessage !== null) {\n <mn-error-message [errorMessage]=\"errorMessage\" [id]=\"resolvedId\"></mn-error-message>\n }\n }\n }\n</div>\n" }]
|
|
5764
6200
|
}], ctorParameters: () => [], propDecorators: { props: [{
|
|
5765
6201
|
type: Input,
|
|
5766
6202
|
args: [{ required: true }]
|
|
6203
|
+
}], triggerRef: [{
|
|
6204
|
+
type: ViewChild,
|
|
6205
|
+
args: ['trigger', { static: false }]
|
|
6206
|
+
}], dropdownRef: [{
|
|
6207
|
+
type: ViewChild,
|
|
6208
|
+
args: ['dropdown', { static: false }]
|
|
6209
|
+
}], shieldRef: [{
|
|
6210
|
+
type: ViewChild,
|
|
6211
|
+
args: ['shield', { static: false }]
|
|
6212
|
+
}], sheetRef: [{
|
|
6213
|
+
type: ViewChild,
|
|
6214
|
+
args: ['sheet', { static: false, read: ElementRef }]
|
|
6215
|
+
}], onDocumentClick: [{
|
|
6216
|
+
type: HostListener,
|
|
6217
|
+
args: ['document:click', ['$event']]
|
|
6218
|
+
}], onEscape: [{
|
|
6219
|
+
type: HostListener,
|
|
6220
|
+
args: ['document:keydown.escape']
|
|
6221
|
+
}], onWindowScrollOrResize: [{
|
|
6222
|
+
type: HostListener,
|
|
6223
|
+
args: ['window:scroll', []]
|
|
6224
|
+
}, {
|
|
6225
|
+
type: HostListener,
|
|
6226
|
+
args: ['window:resize', []]
|
|
5767
6227
|
}] } });
|
|
5768
6228
|
|
|
5769
6229
|
// =========================
|