@zambon-dev/library 1.4.1 → 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,39 @@ 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
+
26
59
  ## [1.4.1] - 2026-09-08
27
60
 
28
61
  - Maintenance release (no consumer-facing changes were documented).
@@ -216,7 +249,8 @@ config options and the `getUserProfile()` method still exist but are no longer c
216
249
  available via [GitHub Releases](https://github.com/RicardoZambon/ZLibraries/releases) and the
217
250
  `library-v*` tags.
218
251
 
219
- [Unreleased]: https://github.com/RicardoZambon/ZLibraries/compare/library-v1.4.1...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
220
254
  [1.4.1]: https://github.com/RicardoZambon/ZLibraries/releases/tag/library-v1.4.1
221
255
  [1.4.0]: https://github.com/RicardoZambon/ZLibraries/releases/tag/library-v1.4.0
222
256
  [1.3.2]: https://github.com/RicardoZambon/ZLibraries/releases/tag/library-v1.3.2
@@ -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));
@@ -3410,7 +3428,7 @@ class SidebarComponent extends BaseComponent {
3410
3428
  .pipe(takeUntil(this.destroy$))
3411
3429
  .subscribe((_menu) => this.deactivate());
3412
3430
  this.sidebarService.loadRoot()
3413
- .pipe(take(1))
3431
+ .pipe(take(1), switchMap((menus) => this.loadAreaChildren(menus)))
3414
3432
  .subscribe({
3415
3433
  next: (menus) => {
3416
3434
  this.menus = menus;
@@ -3439,9 +3457,13 @@ class SidebarComponent extends BaseComponent {
3439
3457
  trackByRegion(_index, region) {
3440
3458
  return region.name ?? '';
3441
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
+ }
3442
3464
  // Group top-level menus into regions by their optional `region` label, preserving
3443
3465
  // first-appearance order. Ungrouped menus fall into a single header-less region.
3444
- groupIntoRegions(menus) {
3466
+ groupByRegionLabel(menus) {
3445
3467
  const regions = [];
3446
3468
  const byName = new Map();
3447
3469
  menus.forEach((menu) => {
@@ -3455,6 +3477,47 @@ class SidebarComponent extends BaseComponent {
3455
3477
  });
3456
3478
  return regions;
3457
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
+ }
3458
3521
  deactivate() {
3459
3522
  if (this.sidebarService.isActive) {
3460
3523
  this.sidebarService.isActive = false;