tailjng 0.1.6 → 0.1.7
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/README.md +12 -4
- package/cli/execute/init-app.js +5 -2
- package/cli/execute/sync-app.js +14 -2
- package/cli/settings/colors-config-utils.js +43 -8
- package/cli/settings/icons-config-utils.js +62 -0
- package/cli/settings/path-utils.js +32 -2
- package/cli/settings/project-utils.js +7 -1
- package/cli/templates/app.generator.js +2 -2
- package/fesm2022/tailjng.mjs +247 -80
- package/fesm2022/tailjng.mjs.map +1 -1
- package/lib/services/static/theme.service.d.ts +39 -1
- package/lib/utils/theme/theme-variables.util.d.ts +31 -0
- package/package.json +1 -1
- package/public-api.d.ts +2 -1
- package/registry/components.json +41 -18
- package/src/colors.safelist.css +2 -2
- package/src/lib/components/.config/README.md +11 -0
- package/src/lib/components/.config/colors/README.md +38 -0
- package/src/lib/components/{colors-config → .config/colors}/colors.config.ts +5 -5
- package/src/lib/components/{colors-config → .config/colors}/colors.safelist.css +2 -2
- package/src/lib/components/.config/icons/README.md +26 -0
- package/src/lib/components/.config/icons/icons.lucide.ts +134 -0
- package/src/lib/components/.config/input/README.md +24 -0
- package/src/lib/components/.config/input/input.classes.ts +119 -0
- package/src/lib/components/alert/alert-dialog/dialog-alert.component.css +244 -2
- package/src/lib/components/alert/alert-dialog/dialog-alert.component.html +25 -38
- package/src/lib/components/alert/alert-dialog/dialog-alert.component.ts +66 -56
- package/src/lib/components/alert/alert-dialog/dialog-alert.types.ts +19 -0
- package/src/lib/components/alert/alert-toast/toast-alert.component.css +630 -12
- package/src/lib/components/alert/alert-toast/toast-alert.component.html +103 -102
- package/src/lib/components/alert/alert-toast/toast-alert.component.ts +485 -128
- package/src/lib/components/alert/alert-toast/toast-alert.types.ts +25 -0
- package/src/lib/components/badge/badge.component.html +34 -21
- package/src/lib/components/badge/badge.component.ts +140 -31
- package/src/lib/components/button/button.component.html +16 -10
- package/src/lib/components/button/button.component.ts +162 -22
- package/src/lib/components/card/card-complete/complete-card.component.html +2 -2
- package/src/lib/components/card/card-complete/complete-card.component.ts +26 -16
- package/src/lib/components/card/card-crud-complete/complete-crud-card.component.html +2 -2
- package/src/lib/components/card/card-crud-complete/complete-crud-card.component.ts +26 -16
- package/src/lib/components/checkbox/checkbox-input/input-checkbox.component.css +97 -0
- package/src/lib/components/checkbox/checkbox-input/input-checkbox.component.html +54 -46
- package/src/lib/components/checkbox/checkbox-input/input-checkbox.component.ts +135 -64
- package/src/lib/components/checkbox/checkbox-input/input-checkbox.types.ts +3 -0
- package/src/lib/components/checkbox/checkbox-switch/switch-checkbox.component.css +112 -0
- package/src/lib/components/checkbox/checkbox-switch/switch-checkbox.component.html +28 -25
- package/src/lib/components/checkbox/checkbox-switch/switch-checkbox.component.ts +67 -15
- package/src/lib/components/checkbox/checkbox-switch/switch-checkbox.types.ts +1 -0
- package/src/lib/components/coach-mark/coach-mark.component.html +4 -22
- package/src/lib/components/coach-mark/coach-mark.component.scss +1 -1
- package/src/lib/components/coach-mark/coach-mark.component.ts +51 -18
- package/src/lib/components/coach-mark/coach-mark.directive.ts +133 -78
- package/src/lib/components/coach-mark/coach-mark.types.ts +12 -0
- package/src/lib/components/dialog/dialog.component.css +103 -1
- package/src/lib/components/dialog/dialog.component.html +46 -66
- package/src/lib/components/dialog/dialog.component.ts +136 -110
- package/src/lib/components/dialog/dialog.types.ts +19 -0
- package/src/lib/components/filter/filter-complete/complete-filter.component.html +16 -19
- package/src/lib/components/filter/filter-complete/complete-filter.component.scss +35 -0
- package/src/lib/components/filter/filter-complete/complete-filter.component.ts +58 -34
- package/src/lib/components/filter/filter-complete/complete-filter.types.ts +7 -0
- package/src/lib/components/filter/filter-complete/complete-filter.util.ts +16 -0
- package/src/lib/components/form/form-container/container-form.component.css +4 -0
- package/src/lib/components/form/form-container/container-form.component.html +2 -2
- package/src/lib/components/form/form-container/container-form.component.ts +72 -16
- package/src/lib/components/form/form-container/container-form.types.ts +42 -0
- package/src/lib/components/form/form-container/form-col-span.directive.ts +25 -0
- package/src/lib/components/form/form-sidebar/sidebar-form.component.css +276 -0
- package/src/lib/components/form/form-sidebar/sidebar-form.component.html +117 -125
- package/src/lib/components/form/form-sidebar/sidebar-form.component.ts +109 -34
- package/src/lib/components/form/form-sidebar/sidebar-form.types.ts +3 -0
- package/src/lib/components/{toggle-radio/toggle-radio.component.css → form/form-validation/validation-form.component.css} +0 -1
- package/src/lib/components/form/form-validation/validation-form.component.html +10 -6
- package/src/lib/components/form/form-validation/validation-form.component.ts +99 -12
- package/src/lib/components/form/form-validation/validation-form.types.ts +33 -0
- package/src/lib/components/icon/icon.component.html +8 -5
- package/src/lib/components/icon/icon.component.ts +111 -9
- package/src/lib/components/input/input/input.component.html +19 -16
- package/src/lib/components/input/input/input.component.ts +130 -53
- package/src/lib/components/input/input/input.types.ts +8 -0
- package/src/lib/components/input/input-file/file-input.component.html +65 -56
- package/src/lib/components/input/input-file/file-input.component.ts +276 -173
- package/src/lib/components/input/input-file/file-input.types.ts +2 -0
- package/src/lib/components/input/input-range/range-input.component.css +67 -0
- package/src/lib/components/input/input-range/range-input.component.html +50 -58
- package/src/lib/components/input/input-range/range-input.component.ts +148 -60
- package/src/lib/components/input/input-range/range-input.types.ts +7 -0
- package/src/lib/components/input/input-textarea/textarea-input.component.html +16 -7
- package/src/lib/components/input/input-textarea/textarea-input.component.ts +140 -50
- package/src/lib/components/input/input-textarea/textarea-input.types.ts +2 -0
- package/src/lib/components/label/label.component.html +17 -16
- package/src/lib/components/label/label.component.ts +70 -16
- package/src/lib/components/label/label.types.ts +2 -0
- package/src/lib/components/menu/menu-options-table/menu-options-defaults.ts +34 -0
- package/src/lib/components/menu/menu-options-table/options-table-menu.component.html +34 -20
- package/src/lib/components/menu/menu-options-table/options-table-menu.component.ts +211 -58
- package/src/lib/components/menu/menu-options-table/options-table-menu.types.ts +38 -0
- package/src/lib/components/menu/options-coach-menu/options-coach-menu.component.html +49 -52
- package/src/lib/components/menu/options-coach-menu/options-coach-menu.component.ts +112 -24
- package/src/lib/components/menu/options-coach-menu/options-coach-menu.types.ts +9 -0
- package/src/lib/components/mode-toggle/mode-toggle.component.html +11 -16
- package/src/lib/components/mode-toggle/mode-toggle.component.ts +69 -33
- package/src/lib/components/paginator/paginator-complete/complete-paginator.component.html +4 -4
- package/src/lib/components/paginator/paginator-complete/complete-paginator.component.ts +31 -7
- package/src/lib/components/paginator/paginator-complete/complete-paginator.types.ts +12 -0
- package/src/lib/components/paginator/paginator-complete/complete-paginator.util.ts +36 -0
- package/src/lib/components/progress-bar/progress-bar.component.css +11 -0
- package/src/lib/components/progress-bar/progress-bar.component.html +41 -40
- package/src/lib/components/progress-bar/progress-bar.component.ts +95 -11
- package/src/lib/components/progress-bar/progress-bar.types.ts +2 -0
- package/src/lib/components/select/select-dropdown/dropdown-select.component.css +6 -0
- package/src/lib/components/select/select-dropdown/dropdown-select.component.html +54 -44
- package/src/lib/components/select/select-dropdown/dropdown-select.component.ts +450 -509
- package/src/lib/components/select/select-dropdown/dropdown-select.types.ts +43 -0
- package/src/lib/components/select/select-dropdown/dropdown-select.util.ts +179 -0
- package/src/lib/components/select/select-multi-dropdown/multi-dropdown-select.component.css +6 -0
- package/src/lib/components/select/select-multi-dropdown/multi-dropdown-select.component.html +131 -42
- package/src/lib/components/select/select-multi-dropdown/multi-dropdown-select.component.ts +491 -475
- package/src/lib/components/select/select-multi-dropdown/multi-dropdown-select.types.ts +22 -0
- package/src/lib/components/select/select-multi-dropdown/multi-dropdown-select.util.ts +20 -0
- package/src/lib/components/select/select-multi-table/multi-table-select.component.css +10 -0
- package/src/lib/components/select/select-multi-table/multi-table-select.component.html +76 -60
- package/src/lib/components/select/select-multi-table/multi-table-select.component.ts +250 -313
- package/src/lib/components/select/select-multi-table/multi-table-select.types.ts +10 -0
- package/src/lib/components/select/select-multi-table/multi-table-select.util.ts +5 -0
- package/src/lib/components/sidebar/sidebar-static/static-sidebar.component.css +212 -0
- package/src/lib/components/sidebar/sidebar-static/static-sidebar.component.html +62 -53
- package/src/lib/components/sidebar/sidebar-static/static-sidebar.component.ts +84 -27
- package/src/lib/components/sidebar/sidebar-static/static-sidebar.types.ts +2 -0
- package/src/lib/components/table/table-complete/complete-table.component.html +15 -17
- package/src/lib/components/table/table-complete/complete-table.component.ts +190 -338
- package/src/lib/components/table/table-complete/complete-table.types.ts +28 -0
- package/src/lib/components/table/table-complete/complete-table.util.ts +236 -0
- package/src/lib/components/table/table-complete/index.ts +2 -0
- package/src/lib/components/table/table-crud-complete/complete-crud-table.animations.ts +34 -0
- package/src/lib/components/table/table-crud-complete/complete-crud-table.component.html +73 -128
- package/src/lib/components/table/table-crud-complete/complete-crud-table.component.ts +542 -829
- package/src/lib/components/table/table-crud-complete/complete-crud-table.types.ts +57 -0
- package/src/lib/components/table/table-crud-complete/complete-crud-table.util.ts +723 -0
- package/src/lib/components/table/table-crud-complete/index.ts +3 -0
- package/src/lib/components/theme-generator/theme-generator.component.css +21 -0
- package/src/lib/components/theme-generator/theme-generator.component.html +146 -116
- package/src/lib/components/theme-generator/theme-generator.component.ts +44 -24
- package/src/lib/components/toggle-radio/shared/toggle-options.types.ts +8 -0
- package/src/lib/components/toggle-radio/shared/toggle-options.util.ts +44 -0
- package/src/lib/components/toggle-radio/toggle-radio/toggle-radio.component.css +135 -0
- package/src/lib/components/toggle-radio/toggle-radio/toggle-radio.component.html +52 -0
- package/src/lib/components/toggle-radio/toggle-radio/toggle-radio.component.ts +198 -0
- package/src/lib/components/toggle-radio/toggle-radio/toggle-radio.types.ts +1 -0
- package/src/lib/components/toggle-radio/toggle-segment/segment-toggle.component.css +108 -0
- package/src/lib/components/toggle-radio/toggle-segment/segment-toggle.component.html +37 -0
- package/src/lib/components/toggle-radio/toggle-segment/segment-toggle.component.ts +193 -0
- package/src/lib/components/toggle-radio/toggle-segment/segment-toggle.types.ts +1 -0
- package/src/lib/components/tooltip/tooltip.directive.ts +12 -9
- package/src/lib/components/tooltip/tooltip.service.ts +331 -133
- package/src/lib/components/tooltip/tooltip.types.ts +9 -0
- package/src/lib/components/viewer/viewer-image/image-viewer.component.css +90 -4
- package/src/lib/components/viewer/viewer-image/image-viewer.component.html +52 -103
- package/src/lib/components/viewer/viewer-image/image-viewer.component.ts +182 -177
- package/src/lib/components/viewer/viewer-image/image-viewer.types.ts +3 -0
- package/src/lib/components/viewer/viewer-pdf/pdf-viewer.component.css +177 -0
- package/src/lib/components/viewer/viewer-pdf/pdf-viewer.component.html +74 -24
- package/src/lib/components/viewer/viewer-pdf/pdf-viewer.component.ts +168 -15
- package/src/lib/components/viewer/viewer-pdf/pdf-viewer.types.ts +1 -0
- package/src/styles.css +2 -2
- package/lib/services/static/icons.service.d.ts +0 -65
- package/src/lib/components/colors-config/README.md +0 -38
- package/src/lib/components/form/form-sidebar/sidebar-form.component.scss +0 -0
- package/src/lib/components/form/form-validation/validation-form.component.scss +0 -0
- package/src/lib/components/menu/menu-options-table/options-table-menu.component.scss +0 -0
- package/src/lib/components/menu/options-coach-menu/options-coach-menu.component.scss +0 -12
- package/src/lib/components/sidebar/sidebar-static/static-sidebar.component.scss +0 -0
- package/src/lib/components/toggle-radio/toggle-radio.component.html +0 -51
- package/src/lib/components/toggle-radio/toggle-radio.component.ts +0 -222
- package/src/lib/components/viewer/viewer-pdf/pdf-viewer.component.scss +0 -0
- package/tailjng-0.1.6.tgz +0 -0
|
@@ -1,144 +1,275 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
1
|
+
import { animate, style, transition, trigger } from '@angular/animations';
|
|
2
|
+
import { CommonModule } from '@angular/common';
|
|
3
|
+
import {
|
|
4
|
+
AfterViewInit,
|
|
5
|
+
ChangeDetectorRef,
|
|
6
|
+
Component,
|
|
7
|
+
ElementRef,
|
|
8
|
+
EventEmitter,
|
|
9
|
+
Input,
|
|
10
|
+
OnChanges,
|
|
11
|
+
OnDestroy,
|
|
12
|
+
OnInit,
|
|
13
|
+
Optional,
|
|
14
|
+
Output,
|
|
15
|
+
SimpleChanges,
|
|
16
|
+
ViewChild,
|
|
17
|
+
forwardRef,
|
|
18
|
+
} from '@angular/core';
|
|
19
|
+
import {
|
|
20
|
+
ControlValueAccessor,
|
|
21
|
+
FormsModule,
|
|
22
|
+
NG_VALUE_ACCESSOR,
|
|
23
|
+
ReactiveFormsModule,
|
|
24
|
+
} from '@angular/forms';
|
|
25
|
+
import { debounceTime, distinctUntilChanged, Subject, Subscription } from 'rxjs';
|
|
26
|
+
import { JGenericCrudService } from 'tailjng';
|
|
27
|
+
import { Icons } from '../../.config/icons/icons.lucide';
|
|
28
|
+
import { JIconComponent } from '../../icon/icon.component';
|
|
29
|
+
import type {
|
|
30
|
+
MultiDropdownOption,
|
|
31
|
+
MultiDropdownSelectDefaultFilters,
|
|
32
|
+
MultiDropdownSelectDynamicParams,
|
|
33
|
+
MultiDropdownSelectType,
|
|
34
|
+
MultiDropdownSortOrder,
|
|
35
|
+
} from './multi-dropdown-select.types';
|
|
36
|
+
import {
|
|
37
|
+
DROPDOWN_PANEL_DEFAULTS,
|
|
38
|
+
filterOptionsBySearch,
|
|
39
|
+
mapObjectOptions,
|
|
40
|
+
mapPrimitiveOptions,
|
|
41
|
+
normalizeMultiDropdownValue,
|
|
42
|
+
positionDropdownPanel,
|
|
43
|
+
rebuildEndpointUrl,
|
|
44
|
+
resetDropdownPanelStyles,
|
|
45
|
+
} from './multi-dropdown-select.util';
|
|
46
|
+
|
|
47
|
+
export type {
|
|
48
|
+
MultiDropdownOption,
|
|
49
|
+
MultiDropdownSelectDefaultFilters,
|
|
50
|
+
MultiDropdownSelectDynamicParams,
|
|
51
|
+
MultiDropdownSelectType,
|
|
52
|
+
MultiDropdownSortOrder,
|
|
53
|
+
} from './multi-dropdown-select.types';
|
|
54
|
+
export {
|
|
55
|
+
DROPDOWN_PANEL_DEFAULTS,
|
|
56
|
+
filterOptionsBySearch,
|
|
57
|
+
getNestedValue,
|
|
58
|
+
mapObjectOptions,
|
|
59
|
+
mapPrimitiveOptions,
|
|
60
|
+
normalizeMultiDropdownValue,
|
|
61
|
+
positionDropdownPanel,
|
|
62
|
+
rebuildEndpointUrl,
|
|
63
|
+
resetDropdownPanelStyles,
|
|
64
|
+
resolveOptionLabel,
|
|
65
|
+
} from './multi-dropdown-select.util';
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Multi-select dropdown with static options or searchable CRUD endpoint.
|
|
69
|
+
* Panel uses fixed viewport positioning (dialogs, tables, filter popovers).
|
|
70
|
+
*
|
|
71
|
+
* Install: `npx tailjng add select-multi-dropdown`
|
|
72
|
+
*
|
|
73
|
+
* ```html
|
|
74
|
+
* <JMultiDropdownSelect
|
|
75
|
+
* [(ngModel)]="demoMultiSelect"
|
|
76
|
+
* [options]="selectOptions"
|
|
77
|
+
* optionLabel="text"
|
|
78
|
+
* optionValue="value"
|
|
79
|
+
* [enableSelectAll]="true"
|
|
80
|
+
* />
|
|
81
|
+
* ```
|
|
82
|
+
*
|
|
83
|
+
* Icons in template: `[icon]="Icons.Check"` (see `readonly Icons = Icons`).
|
|
84
|
+
*/
|
|
15
85
|
@Component({
|
|
16
|
-
selector:
|
|
17
|
-
imports: [
|
|
18
|
-
templateUrl:
|
|
19
|
-
styleUrl:
|
|
86
|
+
selector: 'JMultiDropdownSelect',
|
|
87
|
+
imports: [JIconComponent, CommonModule, FormsModule, ReactiveFormsModule],
|
|
88
|
+
templateUrl: './multi-dropdown-select.component.html',
|
|
89
|
+
styleUrl: './multi-dropdown-select.component.css',
|
|
20
90
|
animations: [
|
|
21
|
-
trigger(
|
|
22
|
-
transition(
|
|
23
|
-
style({ transform:
|
|
24
|
-
animate(
|
|
91
|
+
trigger('modalTransition', [
|
|
92
|
+
transition(':enter', [
|
|
93
|
+
style({ transform: 'translateX(1rem)', opacity: 0 }),
|
|
94
|
+
animate('300ms ease-out', style({ transform: 'translateY(0)', opacity: 1 })),
|
|
95
|
+
]),
|
|
96
|
+
transition(':leave', [
|
|
97
|
+
animate('150ms ease-in', style({ transform: 'translateX(1rem)', opacity: 0 })),
|
|
25
98
|
]),
|
|
26
|
-
transition(":leave", [animate("150ms ease-in", style({ transform: "translateX(1rem)", opacity: 0 }))]),
|
|
27
99
|
]),
|
|
28
100
|
],
|
|
29
101
|
providers: [
|
|
30
102
|
{
|
|
31
103
|
provide: NG_VALUE_ACCESSOR,
|
|
32
|
-
useExisting: JMultiDropdownSelectComponent,
|
|
104
|
+
useExisting: forwardRef(() => JMultiDropdownSelectComponent),
|
|
33
105
|
multi: true,
|
|
34
106
|
},
|
|
35
107
|
],
|
|
36
108
|
})
|
|
37
|
-
export class JMultiDropdownSelectComponent
|
|
109
|
+
export class JMultiDropdownSelectComponent
|
|
110
|
+
implements ControlValueAccessor, OnInit, AfterViewInit, OnChanges, OnDestroy
|
|
111
|
+
{
|
|
112
|
+
// Project Icons registry — template usage: Icons.Check, Icons.Loader2, …
|
|
113
|
+
readonly Icons = Icons;
|
|
38
114
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
@Input() isDisabled = false
|
|
43
|
-
@Input() isLoading = false
|
|
115
|
+
// =====================================================
|
|
116
|
+
// Inputs / outputs
|
|
117
|
+
// =====================================================
|
|
44
118
|
|
|
45
|
-
|
|
46
|
-
@Input()
|
|
47
|
-
@Input() optionValue = "value"
|
|
48
|
-
@Input() labelSeparator = " "
|
|
119
|
+
// Panel heading above the option list
|
|
120
|
+
@Input() title = 'Seleccionar opciones';
|
|
49
121
|
|
|
50
|
-
|
|
51
|
-
@Input()
|
|
122
|
+
// Trigger label when nothing is selected
|
|
123
|
+
@Input() placeholder = 'Seleccione opciones';
|
|
52
124
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
@Input()
|
|
57
|
-
|
|
125
|
+
// Shows clear (X) on the trigger when at least one value is selected
|
|
126
|
+
@Input() showClear = true;
|
|
127
|
+
|
|
128
|
+
@Input() isDisabled = false;
|
|
129
|
+
|
|
130
|
+
// Loading state on trigger or panel (API / parent control)
|
|
131
|
+
@Input() isLoading = false;
|
|
132
|
+
|
|
133
|
+
// Static options (`string[]` or object array with optionLabel / optionValue)
|
|
134
|
+
@Input() options: unknown[] = [];
|
|
58
135
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
136
|
+
// Property name(s) used to build option text (supports dot paths)
|
|
137
|
+
@Input() optionLabel: string | string[] = 'text';
|
|
138
|
+
|
|
139
|
+
// Property name used as the option value
|
|
140
|
+
@Input() optionValue = 'value';
|
|
141
|
+
|
|
142
|
+
// Separator when `optionLabel` is an array of keys
|
|
143
|
+
@Input() labelSeparator = ' ';
|
|
144
|
+
|
|
145
|
+
// `dropdown` = local options · `searchable` = CRUD endpoint + debounced search
|
|
146
|
+
@Input() type: MultiDropdownSelectType = 'dropdown';
|
|
147
|
+
|
|
148
|
+
/** CRUD endpoint; supports `{param}` placeholders resolved via `dynamicParams`. */
|
|
149
|
+
@Input() set endpoint(value: string) {
|
|
150
|
+
this.rawEndpoint = value;
|
|
151
|
+
this.rebuildEndpoint();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
get endpoint(): string {
|
|
155
|
+
return this.rawEndpoint;
|
|
156
|
+
}
|
|
63
157
|
|
|
64
|
-
/**
|
|
65
|
-
@Input()
|
|
66
|
-
|
|
158
|
+
/** Values substituted into `{param}` segments of `endpoint`. */
|
|
159
|
+
@Input() set dynamicParams(value: MultiDropdownSelectDynamicParams) {
|
|
160
|
+
this.endpointDynamicParams = value;
|
|
161
|
+
this.rebuildEndpoint();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
get dynamicParams(): MultiDropdownSelectDynamicParams {
|
|
165
|
+
return this.endpointDynamicParams;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Searchable: default `filter[key]` query params
|
|
169
|
+
@Input() defaultFilters: MultiDropdownSelectDefaultFilters = {};
|
|
170
|
+
|
|
171
|
+
// Searchable: extra fields included in API search
|
|
67
172
|
@Input() searchFields: string[] = [];
|
|
68
|
-
/** ASC/DESC para el backend */
|
|
69
|
-
@Input() sort: 'ASC' | 'DESC' = 'ASC';
|
|
70
|
-
@Input() limit: number = 1000;
|
|
71
173
|
|
|
174
|
+
// Searchable: sort order sent to the API
|
|
175
|
+
@Input() sort: MultiDropdownSortOrder = 'ASC';
|
|
176
|
+
|
|
177
|
+
// Searchable: max records per request
|
|
178
|
+
@Input() limit = 1000;
|
|
72
179
|
|
|
73
|
-
|
|
180
|
+
// Searchable: fetch on init
|
|
74
181
|
@Input() loadOnInit = false;
|
|
75
|
-
|
|
182
|
+
|
|
183
|
+
// Searchable: fetch when the panel opens
|
|
76
184
|
@Input() loadOpen = false;
|
|
77
185
|
|
|
78
|
-
|
|
186
|
+
// Panel search input (local filter or remote search depending on `type`)
|
|
79
187
|
@Input() isSearch = false;
|
|
80
188
|
|
|
81
|
-
/**
|
|
82
|
-
@Output() fullData = new EventEmitter<
|
|
189
|
+
/** Searchable: emits the full API result array after each load. */
|
|
190
|
+
@Output() fullData = new EventEmitter<unknown[]>();
|
|
83
191
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
private searchSubscription?: Subscription;
|
|
192
|
+
// Shows the “select all” row at the top of the panel
|
|
193
|
+
@Input() enableSelectAll = true;
|
|
87
194
|
|
|
88
|
-
|
|
89
|
-
|
|
195
|
+
// Trigger label when every option is selected
|
|
196
|
+
@Input() labelSelectAll = 'TODOS';
|
|
90
197
|
|
|
91
|
-
|
|
92
|
-
|
|
198
|
+
// Separator between selected item labels on the trigger
|
|
199
|
+
@Input() multipleSeparator = ', ';
|
|
93
200
|
|
|
94
|
-
//
|
|
95
|
-
@Input()
|
|
96
|
-
@Input() labelSelectAll = "TODOS"
|
|
97
|
-
@Input() multipleSeparator = ", "
|
|
98
|
-
@Input() maxDisplayItems = 3
|
|
201
|
+
// Max selected labels shown on the trigger before “y N más”
|
|
202
|
+
@Input() maxDisplayItems = 3;
|
|
99
203
|
|
|
204
|
+
/** Emits the array of selected values (empty when cleared). */
|
|
205
|
+
@Output() selectionChange = new EventEmitter<unknown[]>();
|
|
100
206
|
|
|
101
|
-
@
|
|
207
|
+
/** @deprecated Kept for backward compatibility; panel positioning no longer uses this flag. */
|
|
102
208
|
@Input() isFilterSelect = false;
|
|
103
209
|
|
|
104
|
-
|
|
210
|
+
// =====================================================
|
|
211
|
+
// View / state
|
|
212
|
+
// =====================================================
|
|
105
213
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
processedOptions: ProcessedOption[] = []
|
|
109
|
-
displayLabel = ""
|
|
214
|
+
@ViewChild('selectButton') selectButton!: ElementRef<HTMLElement>;
|
|
215
|
+
@ViewChild('dropdownPanel') dropdownPanel?: ElementRef<HTMLElement>;
|
|
110
216
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
217
|
+
isDropdownOpen = false;
|
|
218
|
+
selectedValues: unknown[] = [];
|
|
219
|
+
processedOptions: MultiDropdownOption[] = [];
|
|
220
|
+
filteredProcessedOptions: MultiDropdownOption[] = [];
|
|
221
|
+
displayLabel = '';
|
|
115
222
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
223
|
+
searchTerm = '';
|
|
224
|
+
dropdownWidth = 0;
|
|
225
|
+
|
|
226
|
+
mainEndpoint = '';
|
|
119
227
|
|
|
120
|
-
|
|
121
|
-
|
|
228
|
+
/** Resolved endpoint after `{param}` substitution (used by searchable mode). */
|
|
229
|
+
_finalEndpoint = '';
|
|
230
|
+
|
|
231
|
+
private rawEndpoint = '';
|
|
232
|
+
private endpointDynamicParams: MultiDropdownSelectDynamicParams = {};
|
|
233
|
+
|
|
234
|
+
private readonly searchSubject = new Subject<string>();
|
|
235
|
+
private searchSubscription?: Subscription;
|
|
236
|
+
private clickOutsideListener?: (event: MouseEvent) => void;
|
|
237
|
+
|
|
238
|
+
private onChange: (value: unknown[]) => void = () => {};
|
|
239
|
+
private onTouched: () => void = () => {};
|
|
240
|
+
|
|
241
|
+
private readonly panelPositionConfig = DROPDOWN_PANEL_DEFAULTS;
|
|
242
|
+
private readonly repositionDropdown = () => {
|
|
243
|
+
if (this.isDropdownOpen) {
|
|
244
|
+
this.updateDropdownPosition();
|
|
245
|
+
}
|
|
246
|
+
};
|
|
122
247
|
|
|
123
248
|
constructor(
|
|
124
|
-
public readonly iconsService: JIconsService,
|
|
125
249
|
private readonly cdr: ChangeDetectorRef,
|
|
126
|
-
private readonly elementRef: ElementRef
|
|
250
|
+
private readonly elementRef: ElementRef<HTMLElement>,
|
|
127
251
|
@Optional() private readonly genericService: JGenericCrudService | null,
|
|
128
|
-
) {
|
|
252
|
+
) {}
|
|
253
|
+
|
|
254
|
+
// =====================================================
|
|
255
|
+
// Lifecycle
|
|
256
|
+
// =====================================================
|
|
129
257
|
|
|
130
|
-
ngOnInit() {
|
|
258
|
+
ngOnInit(): void {
|
|
131
259
|
this.updateDisplayLabel();
|
|
132
260
|
|
|
133
|
-
|
|
134
|
-
|
|
261
|
+
if (this.endpoint) {
|
|
262
|
+
this.mainEndpoint = this.endpoint.split('/')[0] ?? this.endpoint;
|
|
263
|
+
}
|
|
135
264
|
|
|
136
|
-
// suscripción de búsqueda (server-side si type = 'searchable')
|
|
137
265
|
this.searchSubscription = this.searchSubject
|
|
138
266
|
.pipe(debounceTime(600), distinctUntilChanged())
|
|
139
267
|
.subscribe(() => {
|
|
140
|
-
if (this.type === 'searchable')
|
|
141
|
-
|
|
268
|
+
if (this.type === 'searchable') {
|
|
269
|
+
this.loadData();
|
|
270
|
+
} else {
|
|
271
|
+
this.filterOptionsLocally();
|
|
272
|
+
}
|
|
142
273
|
});
|
|
143
274
|
|
|
144
275
|
if (this.type === 'searchable' && this.loadOnInit) {
|
|
@@ -146,492 +277,377 @@ export class JMultiDropdownSelectComponent implements ControlValueAccessor, Afte
|
|
|
146
277
|
}
|
|
147
278
|
}
|
|
148
279
|
|
|
149
|
-
ngAfterViewInit() {
|
|
150
|
-
this.setupClickOutsideListener()
|
|
280
|
+
ngAfterViewInit(): void {
|
|
281
|
+
this.setupClickOutsideListener();
|
|
151
282
|
setTimeout(() => this.processOptions());
|
|
152
283
|
}
|
|
153
284
|
|
|
154
285
|
ngOnChanges(changes: SimpleChanges): void {
|
|
155
|
-
if (changes['options'])
|
|
286
|
+
if (changes['options']) {
|
|
287
|
+
this.processOptions();
|
|
288
|
+
}
|
|
156
289
|
}
|
|
157
290
|
|
|
158
|
-
ngOnDestroy() {
|
|
291
|
+
ngOnDestroy(): void {
|
|
292
|
+
this.removeRepositionListeners();
|
|
293
|
+
|
|
159
294
|
if (this.clickOutsideListener) {
|
|
160
|
-
document.removeEventListener(
|
|
295
|
+
document.removeEventListener('click', this.clickOutsideListener);
|
|
161
296
|
}
|
|
162
|
-
}
|
|
163
297
|
|
|
164
|
-
|
|
165
|
-
// ENDPOINT dinámico
|
|
166
|
-
// ============================
|
|
167
|
-
private rebuildEndpoint() {
|
|
168
|
-
if (!this._rawEndpoint) return;
|
|
169
|
-
this._finalEndpoint = this._rawEndpoint.replace(/\{([^}]+)\}/g, (_, key) => {
|
|
170
|
-
return this._dynamicParams?.[key] ?? '';
|
|
171
|
-
});
|
|
172
|
-
// Si cambia endpoint/params y el dropdown está abierto, recargar si aplica
|
|
173
|
-
if (this.type === 'searchable' && (this.loadOnInit || this.loadOpen || this.isDropdownOpen)) {
|
|
174
|
-
this.loadData();
|
|
175
|
-
}
|
|
298
|
+
this.searchSubscription?.unsubscribe();
|
|
176
299
|
}
|
|
177
300
|
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
processOptions() {
|
|
182
|
-
// Si llegan opciones por @Input (modo local), respétalas
|
|
183
|
-
if (this.options && this.options.length > 0 && typeof this.options[0] !== 'object') {
|
|
184
|
-
this.processedOptions = this.options.map((opt) => ({ value: opt, text: opt.toString() }));
|
|
185
|
-
} else if (this.options && this.options.length > 0) {
|
|
186
|
-
this.processedOptions = this.options.map((option) => {
|
|
187
|
-
const text = Array.isArray(this.optionLabel)
|
|
188
|
-
? this.optionLabel.map((k) => this.getNestedValue(option, k)).join(this.labelSeparator)
|
|
189
|
-
: this.getNestedValue(option, this.optionLabel);
|
|
190
|
-
return { value: this.normalizeOptionValue(option[this.optionValue]), text, original: option };
|
|
191
|
-
});
|
|
192
|
-
} else if (!(this.type === 'searchable' && this.endpoint)) {
|
|
193
|
-
// No vaciar opciones ya cargadas desde API (modo remoto)
|
|
194
|
-
this.processedOptions = [];
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// inicial para pintar
|
|
198
|
-
this.filteredProcessedOptions = [...this.processedOptions];
|
|
301
|
+
// =====================================================
|
|
302
|
+
// ControlValueAccessor
|
|
303
|
+
// =====================================================
|
|
199
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Writes selected values from Angular forms into the component.
|
|
307
|
+
* @param value Array of selected values or `null`.
|
|
308
|
+
*/
|
|
309
|
+
writeValue(value: unknown[] | null): void {
|
|
310
|
+
this.selectedValues = Array.isArray(value)
|
|
311
|
+
? value.map((item) => normalizeMultiDropdownValue(item))
|
|
312
|
+
: [];
|
|
200
313
|
this.updateDisplayLabel();
|
|
201
|
-
this.cdr.
|
|
314
|
+
this.cdr.markForCheck();
|
|
202
315
|
}
|
|
203
316
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const params: any = { sortOrder: this.sort, limit: this.limit };
|
|
213
|
-
|
|
214
|
-
// filtros por defecto
|
|
215
|
-
Object.keys(this.defaultFilters).forEach((k) => {
|
|
216
|
-
params[`filter[${k}]`] = this.defaultFilters[k];
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
// búsqueda remota
|
|
220
|
-
if (this.isSearch && this.searchTerm.trim() !== '') {
|
|
221
|
-
const optionLabelsArray = Array.isArray(this.optionLabel) ? this.optionLabel : [this.optionLabel];
|
|
222
|
-
const allSearchFields = [...optionLabelsArray, ...this.searchFields];
|
|
223
|
-
params['search'] = this.searchTerm.trim();
|
|
224
|
-
params['searchFields'] = allSearchFields;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
this.genericService.findAll<any>({ endpoint: this._finalEndpoint, params }).subscribe({
|
|
228
|
-
next: (resp) => {
|
|
229
|
-
const data = resp?.data?.[this.mainEndpoint] ?? resp?.data ?? [];
|
|
230
|
-
const list = Array.isArray(data) ? data : [];
|
|
231
|
-
// Mantener @Input options sincronizado (igual que JDropdownSelect) para processOptions()
|
|
232
|
-
this.options = list;
|
|
233
|
-
this.processedOptions = list.map((option: any) => ({
|
|
234
|
-
value: this.normalizeOptionValue(option?.[this.optionValue]),
|
|
235
|
-
text: Array.isArray(this.optionLabel)
|
|
236
|
-
? this.optionLabel.map((k) => this.getNestedValue(option, k)).filter(Boolean).join(this.labelSeparator)
|
|
237
|
-
: this.getNestedValue(option, this.optionLabel),
|
|
238
|
-
original: option,
|
|
239
|
-
}));
|
|
317
|
+
/**
|
|
318
|
+
* Registers the change callback for Angular forms.
|
|
319
|
+
* @param fn Callback invoked when the selection changes.
|
|
320
|
+
*/
|
|
321
|
+
registerOnChange(fn: (value: unknown[]) => void): void {
|
|
322
|
+
this.onChange = fn;
|
|
323
|
+
}
|
|
240
324
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
error: (err) => {
|
|
248
|
-
console.error('Error fetching data:', err);
|
|
249
|
-
this.isLoading = false;
|
|
250
|
-
this.cdr.detectChanges();
|
|
251
|
-
},
|
|
252
|
-
});
|
|
325
|
+
/**
|
|
326
|
+
* Registers the touched callback for Angular forms.
|
|
327
|
+
* @param fn Callback invoked when the control is touched.
|
|
328
|
+
*/
|
|
329
|
+
registerOnTouched(fn: () => void): void {
|
|
330
|
+
this.onTouched = fn;
|
|
253
331
|
}
|
|
254
332
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
this.
|
|
333
|
+
/**
|
|
334
|
+
* Syncs disabled state from Angular forms.
|
|
335
|
+
* @param isDisabled Whether the control is disabled.
|
|
336
|
+
*/
|
|
337
|
+
setDisabledState(isDisabled: boolean): void {
|
|
338
|
+
this.isDisabled = isDisabled;
|
|
339
|
+
this.cdr.markForCheck();
|
|
261
340
|
}
|
|
262
341
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
342
|
+
// =====================================================
|
|
343
|
+
// Options
|
|
344
|
+
// =====================================================
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Normalizes `options` into internal `{ value, text }` entries.
|
|
348
|
+
*/
|
|
349
|
+
processOptions(): void {
|
|
350
|
+
if (this.options?.length > 0 && typeof this.options[0] !== 'object') {
|
|
351
|
+
this.processedOptions = mapPrimitiveOptions(this.options).map((option) => ({
|
|
352
|
+
...option,
|
|
353
|
+
value: normalizeMultiDropdownValue(option.value),
|
|
354
|
+
}));
|
|
355
|
+
} else if (this.options?.length > 0) {
|
|
356
|
+
this.processedOptions = mapObjectOptions(
|
|
357
|
+
this.options as Record<string, unknown>[],
|
|
358
|
+
this.optionLabel,
|
|
359
|
+
this.optionValue,
|
|
360
|
+
this.labelSeparator,
|
|
361
|
+
).map((option) => ({
|
|
362
|
+
...option,
|
|
363
|
+
value: normalizeMultiDropdownValue(option.value),
|
|
364
|
+
}));
|
|
365
|
+
} else if (!(this.type === 'searchable' && this.endpoint)) {
|
|
366
|
+
this.processedOptions = [];
|
|
267
367
|
}
|
|
268
|
-
|
|
269
|
-
this.filteredProcessedOptions = this.processedOptions
|
|
368
|
+
|
|
369
|
+
this.filteredProcessedOptions = [...this.processedOptions];
|
|
370
|
+
this.updateDisplayLabel();
|
|
371
|
+
this.cdr.detectChanges();
|
|
270
372
|
}
|
|
271
373
|
|
|
272
374
|
/**
|
|
273
|
-
*
|
|
274
|
-
* @param option
|
|
375
|
+
* Toggles one option in the selection array.
|
|
376
|
+
* @param option Normalized option from the panel list.
|
|
275
377
|
*/
|
|
276
|
-
toggleOption(option:
|
|
277
|
-
const index = this.selectedValues.findIndex((
|
|
278
|
-
|
|
279
|
-
|
|
378
|
+
toggleOption(option: MultiDropdownOption): void {
|
|
379
|
+
const index = this.selectedValues.findIndex((value) => value === option.value);
|
|
380
|
+
|
|
381
|
+
if (index > -1) {
|
|
382
|
+
this.selectedValues.splice(index, 1);
|
|
383
|
+
} else {
|
|
384
|
+
this.selectedValues.push(option.value);
|
|
385
|
+
}
|
|
280
386
|
|
|
281
387
|
this.updateDisplayLabel();
|
|
282
388
|
this.onChange(this.selectedValues);
|
|
283
389
|
this.selectionChange.emit(this.selectedValues);
|
|
284
390
|
}
|
|
285
391
|
|
|
286
|
-
|
|
287
|
-
|
|
288
392
|
/**
|
|
289
|
-
*
|
|
393
|
+
* Selects or clears every option in `processedOptions`.
|
|
290
394
|
*/
|
|
291
|
-
toggleSelectAll() {
|
|
292
|
-
const allValues = this.processedOptions.map((
|
|
395
|
+
toggleSelectAll(): void {
|
|
396
|
+
const allValues = this.processedOptions.map((option) => option.value);
|
|
293
397
|
this.selectedValues = this.isAllSelected() ? [] : [...allValues];
|
|
294
398
|
this.updateDisplayLabel();
|
|
295
399
|
this.onChange(this.selectedValues);
|
|
296
400
|
this.selectionChange.emit(this.selectedValues);
|
|
297
401
|
}
|
|
298
402
|
|
|
299
|
-
|
|
300
403
|
/**
|
|
301
|
-
*
|
|
302
|
-
* @returns
|
|
404
|
+
* Whether every processed option is currently selected.
|
|
405
|
+
* @returns `true` when all options are in `selectedValues`.
|
|
303
406
|
*/
|
|
304
407
|
isAllSelected(): boolean {
|
|
305
|
-
const allValues = this.processedOptions.map((
|
|
306
|
-
return allValues.length > 0 && allValues.every((
|
|
408
|
+
const allValues = this.processedOptions.map((option) => option.value);
|
|
409
|
+
return allValues.length > 0 && allValues.every((value) => this.selectedValues.includes(value));
|
|
307
410
|
}
|
|
308
411
|
|
|
309
|
-
|
|
310
|
-
|
|
311
412
|
/**
|
|
312
|
-
*
|
|
313
|
-
* @param event
|
|
413
|
+
* Clears all selected values without opening the panel.
|
|
414
|
+
* @param event Click event (stopped to avoid toggling the panel).
|
|
314
415
|
*/
|
|
315
|
-
clearSelection(event: Event) {
|
|
316
|
-
event.stopPropagation()
|
|
317
|
-
this.selectedValues = []
|
|
318
|
-
this.updateDisplayLabel()
|
|
319
|
-
this.onChange(this.selectedValues)
|
|
320
|
-
this.selectionChange.emit(this.selectedValues)
|
|
416
|
+
clearSelection(event: Event): void {
|
|
417
|
+
event.stopPropagation();
|
|
418
|
+
this.selectedValues = [];
|
|
419
|
+
this.updateDisplayLabel();
|
|
420
|
+
this.onChange(this.selectedValues);
|
|
421
|
+
this.selectionChange.emit(this.selectedValues);
|
|
321
422
|
}
|
|
322
423
|
|
|
323
|
-
// ======================================================
|
|
324
|
-
// Display y UI
|
|
325
|
-
// ======================================================
|
|
326
|
-
|
|
327
424
|
/**
|
|
328
|
-
*
|
|
329
|
-
* @returns void
|
|
425
|
+
* Builds the truncated label shown on the trigger.
|
|
330
426
|
*/
|
|
331
|
-
updateDisplayLabel() {
|
|
332
|
-
|
|
333
|
-
// Check if no values are selected
|
|
427
|
+
updateDisplayLabel(): void {
|
|
334
428
|
if (this.selectedValues.length === 0) {
|
|
335
|
-
this.displayLabel = this.placeholder
|
|
336
|
-
return
|
|
429
|
+
this.displayLabel = this.placeholder;
|
|
430
|
+
return;
|
|
337
431
|
}
|
|
338
432
|
|
|
339
|
-
// Verify if all options are selected
|
|
340
433
|
if (this.enableSelectAll && this.isAllSelected()) {
|
|
341
|
-
this.displayLabel = this.labelSelectAll
|
|
342
|
-
return
|
|
434
|
+
this.displayLabel = this.labelSelectAll;
|
|
435
|
+
return;
|
|
343
436
|
}
|
|
344
437
|
|
|
345
|
-
// Get texts of selected options
|
|
346
438
|
const selectedTexts = this.processedOptions
|
|
347
|
-
.filter((
|
|
348
|
-
.map((
|
|
439
|
+
.filter((option) => this.selectedValues.includes(option.value))
|
|
440
|
+
.map((option) => option.text);
|
|
349
441
|
|
|
350
442
|
if (selectedTexts.length <= this.maxDisplayItems) {
|
|
351
|
-
this.displayLabel = selectedTexts.join(this.multipleSeparator)
|
|
352
|
-
|
|
353
|
-
const visibleItems = selectedTexts.slice(0, this.maxDisplayItems)
|
|
354
|
-
const remainingCount = selectedTexts.length - this.maxDisplayItems
|
|
355
|
-
this.displayLabel = `${visibleItems.join(this.multipleSeparator)} y ${remainingCount} más`
|
|
443
|
+
this.displayLabel = selectedTexts.join(this.multipleSeparator);
|
|
444
|
+
return;
|
|
356
445
|
}
|
|
357
|
-
}
|
|
358
446
|
|
|
447
|
+
const visibleItems = selectedTexts.slice(0, this.maxDisplayItems);
|
|
448
|
+
const remainingCount = selectedTexts.length - this.maxDisplayItems;
|
|
449
|
+
this.displayLabel = `${visibleItems.join(this.multipleSeparator)} y ${remainingCount} más`;
|
|
450
|
+
}
|
|
359
451
|
|
|
452
|
+
// =====================================================
|
|
453
|
+
// Search / API
|
|
454
|
+
// =====================================================
|
|
360
455
|
|
|
361
456
|
/**
|
|
362
|
-
*
|
|
457
|
+
* Loads options from the CRUD API (searchable mode).
|
|
363
458
|
*/
|
|
364
|
-
|
|
365
|
-
if (this.
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
this.onTouched();
|
|
369
|
-
this.updateDropdownPosition();
|
|
459
|
+
loadData(): void {
|
|
460
|
+
if (!this._finalEndpoint || !this.genericService) {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
370
463
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
464
|
+
this.isLoading = true;
|
|
465
|
+
|
|
466
|
+
const params: Record<string, unknown> = {
|
|
467
|
+
sortOrder: this.sort,
|
|
468
|
+
limit: this.limit,
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
Object.keys(this.defaultFilters).forEach((key) => {
|
|
472
|
+
params[`filter[${key}]`] = this.defaultFilters[key];
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
if (this.isSearch && this.searchTerm.trim()) {
|
|
476
|
+
const optionLabels = Array.isArray(this.optionLabel) ? this.optionLabel : [this.optionLabel];
|
|
477
|
+
params['search'] = this.searchTerm.trim();
|
|
478
|
+
params['searchFields'] = [...optionLabels, ...this.searchFields];
|
|
374
479
|
}
|
|
375
|
-
}
|
|
376
480
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
481
|
+
this.genericService.findAll<Record<string, unknown>>({
|
|
482
|
+
endpoint: this._finalEndpoint,
|
|
483
|
+
params,
|
|
484
|
+
}).subscribe({
|
|
485
|
+
next: (response) => {
|
|
486
|
+
const data = (response?.data?.[this.mainEndpoint] ?? response?.data ?? []) as unknown;
|
|
487
|
+
const list = Array.isArray(data) ? data : [];
|
|
488
|
+
|
|
489
|
+
this.options = list;
|
|
490
|
+
this.processedOptions = mapObjectOptions(
|
|
491
|
+
list as Record<string, unknown>[],
|
|
492
|
+
this.optionLabel,
|
|
493
|
+
this.optionValue,
|
|
494
|
+
this.labelSeparator,
|
|
495
|
+
).map((option) => ({
|
|
496
|
+
...option,
|
|
497
|
+
value: normalizeMultiDropdownValue(option.value),
|
|
498
|
+
}));
|
|
499
|
+
|
|
500
|
+
this.filteredProcessedOptions = [...this.processedOptions];
|
|
501
|
+
this.fullData.emit(list);
|
|
502
|
+
this.isLoading = false;
|
|
503
|
+
this.updateDisplayLabel();
|
|
504
|
+
|
|
505
|
+
if (this.isDropdownOpen) {
|
|
506
|
+
this.updateDropdownPosition();
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
this.cdr.detectChanges();
|
|
510
|
+
},
|
|
511
|
+
error: (error) => {
|
|
512
|
+
console.error('Error fetching data:', error);
|
|
513
|
+
this.isLoading = false;
|
|
514
|
+
this.cdr.detectChanges();
|
|
515
|
+
},
|
|
516
|
+
});
|
|
517
|
+
}
|
|
380
518
|
|
|
381
519
|
/**
|
|
382
|
-
*
|
|
383
|
-
* @param obj
|
|
384
|
-
* @param path
|
|
385
|
-
* @returns
|
|
520
|
+
* Handles panel search input — debounced API call or local filter.
|
|
386
521
|
*/
|
|
387
|
-
|
|
388
|
-
|
|
522
|
+
onSearchInput(): void {
|
|
523
|
+
this.searchSubject.next(this.searchTerm);
|
|
389
524
|
}
|
|
390
525
|
|
|
526
|
+
/** Resets the panel search field and re-runs {@link onSearchInput}. */
|
|
527
|
+
clearSearchTerm(): void {
|
|
528
|
+
this.searchTerm = '';
|
|
529
|
+
this.onSearchInput();
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Applies local text filter to `processedOptions`. */
|
|
533
|
+
private filterOptionsLocally(): void {
|
|
534
|
+
this.filteredProcessedOptions = filterOptionsBySearch(this.processedOptions, this.searchTerm);
|
|
535
|
+
}
|
|
391
536
|
|
|
537
|
+
// =====================================================
|
|
538
|
+
// Panel open / position
|
|
539
|
+
// =====================================================
|
|
392
540
|
|
|
393
541
|
/**
|
|
394
|
-
*
|
|
542
|
+
* Opens or closes the option panel.
|
|
543
|
+
* Searchable mode may trigger {@link loadData} on first open.
|
|
395
544
|
*/
|
|
396
|
-
|
|
397
|
-
this.
|
|
398
|
-
|
|
399
|
-
const isOutsideDropdown = !this.elementRef.nativeElement.contains(clickedElement)
|
|
400
|
-
if (this.isDropdownOpen && isOutsideDropdown) {
|
|
401
|
-
this.isDropdownOpen = false
|
|
402
|
-
this.cdr.detectChanges()
|
|
403
|
-
}
|
|
545
|
+
toggleDropdown(): void {
|
|
546
|
+
if (this.isDisabled || this.isLoading) {
|
|
547
|
+
return;
|
|
404
548
|
}
|
|
405
|
-
document.addEventListener("click", this.clickOutsideListener)
|
|
406
|
-
}
|
|
407
549
|
|
|
550
|
+
this.isDropdownOpen = !this.isDropdownOpen;
|
|
408
551
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
setTimeout(() => {
|
|
414
|
-
if (!this.selectButton) return
|
|
415
|
-
|
|
416
|
-
// Get button position
|
|
417
|
-
const button = this.selectButton.nativeElement
|
|
418
|
-
const buttonRect = button.getBoundingClientRect()
|
|
419
|
-
|
|
420
|
-
// Find the closest form container or dialog
|
|
421
|
-
let offsetParent: HTMLElement | null = this.selectButton.nativeElement
|
|
422
|
-
let isInSidebar = false
|
|
423
|
-
|
|
424
|
-
// Check if we're inside a sidebar form
|
|
425
|
-
while (
|
|
426
|
-
offsetParent &&
|
|
427
|
-
!offsetParent.classList.contains("content_form") &&
|
|
428
|
-
!offsetParent.classList.contains("p-dialog")
|
|
429
|
-
) {
|
|
430
|
-
if (offsetParent.classList.contains("fixed") && offsetParent.classList.contains("right-0")) {
|
|
431
|
-
isInSidebar = true
|
|
432
|
-
break
|
|
433
|
-
}
|
|
434
|
-
offsetParent = offsetParent.parentElement
|
|
435
|
-
}
|
|
552
|
+
if (this.isDropdownOpen) {
|
|
553
|
+
this.onTouched();
|
|
554
|
+
this.addRepositionListeners();
|
|
555
|
+
this.updateDropdownPosition();
|
|
436
556
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
let offsetLeft = 0
|
|
440
|
-
if (
|
|
441
|
-
isInSidebar ||
|
|
442
|
-
(offsetParent &&
|
|
443
|
-
(offsetParent.classList.contains("content_form") || offsetParent.classList.contains("p-dialog")))
|
|
444
|
-
) {
|
|
445
|
-
offsetTop = offsetParent ? offsetParent.getBoundingClientRect().top : 0
|
|
446
|
-
offsetLeft = offsetParent ? offsetParent.getBoundingClientRect().left : 0
|
|
557
|
+
if (this.type === 'searchable' && (this.loadOpen || this.processedOptions.length === 0)) {
|
|
558
|
+
this.loadData();
|
|
447
559
|
}
|
|
448
560
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
this.cdr.detectChanges()
|
|
454
|
-
|
|
455
|
-
// Wait for dropdown to be in DOM
|
|
456
|
-
setTimeout(() => {
|
|
457
|
-
// Get dropdown element
|
|
458
|
-
const dropdown = this.elementRef.nativeElement.querySelector(".absolute.z-\\[100\\]")
|
|
459
|
-
if (!dropdown) return
|
|
460
|
-
|
|
461
|
-
// First use fixed positioning to handle scroll position correctly
|
|
462
|
-
dropdown.style.position = "fixed"
|
|
463
|
-
dropdown.style.top = buttonRect.bottom + "px"
|
|
464
|
-
dropdown.style.left = buttonRect.left + "px"
|
|
465
|
-
dropdown.style.width = buttonRect.width + "px"
|
|
466
|
-
dropdown.style.zIndex = "600"
|
|
467
|
-
|
|
468
|
-
// Wait for dropdown to render with fixed positioning
|
|
469
|
-
setTimeout(() => {
|
|
470
|
-
// Get dropdown dimensions
|
|
471
|
-
const dropdownRect = dropdown.getBoundingClientRect()
|
|
472
|
-
const viewportHeight = window.innerHeight
|
|
473
|
-
const documentWidth = document.documentElement.clientWidth
|
|
474
|
-
|
|
475
|
-
// Determine if we need to flip or adjust position
|
|
476
|
-
let newFixedTop = buttonRect.bottom
|
|
477
|
-
let newFixedLeft = buttonRect.left
|
|
478
|
-
|
|
479
|
-
// Check if dropdown goes below viewport and flip it if needed
|
|
480
|
-
if (buttonRect.bottom + dropdownRect.height > viewportHeight) {
|
|
481
|
-
newFixedTop = buttonRect.top - dropdownRect.height
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
// Check if dropdown goes beyond right edge
|
|
485
|
-
if (buttonRect.left + dropdownRect.width > documentWidth) {
|
|
486
|
-
newFixedLeft = buttonRect.left - dropdownRect.width + buttonRect.width
|
|
487
|
-
if (newFixedLeft < 0) {
|
|
488
|
-
newFixedLeft = 5
|
|
489
|
-
if (dropdownRect.width > documentWidth) {
|
|
490
|
-
dropdown.classList.add("constrain-width")
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
// Apply adjusted fixed position
|
|
496
|
-
dropdown.style.top = newFixedTop - 5 + "px"
|
|
497
|
-
dropdown.style.left = newFixedLeft - 15 + "px"
|
|
498
|
-
|
|
499
|
-
// Button filter
|
|
500
|
-
if (this.isFilterSelect) {
|
|
501
|
-
setTimeout(() => {
|
|
502
|
-
// Calculate absolute position based on the current fixed position
|
|
503
|
-
let newAbsoluteTop
|
|
504
|
-
let newAbsoluteLeft
|
|
505
|
-
|
|
506
|
-
if (newFixedTop === buttonRect.bottom) {
|
|
507
|
-
// Dropdown is below the button
|
|
508
|
-
newAbsoluteTop = this.dropdownTop
|
|
509
|
-
} else {
|
|
510
|
-
// Dropdown is above the button (flipped)
|
|
511
|
-
newAbsoluteTop = buttonRect.top - dropdownRect.height - offsetTop
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
if (newFixedLeft === buttonRect.left) {
|
|
515
|
-
// Dropdown is aligned with left edge of button
|
|
516
|
-
newAbsoluteLeft = this.dropdownLeft
|
|
517
|
-
} else if (newFixedLeft === buttonRect.left - dropdownRect.width + buttonRect.width) {
|
|
518
|
-
// Dropdown is aligned with right edge of button
|
|
519
|
-
newAbsoluteLeft = buttonRect.left - dropdownRect.width + buttonRect.width - offsetLeft
|
|
520
|
-
} else {
|
|
521
|
-
// Dropdown is at a fixed position from left edge
|
|
522
|
-
newAbsoluteLeft = newFixedLeft
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
// Switch to absolute positioning
|
|
526
|
-
dropdown.style.position = "absolute"
|
|
527
|
-
dropdown.style.top = newAbsoluteTop + "px"
|
|
528
|
-
dropdown.style.left = newAbsoluteLeft - 2 + "px"
|
|
529
|
-
|
|
530
|
-
// Update internal state
|
|
531
|
-
this.dropdownTop = newAbsoluteTop
|
|
532
|
-
this.dropdownLeft = newAbsoluteLeft
|
|
533
|
-
this.cdr.detectChanges()
|
|
534
|
-
}, 0)
|
|
535
|
-
} else {
|
|
536
|
-
// After positioning is done, switch to absolute positioning
|
|
537
|
-
setTimeout(() => {
|
|
538
|
-
// Get the current visual position of the dropdown (relative to viewport)
|
|
539
|
-
const dropdownRect = dropdown.getBoundingClientRect()
|
|
540
|
-
|
|
541
|
-
// Find the dropdown's offset parent for absolute positioning
|
|
542
|
-
const dropdownOffsetParent = this.findPositionedParent(dropdown) || document.body
|
|
543
|
-
const parentRect = dropdownOffsetParent.getBoundingClientRect()
|
|
544
|
-
|
|
545
|
-
// Calculate absolute position that will maintain the same visual position
|
|
546
|
-
// Absolute positioning is relative to the offset parent
|
|
547
|
-
const absoluteTop = dropdownRect.top - parentRect.top + dropdownOffsetParent.scrollTop
|
|
548
|
-
const absoluteLeft = dropdownRect.left - parentRect.left + dropdownOffsetParent.scrollLeft
|
|
549
|
-
|
|
550
|
-
// Switch to absolute positioning with calculated coordinates
|
|
551
|
-
dropdown.style.position = "absolute"
|
|
552
|
-
dropdown.style.top = absoluteTop + "px"
|
|
553
|
-
dropdown.style.left = absoluteLeft + "px"
|
|
554
|
-
|
|
555
|
-
// Update internal state
|
|
556
|
-
this.dropdownTop = absoluteTop
|
|
557
|
-
this.dropdownLeft = absoluteLeft
|
|
558
|
-
this.cdr.detectChanges()
|
|
559
|
-
}, 0)
|
|
560
|
-
}
|
|
561
|
-
}, 0)
|
|
562
|
-
}, 0)
|
|
563
|
-
})
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
this.closePanel(false);
|
|
564
565
|
}
|
|
565
566
|
|
|
566
567
|
/**
|
|
567
|
-
*
|
|
568
|
-
* @
|
|
569
|
-
* @returns
|
|
568
|
+
* Positions the floating panel with fixed viewport coordinates
|
|
569
|
+
* (flip above/below, clamp to window — see {@link positionDropdownPanel}).
|
|
570
570
|
*/
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
if (position === "relative" || position === "absolute" || position === "fixed") {
|
|
577
|
-
return parent
|
|
571
|
+
updateDropdownPosition(): void {
|
|
572
|
+
setTimeout(() => {
|
|
573
|
+
const button = this.selectButton?.nativeElement;
|
|
574
|
+
if (!button) {
|
|
575
|
+
return;
|
|
578
576
|
}
|
|
579
|
-
parent = parent.parentElement
|
|
580
|
-
}
|
|
581
|
-
return document.body // Default to body if no positioned parent found
|
|
582
|
-
}
|
|
583
577
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
578
|
+
const buttonRect = button.getBoundingClientRect();
|
|
579
|
+
this.dropdownWidth = buttonRect.width;
|
|
580
|
+
this.cdr.detectChanges();
|
|
587
581
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
582
|
+
requestAnimationFrame(() => {
|
|
583
|
+
const panel = this.dropdownPanel?.nativeElement;
|
|
584
|
+
if (!panel) {
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
positionDropdownPanel(panel, buttonRect, this.panelPositionConfig);
|
|
589
|
+
this.cdr.detectChanges();
|
|
590
|
+
});
|
|
591
|
+
});
|
|
598
592
|
}
|
|
599
593
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
594
|
+
/** Document click listener — closes the panel when clicking outside. */
|
|
595
|
+
private setupClickOutsideListener(): void {
|
|
596
|
+
this.clickOutsideListener = (event: MouseEvent) => {
|
|
597
|
+
const clickedElement = event.target as HTMLElement;
|
|
598
|
+
const isOutsideDropdown = !this.elementRef.nativeElement.contains(clickedElement);
|
|
599
|
+
|
|
600
|
+
if (this.isDropdownOpen && isOutsideDropdown) {
|
|
601
|
+
this.closePanel();
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
document.addEventListener('click', this.clickOutsideListener);
|
|
605
606
|
}
|
|
606
607
|
|
|
608
|
+
/** Closes the panel and clears reposition listeners / dynamic panel styles. */
|
|
609
|
+
private closePanel(detectChanges = true): void {
|
|
610
|
+
this.isDropdownOpen = false;
|
|
611
|
+
this.removeRepositionListeners();
|
|
612
|
+
this.resetDropdownPanelStyles();
|
|
607
613
|
|
|
614
|
+
if (detectChanges) {
|
|
615
|
+
this.cdr.detectChanges();
|
|
616
|
+
}
|
|
617
|
+
}
|
|
608
618
|
|
|
609
|
-
/**
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
registerOnChange(fn: any): void {
|
|
614
|
-
this.onChange = fn
|
|
619
|
+
/** Re-applies panel position on scroll (capture) and window resize. */
|
|
620
|
+
private addRepositionListeners(): void {
|
|
621
|
+
window.addEventListener('scroll', this.repositionDropdown, true);
|
|
622
|
+
window.addEventListener('resize', this.repositionDropdown);
|
|
615
623
|
}
|
|
616
624
|
|
|
625
|
+
/** Removes scroll/resize listeners registered while the panel is open. */
|
|
626
|
+
private removeRepositionListeners(): void {
|
|
627
|
+
window.removeEventListener('scroll', this.repositionDropdown, true);
|
|
628
|
+
window.removeEventListener('resize', this.repositionDropdown);
|
|
629
|
+
}
|
|
617
630
|
|
|
631
|
+
/** Resets dynamic max-height on the scrollable options list. */
|
|
632
|
+
private resetDropdownPanelStyles(): void {
|
|
633
|
+
const panel = this.dropdownPanel?.nativeElement;
|
|
634
|
+
if (!panel) {
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
618
637
|
|
|
619
|
-
|
|
620
|
-
* Register the touch event
|
|
621
|
-
* @param fn
|
|
622
|
-
*/
|
|
623
|
-
registerOnTouched(fn: any): void {
|
|
624
|
-
this.onTouched = fn
|
|
638
|
+
resetDropdownPanelStyles(panel, this.panelPositionConfig.optionsListSelector);
|
|
625
639
|
}
|
|
626
640
|
|
|
641
|
+
/** Builds `_finalEndpoint` from `endpoint` + `dynamicParams`. */
|
|
642
|
+
private rebuildEndpoint(): void {
|
|
643
|
+
if (!this.rawEndpoint) {
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
627
646
|
|
|
647
|
+
this._finalEndpoint = rebuildEndpointUrl(this.rawEndpoint, this.endpointDynamicParams);
|
|
628
648
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
*/
|
|
633
|
-
setDisabledState(isDisabled: boolean): void {
|
|
634
|
-
this.isDisabled = isDisabled
|
|
635
|
-
this.cdr.markForCheck()
|
|
649
|
+
if (this.type === 'searchable' && (this.loadOnInit || this.loadOpen || this.isDropdownOpen)) {
|
|
650
|
+
this.loadData();
|
|
651
|
+
}
|
|
636
652
|
}
|
|
637
653
|
}
|