Skip to content

Commit 965d355

Browse files
feat(radio-value-accessor): adds radio-value-accessor directive (#22)
1 parent 121a779 commit 965d355

File tree

9 files changed

+445
-5
lines changed

9 files changed

+445
-5
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './radio-control-accessor';
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './radio-value-accessor.directive';
2+
export * from './radio-control-registry.service';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import {RadioControlRegistry} from "./radio-control-registry.service";
2+
import {TestBed} from "@angular/core/testing";
3+
import {ControlValueAccessor} from "@angular/forms";
4+
import {noop} from "rxjs";
5+
6+
class ControlValueAccessSpec implements ControlValueAccessor {
7+
8+
onChange = noop;
9+
onTouched = noop;
10+
11+
registerOnChange = jest.fn();
12+
13+
registerOnTouched = jest.fn();
14+
15+
writeValue = jest.fn();
16+
17+
setDisabledState = jest.fn();
18+
}
19+
20+
const name = 'radio-control';
21+
22+
describe('Radio Control Registry', () => {
23+
24+
let registry: RadioControlRegistry;
25+
let control1: ControlValueAccessSpec;
26+
let control2: ControlValueAccessSpec;
27+
let control3: ControlValueAccessSpec;
28+
29+
beforeEach(() => {
30+
registry = TestBed.get(RadioControlRegistry);
31+
control1 = new ControlValueAccessSpec();
32+
control2 = new ControlValueAccessSpec();
33+
control3 = new ControlValueAccessSpec();
34+
});
35+
36+
it('should register "controls"', () => {
37+
registry.add(name, control1);
38+
registry.add(name, control2);
39+
registry.add(name, control3);
40+
expect(registry['accessors'].length).toBe(3);
41+
});
42+
43+
it('should remove "controls"', () => {
44+
registry.add(name, control1);
45+
registry.add(name, control2);
46+
registry.remove(control1);
47+
expect(registry['accessors'].length).toBe(1);
48+
});
49+
50+
it("should set the value of all controls matching `name`", () => {
51+
const value = 'Hello World';
52+
registry.add(name, control1);
53+
registry.add(name, control2);
54+
registry.add(`${name}-2`, control3);
55+
56+
registry.setValue(name, value)
57+
expect(control1.writeValue).toHaveBeenCalledWith(value)
58+
expect(control2.writeValue).toHaveBeenCalledWith(value)
59+
expect(control3.writeValue).not.toHaveBeenCalled();
60+
});
61+
62+
})
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import {Injectable} from "@angular/core";
2+
import type {ControlValueAccessor} from "@angular/forms";
3+
4+
@Injectable({providedIn: 'root'})
5+
export class RadioControlRegistry {
6+
private accessors: Array<[string, ControlValueAccessor]> = [];
7+
8+
add(name: string, accessor: ControlValueAccessor) {
9+
this.accessors.push([name, accessor]);
10+
}
11+
12+
remove(accessor: ControlValueAccessor) {
13+
const index = this.accessors.findIndex(([, _accessor]) => _accessor === accessor);
14+
if (index > -1) {
15+
this.accessors.splice(index, 1);
16+
}
17+
}
18+
19+
setValue(name: string, value: unknown) {
20+
this.accessors
21+
.filter(([_name]) => _name === name)
22+
.forEach(([, _accessor]) => _accessor.writeValue(value));
23+
}
24+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import {Component, DebugElement, HostBinding, HostListener, Input} from "@angular/core";
2+
import {RadioValueAccessorDirective} from "./radio-value-accessor.directive";
3+
import {CheckboxControlValueAccessor, FormControl, ReactiveFormsModule} from "@angular/forms";
4+
import {fireEvent, render, RenderResult} from "@testing-library/angular";
5+
6+
const selectors = {
7+
one: 'control-one',
8+
two: 'control-two',
9+
three: 'control-three',
10+
}
11+
12+
@Component({
13+
selector: 'app-rva',
14+
template: '',
15+
standalone: true,
16+
hostDirectives: [{
17+
directive: RadioValueAccessorDirective,
18+
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
19+
inputs: ['name', 'value'],
20+
}],
21+
})
22+
export class RadioValueAccessorSpecComponent {
23+
24+
@Input() name!: string;
25+
@Input() value!: string;
26+
27+
@HostBinding('attr.data-testid')
28+
get hostAttrTestId() {
29+
return `${this.name}-${this.value}`;
30+
}
31+
32+
@HostBinding('attr.data-selected')
33+
get hostAttrSelected() {
34+
return this.rva.selected;
35+
}
36+
37+
38+
@HostBinding('attr.data-disabled')
39+
get hostAttrDisabled() {
40+
return this.rva.disabled;
41+
}
42+
43+
@HostBinding('attr.data-value')
44+
get hostAttrValue() {
45+
return this.value;
46+
}
47+
48+
@HostListener('click')
49+
onClick() {
50+
this.rva.select();
51+
}
52+
53+
constructor(
54+
private rva: RadioValueAccessorDirective,
55+
) {
56+
}
57+
58+
}
59+
60+
61+
describe('Radio Value Accessor Directive', () => {
62+
63+
it('should throw an error if name is not provided', async () => {
64+
const instance = await render(`<app-rva value="one"/>`, {
65+
imports: [RadioValueAccessorSpecComponent],
66+
detectChangesOnRender: false
67+
});
68+
expect(instance.detectChanges).toThrow()
69+
});
70+
71+
it('should throw an error if value is not provided', async () => {
72+
const instance = await render(`<app-rva name="one"/>`, {
73+
imports: [RadioValueAccessorSpecComponent],
74+
detectChangesOnRender: false
75+
});
76+
expect(instance.detectChanges).toThrow()
77+
});
78+
79+
describe('Control Value Accessor', () => {
80+
81+
let result: RenderResult<{ control: FormControl }>;
82+
let control1: HTMLElement;
83+
let control2: HTMLElement;
84+
85+
beforeEach(async () => {
86+
result = await render(`
87+
<app-rva name="control" value="one" [formControl]="control"/>
88+
<app-rva name="control" value="two" [formControl]="control"/>
89+
<app-rva name="control" value="three" [formControl]="control"/>
90+
`,
91+
{
92+
imports: [ReactiveFormsModule, RadioValueAccessorSpecComponent],
93+
componentProperties: {control: new FormControl<string>('')}
94+
});
95+
96+
control1 = result.getByTestId(selectors.one);
97+
control2 = result.getByTestId(selectors.two);
98+
});
99+
100+
it('should not select a control without a matching value', () => {
101+
expect(control1).toHaveAttribute('data-selected', 'false');
102+
expect(control2).toHaveAttribute('data-selected', 'false');
103+
});
104+
105+
it('should only select the interacted control', () => {
106+
fireEvent.click(control1);
107+
expect(control1).toHaveAttribute('data-selected', 'true');
108+
expect(control2).toHaveAttribute('data-selected', 'false');
109+
});
110+
111+
it('should only allow a single control to be selected', () => {
112+
fireEvent.click(control1);
113+
fireEvent.click(control2);
114+
expect(control1).toHaveAttribute('data-selected', 'false');
115+
expect(control2).toHaveAttribute('data-selected', 'true');
116+
});
117+
118+
it('should set the NG_CONTROL value to the selected value', () => {
119+
fireEvent.click(control2);
120+
const value = control2.getAttribute('data-value');
121+
expect(result.fixture.componentInstance.control.value).toBe(value);
122+
});
123+
124+
it('should disable all controls that share the NG_CONTROL', () => {
125+
result.fixture.componentInstance.control.disable();
126+
result.detectChanges();
127+
expect(control1).toHaveAttribute('data-disabled', 'true');
128+
expect(control2).toHaveAttribute('data-disabled', 'true');
129+
});
130+
131+
});
132+
133+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import {Directive, Input, OnDestroy, OnInit} from "@angular/core";
2+
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from "@angular/forms";
3+
import {noop} from "rxjs";
4+
import {RadioControlRegistry} from "./index";
5+
6+
7+
@Directive({
8+
selector: '[appRadioValueAccessor]',
9+
standalone: true,
10+
providers: [{
11+
provide: NG_VALUE_ACCESSOR,
12+
useExisting: RadioValueAccessorDirective,
13+
multi: true
14+
}]
15+
})
16+
export class RadioValueAccessorDirective implements OnInit, OnDestroy, ControlValueAccessor {
17+
18+
@Input() name!: string;
19+
@Input() value: unknown;
20+
21+
selected = false;
22+
disabled = false
23+
24+
constructor(
25+
private radioControlRegistry: RadioControlRegistry,
26+
) {
27+
}
28+
29+
onChange: (value: unknown) => void = noop;
30+
onTouched: () => void = noop;
31+
32+
ngOnInit() {
33+
if (!this.name) {
34+
throw new Error('RadioValueAccessorDirective // Name is a required Input')
35+
}
36+
37+
if (!this.value) {
38+
throw new Error('RadioValueAccessorDirective // Value is a required Input')
39+
}
40+
41+
this.radioControlRegistry.add(this.name, this);
42+
}
43+
44+
ngOnDestroy() {
45+
this.radioControlRegistry.remove(this);
46+
}
47+
48+
registerOnTouched(fn: never) {
49+
this.onTouched = fn;
50+
}
51+
52+
registerOnChange(fn: never) {
53+
this.onChange = fn;
54+
}
55+
56+
setDisabledState(isDisabled: boolean) {
57+
this.disabled = isDisabled;
58+
}
59+
60+
writeValue(value: unknown) {
61+
this.selected = value === this.value;
62+
}
63+
64+
select() {
65+
this.radioControlRegistry.setValue(this.name, this.value);
66+
this.writeValue(this.value);
67+
this.onChange(this.value);
68+
this.onTouched();
69+
}
70+
71+
}
72+

apps/www/src/test-setup.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
import 'jest-preset-angular/setup-jest';
2+
import '@testing-library/angular';
3+
import '@testing-library/jest-dom';

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@
5555
"@nrwl/linter": "15.8.3",
5656
"@nrwl/workspace": "15.8.3",
5757
"@schematics/angular": "~15.2.0",
58+
"@testing-library/angular": "^13.3.0",
59+
"@testing-library/dom": "^9.0.0",
60+
"@testing-library/jest-dom": "^5.16.5",
5861
"@types/jest": "^29.4.0",
5962
"@types/node": "16.11.7",
6063
"@typescript-eslint/eslint-plugin": "^5.36.1",

0 commit comments

Comments
 (0)