@zambon-dev/library 1.4.0 → 1.5.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,43 @@ 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.5.0] - 2026-09-09
27
+
28
+ ### Added
29
+
30
+ - **`SidebarConfigs.shouldDeriveAreasFromRootMenus`** — derive region headers from the menu tree
31
+ instead of from `SidebarMenu.region`. Off by default, so nothing changes until you opt in.
32
+
33
+ With it on, a top-level menu that has children **and no URL** stops being a collapsible node and
34
+ becomes an area header, with its children rendered flat beneath it as top-level items — icons
35
+ included. A top-level menu that has its own URL stays an item, and a parent that carries a URL
36
+ stays collapsible, so only the menus that were already acting purely as groups change shape.
37
+
38
+ The point is where the grouping lives. `region` is a label repeated on every item that belongs to
39
+ a group, matched by exact string equality: a typo silently splits one area into two, and the
40
+ area has no row of its own to carry an order or a translation. Derived from the tree, the area
41
+ *is* a menu row — it already has a translated label and an order — and nothing has to be
42
+ duplicated across its items.
43
+
44
+ The children of an area are fetched **eagerly**, at load, because they are rendered without a
45
+ click and the lazy load a collapsible parent relies on would never fire. That is one extra
46
+ request per area. `region` keeps working exactly as before for anyone who prefers it; the two
47
+ mechanisms are independent and the flag chooses between them.
48
+
49
+ - **`SidebarService.loadChildrenFor(parentMenu)`** — loads a parent’s children and returns them as
50
+ an observable, for callers that need them before the user clicks. `loadChildren` is unchanged: it
51
+ is still the fire-and-forget variant that raises `childrenLoading` and `childrenFailed`, and it
52
+ now delegates to this one.
53
+
54
+ ### ⚠ Breaking Changes / Migration
55
+
56
+ None. `shouldDeriveAreasFromRootMenus` defaults to `false`, so a sidebar keeps grouping by
57
+ `region` and keeps rendering top-level parents as collapsible nodes until you set it.
58
+
59
+ ## [1.4.1] - 2026-09-08
60
+
61
+ - Maintenance release (no consumer-facing changes were documented).
62
+
26
63
  ## [1.4.0] - 2026-09-08
27
64
 
28
65
  ### Added
@@ -113,6 +150,18 @@ and want external items too, add a subscription to `menuExternalUrlSelected`.
113
150
  only the last one saved just that row, because `onSaveClick` validates the current selection only,
114
151
  so the earlier rows silently vanished on refresh. `newData()`'s values are now registered when the
115
152
  row is added.
153
+ - **`lib-catalog-select` no longer renders an empty caption after a form reset.** `FormService.resetForm()`
154
+ clears every control and then patches the model back, so the component receives the value it already
155
+ held. It skipped refreshing the caption in that case, on the assumption that an unchanged value means
156
+ the caption is still on screen — but the reset had emptied the display control a moment earlier. The
157
+ field therefore rendered blank whenever the stored value equalled the control's initial value, which
158
+ is why it only ever showed on the entry the form defaults to: a `nonNullable` control declared as
159
+ `new FormControl(0)` bound to an enum whose first member is `0`, for instance. Values differing from
160
+ the initial one refreshed normally and always looked right, which made the fault read like a
161
+ translation or data problem rather than a reset one. The caption is now rewritten whenever it has
162
+ drifted from the selected entry, so the value alone no longer decides. Selects backed by a
163
+ `searchEndpoint` are unaffected, since their caption comes from the server-side selection rather than
164
+ `entriesList`. No consumer changes are required.
116
165
 
117
166
  ### ⚠ Breaking Changes / Migration
118
167
 
@@ -200,7 +249,9 @@ config options and the `getUserProfile()` method still exist but are no longer c
200
249
  available via [GitHub Releases](https://github.com/RicardoZambon/ZLibraries/releases) and the
201
250
  `library-v*` tags.
202
251
 
203
- [Unreleased]: https://github.com/RicardoZambon/ZLibraries/compare/library-v1.4.0...HEAD
252
+ [Unreleased]: https://github.com/RicardoZambon/ZLibraries/compare/library-v1.5.0...HEAD
253
+ [1.5.0]: https://github.com/RicardoZambon/ZLibraries/releases/tag/library-v1.5.0
254
+ [1.4.1]: https://github.com/RicardoZambon/ZLibraries/releases/tag/library-v1.4.1
204
255
  [1.4.0]: https://github.com/RicardoZambon/ZLibraries/releases/tag/library-v1.4.0
205
256
  [1.3.2]: https://github.com/RicardoZambon/ZLibraries/releases/tag/library-v1.3.2
206
257
  [1.3.1]: https://github.com/RicardoZambon/ZLibraries/releases/tag/library-v1.3.1
@@ -977,6 +977,17 @@ class SidebarConfigs {
977
977
  /** Tooltip for items that open in a new browser tab. Rendered as-is, like {@link errorText}. */
978
978
  externalLinkText = 'Opens in a new browser tab';
979
979
  loadingText = 'Loading';
980
+ /**
981
+ * Derive region headers from the menu tree instead of from {@link SidebarMenu.region}.
982
+ *
983
+ * With this on, a top-level menu that has children and no URL stops being a collapsible node
984
+ * and becomes an area header, with its children rendered flat beneath it as top-level items --
985
+ * icons included. The area is then a real menu row, so it carries its own translated label and
986
+ * its own order, and no `region` label has to be repeated across every item that belongs to it.
987
+ *
988
+ * Off by default: without it a top-level parent stays the collapsible group it has always been.
989
+ */
990
+ shouldDeriveAreasFromRootMenus = false;
980
991
  logoCollapsedPath;
981
992
  logoExpandedPath;
982
993
  constructor(options = {}) {
@@ -1088,19 +1099,26 @@ class SidebarService {
1088
1099
  }
1089
1100
  loadChildren(parentMenu) {
1090
1101
  this.childrenLoading.emit(parentMenu);
1091
- this.loadMenus(parentMenu)
1092
- .pipe(take(1), map((menus) => menus.map((menu) => new SidebarMenu(menu))))
1102
+ this.loadChildrenFor(parentMenu)
1093
1103
  .subscribe({
1094
- next: (childrenMenus) => {
1095
- parentMenu.children = childrenMenus;
1096
- childrenMenus.forEach((childMenu) => childMenu.parent = parentMenu);
1097
- },
1098
1104
  error: (exception) => {
1099
1105
  this.childrenFailed.emit(parentMenu);
1100
1106
  throw exception;
1101
1107
  }
1102
1108
  });
1103
1109
  }
1110
+ /**
1111
+ * Loads a parent's children and hands them back, for callers that need them before the user
1112
+ * clicks -- a sidebar rendering areas flat has to have them up front. {@link loadChildren} is
1113
+ * the fire-and-forget variant that also raises the loading and failure events.
1114
+ */
1115
+ loadChildrenFor(parentMenu) {
1116
+ return this.loadMenus(parentMenu)
1117
+ .pipe(take(1), map((menus) => menus.map((menu) => new SidebarMenu(menu))), tap((childrenMenus) => {
1118
+ parentMenu.children = childrenMenus;
1119
+ childrenMenus.forEach((childMenu) => childMenu.parent = parentMenu);
1120
+ }));
1121
+ }
1104
1122
  loadRoot() {
1105
1123
  return this.loadMenus(null)
1106
1124
  .pipe(take(1), map((menus) => menus.map((menu) => new SidebarMenu(menu))), tap((menus) => this.menus = menus));
@@ -1682,7 +1700,7 @@ class CatalogSelectComponent extends BaseComponent {
1682
1700
  .pipe(takeUntil(this.destroy$))
1683
1701
  .subscribe((value) => {
1684
1702
  this.syncFormControlsEnabledDisabled();
1685
- if (this.selectedValue === value) {
1703
+ if (!this.shouldRefreshDisplay(value)) {
1686
1704
  return;
1687
1705
  }
1688
1706
  this.selectedValue = value;
@@ -1692,8 +1710,7 @@ class CatalogSelectComponent extends BaseComponent {
1692
1710
  }
1693
1711
  }
1694
1712
  else {
1695
- const display = this.entriesDataSource.filter((entry) => entry.value === value)[0]?.display ?? '';
1696
- this.displayControl?.setValue(display, { emitEvent: false });
1713
+ this.displayControl?.setValue(this.resolveDisplay(value), { emitEvent: false });
1697
1714
  }
1698
1715
  });
1699
1716
  // Initial sync: if the form control already has a value (e.g., the model was loaded
@@ -1972,17 +1989,37 @@ class CatalogSelectComponent extends BaseComponent {
1972
1989
  this.showNoResultsMessage = false;
1973
1990
  this.showMinimumCharactersMessage = false;
1974
1991
  }
1992
+ resolveDisplay(value) {
1993
+ return this.entriesDataSource.filter((entry) => entry.value === value)[0]?.display ?? '';
1994
+ }
1975
1995
  syncDisplayFromCurrentValue() {
1976
1996
  const currentValue = this.formControl?.value;
1977
- if (currentValue == null || this.selectedValue === currentValue) {
1997
+ if (currentValue == null || !this.shouldRefreshDisplay(currentValue)) {
1978
1998
  return;
1979
1999
  }
1980
2000
  this.selectedValue = currentValue;
1981
2001
  if (!this.hasSearchEndpoint) {
1982
- const display = this.entriesDataSource.filter((entry) => entry.value === currentValue)[0]?.display ?? '';
1983
- this.displayControl?.setValue(display, { emitEvent: false });
2002
+ this.displayControl?.setValue(this.resolveDisplay(currentValue), { emitEvent: false });
1984
2003
  }
1985
2004
  }
2005
+ /**
2006
+ * Decides whether the display control has to be written again.
2007
+ *
2008
+ * The value on its own is not enough. Resetting a form clears the display control and then
2009
+ * patches the same value back, so an unchanged value can still leave the display empty. That
2010
+ * only happens when the stored value equals the control's initial value, which is why it used
2011
+ * to show up on the default entry alone.
2012
+ */
2013
+ shouldRefreshDisplay(value) {
2014
+ if (this.selectedValue !== value) {
2015
+ return true;
2016
+ }
2017
+ if (this.hasSearchEndpoint) {
2018
+ return false;
2019
+ }
2020
+ const display = this.resolveDisplay(value);
2021
+ return display.length > 0 && this.displayControl?.value !== display;
2022
+ }
1986
2023
  syncFormControlsEnabledDisabled() {
1987
2024
  if (this.formControl.enabled !== this.displayControl.enabled) {
1988
2025
  if (this.formControl.disabled) {
@@ -3391,7 +3428,7 @@ class SidebarComponent extends BaseComponent {
3391
3428
  .pipe(takeUntil(this.destroy$))
3392
3429
  .subscribe((_menu) => this.deactivate());
3393
3430
  this.sidebarService.loadRoot()
3394
- .pipe(take(1))
3431
+ .pipe(take(1), switchMap((menus) => this.loadAreaChildren(menus)))
3395
3432
  .subscribe({
3396
3433
  next: (menus) => {
3397
3434
  this.menus = menus;
@@ -3420,9 +3457,13 @@ class SidebarComponent extends BaseComponent {
3420
3457
  trackByRegion(_index, region) {
3421
3458
  return region.name ?? '';
3422
3459
  }
3460
+ /** Whether this top-level menu stands for an area rather than a destination of its own. */
3461
+ isArea(menu) {
3462
+ return menu.childCount > 0 && (menu.url?.length ?? 0) === 0;
3463
+ }
3423
3464
  // Group top-level menus into regions by their optional `region` label, preserving
3424
3465
  // first-appearance order. Ungrouped menus fall into a single header-less region.
3425
- groupIntoRegions(menus) {
3466
+ groupByRegionLabel(menus) {
3426
3467
  const regions = [];
3427
3468
  const byName = new Map();
3428
3469
  menus.forEach((menu) => {
@@ -3436,6 +3477,47 @@ class SidebarComponent extends BaseComponent {
3436
3477
  });
3437
3478
  return regions;
3438
3479
  }
3480
+ // Derive the regions from the tree: an area menu becomes a header and lends the group its own
3481
+ // children, so the area carries one translated label and one order instead of a string repeated
3482
+ // across every item. Anything that is not an area keeps falling into a header-less region, which
3483
+ // is created where the first such item appears so the original ordering still holds.
3484
+ groupByRootMenus(menus) {
3485
+ const regions = [];
3486
+ let ungrouped;
3487
+ menus.forEach((menu) => {
3488
+ if (this.isArea(menu)) {
3489
+ regions.push({ name: menu.label, items: menu.children });
3490
+ return;
3491
+ }
3492
+ if (!ungrouped) {
3493
+ ungrouped = { name: undefined, items: [] };
3494
+ regions.push(ungrouped);
3495
+ }
3496
+ ungrouped.items.push(menu);
3497
+ });
3498
+ return regions;
3499
+ }
3500
+ groupIntoRegions(menus) {
3501
+ return this.sidebarConfigs.shouldDeriveAreasFromRootMenus
3502
+ ? this.groupByRootMenus(menus)
3503
+ : this.groupByRegionLabel(menus);
3504
+ }
3505
+ /**
3506
+ * Fetches the children of every area up front, because they are rendered flat rather than
3507
+ * behind a click, so the lazy load a collapsible parent relies on would never be triggered.
3508
+ */
3509
+ loadAreaChildren(menus) {
3510
+ if (!this.sidebarConfigs.shouldDeriveAreasFromRootMenus) {
3511
+ return of(menus);
3512
+ }
3513
+ const areas = menus.filter((menu) => this.isArea(menu));
3514
+ // forkJoin never emits on an empty array, so a menu with no areas has to short-circuit.
3515
+ if (areas.length === 0) {
3516
+ return of(menus);
3517
+ }
3518
+ return forkJoin(areas.map((area) => this.sidebarService.loadChildrenFor(area)))
3519
+ .pipe(map(() => menus));
3520
+ }
3439
3521
  deactivate() {
3440
3522
  if (this.sidebarService.isActive) {
3441
3523
  this.sidebarService.isActive = false;