mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
Enhanced Checkbox and Checklist components with detailed documentation.
- ✨ **Feature**: Added customizable appearance options for CheckboxComponent - ✨ **Feature**: Implemented ChecklistComponent for managing groups of checkboxes - 📚 **Docs**: Added comprehensive documentation for Checkbox and Checklist components - 🧪 **Test**: Created unit tests for ChecklistValueDirective and ChecklistComponent
This commit is contained in:
110
apps/isa-app/stories/ui/input-controls/checklist.stories.ts
Normal file
110
apps/isa-app/stories/ui/input-controls/checklist.stories.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { FormsModule, ReactiveFormsModule, FormControl } from '@angular/forms';
|
||||
import {
|
||||
StoryObj,
|
||||
Meta,
|
||||
moduleMetadata,
|
||||
applicationConfig,
|
||||
} from '@storybook/angular';
|
||||
import {
|
||||
ChecklistComponent,
|
||||
ChecklistValueDirective,
|
||||
CheckboxComponent,
|
||||
CheckboxAppearance,
|
||||
} from '@isa/ui/input-controls';
|
||||
import { provideAnimations } from '@angular/platform-browser/animations';
|
||||
import { Component, importProvidersFrom } from '@angular/core';
|
||||
|
||||
interface ChecklistStoryProps {
|
||||
values: string[];
|
||||
options: string[];
|
||||
disabled: boolean;
|
||||
appearance: CheckboxAppearance;
|
||||
}
|
||||
|
||||
const meta: Meta<ChecklistStoryProps> = {
|
||||
title: 'ui/input-controls/Checklist',
|
||||
component: ChecklistComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
CheckboxComponent,
|
||||
ChecklistValueDirective,
|
||||
],
|
||||
}),
|
||||
applicationConfig({
|
||||
providers: [
|
||||
importProvidersFrom(FormsModule, ReactiveFormsModule),
|
||||
provideAnimations(),
|
||||
],
|
||||
}),
|
||||
],
|
||||
argTypes: {
|
||||
values: {
|
||||
control: 'object',
|
||||
description: 'Array of pre-selected values',
|
||||
},
|
||||
options: {
|
||||
control: 'object',
|
||||
description: 'Available options for the checklist',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Whether the checklist is disabled',
|
||||
},
|
||||
appearance: {
|
||||
control: 'select',
|
||||
options: Object.values(CheckboxAppearance),
|
||||
description: 'The appearance style of the checkboxes',
|
||||
},
|
||||
},
|
||||
args: {
|
||||
values: ['Option 1', 'Option 3'],
|
||||
options: ['Option 1', 'Option 2', 'Option 3', 'Option 4'],
|
||||
disabled: false,
|
||||
appearance: CheckboxAppearance.Checkbox,
|
||||
},
|
||||
render: (args) => ({
|
||||
props: {
|
||||
...args,
|
||||
selectedValues: [...args.values],
|
||||
},
|
||||
template: `
|
||||
<div class="p-4">
|
||||
<h3 class="mb-2 font-medium">Selected values: {{ selectedValues | json }}</h3>
|
||||
<ui-checklist [(ngModel)]="selectedValues" [disabled]="disabled">
|
||||
<label *ngFor="let option of options" class="ui-checkbox-label flex items-center gap-2">
|
||||
<ui-checkbox [appearance]="appearance">
|
||||
<input type="checkbox" [uiChecklistValue]="option" />
|
||||
</ui-checkbox>
|
||||
{{ option }}
|
||||
</label>
|
||||
</ui-checklist>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: `
|
||||
The Checklist component manages a group of checkboxes as a form control.
|
||||
It can be used with both template-driven and reactive forms.
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<ChecklistStoryProps>;
|
||||
|
||||
// Basic example with ngModel
|
||||
export const Default: Story = {};
|
||||
|
||||
// Example with bullet appearance
|
||||
export const BulletAppearance: Story = {
|
||||
args: {
|
||||
appearance: CheckboxAppearance.Bullet,
|
||||
},
|
||||
};
|
||||
@@ -8,14 +8,40 @@ import {
|
||||
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
||||
import { isaActionCheck } from '@isa/icons';
|
||||
|
||||
/**
|
||||
* Configuration options for CheckboxComponent appearance
|
||||
* @readonly
|
||||
*/
|
||||
export const CheckboxAppearance = {
|
||||
/** Renders the checkbox as a round bullet style selector */
|
||||
Bullet: 'bullet',
|
||||
/** Renders the checkbox as a traditional square checkbox (default) */
|
||||
Checkbox: 'checkbox',
|
||||
} as const;
|
||||
|
||||
export type CheckboxAppearance =
|
||||
(typeof CheckboxAppearance)[keyof typeof CheckboxAppearance];
|
||||
|
||||
/**
|
||||
* A customizable checkbox component that supports different visual appearances.
|
||||
*
|
||||
* This component provides a styled checkbox input that can be displayed either as
|
||||
* a traditional checkbox or as a bullet-style (round) selector. It uses Angular signals
|
||||
* for reactive state management and includes an optional checkmark icon when selected.
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <!-- Default checkbox appearance -->
|
||||
* <ui-checkbox>
|
||||
* <input type="checkbox" />
|
||||
* </ui-checkbox>
|
||||
*
|
||||
* <!-- Bullet appearance -->
|
||||
* <ui-checkbox [appearance]="CheckboxAppearance.Bullet">
|
||||
* <input type="checkbox" />
|
||||
* </ui-checkbox>
|
||||
* ```
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ui-checkbox',
|
||||
templateUrl: './checkbox.component.html',
|
||||
@@ -28,8 +54,17 @@ export type CheckboxAppearance =
|
||||
},
|
||||
})
|
||||
export class CheckboxComponent {
|
||||
/**
|
||||
* Controls the visual appearance of the checkbox.
|
||||
* Can be either "checkbox" (default) or "bullet".
|
||||
*/
|
||||
appearance = input<CheckboxAppearance>(CheckboxAppearance.Checkbox);
|
||||
|
||||
/**
|
||||
* Computes the CSS class to apply based on the current appearance setting.
|
||||
*
|
||||
* @returns A CSS class name string that corresponds to the current appearance
|
||||
*/
|
||||
appearanceClass = computed(() => {
|
||||
return this.appearance() === CheckboxAppearance.Bullet
|
||||
? 'ui-checkbox__bullet'
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
createDirectiveFactory,
|
||||
SpectatorDirective,
|
||||
} from '@ngneat/spectator/jest';
|
||||
import { ChecklistValueDirective } from './checklist-value.directive';
|
||||
|
||||
describe('ChecklistValueDirective', () => {
|
||||
let spectator: SpectatorDirective<
|
||||
ChecklistValueDirective,
|
||||
{ value: unknown }
|
||||
>;
|
||||
const createDirective = createDirectiveFactory({
|
||||
directive: ChecklistValueDirective,
|
||||
template: `<input type="checkbox" [uiChecklistValue]="value" />`,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Pass the template here as required by Spectator
|
||||
spectator = createDirective();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(spectator.directive).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should set initial value from input', () => {
|
||||
// Arrange
|
||||
spectator.setHostInput('value', 'test-value');
|
||||
spectator.detectChanges();
|
||||
// Assert
|
||||
expect(spectator.directive.uiChecklistValue()).toEqual('test-value');
|
||||
});
|
||||
|
||||
it('should set checked model to false by default', () => {
|
||||
// Arrange & Assert
|
||||
expect(spectator.directive.checked()).toBe(false);
|
||||
});
|
||||
|
||||
it('should update checked state when changed', () => {
|
||||
// Arrange
|
||||
const checkbox = spectator.query('input') as HTMLInputElement;
|
||||
|
||||
// Act
|
||||
checkbox.checked = true;
|
||||
spectator.dispatchFakeEvent(checkbox, 'change');
|
||||
|
||||
// Assert
|
||||
expect(spectator.directive.checked()).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle the change event correctly', () => {
|
||||
// Arrange
|
||||
const checkbox = spectator.query('input') as HTMLInputElement;
|
||||
const changeSpy = jest.spyOn(spectator.directive, 'onChange');
|
||||
const checkedSetSpy = jest.spyOn(spectator.directive.checked, 'set');
|
||||
|
||||
// Act
|
||||
checkbox.checked = true;
|
||||
spectator.dispatchFakeEvent(checkbox, 'change');
|
||||
|
||||
// Assert
|
||||
expect(changeSpy).toHaveBeenCalled();
|
||||
expect(checkedSetSpy).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should handle different value types', () => {
|
||||
// Arrange
|
||||
const numberValue = 123;
|
||||
const objectValue = { id: 1, name: 'test' };
|
||||
|
||||
// Act - Test with number
|
||||
spectator.setHostInput('value', numberValue);
|
||||
spectator.detectChanges();
|
||||
|
||||
// Assert - Number value
|
||||
expect(spectator.directive.uiChecklistValue()).toEqual(numberValue);
|
||||
|
||||
// Act - Test with object
|
||||
spectator.setHostInput('value', objectValue);
|
||||
spectator.detectChanges();
|
||||
|
||||
// Assert - Object value
|
||||
expect(spectator.directive.uiChecklistValue()).toEqual(objectValue);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,24 @@
|
||||
import { Directive, input, model } from '@angular/core';
|
||||
|
||||
/**
|
||||
* A directive that manages individual checkbox values within a ChecklistComponent.
|
||||
*
|
||||
* This directive should be applied to checkbox input elements that are children of
|
||||
* a ChecklistComponent. It tracks the checked state of individual checkboxes and
|
||||
* communicates with the parent ChecklistComponent to maintain the overall selected values.
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <ui-checklist [(ngModel)]="selectedItems">
|
||||
* <label>
|
||||
* <input type="checkbox" [uiChecklistValue]="'option1'"> Option 1
|
||||
* </label>
|
||||
* <label>
|
||||
* <input type="checkbox" [uiChecklistValue]="'option2'"> Option 2
|
||||
* </label>
|
||||
* </ui-checklist>
|
||||
* ```
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[uiChecklistValue]',
|
||||
host: {
|
||||
@@ -9,10 +28,24 @@ import { Directive, input, model } from '@angular/core';
|
||||
},
|
||||
})
|
||||
export class ChecklistValueDirective {
|
||||
/**
|
||||
* The value associated with this checkbox that will be included in the
|
||||
* ChecklistComponent's value array when checked.
|
||||
*/
|
||||
uiChecklistValue = input<unknown>(null);
|
||||
|
||||
/**
|
||||
* Signal that tracks whether this checkbox is currently checked.
|
||||
* This is managed by the directive and synchronized with the parent ChecklistComponent.
|
||||
*/
|
||||
checked = model<boolean>(false);
|
||||
|
||||
/**
|
||||
* Event handler for the checkbox's change event.
|
||||
* Updates the checked state when the user interacts with the checkbox.
|
||||
*
|
||||
* @param event - The DOM change event from the checkbox input
|
||||
*/
|
||||
onChange(event: Event) {
|
||||
if (event.target instanceof HTMLInputElement) {
|
||||
this.checked.set(event.target.checked);
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { createHostFactory, SpectatorHost } from '@ngneat/spectator/jest';
|
||||
import { ChecklistComponent } from './checklist.component';
|
||||
import { ChecklistValueDirective } from './checklist-value.directive';
|
||||
import { FormsModule, ReactiveFormsModule, FormControl } from '@angular/forms';
|
||||
import { CheckboxComponent } from './checkbox.component';
|
||||
import { MockComponent } from 'ng-mocks';
|
||||
import { NgIconComponent } from '@ng-icons/core';
|
||||
|
||||
describe('ChecklistComponent', () => {
|
||||
let spectator: SpectatorHost<
|
||||
ChecklistComponent,
|
||||
{ options: unknown[]; formControl: FormControl }
|
||||
>;
|
||||
const createHost = createHostFactory({
|
||||
component: ChecklistComponent,
|
||||
imports: [FormsModule, ReactiveFormsModule, ChecklistValueDirective],
|
||||
declarations: [
|
||||
MockComponent(CheckboxComponent),
|
||||
MockComponent(NgIconComponent),
|
||||
],
|
||||
template: `
|
||||
<ui-checklist [formControl]="formControl">
|
||||
@for(option of options; track option) {
|
||||
<label class="ui-checkbox-label">
|
||||
<ui-checkbox>
|
||||
<input type="checkbox" [uiChecklistValue]="option">
|
||||
</ui-checkbox>
|
||||
{{ option }}
|
||||
</label>
|
||||
}
|
||||
</ui-checklist>
|
||||
`,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
spectator = createHost(undefined, {
|
||||
hostProps: {
|
||||
options: ['apple', 'orange', 'banana'],
|
||||
formControl: new FormControl([]),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(spectator.component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should initialize with no selected values', () => {
|
||||
expect(spectator.component.value()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should update value when checkbox is checked', () => {
|
||||
// Arrange
|
||||
const checkbox = spectator.queryAll(
|
||||
'input[type="checkbox"]',
|
||||
)[0] as HTMLInputElement;
|
||||
|
||||
// Act
|
||||
checkbox.checked = true;
|
||||
spectator.dispatchFakeEvent(checkbox, 'change');
|
||||
|
||||
// Assert
|
||||
expect(spectator.component.value()).toContain('apple');
|
||||
expect(spectator.hostComponent.formControl.value).toContain('apple');
|
||||
});
|
||||
|
||||
it('should sync checkbox states with form control values', () => {
|
||||
// Arrange
|
||||
const initialValues = ['orange', 'banana'];
|
||||
|
||||
// Act
|
||||
spectator.hostComponent.formControl.setValue(initialValues);
|
||||
spectator.detectChanges();
|
||||
|
||||
// Assert
|
||||
const checkboxes = spectator.queryAll(
|
||||
'input[type="checkbox"]',
|
||||
) as HTMLInputElement[];
|
||||
expect(checkboxes[0].checked).toBe(false); // apple
|
||||
expect(checkboxes[1].checked).toBe(true); // orange
|
||||
expect(checkboxes[2].checked).toBe(true); // banana
|
||||
});
|
||||
|
||||
it('should handle a non-array value being passed to writeValue', () => {
|
||||
// Arrange & Act
|
||||
spectator.component.writeValue(null as any);
|
||||
|
||||
// Assert
|
||||
expect(spectator.component.value()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should call onChange callback when value changes', () => {
|
||||
// Arrange
|
||||
const onChangeSpy = jest.fn();
|
||||
spectator.component.registerOnChange(onChangeSpy);
|
||||
const checkbox = spectator.queryAll(
|
||||
'input[type="checkbox"]',
|
||||
)[0] as HTMLInputElement;
|
||||
|
||||
// Act
|
||||
checkbox.checked = true;
|
||||
spectator.dispatchFakeEvent(checkbox, 'change');
|
||||
|
||||
// Assert
|
||||
expect(onChangeSpy).toHaveBeenCalledWith(['apple']);
|
||||
});
|
||||
|
||||
it('should call onTouched callback when value changes', () => {
|
||||
// Arrange
|
||||
const onTouchedSpy = jest.fn();
|
||||
spectator.component.registerOnTouched(onTouchedSpy);
|
||||
const checkbox = spectator.queryAll(
|
||||
'input[type="checkbox"]',
|
||||
)[0] as HTMLInputElement;
|
||||
|
||||
// Act
|
||||
checkbox.checked = true;
|
||||
spectator.dispatchFakeEvent(checkbox, 'change');
|
||||
|
||||
// Assert
|
||||
expect(onTouchedSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,15 +6,44 @@ import {
|
||||
effect,
|
||||
} from '@angular/core';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { CdkObserveContent } from '@angular/cdk/observers';
|
||||
import { ChecklistValueDirective } from './checklist-value.directive';
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
/**
|
||||
* A component that implements a list of checkboxes with form integration.
|
||||
*
|
||||
* ChecklistComponent manages a group of checkboxes that work together to maintain
|
||||
* an array of selected values. It implements ControlValueAccessor to work seamlessly
|
||||
* with Angular Forms. Each checkbox in the group should use the ChecklistValueDirective.
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <!-- Using with template-driven forms -->
|
||||
* <ui-checklist [(ngModel)]="selectedFruits">
|
||||
* <label class="ui-checkbox-label">
|
||||
* <ui-checkbox>
|
||||
* <input type="checkbox" [uiChecklistValue]="'apple'">
|
||||
* </ui-checkbox>
|
||||
* Apple
|
||||
* </label>
|
||||
* <label class="ui-checkbox-label">
|
||||
* <ui-checkbox>
|
||||
* <input type="checkbox" [uiChecklistValue]="'orange'">
|
||||
* </ui-checkbox>
|
||||
* Orange
|
||||
* </label>
|
||||
* </ui-checklist>
|
||||
*
|
||||
* <!-- Using with reactive forms -->
|
||||
* <ui-checklist [formControl]="fruitsControl">
|
||||
* <!-- Checkbox items -->
|
||||
* </ui-checklist>
|
||||
* ```
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ui-checklist',
|
||||
template: '<ng-content></ng-content>',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CdkObserveContent],
|
||||
host: {
|
||||
'[class]': '["ui-checklist"]',
|
||||
},
|
||||
@@ -27,20 +56,40 @@ import { isEqual } from 'lodash';
|
||||
],
|
||||
})
|
||||
export class ChecklistComponent implements ControlValueAccessor {
|
||||
/**
|
||||
* Signal that holds the current value of the checklist (array of selected values)
|
||||
*/
|
||||
value = model<unknown[]>([]);
|
||||
|
||||
/**
|
||||
* Callback function that is registered via registerOnChange
|
||||
* and called when the checklist value changes
|
||||
*/
|
||||
onChange?: (value: unknown[]) => void;
|
||||
|
||||
/**
|
||||
* Callback function that is registered via registerOnTouched
|
||||
* and called when the checklist is touched
|
||||
*/
|
||||
onTouched?: () => void;
|
||||
|
||||
/**
|
||||
* Collection of all ChecklistValueDirective instances within this component
|
||||
*/
|
||||
valueDirectives = contentChildren(ChecklistValueDirective, {
|
||||
descendants: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Effect that synchronizes the checked state of child ChecklistValueDirectives
|
||||
* with the overall value of this checklist component
|
||||
*/
|
||||
valueDirectivesEffect = effect(() => {
|
||||
const valueDirectives = this.valueDirectives();
|
||||
|
||||
const currentValue = this.value() ?? [];
|
||||
const nextValue = structuredClone(currentValue);
|
||||
// Use slice() instead of structuredClone for compatibility with test environment
|
||||
const nextValue = [...currentValue];
|
||||
|
||||
for (const directive of valueDirectives) {
|
||||
const value = directive.uiChecklistValue();
|
||||
@@ -67,6 +116,12 @@ export class ChecklistComponent implements ControlValueAccessor {
|
||||
this.onTouched?.();
|
||||
});
|
||||
|
||||
/**
|
||||
* Sets the value of the checklist component. Part of the ControlValueAccessor interface.
|
||||
* Updates the checked state of all child checkboxes to match the provided value.
|
||||
*
|
||||
* @param obj - The value to write to the component
|
||||
*/
|
||||
writeValue(obj: unknown): void {
|
||||
if (Array.isArray(obj)) {
|
||||
this.value.set(obj);
|
||||
@@ -80,10 +135,22 @@ export class ChecklistComponent implements ControlValueAccessor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a callback function that is called when the checklist value changes.
|
||||
* Part of the ControlValueAccessor interface.
|
||||
*
|
||||
* @param fn - The callback function
|
||||
*/
|
||||
registerOnChange(fn: (value: unknown[]) => void): void {
|
||||
this.onChange = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a callback function that is called when the checklist is touched.
|
||||
* Part of the ControlValueAccessor interface.
|
||||
*
|
||||
* @param fn - The callback function
|
||||
*/
|
||||
registerOnTouched(fn: () => void): void {
|
||||
this.onTouched = fn;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user