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.
Files changed (176) hide show
  1. package/README.md +12 -4
  2. package/cli/execute/init-app.js +5 -2
  3. package/cli/execute/sync-app.js +14 -2
  4. package/cli/settings/colors-config-utils.js +43 -8
  5. package/cli/settings/icons-config-utils.js +62 -0
  6. package/cli/settings/path-utils.js +32 -2
  7. package/cli/settings/project-utils.js +7 -1
  8. package/cli/templates/app.generator.js +2 -2
  9. package/fesm2022/tailjng.mjs +247 -80
  10. package/fesm2022/tailjng.mjs.map +1 -1
  11. package/lib/services/static/theme.service.d.ts +39 -1
  12. package/lib/utils/theme/theme-variables.util.d.ts +31 -0
  13. package/package.json +1 -1
  14. package/public-api.d.ts +2 -1
  15. package/registry/components.json +41 -18
  16. package/src/colors.safelist.css +2 -2
  17. package/src/lib/components/.config/README.md +11 -0
  18. package/src/lib/components/.config/colors/README.md +38 -0
  19. package/src/lib/components/{colors-config → .config/colors}/colors.config.ts +5 -5
  20. package/src/lib/components/{colors-config → .config/colors}/colors.safelist.css +2 -2
  21. package/src/lib/components/.config/icons/README.md +26 -0
  22. package/src/lib/components/.config/icons/icons.lucide.ts +134 -0
  23. package/src/lib/components/.config/input/README.md +24 -0
  24. package/src/lib/components/.config/input/input.classes.ts +119 -0
  25. package/src/lib/components/alert/alert-dialog/dialog-alert.component.css +244 -2
  26. package/src/lib/components/alert/alert-dialog/dialog-alert.component.html +25 -38
  27. package/src/lib/components/alert/alert-dialog/dialog-alert.component.ts +66 -56
  28. package/src/lib/components/alert/alert-dialog/dialog-alert.types.ts +19 -0
  29. package/src/lib/components/alert/alert-toast/toast-alert.component.css +630 -12
  30. package/src/lib/components/alert/alert-toast/toast-alert.component.html +103 -102
  31. package/src/lib/components/alert/alert-toast/toast-alert.component.ts +485 -128
  32. package/src/lib/components/alert/alert-toast/toast-alert.types.ts +25 -0
  33. package/src/lib/components/badge/badge.component.html +34 -21
  34. package/src/lib/components/badge/badge.component.ts +140 -31
  35. package/src/lib/components/button/button.component.html +16 -10
  36. package/src/lib/components/button/button.component.ts +162 -22
  37. package/src/lib/components/card/card-complete/complete-card.component.html +2 -2
  38. package/src/lib/components/card/card-complete/complete-card.component.ts +26 -16
  39. package/src/lib/components/card/card-crud-complete/complete-crud-card.component.html +2 -2
  40. package/src/lib/components/card/card-crud-complete/complete-crud-card.component.ts +26 -16
  41. package/src/lib/components/checkbox/checkbox-input/input-checkbox.component.css +97 -0
  42. package/src/lib/components/checkbox/checkbox-input/input-checkbox.component.html +54 -46
  43. package/src/lib/components/checkbox/checkbox-input/input-checkbox.component.ts +135 -64
  44. package/src/lib/components/checkbox/checkbox-input/input-checkbox.types.ts +3 -0
  45. package/src/lib/components/checkbox/checkbox-switch/switch-checkbox.component.css +112 -0
  46. package/src/lib/components/checkbox/checkbox-switch/switch-checkbox.component.html +28 -25
  47. package/src/lib/components/checkbox/checkbox-switch/switch-checkbox.component.ts +67 -15
  48. package/src/lib/components/checkbox/checkbox-switch/switch-checkbox.types.ts +1 -0
  49. package/src/lib/components/coach-mark/coach-mark.component.html +4 -22
  50. package/src/lib/components/coach-mark/coach-mark.component.scss +1 -1
  51. package/src/lib/components/coach-mark/coach-mark.component.ts +51 -18
  52. package/src/lib/components/coach-mark/coach-mark.directive.ts +133 -78
  53. package/src/lib/components/coach-mark/coach-mark.types.ts +12 -0
  54. package/src/lib/components/dialog/dialog.component.css +103 -1
  55. package/src/lib/components/dialog/dialog.component.html +46 -66
  56. package/src/lib/components/dialog/dialog.component.ts +136 -110
  57. package/src/lib/components/dialog/dialog.types.ts +19 -0
  58. package/src/lib/components/filter/filter-complete/complete-filter.component.html +16 -19
  59. package/src/lib/components/filter/filter-complete/complete-filter.component.scss +35 -0
  60. package/src/lib/components/filter/filter-complete/complete-filter.component.ts +58 -34
  61. package/src/lib/components/filter/filter-complete/complete-filter.types.ts +7 -0
  62. package/src/lib/components/filter/filter-complete/complete-filter.util.ts +16 -0
  63. package/src/lib/components/form/form-container/container-form.component.css +4 -0
  64. package/src/lib/components/form/form-container/container-form.component.html +2 -2
  65. package/src/lib/components/form/form-container/container-form.component.ts +72 -16
  66. package/src/lib/components/form/form-container/container-form.types.ts +42 -0
  67. package/src/lib/components/form/form-container/form-col-span.directive.ts +25 -0
  68. package/src/lib/components/form/form-sidebar/sidebar-form.component.css +276 -0
  69. package/src/lib/components/form/form-sidebar/sidebar-form.component.html +117 -125
  70. package/src/lib/components/form/form-sidebar/sidebar-form.component.ts +109 -34
  71. package/src/lib/components/form/form-sidebar/sidebar-form.types.ts +3 -0
  72. package/src/lib/components/{toggle-radio/toggle-radio.component.css → form/form-validation/validation-form.component.css} +0 -1
  73. package/src/lib/components/form/form-validation/validation-form.component.html +10 -6
  74. package/src/lib/components/form/form-validation/validation-form.component.ts +99 -12
  75. package/src/lib/components/form/form-validation/validation-form.types.ts +33 -0
  76. package/src/lib/components/icon/icon.component.html +8 -5
  77. package/src/lib/components/icon/icon.component.ts +111 -9
  78. package/src/lib/components/input/input/input.component.html +19 -16
  79. package/src/lib/components/input/input/input.component.ts +130 -53
  80. package/src/lib/components/input/input/input.types.ts +8 -0
  81. package/src/lib/components/input/input-file/file-input.component.html +65 -56
  82. package/src/lib/components/input/input-file/file-input.component.ts +276 -173
  83. package/src/lib/components/input/input-file/file-input.types.ts +2 -0
  84. package/src/lib/components/input/input-range/range-input.component.css +67 -0
  85. package/src/lib/components/input/input-range/range-input.component.html +50 -58
  86. package/src/lib/components/input/input-range/range-input.component.ts +148 -60
  87. package/src/lib/components/input/input-range/range-input.types.ts +7 -0
  88. package/src/lib/components/input/input-textarea/textarea-input.component.html +16 -7
  89. package/src/lib/components/input/input-textarea/textarea-input.component.ts +140 -50
  90. package/src/lib/components/input/input-textarea/textarea-input.types.ts +2 -0
  91. package/src/lib/components/label/label.component.html +17 -16
  92. package/src/lib/components/label/label.component.ts +70 -16
  93. package/src/lib/components/label/label.types.ts +2 -0
  94. package/src/lib/components/menu/menu-options-table/menu-options-defaults.ts +34 -0
  95. package/src/lib/components/menu/menu-options-table/options-table-menu.component.html +34 -20
  96. package/src/lib/components/menu/menu-options-table/options-table-menu.component.ts +211 -58
  97. package/src/lib/components/menu/menu-options-table/options-table-menu.types.ts +38 -0
  98. package/src/lib/components/menu/options-coach-menu/options-coach-menu.component.html +49 -52
  99. package/src/lib/components/menu/options-coach-menu/options-coach-menu.component.ts +112 -24
  100. package/src/lib/components/menu/options-coach-menu/options-coach-menu.types.ts +9 -0
  101. package/src/lib/components/mode-toggle/mode-toggle.component.html +11 -16
  102. package/src/lib/components/mode-toggle/mode-toggle.component.ts +69 -33
  103. package/src/lib/components/paginator/paginator-complete/complete-paginator.component.html +4 -4
  104. package/src/lib/components/paginator/paginator-complete/complete-paginator.component.ts +31 -7
  105. package/src/lib/components/paginator/paginator-complete/complete-paginator.types.ts +12 -0
  106. package/src/lib/components/paginator/paginator-complete/complete-paginator.util.ts +36 -0
  107. package/src/lib/components/progress-bar/progress-bar.component.css +11 -0
  108. package/src/lib/components/progress-bar/progress-bar.component.html +41 -40
  109. package/src/lib/components/progress-bar/progress-bar.component.ts +95 -11
  110. package/src/lib/components/progress-bar/progress-bar.types.ts +2 -0
  111. package/src/lib/components/select/select-dropdown/dropdown-select.component.css +6 -0
  112. package/src/lib/components/select/select-dropdown/dropdown-select.component.html +54 -44
  113. package/src/lib/components/select/select-dropdown/dropdown-select.component.ts +450 -509
  114. package/src/lib/components/select/select-dropdown/dropdown-select.types.ts +43 -0
  115. package/src/lib/components/select/select-dropdown/dropdown-select.util.ts +179 -0
  116. package/src/lib/components/select/select-multi-dropdown/multi-dropdown-select.component.css +6 -0
  117. package/src/lib/components/select/select-multi-dropdown/multi-dropdown-select.component.html +131 -42
  118. package/src/lib/components/select/select-multi-dropdown/multi-dropdown-select.component.ts +491 -475
  119. package/src/lib/components/select/select-multi-dropdown/multi-dropdown-select.types.ts +22 -0
  120. package/src/lib/components/select/select-multi-dropdown/multi-dropdown-select.util.ts +20 -0
  121. package/src/lib/components/select/select-multi-table/multi-table-select.component.css +10 -0
  122. package/src/lib/components/select/select-multi-table/multi-table-select.component.html +76 -60
  123. package/src/lib/components/select/select-multi-table/multi-table-select.component.ts +250 -313
  124. package/src/lib/components/select/select-multi-table/multi-table-select.types.ts +10 -0
  125. package/src/lib/components/select/select-multi-table/multi-table-select.util.ts +5 -0
  126. package/src/lib/components/sidebar/sidebar-static/static-sidebar.component.css +212 -0
  127. package/src/lib/components/sidebar/sidebar-static/static-sidebar.component.html +62 -53
  128. package/src/lib/components/sidebar/sidebar-static/static-sidebar.component.ts +84 -27
  129. package/src/lib/components/sidebar/sidebar-static/static-sidebar.types.ts +2 -0
  130. package/src/lib/components/table/table-complete/complete-table.component.html +15 -17
  131. package/src/lib/components/table/table-complete/complete-table.component.ts +190 -338
  132. package/src/lib/components/table/table-complete/complete-table.types.ts +28 -0
  133. package/src/lib/components/table/table-complete/complete-table.util.ts +236 -0
  134. package/src/lib/components/table/table-complete/index.ts +2 -0
  135. package/src/lib/components/table/table-crud-complete/complete-crud-table.animations.ts +34 -0
  136. package/src/lib/components/table/table-crud-complete/complete-crud-table.component.html +73 -128
  137. package/src/lib/components/table/table-crud-complete/complete-crud-table.component.ts +542 -829
  138. package/src/lib/components/table/table-crud-complete/complete-crud-table.types.ts +57 -0
  139. package/src/lib/components/table/table-crud-complete/complete-crud-table.util.ts +723 -0
  140. package/src/lib/components/table/table-crud-complete/index.ts +3 -0
  141. package/src/lib/components/theme-generator/theme-generator.component.css +21 -0
  142. package/src/lib/components/theme-generator/theme-generator.component.html +146 -116
  143. package/src/lib/components/theme-generator/theme-generator.component.ts +44 -24
  144. package/src/lib/components/toggle-radio/shared/toggle-options.types.ts +8 -0
  145. package/src/lib/components/toggle-radio/shared/toggle-options.util.ts +44 -0
  146. package/src/lib/components/toggle-radio/toggle-radio/toggle-radio.component.css +135 -0
  147. package/src/lib/components/toggle-radio/toggle-radio/toggle-radio.component.html +52 -0
  148. package/src/lib/components/toggle-radio/toggle-radio/toggle-radio.component.ts +198 -0
  149. package/src/lib/components/toggle-radio/toggle-radio/toggle-radio.types.ts +1 -0
  150. package/src/lib/components/toggle-radio/toggle-segment/segment-toggle.component.css +108 -0
  151. package/src/lib/components/toggle-radio/toggle-segment/segment-toggle.component.html +37 -0
  152. package/src/lib/components/toggle-radio/toggle-segment/segment-toggle.component.ts +193 -0
  153. package/src/lib/components/toggle-radio/toggle-segment/segment-toggle.types.ts +1 -0
  154. package/src/lib/components/tooltip/tooltip.directive.ts +12 -9
  155. package/src/lib/components/tooltip/tooltip.service.ts +331 -133
  156. package/src/lib/components/tooltip/tooltip.types.ts +9 -0
  157. package/src/lib/components/viewer/viewer-image/image-viewer.component.css +90 -4
  158. package/src/lib/components/viewer/viewer-image/image-viewer.component.html +52 -103
  159. package/src/lib/components/viewer/viewer-image/image-viewer.component.ts +182 -177
  160. package/src/lib/components/viewer/viewer-image/image-viewer.types.ts +3 -0
  161. package/src/lib/components/viewer/viewer-pdf/pdf-viewer.component.css +177 -0
  162. package/src/lib/components/viewer/viewer-pdf/pdf-viewer.component.html +74 -24
  163. package/src/lib/components/viewer/viewer-pdf/pdf-viewer.component.ts +168 -15
  164. package/src/lib/components/viewer/viewer-pdf/pdf-viewer.types.ts +1 -0
  165. package/src/styles.css +2 -2
  166. package/lib/services/static/icons.service.d.ts +0 -65
  167. package/src/lib/components/colors-config/README.md +0 -38
  168. package/src/lib/components/form/form-sidebar/sidebar-form.component.scss +0 -0
  169. package/src/lib/components/form/form-validation/validation-form.component.scss +0 -0
  170. package/src/lib/components/menu/menu-options-table/options-table-menu.component.scss +0 -0
  171. package/src/lib/components/menu/options-coach-menu/options-coach-menu.component.scss +0 -12
  172. package/src/lib/components/sidebar/sidebar-static/static-sidebar.component.scss +0 -0
  173. package/src/lib/components/toggle-radio/toggle-radio.component.html +0 -51
  174. package/src/lib/components/toggle-radio/toggle-radio.component.ts +0 -222
  175. package/src/lib/components/viewer/viewer-pdf/pdf-viewer.component.scss +0 -0
  176. package/tailjng-0.1.6.tgz +0 -0
@@ -1,144 +1,275 @@
1
- import { Component, Input, Output, EventEmitter, ElementRef, ViewChild, OnDestroy, ChangeDetectorRef, AfterViewInit, OnInit, SimpleChanges, OnChanges, Optional, } from "@angular/core"
2
- import { FormsModule, type ControlValueAccessor, ReactiveFormsModule, NG_VALUE_ACCESSOR } from "@angular/forms"
3
- import { CommonModule } from "@angular/common"
4
- import { LucideAngularModule } from "lucide-angular"
5
- import { animate, style, transition, trigger } from "@angular/animations"
6
- import { JGenericCrudService, JIconsService } from "tailjng"
7
- import { debounceTime, distinctUntilChanged, Subject, Subscription } from "rxjs"
8
-
9
- interface ProcessedOption {
10
- value: any
11
- text: string
12
- original?: any
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: "JMultiDropdownSelect",
17
- imports: [LucideAngularModule, CommonModule, FormsModule, ReactiveFormsModule],
18
- templateUrl: "./multi-dropdown-select.component.html",
19
- styleUrl: "./multi-dropdown-select.component.css",
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("modalTransition", [
22
- transition(":enter", [
23
- style({ transform: "translateX(1rem)", opacity: 0 }),
24
- animate("300ms ease-out", style({ transform: "translateY(0)", opacity: 1 })),
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 implements ControlValueAccessor, AfterViewInit, OnInit, OnChanges, OnDestroy {
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
- @Input() title = "Seleccionar opciones"
40
- @Input() placeholder = "Seleccione opciones"
41
- @Input() showClear = true
42
- @Input() isDisabled = false
43
- @Input() isLoading = false
115
+ // =====================================================
116
+ // Inputs / outputs
117
+ // =====================================================
44
118
 
45
- @Input() options: any[] = []
46
- @Input() optionLabel: string | string[] = "text"
47
- @Input() optionValue = "value"
48
- @Input() labelSeparator = " "
119
+ // Panel heading above the option list
120
+ @Input() title = 'Seleccionar opciones';
49
121
 
50
- /** "dropdown" = local, "searchable" = remota con búsqueda */
51
- @Input() type: 'dropdown' | 'searchable' = 'dropdown';
122
+ // Trigger label when nothing is selected
123
+ @Input() placeholder = 'Seleccione opciones';
52
124
 
53
- /** endpoint base, ej: "users" o "users/{companyId}/roles" */
54
- private _rawEndpoint = '';
55
- _finalEndpoint = '';
56
- @Input() set endpoint(value: string) { this._rawEndpoint = value; this.rebuildEndpoint(); }
57
- get endpoint() { return this._rawEndpoint; }
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
- /** reemplaza {keys} en endpoint, ej: { companyId: 12 } */
60
- private _dynamicParams: Record<string, any> = {};
61
- @Input() set dynamicParams(v: Record<string, any>) { this._dynamicParams = v; this.rebuildEndpoint(); }
62
- get dynamicParams() { return this._dynamicParams; }
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
- /** filtros por defecto -> se envían como filter[key]=value */
65
- @Input() defaultFilters: Record<string, any> = {};
66
- /** campos extra para búsqueda en servidor */
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
- /** cargar al iniciar (solo searchable) */
180
+ // Searchable: fetch on init
74
181
  @Input() loadOnInit = false;
75
- /** cargar al abrir (solo searchable) */
182
+
183
+ // Searchable: fetch when the panel opens
76
184
  @Input() loadOpen = false;
77
185
 
78
- /** activar input de búsqueda (local o remota según type) */
186
+ // Panel search input (local filter or remote search depending on `type`)
79
187
  @Input() isSearch = false;
80
188
 
81
- /** emite el data crudo recibido del backend */
82
- @Output() fullData = new EventEmitter<any[]>();
189
+ /** Searchable: emits the full API result array after each load. */
190
+ @Output() fullData = new EventEmitter<unknown[]>();
83
191
 
84
- searchTerm = '';
85
- private readonly searchSubject = new Subject<string>();
86
- private searchSubscription?: Subscription;
192
+ // Shows the “select all” row at the top of the panel
193
+ @Input() enableSelectAll = true;
87
194
 
88
- /** opciones filtradas para pintar lista (soporta búsqueda) */
89
- filteredProcessedOptions: ProcessedOption[] = [];
195
+ // Trigger label when every option is selected
196
+ @Input() labelSelectAll = 'TODOS';
90
197
 
91
- /** para mapear respuesta */
92
- mainEndpoint!: string;
198
+ // Separator between selected item labels on the trigger
199
+ @Input() multipleSeparator = ', ';
93
200
 
94
- // Multi-selection
95
- @Input() enableSelectAll = true
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
- @Output() selectionChange = new EventEmitter<any[]>()
207
+ /** @deprecated Kept for backward compatibility; panel positioning no longer uses this flag. */
102
208
  @Input() isFilterSelect = false;
103
209
 
104
- @ViewChild("selectButton") selectButton!: ElementRef
210
+ // =====================================================
211
+ // View / state
212
+ // =====================================================
105
213
 
106
- isDropdownOpen = false
107
- selectedValues: any[] = []
108
- processedOptions: ProcessedOption[] = []
109
- displayLabel = ""
214
+ @ViewChild('selectButton') selectButton!: ElementRef<HTMLElement>;
215
+ @ViewChild('dropdownPanel') dropdownPanel?: ElementRef<HTMLElement>;
110
216
 
111
- // Dropdown positioning
112
- dropdownTop = 0
113
- dropdownLeft = 0
114
- dropdownWidth = 0
217
+ isDropdownOpen = false;
218
+ selectedValues: unknown[] = [];
219
+ processedOptions: MultiDropdownOption[] = [];
220
+ filteredProcessedOptions: MultiDropdownOption[] = [];
221
+ displayLabel = '';
115
222
 
116
- // ControlValueAccessor
117
- private onChange: any = () => { }
118
- private onTouched: any = () => { }
223
+ searchTerm = '';
224
+ dropdownWidth = 0;
225
+
226
+ mainEndpoint = '';
119
227
 
120
- // Click outside listener
121
- private clickOutsideListener: any
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
- // preparar mainEndpoint (clave del array en response.data)
134
- if (this.endpoint) this.mainEndpoint = this.endpoint.split('/')[0] ?? this.endpoint;
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') this.loadData();
141
- else this.filterOptionsLocally();
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']) this.processOptions();
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("click", this.clickOutsideListener)
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
- // Procesamiento opciones
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.detectChanges();
314
+ this.cdr.markForCheck();
202
315
  }
203
316
 
204
- // ============================
205
- // Carga desde API
206
- // ============================
207
- loadData() {
208
- if (!this._finalEndpoint || !this.genericService) return;
209
-
210
- this.isLoading = true;
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
- this.filteredProcessedOptions = [...this.processedOptions];
242
- this.fullData.emit(data);
243
- this.isLoading = false;
244
- this.updateDisplayLabel();
245
- this.cdr.detectChanges();
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
- // 🔎 Búsqueda
257
- // ============================
258
- onSearchInput() {
259
- // dispara debounce para ambos modos
260
- this.searchSubject.next(this.searchTerm);
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
- private filterOptionsLocally() {
264
- if (!this.searchTerm.trim()) {
265
- this.filteredProcessedOptions = [...this.processedOptions];
266
- return;
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
- const q = this.searchTerm.toLowerCase();
269
- this.filteredProcessedOptions = this.processedOptions.filter(o => o.text.toLowerCase().includes(q));
368
+
369
+ this.filteredProcessedOptions = [...this.processedOptions];
370
+ this.updateDisplayLabel();
371
+ this.cdr.detectChanges();
270
372
  }
271
373
 
272
374
  /**
273
- * Method to toggle option selection
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: ProcessedOption) {
277
- const index = this.selectedValues.findIndex((v) => v === option.value);
278
- if (index > -1) this.selectedValues.splice(index, 1);
279
- else this.selectedValues.push(option.value);
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
- * Method to toggle select all options
393
+ * Selects or clears every option in `processedOptions`.
290
394
  */
291
- toggleSelectAll() {
292
- const allValues = this.processedOptions.map((o) => o.value); // selecciona TODO el dataset, no solo filtrado
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
- * Method to check if all options are selected
302
- * @returns boolean
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((opt) => opt.value)
306
- return allValues.length > 0 && allValues.every((v) => this.selectedValues.includes(v))
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
- * Method to clear the selection
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
- * Method to update the display label based on selected values
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((opt) => this.selectedValues.includes(opt.value))
348
- .map((opt) => opt.text)
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
- } else {
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
- * Method to toggle the dropdown visibility
457
+ * Loads options from the CRUD API (searchable mode).
363
458
  */
364
- toggleDropdown() {
365
- if (this.isDisabled || this.isLoading) return;
366
- this.isDropdownOpen = !this.isDropdownOpen;
367
- if (this.isDropdownOpen) {
368
- this.onTouched();
369
- this.updateDropdownPosition();
459
+ loadData(): void {
460
+ if (!this._finalEndpoint || !this.genericService) {
461
+ return;
462
+ }
370
463
 
371
- if (this.type === 'searchable' && (this.loadOpen || this.processedOptions.length === 0)) {
372
- this.loadData();
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
- // Utilities
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
- * Get nested value from an object
383
- * @param obj
384
- * @param path
385
- * @returns
520
+ * Handles panel search input debounced API call or local filter.
386
521
  */
387
- getNestedValue(obj: any, path: string): any {
388
- return path.split(".").reduce((acc, part) => acc && acc[part], obj) ?? ""
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
- * Setup click outside listener to close dropdown
542
+ * Opens or closes the option panel.
543
+ * Searchable mode may trigger {@link loadData} on first open.
395
544
  */
396
- setupClickOutsideListener() {
397
- this.clickOutsideListener = (event: MouseEvent) => {
398
- const clickedElement = event.target as HTMLElement
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
- * Update the dropdown position based on the button
411
- */
412
- updateDropdownPosition() {
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
- // Get offsets based on container
438
- let offsetTop = 0
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
- // Position directly below the button by default
450
- this.dropdownTop = buttonRect.bottom - offsetTop
451
- this.dropdownLeft = buttonRect.left - offsetLeft
452
- this.dropdownWidth = buttonRect.width
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
- * Helper method to find the offset parent for absolute positioning
568
- * @param element
569
- * @returns
568
+ * Positions the floating panel with fixed viewport coordinates
569
+ * (flip above/below, clamp to window — see {@link positionDropdownPanel}).
570
570
  */
571
- private findPositionedParent(element: HTMLElement): HTMLElement | null {
572
- if (!element) return null
573
- let parent = element.parentElement
574
- while (parent) {
575
- const position = window.getComputedStyle(parent).position
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
- // ControlValueAccessor Implementation
586
- // ======================================================
578
+ const buttonRect = button.getBoundingClientRect();
579
+ this.dropdownWidth = buttonRect.width;
580
+ this.cdr.detectChanges();
587
581
 
588
- /**
589
- * Write the value to the component
590
- * @param value
591
- */
592
- writeValue(value: any[] | null): void {
593
- this.selectedValues = Array.isArray(value)
594
- ? value.map((item) => this.normalizeOptionValue(item))
595
- : [];
596
- this.updateDisplayLabel();
597
- this.cdr.markForCheck();
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
- private normalizeOptionValue(value: unknown): unknown {
601
- if (typeof value === 'string' && value.trim() !== '' && !Number.isNaN(Number(value))) {
602
- return Number(value);
603
- }
604
- return value;
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
- * Register the change of the selected value
611
- * @param fn
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
- * Set the disabled state of the component
631
- * @param isDisabled
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
  }