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,677 +1,618 @@
1
-
2
- import { Component, Input, Output, EventEmitter, ElementRef, ViewChild, OnDestroy, ChangeDetectorRef, AfterViewInit, OnInit, SimpleChanges, OnChanges, Optional, } from "@angular/core"
3
- import { FormsModule, ControlValueAccessor, ReactiveFormsModule, NG_VALUE_ACCESSOR } from "@angular/forms"
4
- import { CommonModule } from "@angular/common"
5
- import { LucideAngularModule } from "lucide-angular"
6
- import { animate, style, transition, trigger } from "@angular/animations"
7
- import { debounceTime, distinctUntilChanged, Subject, Subscription } from "rxjs"
8
- import { JGenericCrudService, JIconsService } from "tailjng"
9
-
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
+ DropdownOption,
31
+ DropdownSelectDefaultFilters,
32
+ DropdownSelectDynamicParams,
33
+ DropdownSelectType,
34
+ DropdownSortOrder,
35
+ } from './dropdown-select.types';
36
+ import {
37
+ DROPDOWN_PANEL_DEFAULTS,
38
+ filterOptionsBySearch,
39
+ mapObjectOptions,
40
+ mapPrimitiveOptions,
41
+ positionDropdownPanel,
42
+ rebuildEndpointUrl,
43
+ resetDropdownPanelStyles,
44
+ resolveOptionLabel,
45
+ } from './dropdown-select.util';
46
+
47
+ export type {
48
+ DropdownOption,
49
+ DropdownPanelPosition,
50
+ DropdownPanelPositionConfig,
51
+ DropdownSelectDefaultFilters,
52
+ DropdownSelectDynamicParams,
53
+ DropdownSelectType,
54
+ DropdownSortOrder,
55
+ } from './dropdown-select.types';
56
+ export {
57
+ DROPDOWN_PANEL_DEFAULTS,
58
+ filterOptionsBySearch,
59
+ getNestedValue,
60
+ mapObjectOptions,
61
+ mapPrimitiveOptions,
62
+ positionDropdownPanel,
63
+ rebuildEndpointUrl,
64
+ resetDropdownPanelStyles,
65
+ resolveOptionLabel,
66
+ } from './dropdown-select.util';
67
+
68
+ /**
69
+ * Dropdown select with static options or searchable CRUD endpoint.
70
+ * Panel uses fixed viewport positioning (dialogs, tables, filter popovers).
71
+ *
72
+ * Install: `npx tailjng add select-dropdown`
73
+ *
74
+ * ```html
75
+ * <JDropdownSelect
76
+ * [(ngModel)]="demoSelect"
77
+ * [options]="selectOptions"
78
+ * optionLabel="text"
79
+ * optionValue="value"
80
+ * [showClear]="true"
81
+ * />
82
+ * ```
83
+ *
84
+ * Icons in template: `[icon]="Icons.ChevronDown"` (see `readonly Icons = Icons`).
85
+ */
10
86
  @Component({
11
- selector: "JDropdownSelect",
12
- imports: [LucideAngularModule, CommonModule, FormsModule, ReactiveFormsModule],
13
- templateUrl: "./dropdown-select.component.html",
14
- styleUrl: "./dropdown-select.component.css",
87
+ selector: 'JDropdownSelect',
88
+ imports: [JIconComponent, CommonModule, FormsModule, ReactiveFormsModule],
89
+ templateUrl: './dropdown-select.component.html',
90
+ styleUrl: './dropdown-select.component.css',
15
91
  animations: [
16
- trigger("modalTransition", [
17
- transition(":enter", [
18
- style({ transform: "translateX(1rem)", opacity: 0 }),
19
- animate("300ms ease-out", style({ transform: "translateY(0)", opacity: 1 })),
92
+ trigger('modalTransition', [
93
+ transition(':enter', [
94
+ style({ transform: 'translateX(1rem)', opacity: 0 }),
95
+ animate('300ms ease-out', style({ transform: 'translateY(0)', opacity: 1 })),
96
+ ]),
97
+ transition(':leave', [
98
+ animate('150ms ease-in', style({ transform: 'translateX(1rem)', opacity: 0 })),
20
99
  ]),
21
- transition(":leave", [animate("150ms ease-in", style({ transform: "translateX(1rem)", opacity: 0 }))]),
22
100
  ]),
23
101
  ],
24
102
  providers: [
25
103
  {
26
104
  provide: NG_VALUE_ACCESSOR,
27
- useExisting: JDropdownSelectComponent,
105
+ useExisting: forwardRef(() => JDropdownSelectComponent),
28
106
  multi: true,
29
107
  },
30
108
  ],
31
109
  })
32
- export class JDropdownSelectComponent implements ControlValueAccessor, AfterViewInit, OnDestroy, OnInit, OnChanges {
110
+ export class JDropdownSelectComponent
111
+ implements ControlValueAccessor, OnInit, AfterViewInit, OnChanges, OnDestroy
112
+ {
113
+ // Project Icons registry — template usage: Icons.ChevronDown, Icons.Loader2, …
114
+ readonly Icons = Icons;
115
+
116
+ // =====================================================
117
+ // Inputs / outputs
118
+ // =====================================================
119
+
120
+ // `dropdown` = static options · `searchable` = CRUD endpoint + debounced search
121
+ @Input() type: DropdownSelectType = 'dropdown';
122
+
123
+ // Panel heading above the option list
124
+ @Input() title = 'Seleccionar';
125
+
126
+ // Trigger label when nothing is selected
127
+ @Input() placeholder = 'Seleccione una opción';
128
+
129
+ // Static options (`string[]` or object array with optionLabel / optionValue)
130
+ @Input() options: unknown[] = [];
131
+
132
+ // Property name(s) used to build option text (supports dot paths)
133
+ @Input() optionLabel: string | string[] = 'text';
33
134
 
34
- @Input() type: "dropdown" | "searchable" = "dropdown";
135
+ // Property name used as the option value
136
+ @Input() optionValue = 'value';
35
137
 
36
- @Input() title = "Seleccionar";
37
- @Input() placeholder = "Seleccione una opción";
138
+ // Separator when `optionLabel` is an array of keys
139
+ @Input() labelSeparator = ' ';
38
140
 
39
- @Input() options: any[] = [];
40
- @Input() optionLabel: string | string[] = "text";
41
- @Input() optionValue = "value";
42
- @Input() labelSeparator = " ";
141
+ // Prepends a synthetic “TODOS” option with `value: null` (searchable mode)
43
142
  @Input() showAllOption = false;
44
143
 
45
- @Input() isDisabled = false
144
+ @Input() isDisabled = false;
145
+
146
+ // Shows clear (X) on the trigger when a value is selected
46
147
  @Input() showClear = false;
148
+
149
+ // Loading state on trigger or panel (API / parent control)
47
150
  @Input() isLoading = false;
48
151
 
152
+ // Searchable: fetch on init
49
153
  @Input() loadOnInit = false;
154
+
155
+ // Searchable: fetch when the panel opens
50
156
  @Input() loadOpen = false;
51
157
 
52
- @Input() defaultFilters: { [key: string]: any } = {};
53
- @Input() searchFields: any[] = [];
54
- @Input() sort: "ASC" | "DESC" = "ASC";
55
- @Input() limit: number = 1000;
158
+ // Searchable: default `filter[key]` query params
159
+ @Input() defaultFilters: DropdownSelectDefaultFilters = {};
56
160
 
57
- @Input() isSearch = true;
58
- @Input() isFilterSelect = false;
161
+ // Searchable: extra fields included in API search
162
+ @Input() searchFields: string[] = [];
59
163
 
60
- @Input() classes: string = '';
61
- @Input() ngClasses: { [key: string]: boolean } = {};
164
+ // Searchable: sort order sent to the API
165
+ @Input() sort: DropdownSortOrder = 'ASC';
62
166
 
63
- @Output() selectionChange = new EventEmitter<any>();
64
- @Output() fullData = new EventEmitter<any[]>();
167
+ // Searchable: max records per request
168
+ @Input() limit = 1000;
65
169
 
66
- @ViewChild("selectButton") selectButton!: ElementRef;
67
-
68
- // Selectors
69
- isColumnSelectorOpen = false
70
- selectedValue: any = null
71
- selectedLabel = ""
72
- internalOptions: Array<{ value: any; text: string }> = [];
170
+ // Searchable panel: show inline search input
171
+ @Input() isSearch = true;
73
172
 
74
- // Search functionality
75
- searchTerm = "";
76
- private readonly searchSubject = new Subject<string>();
77
- private searchSubscription?: Subscription;
78
- filteredOptions: Array<{ value: any; text: string; original?: any }> = [];
173
+ /** @deprecated Kept for backward compatibility; panel positioning no longer uses this flag. */
174
+ @Input() isFilterSelect = false;
79
175
 
80
- // Dropdown positioning
81
- dropdownTop = 0;
82
- dropdownLeft = 0;
83
- dropdownWidth = 0;
176
+ // Extra Tailwind classes on trigger and panel shell
177
+ @Input() classes = '';
84
178
 
85
- // Implement writeValue, registerOnChange, registerOnTouched
86
- private onChange: any = () => { };
87
- private onTouched: any = () => { };
179
+ // Extra conditional classes on the trigger button
180
+ @Input() ngClasses: Record<string, boolean> = {};
88
181
 
89
- // Will be used to close the dropdown when clicking outside
90
- private clickOutsideListener: any;
182
+ /** Emits the selected raw value or `null` when cleared. */
183
+ @Output() selectionChange = new EventEmitter<unknown>();
91
184
 
92
- // Assigned dynamically to the endpoint
93
- private _rawEndpoint = "";
94
- _finalEndpoint = "";
185
+ /** Searchable: emits the full API result array after each load. */
186
+ @Output() fullData = new EventEmitter<unknown[]>();
95
187
 
188
+ /** CRUD endpoint; supports `{param}` placeholders resolved via `dynamicParams`. */
96
189
  @Input() set endpoint(value: string) {
97
- this._rawEndpoint = value
98
- this.rebuildEndpoint()
190
+ this.rawEndpoint = value;
191
+ this.rebuildEndpoint();
99
192
  }
100
193
 
101
194
  get endpoint(): string {
102
- return this._rawEndpoint
195
+ return this.rawEndpoint;
103
196
  }
104
197
 
105
- mainEndpoint!: string
106
- private _dynamicParams: { [key: string]: any } = {}
107
- @Input() set dynamicParams(value: { [key: string]: any }) {
108
- this._dynamicParams = value
109
- this.rebuildEndpoint()
110
- }
111
- get dynamicParams(): { [key: string]: any } {
112
- return this._dynamicParams
198
+ /** Values substituted into `{param}` segments of `endpoint`. */
199
+ @Input() set dynamicParams(value: DropdownSelectDynamicParams) {
200
+ this.endpointDynamicParams = value;
201
+ this.rebuildEndpoint();
113
202
  }
114
203
 
115
- private rebuildEndpoint() {
116
- if (!this._rawEndpoint) return
117
- this._finalEndpoint = this._rawEndpoint.replace(/\{([^}]+)\}/g, (_, key) => {
118
- return this._dynamicParams?.[key] ?? ""
119
- })
204
+ get dynamicParams(): DropdownSelectDynamicParams {
205
+ return this.endpointDynamicParams;
120
206
  }
121
207
 
208
+ // =====================================================
209
+ // View / state
210
+ // =====================================================
211
+
212
+ @ViewChild('selectButton') selectButton!: ElementRef<HTMLElement>;
213
+ @ViewChild('dropdownPanel') dropdownPanel?: ElementRef<HTMLElement>;
214
+
215
+ isColumnSelectorOpen = false;
216
+ selectedValue: unknown = null;
217
+ selectedLabel = '';
218
+ internalOptions: DropdownOption[] = [];
219
+ filteredOptions: DropdownOption[] = [];
220
+
221
+ searchTerm = '';
222
+ dropdownWidth = 0;
223
+
224
+ mainEndpoint = '';
225
+
226
+ /** Resolved endpoint after `{param}` substitution (used by searchable mode). */
227
+ _finalEndpoint = '';
228
+
229
+ private rawEndpoint = '';
230
+ private endpointDynamicParams: DropdownSelectDynamicParams = {};
231
+
232
+ private readonly searchSubject = new Subject<string>();
233
+ private searchSubscription?: Subscription;
234
+ private clickOutsideListener?: (event: MouseEvent) => void;
235
+
236
+ private onChange: (value: unknown) => void = () => {};
237
+ private onTouched: () => void = () => {};
238
+
239
+ private readonly panelPositionConfig = DROPDOWN_PANEL_DEFAULTS;
240
+ private readonly repositionDropdown = () => {
241
+ if (this.isColumnSelectorOpen) {
242
+ this.updateDropdownPosition();
243
+ }
244
+ };
245
+
122
246
  constructor(
123
- public readonly iconsService: JIconsService,
124
247
  private readonly cdr: ChangeDetectorRef,
125
- private readonly elementRef: ElementRef,
248
+ private readonly elementRef: ElementRef<HTMLElement>,
126
249
  @Optional() private readonly genericService: JGenericCrudService | null,
127
- ) { }
250
+ ) {}
128
251
 
129
- ngOnInit() {
130
- this.mainEndpoint = this.endpoint.split("/")[0] ?? this.endpoint
252
+ // =====================================================
253
+ // Lifecycle
254
+ // =====================================================
131
255
 
132
- // Config the search input
133
- this.searchSubscription = this.searchSubject.pipe(debounceTime(1000), distinctUntilChanged()).subscribe(() => {
134
- if (this.type === "searchable") {
135
- this.loadData()
136
- }
137
- })
256
+ ngOnInit(): void {
257
+ this.mainEndpoint = this.endpoint.split('/')[0] ?? this.endpoint;
138
258
 
139
- // Charge data on init if loadOnInit is true
140
- if (this.loadOnInit && this.type === "searchable") {
141
- this.loadData()
259
+ this.searchSubscription = this.searchSubject
260
+ .pipe(debounceTime(1000), distinctUntilChanged())
261
+ .subscribe(() => {
262
+ if (this.type === 'searchable') {
263
+ this.loadData();
264
+ }
265
+ });
266
+
267
+ if (this.loadOnInit && this.type === 'searchable') {
268
+ this.loadData();
142
269
  }
143
270
 
144
- // Assigned the button text if not provided
145
- this.updateSelectedLabel()
271
+ this.updateSelectedLabel();
146
272
  }
147
273
 
148
- ngAfterViewInit() {
149
- this.setupClickOutsideListener()
150
- setTimeout(() => {
151
- this.processOptions()
152
- })
274
+ ngAfterViewInit(): void {
275
+ this.setupClickOutsideListener();
276
+ setTimeout(() => this.processOptions());
153
277
  }
154
278
 
155
279
  ngOnChanges(changes: SimpleChanges): void {
156
- if (changes["options"]) {
157
- this.processOptions()
280
+ if (changes['options']) {
281
+ this.processOptions();
158
282
  }
159
283
  }
160
284
 
161
- ngOnDestroy() {
285
+ ngOnDestroy(): void {
286
+ this.removeRepositionListeners();
287
+
162
288
  if (this.clickOutsideListener) {
163
- document.removeEventListener("click", this.clickOutsideListener)
164
- }
165
- if (this.searchSubscription) {
166
- this.searchSubscription.unsubscribe()
289
+ document.removeEventListener('click', this.clickOutsideListener);
167
290
  }
291
+
292
+ this.searchSubscription?.unsubscribe();
168
293
  }
169
294
 
170
- // ======================================================
171
- // Methods for processing options and selection
172
- // ======================================================
295
+ // =====================================================
296
+ // ControlValueAccessor
297
+ // =====================================================
173
298
 
174
299
  /**
175
- * Method to process dropdown options
300
+ * Writes the value from Angular forms into the component.
301
+ * @param value Selected option value or `null`.
176
302
  */
177
- processOptions() {
178
- this.internalOptions = []
179
-
180
- if (this.options && this.options.length > 0 && typeof this.options[0] !== "object") {
181
- this.internalOptions = this.options.map((option) => ({
182
- value: option,
183
- text: option.toString(),
184
- }))
185
- } else if (this.options && this.options.length > 0) {
186
- this.internalOptions = this.options.map((option) => {
187
- const text = Array.isArray(this.optionLabel)
188
- ? this.optionLabel.map((k) => this.getNestedValue(option, k)).join(" ")
189
- : this.getNestedValue(option, this.optionLabel)
190
- return {
191
- value: option[this.optionValue],
192
- text,
193
- original: option,
194
- }
195
- })
303
+ writeValue(value: unknown): void {
304
+ this.selectedValue = value;
305
+
306
+ if (this.internalOptions.length > 0) {
307
+ this.updateSelectedLabel();
308
+ } else {
309
+ setTimeout(() => {
310
+ this.updateSelectedLabel();
311
+ this.cdr.detectChanges();
312
+ });
196
313
  }
197
314
 
198
- this.filteredOptions = [...this.internalOptions]
199
- this.updateSelectedLabel()
200
- this.cdr.detectChanges()
315
+ this.cdr.markForCheck();
201
316
  }
202
317
 
203
-
204
-
205
318
  /**
206
- * Selected option from the dropdown
207
- * @param option
319
+ * Registers the change callback for Angular forms.
320
+ * @param fn Callback invoked when the selection changes.
208
321
  */
209
- selectOption(option: { value: any; text: string; original?: any }) {
210
- this.selectedValue = option.value
211
- this.selectedLabel = option.text
212
- this.onChange(this.selectedValue)
213
- this.selectionChange.emit(option.original ?? option.value)
214
- this.isColumnSelectorOpen = false
322
+ registerOnChange(fn: (value: unknown) => void): void {
323
+ this.onChange = fn;
215
324
  }
216
325
 
326
+ /**
327
+ * Registers the touched callback for Angular forms.
328
+ * @param fn Callback invoked when the control is touched.
329
+ */
330
+ registerOnTouched(fn: () => void): void {
331
+ this.onTouched = fn;
332
+ }
217
333
 
334
+ // =====================================================
335
+ // Options
336
+ // =====================================================
218
337
 
219
338
  /**
220
- * Clean the selection
221
- * @param event
339
+ * Normalizes `options` into internal `{ value, text }` entries.
222
340
  */
223
- clearSelection(event: Event) {
224
- event.stopPropagation()
225
- this.writeValue(null)
226
- this.onChange(null)
227
- this.selectionChange.emit(null)
228
- }
341
+ processOptions(): void {
342
+ this.internalOptions = [];
343
+
344
+ if (this.options?.length > 0 && typeof this.options[0] !== 'object') {
345
+ this.internalOptions = mapPrimitiveOptions(this.options);
346
+ } else if (this.options?.length > 0) {
347
+ this.internalOptions = mapObjectOptions(
348
+ this.options as Record<string, unknown>[],
349
+ this.optionLabel,
350
+ this.optionValue,
351
+ this.labelSeparator,
352
+ );
353
+ }
229
354
 
355
+ this.filteredOptions = [...this.internalOptions];
356
+ this.updateSelectedLabel();
357
+ this.cdr.detectChanges();
358
+ }
230
359
 
360
+ /**
361
+ * Selects an option, updates the form model and closes the panel.
362
+ * @param option Normalized option from the panel list.
363
+ */
364
+ selectOption(option: DropdownOption): void {
365
+ this.selectedValue = option.value;
366
+ this.selectedLabel = option.text;
367
+ this.onChange(this.selectedValue);
368
+ this.selectionChange.emit(option.original ?? option.value);
369
+ this.closePanel();
370
+ }
231
371
 
232
372
  /**
233
- * Clean the search term
373
+ * Clears the current selection without opening the panel.
374
+ * @param event Click event (stopped to avoid toggling the panel).
234
375
  */
235
- clearSearchTerm(): void {
236
- this.searchTerm = ""
237
- this.onSearchInput()
376
+ clearSelection(event: Event): void {
377
+ event.stopPropagation();
378
+ this.writeValue(null);
379
+ this.onChange(null);
380
+ this.selectionChange.emit(null);
238
381
  }
239
382
 
240
383
  /**
241
- * Update the selected label based on the current selection
242
- * @returns
384
+ * Syncs `selectedLabel` with `selectedValue` and `internalOptions`.
243
385
  */
244
- updateSelectedLabel() {
386
+ updateSelectedLabel(): void {
245
387
  if (this.selectedValue === null) {
246
- this.selectedLabel = this.placeholder
247
- return
388
+ this.selectedLabel = this.placeholder;
389
+ return;
248
390
  }
249
391
 
250
- const selectedOption = this.internalOptions.find((opt) => opt.value === this.selectedValue)
251
- this.selectedLabel = selectedOption ? selectedOption.text : this.placeholder
392
+ const selectedOption = this.internalOptions.find((option) => option.value === this.selectedValue);
393
+ this.selectedLabel = selectedOption ? selectedOption.text : this.placeholder;
252
394
  }
253
395
 
254
396
  /**
255
- * Get nested value from an object
256
- * @param obj
257
- * @param path
258
- * @returns
397
+ * Class map for the trigger button (disabled / loading + `ngClasses`).
398
+ * @returns Conditional classes merged with consumer `ngClasses`.
259
399
  */
260
- getNestedValue(obj: any, path: string): any {
261
- return path.split(".").reduce((acc, part) => acc && acc[part], obj) ?? ""
400
+ getButtonNgClass(): Record<string, boolean> {
401
+ return {
402
+ 'opacity-50 cursor-not-allowed pointer-events-none': this.isDisabled || this.isLoading,
403
+ ...this.ngClasses,
404
+ };
262
405
  }
263
406
 
264
- // ======================================================
265
- // Search input service
266
- // ======================================================
407
+ // =====================================================
408
+ // Search / API
409
+ // =====================================================
267
410
 
268
411
  /**
269
- * Load data from the service
270
- * @returns
412
+ * Loads options from the CRUD API (searchable mode).
271
413
  */
272
- loadData() {
273
- if (!this.endpoint || !this.genericService) return
414
+ loadData(): void {
415
+ if (!this.endpoint || !this.genericService) {
416
+ return;
417
+ }
418
+
419
+ this.isLoading = true;
274
420
 
275
- this.isLoading = true
276
- const params: any = {}
277
- params["sortOrder"] = this.sort
278
- params["limit"] = this.limit
421
+ const params: Record<string, unknown> = {
422
+ sortOrder: this.sort,
423
+ limit: this.limit,
424
+ };
279
425
 
280
- // Apply default filters sent from the parent
281
426
  Object.keys(this.defaultFilters).forEach((key) => {
282
- params[`filter[${key}]`] = this.defaultFilters[key]
283
- })
284
-
285
- // Add search term to the parameters
286
- if (this.searchTerm && this.searchTerm.trim() !== "") {
287
- params["search"] = this.searchTerm
288
- // Apply additional search filters
289
- const optionLabelsArray = Array.isArray(this.optionLabel) ? this.optionLabel : [this.optionLabel]
290
- const allSearchFields = [...optionLabelsArray, ...this.searchFields]
291
- params["searchFields"] = allSearchFields
427
+ params[`filter[${key}]`] = this.defaultFilters[key];
428
+ });
429
+
430
+ if (this.searchTerm.trim()) {
431
+ params['search'] = this.searchTerm;
432
+ const optionLabels = Array.isArray(this.optionLabel) ? this.optionLabel : [this.optionLabel];
433
+ params['searchFields'] = [...optionLabels, ...this.searchFields];
292
434
  }
293
435
 
294
- this.genericService.findAll<any>({ endpoint: this._finalEndpoint, params }).subscribe({
436
+ this.genericService.findAll<Record<string, unknown>>({
437
+ endpoint: this._finalEndpoint,
438
+ params,
439
+ }).subscribe({
295
440
  next: (response) => {
296
- // Process the response data
297
- const data = response.data[this.mainEndpoint] || []
441
+ const data = (response.data[this.mainEndpoint] as Record<string, unknown>[]) || [];
298
442
  this.options = data;
299
443
 
300
- // Add the "ALL" option if required
301
444
  if (this.showAllOption) {
302
-
303
- this.internalOptions = [{
304
- value: null, // Null value for the "ALL" option
305
- text: 'TODOS',
306
- }];
307
-
308
- // Process the options for the dropdown
445
+ this.internalOptions = [{ value: null, text: 'TODOS' }];
309
446
  this.internalOptions.push(
310
- ...this.options.map((option) => ({
311
- value: option[this.optionValue],
312
- text: this.resolveLabel(option),
313
- original: option,
314
- }))
447
+ ...mapObjectOptions(data, this.optionLabel, this.optionValue, this.labelSeparator),
315
448
  );
316
-
317
449
  } else {
450
+ this.internalOptions = mapObjectOptions(
451
+ data,
452
+ this.optionLabel,
453
+ this.optionValue,
454
+ this.labelSeparator,
455
+ );
456
+ }
318
457
 
319
- // Process the options for the dropdown
320
- this.internalOptions = this.options.map((option) => ({
321
- value: option[this.optionValue],
322
- text: this.resolveLabel(option),
323
- original: option,
324
- }))
458
+ this.filteredOptions = [...this.internalOptions];
459
+ this.fullData.emit(this.options);
460
+ this.isLoading = false;
461
+ this.updateSelectedLabel();
325
462
 
463
+ if (this.isColumnSelectorOpen) {
464
+ this.updateDropdownPosition();
326
465
  }
327
466
 
328
- this.filteredOptions = [...this.internalOptions]
329
- this.fullData.emit(this.options)
330
- this.isLoading = false
331
- this.updateSelectedLabel()
332
- this.cdr.detectChanges()
467
+ this.cdr.detectChanges();
333
468
  },
334
469
  error: (error) => {
335
- console.error("Error fetching data:", error)
336
- this.isLoading = false
337
- this.cdr.detectChanges()
470
+ console.error('Error fetching data:', error);
471
+ this.isLoading = false;
472
+ this.cdr.detectChanges();
338
473
  },
339
- })
340
- }
341
-
342
-
343
-
344
- /**
345
- * Search input handler
346
- */
347
- onSearchInput() {
348
- if (this.type === "searchable") {
349
- // For searchable type, send the search term to the service
350
- this.searchSubject.next(this.searchTerm)
351
- } else {
352
- // For other types, filter locally
353
- this.filterOptions()
354
- }
474
+ });
355
475
  }
356
476
 
357
-
358
-
359
477
  /**
360
- * Filter options locally
361
- * @returns
478
+ * Handles panel search input — debounced API call or local filter.
362
479
  */
363
- filterOptions() {
364
- if (!this.searchTerm || this.searchTerm.trim() === "") {
365
- this.filteredOptions = [...this.internalOptions]
366
- return
480
+ onSearchInput(): void {
481
+ if (this.type === 'searchable') {
482
+ this.searchSubject.next(this.searchTerm);
483
+ return;
367
484
  }
368
485
 
369
- const searchTermLower = this.searchTerm.toLowerCase()
370
- this.filteredOptions = this.internalOptions.filter((option) => option.text.toLowerCase().includes(searchTermLower))
486
+ this.filteredOptions = filterOptionsBySearch(this.internalOptions, this.searchTerm);
371
487
  }
372
488
 
489
+ /** Resets the panel search field and re-runs {@link onSearchInput}. */
490
+ clearSearchTerm(): void {
491
+ this.searchTerm = '';
492
+ this.onSearchInput();
493
+ }
373
494
 
495
+ // =====================================================
496
+ // Panel open / position
497
+ // =====================================================
374
498
 
375
499
  /**
376
- * Filter options locally
377
- * @param option
378
- * @returns
500
+ * Opens or closes the option panel.
501
+ * Searchable mode may trigger {@link loadData} on first open.
379
502
  */
380
- resolveLabel(option: any): string {
381
- if (Array.isArray(this.optionLabel)) {
382
- return this.optionLabel
383
- .map((key) => this.getNestedValue(option, key))
384
- .filter(Boolean)
385
- .join(this.labelSeparator)
503
+ toggleColumnSelector(): void {
504
+ if (this.isDisabled) {
505
+ return;
386
506
  }
387
- return this.getNestedValue(option, this.optionLabel)
388
- }
389
507
 
390
- // ======================================================
391
- // Elements
392
- // ======================================================
508
+ this.isColumnSelectorOpen = !this.isColumnSelectorOpen;
393
509
 
394
- /**
395
- * Open or close the dropdown
396
- * @returns
397
- */
398
- toggleColumnSelector() {
399
- if (this.isDisabled) return
400
-
401
- this.isColumnSelectorOpen = !this.isColumnSelectorOpen
402
510
  if (this.isColumnSelectorOpen) {
403
- this.onTouched()
404
- this.updateDropdownPosition()
511
+ this.onTouched();
512
+ this.addRepositionListeners();
513
+ this.updateDropdownPosition();
405
514
 
406
- // Load data if searchable and loadOpen is true
407
- if (this.type === "searchable" && this.loadOpen) {
408
- this.loadData()
515
+ if (this.type === 'searchable' && this.loadOpen) {
516
+ this.loadData();
409
517
  }
410
518
 
411
- if (this.type === "searchable" && !this.loadOnInit && this.shouldTriggerLoad()) {
412
- this.loadData()
519
+ if (this.type === 'searchable' && !this.loadOnInit && this.shouldTriggerLoad()) {
520
+ this.loadData();
413
521
  }
414
- }
415
- }
416
-
417
-
418
522
 
419
- /**
420
- * Detect clicks outside the dropdown to close it
421
- */
422
- setupClickOutsideListener() {
423
- this.clickOutsideListener = (event: MouseEvent) => {
424
- const clickedElement = event.target as HTMLElement
425
- const isOutsideDropdown = !this.elementRef.nativeElement.contains(clickedElement)
426
- if (this.isColumnSelectorOpen && isOutsideDropdown) {
427
- this.isColumnSelectorOpen = false
428
- this.cdr.detectChanges()
429
- }
523
+ return;
430
524
  }
431
- document.addEventListener("click", this.clickOutsideListener)
432
- }
433
-
434
525
 
526
+ this.closePanel(false);
527
+ }
435
528
 
436
529
  /**
437
- * Update the dropdown position based on the button
530
+ * Positions the floating panel with fixed viewport coordinates
531
+ * (flip above/below, clamp to window — see {@link positionDropdownPanel}).
438
532
  */
439
- updateDropdownPosition() {
533
+ updateDropdownPosition(): void {
440
534
  setTimeout(() => {
441
- if (!this.selectButton) return
442
-
443
- // Get button position
444
- const button = this.selectButton.nativeElement
445
- const buttonRect = button.getBoundingClientRect()
446
-
447
- // Find the closest form container or dialog
448
- let offsetParent: HTMLElement | null = this.selectButton.nativeElement
449
- let isInSidebar = false
450
-
451
- // Check if we're inside a sidebar form
452
- while (
453
- offsetParent &&
454
- !offsetParent.classList.contains("content_form") &&
455
- !offsetParent.classList.contains("p-dialog")
456
- ) {
457
- if (offsetParent.classList.contains("fixed") && offsetParent.classList.contains("right-0")) {
458
- isInSidebar = true
459
- break
460
- }
461
- offsetParent = offsetParent.parentElement
535
+ const button = this.selectButton?.nativeElement;
536
+ if (!button) {
537
+ return;
462
538
  }
463
539
 
464
- // Get offsets based on container
465
- let offsetTop = 0
466
- let offsetLeft = 0
467
- if (
468
- isInSidebar ||
469
- (offsetParent &&
470
- (offsetParent.classList.contains("content_form") || offsetParent.classList.contains("p-dialog")))
471
- ) {
472
- offsetTop = offsetParent ? offsetParent.getBoundingClientRect().top : 0
473
- offsetLeft = offsetParent ? offsetParent.getBoundingClientRect().left : 0
474
- }
540
+ const buttonRect = button.getBoundingClientRect();
541
+ this.dropdownWidth = buttonRect.width;
542
+ this.cdr.detectChanges();
475
543
 
476
- // Position directly below the button by default
477
- this.dropdownTop = buttonRect.bottom - offsetTop
478
- this.dropdownLeft = buttonRect.left - offsetLeft
479
- this.dropdownWidth = buttonRect.width
480
- this.cdr.detectChanges()
544
+ requestAnimationFrame(() => {
545
+ const panel = this.dropdownPanel?.nativeElement;
546
+ if (!panel) {
547
+ return;
548
+ }
481
549
 
482
- // Wait for dropdown to be in DOM
483
- setTimeout(() => {
484
- // Get dropdown element
485
- const dropdown = this.elementRef.nativeElement.querySelector(".absolute.z-\\[100\\]")
486
- if (!dropdown) return
487
-
488
- // First use fixed positioning to handle scroll position correctly
489
- dropdown.style.position = "fixed"
490
- dropdown.style.top = buttonRect.bottom + "px"
491
- dropdown.style.left = buttonRect.left + "px"
492
- dropdown.style.width = buttonRect.width + "px"
493
- dropdown.style.zIndex = "600"
494
-
495
- // Wait for dropdown to render with fixed positioning
496
- setTimeout(() => {
497
- // Get dropdown dimensions
498
- const dropdownRect = dropdown.getBoundingClientRect()
499
- const viewportHeight = window.innerHeight
500
- const documentWidth = document.documentElement.clientWidth
501
-
502
- // Determine if we need to flip or adjust position
503
- let newFixedTop = buttonRect.bottom
504
- let newFixedLeft = buttonRect.left
505
-
506
- // Check if dropdown goes below viewport and flip it if needed
507
- if (buttonRect.bottom + dropdownRect.height > viewportHeight) {
508
- newFixedTop = buttonRect.top - dropdownRect.height
509
- }
510
-
511
- // Check if dropdown goes beyond right edge
512
- if (buttonRect.left + dropdownRect.width > documentWidth) {
513
- newFixedLeft = buttonRect.left - dropdownRect.width + buttonRect.width
514
- if (newFixedLeft < 0) {
515
- newFixedLeft = 5
516
- if (dropdownRect.width > documentWidth) {
517
- dropdown.classList.add("constrain-width")
518
- }
519
- }
520
- }
521
-
522
- // Apply adjusted fixed position
523
- dropdown.style.top = newFixedTop - 5 + "px"
524
- dropdown.style.left = newFixedLeft - 15 + "px"
525
-
526
- // Button filter
527
- if (this.isFilterSelect) {
528
- setTimeout(() => {
529
- // Calculate absolute position based on the current fixed position
530
- let newAbsoluteTop
531
- let newAbsoluteLeft
532
-
533
- if (newFixedTop === buttonRect.bottom) {
534
- // Dropdown is below the button
535
- newAbsoluteTop = this.dropdownTop
536
- } else {
537
- // Dropdown is above the button (flipped)
538
- newAbsoluteTop = buttonRect.top - dropdownRect.height - offsetTop
539
- }
540
-
541
- if (newFixedLeft === buttonRect.left) {
542
- // Dropdown is aligned with left edge of button
543
- newAbsoluteLeft = this.dropdownLeft
544
- } else if (newFixedLeft === buttonRect.left - dropdownRect.width + buttonRect.width) {
545
- // Dropdown is aligned with right edge of button
546
- newAbsoluteLeft = buttonRect.left - dropdownRect.width + buttonRect.width - offsetLeft
547
- } else {
548
- // Dropdown is at a fixed position from left edge
549
- newAbsoluteLeft = newFixedLeft
550
- }
551
-
552
- // Switch to absolute positioning
553
- dropdown.style.position = "absolute"
554
- dropdown.style.top = newAbsoluteTop + "px"
555
- dropdown.style.left = newAbsoluteLeft - 2 + "px"
556
-
557
- // Update internal state
558
- this.dropdownTop = newAbsoluteTop
559
- this.dropdownLeft = newAbsoluteLeft
560
- this.cdr.detectChanges()
561
- }, 0)
562
- } else {
563
- // After positioning is done, switch to absolute positioning
564
- setTimeout(() => {
565
- // Get the current visual position of the dropdown (relative to viewport)
566
- const dropdownRect = dropdown.getBoundingClientRect()
567
-
568
- // Find the dropdown's offset parent for absolute positioning
569
- const dropdownOffsetParent = this.findPositionedParent(dropdown) || document.body
570
- const parentRect = dropdownOffsetParent.getBoundingClientRect()
571
-
572
- // Calculate absolute position that will maintain the same visual position
573
- // Absolute positioning is relative to the offset parent
574
- const absoluteTop = dropdownRect.top - parentRect.top + dropdownOffsetParent.scrollTop
575
- const absoluteLeft = dropdownRect.left - parentRect.left + dropdownOffsetParent.scrollLeft
576
-
577
- // Switch to absolute positioning with calculated coordinates
578
- dropdown.style.position = "absolute"
579
- dropdown.style.top = absoluteTop + "px"
580
- dropdown.style.left = absoluteLeft + "px"
581
-
582
- // Update internal state
583
- this.dropdownTop = absoluteTop
584
- this.dropdownLeft = absoluteLeft
585
- this.cdr.detectChanges()
586
- }, 0)
587
- }
588
- }, 0)
589
- }, 0)
590
- })
550
+ positionDropdownPanel(panel, buttonRect, this.panelPositionConfig);
551
+ this.cdr.detectChanges();
552
+ });
553
+ });
591
554
  }
592
555
 
593
- /**
594
- * Helper method to find the offset parent for absolute positioning
595
- * @param element
596
- * @returns
597
- */
598
- private findPositionedParent(element: HTMLElement): HTMLElement | null {
599
- if (!element) return null
600
- let parent = element.parentElement
601
- while (parent) {
602
- const position = window.getComputedStyle(parent).position
603
- if (position === "relative" || position === "absolute" || position === "fixed") {
604
- return parent
605
- }
606
- parent = parent.parentElement
607
- }
608
- return document.body // Default to body if no positioned parent found
609
- }
556
+ /** Document click listener — closes the panel when clicking outside. */
557
+ private setupClickOutsideListener(): void {
558
+ this.clickOutsideListener = (event: MouseEvent) => {
559
+ const clickedElement = event.target as HTMLElement;
560
+ const isOutsideDropdown = !this.elementRef.nativeElement.contains(clickedElement);
610
561
 
562
+ if (this.isColumnSelectorOpen && isOutsideDropdown) {
563
+ this.closePanel();
564
+ }
565
+ };
611
566
 
567
+ document.addEventListener('click', this.clickOutsideListener);
568
+ }
612
569
 
613
- /**
614
- * Read the selected value
615
- * @param value
616
- */
617
- writeValue(value: any): void {
618
- this.selectedValue = value
570
+ /** Closes the panel and clears reposition listeners / dynamic panel styles. */
571
+ private closePanel(detectChanges = true): void {
572
+ this.isColumnSelectorOpen = false;
573
+ this.removeRepositionListeners();
574
+ this.resetDropdownPanelStyles();
619
575
 
620
- // If there are already options available, update the label
621
- if (this.internalOptions.length > 0) {
622
- this.updateSelectedLabel()
623
- } else {
624
- // Wait for options to load and then update the label
625
- setTimeout(() => {
626
- this.updateSelectedLabel()
627
- this.cdr.detectChanges()
628
- })
576
+ if (detectChanges) {
577
+ this.cdr.detectChanges();
629
578
  }
630
- this.cdr.markForCheck()
631
579
  }
632
580
 
633
- // ================================================
634
- // Register changes
635
- // ================================================
636
-
637
- /**
638
- * Register the change of the selected value
639
- * @param fn
640
- */
641
- registerOnChange(fn: any): void {
642
- this.onChange = fn
581
+ /** Re-applies panel position on scroll (capture) and window resize. */
582
+ private addRepositionListeners(): void {
583
+ window.addEventListener('scroll', this.repositionDropdown, true);
584
+ window.addEventListener('resize', this.repositionDropdown);
643
585
  }
644
586
 
587
+ /** Removes scroll/resize listeners registered while the panel is open. */
588
+ private removeRepositionListeners(): void {
589
+ window.removeEventListener('scroll', this.repositionDropdown, true);
590
+ window.removeEventListener('resize', this.repositionDropdown);
591
+ }
645
592
 
593
+ /** Resets dynamic max-height on the scrollable options list. */
594
+ private resetDropdownPanelStyles(): void {
595
+ const panel = this.dropdownPanel?.nativeElement;
596
+ if (!panel) {
597
+ return;
598
+ }
646
599
 
647
- /**
648
- * Register the touch event
649
- * @param fn
650
- */
651
- registerOnTouched(fn: any): void {
652
- this.onTouched = fn
600
+ resetDropdownPanelStyles(panel, this.panelPositionConfig.optionsListSelector);
653
601
  }
654
602
 
603
+ /** Builds `_finalEndpoint` from `endpoint` + `dynamicParams`. */
604
+ private rebuildEndpoint(): void {
605
+ if (!this.rawEndpoint) {
606
+ return;
607
+ }
655
608
 
656
-
657
- /**
658
- * Load searchable elements when needed
659
- * @returns
660
- */
661
- private shouldTriggerLoad(): boolean {
662
- const isSearchActive = this.searchTerm && this.searchTerm.trim() !== ""
663
- const isInitialState = this.selectedValue === null
664
- return isSearchActive || isInitialState
609
+ this._finalEndpoint = rebuildEndpointUrl(this.rawEndpoint, this.endpointDynamicParams);
665
610
  }
666
611
 
667
- /**
668
- * Get the ngClass for the button based on disabled and loading state
669
- * @returns
670
- */
671
- getButtonNgClass(): { [key: string]: boolean } {
672
- return {
673
- 'opacity-50 cursor-not-allowed pointer-events-none': this.isDisabled || this.isLoading,
674
- ...this.ngClasses,
675
- };
612
+ /** Whether searchable mode should fetch on first panel open. */
613
+ private shouldTriggerLoad(): boolean {
614
+ const isSearchActive = this.searchTerm.trim().length > 0;
615
+ const isInitialState = this.selectedValue === null;
616
+ return isSearchActive || isInitialState;
676
617
  }
677
- }
618
+ }