mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-31 09:37:15 +01:00
committed by
Nino Righi
parent
5f74c6ddf8
commit
40c9d51dfc
162
libs/ui/bullet-list/src/lib/bullet-list-item.component.spec.ts
Normal file
162
libs/ui/bullet-list/src/lib/bullet-list-item.component.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
85
libs/ui/bullet-list/src/lib/bullet-list.component.spec.ts
Normal file
85
libs/ui/bullet-list/src/lib/bullet-list.component.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -2,3 +2,4 @@
|
||||
@use "lib/icon-button";
|
||||
@use "lib/info-button";
|
||||
@use "lib/text-button";
|
||||
@use "lib/stateful-button/stateful-button";
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user