@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 +52 -1
- package/fesm2022/zambon-dev-library.mjs +96 -14
- package/fesm2022/zambon-dev-library.mjs.map +1 -1
- package/lib/components/catalog-select/catalog-select.component.d.ts +10 -0
- package/lib/components/sidebar/sidebar.component.d.ts +9 -0
- package/lib/models/sidebar/sidebar-configs.d.ts +11 -0
- package/lib/services/sidebar.service.d.ts +6 -0
- package/package.json +1 -1
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
1997
|
+
if (currentValue == null || !this.shouldRefreshDisplay(currentValue)) {
|
|
1978
1998
|
return;
|
|
1979
1999
|
}
|
|
1980
2000
|
this.selectedValue = currentValue;
|
|
1981
2001
|
if (!this.hasSearchEndpoint) {
|
|
1982
|
-
|
|
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
|
-
|
|
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;
|