AAE-44098 Bottom tab navigation buttons (#11834)

* feat: enable tab navigation buttons

* feat: better strings and button positioning

* feat: reduce overall padding

* feat: adjust buttons based on ux mockups

* test: add some tests

* fix: build error

* fix: rename property to showBottomTabNavButtons for clarity

* feat: simplify changes

* test: copilot suggestion

* fix: prevent upstream visual regression issues.

* fix: copilot suggestions

* fix: height change
This commit is contained in:
Joshua Cain
2026-04-29 15:12:06 -04:00
committed by GitHub
parent a394222c54
commit d1d06a68bc
7 changed files with 670 additions and 118 deletions
@@ -86,6 +86,30 @@ const expectElementToBeValid = (testingUtils: UnitTestingUtils, fieldId: string)
expect(invalidElementContainer).toBeFalsy();
};
const buildTabbedForm = (tabCount: number, hiddenTabIndices: number[] = []): FormModel => {
const tabs = Array.from({ length: tabCount }, (_, i) => ({
id: `tab-${i}`,
title: `Tab ${i}`
}));
const fields = tabs.map((tab) => ({
id: `container-${tab.id}`,
type: 'container',
tab: tab.id,
numberOfColumns: 1,
fields: { 1: [{ id: `text-${tab.id}`, type: 'text', name: `Text in ${tab.title}` }] }
}));
const form = new FormModel({ tabs, fields });
hiddenTabIndices.forEach((tabIndex) => {
if (form.tabs[tabIndex]) {
form.tabs[tabIndex].isVisible = false;
}
});
return form;
};
describe('Form Renderer Component', () => {
let formRendererComponent: FormRendererComponent<any>;
let fixture: ComponentFixture<FormRendererComponent<any>>;
@@ -909,6 +933,117 @@ describe('Form Renderer Component', () => {
});
});
describe('Tab navigation', () => {
describe('visibleTabs', () => {
it('should return only tabs where isVisible is true', () => {
formRendererComponent.formDefinition = buildTabbedForm(3, [1]);
expect(formRendererComponent.visibleTabs().length).toBe(2);
expect(formRendererComponent.visibleTabs().every((t) => t.isVisible)).toBeTrue();
});
it('should return all tabs when none are hidden', () => {
formRendererComponent.formDefinition = buildTabbedForm(3);
expect(formRendererComponent.visibleTabs().length).toBe(3);
});
it('should return empty array when all tabs are hidden', () => {
formRendererComponent.formDefinition = buildTabbedForm(3, [0, 1, 2]);
expect(formRendererComponent.visibleTabs().length).toBe(0);
});
});
describe('canNavigateNext and canNavigatePrevious', () => {
beforeEach(() => {
fixture.componentRef.setInput('formDefinition', buildTabbedForm(3));
fixture.detectChanges();
});
it('should not allow navigating previous on the first tab', () => {
expect(formRendererComponent.canNavigatePrevious).toBeFalse();
expect(formRendererComponent.canNavigateNext).toBeTrue();
});
it('should allow navigating previous and next between the first and last tabs', async () => {
formRendererComponent.navigateToNextTab();
fixture.detectChanges();
await fixture.whenStable();
expect(formRendererComponent.canNavigatePrevious).toBeTrue();
expect(formRendererComponent.canNavigateNext).toBeTrue();
});
it('should not allow navigating next on the last tab', async () => {
formRendererComponent.navigateToNextTab();
fixture.detectChanges();
await fixture.whenStable();
formRendererComponent.tabGroup.selectedIndexChange.emit(1);
fixture.detectChanges();
formRendererComponent.navigateToNextTab();
fixture.detectChanges();
await fixture.whenStable();
formRendererComponent.tabGroup.selectedIndexChange.emit(2);
fixture.detectChanges();
expect(formRendererComponent.canNavigatePrevious).toBeTrue();
expect(formRendererComponent.canNavigateNext).toBeFalse();
});
});
describe('navigateToNextTab and navigateToPreviousTab', () => {
beforeEach(() => {
fixture.componentRef.setInput('formDefinition', buildTabbedForm(3));
fixture.detectChanges();
expect(formRendererComponent.tabGroup).toBeDefined();
});
it('should increment selectedIndex when navigating to next tab', async () => {
const initialIndex = formRendererComponent.tabGroup.selectedIndex;
formRendererComponent.navigateToNextTab();
fixture.detectChanges();
await fixture.whenStable();
expect(formRendererComponent.tabGroup.selectedIndex).toBe(initialIndex + 1);
});
it('should decrement selectedIndex when navigating to previous tab', async () => {
formRendererComponent.navigateToNextTab();
fixture.detectChanges();
await fixture.whenStable();
const indexAfterNext = formRendererComponent.tabGroup.selectedIndex;
formRendererComponent.navigateToPreviousTab();
fixture.detectChanges();
await fixture.whenStable();
expect(formRendererComponent.tabGroup.selectedIndex).toBe(indexAfterNext - 1);
});
it('should not go below 0 when navigating previous on the first tab', async () => {
formRendererComponent.navigateToPreviousTab();
fixture.detectChanges();
await fixture.whenStable();
expect(formRendererComponent.tabGroup.selectedIndex).toBe(0);
});
it('should not exceed last index when navigating next on the last tab', async () => {
formRendererComponent.navigateToNextTab();
fixture.detectChanges();
await fixture.whenStable();
formRendererComponent.tabGroup.selectedIndexChange.emit(1);
fixture.detectChanges();
formRendererComponent.navigateToNextTab();
fixture.detectChanges();
await fixture.whenStable();
formRendererComponent.tabGroup.selectedIndexChange.emit(2);
fixture.detectChanges();
const lastIndex = formRendererComponent.tabGroup.selectedIndex;
formRendererComponent.navigateToNextTab();
fixture.detectChanges();
await fixture.whenStable();
expect(formRendererComponent.tabGroup.selectedIndex).toBe(lastIndex);
});
});
});
describe('Repeatable section', () => {
const repeatableSectionField = new FormFieldModel(new FormModel(), {
id: 'RepeatableSection0tbw2y',
@@ -16,12 +16,24 @@
*/
import { NgClass, NgStyle, NgTemplateOutlet } from '@angular/common';
import { ChangeDetectorRef, Component, DestroyRef, inject, Injector, Input, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core';
import {
ChangeDetectorRef,
Component,
DestroyRef,
inject,
Injector,
Input,
OnDestroy,
OnInit,
signal,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { filter } from 'rxjs';
import { filter, Subscription } from 'rxjs';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatTabsModule } from '@angular/material/tabs';
import { MatTabGroup, MatTabsModule } from '@angular/material/tabs';
import { TranslatePipe } from '@ngx-translate/core';
import { FormRulesManager, formRulesManagerFactory } from '../models/form-rules.model';
import { FormService } from '../services/form.service';
@@ -82,11 +94,54 @@ export class FormRendererComponent<T> implements OnInit, OnDestroy {
private readonly destroyRef = inject(DestroyRef);
@Input({ required: true })
formDefinition: FormModel;
set formDefinition(formDefinition: FormModel) {
this._formDefinition = formDefinition;
this.syncCurrentTabIndex();
}
get formDefinition(): FormModel {
return this._formDefinition;
}
@Input()
readOnly = false;
@ViewChild(MatTabGroup)
set tabGroup(tabGroup: MatTabGroup | undefined) {
this.tabGroupSelectionSubscription?.unsubscribe();
this._tabGroup = tabGroup;
if (tabGroup) {
this.syncCurrentTabIndex(tabGroup.selectedIndex);
this.tabGroupSelectionSubscription = tabGroup.selectedIndexChange.subscribe((index) => this.syncCurrentTabIndex(index));
}
}
get tabGroup(): MatTabGroup {
return this._tabGroup as MatTabGroup;
}
private readonly currentTabIndex = signal(0);
private _formDefinition: FormModel;
private _tabGroup?: MatTabGroup;
private tabGroupSelectionSubscription?: Subscription;
get canNavigateNext(): boolean {
return this.currentTabIndex() < this.visibleTabCount - 1;
}
get canNavigatePrevious(): boolean {
return this.currentTabIndex() > 0;
}
get visibleTabCount(): number {
return this.visibleTabs().length;
}
get selectedTabIndex(): number {
return this.currentTabIndex();
}
debugMode: boolean;
fields: FormFieldModel[];
@@ -106,15 +161,37 @@ export class FormRendererComponent<T> implements OnInit, OnDestroy {
}
ngOnDestroy() {
this.tabGroupSelectionSubscription?.unsubscribe();
this.formRulesManager.destroy();
}
hasTabs(): boolean {
return this.formDefinition.tabs && this.formDefinition.tabs.length > 0;
return this.formDefinition?.tabs && this.formDefinition.tabs.length > 0;
}
visibleTabs(): TabModel[] {
return this.formDefinition.tabs.filter((tab) => tab.isVisible);
return this.formDefinition?.tabs?.filter((tab) => tab.isVisible) ?? [];
}
navigateToNextTab(): void {
if (this.tabGroup && this.canNavigateNext) {
this.tabGroup.selectedIndex = (this.tabGroup.selectedIndex ?? 0) + 1;
this.syncCurrentTabIndex(this.tabGroup.selectedIndex);
}
}
navigateToPreviousTab(): void {
if (this.tabGroup && this.canNavigatePrevious) {
this.tabGroup.selectedIndex = (this.tabGroup.selectedIndex ?? 0) - 1;
this.syncCurrentTabIndex(this.tabGroup.selectedIndex);
}
}
private syncCurrentTabIndex(index = this.tabGroup?.selectedIndex): void {
const maxTabIndex = Math.max(this.visibleTabCount - 1, 0);
const currentIndex = index ?? 0;
this.currentTabIndex.set(Math.min(Math.max(currentIndex, 0), maxTabIndex));
}
getNumberOfColumns(content: ContainerModel): number {
+6
View File
@@ -87,6 +87,12 @@
"NO_LABEL": "Cancel"
}
},
"BUTTON": {
"PREVIOUS_TAB": "Previous",
"PREVIOUS_TAB_TITLE": "Navigate to previous tab",
"NEXT_TAB": "Next",
"NEXT_TAB_TITLE": "Navigate to next tab"
},
"FIELD_STYLE": {
"FONT_SIZE": "Font size",
"FONT_WEIGHT": "Font weight",