From 6eb8b4d879eaed5204a503c9b6605956f6414e30 Mon Sep 17 00:00:00 2001 From: Ly Tempel Date: Wed, 25 Feb 2026 18:36:36 +0200 Subject: [PATCH 1/2] 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 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..263c1cfe --- /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 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..c79ee751 --- /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 00000000..3944af47 --- /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 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); + } + } + } + } +} From 1c881ef8c3e9b3a0cda386e3e4386f73c579a89d Mon Sep 17 00:00:00 2001 From: Ly Tempel Date: Fri, 27 Feb 2026 13:45:21 +0200 Subject: [PATCH 2/2] 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 263c1cfe..cca2fea6 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: {