feat: update button component with spinner support and improve test coverage

This commit is contained in:
Lorenz Hilpert
2025-03-28 18:08:17 +01:00
parent d0b7c95be2
commit 8bbaf1c70c
8 changed files with 180 additions and 53 deletions

View File

@@ -55,6 +55,7 @@ You are a mentor with a dual approach: when I make a mistake or my work needs im
- Testing Framework Jest
- Spectator should be used for Unit tests
- When using Spectator use spectator.setInput('<input>','<value>') to set the Component inputs
- Unit tests should be included for all components and services
- Use the Angular TestBed configuration
- Include error case testing
@@ -66,6 +67,7 @@ You are a mentor with a dual approach: when I make a mistake or my work needs im
- Use strict TypeScript configurations
- Follow Angular style guide naming conventions
- Follow the project's guidelines in `/docs/guidelines.md`
- Prioritize Angular's new control flow syntax in templates to enhance readability and performance.
- Organize imports in groups:
1. Angular core imports
2. Third-party libraries

View File

@@ -9,6 +9,10 @@
border-radius: 6.25rem;
}
.ui-button__spinner {
@apply animate-spin;
}
.ui-button__small {
min-width: 10rem;
padding: 0.375rem 0.75rem;
@@ -51,6 +55,10 @@
&.disabled {
@apply bg-isa-neutral-400;
}
.ui-button__spinner {
@apply text-isa-white;
}
}
.ui-button__secondary {
@@ -69,7 +77,7 @@
@apply border-isa-neutral-400 text-isa-neutral-400 bg-isa-white;
}
ng-icon {
.ui-button__spinner {
@apply text-isa-secondary-600;
}
}
@@ -111,7 +119,7 @@
@apply bg-isa-neutral-200 text-isa-neutral-500;
}
ng-icon {
.ui-button__spinner {
@apply text-isa-neutral-600;
}
}

View File

@@ -1,5 +1,5 @@
@if (!pending()) {
<ng-content></ng-content>
} @else {
<ng-icon size="1.5rem" class="animate-spin text-isa-white" name="isaLoading"></ng-icon>
<ng-icon class="ui-button__spinner" size="1.5rem" name="isaLoading"></ng-icon>
}

View File

@@ -1,71 +1,45 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { ButtonComponent } from './button.component';
import { NgIconComponent } from '@ng-icons/core';
import { ButtonColor, ButtonSize } from './types';
describe('ButtonComponent', () => {
let spectator: Spectator<ButtonComponent>;
const createComponent = createComponentFactory({
component: ButtonComponent,
imports: [NgIconComponent],
});
const createComponent = createComponentFactory(ButtonComponent);
beforeEach(() => {
spectator = createComponent();
});
it('should create', () => {
it('should create the button component', () => {
expect(spectator.component).toBeTruthy();
});
it('should set default input values', () => {
expect(spectator.component.size()).toBe('medium');
expect(spectator.component.color()).toBe('primary');
expect(spectator.component.pending()).toBe(false);
expect(spectator.component.tabIndex()).toBe(0);
it('should have default classes and tabIndex set', () => {
const host = spectator.element;
expect(host.classList).toContain('ui-button');
expect(host.classList).toContain('ui-button__medium');
expect(host.classList).toContain('ui-button__primary');
expect(host.getAttribute('tabindex')).toBe('0');
});
it('should apply correct size classes', () => {
const sizes: ButtonSize[] = ['small', 'medium', 'large'];
it('should update size class when size input changes', () => {
// Update the size signal from its default value ('medium') to 'large'
spectator.setInput('size', 'large');
spectator.detectChanges();
sizes.forEach((size) => {
spectator.setInput('size', size);
expect(spectator.element).toHaveClass(`ui-button__${size}`);
});
const host = spectator.element;
expect(host.classList).toContain('ui-button__large');
// Ensure previous size class is no longer present
expect(host.classList).not.toContain('ui-button__medium');
});
it('should apply correct color classes', () => {
const colors: ButtonColor[] = ['primary', 'secondary', 'tertiary'];
it('should update color class when color input changes', () => {
// Update the color signal from its default value ('primary') to 'secondary'
spectator.setInput('color', 'secondary');
spectator.detectChanges();
colors.forEach((color) => {
spectator.setInput('color', color);
expect(spectator.element).toHaveClass(`ui-button__${color}`);
});
});
it('should update tabIndex in DOM', () => {
spectator.setInput('tabIndex', -1);
expect(spectator.element.tabIndex).toBe(-1);
});
it('should show loading icon when pending', () => {
spectator.setInput('pending', true);
const loadingIcon = spectator.query('ng-icon[name="isaLoading"]');
expect(loadingIcon).toExist();
});
it('should have base button class', () => {
expect(spectator.element).toHaveClass('ui-button');
});
it('should combine all classes correctly', () => {
spectator.setInput({
size: 'large',
color: 'secondary',
});
expect(spectator.element).toHaveClass('ui-button');
expect(spectator.element).toHaveClass('ui-button__large');
expect(spectator.element).toHaveClass('ui-button__secondary');
const host = spectator.element;
expect(host.classList).toContain('ui-button__secondary');
// Ensure previous color class is no longer present
expect(host.classList).not.toContain('ui-button__primary');
});
});

View File

@@ -9,6 +9,23 @@ import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { ButtonColor, ButtonSize } from './types';
import { isaLoading } from '@isa/icons';
/**
* A UI button component that allows customization of size, color, and state.
*
* The component uses reactive signals to manage its properties, allowing dynamic
* updates to its CSS classes without manual DOM manipulation.
*
* @property size - The size of the button (e.g., 'small', 'medium', 'large'). The default is 'medium'.
* @property sizeClass - A computed CSS class based on the current size, formatted as 'ui-button__{size}'.
* @property color - The color theme of the button (e.g., 'primary', 'secondary'). The default is 'primary'.
* @property colorClass - A computed CSS class based on the current color, formatted as 'ui-button__{color}'.
* @property pending - A boolean flag indicating whether the button is in a loading or pending state.
* @property tabIndex - The tab index for the button, used to control keyboard navigation order.
*
* @remarks
* This component relies on reactive input and computed signals to update its state and classes,
* ensuring that transformations are automatically reflected in the UI.
*/
@Component({
selector: 'ui-button, [uiButton]',
templateUrl: './button.component.html',

View File

@@ -0,0 +1,46 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { IconButtonComponent } from './icon-button.component';
describe('IconButtonComponent', () => {
let spectator: Spectator<IconButtonComponent>;
let component: IconButtonComponent;
const createComponent = createComponentFactory({
component: IconButtonComponent,
imports: [], // additional imports if needed
});
beforeEach(() => {
spectator = createComponent();
component = spectator.component;
});
test('should have default size and computed sizeClass', () => {
expect(component.size()).toBe('medium');
expect(component.sizeClass()).toBe('ui-icon-button__medium');
});
test('should update size and computed sizeClass when size changes', () => {
spectator.setInput('size', 'small');
spectator.detectChanges();
expect(component.size()).toBe('small');
expect(component.sizeClass()).toBe('ui-icon-button__small');
});
test('should have default color and computed colorClass', () => {
expect(component.color()).toBe('primary');
expect(component.colorClass()).toBe('ui-icon-button__primary');
});
test('should update color and computed colorClass when color changes', () => {
spectator.setInput('color', 'secondary');
spectator.detectChanges();
expect(component.color()).toBe('secondary');
expect(component.colorClass()).toBe('ui-icon-button__secondary');
});
test('should have default pending and tabIndex values', () => {
expect(component.pending()).toBe(false);
expect(component.tabIndex()).toBe(0);
});
});

View File

@@ -0,0 +1,36 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { InfoButtonComponent } from './info-button.component';
describe('InfoButtonComponent', () => {
let spectator: Spectator<InfoButtonComponent>;
const createComponent = createComponentFactory({
component: InfoButtonComponent,
shallow: true,
});
beforeEach(() => {
spectator = createComponent();
});
it('should create the component', () => {
expect(spectator.component).toBeTruthy();
});
it('should have the default "ui-info-button" class when pending is false', () => {
spectator.setInput('pending', false);
spectator.detectChanges();
const hostClasses = spectator.element.classList;
expect(hostClasses).toContain('ui-info-button');
expect(hostClasses).not.toContain('ui-info-button--pending');
});
it('should add the "ui-info-button--pending" class when pending is true', () => {
spectator.setInput('pending', true);
spectator.detectChanges();
const hostClasses = spectator.element.classList;
expect(hostClasses).toContain('ui-info-button');
expect(hostClasses).toContain('ui-info-button--pending');
});
});

View File

@@ -0,0 +1,44 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { TextButtonComponent } from './text-button.component';
describe('TextButtonComponent', () => {
let spectator: Spectator<TextButtonComponent>;
const createComponent = createComponentFactory({
component: TextButtonComponent,
});
beforeEach(() => {
spectator = createComponent();
});
it('should create the component', () => {
expect(spectator.component).toBeTruthy();
});
it('should have default classes for size and color', () => {
const hostElement = spectator.element;
// Expected default inputs: size 'medium', color 'normal'
expect(hostElement.classList).toContain('ui-text-button');
expect(hostElement.classList).toContain('ui-text-button__medium');
expect(hostElement.classList).toContain('ui-text-button__normal');
});
it('should update classes when inputs change', () => {
spectator.setInput('size', 'large');
spectator.setInput('color', 'primary');
spectator.detectChanges();
const hostElement = spectator.element;
expect(hostElement.classList).toContain('ui-text-button__large');
expect(hostElement.classList).toContain('ui-text-button__primary');
});
it('should correctly set the tabindex attribute', () => {
const hostElement = spectator.element;
expect(hostElement.getAttribute('tabindex')).toEqual('0');
spectator.setInput('tabIndex', 5);
spectator.detectChanges();
expect(hostElement.getAttribute('tabindex')).toEqual('5');
});
});