diff --git a/tedi/components/form/feedback-text/feedback-text.component.scss b/tedi/components/form/feedback-text/feedback-text.component.scss
index 39d88635..426eb912 100644
--- a/tedi/components/form/feedback-text/feedback-text.component.scss
+++ b/tedi/components/form/feedback-text/feedback-text.component.scss
@@ -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 {
diff --git a/tedi/components/form/number-field/number-field.component.scss b/tedi/components/form/number-field/number-field.component.scss
index d34ccc30..8577fe5a 100644
--- a/tedi/components/form/number-field/number-field.component.scss
+++ b/tedi/components/form/number-field/number-field.component.scss
@@ -138,13 +138,14 @@
grid-column: span 2;
width: 100%;
font-size: var(--heading-h6-size);
+ color: var(--form-input-text-filled);
text-align: center;
outline: none;
background-color: var(--form-input-background-default);
border: 0;
border-radius: 0;
- // /* Chrome, Safari, Edge, Opera */
+ /* Chrome, Safari, Edge, Opera */
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
appearance: none;
diff --git a/tedi/components/form/number-field/number-field.stories.ts b/tedi/components/form/number-field/number-field.stories.ts
index 03ae66fe..b426552e 100644
--- a/tedi/components/form/number-field/number-field.stories.ts
+++ b/tedi/components/form/number-field/number-field.stories.ts
@@ -152,7 +152,7 @@ export default {
},
feedbackText: {
description:
- "[FeedbackText](/?path=/docs/community-angular-form-feedbacktext--docs) component inputs.",
+ "[FeedbackText](/?path=/docs/tedi-ready-components-form-feedbacktext--docs) component inputs.",
control: {
type: "object",
},
diff --git a/tedi/components/form/text-field/text-field.component.html b/tedi/components/form/text-field/text-field.component.html
new file mode 100644
index 00000000..cca2fea6
--- /dev/null
+++ b/tedi/components/form/text-field/text-field.component.html
@@ -0,0 +1,72 @@
+@if (label()) {
+
+}
+
+
+
+
+ @if (showClearButton()) {
+
+
+ @if (icon()) {
+
+ }
+
+ }
+
+ @if (resolvedIcon(); as icon) {
+
+
+
+ }
+
+
+@if (helper(); as feedback) {
+
+
+
+}
diff --git a/tedi/components/form/text-field/text-field.component.scss b/tedi/components/form/text-field/text-field.component.scss
new file mode 100644
index 00000000..2cef8e5a
--- /dev/null
+++ b/tedi/components/form/text-field/text-field.component.scss
@@ -0,0 +1,125 @@
+.tedi-text-field {
+ position: relative;
+ display: flex;
+ gap: var(--form-field-inner-spacing);
+ align-items: center;
+ width: 100%;
+ background: var(--form-input-background-default);
+ border: 1px solid var(--form-input-border-default);
+ border-radius: var(--form-field-radius);
+
+ &:hover,
+ &--hover {
+ border-color: var(--form-input-border-hover);
+ }
+
+ &:focus-within,
+ &--focus {
+ border-color: var(--form-input-border-focus);
+ box-shadow: 0 0 0 1px var(--form-input-border-focus);
+ }
+
+ &:active,
+ &--active {
+ border-color: var(--form-input-border-active);
+ box-shadow: 0 0 0 1px var(--form-input-border-active);
+ }
+
+ &.tedi-text-field--valid {
+ border-color: var(--form-general-feedback-success-border);
+
+ &:focus-within {
+ box-shadow: 0 0 0 1px var(--form-general-feedback-success-border);
+ }
+ }
+
+ &.tedi-text-field--invalid {
+ border-color: var(--form-general-feedback-error-border);
+
+ &:focus-within {
+ box-shadow: 0 0 0 1px var(--form-general-feedback-error-border);
+ }
+ }
+
+ &.tedi-text-field--disabled {
+ cursor: not-allowed;
+ background: var(--form-input-background-disabled);
+ border-color: var(--form-input-border-disabled);
+ box-shadow: none;
+ }
+
+ &--small .tedi-text-field__input {
+ height: var(--form-field-height-sm);
+ padding-block: var(--form-field-padding-y-sm);
+ padding-inline: var(--form-field-padding-x-md-default);
+ }
+
+ &--large .tedi-text-field__input {
+ height: var(--form-field-height-lg);
+ padding-block: var(--form-field-padding-y-lg);
+ padding-inline: var(--form-field-padding-x-lg);
+ }
+
+ &--with-icon {
+ padding-right: var(--form-field-padding-x-md-default);
+
+ &.tedi-text-field--large {
+ padding-right: var(--form-field-padding-x-lg);
+ }
+
+ .tedi-text-field__input {
+ padding-right: 0;
+ }
+ }
+
+ &__clear {
+ &:disabled {
+ cursor: not-allowed;
+ }
+ }
+}
+
+.tedi-text-field__input {
+ flex: 1;
+ height: var(--form-field-height);
+ padding: var(--form-field-padding-y-md-default)
+ var(--form-field-padding-x-md-default);
+ font-size: var(--body-regular-size);
+ font-weight: var(--body-regular-weight);
+ line-height: var(--body-regular-line-height);
+ color: var(--form-input-text-filled);
+ outline: none;
+ background: transparent;
+ border: 0;
+
+ &::placeholder {
+ color: var(--form-input-text-placeholder);
+ }
+
+ &:disabled {
+ color: var(--form-input-text-disabled);
+ cursor: not-allowed;
+ background: transparent;
+ }
+
+ /* Chrome, Safari, Edge, Opera */
+ &--arrows-hidden::-webkit-outer-spin-button,
+ &--arrows-hidden::-webkit-inner-spin-button {
+ appearance: none;
+ }
+
+ /* Firefox */
+ &--arrows-hidden[type="number"] {
+ appearance: textfield;
+ }
+}
+
+.tedi-text-field__input-buttons {
+ display: flex;
+ gap: var(--layout-grid-gutters-04);
+ align-items: center;
+}
+
+.tedi-text-field__feedback {
+ margin-top: var(--form-field-outer-spacing);
+}
diff --git a/tedi/components/form/text-field/text-field.component.spec.ts b/tedi/components/form/text-field/text-field.component.spec.ts
new file mode 100644
index 00000000..4f4e4496
--- /dev/null
+++ b/tedi/components/form/text-field/text-field.component.spec.ts
@@ -0,0 +1,224 @@
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { TextFieldComponent, TextFieldIcon } from "./text-field.component";
+import { ReactiveFormsModule } from "@angular/forms";
+import { Component } from "@angular/core";
+import { By } from "@angular/platform-browser";
+import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../tokens/translation.token";
+import { ComponentInputs } from "tedi/types";
+import { FeedbackTextComponent } from "../feedback-text/feedback-text.component";
+
+@Component({
+ standalone: true,
+ imports: [TextFieldComponent, ReactiveFormsModule],
+ template: ``,
+})
+class TestHostComponent {
+ value = "";
+ required = false;
+ invalid = false;
+ inputAttrs: Record = {};
+ isClearable = false;
+ disabled = false;
+ icon: string | TextFieldIcon | undefined = undefined;
+ helper?: ComponentInputs;
+ size = "default";
+ inputClass = "";
+}
+
+describe("TextFieldComponent", () => {
+ let fixture: ComponentFixture;
+ let host: TestHostComponent;
+ let input: HTMLInputElement;
+ let textField: TextFieldComponent;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [TestHostComponent],
+ providers: [{ provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(TestHostComponent);
+ host = fixture.componentInstance;
+
+ fixture.detectChanges();
+
+ const textFieldDebug = fixture.debugElement.query(
+ By.directive(TextFieldComponent),
+ );
+ textField = textFieldDebug.componentInstance;
+ input = textFieldDebug.nativeElement.querySelector("input");
+ });
+
+ it("should create", () => {
+ expect(textField).toBeTruthy();
+ expect(input).toBeTruthy();
+ });
+
+ it("writeValue() should set the passed-in value", () => {
+ textField.writeValue("test");
+ fixture.detectChanges();
+
+ expect(input.value).toBe("test");
+ });
+
+ it("setDisabledState() should toggle formDisabled and disable input", () => {
+ textField.setDisabledState(true);
+ fixture.detectChanges();
+
+ expect(input.disabled).toBeTruthy();
+
+ textField.setDisabledState(false);
+ fixture.detectChanges();
+
+ expect(input.disabled).toBeFalsy();
+ });
+
+ it("should disable input when disabled input is true", () => {
+ host.disabled = true;
+ fixture.detectChanges();
+
+ expect(input.disabled).toBeTruthy();
+ });
+
+ it("should apply required attribute", () => {
+ host.required = true;
+ fixture.detectChanges();
+
+ expect(input.required).toBeTruthy();
+ });
+
+ it("should clear input when clear button clicked", () => {
+ host.isClearable = true;
+ host.value = "Test";
+ fixture.detectChanges();
+
+ const button: HTMLButtonElement =
+ fixture.nativeElement.querySelector("button");
+ button.click();
+ fixture.detectChanges();
+
+ expect(input.value).toBe("");
+ });
+
+ it("should not show clear button when isClearable is false", () => {
+ host.isClearable = false;
+ host.value = "Test";
+ fixture.detectChanges();
+
+ const button = fixture.nativeElement.querySelector("button");
+ expect(button).toBeNull();
+ });
+
+ it("should not show clear button when value is empty", () => {
+ host.isClearable = true;
+ host.value = "";
+ fixture.detectChanges();
+
+ const button = fixture.nativeElement.querySelector("button");
+ expect(button).toBeNull();
+ });
+
+ it("should set aria-invalid when validation state is invalid", () => {
+ host.invalid = true;
+ fixture.detectChanges();
+
+ expect(input.getAttribute("aria-invalid")).toBe("true");
+ });
+
+ it("should be invalid when helper type is error", () => {
+ host.invalid = false;
+ host.helper = { text: "Error message", type: "error", position: "left" };
+ fixture.detectChanges();
+
+ expect(input.getAttribute("aria-invalid")).toBe("true");
+ });
+
+ it("should apply valid class when helper type is valid", () => {
+ host.helper = { text: "Success message", type: "valid", position: "left" };
+ fixture.detectChanges();
+
+ const container = fixture.nativeElement.querySelector(".tedi-text-field");
+ expect(container.classList.contains("tedi-text-field--valid")).toBeTruthy();
+ });
+
+ it("should apply inputAttrs to input", () => {
+ host.inputAttrs = { inputmode: "numeric", autocomplete: "off" };
+ fixture.detectChanges();
+
+ expect(input.getAttribute("inputmode")).toBe("numeric");
+ expect(input.getAttribute("autocomplete")).toBe("off");
+ });
+
+ 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 resolve string icon to config object", () => {
+ host.icon = "search";
+ 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 apply small size class", () => {
+ host.size = "small";
+ fixture.detectChanges();
+
+ const container = fixture.nativeElement.querySelector(".tedi-text-field");
+ expect(container.classList.contains("tedi-text-field--small")).toBeTruthy();
+ });
+
+ it("should apply custom input class", () => {
+ host.inputClass = "custom-class";
+ fixture.detectChanges();
+
+ expect(input.classList.contains("custom-class")).toBeTruthy();
+ });
+
+ it("should call onChange when input changes", () => {
+ const onChangeSpy = jest.fn();
+ textField.registerOnChange(onChangeSpy);
+
+ input.value = "test";
+ input.dispatchEvent(new Event("input"));
+ fixture.detectChanges();
+
+ expect(onChangeSpy).toHaveBeenCalledWith("test");
+ expect(textField.value()).toBe("test");
+ });
+
+ it("should call onTouched when input is blurred", () => {
+ const onTouchedSpy = jest.fn();
+ textField.registerOnTouched(onTouchedSpy);
+
+ input.dispatchEvent(new Event("blur"));
+ fixture.detectChanges();
+
+ expect(onTouchedSpy).toHaveBeenCalled();
+ });
+});
diff --git a/tedi/components/form/text-field/text-field.component.ts b/tedi/components/form/text-field/text-field.component.ts
new file mode 100644
index 00000000..a156c174
--- /dev/null
+++ b/tedi/components/form/text-field/text-field.component.ts
@@ -0,0 +1,252 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ input,
+ model,
+ ViewEncapsulation,
+ forwardRef,
+ signal,
+ output,
+} from "@angular/core";
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
+import {
+ ClosingButtonComponent,
+ ComponentInputs,
+ FeedbackTextComponent,
+ IconColor,
+ IconComponent,
+ IconSize,
+ IconType,
+ IconVariant,
+ LabelComponent,
+ SeparatorComponent,
+ TediTranslationPipe,
+} from "@tedi-design-system/angular/tedi";
+import { NgClass } from "@angular/common";
+import { SpreadAttrsDirective } from "../../../directives/spread-attrs/spread-attrs.directive";
+
+export type InputSize = "small" | "large" | "default";
+export type InputState = "valid" | "error" | "default";
+type ValidationState = "invalid" | "valid" | "neutral";
+type PseudoState = "Hover" | "Active" | "Focus";
+
+export interface TextFieldIcon {
+ name: string;
+ size?: IconSize;
+ color?: IconColor;
+ type?: IconType;
+ variant?: IconVariant;
+}
+
+@Component({
+ selector: "tedi-text-field",
+ standalone: true,
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => TextFieldComponent),
+ multi: true,
+ },
+ ],
+ imports: [
+ NgClass,
+ LabelComponent,
+ IconComponent,
+ FeedbackTextComponent,
+ ClosingButtonComponent,
+ SeparatorComponent,
+ SpreadAttrsDirective,
+ TediTranslationPipe,
+ ],
+ templateUrl: "./text-field.component.html",
+ styleUrl: "./text-field.component.scss",
+ encapsulation: ViewEncapsulation.None,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class TextFieldComponent implements ControlValueAccessor {
+ /**
+ * The unique identifier for the input element that this label is associated with. This ID should match the input element's id attribute to ensure accessibility.
+ */
+ inputId = input.required();
+ /**
+ * The text content of the label that describes the input field.
+ */
+ label = input();
+ /**
+ * Indicates whether the input field is required. If set to true, the required indicator will be displayed next to the label.
+ * @default false
+ */
+ required = input(false);
+ /**
+ * The size of the input.
+ * @default "default"
+ */
+ size = input("default");
+ /**
+ * Value of the input field. Supports two-way binding, use with form controls.
+ */
+ value = model("");
+ /**
+ * Marks the field as invalid for validation purposes.
+ * @default false
+ */
+ invalid = input(false);
+ /**
+ * Whether the input is disabled.
+ * @default false
+ */
+ disabled = input(false);
+ /**
+ * Placeholder text displayed inside the input.
+ */
+ placeholder = input("");
+ /**
+ * Icon name or configuration object.
+ */
+ icon = input();
+ /**
+ * Whether the input includes a clear button.
+ * @default false
+ */
+ isClearable = input(false);
+ /**
+ * Helper text or feedback messages.
+ */
+ helper = input>();
+ /**
+ * Name attribute for the input element.
+ */
+ name = input(null);
+ /**
+ * Whether the input is read-only.
+ * @default false
+ */
+ readOnly = input(false);
+ /**
+ * Whether to hide arrows for number inputs.
+ * @default true
+ */
+ arrowsHidden = input(true);
+ /**
+ * Additional attributes to pass directly to the input element.
+ */
+ inputAttrs = input>({});
+ /**
+ * Custom CSS classes for the container.
+ */
+ class = input(null);
+ /**
+ * Custom CSS classes for the input element.
+ */
+ inputClass = input(null);
+ /**
+ * Internal: used only for Storybook pseudo-state rendering.
+ * Do not use in production.
+ */
+ readonly _forceState = input(null);
+ /**
+ * Callback triggered when the clear button is clicked.
+ */
+ readonly clear = output();
+
+ private formDisabled = signal(false);
+ private onChange: (value: string) => void = () => {};
+ private onTouched: () => void = () => {};
+
+ writeValue(value: string | null): void {
+ const newValue = value ?? "";
+
+ if (newValue !== this.value()) {
+ this.value.set(newValue);
+ }
+ }
+
+ registerOnChange(fn: (value: string) => void): void {
+ this.onChange = fn;
+ }
+
+ registerOnTouched(fn: () => void): void {
+ this.onTouched = fn;
+ }
+
+ setDisabledState(isDisabled: boolean): void {
+ this.formDisabled.set(isDisabled);
+ }
+
+ readonly resolvedIcon = computed(() => {
+ const icon = this.icon();
+ if (!icon) return undefined;
+
+ return typeof icon === "string" ? { name: icon } : icon;
+ });
+
+ readonly feedbackId = computed(() =>
+ this.helper() ? `${this.inputId()}-feedback` : null,
+ );
+
+ readonly isDisabled = computed(() => this.disabled() || this.formDisabled());
+
+ readonly validationState = computed(() => {
+ if (this.invalid() || this.helper()?.type === "error") {
+ return "invalid";
+ }
+
+ if (this.helper()?.type === "valid") {
+ return "valid";
+ }
+
+ return "neutral";
+ });
+
+ showClearButton = computed(() => {
+ return this.isClearable() && this.value();
+ });
+
+ handleInputChange(event: Event) {
+ const input = event.target as HTMLInputElement;
+ const value = input.value;
+
+ this.value.set(value);
+ this.onChange(value);
+ }
+
+ handleBlur() {
+ this.onTouched();
+ }
+
+ clearInput() {
+ this.value.set("");
+ this.onChange("");
+ this.clear.emit();
+ this.onTouched();
+ }
+
+ readonly containerClasses = computed(() => {
+ const customClass = this.class();
+
+ return {
+ "tedi-text-field": true,
+ ...(customClass ? { [customClass]: true } : {}),
+ "tedi-text-field--hover": this._forceState() === "Hover",
+ "tedi-text-field--active": this._forceState() === "Active",
+ "tedi-text-field--focus": this._forceState() === "Focus",
+ "tedi-text-field--valid": this.validationState() === "valid",
+ "tedi-text-field--invalid": this.validationState() === "invalid",
+ "tedi-text-field--disabled": this.isDisabled(),
+ "tedi-text-field--small": this.size() === "small",
+ "tedi-text-field--large": this.size() === "large",
+ "tedi-text-field--with-icon": this.showClearButton() || !!this.icon(),
+ };
+ });
+
+ readonly inputClasses = computed(() => {
+ const customClass = this.inputClass();
+
+ return {
+ "tedi-text-field__input": true,
+ ...(customClass ? { [customClass]: true } : {}),
+ "tedi-text-field__input--arrows-hidden": this.arrowsHidden(),
+ };
+ });
+}
diff --git a/tedi/components/form/text-field/text-field.stories.ts b/tedi/components/form/text-field/text-field.stories.ts
new file mode 100644
index 00000000..709a4a26
--- /dev/null
+++ b/tedi/components/form/text-field/text-field.stories.ts
@@ -0,0 +1,356 @@
+import {
+ argsToTemplate,
+ Meta,
+ moduleMetadata,
+ StoryObj,
+} from "@storybook/angular";
+import { TextFieldComponent } from "./text-field.component";
+import { ColComponent, RowComponent } from "tedi/components/helpers";
+import { LabelComponent } from "../label/label.component";
+import { TextComponent } from "tedi/components/base";
+
+const PSEUDO_STATE = ["Default", "Hover", "Active", "Disabled", "Focus"];
+
+/**
+ * Figma ↗
+ * Zeroheight ↗
+ * Can be used with Reactive forms and with Template-driven forms
+ */
+
+export default {
+ title: "TEDI-Ready/Components/Form/TextField",
+ component: TextFieldComponent,
+ decorators: [
+ moduleMetadata({
+ imports: [RowComponent, ColComponent, LabelComponent, TextComponent],
+ }),
+ ],
+ argTypes: {
+ inputId: {
+ description:
+ "The unique identifier for the input element that this label is associated with. This ID should match the input element's id attribute to ensure accessibility.",
+ control: {
+ type: "text",
+ },
+ table: {
+ category: "inputs",
+ type: { summary: "string" },
+ },
+ },
+ label: {
+ description:
+ "The text content of the label that describes the input field.",
+ control: {
+ type: "text",
+ },
+ table: {
+ category: "inputs",
+ type: { summary: "string" },
+ },
+ },
+ name: {
+ description: "Name attribute for the input element.",
+ control: {
+ type: "text",
+ },
+ table: {
+ category: "inputs",
+ type: { summary: "string" },
+ },
+ },
+ value: {
+ description:
+ "Value of the input field. Supports two-way binding, use with form controls.",
+ control: {
+ type: "text",
+ },
+ table: {
+ category: "inputs",
+ type: { summary: "string" },
+ },
+ },
+ disabled: {
+ description: "Whether the input is disabled.",
+ control: {
+ type: "boolean",
+ },
+ table: {
+ category: "inputs",
+ type: { summary: "boolean" },
+ defaultValue: { summary: "false" },
+ },
+ },
+ required: {
+ description:
+ "Indicates whether the input field is required. If set to true, the required indicator will be displayed next to the label.",
+ control: {
+ type: "boolean",
+ },
+ table: {
+ category: "inputs",
+ type: { summary: "boolean" },
+ defaultValue: { summary: "false" },
+ },
+ },
+ size: {
+ description: "Input field size.",
+ control: {
+ type: "select",
+ },
+ options: ["default", "small", "large"],
+ table: {
+ category: "inputs",
+ type: { summary: "TextFieldSize", detail: "default \nsmall" },
+ defaultValue: { summary: "default" },
+ },
+ },
+ invalid: {
+ description: "Marks the field as invalid for validation purposes.",
+ control: {
+ type: "boolean",
+ },
+ table: {
+ category: "inputs",
+ type: { summary: "boolean" },
+ defaultValue: { summary: "false" },
+ },
+ },
+ placeholder: {
+ description: "Placeholder text displayed inside the input.",
+ control: {
+ type: "text",
+ },
+ table: {
+ category: "inputs",
+ type: { summary: "string" },
+ },
+ },
+ icon: {
+ description: "Icon name or configuration for the input field.",
+ control: {
+ type: "object",
+ },
+ table: {
+ category: "inputs",
+ type: { summary: "string | TextFieldIcon" },
+ },
+ },
+ isClearable: {
+ description: "Whether the input includes a clear button.",
+ control: {
+ type: "boolean",
+ },
+ table: {
+ category: "inputs",
+ type: { summary: "boolean" },
+ defaultValue: { summary: "false" },
+ },
+ },
+ readOnly: {
+ description: "Whether the input is read-only.",
+ control: {
+ type: "boolean",
+ },
+ table: {
+ category: "inputs",
+ type: { summary: "boolean" },
+ defaultValue: { summary: "false" },
+ },
+ },
+ arrowsHidden: {
+ description: "Whether to hide arrows for number inputs.",
+ control: {
+ type: "boolean",
+ },
+ table: {
+ category: "inputs",
+ type: { summary: "boolean" },
+ defaultValue: { summary: "true" },
+ },
+ },
+ inputAttrs: {
+ description: "Additional attributes for the input element.",
+ control: { type: "object" },
+ table: {
+ category: "inputs",
+ type: { summary: "InputHTMLAttributes" },
+ },
+ },
+ class: {
+ control: "text",
+ description: "Custom CSS classes for the container.",
+ table: {
+ category: "inputs",
+ type: { summary: "string" },
+ defaultValue: { summary: "" },
+ },
+ },
+ inputClass: {
+ control: "text",
+ description: "Custom CSS classes for the input element.",
+ table: {
+ category: "inputs",
+ type: { summary: "string" },
+ defaultValue: { summary: "" },
+ },
+ },
+ },
+} as Meta;
+
+export const Default: StoryObj = {
+ args: {
+ inputId: "example-id",
+ label: "Label",
+ required: false,
+ value: "",
+ invalid: false,
+ disabled: false,
+ placeholder: "Placeholder",
+ icon: {
+ name: "person",
+ },
+ isClearable: false,
+ name: "",
+ readOnly: false,
+ arrowsHidden: true,
+ inputAttrs: {},
+ class: "",
+ inputClass: "",
+ },
+ render: (args) => ({
+ props: {
+ ...args,
+ handleClear: () => {
+ console.log("Input cleared");
+ },
+ },
+ template: ``,
+ }),
+};
+
+export const Size: StoryObj = {
+ render: (args) => ({
+ props: args,
+ template: `
+
+
+ Default
+
+
+
+
+ Small
+
+
+
+
+ `,
+ }),
+};
+
+export const States: StoryObj = {
+ parameters: {
+ pseudo: {
+ hover: "#Hover",
+ active: "#Active",
+ focusVisible: "#Focus",
+ },
+ },
+ render: (args) => ({
+ props: { args, PSEUDO_STATE },
+ template: `
+
+
+
+ {{ state }}
+
+
+
+
+
+
+
+ Error
+
+
+
+
+
+
+
+ Success
+
+
+
+
+
+
+ `,
+ }),
+};
+
+export const WithHint: StoryObj = {
+ args: {
+ inputId: "example-hint",
+ label: "Label",
+ helper: {
+ text: "Hint text",
+ type: "hint",
+ position: "left",
+ },
+ },
+ render: (args) => ({
+ props: args,
+ template: `
+ `,
+ }),
+};
+
+export const Password: StoryObj = {
+ args: {
+ inputId: "example-password",
+ label: "Label",
+ inputAttrs: { type: "password" },
+ value: "123456789",
+ },
+ render: (args) => ({
+ props: args,
+ template: ``,
+ }),
+};
+
+export const Placeholder: StoryObj = {
+ args: {
+ inputId: "example-placeholder",
+ label: "Label",
+ },
+ render: (args) => ({
+ props: args,
+ template: ``,
+ }),
+};
diff --git a/tedi/directives/index.ts b/tedi/directives/index.ts
index 35045075..076d1a18 100644
--- a/tedi/directives/index.ts
+++ b/tedi/directives/index.ts
@@ -1,3 +1,4 @@
export * from "./hide-at/hide-at.directive";
export * from "./show-at/show-at.directive";
export * from "./vertical-spacing";
+export * from "./spread-attrs/spread-attrs.directive";
diff --git a/tedi/directives/spread-attrs/spread-attrs.directive.spec.ts b/tedi/directives/spread-attrs/spread-attrs.directive.spec.ts
new file mode 100644
index 00000000..d0b091c2
--- /dev/null
+++ b/tedi/directives/spread-attrs/spread-attrs.directive.spec.ts
@@ -0,0 +1,77 @@
+import { Component } from "@angular/core";
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { SpreadAttrsDirective } from "./spread-attrs.directive";
+
+@Component({
+ standalone: true,
+ imports: [SpreadAttrsDirective],
+ template: `
+
+ `,
+})
+class TestHostComponent {
+ attrs: Record = {};
+}
+
+describe("SpreadAttrsDirective", () => {
+ let fixture: ComponentFixture;
+ let host: TestHostComponent;
+ let input: HTMLInputElement;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [TestHostComponent],
+ });
+
+ fixture = TestBed.createComponent(TestHostComponent);
+ host = fixture.componentInstance;
+ input = fixture.nativeElement.querySelector("input");
+ });
+
+ it("should create", () => {
+ fixture.detectChanges();
+ expect(input).toBeTruthy();
+ });
+
+ it("should apply attributes", () => {
+ host.attrs = {
+ inputmode: "numeric",
+ autocomplete: "off",
+ };
+
+ fixture.detectChanges();
+
+ expect(input.getAttribute("inputmode")).toBe("numeric");
+ expect(input.getAttribute("autocomplete")).toBe("off");
+ });
+
+ it("should remove attribute when set to null", () => {
+ host.attrs = { inputmode: "numeric" };
+ fixture.detectChanges();
+
+ host.attrs = { inputmode: null };
+ fixture.detectChanges();
+
+ expect(input.hasAttribute("inputmode")).toBeFalsy();
+ });
+
+ it("should apply data attributes", () => {
+ host.attrs = {
+ "data-testid": "username-input",
+ };
+
+ fixture.detectChanges();
+
+ expect(input.getAttribute("data-testid")).toBe("username-input");
+ });
+
+ it("should apply aria attributes", () => {
+ host.attrs = {
+ "aria-label": "Custom label",
+ };
+
+ fixture.detectChanges();
+
+ expect(input.getAttribute("aria-label")).toBe("Custom label");
+ });
+});
diff --git a/tedi/directives/spread-attrs/spread-attrs.directive.ts b/tedi/directives/spread-attrs/spread-attrs.directive.ts
new file mode 100644
index 00000000..b47804ad
--- /dev/null
+++ b/tedi/directives/spread-attrs/spread-attrs.directive.ts
@@ -0,0 +1,37 @@
+import {
+ Directive,
+ ElementRef,
+ Input,
+ OnChanges,
+ Renderer2,
+ SimpleChanges,
+} from "@angular/core";
+
+@Directive({
+ selector: "[tediSpreadAttrs]",
+})
+export class SpreadAttrsDirective implements OnChanges {
+ @Input("tediSpreadAttrs") attrs: Record<
+ string,
+ string | number | boolean | null | undefined
+ > = {};
+
+ constructor(
+ private el: ElementRef,
+ private renderer: Renderer2,
+ ) {}
+
+ ngOnChanges(changes: SimpleChanges) {
+ if (changes["attrs"]) {
+ const element = this.el.nativeElement as HTMLElement;
+
+ for (const [key, value] of Object.entries(this.attrs)) {
+ if (value !== null && value !== undefined) {
+ this.renderer.setAttribute(element, key, String(value));
+ } else {
+ this.renderer.removeAttribute(element, key);
+ }
+ }
+ }
+ }
+}