Merged PR 1881: Stateful Remi Button

#5203

Related work items: #5203
This commit is contained in:
Lorenz Hilpert
2025-07-14 11:57:03 +00:00
committed by Nino Righi
parent 5f74c6ddf8
commit 40c9d51dfc
24 changed files with 1418 additions and 604 deletions

View File

@@ -0,0 +1,162 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component } from '@angular/core';
import { By } from '@angular/platform-browser';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { BulletListItemComponent } from './bullet-list-item.component';
import { BulletListComponent } from './bullet-list.component';
import { isaActionChevronRight } from '@isa/icons';
// Mock icons for testing
const testIcons = {
isaActionChevronRight,
customIcon: '<svg></svg>',
ownIcon: '<svg></svg>',
parentIcon: '<svg></svg>',
childIcon: '<svg></svg>',
newParentIcon: '<svg></svg>',
};
// Test host component without parent BulletListComponent
@Component({
template: `
<ui-bullet-list-item [icon]="testIcon">
<span data-what="item-content">Test item content</span>
</ui-bullet-list-item>
`,
standalone: true,
imports: [BulletListItemComponent],
providers: [provideIcons(testIcons)],
})
class TestHostComponent {
testIcon: string | undefined = undefined;
}
// Test host component with parent BulletListComponent
@Component({
template: `
<ui-bullet-list [icon]="parentIcon">
<ui-bullet-list-item [icon]="childIcon">
<span data-what="item-content">Test item content</span>
</ui-bullet-list-item>
</ui-bullet-list>
`,
standalone: true,
imports: [BulletListComponent, BulletListItemComponent],
})
class TestHostWithParentComponent {
parentIcon = 'parentIcon';
childIcon: string | undefined = undefined;
}
describe('BulletListItemComponent', () => {
let component: BulletListItemComponent;
let fixture: ComponentFixture<BulletListItemComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [BulletListItemComponent],
providers: [provideIcons(testIcons)],
}).compileComponents();
fixture = TestBed.createComponent(BulletListItemComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should have undefined icon by default', () => {
expect(component.icon()).toBeUndefined();
});
it('should accept custom icon input', () => {
fixture.componentRef.setInput('icon', 'customIcon');
fixture.detectChanges();
expect(component.icon()).toBe('customIcon');
});
it('should render with correct CSS class', () => {
const hostElement = fixture.nativeElement;
expect(hostElement.classList.contains('ui-bullet-list-item')).toBe(true);
});
it('should render ng-icon with correct size', () => {
const ngIcon = fixture.debugElement.query(By.directive(NgIcon));
expect(ngIcon).toBeTruthy();
expect(ngIcon.nativeElement.getAttribute('size')).toBe('1.5rem');
});
it('should use undefined iconName when no icon is provided and no parent exists', () => {
expect(component.iconName()).toBeUndefined();
});
it('should use own icon when provided', () => {
fixture.componentRef.setInput('icon', 'ownIcon');
fixture.detectChanges();
expect(component.iconName()).toBe('ownIcon');
});
it('should project content correctly', async () => {
const hostFixture = TestBed.createComponent(TestHostComponent);
hostFixture.detectChanges();
const projectedContent = hostFixture.nativeElement.querySelector('[data-what="item-content"]');
expect(projectedContent).toBeTruthy();
expect(projectedContent.textContent.trim()).toBe('Test item content');
});
it('should inherit parent icon when no own icon is provided', async () => {
const hostFixture = TestBed.createComponent(TestHostWithParentComponent);
hostFixture.detectChanges();
const bulletListItem = hostFixture.debugElement.query(
sel => sel.componentInstance instanceof BulletListItemComponent
).componentInstance;
expect(bulletListItem.iconName()).toBe('parentIcon');
});
it('should use own icon instead of parent icon when both are provided', async () => {
const hostFixture = TestBed.createComponent(TestHostWithParentComponent);
const hostComponent = hostFixture.componentInstance;
hostComponent.childIcon = 'childIcon';
hostFixture.detectChanges();
const bulletListItem = hostFixture.debugElement.query(
sel => sel.componentInstance instanceof BulletListItemComponent
).componentInstance;
expect(bulletListItem.iconName()).toBe('childIcon');
});
it('should render ng-icon with computed iconName', async () => {
const hostFixture = TestBed.createComponent(TestHostWithParentComponent);
hostFixture.detectChanges();
const ngIcon = hostFixture.debugElement.query(By.directive(NgIcon));
expect(ngIcon.componentInstance.name()).toBe('parentIcon');
});
it('should update icon when parent icon changes', async () => {
const hostFixture = TestBed.createComponent(TestHostWithParentComponent);
const hostComponent = hostFixture.componentInstance;
hostFixture.detectChanges();
const bulletListItem = hostFixture.debugElement.query(
sel => sel.componentInstance instanceof BulletListItemComponent
).componentInstance;
expect(bulletListItem.iconName()).toBe('parentIcon');
// Change parent icon
hostComponent.parentIcon = 'newParentIcon';
hostFixture.detectChanges();
expect(bulletListItem.iconName()).toBe('newParentIcon');
});
});

View File

@@ -0,0 +1,85 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component } from '@angular/core';
import { provideIcons } from '@ng-icons/core';
import { BulletListComponent } from './bullet-list.component';
import { isaActionChevronRight } from '@isa/icons';
// Mock icons for testing
const testIcons = {
isaActionChevronRight,
customIcon: '<svg></svg>',
customTestIcon: '<svg></svg>',
};
// Test host component to verify content projection
@Component({
template: `
<ui-bullet-list [icon]="testIcon">
<div data-what="test-content">Test content</div>
</ui-bullet-list>
`,
standalone: true,
imports: [BulletListComponent],
})
class TestHostComponent {
testIcon = 'testIcon';
}
describe('BulletListComponent', () => {
let component: BulletListComponent;
let fixture: ComponentFixture<BulletListComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [BulletListComponent],
providers: [provideIcons(testIcons)],
}).compileComponents();
fixture = TestBed.createComponent(BulletListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should have default icon value', () => {
expect(component.icon()).toBe('isaActionChevronRight');
});
it('should accept custom icon input', () => {
fixture.componentRef.setInput('icon', 'customIcon');
fixture.detectChanges();
expect(component.icon()).toBe('customIcon');
});
it('should render with correct CSS class', () => {
const hostElement = fixture.nativeElement;
expect(hostElement.classList.contains('ui-bullet-list')).toBe(true);
});
it('should project content correctly', async () => {
const hostFixture = TestBed.createComponent(TestHostComponent);
hostFixture.detectChanges();
const projectedContent = hostFixture.nativeElement.querySelector('[data-what="test-content"]');
expect(projectedContent).toBeTruthy();
expect(projectedContent.textContent.trim()).toBe('Test content');
});
it('should pass icon to child components via dependency injection', async () => {
const hostFixture = TestBed.createComponent(TestHostComponent);
const hostComponent = hostFixture.componentInstance;
hostComponent.testIcon = 'customTestIcon';
hostFixture.detectChanges();
const bulletListComponent = hostFixture.debugElement.query(
sel => sel.componentInstance instanceof BulletListComponent
).componentInstance;
expect(bulletListComponent.icon()).toBe('customTestIcon');
});
});

View File

@@ -2,3 +2,4 @@
@use "lib/icon-button";
@use "lib/info-button";
@use "lib/text-button";
@use "lib/stateful-button/stateful-button";

View File

@@ -2,4 +2,5 @@ export * from './lib/button.component';
export * from './lib/icon-button.component';
export * from './lib/info-button.component';
export * from './lib/text-button.component';
export * from './lib/stateful-button/stateful-button.component';
export * from './lib/types';

View File

@@ -0,0 +1,24 @@
.stateful-button {
@apply flex items-center justify-center select-none;
overflow: hidden;
width: 100%;
.stateful-button-content {
@apply flex items-center gap-1 w-full;
white-space: nowrap;
&--default {
@apply justify-center;
}
&--error {
@apply justify-between;
}
&--success {
@apply justify-start;
}
}
}
.stateful-button-action {
@apply cursor-pointer font-bold;
}

View File

@@ -0,0 +1,52 @@
<ui-button
[class]="['stateful-button', stateClass()]"
[color]="color()"
[size]="size()"
[pending]="pending()"
(click)="handleButtonClick()"
data-what="stateful-button"
[attr.data-which]="state()"
>
@switch (state()) {
@case ('default') {
<div
class="stateful-button-content stateful-button-content--default"
[@fade]
>
<span>{{ defaultContent() }}</span>
</div>
}
@case ('success') {
<div
class="stateful-button-content stateful-button-content--success"
[@fade]
>
<ng-icon name="isaActionCheck" size="1.5rem"></ng-icon>
<span>{{ successContent() }}</span>
</div>
}
@case ('error') {
<div
class="stateful-button-content stateful-button-content--error"
[@fade]
>
<span class="font-normal">{{ errorContent() }}</span>
@if (errorAction()) {
<span
class="stateful-button-action"
(click)="handleActionClick($event)"
(keydown.enter)="handleActionClick($event)"
(keydown.space)="handleActionClick($event)"
tabindex="0"
role="button"
data-what="stateful-button-action"
>
{{ errorAction() }}
</span>
} @else {
<span></span>
}
</div>
}
}
</ui-button>

View File

@@ -0,0 +1,227 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { StatefulButtonComponent } from './stateful-button.component';
import { fakeAsync, tick } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
describe('StatefulButtonComponent', () => {
let spectator: Spectator<StatefulButtonComponent>;
const createComponent = createComponentFactory({
component: StatefulButtonComponent,
imports: [NoopAnimationsModule],
});
beforeEach(() => {
spectator = createComponent({
props: {
defaultContent: 'Submit',
successContent: 'Submitted',
errorContent: 'Failed to submit',
},
});
});
it('should create', () => {
expect(spectator.component).toBeTruthy();
});
it('should display default content initially', () => {
expect(spectator.query('.stateful-button-content--default')).toHaveText('Submit');
expect(spectator.component.state()).toBe('default');
});
it('should have correct e2e attributes', () => {
const button = spectator.query('ui-button');
expect(button).toHaveAttribute('data-what', 'stateful-button');
expect(button).toHaveAttribute('data-which', 'default');
});
it('should apply correct CSS classes based on state', () => {
expect(spectator.query('.stateful-button-content--default')).toExist();
spectator.component.state.set('success');
spectator.detectChanges();
expect(spectator.query('.stateful-button-content--success')).toExist();
spectator.component.state.set('error');
spectator.detectChanges();
expect(spectator.query('.stateful-button-content--error')).toExist();
});
it('should emit clicked event when clicked in default state', () => {
const clickedSpy = jest.fn();
spectator.output('clicked').subscribe(clickedSpy);
spectator.click('ui-button');
expect(clickedSpy).toHaveBeenCalledTimes(1);
});
it('should transition from success to default when clicked', () => {
spectator.component.state.set('success');
spectator.detectChanges();
expect(spectator.component.state()).toBe('success');
expect(spectator.query('.stateful-button-content--success')).toContainText('Submitted');
spectator.click('ui-button');
expect(spectator.component.state()).toBe('default');
});
it('should transition from error to default when clicked', () => {
spectator.component.state.set('error');
spectator.detectChanges();
expect(spectator.component.state()).toBe('error');
spectator.click('ui-button');
expect(spectator.component.state()).toBe('default');
});
it('should display success icon and content', () => {
spectator.component.state.set('success');
spectator.detectChanges();
expect(spectator.query('ng-icon')).toExist();
expect(spectator.query('ng-icon')).toHaveAttribute('name', 'isaActionCheck');
expect(spectator.query('.stateful-button-content--success')).toContainText('Submitted');
});
it('should display error content with action button', () => {
spectator.setInput('errorAction', 'Try again');
spectator.component.state.set('error');
spectator.detectChanges();
expect(spectator.query('.stateful-button-content--error')).toContainText('Failed to submit');
expect(spectator.query('.stateful-button-action')).toHaveText('Try again');
});
it('should emit action event when error action is clicked', () => {
spectator.setInput('errorAction', 'Try again');
spectator.component.state.set('error');
spectator.detectChanges();
const actionSpy = jest.fn();
spectator.output('action').subscribe(actionSpy);
spectator.click('.stateful-button-action');
expect(actionSpy).toHaveBeenCalledTimes(1);
});
it('should display error content without action when errorAction is not provided', () => {
spectator.component.state.set('error');
spectator.detectChanges();
expect(spectator.query('.stateful-button-content--error')).toContainText('Failed to submit');
expect(spectator.query('.stateful-button-action')).not.toExist();
});
it('should pass through button properties correctly', () => {
spectator.setInput('color', 'brand');
spectator.setInput('size', 'large');
spectator.setInput('pending', true);
spectator.detectChanges();
const button = spectator.query('ui-button');
expect(button).toHaveClass('ui-button__brand');
expect(button).toHaveClass('ui-button__large');
expect(button).toHaveClass('ui-button__pending');
});
it('should auto-dismiss success state after timeout', fakeAsync(() => {
spectator.setInput('dismiss', 1000);
spectator.component.state.set('success');
spectator.detectChanges();
expect(spectator.component.state()).toBe('success');
tick(1000);
expect(spectator.component.state()).toBe('default');
}));
it('should use correct widths for each state', () => {
// Setup the expected widths
spectator.setInput('defaultWidth', '10rem');
spectator.setInput('successWidth', '20.375rem');
spectator.setInput('errorWidth', '32rem');
expect(spectator.component.widthStyle()).toBe('10rem');
spectator.component.state.set('success');
expect(spectator.component.widthStyle()).toBe('20.375rem');
spectator.component.state.set('error');
expect(spectator.component.widthStyle()).toBe('32rem');
});
it('should use custom widths when provided', () => {
spectator.setInput('defaultWidth', '8rem');
spectator.setInput('successWidth', '16rem');
spectator.setInput('errorWidth', '24rem');
expect(spectator.component.widthStyle()).toBe('8rem');
spectator.component.state.set('success');
expect(spectator.component.widthStyle()).toBe('16rem');
spectator.component.state.set('error');
expect(spectator.component.widthStyle()).toBe('24rem');
});
it('should update e2e data-which attribute based on state', () => {
const button = spectator.query('ui-button');
expect(button).toHaveAttribute('data-which', 'default');
spectator.component.state.set('success');
spectator.detectChanges();
expect(button).toHaveAttribute('data-which', 'success');
spectator.component.state.set('error');
spectator.detectChanges();
expect(button).toHaveAttribute('data-which', 'error');
});
it('should handle keyboard accessibility on action button', () => {
spectator.setInput('errorAction', 'Try again');
spectator.component.state.set('error');
spectator.detectChanges();
const action = spectator.query('.stateful-button-action');
expect(action).toHaveAttribute('role', 'button');
expect(action).toHaveAttribute('tabindex', '0');
expect(action).toHaveAttribute('data-what', 'stateful-button-action');
});
it('should have fade animation applied to content divs', () => {
// Animation triggers don't add DOM attributes, so we test for the elements to exist
// The animation setup is tested through the NoopAnimationsModule in the component setup
expect(spectator.query('.stateful-button-content--default')).toExist();
spectator.component.state.set('success');
spectator.detectChanges();
expect(spectator.query('.stateful-button-content--success')).toExist();
spectator.component.state.set('error');
spectator.detectChanges();
expect(spectator.query('.stateful-button-content--error')).toExist();
});
it('should properly align content based on state', () => {
const defaultContent = spectator.query('.stateful-button-content--default');
expect(defaultContent).toHaveClass('stateful-button-content--default');
spectator.component.state.set('success');
spectator.detectChanges();
const successContent = spectator.query('.stateful-button-content--success');
expect(successContent).toHaveClass('stateful-button-content--success');
spectator.component.state.set('error');
spectator.detectChanges();
const errorContent = spectator.query('.stateful-button-content--error');
expect(errorContent).toHaveClass('stateful-button-content--error');
});
});

View File

@@ -0,0 +1,176 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
model,
output,
effect,
OnDestroy,
inject,
viewChild,
ElementRef,
untracked,
} from '@angular/core';
import { ButtonComponent } from '../button.component';
import { ButtonColor, ButtonSize } from '../types';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaActionCheck } from '@isa/icons';
import {
animate,
AnimationBuilder,
style,
transition,
trigger,
} from '@angular/animations';
export type StatefulButtonState = 'default' | 'success' | 'error';
@Component({
selector: 'ui-stateful-button',
templateUrl: './stateful-button.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [ButtonComponent, NgIcon],
providers: [provideIcons({ isaActionCheck })],
animations: [
trigger('fade', [
transition(':enter', [
style({ opacity: 0.5 }),
animate('125ms 125ms ease-in', style({ opacity: 1 })),
]),
transition(':leave', [
style({ opacity: '*' }),
animate('124ms ease-out', style({ opacity: 0.5 })),
]),
]),
],
})
export class StatefulButtonComponent implements OnDestroy {
#animationBuilder = inject(AnimationBuilder);
private buttonElement = viewChild.required(ButtonComponent, {
read: ElementRef,
});
// State management
state = model<StatefulButtonState>('default');
stateEffect = effect(() => {
this.state();
untracked(() => {
this.#makeWidthAnimation(this.widthStyle());
});
});
// Content inputs for each state
defaultContent = input.required<string>();
successContent = input.required<string>();
errorContent = input.required<string>();
errorAction = input<string>();
// Width configuration for each state
defaultWidth = input<string>('100%');
successWidth = input<string>('100%');
errorWidth = input<string>('100%');
// Optional dismiss timeout in milliseconds
dismiss = input<number>();
// Button properties
color = input<ButtonColor>('primary');
size = input<ButtonSize>('medium');
pending = input<boolean>(false);
// Output events
clicked = output<void>();
action = output<void>();
// Internal state
private dismissTimer: ReturnType<typeof setTimeout> | null = null;
// Computed properties
stateClass = computed(() => `stateful-button--${this.state()}`);
widthStyle = computed(() => {
switch (this.state()) {
case 'success':
return this.successWidth();
case 'error':
return this.errorWidth();
default:
return this.defaultWidth();
}
});
constructor() {
// Watch for state changes to handle auto-dismiss
this.setupStateEffect();
}
ngOnDestroy(): void {
this.clearDismissTimer();
}
private setupStateEffect(): void {
// Use effect to watch state changes
effect(() => {
const currentState = this.state();
const dismissTimeout = this.dismiss();
// Clear any existing timer
this.clearDismissTimer();
// Set up auto-dismiss if configured
if (
dismissTimeout &&
(currentState === 'success' || currentState === 'error')
) {
this.dismissTimer = setTimeout(() => {
this.changeState('default');
}, dismissTimeout);
}
});
}
handleButtonClick(): void {
const currentState = this.state();
if (currentState === 'default') {
// Only emit click event in default state
this.clicked.emit();
} else if (currentState === 'success' || currentState === 'error') {
// In success/error states, clicking the button returns to default
this.changeState('default');
}
}
handleActionClick(event: Event): void {
// Prevent button click from firing
event.stopPropagation();
// Emit action event
this.action.emit();
}
private changeState(newState: StatefulButtonState): void {
this.clearDismissTimer();
this.state.set(newState);
}
private clearDismissTimer(): void {
if (this.dismissTimer) {
clearTimeout(this.dismissTimer);
this.dismissTimer = null;
}
}
#makeWidthAnimation(width: string): void {
const animation = this.#animationBuilder.build([
style({ width: '*' }),
animate('250ms', style({ width })),
]);
const player = animation.create(this.buttonElement().nativeElement);
player.play();
}
}