@praxisui/tabs 9.0.56 → 9.0.58

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/README.md CHANGED
@@ -73,6 +73,16 @@ export class TabsHostComponent {
73
73
 
74
74
  Controlled `selectedIndex` updates the active tab without re-emitting `selectedIndexChange`, which prevents loops in dynamic-page state projections.
75
75
 
76
+ Nested widgets declared in `tabs[].widgets` or `nav.links[].widgets` expose their
77
+ registered outputs automatically through the contextual `widgetEvent` envelope.
78
+ Do not repeat component outputs in each nested widget definition only to make a
79
+ `UiCompositionPlan` link work. The envelope preserves the container/tab path and
80
+ `childWidgetKey`, allowing Dynamic Page to resolve canonical `nestedPath` links.
81
+
82
+ When Dynamic Page reprojects a config object solely to deliver a nested input,
83
+ Tabs preserves the active tab by stable tab/link id. An explicitly authored
84
+ `group.selectedIndex` or `nav.selectedIndex` change remains authoritative.
85
+
76
86
  For navigation where the host renders the active content externally, set `config.group.renderBody = false` in group mode or `config.nav.renderBody = false` in nav mode. The component still owns the Material tab header/link header, selected index, keyboard navigation and selection ARIA, but it does not render internal tab/nav bodies or empty states. Hosts should render the external active panel next to the tabs and give that panel a stable accessible label that matches the active section.
77
87
 
78
88
  ## Metadata Shape
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": "1.0.0",
3
- "generatedAt": "2026-09-01T07:07:50.474Z",
3
+ "generatedAt": "2026-09-03T03:04:37.933Z",
4
4
  "packageName": "@praxisui/tabs",
5
- "packageVersion": "9.0.56",
5
+ "packageVersion": "9.0.58",
6
6
  "sourceRegistry": "praxis-component-registry-ingestion",
7
7
  "sourceRegistryVersion": "1.0.0",
8
8
  "componentCount": 1,
@@ -1,10 +1,10 @@
1
1
  import * as i0 from '@angular/core';
2
- import { inject, Input, Inject, Component, ChangeDetectorRef, effect, EventEmitter, signal, Output, ChangeDetectionStrategy, ViewChild, ENVIRONMENT_INITIALIZER } from '@angular/core';
2
+ import { inject, Input, Inject, Component, ChangeDetectorRef, effect, EventEmitter, signal, ViewChild, Output, ChangeDetectionStrategy, ENVIRONMENT_INITIALIZER } from '@angular/core';
3
3
  import { ActivatedRoute } from '@angular/router';
4
4
  import * as i1$1 from '@angular/common';
5
5
  import { CommonModule } from '@angular/common';
6
6
  import * as i3 from '@angular/material/tabs';
7
- import { MatTabsModule } from '@angular/material/tabs';
7
+ import { MatTabsModule, MatTabGroup } from '@angular/material/tabs';
8
8
  import * as i4$1 from '@angular/material/icon';
9
9
  import { MatIconModule } from '@angular/material/icon';
10
10
  import * as i10 from '@angular/material/tooltip';
@@ -3182,6 +3182,8 @@ class PraxisTabs {
3182
3182
  groupLoaded = new Set();
3183
3183
  navLoaded = new Set();
3184
3184
  controlledSelectedIndex;
3185
+ destroyed = false;
3186
+ groupComponent;
3185
3187
  destroy$ = new Subject();
3186
3188
  widgetDefinitionCache = new WeakMap();
3187
3189
  generatedContentForm = new FormGroup({});
@@ -3200,8 +3202,10 @@ class PraxisTabs {
3200
3202
  // previous inputs in that case (for example, a newly selected parent
3201
3203
  // identity projected into a related-resource outlet).
3202
3204
  this.widgetDefinitionCache = new WeakMap();
3203
- // Reset loaded caches on config change and seed with current selections
3204
- this.syncSelectionFromConfig();
3205
+ // Runtime composition can replace the config object only to project a
3206
+ // nested child input. Preserve the user's active tab when the incoming
3207
+ // document did not author a different selectedIndex explicitly.
3208
+ this.syncSelectionFromConfigChange(changes['config']);
3205
3209
  this.syncGeneratedContentForm();
3206
3210
  // Persist when tabsId provided and the instance is storage-owned.
3207
3211
  this.persistConfig(this.config);
@@ -3209,6 +3213,7 @@ class PraxisTabs {
3209
3213
  }
3210
3214
  }
3211
3215
  ngOnDestroy() {
3216
+ this.destroyed = true;
3212
3217
  this.assistantSessions.removeContextSession(this.buildAiAssistantContextSnapshot().identity);
3213
3218
  this.aiAssistantStateSubscription?.unsubscribe();
3214
3219
  this.destroy$.next();
@@ -3253,8 +3258,45 @@ class PraxisTabs {
3253
3258
  const entry = this.visibleTabEntries()[index];
3254
3259
  if (!entry)
3255
3260
  return;
3261
+ if (entry.index === this.selectedIndexSignal())
3262
+ return;
3256
3263
  this.onSelectedIndexChange(entry.index);
3257
3264
  }
3265
+ /**
3266
+ * Material commits a pointer-selected tab during its next content-check cycle.
3267
+ * A Tabs instance hosted by DynamicWidgetLoader can receive a composition
3268
+ * reprojection in that same cycle, leaving Material's pending index uncommitted.
3269
+ * Resolve the semantic tab identity at the container boundary so pointer
3270
+ * selection remains deterministic without changing keyboard focus semantics.
3271
+ */
3272
+ onGroupPointerClick(event) {
3273
+ const tabElement = event.composedPath().find((candidate) => (candidate instanceof HTMLElement && candidate.getAttribute('role') === 'tab'));
3274
+ if (!tabElement || tabElement.getAttribute('aria-disabled') === 'true')
3275
+ return;
3276
+ const entries = this.visibleTabEntries();
3277
+ const tabId = tabElement.id;
3278
+ const byId = tabId
3279
+ ? entries.findIndex((entry) => entry.tab.id === tabId)
3280
+ : -1;
3281
+ const ariaPosition = Number(tabElement.getAttribute('aria-posinset')) - 1;
3282
+ const visibleIndex = byId >= 0
3283
+ ? byId
3284
+ : Number.isInteger(ariaPosition) ? ariaPosition : -1;
3285
+ if (visibleIndex < 0 || visibleIndex >= entries.length)
3286
+ return;
3287
+ this.onVisibleTabIndexChange(visibleIndex);
3288
+ // Dynamic widget hosts can reconcile inputs outside the regular parent
3289
+ // change-detection traversal. Material records its pending selection in
3290
+ // the target click handler, so finish the local check after bubbling ends.
3291
+ queueMicrotask(() => {
3292
+ if (!this.destroyed) {
3293
+ // Commit Material's delayed selection before rendering nested content;
3294
+ // a child widget may fail independently while loading remote metadata.
3295
+ this.groupComponent?.ngAfterContentChecked();
3296
+ this.cdr.detectChanges();
3297
+ }
3298
+ });
3299
+ }
3258
3300
  onNavClick(i) {
3259
3301
  if (!this.config?.nav?.links?.length)
3260
3302
  return;
@@ -3893,6 +3935,41 @@ class PraxisTabs {
3893
3935
  if (linksLength > 0)
3894
3936
  this.navLoaded.add(navIndex);
3895
3937
  }
3938
+ syncSelectionFromConfigChange(change) {
3939
+ if (change.firstChange) {
3940
+ this.syncSelectionFromConfig();
3941
+ return;
3942
+ }
3943
+ const previous = change.previousValue;
3944
+ const current = change.currentValue;
3945
+ const previousGroupIndex = this.selectedIndexSignal();
3946
+ const previousNavIndex = this.currentNavIndex();
3947
+ const previousTabId = previous?.tabs?.[previousGroupIndex]?.id;
3948
+ const previousNavId = previous?.nav?.links?.[previousNavIndex]?.id;
3949
+ const groupSelectionAuthored = previous?.group?.selectedIndex !== current?.group?.selectedIndex;
3950
+ const navSelectionAuthored = previous?.nav?.selectedIndex !== current?.nav?.selectedIndex;
3951
+ this.syncSelectionFromConfig();
3952
+ if (!groupSelectionAuthored && (current?.tabs?.length ?? 0) > 0) {
3953
+ const preservedGroupIndex = previousTabId
3954
+ ? current.tabs.findIndex((tab) => tab.id === previousTabId)
3955
+ : this.clampIndex(previousGroupIndex, current.tabs.length);
3956
+ const nextGroupIndex = preservedGroupIndex >= 0
3957
+ ? preservedGroupIndex
3958
+ : this.clampIndex(previousGroupIndex, current.tabs.length);
3959
+ this.selectedIndexSignal.set(nextGroupIndex);
3960
+ this.groupLoaded.add(nextGroupIndex);
3961
+ }
3962
+ if (!navSelectionAuthored && (current?.nav?.links?.length ?? 0) > 0) {
3963
+ const preservedNavIndex = previousNavId
3964
+ ? current.nav.links.findIndex((link) => link.id === previousNavId)
3965
+ : this.clampIndex(previousNavIndex, current.nav.links.length);
3966
+ const nextNavIndex = preservedNavIndex >= 0
3967
+ ? preservedNavIndex
3968
+ : this.clampIndex(previousNavIndex, current.nav.links.length);
3969
+ this.currentNavIndex.set(nextNavIndex);
3970
+ this.navLoaded.add(nextNavIndex);
3971
+ }
3972
+ }
3896
3973
  pruneLoadedIndexes(indexes, size) {
3897
3974
  for (const index of Array.from(indexes)) {
3898
3975
  if (index < 0 || index >= size) {
@@ -4237,7 +4314,7 @@ class PraxisTabs {
4237
4314
  return JSON.parse(JSON.stringify(config));
4238
4315
  }
4239
4316
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: PraxisTabs, deps: [], target: i0.ɵɵFactoryTarget.Component });
4240
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.14", type: PraxisTabs, isStandalone: true, selector: "praxis-tabs", inputs: { config: "config", tabsId: "tabsId", componentInstanceId: "componentInstanceId", configPersistenceStrategy: "configPersistenceStrategy", selectedIndex: "selectedIndex", enableCustomization: "enableCustomization", form: "form", context: "context" }, outputs: { animationDone: "animationDone", focusChange: "focusChange", selectedIndexChange: "selectedIndexChange", selectedTabChange: "selectedTabChange", indexFocused: "indexFocused", selectFocusedIndex: "selectFocusedIndex", configChange: "configChange", widgetEvent: "widgetEvent" }, providers: [providePraxisI18nConfig(PRAXIS_TABS_I18N_CONFIG)], usesOnChanges: true, ngImport: i0, template: `
4317
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.14", type: PraxisTabs, isStandalone: true, selector: "praxis-tabs", inputs: { config: "config", tabsId: "tabsId", componentInstanceId: "componentInstanceId", configPersistenceStrategy: "configPersistenceStrategy", selectedIndex: "selectedIndex", enableCustomization: "enableCustomization", form: "form", context: "context" }, outputs: { animationDone: "animationDone", focusChange: "focusChange", selectedIndexChange: "selectedIndexChange", selectedTabChange: "selectedTabChange", indexFocused: "indexFocused", selectFocusedIndex: "selectFocusedIndex", configChange: "configChange", widgetEvent: "widgetEvent" }, providers: [providePraxisI18nConfig(PRAXIS_TABS_I18N_CONFIG)], viewQueries: [{ propertyName: "groupComponent", first: true, predicate: MatTabGroup, descendants: true }], usesOnChanges: true, ngImport: i0, template: `
4241
4318
  <div
4242
4319
  class="praxis-tabs-root"
4243
4320
  [class.density-compact]="config?.appearance?.density === 'compact'"
@@ -4387,6 +4464,7 @@ class PraxisTabs {
4387
4464
  <ng-container
4388
4465
  [dynamicWidgetLoader]="resolveWidgetDefinition(w)"
4389
4466
  [context]="context || {}"
4467
+ [autoWireOutputs]="true"
4390
4468
  (widgetEvent)="emitWidgetEvent(linkEventPath(l.id, currentNavIndex()), $event)"
4391
4469
  ></ng-container>
4392
4470
  </div>
@@ -4427,6 +4505,7 @@ class PraxisTabs {
4427
4505
  (focusChange)="focusChange.emit($event)"
4428
4506
  (selectedIndexChange)="onVisibleTabIndexChange($event)"
4429
4507
  (selectedTabChange)="selectedTabChange.emit($event)"
4508
+ (click)="onGroupPointerClick($event)"
4430
4509
  class="praxis-tabs-group"
4431
4510
  >
4432
4511
  @for (entry of visibleTabEntries(); track trackVisibleTab(i, entry); let i = $index) {
@@ -4493,6 +4572,7 @@ class PraxisTabs {
4493
4572
  <ng-container
4494
4573
  [dynamicWidgetLoader]="resolveWidgetDefinition(w)"
4495
4574
  [context]="context || {}"
4575
+ [autoWireOutputs]="true"
4496
4576
  (widgetEvent)="emitWidgetEvent(tabEventPath(entry.tab.id, entry.index), $event)"
4497
4577
  ></ng-container>
4498
4578
  </div>
@@ -4708,6 +4788,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
4708
4788
  <ng-container
4709
4789
  [dynamicWidgetLoader]="resolveWidgetDefinition(w)"
4710
4790
  [context]="context || {}"
4791
+ [autoWireOutputs]="true"
4711
4792
  (widgetEvent)="emitWidgetEvent(linkEventPath(l.id, currentNavIndex()), $event)"
4712
4793
  ></ng-container>
4713
4794
  </div>
@@ -4748,6 +4829,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
4748
4829
  (focusChange)="focusChange.emit($event)"
4749
4830
  (selectedIndexChange)="onVisibleTabIndexChange($event)"
4750
4831
  (selectedTabChange)="selectedTabChange.emit($event)"
4832
+ (click)="onGroupPointerClick($event)"
4751
4833
  class="praxis-tabs-group"
4752
4834
  >
4753
4835
  @for (entry of visibleTabEntries(); track trackVisibleTab(i, entry); let i = $index) {
@@ -4814,6 +4896,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
4814
4896
  <ng-container
4815
4897
  [dynamicWidgetLoader]="resolveWidgetDefinition(w)"
4816
4898
  [context]="context || {}"
4899
+ [autoWireOutputs]="true"
4817
4900
  (widgetEvent)="emitWidgetEvent(tabEventPath(entry.tab.id, entry.index), $event)"
4818
4901
  ></ng-container>
4819
4902
  </div>
@@ -4895,6 +4978,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
4895
4978
  type: Output
4896
4979
  }], widgetEvent: [{
4897
4980
  type: Output
4981
+ }], groupComponent: [{
4982
+ type: ViewChild,
4983
+ args: [MatTabGroup]
4898
4984
  }] } });
4899
4985
 
4900
4986
  class PraxisTabsWidgetConfigEditor {
package/package.json CHANGED
@@ -1,18 +1,18 @@
1
1
  {
2
2
  "name": "@praxisui/tabs",
3
- "version": "9.0.56",
3
+ "version": "9.0.58",
4
4
  "description": "Configurable tabs (group and nav) for Praxis UI with metadata-driven content and runtime editor.",
5
5
  "peerDependencies": {
6
6
  "@angular/common": "^21.0.0",
7
7
  "@angular/core": "^21.0.0",
8
8
  "@angular/material": "^21.0.0",
9
9
  "@angular/cdk": "^21.0.0",
10
- "@praxisui/core": "^9.0.56",
11
- "@praxisui/dynamic-fields": "^9.0.56",
12
- "@praxisui/settings-panel": "^9.0.56",
10
+ "@praxisui/core": "^9.0.58",
11
+ "@praxisui/dynamic-fields": "^9.0.58",
12
+ "@praxisui/settings-panel": "^9.0.58",
13
13
  "@angular/forms": "^21.0.0",
14
14
  "@angular/router": "^21.0.0",
15
- "@praxisui/ai": "^9.0.56",
15
+ "@praxisui/ai": "^9.0.58",
16
16
  "rxjs": "~7.8.0"
17
17
  },
18
18
  "dependencies": {
@@ -206,6 +206,8 @@ declare class PraxisTabs implements OnInit, OnChanges, OnDestroy {
206
206
  private groupLoaded;
207
207
  private navLoaded;
208
208
  private controlledSelectedIndex?;
209
+ private destroyed;
210
+ private groupComponent?;
209
211
  private readonly destroy$;
210
212
  private widgetDefinitionCache;
211
213
  private readonly generatedContentForm;
@@ -227,6 +229,14 @@ declare class PraxisTabs implements OnInit, OnChanges, OnDestroy {
227
229
  protected selectedVisibleTabIndex(): number;
228
230
  protected effectiveContentForm(): FormGroup;
229
231
  protected onVisibleTabIndexChange(index: number): void;
232
+ /**
233
+ * Material commits a pointer-selected tab during its next content-check cycle.
234
+ * A Tabs instance hosted by DynamicWidgetLoader can receive a composition
235
+ * reprojection in that same cycle, leaving Material's pending index uncommitted.
236
+ * Resolve the semantic tab identity at the container boundary so pointer
237
+ * selection remains deterministic without changing keyboard focus semantics.
238
+ */
239
+ protected onGroupPointerClick(event: MouseEvent): void;
230
240
  onNavClick(i: number): void;
231
241
  onNavDrop(event: CdkDragDrop<any>): void;
232
242
  protected onVisibleNavDrop(event: CdkDragDrop<any>): void;
@@ -271,6 +281,7 @@ declare class PraxisTabs implements OnInit, OnChanges, OnDestroy {
271
281
  private storageKey;
272
282
  private loadStoredConfigForCurrentIdentity;
273
283
  private syncSelectionFromConfig;
284
+ private syncSelectionFromConfigChange;
274
285
  private pruneLoadedIndexes;
275
286
  private persistConfig;
276
287
  private usesInputFirstConfig;