From 6eb8b4d879eaed5204a503c9b6605956f6414e30 Mon Sep 17 00:00:00 2001 From: Ly Tempel Date: Wed, 25 Feb 2026 18:36:36 +0200 Subject: [PATCH 1/8] feat(text-field): add TextField Angular TEDI-Ready component #71 --- .../feedback-text.component.scss | 4 +- .../number-field/number-field.component.scss | 3 +- .../form/number-field/number-field.stories.ts | 2 +- .../form/text-field/text-field.component.html | 73 ++++ .../form/text-field/text-field.component.scss | 125 +++++++ .../text-field/text-field.component.spec.ts | 224 ++++++++++++ .../form/text-field/text-field.component.ts | 250 +++++++++++++ .../form/text-field/text-field.stories.ts | 336 ++++++++++++++++++ tedi/directives/index.ts | 1 + .../spread-attrs.directive.spec.ts | 77 ++++ .../spread-attrs/spread-attrs.directive.ts | 37 ++ 11 files changed, 1128 insertions(+), 4 deletions(-) create mode 100644 tedi/components/form/text-field/text-field.component.html create mode 100644 tedi/components/form/text-field/text-field.component.scss create mode 100644 tedi/components/form/text-field/text-field.component.spec.ts create mode 100644 tedi/components/form/text-field/text-field.component.ts create mode 100644 tedi/components/form/text-field/text-field.stories.ts create mode 100644 tedi/directives/spread-attrs/spread-attrs.directive.spec.ts create mode 100644 tedi/directives/spread-attrs/spread-attrs.directive.ts diff --git a/tedi/components/form/feedback-text/feedback-text.component.scss b/tedi/components/form/feedback-text/feedback-text.component.scss index 39d88635e..426eb912d 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 d34ccc303..8577fe5a4 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 03ae66fe7..b426552e2 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 000000000..263c1cfef --- /dev/null +++ b/tedi/components/form/text-field/text-field.component.html @@ -0,0 +1,73 @@ +@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 000000000..2cef8e5a8 --- /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 000000000..4f4e4496d --- /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 000000000..c79ee751a --- /dev/null +++ b/tedi/components/form/text-field/text-field.component.ts @@ -0,0 +1,250 @@ +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 { + ...(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 { + ...(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 000000000..3944af472 --- /dev/null +++ b/tedi/components/form/text-field/text-field.stories.ts @@ -0,0 +1,336 @@ +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" }, + }, + }, + }, +} 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: {}, + }, + 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 350450751..076d1a184 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 000000000..d0b091c24 --- /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 000000000..b47804ad7 --- /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); + } + } + } + } +} From 1c881ef8c3e9b3a0cda386e3e4386f73c579a89d Mon Sep 17 00:00:00 2001 From: Ly Tempel Date: Fri, 27 Feb 2026 13:45:21 +0200 Subject: [PATCH 2/8] feat(text-field): add missing inputs #71 --- .../form/text-field/text-field.component.html | 3 +-- .../form/text-field/text-field.component.ts | 2 ++ .../form/text-field/text-field.stories.ts | 20 +++++++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/tedi/components/form/text-field/text-field.component.html b/tedi/components/form/text-field/text-field.component.html index 263c1cfef..cca2fea6c 100644 --- a/tedi/components/form/text-field/text-field.component.html +++ b/tedi/components/form/text-field/text-field.component.html @@ -9,9 +9,8 @@ } -
+
" }, }, }, + 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; @@ -196,6 +214,8 @@ export const Default: StoryObj = { readOnly: false, arrowsHidden: true, inputAttrs: {}, + class: "", + inputClass: "", }, render: (args) => ({ props: { From 98bfde2203e5df3b8b9edb4240528741ecf483d2 Mon Sep 17 00:00:00 2001 From: Ly Tempel Date: Tue, 3 Mar 2026 18:10:36 +0200 Subject: [PATCH 3/8] feat(text-field): add deprecated to the community component #71 --- community/components/form/input/input.component.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/community/components/form/input/input.component.ts b/community/components/form/input/input.component.ts index ab6f8daf6..ff47b4785 100644 --- a/community/components/form/input/input.component.ts +++ b/community/components/form/input/input.component.ts @@ -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, From 40aa0093539425a7e76134abdd504a2f93154959 Mon Sep 17 00:00:00 2001 From: Ly Tempel Date: Wed, 4 Mar 2026 10:22:07 +0200 Subject: [PATCH 4/8] feat(text-field): update angular.json #71 --- angular.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/angular.json b/angular.json index bbe347f17..e3cc6d5bb 100644 --- a/angular.json +++ b/angular.json @@ -48,7 +48,8 @@ "outputDir": "dist/storybook-static", "styles": [ "./src/styles/index", - "./node_modules/@tedi-design-system/core/index.scss" + "./node_modules/@tedi-design-system/core/index.scss", + "./node_modules/@tedi-design-system/core/tedi-storybook-styles.scss" ], "experimentalZoneless": true } From c35f598004f8747903f01b8d4030f4b6efc21924 Mon Sep 17 00:00:00 2001 From: Ly Tempel Date: Thu, 5 Mar 2026 11:02:26 +0200 Subject: [PATCH 5/8] feat(text-field): add design review fixes #71 --- .../form/text-field/text-field.component.scss | 20 ++++++++++++------- .../form/text-field/text-field.stories.ts | 16 +++------------ 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/tedi/components/form/text-field/text-field.component.scss b/tedi/components/form/text-field/text-field.component.scss index 2cef8e5a8..2911545d3 100644 --- a/tedi/components/form/text-field/text-field.component.scss +++ b/tedi/components/form/text-field/text-field.component.scss @@ -4,6 +4,7 @@ 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); @@ -48,16 +49,22 @@ box-shadow: none; } - &--small .tedi-text-field__input { + &--small { height: var(--form-field-height-sm); - padding-block: var(--form-field-padding-y-sm); - padding-inline: var(--form-field-padding-x-md-default); + + .tedi-text-field__input { + padding-block: var(--form-field-padding-y-sm); + padding-inline: var(--form-field-padding-x-md-default); + } } - &--large .tedi-text-field__input { + &--large { height: var(--form-field-height-lg); - padding-block: var(--form-field-padding-y-lg); - padding-inline: var(--form-field-padding-x-lg); + + .tedi-text-field__input { + padding-block: var(--form-field-padding-y-lg); + padding-inline: var(--form-field-padding-x-lg); + } } &--with-icon { @@ -81,7 +88,6 @@ .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); diff --git a/tedi/components/form/text-field/text-field.stories.ts b/tedi/components/form/text-field/text-field.stories.ts index 709a4a26d..cc5ce19b3 100644 --- a/tedi/components/form/text-field/text-field.stories.ts +++ b/tedi/components/form/text-field/text-field.stories.ts @@ -95,12 +95,12 @@ export default { size: { description: "Input field size.", control: { - type: "select", + type: "radio", }, options: ["default", "small", "large"], table: { category: "inputs", - type: { summary: "TextFieldSize", detail: "default \nsmall" }, + type: { summary: "InputSize", detail: "default \nsmall \nlarge" }, defaultValue: { summary: "default" }, }, }, @@ -182,7 +182,6 @@ export default { table: { category: "inputs", type: { summary: "string" }, - defaultValue: { summary: "" }, }, }, inputClass: { @@ -191,7 +190,6 @@ export default { table: { category: "inputs", type: { summary: "string" }, - defaultValue: { summary: "" }, }, }, }, @@ -202,20 +200,12 @@ export const Default: StoryObj = { 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: "", + size: "default", }, render: (args) => ({ props: { From 9463bece0212b49fc0927ae52cf6ab94a7bf1877 Mon Sep 17 00:00:00 2001 From: Ly Tempel Date: Mon, 16 Mar 2026 11:57:41 +0200 Subject: [PATCH 6/8] feat(text-field): refactor TextField component #71 --- .../form/input/textfield.stories.ts | 5 + .../form/form-field/form-field-control.ts | 35 ++ .../form/form-field/form-field.component.html | 43 +++ .../form/form-field/form-field.component.scss | 89 +++++ .../form-field/form-field.component.spec.ts | 174 ++++++++++ .../form/form-field/form-field.component.ts | 112 +++++++ tedi/components/form/index.ts | 2 + .../form/text-field/text-field.component.html | 72 ---- .../form/text-field/text-field.component.scss | 116 ++----- .../text-field/text-field.component.spec.ts | 169 ++-------- .../form/text-field/text-field.component.ts | 231 ++++--------- .../form/text-field/text-field.stories.ts | 308 ++++++------------ tedi/directives/index.ts | 1 - .../spread-attrs.directive.spec.ts | 77 ----- .../spread-attrs/spread-attrs.directive.ts | 37 --- 15 files changed, 670 insertions(+), 801 deletions(-) create mode 100644 tedi/components/form/form-field/form-field-control.ts create mode 100644 tedi/components/form/form-field/form-field.component.html create mode 100644 tedi/components/form/form-field/form-field.component.scss create mode 100644 tedi/components/form/form-field/form-field.component.spec.ts create mode 100644 tedi/components/form/form-field/form-field.component.ts delete mode 100644 tedi/components/form/text-field/text-field.component.html delete mode 100644 tedi/directives/spread-attrs/spread-attrs.directive.spec.ts delete mode 100644 tedi/directives/spread-attrs/spread-attrs.directive.ts diff --git a/community/components/form/input/textfield.stories.ts b/community/components/form/input/textfield.stories.ts index 39090c654..ff0fb2963 100644 --- a/community/components/form/input/textfield.stories.ts +++ b/community/components/form/input/textfield.stories.ts @@ -33,6 +33,11 @@ export default { }, }, }, + parameters: { + status: { + type: ["deprecated", "existsInTediReady"], + }, + }, } as Meta; type InputStory = StoryObj; diff --git a/tedi/components/form/form-field/form-field-control.ts b/tedi/components/form/form-field/form-field-control.ts new file mode 100644 index 000000000..c08368787 --- /dev/null +++ b/tedi/components/form/form-field/form-field-control.ts @@ -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 { + /** + * Current value of the control. + */ + value: Signal; + /** + * Whether the control is disabled. + */ + disabled: Signal; + /** + * Whether the control is currently in an invalid state. + * Usually derived from Angular form validation state. + */ + invalid: Signal; + + /** + * 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( + "TEDI_FORM_FIELD_CONTROL", +); diff --git a/tedi/components/form/form-field/form-field.component.html b/tedi/components/form/form-field/form-field.component.html new file mode 100644 index 000000000..e5c4a0514 --- /dev/null +++ b/tedi/components/form/form-field/form-field.component.html @@ -0,0 +1,43 @@ + + +
+ + + @if (showClearButton()) { +
+ + + @if (icon()) { + + } +
+ } + + @if (resolvedIcon(); as icon) { +
+ +
+ } +
+ +@if (feedback) { + +} diff --git a/tedi/components/form/form-field/form-field.component.scss b/tedi/components/form/form-field/form-field.component.scss new file mode 100644 index 000000000..663a68b32 --- /dev/null +++ b/tedi/components/form/form-field/form-field.component.scss @@ -0,0 +1,89 @@ +.tedi-form-field { + 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); + + &:not( + .tedi-form-field--disabled, + .tedi-form-field--valid, + .tedi-form-field--invalid + ) { + &: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); + } + } + + &.tedi-form-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-form-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-form-field--disabled { + cursor: not-allowed; + background: var(--form-input-background-disabled); + border-color: var(--form-input-border-disabled); + box-shadow: none; + } + + &--small { + height: var(--form-field-height-sm); + } + + &--large { + height: var(--form-field-height-lg); + } + + &--with-icon { + padding-right: var(--form-field-padding-x-md-default); + + &.tedi-form-field--large { + padding-right: var(--form-field-padding-x-lg); + } + } + + &__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; + } +} diff --git a/tedi/components/form/form-field/form-field.component.spec.ts b/tedi/components/form/form-field/form-field.component.spec.ts new file mode 100644 index 000000000..36cf3f2fe --- /dev/null +++ b/tedi/components/form/form-field/form-field.component.spec.ts @@ -0,0 +1,174 @@ +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 { + 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: ` + + + + + `, +}) +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; + containerClass?: string; + feedbackType: "valid" | "error" | "default" = "default"; +} + +describe("FormFieldComponent", () => { + let fixture: ComponentFixture; + 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.classes()["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.containerClass = "custom-class"; + fixture.detectChanges(); + + const classes = formField.classes() as Record; + expect(classes["custom-class"]).toBe(true); + }); +}); diff --git a/tedi/components/form/form-field/form-field.component.ts b/tedi/components/form/form-field/form-field.component.ts new file mode 100644 index 000000000..e004bb594 --- /dev/null +++ b/tedi/components/form/form-field/form-field.component.ts @@ -0,0 +1,112 @@ +import { Component, computed, ContentChild, input } from "@angular/core"; +import { NgClass } from "@angular/common"; +import { + FormFieldControl, + TEDI_FORM_FIELD_CONTROL, +} from "./form-field-control"; +import { + IconColor, + IconComponent, + IconSize, + IconType, + IconVariant, +} from "../../../components/base/icon/icon.component"; +import { ClosingButtonComponent } from "../../../components/buttons/closing-button/closing-button.component"; +import { SeparatorComponent } from "../../../components/helpers/separator/separator.component"; +import { TediTranslationPipe } from "../../../services/translation/translation.pipe"; +import { FeedbackTextComponent } from "../feedback-text/feedback-text.component"; + +export type InputSize = "small" | "large" | "default"; +export type InputState = "valid" | "error" | "default"; +type ValidationState = "invalid" | "valid" | "neutral"; + +export interface FormFieldIcon { + name: string; + size?: IconSize; + color?: IconColor; + type?: IconType; + variant?: IconVariant; +} + +@Component({ + selector: "tedi-form-field", + standalone: true, + templateUrl: "./form-field.component.html", + styleUrl: "./form-field.component.scss", + imports: [ + NgClass, + IconComponent, + ClosingButtonComponent, + SeparatorComponent, + TediTranslationPipe, + ], +}) +export class FormFieldComponent { + /** + * The size of the form field. + * @default "default" + */ + size = input("default"); + /** + * Icon name or configuration object. + */ + icon = input(); + /** + * Whether the form field includes a clear button. + * @default false + */ + clearable = input(false); + /** + * Custom CSS classes for the container. + */ + containerClass = input(null); + + @ContentChild(TEDI_FORM_FIELD_CONTROL) + control?: FormFieldControl; + + @ContentChild(FeedbackTextComponent) + feedback?: FeedbackTextComponent; + + readonly resolvedIcon = computed(() => { + const icon = this.icon(); + if (!icon) return undefined; + + return typeof icon === "string" ? { name: icon } : icon; + }); + + readonly validationState = computed(() => { + const feedbackType = this.feedback?.type(); + const fieldInvalid = this.control?.invalid(); + + if (fieldInvalid || feedbackType === "error") return "invalid"; + if (feedbackType === "valid") return "valid"; + + return "neutral"; + }); + + showClearButton = computed(() => { + const value = this.control?.value(); + return this.clearable() && !!value; + }); + + readonly isDisabled = computed(() => this.control?.disabled() ?? false); + + readonly classes = computed(() => { + const customClass = this.containerClass(); + + return { + "tedi-form-field": true, + ...(customClass ? { [customClass]: true } : {}), + "tedi-form-field--valid": this.validationState() === "valid", + "tedi-form-field--invalid": this.validationState() === "invalid", + "tedi-form-field--disabled": this.control?.disabled(), + "tedi-form-field--small": this.size() === "small", + "tedi-form-field--large": this.size() === "large", + "tedi-form-field--with-icon": this.showClearButton() || !!this.icon(), + }; + }); + + clear() { + this.control?.clearField?.(); + } +} diff --git a/tedi/components/form/index.ts b/tedi/components/form/index.ts index 9f93af829..644875c23 100644 --- a/tedi/components/form/index.ts +++ b/tedi/components/form/index.ts @@ -4,3 +4,5 @@ export * from "./feedback-text/feedback-text.component"; export * from "./label/label.component"; export * from "./number-field/number-field.component"; export * from "./toggle/toggle.component"; +export * from "./form-field/form-field.component"; +export * from "./text-field/text-field.component"; diff --git a/tedi/components/form/text-field/text-field.component.html b/tedi/components/form/text-field/text-field.component.html deleted file mode 100644 index cca2fea6c..000000000 --- a/tedi/components/form/text-field/text-field.component.html +++ /dev/null @@ -1,72 +0,0 @@ -@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 index 2911545d3..bf38d409f 100644 --- a/tedi/components/form/text-field/text-field.component.scss +++ b/tedi/components/form/text-field/text-field.component.scss @@ -1,93 +1,6 @@ .tedi-text-field { - 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); - - &: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 { - height: var(--form-field-height-sm); - - .tedi-text-field__input { - padding-block: var(--form-field-padding-y-sm); - padding-inline: var(--form-field-padding-x-md-default); - } - } - - &--large { - height: var(--form-field-height-lg); - - .tedi-text-field__input { - 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: 100%; padding: var(--form-field-padding-y-md-default) var(--form-field-padding-x-md-default); font-size: var(--body-regular-size); @@ -97,6 +10,7 @@ outline: none; background: transparent; border: 0; + border-radius: var(--form-field-radius); &::placeholder { color: var(--form-input-text-placeholder); @@ -120,12 +34,24 @@ } } -.tedi-text-field__input-buttons { - display: flex; - gap: var(--layout-grid-gutters-04); - align-items: center; -} +.tedi-form-field { + &--with-icon { + .tedi-text-field { + padding-right: 0; + } + } -.tedi-text-field__feedback { - margin-top: var(--form-field-outer-spacing); + &--small { + .tedi-text-field { + padding-block: var(--form-field-padding-y-sm); + padding-inline: var(--form-field-padding-x-md-default); + } + } + + &--large { + .tedi-text-field { + padding-block: var(--form-field-padding-y-lg); + padding-inline: var(--form-field-padding-x-lg); + } + } } diff --git a/tedi/components/form/text-field/text-field.component.spec.ts b/tedi/components/form/text-field/text-field.component.spec.ts index 4f4e4496d..b51bfe242 100644 --- a/tedi/components/form/text-field/text-field.component.spec.ts +++ b/tedi/components/form/text-field/text-field.component.spec.ts @@ -1,64 +1,36 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { TextFieldComponent, TextFieldIcon } from "./text-field.component"; +import { TextFieldComponent } 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: ``, + template: ``, }) -class TestHostComponent { - value = ""; - required = false; - invalid = false; - inputAttrs: Record = {}; - isClearable = false; - disabled = false; - icon: string | TextFieldIcon | undefined = undefined; - helper?: ComponentInputs; - size = "default"; - inputClass = ""; -} +class TestHostComponent {} 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" }], + providers: [{ provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "en" }], }).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"); + input = textFieldDebug.nativeElement; }); it("should create", () => { @@ -73,131 +45,52 @@ describe("TextFieldComponent", () => { 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("registerOnChange() triggers when input changes", () => { + const onChangeSpy = jest.fn(); + textField.registerOnChange(onChangeSpy); - it("should be invalid when helper type is error", () => { - host.invalid = false; - host.helper = { text: "Error message", type: "error", position: "left" }; + input.value = "changed"; + input.dispatchEvent(new Event("input")); fixture.detectChanges(); - expect(input.getAttribute("aria-invalid")).toBe("true"); + expect(onChangeSpy).toHaveBeenCalledWith("changed"); + expect(textField.value()).toBe("changed"); }); - 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("registerOnTouched() triggers when blurred", () => { + const onTouchedSpy = jest.fn(); + textField.registerOnTouched(onTouchedSpy); - it("should apply inputAttrs to input", () => { - host.inputAttrs = { inputmode: "numeric", autocomplete: "off" }; + input.dispatchEvent(new Event("blur")); fixture.detectChanges(); - expect(input.getAttribute("inputmode")).toBe("numeric"); - expect(input.getAttribute("autocomplete")).toBe("off"); + expect(onTouchedSpy).toHaveBeenCalled(); }); - it("should not render icon when icon is undefined", () => { - host.icon = undefined; + it("setDisabledState() should disable control", () => { + textField.setDisabledState(true); fixture.detectChanges(); - const icon = fixture.nativeElement.querySelector("tedi-icon"); - expect(icon).toBeNull(); + expect(textField.disabled()).toBe(true); + expect(input.disabled).toBe(true); }); - it("should resolve string icon to config object", () => { - host.icon = "search"; - fixture.detectChanges(); - - const icon = fixture.nativeElement.querySelector("tedi-icon"); - expect(icon).toBeTruthy(); - }); + it("clearField() should clear value", () => { + textField.writeValue("test"); - it("should use full icon config object", () => { - host.icon = { name: "search", size: 24 }; - fixture.detectChanges(); + textField.clearField(); - const icon = fixture.nativeElement.querySelector("tedi-icon"); - expect(icon).toBeTruthy(); + expect(textField.value()).toBe(""); + expect(input.value).toBe(""); }); - 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("clearField() should not clear when disabled", () => { + textField.writeValue("test"); + textField.setDisabledState(true); - it("should apply custom input class", () => { - host.inputClass = "custom-class"; - fixture.detectChanges(); + textField.clearField(); - expect(input.classList.contains("custom-class")).toBeTruthy(); + expect(textField.value()).toBe("test"); }); it("should call onChange when input changes", () => { diff --git a/tedi/components/form/text-field/text-field.component.ts b/tedi/components/form/text-field/text-field.component.ts index a156c174c..01ed145b5 100644 --- a/tedi/components/form/text-field/text-field.component.ts +++ b/tedi/components/form/text-field/text-field.component.ts @@ -8,39 +8,22 @@ import { forwardRef, signal, output, + ElementRef, + Self, + Optional, } 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; -} + ControlValueAccessor, + NG_VALUE_ACCESSOR, + NgControl, +} from "@angular/forms"; +import { + FormFieldControl, + TEDI_FORM_FIELD_CONTROL, +} from "../form-field/form-field-control"; @Component({ - selector: "tedi-text-field", + selector: "input[tedi-text-field]", standalone: true, providers: [ { @@ -48,118 +31,68 @@ export interface TextFieldIcon { useExisting: forwardRef(() => TextFieldComponent), multi: true, }, + { + provide: TEDI_FORM_FIELD_CONTROL, + useExisting: forwardRef(() => TextFieldComponent), + }, ], - imports: [ - NgClass, - LabelComponent, - IconComponent, - FeedbackTextComponent, - ClosingButtonComponent, - SeparatorComponent, - SpreadAttrsDirective, - TediTranslationPipe, - ], - templateUrl: "./text-field.component.html", + template: "", styleUrl: "./text-field.component.scss", encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: "tedi-text-field", + "[class.tedi-text-field--arrows-hidden]": "arrowsHidden()", + "[attr.aria-invalid]": "invalid() || null", + "(input)": "handleInputChange($event)", + "(blur)": "handleBlur()", + }, }) -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"); +export class TextFieldComponent + implements ControlValueAccessor, FormFieldControl +{ /** * 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(); + constructor( + private el: ElementRef, + @Optional() @Self() private ngControl: NgControl | null, + ) { + if (this.ngControl) { + this.ngControl.valueAccessor = this; + } + } + + readonly disabled = computed( + () => this.el.nativeElement.disabled || this.formDisabled(), + ); + readonly invalid = computed(() => { + const control = this.ngControl?.control; + return !!(control?.invalid && (control?.touched || control?.dirty)); + }); + private formDisabled = signal(false); private onChange: (value: string) => void = () => {}; private onTouched: () => void = () => {}; - writeValue(value: string | null): void { - const newValue = value ?? ""; + private setValue(value: string) { + this.el.nativeElement.value = value; + this.value.set(value); + } - if (newValue !== this.value()) { - this.value.set(newValue); - } + writeValue(value: string | null): void { + this.setValue(value ?? ""); } registerOnChange(fn: (value: string) => void): void { @@ -172,37 +105,9 @@ export class TextFieldComponent implements ControlValueAccessor { setDisabledState(isDisabled: boolean): void { this.formDisabled.set(isDisabled); + this.el.nativeElement.disabled = 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; @@ -215,38 +120,12 @@ export class TextFieldComponent implements ControlValueAccessor { this.onTouched(); } - clearInput() { - this.value.set(""); + clearField() { + if (this.disabled()) return; + + this.setValue(""); 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 index cc5ce19b3..8086a121f 100644 --- a/tedi/components/form/text-field/text-field.stories.ts +++ b/tedi/components/form/text-field/text-field.stories.ts @@ -4,10 +4,15 @@ import { 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"; +import { + TextFieldComponent, + FormFieldComponent, + ColComponent, + RowComponent, + FeedbackTextComponent, + TextComponent, + LabelComponent, +} from "@tedi-design-system/angular/tedi"; const PSEUDO_STATE = ["Default", "Hover", "Active", "Disabled", "Focus"]; @@ -22,76 +27,17 @@ export default { component: TextFieldComponent, decorators: [ moduleMetadata({ - imports: [RowComponent, ColComponent, LabelComponent, TextComponent], + imports: [ + RowComponent, + ColComponent, + LabelComponent, + TextComponent, + FormFieldComponent, + FeedbackTextComponent, + ], }), ], 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: { @@ -99,62 +45,38 @@ export default { }, options: ["default", "small", "large"], table: { - category: "inputs", + category: "Form Field inputs", type: { summary: "InputSize", detail: "default \nsmall \nlarge" }, 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", + category: "Form Field inputs", type: { summary: "string | TextFieldIcon" }, }, }, - isClearable: { + clearable: { description: "Whether the input includes a clear button.", control: { type: "boolean", }, table: { - category: "inputs", + category: "Form Field inputs", type: { summary: "boolean" }, defaultValue: { summary: "false" }, }, }, - readOnly: { - description: "Whether the input is read-only.", - control: { - type: "boolean", - }, + containerClass: { + control: "text", + description: "Custom CSS classes for the container.", table: { - category: "inputs", - type: { summary: "boolean" }, - defaultValue: { summary: "false" }, + category: "Form Field inputs", + type: { summary: "string" }, }, }, arrowsHidden: { @@ -163,75 +85,68 @@ export default { type: "boolean", }, table: { - category: "inputs", + category: "Text Field inputs", type: { summary: "boolean" }, defaultValue: { summary: "true" }, }, }, - inputAttrs: { - description: "Additional attributes for the input element.", - control: { type: "object" }, + clear: { + description: "Callback triggered when the clear button is clicked.", + control: false, + action: "clear", table: { - category: "inputs", - type: { summary: "InputHTMLAttributes" }, - }, - }, - class: { - control: "text", - description: "Custom CSS classes for the container.", - table: { - category: "inputs", - type: { summary: "string" }, - }, - }, - inputClass: { - control: "text", - description: "Custom CSS classes for the input element.", - table: { - category: "inputs", - type: { summary: "string" }, + category: "Text Field outputs", + type: { summary: "void" }, }, }, }, } as Meta; -export const Default: StoryObj = { +export const Default: StoryObj = { args: { - inputId: "example-id", - label: "Label", - required: false, - invalid: false, - disabled: false, - isClearable: false, - readOnly: false, - arrowsHidden: true, size: "default", + clearable: false, + arrowsHidden: true, }, - render: (args) => ({ + render: ({ arrowsHidden, ...formFieldArgs }) => ({ props: { - ...args, - handleClear: () => { - console.log("Input cleared"); - }, + arrowsHidden, + ...formFieldArgs, }, - template: ``, + template: ` + + + + + `, }), }; export const Size: StoryObj = { - render: (args) => ({ - props: args, + render: () => ({ template: `

Default

- - + + + + + + + +

Small

- - + + + + + + + +
`, @@ -246,8 +161,8 @@ export const States: StoryObj = { focusVisible: "#Focus", }, }, - render: (args) => ({ - props: { args, PSEUDO_STATE }, + render: () => ({ + props: { PSEUDO_STATE }, template: ` @@ -255,14 +170,14 @@ export const States: StoryObj = {

{{ state }}

- + + + +
@@ -270,16 +185,11 @@ export const States: StoryObj = {

Error

- + + + + +
@@ -287,16 +197,11 @@ export const States: StoryObj = {

Success

- + + + + +
@@ -305,42 +210,35 @@ export const States: StoryObj = { }; export const WithHint: StoryObj = { - args: { - inputId: "example-hint", - label: "Label", - helper: { - text: "Hint text", - type: "hint", - position: "left", - }, - }, - render: (args) => ({ - props: args, + render: () => ({ template: ` - `, + + + + + + `, }), }; export const Password: StoryObj = { - args: { - inputId: "example-password", - label: "Label", - inputAttrs: { type: "password" }, - value: "123456789", - }, - render: (args) => ({ - props: args, - template: ``, + render: () => ({ + template: ` + + + + + `, }), }; export const Placeholder: StoryObj = { - args: { - inputId: "example-placeholder", - label: "Label", - }, - render: (args) => ({ - props: args, - template: ``, + render: () => ({ + template: ` + + + + + `, }), }; diff --git a/tedi/directives/index.ts b/tedi/directives/index.ts index 076d1a184..350450751 100644 --- a/tedi/directives/index.ts +++ b/tedi/directives/index.ts @@ -1,4 +1,3 @@ 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 deleted file mode 100644 index d0b091c24..000000000 --- a/tedi/directives/spread-attrs/spread-attrs.directive.spec.ts +++ /dev/null @@ -1,77 +0,0 @@ -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 deleted file mode 100644 index b47804ad7..000000000 --- a/tedi/directives/spread-attrs/spread-attrs.directive.ts +++ /dev/null @@ -1,37 +0,0 @@ -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); - } - } - } - } -} From 5f03802090588f17a9965b48b0fcc89a68f3c6ae Mon Sep 17 00:00:00 2001 From: Ly Tempel Date: Mon, 16 Mar 2026 12:09:57 +0200 Subject: [PATCH 7/8] feat(text-field): fix clear button aria-label #71 --- tedi/components/form/form-field/form-field.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tedi/components/form/form-field/form-field.component.html b/tedi/components/form/form-field/form-field.component.html index e5c4a0514..ae1279b07 100644 --- a/tedi/components/form/form-field/form-field.component.html +++ b/tedi/components/form/form-field/form-field.component.html @@ -10,7 +10,7 @@ tedi-closing-button type="button" size="small" - [attr.aria-label]="'clear' | tediTranslate" + [ariaLabel]="'clear' | tediTranslate" [iconSize]="18" [disabled]="isDisabled()" (click)="clear()" From f389864f7b877a8644707c05e0fc9f830cb0363b Mon Sep 17 00:00:00 2001 From: Ly Tempel Date: Wed, 18 Mar 2026 13:15:35 +0200 Subject: [PATCH 8/8] feat(text-field): add CR fixes #71 --- .../form/form-field/form-field.component.html | 2 +- .../form/form-field/form-field.component.scss | 84 +++++++++++-------- .../form-field/form-field.component.spec.ts | 14 ++-- .../form/form-field/form-field.component.ts | 32 +++++-- .../form/text-field/text-field.stories.ts | 4 +- 5 files changed, 82 insertions(+), 54 deletions(-) diff --git a/tedi/components/form/form-field/form-field.component.html b/tedi/components/form/form-field/form-field.component.html index ae1279b07..a8f01d420 100644 --- a/tedi/components/form/form-field/form-field.component.html +++ b/tedi/components/form/form-field/form-field.component.html @@ -1,6 +1,6 @@ -
+
@if (showClearButton()) { diff --git a/tedi/components/form/form-field/form-field.component.scss b/tedi/components/form/form-field/form-field.component.scss index 663a68b32..2b259c934 100644 --- a/tedi/components/form/form-field/form-field.component.scss +++ b/tedi/components/form/form-field/form-field.component.scss @@ -1,38 +1,34 @@ .tedi-form-field { - 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); + flex-direction: column; - &:not( - .tedi-form-field--disabled, - .tedi-form-field--valid, - .tedi-form-field--invalid - ) { - &:hover, - &:has(input:hover) { - border-color: var(--form-input-border-hover); - } + &__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); + } - &:active, - &:has(input:active) { - border-color: var(--form-input-border-active); - box-shadow: 0 0 0 1px var(--form-input-border-active); + &--small { + .tedi-label { + font-size: var(--body-small-regular-size); } - &:focus-within, - &:has(input:focus-visible) { - border-color: var(--form-input-border-focus); - box-shadow: 0 0 0 1px var(--form-input-border-focus); + .tedi-form-field__input { + height: var(--form-field-height-sm); } } - &.tedi-form-field--valid { + &--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 { @@ -40,7 +36,7 @@ } } - &.tedi-form-field--invalid { + &--invalid .tedi-form-field__input { border-color: var(--form-general-feedback-error-border); &:focus-within { @@ -48,26 +44,42 @@ } } - &.tedi-form-field--disabled { + &--disabled .tedi-form-field__input { cursor: not-allowed; background: var(--form-input-background-disabled); border-color: var(--form-input-border-disabled); box-shadow: none; } - &--small { - height: var(--form-field-height-sm); + &--with-icon .tedi-form-field__input { + padding-right: var(--form-field-padding-x-md-default); } - &--large { - height: var(--form-field-height-lg); + &--with-icon.tedi-form-field--large .tedi-form-field__input { + padding-right: var(--form-field-padding-x-lg); } - &--with-icon { - padding-right: var(--form-field-padding-x-md-default); + &: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); + } - &.tedi-form-field--large { - padding-right: var(--form-field-padding-x-lg); + &: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); } } diff --git a/tedi/components/form/form-field/form-field.component.spec.ts b/tedi/components/form/form-field/form-field.component.spec.ts index 36cf3f2fe..50a072340 100644 --- a/tedi/components/form/form-field/form-field.component.spec.ts +++ b/tedi/components/form/form-field/form-field.component.spec.ts @@ -9,9 +9,7 @@ import { TEDI_FORM_FIELD_CONTROL, FormFieldControl, } from "./form-field-control"; -import { - FeedbackTextComponent, -} from "../feedback-text/feedback-text.component"; +import { FeedbackTextComponent } from "../feedback-text/feedback-text.component"; @Component({ selector: "mock-control", @@ -47,7 +45,7 @@ export class MockFeedbackComponent extends FeedbackTextComponent {} [size]="size" [icon]="icon" [clearable]="clearable" - [containerClass]="containerClass" + [inputClass]="inputClass" > { it("should apply small size class", () => { host.size = "small"; fixture.detectChanges(); - expect(formField.classes()["tedi-form-field--small"]).toBe(true); + expect(formField.hostClasses()["tedi-form-field--small"]).toBe(true); }); it("should resolve string icon to config object", () => { @@ -165,10 +163,10 @@ describe("FormFieldComponent", () => { }); it("should apply custom class", () => { - host.containerClass = "custom-class"; + host.inputClass = "custom-class"; fixture.detectChanges(); - const classes = formField.classes() as Record; + const classes = formField.inputClasses() as Record; expect(classes["custom-class"]).toBe(true); }); }); diff --git a/tedi/components/form/form-field/form-field.component.ts b/tedi/components/form/form-field/form-field.component.ts index e004bb594..ed62a332d 100644 --- a/tedi/components/form/form-field/form-field.component.ts +++ b/tedi/components/form/form-field/form-field.component.ts @@ -1,4 +1,11 @@ -import { Component, computed, ContentChild, input } from "@angular/core"; +import { + ChangeDetectionStrategy, + Component, + computed, + ContentChild, + input, + ViewEncapsulation, +} from "@angular/core"; import { NgClass } from "@angular/common"; import { FormFieldControl, @@ -33,6 +40,8 @@ export interface FormFieldIcon { standalone: true, templateUrl: "./form-field.component.html", styleUrl: "./form-field.component.scss", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ NgClass, IconComponent, @@ -40,6 +49,9 @@ export interface FormFieldIcon { SeparatorComponent, TediTranslationPipe, ], + host: { + "[class]": "hostClasses()", + }, }) export class FormFieldComponent { /** @@ -57,9 +69,9 @@ export class FormFieldComponent { */ clearable = input(false); /** - * Custom CSS classes for the container. + * Custom CSS classes for the input. */ - containerClass = input(null); + inputClass = input(null); @ContentChild(TEDI_FORM_FIELD_CONTROL) control?: FormFieldControl; @@ -91,12 +103,9 @@ export class FormFieldComponent { readonly isDisabled = computed(() => this.control?.disabled() ?? false); - readonly classes = computed(() => { - const customClass = this.containerClass(); - + readonly hostClasses = computed(() => { return { "tedi-form-field": true, - ...(customClass ? { [customClass]: true } : {}), "tedi-form-field--valid": this.validationState() === "valid", "tedi-form-field--invalid": this.validationState() === "invalid", "tedi-form-field--disabled": this.control?.disabled(), @@ -106,6 +115,15 @@ export class FormFieldComponent { }; }); + readonly inputClasses = computed(() => { + const customClass = this.inputClass(); + + return { + "tedi-form-field__input": true, + ...(customClass ? { [customClass]: true } : {}), + }; + }); + clear() { this.control?.clearField?.(); } diff --git a/tedi/components/form/text-field/text-field.stories.ts b/tedi/components/form/text-field/text-field.stories.ts index 8086a121f..53a86594e 100644 --- a/tedi/components/form/text-field/text-field.stories.ts +++ b/tedi/components/form/text-field/text-field.stories.ts @@ -71,9 +71,9 @@ export default { defaultValue: { summary: "false" }, }, }, - containerClass: { + inputClass: { control: "text", - description: "Custom CSS classes for the container.", + description: "Custom CSS classes for the input.", table: { category: "Form Field inputs", type: { summary: "string" },