mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
✨ 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:
@@ -1 +1,2 @@
|
||||
@use "./lib/checkbox/checkbox";
|
||||
@use "./lib/chips/chip-option";
|
||||
|
||||
37
libs/ui/input-controls/src/lib/chips/_chips.scss
Normal file
37
libs/ui/input-controls/src/lib/chips/_chips.scss
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
.ui-chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
210
libs/ui/input-controls/src/lib/chips/chips.component.spec.ts
Normal file
210
libs/ui/input-controls/src/lib/chips/chips.component.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user