Skip to content
Merged
3 changes: 3 additions & 0 deletions community/components/form/input/input.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import {
export type InputSize = "small" | "default";
export type InputState = "valid" | "error" | "default";

/**
* @deprecated Use TextField from TEDI-ready instead. This component will be removed from future versions.
*/
@Component({
selector: "[tedi-input]",
standalone: true,
Expand Down
5 changes: 5 additions & 0 deletions community/components/form/input/textfield.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ export default {
},
},
},
parameters: {
status: {
type: ["deprecated", "existsInTediReady"],
},
},
} as Meta<InputComponent>;

type InputStory = StoryObj<InputComponent>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
color: var(--general-text-tertiary);

&--valid {
color: var(--general-status-success-text);
color: var(--form-general-feedback-success-text);
}

&--error {
color: var(--general-status-danger-text);
color: var(--form-general-feedback-error-text);
}

&--left {
Expand Down
35 changes: 35 additions & 0 deletions tedi/components/form/form-field/form-field-control.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { InjectionToken, Signal } from "@angular/core";

/**
* Interface implemented by controls that can be used inside `tedi-form-field`.
* Allows the form field container to interact with the underlying control.
*/
export interface FormFieldControl<T = unknown> {
/**
* Current value of the control.
*/
value: Signal<T>;
/**
* Whether the control is disabled.
*/
disabled: Signal<boolean>;
/**
* Whether the control is currently in an invalid state.
* Usually derived from Angular form validation state.
*/
invalid: Signal<boolean>;

/**
* Optional method used by the form field clear button.
* If implemented, the form field can trigger clearing the value.
*/
clearField?(): void;
}

/**
* Injection token used by `tedi-form-field` to obtain
* the associated control instance.
*/
export const TEDI_FORM_FIELD_CONTROL = new InjectionToken<FormFieldControl>(
"TEDI_FORM_FIELD_CONTROL",
);
43 changes: 43 additions & 0 deletions tedi/components/form/form-field/form-field.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<ng-content select="label[tedi-label]"></ng-content>

<div [ngClass]="inputClasses()">
<ng-content select="input[tedi-text-field]"></ng-content>

@if (showClearButton()) {
<div class="tedi-form-field__buttons">
<button
class="tedi-form-field__clear"
tedi-closing-button
type="button"
size="small"
[ariaLabel]="'clear' | tediTranslate"
[iconSize]="18"
[disabled]="isDisabled()"
(click)="clear()"
></button>

@if (icon()) {
<tedi-separator axis="vertical" size="1rem" />
}
</div>
}

@if (resolvedIcon(); as icon) {
<div class="tedi-form-field__icon">
<tedi-icon
[name]="icon.name"
[size]="icon.size ?? (size() === 'small' ? 16 : 18)"
[color]="icon.color ?? 'secondary'"
[type]="icon.type ?? 'outlined'"
[variant]="icon.variant ?? 'outlined'"
[attr.aria-hidden]="true"
/>
</div>
}
</div>

@if (feedback) {
<div class="tedi-form-field__feedback">
<ng-content select="tedi-feedback-text"></ng-content>
</div>
}
101 changes: 101 additions & 0 deletions tedi/components/form/form-field/form-field.component.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
.tedi-form-field {
display: flex;
flex-direction: column;

&__input {
position: relative;
display: flex;
gap: var(--form-field-inner-spacing);
align-items: center;
width: 100%;
height: var(--form-field-height);
background: var(--form-input-background-default);
border: 1px solid var(--form-input-border-default);
border-radius: var(--form-field-radius);
}

&--small {
.tedi-label {
font-size: var(--body-small-regular-size);
}

.tedi-form-field__input {
height: var(--form-field-height-sm);
}
}

&--large .tedi-form-field__input {
height: var(--form-field-height-lg);
}

&--valid .tedi-form-field__input {
border-color: var(--form-general-feedback-success-border);

&:focus-within {
box-shadow: 0 0 0 1px var(--form-general-feedback-success-border);
}
}

&--invalid .tedi-form-field__input {
border-color: var(--form-general-feedback-error-border);

&:focus-within {
box-shadow: 0 0 0 1px var(--form-general-feedback-error-border);
}
}

&--disabled .tedi-form-field__input {
cursor: not-allowed;
background: var(--form-input-background-disabled);
border-color: var(--form-input-border-disabled);
box-shadow: none;
}

&--with-icon .tedi-form-field__input {
padding-right: var(--form-field-padding-x-md-default);
}

&--with-icon.tedi-form-field--large .tedi-form-field__input {
padding-right: var(--form-field-padding-x-lg);
}

&:not(
.tedi-form-field--disabled,
.tedi-form-field--valid,
.tedi-form-field--invalid
)
.tedi-form-field__input {
&:hover,
&:has(input:hover) {
border-color: var(--form-input-border-hover);
}

&:active,
&:has(input:active) {
border-color: var(--form-input-border-active);
box-shadow: 0 0 0 1px var(--form-input-border-active);
}

&:focus-within,
&:has(input:focus-visible) {
border-color: var(--form-input-border-focus);
box-shadow: 0 0 0 1px var(--form-input-border-focus);
}
}

&__clear {
&:disabled {
cursor: not-allowed;
}
}

&__feedback {
margin-top: var(--form-field-outer-spacing);
}

&__buttons {
display: flex;
gap: var(--layout-grid-gutters-04);
align-items: center;
}
}
172 changes: 172 additions & 0 deletions tedi/components/form/form-field/form-field.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { Component, signal, ViewChild } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import {
FormFieldComponent,
FormFieldIcon,
InputSize,
} from "./form-field.component";
import {
TEDI_FORM_FIELD_CONTROL,
FormFieldControl,
} from "./form-field-control";
import { FeedbackTextComponent } from "../feedback-text/feedback-text.component";

@Component({
selector: "mock-control",
standalone: true,
template: "",
providers: [
{
provide: TEDI_FORM_FIELD_CONTROL,
useExisting: MockControlComponent,
},
],
})
class MockControlComponent implements FormFieldControl<string> {
value = signal("");
disabled = signal(false);
invalid = signal(false);
clearField = jest.fn();
}

@Component({
selector: "mock-feedback",
standalone: true,
template: "",
})
export class MockFeedbackComponent extends FeedbackTextComponent {}

@Component({
standalone: true,
imports: [FormFieldComponent, MockControlComponent, MockFeedbackComponent],
template: `
<tedi-form-field
#formField
[size]="size"
[icon]="icon"
[clearable]="clearable"
[inputClass]="inputClass"
>
<mock-control #mockControl></mock-control>
<tedi-feedback-text
#feedback
[text]="'Feedback text'"
[type]="feedbackType"
></tedi-feedback-text>
</tedi-form-field>
`,
})
class TestHostComponent {
@ViewChild("formField", { static: true }) formField!: FormFieldComponent;
@ViewChild("mockControl", { static: true })
mockControl!: MockControlComponent;
@ViewChild("feedback", { static: true }) feedback!: FeedbackTextComponent;

size: InputSize = "default";
icon?: string | FormFieldIcon;
clearable = false;
inputClass?: string;
feedbackType: "valid" | "error" | "default" = "default";
}

describe("FormFieldComponent", () => {
let fixture: ComponentFixture<TestHostComponent>;
let host: TestHostComponent;
let formField: FormFieldComponent;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TestHostComponent],
}).compileComponents();

fixture = TestBed.createComponent(TestHostComponent);
host = fixture.componentInstance;
fixture.detectChanges();

formField = host.formField;
});

it("should create", () => {
expect(formField).toBeTruthy();
});

it("should apply small size class", () => {
host.size = "small";
fixture.detectChanges();
expect(formField.hostClasses()["tedi-form-field--small"]).toBe(true);
});

it("should resolve string icon to config object", () => {
host.icon = "person";
fixture.detectChanges();

const icon = fixture.nativeElement.querySelector("tedi-icon");
expect(icon).toBeTruthy();
});

it("should use full icon config object", () => {
host.icon = { name: "search", size: 24 };
fixture.detectChanges();

const icon = fixture.nativeElement.querySelector("tedi-icon");
expect(icon).toBeTruthy();
});

it("should not render icon when icon is undefined", () => {
host.icon = undefined;
fixture.detectChanges();

const icon = fixture.nativeElement.querySelector("tedi-icon");
expect(icon).toBeNull();
});

it("should not show clear button when value is empty", () => {
host.clearable = true;
host.mockControl.value.set("");
fixture.detectChanges();

const button = fixture.nativeElement.querySelector("button");
expect(button).toBeNull();
});

it("should not show clear button when clearable is false", () => {
host.clearable = false;
host.mockControl.value.set("Test");
fixture.detectChanges();

const button = fixture.nativeElement.querySelector("button");
expect(button).toBeNull();
});

it("should call control.clearField when clear is triggered", () => {
formField.clear();

expect(host.mockControl.clearField).toHaveBeenCalled();
});

it("should be invalid when control invalid", () => {
host.mockControl.invalid.set(true);

fixture.detectChanges();

expect(formField.validationState()).toBe("invalid");
});

it("should reflect disabled state from control", () => {
host.mockControl.disabled.set(true);
fixture.detectChanges();
expect(formField.isDisabled()).toBe(true);

host.mockControl.disabled.set(false);
fixture.detectChanges();
expect(formField.isDisabled()).toBe(false);
});

it("should apply custom class", () => {
host.inputClass = "custom-class";
fixture.detectChanges();

const classes = formField.inputClasses() as Record<string, boolean>;
expect(classes["custom-class"]).toBe(true);
});
});
Loading
Loading