Refactor chip option component and styles

Updated the chip option component and its styles for improved functionality and organization.

-  **Feature**: Introduced new chip option styles and layout
- 🛠️ **Refactor**: Removed outdated styles and organized SCSS files
- 🧪 **Test**: Added unit tests for chip option component functionality
This commit is contained in:
Lorenz Hilpert
2025-03-31 13:28:32 +02:00
parent d38fed297d
commit 417bd649e2
9 changed files with 452 additions and 46 deletions

View File

@@ -1 +1,2 @@
@use "./lib/checkbox/checkbox";
@use "./lib/chips/chip-option";

View File

@@ -0,0 +1,37 @@
// Styles for the ui-chips container
// This container aligns chip options horizontally with spacing between them.
.ui-chips {
display: flex;
align-items: center;
gap: 0.75rem;
}
// Styles for individual chip options
// Defines the appearance and layout of each chip option.
.ui-chip-option {
display: inline-flex;
height: 2.5rem;
min-width: 10rem;
padding: 0.5rem 1.25rem;
justify-content: center;
align-items: center;
flex-shrink: 0;
border-radius: 6.25rem;
@apply border border-solid border-neutral-600;
@apply isa-text-body-2-bold text-isa-neutral-600 cursor-pointer;
}
// Styles for a selected chip option
// Applies background and text color changes when a chip is selected.
.ui-chip-option__selected {
@apply bg-isa-neutral-200 text-isa-neutral-800;
}
// Styles for a disabled ui-chips container
// Reduces opacity and disables pointer events when the container is disabled.
.ui-chips__disabled {
opacity: 0.5;
pointer-events: none;
}

View File

@@ -1,18 +0,0 @@
.ui-chip-option {
display: inline-flex;
height: 2.5rem;
min-width: 10rem;
padding: 0.5rem 1.25rem;
justify-content: center;
align-items: center;
flex-shrink: 0;
border-radius: 6.25rem;
@apply border border-solid border-neutral-600;
@apply isa-text-body-2-bold text-isa-neutral-600 cursor-pointer;
}
.ui-chip-option__selected {
@apply bg-isa-neutral-200 text-isa-neutral-800;
}

View File

@@ -0,0 +1,87 @@
import { createHostFactory, SpectatorHost } from '@ngneat/spectator/jest';
import { ChipOptionComponent } from './chip-option.component';
import { ChipsComponent } from './chips.component';
describe('ChipOptionComponent', () => {
let spectator: SpectatorHost<ChipOptionComponent<string>>;
const createHost = createHostFactory({
component: ChipOptionComponent<string>,
imports: [ChipsComponent],
template: `<ui-chips><ui-chip-option [value]="value"></ui-chip-option></ui-chips>`,
});
beforeEach(() => {
spectator = createHost(undefined, {
hostProps: {
value: 'test',
},
});
});
it('should create', () => {
expect(spectator.component).toBeTruthy();
});
it('should toggle selection on click', () => {
// Arrange
const mockSelect = jest.spyOn(spectator.component['host'], 'select');
// Act
spectator.click(spectator.element);
// Assert
expect(mockSelect).toHaveBeenCalledWith('test');
});
it('should apply selected class when selected', () => {
// Arrange
spectator.component['host'].select('test');
spectator.detectChanges();
// Assert
expect(spectator.element).toHaveClass('ui-chip-option__selected');
});
describe('Edge Cases', () => {
it('should handle null value gracefully', () => {
// Arrange
spectator.setHostInput('value', null);
// Act
spectator.click(spectator.element);
// Assert
expect(() => spectator.component.toggle()).not.toThrow();
});
it('should handle undefined value gracefully', () => {
// Arrange
spectator.setHostInput('value', undefined);
// Act
spectator.click(spectator.element);
// Assert
expect(() => spectator.component.toggle()).not.toThrow();
});
it('should not apply selected class for null value', () => {
// Arrange
spectator.setHostInput('value', null);
spectator.detectChanges();
// Assert
expect(spectator.element).not.toHaveClass('ui-chip-option__selected');
});
it('should not apply selected class for undefined value', () => {
// Arrange
spectator.setHostInput('value', undefined);
spectator.detectChanges();
// Assert
expect(spectator.element).not.toHaveClass('ui-chip-option__selected');
});
});
});

View File

@@ -1,37 +1,62 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
ViewEncapsulation,
} from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
import { ChipsComponent } from './chips.component';
/**
* A selectable chip option component that integrates with ChipsComponent.
* This component represents an individual selectable item within a chips group.
*
* @template T - The type of value that this chip option can hold
*
* @example
* ```html
* <ui-chips [(ngModel)]="selected">
* <ui-chip-option [value]="1">Option 1</ui-chip-option>
* <ui-chip-option [value]="2">Option 2</ui-chip-option>
* </ui-chips>
* ```
*/
@Component({
selector: 'ui-chip-option',
template: `<ng-content></ng-content>`,
styleUrls: ['./chip-option.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
standalone: true,
host: { '[class]': '["ui-chip-option", selectedClass()]', '(click)': 'toggle()' },
})
export class ChipOptionComponent<T> {
/** Reference to the parent ChipsComponent */
private host = inject(ChipsComponent, { host: true });
/**
* Computed signal that determines if this option is currently selected.
* Updates when the parent chips component value changes.
*/
selected = computed(() => {
this.host.value();
return this.host['selectionModel'].isSelected(this.value());
return this.host.isSelected(this.value());
});
/**
* Computed signal that returns the CSS class for the selected state.
* Returns 'ui-chip-option__selected' when the option is selected, empty string otherwise.
*/
selectedClass = computed(() => {
return this.selected() ? 'ui-chip-option__selected' : '';
});
/**
* The value associated with this chip option.
* This is a required input that must be provided when using the component.
*/
value = input.required<T>();
toggle() {
/**
* Toggles the selection state of this chip option.
* Delegates the selection handling to the parent ChipsComponent.
* Prevents toggling if the parent ChipsComponent is disabled.
*/
toggle(): void {
if (this.host.disabled()) {
return;
}
this.host.select(this.value());
}
}

View File

@@ -1,5 +0,0 @@
.ui-chips {
display: flex;
align-items: center;
gap: 0.75rem;
}

View File

@@ -0,0 +1,210 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { ChipsComponent } from './chips.component';
describe('ChipsComponent', () => {
let spectator: Spectator<ChipsComponent<string>>;
const createComponent = createComponentFactory({
component: ChipsComponent<string>,
});
beforeEach(() => {
spectator = createComponent();
});
it('should create', () => {
expect(spectator.component).toBeTruthy();
});
describe('ControlValueAccessor Implementation', () => {
it('should implement writeValue correctly', () => {
// Arrange
const testValue = 'test';
// Act
spectator.component.writeValue(testValue);
// Assert
expect(spectator.component.value()).toBe(testValue);
});
it('should register onChange function', () => {
// Arrange
const mockOnChange = jest.fn();
// Act
spectator.component.registerOnChange(mockOnChange);
// Assert
expect(spectator.component['onChange']).toBe(mockOnChange);
});
it('should register onTouched function', () => {
// Arrange
const mockOnTouched = jest.fn();
// Act
spectator.component.registerOnTouched(mockOnTouched);
// Assert
expect(spectator.component['onTouched']).toBe(mockOnTouched);
});
it('should set disabled state correctly', () => {
// Act
spectator.component.setDisabledState?.(true);
// Assert
expect(spectator.component.disabled()).toBe(true);
expect(spectator.component.disabledClass()).toBe('ui-chips__disabled');
});
});
describe('Selection Behavior', () => {
it('should select value and emit change event', () => {
// Arrange
const testValue = 'test';
const mockOnChange = jest.fn();
spectator.component.registerOnChange(mockOnChange);
const mockOnTouched = jest.fn();
spectator.component.registerOnTouched(mockOnTouched);
// Act
spectator.component.select(testValue);
// Assert
expect(spectator.component.value()).toBe(testValue);
expect(mockOnChange).toHaveBeenCalledWith(testValue);
expect(mockOnTouched).toHaveBeenCalled();
});
it('should select value without emitting change event when emitEvent is false', () => {
// Arrange
const testValue = 'test';
const mockOnChange = jest.fn();
spectator.component.registerOnChange(mockOnChange);
// Act
spectator.component.select(testValue, { emitEvent: false });
// Assert
expect(spectator.component.value()).toBe(testValue);
expect(mockOnChange).not.toHaveBeenCalled();
});
it('should toggle value correctly', () => {
// Arrange
const testValue = 'test';
const mockOnChange = jest.fn();
spectator.component.registerOnChange(mockOnChange);
// Act
spectator.component.toggle(testValue);
// Assert - First toggle selects the value
expect(spectator.component.value()).toBe(testValue);
expect(mockOnChange).toHaveBeenCalledWith(testValue);
// Act - Second toggle deselects the value
spectator.component.toggle(testValue);
// Assert
expect(spectator.component.value()).toBeUndefined();
expect(mockOnChange).toHaveBeenCalledWith(undefined);
});
it('should toggle value without emitting change event when emitEvent is false', () => {
// Arrange
const testValue = 'test';
const mockOnChange = jest.fn();
spectator.component.registerOnChange(mockOnChange);
// Act
spectator.component.toggle(testValue, { emitEvent: false });
// Assert
expect(spectator.component.value()).toBe(testValue);
expect(mockOnChange).not.toHaveBeenCalled();
});
});
describe('CSS Classes', () => {
it('should have base class', () => {
expect(spectator.element).toHaveClass('ui-chips');
});
it('should add disabled class when disabled', () => {
// Act
spectator.component.setDisabledState?.(true);
spectator.detectChanges();
// Assert
expect(spectator.element).toHaveClass('ui-chips__disabled');
});
it('should not have disabled class when enabled', () => {
// Act
spectator.component.setDisabledState?.(false);
spectator.detectChanges();
// Assert
expect(spectator.element).not.toHaveClass('ui-chips__disabled');
});
});
describe('Selection Model', () => {
it('should allow only single selection', () => {
// Arrange
const value1 = 'test1';
const value2 = 'test2';
// Act
spectator.component.select(value1);
spectator.component.select(value2);
// Assert
expect(spectator.component.value()).toBe(value2);
});
it('should clear selection when toggling selected value', () => {
// Arrange
const testValue = 'test';
spectator.component.select(testValue);
// Act
spectator.component.toggle(testValue);
// Assert
expect(spectator.component.value()).toBeUndefined();
});
});
describe('Edge Cases', () => {
it('should handle null value in writeValue', () => {
// Act
spectator.component.writeValue(null);
// Assert
expect(spectator.component.value()).toBeNull();
});
it('should handle undefined value in writeValue', () => {
// Act
spectator.component.writeValue(undefined);
// Assert
expect(spectator.component.value()).toBeUndefined();
});
it('should not throw when selecting null value', () => {
// Act & Assert
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(() => spectator.component.select(null as any)).not.toThrow();
});
it('should not throw when toggling undefined value', () => {
// Act & Assert
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(() => spectator.component.toggle(undefined as any)).not.toThrow();
});
});
});

View File

@@ -1,19 +1,32 @@
import {
ChangeDetectionStrategy,
Component,
computed,
model,
ViewEncapsulation,
} from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, model } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { SelectionModel } from '@angular/cdk/collections';
/**
* A reusable chip component that implements ControlValueAccessor for form integration.
* Supports single selection mode and disabled state management.
*
* @template T - The type of value that can be selected in the chip
*
* @example
* ```typescript
* // Basic usage in a template
* <ui-chips [(ngModel)]="selectedValue">
* <ui-chip [value]="item1">Item 1</ui-chip>
* <ui-chip [value]="item2">Item 2</ui-chip>
* </ui-chips>
*
* // Reactive form usage
* <ui-chips formControlName="selection">
* <ui-chip [value]="item1">Item 1</ui-chip>
* <ui-chip [value]="item2">Item 2</ui-chip>
* </ui-chips>
* ```
*/
@Component({
selector: 'ui-chips',
template: `<ng-content></ng-content>`,
styleUrls: ['./chips.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
host: {
'[class]': '["ui-chips", disabledClass()]',
},
@@ -26,41 +39,78 @@ import { SelectionModel } from '@angular/cdk/collections';
],
})
export class ChipsComponent<T> implements ControlValueAccessor {
/** The currently selected value */
value = model<T>();
/** Whether the chips component is disabled */
disabled = model(false);
/** Computed CSS class for disabled state */
disabledClass = computed(() => {
return this.disabled() ? 'ui-chips__disabled' : '';
});
/** Internal selection model for managing chip selection state */
private selectionModel: SelectionModel<T>;
/** Callback function for value changes */
onChange?: (value: T) => void;
/** Callback function for touched state */
onTouched?: () => void;
constructor() {
this.selectionModel = new SelectionModel<T>(false);
}
/**
* Writes a new value to the component.
* Part of the ControlValueAccessor interface.
*
* @param obj - The new value to write
*/
writeValue(obj: unknown): void {
this.selectionModel?.select(obj as T);
this.value.set(obj as T);
}
/**
* Registers a callback function that is called when the control's value changes.
* Part of the ControlValueAccessor interface.
*
* @param fn - The callback function to register
*/
registerOnChange(fn: () => void): void {
this.onChange = fn;
}
/**
* Registers a callback function that is called when the control is touched.
* Part of the ControlValueAccessor interface.
*
* @param fn - The callback function to register
*/
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
/**
* Sets the disabled state of the component.
* Part of the ControlValueAccessor interface.
*
* @param isDisabled - Whether the component should be disabled
*/
setDisabledState?(isDisabled: boolean): void {
this.disabled.set(isDisabled);
}
/**
* Selects a value in the chip component.
*
* @param value - The value to select
* @param options - Configuration options for the selection
* @param options.emitEvent - Whether to emit the change event (default: true)
*/
select(value: T, options: { emitEvent: boolean } = { emitEvent: true }) {
this.selectionModel.select(value);
this.onTouched?.();
@@ -70,6 +120,13 @@ export class ChipsComponent<T> implements ControlValueAccessor {
}
}
/**
* Toggles the selection state of a value in the chip component.
*
* @param value - The value to toggle
* @param options - Configuration options for the toggle operation
* @param options.emitEvent - Whether to emit the change event (default: true)
*/
toggle(value: T, options: { emitEvent: boolean } = { emitEvent: true }) {
this.selectionModel.toggle(value);
this.onTouched?.();
@@ -78,4 +135,17 @@ export class ChipsComponent<T> implements ControlValueAccessor {
this.onChange?.(this.selectionModel.selected[0]);
}
}
/**
* Determines if the specified value is currently selected.
*
* This method uses the underlying selection model to check whether the given value
* is part of the selected items.
*
* @param value - The value to check for selection.
* @returns True if the value is selected; otherwise, false.
*/
isSelected(value: T): boolean {
return this.selectionModel.isSelected(value);
}
}