@zambon-dev/library 1.4.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -23,6 +23,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
23
23
 
24
24
  ### ⚠ Breaking Changes / Migration
25
25
 
26
+ ## [1.6.0] - 2026-09-16
27
+
28
+ ### Added
29
+
30
+ - **`DisplayControls`**, which records the form controls that exist only to show a catalog
31
+ selection's label. `lib-catalog-select` works in pairs — `controlName` holds the identifier,
32
+ `displayControlName` holds the text — and it creates the second itself when a screen does not
33
+ declare one, so a form carries controls nobody wrote down. It now marks whichever control it
34
+ drives as its display, and `framework-button-filters` reads that mark to keep those out of what
35
+ it submits.
36
+
37
+ The mark is held in a `WeakSet` keyed by the control instance, not by its name: two forms may
38
+ each have an `employeeName`, and only the one a catalog select drives is a display control.
39
+ Nothing about an application changes — declaring the display control yourself still works, and
40
+ it is marked just the same.
41
+
42
+ ## [1.5.0] - 2026-09-09
43
+
44
+ ### Added
45
+
46
+ - **`SidebarConfigs.shouldDeriveAreasFromRootMenus`** — derive region headers from the menu tree
47
+ instead of from `SidebarMenu.region`. Off by default, so nothing changes until you opt in.
48
+
49
+ With it on, a top-level menu that has children **and no URL** stops being a collapsible node and
50
+ becomes an area header, with its children rendered flat beneath it as top-level items — icons
51
+ included. A top-level menu that has its own URL stays an item, and a parent that carries a URL
52
+ stays collapsible, so only the menus that were already acting purely as groups change shape.
53
+
54
+ The point is where the grouping lives. `region` is a label repeated on every item that belongs to
55
+ a group, matched by exact string equality: a typo silently splits one area into two, and the
56
+ area has no row of its own to carry an order or a translation. Derived from the tree, the area
57
+ *is* a menu row — it already has a translated label and an order — and nothing has to be
58
+ duplicated across its items.
59
+
60
+ The children of an area are fetched **eagerly**, at load, because they are rendered without a
61
+ click and the lazy load a collapsible parent relies on would never fire. That is one extra
62
+ request per area. `region` keeps working exactly as before for anyone who prefers it; the two
63
+ mechanisms are independent and the flag chooses between them.
64
+
65
+ - **`SidebarService.loadChildrenFor(parentMenu)`** — loads a parent’s children and returns them as
66
+ an observable, for callers that need them before the user clicks. `loadChildren` is unchanged: it
67
+ is still the fire-and-forget variant that raises `childrenLoading` and `childrenFailed`, and it
68
+ now delegates to this one.
69
+
70
+ ### ⚠ Breaking Changes / Migration
71
+
72
+ None. `shouldDeriveAreasFromRootMenus` defaults to `false`, so a sidebar keeps grouping by
73
+ `region` and keeps rendering top-level parents as collapsible nodes until you set it.
74
+
26
75
  ## [1.4.1] - 2026-09-08
27
76
 
28
77
  - Maintenance release (no consumer-facing changes were documented).
@@ -216,7 +265,9 @@ config options and the `getUserProfile()` method still exist but are no longer c
216
265
  available via [GitHub Releases](https://github.com/RicardoZambon/ZLibraries/releases) and the
217
266
  `library-v*` tags.
218
267
 
219
- [Unreleased]: https://github.com/RicardoZambon/ZLibraries/compare/library-v1.4.1...HEAD
268
+ [Unreleased]: https://github.com/RicardoZambon/ZLibraries/compare/library-v1.6.0...HEAD
269
+ [1.6.0]: https://github.com/RicardoZambon/ZLibraries/releases/tag/library-v1.6.0
270
+ [1.5.0]: https://github.com/RicardoZambon/ZLibraries/releases/tag/library-v1.5.0
220
271
  [1.4.1]: https://github.com/RicardoZambon/ZLibraries/releases/tag/library-v1.4.1
221
272
  [1.4.0]: https://github.com/RicardoZambon/ZLibraries/releases/tag/library-v1.4.0
222
273
  [1.3.2]: https://github.com/RicardoZambon/ZLibraries/releases/tag/library-v1.3.2
@@ -38,6 +38,95 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.1.6", ngImpor
38
38
  args: [{ template: '' }]
39
39
  }] });
40
40
 
41
+ class DateHelpers {
42
+ //#region Public methods
43
+ static diffDays(initialDate, finalDate) {
44
+ const initialDateOnly = new Date(initialDate.getFullYear(), initialDate.getMonth(), initialDate.getDate());
45
+ const finalDateOnly = new Date(finalDate.getFullYear(), finalDate.getMonth(), finalDate.getDate());
46
+ const diff = initialDateOnly.getTime() - finalDateOnly.getTime();
47
+ return Math.floor(diff / (1000 * 60 * 60 * 24));
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Tracks the form controls that exist only to show a catalog selection's label.
53
+ *
54
+ * `lib-catalog-select` works in pairs: the control named by `controlName` holds the identifier, and
55
+ * the one named by `displayControlName` holds the text the user reads. The second is created by the
56
+ * component itself when a screen does not declare it, so a form ends up carrying controls nobody
57
+ * wrote down — and a filters form then submits them alongside the real filters.
58
+ *
59
+ * The mark lives in a `WeakSet` keyed by the control instance rather than by its name. That is the
60
+ * only scope that is actually correct: two forms may each have an `employeeName`, and only the one
61
+ * a catalog select drives is a display control. It also keeps nothing alive.
62
+ */
63
+ class DisplayControls {
64
+ //#region Variables
65
+ static marked = new WeakSet();
66
+ //#endregion
67
+ //#region Public methods
68
+ /**
69
+ * Whether the control exists only to display a catalog selection's label.
70
+ *
71
+ * @param control The control, which may be absent.
72
+ * @returns `true` when a catalog select drives it as its display control.
73
+ */
74
+ static isDisplayControl(control) {
75
+ return !!control && DisplayControls.marked.has(control);
76
+ }
77
+ /**
78
+ * Marks the control as existing only to display a catalog selection's label.
79
+ *
80
+ * @param control The control, which may be absent.
81
+ */
82
+ static markAsDisplayControl(control) {
83
+ if (control) {
84
+ DisplayControls.marked.add(control);
85
+ }
86
+ }
87
+ }
88
+
89
+ class GuidHelper {
90
+ static generateGUID() {
91
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
92
+ const r = (Math.random() * 16) | 0;
93
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
94
+ return v.toString(16);
95
+ });
96
+ }
97
+ }
98
+
99
+ class RouterFormatter {
100
+ //#region ViewChilds, Inputs, Outputs
101
+ //#endregion
102
+ //#region Variables
103
+ //#endregion
104
+ //#region Properties
105
+ //#endregion
106
+ //#region Constructor and Angular life cycle methods
107
+ //#endregion
108
+ //#region Event handlers
109
+ //#endregion
110
+ //#region Public methods
111
+ static getURL(route) {
112
+ let parentRoute = '';
113
+ if (route.parent) {
114
+ parentRoute = this.getURL(route.parent);
115
+ }
116
+ let path = route.routeConfig?.path ?? '';
117
+ route.paramMap.keys
118
+ .forEach(k => {
119
+ path = path.replace(':' + k, k === 'view'
120
+ ? ''
121
+ : (route.paramMap.get(k) ?? ''));
122
+ });
123
+ if (!route.parent || (parentRoute !== '' && parentRoute !== '/' && route.routeConfig?.path && path)) {
124
+ parentRoute += '/';
125
+ }
126
+ return parentRoute + path;
127
+ }
128
+ }
129
+
41
130
  class CatalogService {
42
131
  //#region ViewChilds, Inputs, Outputs
43
132
  //#endregion
@@ -193,57 +282,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.1.6", ngImpor
193
282
  type: Injectable
194
283
  }], ctorParameters: () => [] });
195
284
 
196
- class DateHelpers {
197
- //#region Public methods
198
- static diffDays(initialDate, finalDate) {
199
- const initialDateOnly = new Date(initialDate.getFullYear(), initialDate.getMonth(), initialDate.getDate());
200
- const finalDateOnly = new Date(finalDate.getFullYear(), finalDate.getMonth(), finalDate.getDate());
201
- const diff = initialDateOnly.getTime() - finalDateOnly.getTime();
202
- return Math.floor(diff / (1000 * 60 * 60 * 24));
203
- }
204
- }
205
-
206
- class GuidHelper {
207
- static generateGUID() {
208
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
209
- const r = (Math.random() * 16) | 0;
210
- const v = c === 'x' ? r : (r & 0x3) | 0x8;
211
- return v.toString(16);
212
- });
213
- }
214
- }
215
-
216
- class RouterFormatter {
217
- //#region ViewChilds, Inputs, Outputs
218
- //#endregion
219
- //#region Variables
220
- //#endregion
221
- //#region Properties
222
- //#endregion
223
- //#region Constructor and Angular life cycle methods
224
- //#endregion
225
- //#region Event handlers
226
- //#endregion
227
- //#region Public methods
228
- static getURL(route) {
229
- let parentRoute = '';
230
- if (route.parent) {
231
- parentRoute = this.getURL(route.parent);
232
- }
233
- let path = route.routeConfig?.path ?? '';
234
- route.paramMap.keys
235
- .forEach(k => {
236
- path = path.replace(':' + k, k === 'view'
237
- ? ''
238
- : (route.paramMap.get(k) ?? ''));
239
- });
240
- if (!route.parent || (parentRoute !== '' && parentRoute !== '/' && route.routeConfig?.path && path)) {
241
- parentRoute += '/';
242
- }
243
- return parentRoute + path;
244
- }
245
- }
246
-
247
285
  class GridConfigsProvider {
248
286
  //#region ViewChilds, Inputs, Outputs
249
287
  //#endregion
@@ -977,6 +1015,17 @@ class SidebarConfigs {
977
1015
  /** Tooltip for items that open in a new browser tab. Rendered as-is, like {@link errorText}. */
978
1016
  externalLinkText = 'Opens in a new browser tab';
979
1017
  loadingText = 'Loading';
1018
+ /**
1019
+ * Derive region headers from the menu tree instead of from {@link SidebarMenu.region}.
1020
+ *
1021
+ * With this on, a top-level menu that has children and no URL stops being a collapsible node
1022
+ * and becomes an area header, with its children rendered flat beneath it as top-level items --
1023
+ * icons included. The area is then a real menu row, so it carries its own translated label and
1024
+ * its own order, and no `region` label has to be repeated across every item that belongs to it.
1025
+ *
1026
+ * Off by default: without it a top-level parent stays the collapsible group it has always been.
1027
+ */
1028
+ shouldDeriveAreasFromRootMenus = false;
980
1029
  logoCollapsedPath;
981
1030
  logoExpandedPath;
982
1031
  constructor(options = {}) {
@@ -1088,19 +1137,26 @@ class SidebarService {
1088
1137
  }
1089
1138
  loadChildren(parentMenu) {
1090
1139
  this.childrenLoading.emit(parentMenu);
1091
- this.loadMenus(parentMenu)
1092
- .pipe(take(1), map((menus) => menus.map((menu) => new SidebarMenu(menu))))
1140
+ this.loadChildrenFor(parentMenu)
1093
1141
  .subscribe({
1094
- next: (childrenMenus) => {
1095
- parentMenu.children = childrenMenus;
1096
- childrenMenus.forEach((childMenu) => childMenu.parent = parentMenu);
1097
- },
1098
1142
  error: (exception) => {
1099
1143
  this.childrenFailed.emit(parentMenu);
1100
1144
  throw exception;
1101
1145
  }
1102
1146
  });
1103
1147
  }
1148
+ /**
1149
+ * Loads a parent's children and hands them back, for callers that need them before the user
1150
+ * clicks -- a sidebar rendering areas flat has to have them up front. {@link loadChildren} is
1151
+ * the fire-and-forget variant that also raises the loading and failure events.
1152
+ */
1153
+ loadChildrenFor(parentMenu) {
1154
+ return this.loadMenus(parentMenu)
1155
+ .pipe(take(1), map((menus) => menus.map((menu) => new SidebarMenu(menu))), tap((childrenMenus) => {
1156
+ parentMenu.children = childrenMenus;
1157
+ childrenMenus.forEach((childMenu) => childMenu.parent = parentMenu);
1158
+ }));
1159
+ }
1104
1160
  loadRoot() {
1105
1161
  return this.loadMenus(null)
1106
1162
  .pipe(take(1), map((menus) => menus.map((menu) => new SidebarMenu(menu))), tap((menus) => this.menus = menus));
@@ -1815,6 +1871,10 @@ class CatalogSelectComponent extends BaseComponent {
1815
1871
  if (!parent.get(name)) {
1816
1872
  parent.addControl(name, new FormControl(''));
1817
1873
  }
1874
+ // Marked whether this component created it or the screen declared it: what makes a control
1875
+ // a display control is being driven as one, not who wrote it down. A filters form reads the
1876
+ // mark to keep it out of what it submits.
1877
+ DisplayControls.markAsDisplayControl(parent.get(name));
1818
1878
  }
1819
1879
  });
1820
1880
  }
@@ -3410,7 +3470,7 @@ class SidebarComponent extends BaseComponent {
3410
3470
  .pipe(takeUntil(this.destroy$))
3411
3471
  .subscribe((_menu) => this.deactivate());
3412
3472
  this.sidebarService.loadRoot()
3413
- .pipe(take(1))
3473
+ .pipe(take(1), switchMap((menus) => this.loadAreaChildren(menus)))
3414
3474
  .subscribe({
3415
3475
  next: (menus) => {
3416
3476
  this.menus = menus;
@@ -3439,9 +3499,13 @@ class SidebarComponent extends BaseComponent {
3439
3499
  trackByRegion(_index, region) {
3440
3500
  return region.name ?? '';
3441
3501
  }
3502
+ /** Whether this top-level menu stands for an area rather than a destination of its own. */
3503
+ isArea(menu) {
3504
+ return menu.childCount > 0 && (menu.url?.length ?? 0) === 0;
3505
+ }
3442
3506
  // Group top-level menus into regions by their optional `region` label, preserving
3443
3507
  // first-appearance order. Ungrouped menus fall into a single header-less region.
3444
- groupIntoRegions(menus) {
3508
+ groupByRegionLabel(menus) {
3445
3509
  const regions = [];
3446
3510
  const byName = new Map();
3447
3511
  menus.forEach((menu) => {
@@ -3455,6 +3519,47 @@ class SidebarComponent extends BaseComponent {
3455
3519
  });
3456
3520
  return regions;
3457
3521
  }
3522
+ // Derive the regions from the tree: an area menu becomes a header and lends the group its own
3523
+ // children, so the area carries one translated label and one order instead of a string repeated
3524
+ // across every item. Anything that is not an area keeps falling into a header-less region, which
3525
+ // is created where the first such item appears so the original ordering still holds.
3526
+ groupByRootMenus(menus) {
3527
+ const regions = [];
3528
+ let ungrouped;
3529
+ menus.forEach((menu) => {
3530
+ if (this.isArea(menu)) {
3531
+ regions.push({ name: menu.label, items: menu.children });
3532
+ return;
3533
+ }
3534
+ if (!ungrouped) {
3535
+ ungrouped = { name: undefined, items: [] };
3536
+ regions.push(ungrouped);
3537
+ }
3538
+ ungrouped.items.push(menu);
3539
+ });
3540
+ return regions;
3541
+ }
3542
+ groupIntoRegions(menus) {
3543
+ return this.sidebarConfigs.shouldDeriveAreasFromRootMenus
3544
+ ? this.groupByRootMenus(menus)
3545
+ : this.groupByRegionLabel(menus);
3546
+ }
3547
+ /**
3548
+ * Fetches the children of every area up front, because they are rendered flat rather than
3549
+ * behind a click, so the lazy load a collapsible parent relies on would never be triggered.
3550
+ */
3551
+ loadAreaChildren(menus) {
3552
+ if (!this.sidebarConfigs.shouldDeriveAreasFromRootMenus) {
3553
+ return of(menus);
3554
+ }
3555
+ const areas = menus.filter((menu) => this.isArea(menu));
3556
+ // forkJoin never emits on an empty array, so a menu with no areas has to short-circuit.
3557
+ if (areas.length === 0) {
3558
+ return of(menus);
3559
+ }
3560
+ return forkJoin(areas.map((area) => this.sidebarService.loadChildrenFor(area)))
3561
+ .pipe(map(() => menus));
3562
+ }
3458
3563
  deactivate() {
3459
3564
  if (this.sidebarService.isActive) {
3460
3565
  this.sidebarService.isActive = false;
@@ -3950,5 +4055,5 @@ class DateValidators {
3950
4055
  * Generated bundle index. Do not edit.
3951
4056
  */
3952
4057
 
3953
- export { BaseComponent, BaseDataset, CatalogSelectComponent, CatalogService, DataGridComponent, DataGridConfigsProvider, DataGridDataset, DataGridRowComponent, DataProviderService, DateHelpers, DateValidators, EnumTranslatePipe, FormGroupComponent, FormGroupService, FormInputComponent, FormInputGroupComponent, FormService, GridConfigsProvider, GridDataset, GroupAccordionComponent, GroupContainerComponent, GroupScrollSpyComponent, GuidHelper, LibraryModule, ModalComponent, MultiEditorComponent, MultiEditorDataset, MultiSelectComponent, MultiSelectResultDataset, MultiSelectResultGridComponent, ReplaceManyPipe, ReplacePipe, RibbonButtonComponent, RibbonComponent, RibbonGroupChild, RibbonGroupComponent, RouterFormatter, SIDEBAR_CONFIGS, ScrollSpyDirective, SidebarComponent, SidebarConfigs, SidebarMenu, SidebarMenuOpenMode, SidebarService, toSidebarMenuOpenMode };
4058
+ export { BaseComponent, BaseDataset, CatalogSelectComponent, CatalogService, DataGridComponent, DataGridConfigsProvider, DataGridDataset, DataGridRowComponent, DataProviderService, DateHelpers, DateValidators, DisplayControls, EnumTranslatePipe, FormGroupComponent, FormGroupService, FormInputComponent, FormInputGroupComponent, FormService, GridConfigsProvider, GridDataset, GroupAccordionComponent, GroupContainerComponent, GroupScrollSpyComponent, GuidHelper, LibraryModule, ModalComponent, MultiEditorComponent, MultiEditorDataset, MultiSelectComponent, MultiSelectResultDataset, MultiSelectResultGridComponent, ReplaceManyPipe, ReplacePipe, RibbonButtonComponent, RibbonComponent, RibbonGroupChild, RibbonGroupComponent, RouterFormatter, SIDEBAR_CONFIGS, ScrollSpyDirective, SidebarComponent, SidebarConfigs, SidebarMenu, SidebarMenuOpenMode, SidebarService, toSidebarMenuOpenMode };
3954
4059
  //# sourceMappingURL=zambon-dev-library.mjs.map