Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 57 additions & 3 deletions libs/angular-components/src/lib/components/base.component.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Spacing } from "@abgov/ui-components-common";
import { booleanAttribute, Component, Input } from "@angular/core";
import {
booleanAttribute,
Component,
Input,
ElementRef,
ViewChild,
Renderer2,
} from "@angular/core";
import { ControlValueAccessor } from "@angular/forms";

@Component({
Expand Down Expand Up @@ -28,9 +35,25 @@ export abstract class GoabBaseComponent {
* - Supports `disabled="true"` and `error="true` attribute bindings for convenience.
* - Handles form control value changes and touch events via `ControlValueAccessor` methods.
* - Allows for flexible value types (`unknown`), making it suitable for various data types like integers, dates, or booleans.
* - Uses ViewChild to capture a reference to the native GOA web component element via `#goaComponentRef`.
* - Uses Renderer2 for safe DOM manipulation (compatible with SSR and security best practices).
*
* ## Usage
* Extend this class to create custom form controls. Implement additional functionality as needed for your specific use case.
* Extend this class to create custom form controls. Child components must:
* 1. Add `#goaComponentRef` template reference to their `goa-*` element in the template
* 2. Inject `Renderer2` in their constructor and pass it to `super(renderer)`
*
* ### Example:
* ```typescript
* @Component({
* template: `<goa-input #goaComponentRef [value]="value" ...></goa-input>`
* })
* export class GoabInput extends GoabControlValueAccessor {
* constructor(private cdr: ChangeDetectorRef, renderer: Renderer2) {
* super(renderer); // Required: pass Renderer2 to base class
* }
* }
* ```
*
* ## Properties
* - `id?`: An optional identifier for the component.
Expand All @@ -40,10 +63,11 @@ export abstract class GoabBaseComponent {
*
* ## Methods
* - `markAsTouched()`: Marks the component as touched and triggers the `fcTouched` callback if defined.
* - `writeValue(value: unknown)`: Writes a new value to the form control.
* - `writeValue(value: unknown)`: Writes a new value to the form control (can be overridden for special behavior like checkbox).
* - `registerOnChange(fn: any)`: Registers a function to handle changes in the form control value.
* - `registerOnTouched(fn: any)`: Registers a function to handle touch events on the form control.
* - `setDisabledState?(isDisabled: boolean)`: Sets the disabled state of the component.
* - `convertValueToString(value: unknown)`: Converts a value to a string for DOM attribute assignment (can be overridden).
*
* ## Callbacks
* - `fcChange?`: A function to handle changes in the form control value.
Expand Down Expand Up @@ -87,12 +111,42 @@ export abstract class GoabControlValueAccessor
}
}

/**
* Reference to the native GOA web component element.
* Child templates should declare `#goaComponentRef` on the `goa-*` element.
* The base class captures it here so children don't need their own ViewChild.
*/
@ViewChild("goaComponentRef", { static: false, read: ElementRef })
protected goaComponentRef?: ElementRef;

constructor(protected renderer: Renderer2) {
super();
}

/**
* Convert an arbitrary value into a string for DOM attribute assignment.
* Child classes can override when they need special formatting.
* @param value The value to convert
* @returns string representation or empty string for nullish/empty
*/
protected convertValueToString(value: unknown): string {
if (value === null || value === undefined || value === "") {
return "";
}
return String(value);
}

/**
* Writes a new value to the form control.
* @param {unknown} value - The value to write.
*/
public writeValue(value: unknown): void {
this.value = value;
const el = this.goaComponentRef?.nativeElement as HTMLElement | undefined;
if (el) {
const stringValue = this.convertValueToString(value);
this.renderer.setAttribute(el, "value", stringValue);
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
forwardRef,
OnInit,
ChangeDetectorRef,
Renderer2,
} from "@angular/core";
import { NG_VALUE_ACCESSOR } from "@angular/forms";
import { CommonModule } from "@angular/common";
Expand Down Expand Up @@ -51,8 +52,11 @@ export class GoabCheckboxList extends GoabControlValueAccessor implements OnInit
// Override value to handle string arrays consistently
@Input() override value?: string[];

constructor(private cdr: ChangeDetectorRef) {
super();
constructor(
private cdr: ChangeDetectorRef,
renderer: Renderer2,
) {
super(renderer);
}

ngOnInit(): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ describe("GoabCheckbox", () => {
expect(checkboxElement.getAttribute("maxwidth")).toBe("480px");
});

it("should handle onChange event", fakeAsync(() => {
it("should handle onChange event", async () => {
const onChange = jest.spyOn(component, "onChange");

const checkboxElement = fixture.debugElement.query(
Expand All @@ -108,7 +108,50 @@ describe("GoabCheckbox", () => {
);

expect(onChange).toHaveBeenCalled();
}));
});

describe("writeValue", () => {
it("should set checked attribute to true when value is truthy", () => {
const checkboxComponent = fixture.debugElement.query(By.css("goab-checkbox")).componentInstance;
const checkboxElement = fixture.debugElement.query(By.css("goa-checkbox")).nativeElement;

checkboxComponent.writeValue(true);
expect(checkboxElement.getAttribute("checked")).toBe("true");

checkboxComponent.writeValue("some value");
expect(checkboxElement.getAttribute("checked")).toBe("true");

checkboxComponent.writeValue(1);
expect(checkboxElement.getAttribute("checked")).toBe("true");
});

it("should set checked attribute to false when value is falsy", () => {
const checkboxComponent = fixture.debugElement.query(By.css("goab-checkbox")).componentInstance;
const checkboxElement = fixture.debugElement.query(By.css("goa-checkbox")).nativeElement;

checkboxComponent.writeValue(false);
expect(checkboxElement.getAttribute("checked")).toBe("false");

checkboxComponent.writeValue(null);
expect(checkboxElement.getAttribute("checked")).toBe("false");

checkboxComponent.writeValue(undefined);
expect(checkboxElement.getAttribute("checked")).toBe("false");

checkboxComponent.writeValue("");
expect(checkboxElement.getAttribute("checked")).toBe("false");
});

it("should update component value property", () => {
const checkboxComponent = fixture.debugElement.query(By.css("goab-checkbox")).componentInstance;

checkboxComponent.writeValue(true);
expect(checkboxComponent.value).toBe(true);

checkboxComponent.writeValue(null);
expect(checkboxComponent.value).toBe(null);
});
});
});

@Component({
Expand Down Expand Up @@ -136,7 +179,7 @@ describe("Checkbox with description slot", () => {

it("should render with slot description", fakeAsync(() => {
TestBed.configureTestingModule({
imports: [GoabCheckbox, ReactiveFormsModule, TestCheckboxWithDescriptionSlotComponent],
imports: [TestCheckboxWithDescriptionSlotComponent, GoabCheckbox, ReactiveFormsModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
}).compileComponents();

Expand All @@ -155,7 +198,7 @@ describe("Checkbox with description slot", () => {

@Component({
standalone: true,
imports: [GoabCheckbox, ReactiveFormsModule],
imports: [GoabCheckbox],
template: `
<goab-checkbox
name="test"
Expand All @@ -177,7 +220,7 @@ describe("Checkbox with reveal slot", () => {

beforeEach(fakeAsync(() => {
TestBed.configureTestingModule({
imports: [GoabCheckbox, ReactiveFormsModule, TestCheckboxWithRevealSlotComponent],
imports: [TestCheckboxWithRevealSlotComponent, GoabCheckbox, ReactiveFormsModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
}).compileComponents();

Expand Down
78 changes: 47 additions & 31 deletions libs/angular-components/src/lib/components/checkbox/checkbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
booleanAttribute,
OnInit,
ChangeDetectorRef,
Renderer2,
} from "@angular/core";
import { NG_VALUE_ACCESSOR } from "@angular/forms";
import { NgTemplateOutlet, CommonModule } from "@angular/common";
Expand All @@ -19,34 +20,35 @@ import { GoabControlValueAccessor } from "../base.component";
standalone: true,
selector: "goab-checkbox",
template: ` <goa-checkbox
*ngIf="isReady"
[attr.name]="name"
[checked]="checked"
[disabled]="disabled"
[attr.indeterminate]="indeterminate ? 'true' : undefined"
[attr.error]="error"
[attr.text]="text"
[value]="value"
[attr.testid]="testId"
[attr.arialabel]="ariaLabel"
[attr.description]="getDescriptionAsString()"
[attr.revealarialabel]="revealArialLabel"
[id]="id"
[attr.maxwidth]="maxWidth"
[attr.mt]="mt"
[attr.mb]="mb"
[attr.ml]="ml"
[attr.mr]="mr"
(_change)="_onChange($event)"
>
<ng-content />
<div slot="description">
<ng-container [ngTemplateOutlet]="getDescriptionAsTemplate()"></ng-container>
</div>
<div slot="reveal">
<ng-container *ngIf="reveal" [ngTemplateOutlet]="reveal"></ng-container>
</div>
</goa-checkbox>`,
#goaComponentRef
*ngIf="isReady"
[attr.name]="name"
[checked]="checked"
[disabled]="disabled"
[attr.indeterminate]="indeterminate ? 'true' : undefined"
[attr.error]="error"
[attr.text]="text"
[value]="value"
[attr.testid]="testId"
[attr.arialabel]="ariaLabel"
[attr.description]="getDescriptionAsString()"
[attr.revealarialabel]="revealArialLabel"
[id]="id"
[attr.maxwidth]="maxWidth"
[attr.mt]="mt"
[attr.mb]="mb"
[attr.ml]="ml"
[attr.mr]="mr"
(_change)="_onChange($event)"
>
<ng-content />
<div slot="description">
<ng-container [ngTemplateOutlet]="getDescriptionAsTemplate()"></ng-container>
</div>
<div slot="reveal">
<ng-container *ngIf="reveal" [ngTemplateOutlet]="reveal"></ng-container>
</div>
</goa-checkbox>`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
providers: [
{
Expand All @@ -60,8 +62,11 @@ import { GoabControlValueAccessor } from "../base.component";
export class GoabCheckbox extends GoabControlValueAccessor implements OnInit {
isReady = false;

constructor(private cdr: ChangeDetectorRef) {
super();
constructor(
private cdr: ChangeDetectorRef,
renderer: Renderer2,
) {
super(renderer);
}

ngOnInit(): void {
Expand All @@ -78,7 +83,7 @@ export class GoabCheckbox extends GoabControlValueAccessor implements OnInit {
@Input({ transform: booleanAttribute }) indeterminate?: boolean;
@Input() text?: string;
// ** NOTE: can we just use the base component for this?
@Input() override value?: string | number | boolean;
@Input() override value?: string | number | boolean | null;
@Input() ariaLabel?: string;
@Input() description!: string | TemplateRef<any>;
@Input() reveal?: TemplateRef<any>;
Expand All @@ -104,4 +109,15 @@ export class GoabCheckbox extends GoabControlValueAccessor implements OnInit {
this.markAsTouched();
this.fcChange?.(detail.binding === "check" ? detail.checked : detail.value || "");
}

// Checkbox is a special case: it uses `checked` instead of `value`.
override writeValue(value: string | number | boolean | null): void {
this.value = value;
this.checked = !!value;

const el = this.goaComponentRef?.nativeElement as HTMLElement | undefined;
if (el) {
this.renderer.setAttribute(el, "checked", this.checked ? "true" : "false");
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { GoabDatePickerInputType, GoabDatePickerOnChangeDetail } from "@abgov/ui-components-common";
import {
GoabDatePickerInputType,
GoabDatePickerOnChangeDetail,
} from "@abgov/ui-components-common";
import {
CUSTOM_ELEMENTS_SCHEMA,
Component,
Expand All @@ -10,6 +13,7 @@ import {
HostListener,
OnInit,
ChangeDetectorRef,
Renderer2,
} from "@angular/core";
import { NG_VALUE_ACCESSOR } from "@angular/forms";
import { CommonModule } from "@angular/common";
Expand All @@ -20,6 +24,7 @@ import { GoabControlValueAccessor } from "../base.component";
selector: "goab-date-picker",
imports: [CommonModule],
template: ` <goa-date-picker
#goaComponentRef
*ngIf="isReady"
[attr.name]="name"
[attr.value]="formatValue(value)"
Expand Down Expand Up @@ -78,8 +83,12 @@ export class GoabDatePicker extends GoabControlValueAccessor implements OnInit {
this.fcChange?.(detail.value);
}

constructor(protected elementRef: ElementRef, private cdr: ChangeDetectorRef) {
super();
constructor(
protected elementRef: ElementRef,
private cdr: ChangeDetectorRef,
renderer: Renderer2,
) {
super(renderer);
}

ngOnInit(): void {
Expand All @@ -104,16 +113,12 @@ export class GoabDatePicker extends GoabControlValueAccessor implements OnInit {
override writeValue(value: Date | null): void {
this.value = value;

const datePickerEl = this.elementRef?.nativeElement?.querySelector("goa-date-picker");

const datePickerEl = this.goaComponentRef?.nativeElement as HTMLElement | undefined;
if (datePickerEl) {
if (!value) {
datePickerEl.setAttribute("value", "");
this.renderer.setAttribute(datePickerEl, "value", "");
} else {
datePickerEl.setAttribute(
"value",
value instanceof Date ? value.toISOString() : value,
);
this.renderer.setAttribute(datePickerEl, "value", value instanceof Date ? value.toISOString() : value);
}
}
}
Expand Down
Loading