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:
Lorenz Hilpert
2025-04-15 18:38:55 +02:00
parent fdfff237f2
commit a608d77ab5
6 changed files with 456 additions and 3 deletions

View 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,
},
};

View File

@@ -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'

View File

@@ -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);
});
});

View File

@@ -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);

View File

@@ -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();
});
});

View File

@@ -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;
}