diff --git a/skills/tedi-angular/references/components.md b/skills/tedi-angular/references/components.md index 4e499dbb6..5b36a2b83 100644 --- a/skills/tedi-angular/references/components.md +++ b/skills/tedi-angular/references/components.md @@ -204,9 +204,10 @@ Both entry points declare `[tedi-floating-button]`. The Community component is ### Clear buttons - **`showClearOnInteraction` belongs on the component that renders the clear button.** For a text - field or textarea wrapped in ``, set it on the form field, not the - control. `` owns its form field and exposes the input itself; `` exposes - it for its own clear button. In each case, `clearable` must be true and the field must have a value. + field wrapped in ``, set it on the form field, not the control (a textarea + never gets a clear button). `` owns its form field and exposes the input itself; + `` exposes it for its own clear button. In each case, `clearable` must be true and + the field must have a value. - With the option enabled, the clear button appears while the field is hovered, active, or contains focus (including keyboard focus). The default is false, which keeps the clear button visible whenever the clearable field has a value. diff --git a/src/community/text-editor/text-editor.component.spec.ts b/src/community/text-editor/text-editor.component.spec.ts index 41ecd83c4..5fdb134c6 100644 --- a/src/community/text-editor/text-editor.component.spec.ts +++ b/src/community/text-editor/text-editor.component.spec.ts @@ -5,6 +5,7 @@ import { By } from "@angular/platform-browser"; import { QuillModules } from "ngx-quill"; import type Quill from "quill"; import { + FormFieldComponent, TEDI_TRANSLATION_DEFAULT_TOKEN, TediTranslationService, } from "@tedi-design-system/angular/tedi"; @@ -373,3 +374,34 @@ describe("TextEditorComponent", () => { }); }); }); + +@Component({ + standalone: true, + imports: [FormFieldComponent, TextEditorComponent, ReactiveFormsModule], + template: ` + + + + `, +}) +class FormFieldHostComponent { + control = new FormControl("

Text

", { nonNullable: true }); +} + +describe("TextEditorComponent inside tedi-form-field", () => { + it("gets no clear button or field box, even when clearable", () => { + TestBed.configureTestingModule({ + imports: [FormFieldHostComponent], + providers: [ + { provide: TediTranslationService, useClass: TranslationMock }, + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + ], + }); + const fixture = TestBed.createComponent(FormFieldHostComponent); + fixture.detectChanges(); + + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelector(".tedi-form-field__clear")).toBeNull(); + expect(el.querySelector(".tedi-form-field__box")).toBeNull(); + }); +}); diff --git a/src/community/text-editor/text-editor.component.ts b/src/community/text-editor/text-editor.component.ts index 844379dbb..b42edaca4 100644 --- a/src/community/text-editor/text-editor.component.ts +++ b/src/community/text-editor/text-editor.component.ts @@ -260,6 +260,9 @@ export class TextEditorComponent readonly touched = this.derived.touched; readonly dirty = this.derived.dirty; + /** Rich text takes no clear button, even inside a `clearable` form field. */ + readonly clearButton = false; + readonly disabled = computed( () => this.formDisabled() || (this.fieldContext?.disabled() ?? false), ); diff --git a/src/community/text-editor/text-editor.stories.ts b/src/community/text-editor/text-editor.stories.ts index a6d0fc27f..4dc2e9f28 100644 --- a/src/community/text-editor/text-editor.stories.ts +++ b/src/community/text-editor/text-editor.stories.ts @@ -11,6 +11,8 @@ import { import { TextEditorComponent } from "./text-editor.component"; /** + * Figma ↗ + * * Rich text editor built on ngx-quill ↗. * * Install `ngx-quill` and `quill` yourself — they are optional peer dependencies diff --git a/src/tedi/components/content/table/table.component.html b/src/tedi/components/content/table/table.component.html index eed85625d..641c6315e 100644 --- a/src/tedi/components/content/table/table.component.html +++ b/src/tedi/components/content/table/table.component.html @@ -326,6 +326,7 @@ (input)=" handleColumnFilter(column, $any($event.target).value) " + (clear)="handleColumnFilter(column, '')" />
} diff --git a/src/tedi/components/content/table/table.component.spec.ts b/src/tedi/components/content/table/table.component.spec.ts index c80f5d710..46e78d62d 100644 --- a/src/tedi/components/content/table/table.component.spec.ts +++ b/src/tedi/components/content/table/table.component.spec.ts @@ -689,6 +689,34 @@ describe("TediTableComponent", () => { expect(rows[0].textContent).toContain("Anna"); }); + it("resets the column filter from the clear button", () => { + const fixture = setupHost(); + fixture.componentInstance.enableColumnFilters.set(true); + fixture.detectChanges(); + const rows = () => + fixture.nativeElement.querySelectorAll( + ".tedi-table__body .tedi-table__row", + ).length; + const total = rows(); + const input = fixture.nativeElement.querySelector( + 'tr.tedi-table__row--filter input[aria-label="Filter Name"]', + ) as HTMLInputElement; + input.value = "Anna"; + input.dispatchEvent(new Event("input")); + fixture.detectChanges(); + expect(rows()).toBe(1); + + ( + input + .closest("tedi-form-field")! + .querySelector(".tedi-form-field__clear") as HTMLButtonElement + ).click(); + fixture.detectChanges(); + + expect(input.value).toBe(""); + expect(rows()).toBe(total); + }); + it("clearFilters() resets every column filter at once", () => { const fixture = setupHost(); fixture.componentInstance.enableColumnFilters.set(true); diff --git a/src/tedi/components/form/date-field/date-field.component.spec.ts b/src/tedi/components/form/date-field/date-field.component.spec.ts index 90a77b01b..7ffd13b02 100644 --- a/src/tedi/components/form/date-field/date-field.component.spec.ts +++ b/src/tedi/components/form/date-field/date-field.component.spec.ts @@ -1426,6 +1426,7 @@ describe("DateFieldComponent", () => { fixture.componentRef.setInput("value", new Date(2026, 4, 14)); fixture.detectChanges(); + expect(component.clearableResolved()).toBe(true); expect(component.canClear()).toBe(true); expect(getClear(el)).not.toBeNull(); }); @@ -1470,6 +1471,31 @@ describe("DateFieldComponent", () => { expect(component.useNativePickerEffective()).toBe(false); expect(getClear(el)).not.toBeNull(); }); + + it("converts static attribute values to booleans", () => { + const { component, fixture } = createField(); + fixture.componentRef.setInput("value", new Date(2026, 4, 14)); + + fixture.componentRef.setInput("clearable", "false"); + fixture.detectChanges(); + expect(component.clearable()).toBe(false); + expect(component.canClear()).toBe(false); + + fixture.componentRef.setInput("clearable", ""); + fixture.detectChanges(); + expect(component.clearable()).toBe(true); + expect(component.canClear()).toBe(true); + }); + + it("hides the clear button when a standalone field opts out", () => { + const { component, el, fixture } = createField(); + fixture.componentRef.setInput("clearable", false); + fixture.componentRef.setInput("value", new Date(2026, 4, 14)); + fixture.detectChanges(); + + expect(component.canClear()).toBe(false); + expect(el.querySelector(".tedi-date-input__clear")).toBeNull(); + }); }); describe("native picker parse rejection", () => { @@ -1695,15 +1721,21 @@ describe("DateFieldComponent with ReactiveFormsModule", () => { ReactiveFormsModule, ], template: ` - + - + `, }) class CompositeHostComponent { control = new FormControl(null); + clearable = false; + fieldClearable?: boolean; } describe("DateFieldComponent inside FormFieldComponent", () => { @@ -1738,6 +1770,46 @@ describe("DateFieldComponent inside FormFieldComponent", () => { expect(el.querySelector("tedi-feedback-text")).toBeTruthy(); }); + describe("clearable driven by the form field", () => { + const seed = (clearable: boolean) => { + fixture.componentInstance.clearable = clearable; + fixture.componentInstance.control.setValue(new Date(2026, 4, 14)); + fixture.detectChanges(); + }; + + it("renders none when the form field opts out", () => { + seed(false); + expect(el.querySelectorAll(".tedi-date-input__clear")).toHaveLength(0); + expect(el.querySelectorAll(".tedi-form-field__clear")).toHaveLength(0); + }); + + it("renders exactly one when the form field opts in", () => { + seed(true); + expect(el.querySelectorAll(".tedi-date-input__clear")).toHaveLength(1); + expect(el.querySelectorAll(".tedi-form-field__clear")).toHaveLength(0); + }); + + it("lets the date field's own clearable override the form field", () => { + fixture.componentInstance.fieldClearable = true; + seed(false); + expect(el.querySelectorAll(".tedi-date-input__clear")).toHaveLength(1); + + fixture.componentInstance.fieldClearable = false; + seed(true); + expect(el.querySelectorAll(".tedi-date-input__clear")).toHaveLength(0); + }); + + it("still clears programmatically when opted out", () => { + seed(false); + const component = fixture.debugElement.query( + By.directive(DateFieldComponent), + ).componentInstance as DateFieldComponent; + + component.reset(); + expect(component.value()).toBeNull(); + }); + }); + it("associates the feedback text with the input via aria-describedby", () => { const input = el.querySelector( "input.tedi-date-input__input", diff --git a/src/tedi/components/form/date-field/date-field.component.ts b/src/tedi/components/form/date-field/date-field.component.ts index 8da8186ed..5f7a9ac5e 100644 --- a/src/tedi/components/form/date-field/date-field.component.ts +++ b/src/tedi/components/form/date-field/date-field.component.ts @@ -1,4 +1,5 @@ import { + booleanAttribute, ChangeDetectionStrategy, Component, computed, @@ -69,7 +70,6 @@ import { DateFieldModalComponent, DateFieldModalData, } from "./date-field-modal/date-field-modal.component"; -import { ButtonComponent } from "../../buttons"; import { TediTranslationPipe } from "../../../services/translation/translation.pipe"; type DateFieldValue = Date | Date[] | DateRange | null; @@ -93,7 +93,6 @@ export type DateFieldSize = "default" | "small"; imports: [ CalendarComponent, DateInputComponent, - ButtonComponent, OverlayModule, A11yModule, TediTranslationPipe, @@ -201,6 +200,15 @@ export class DateFieldComponent * set here. */ readonly size = input(); + /** + * Whether the clear button shows once the field has a value. Falls back to the + * wrapping `tedi-form-field`'s `clearable` when not set here — set it on the + * wrapper, and use this only for a standalone field. + */ + readonly clearable = input(undefined, { + // Unset stays `undefined` so the wrapper's `clearable` still applies. + transform: (v: unknown) => (v == null ? undefined : booleanAttribute(v)), + }); /** * Forces the error state on, or off, regardless of the reactive-forms state. * Leave unset to let the control derive it. @@ -430,12 +438,21 @@ export class DateFieldComponent invalid: computed(() => this.invalid()), valid: computed(() => this.valid()), disabled: computed(() => this.disabled()), + clearable: computed(() => this.clearableResolved()), }; ngOnInit(): void { this.derived.connect(); } + /** The clear button sits in the date input's action row, beside the calendar. */ + readonly ownsClearButton = true; + + /** Own `clearable` wins, then the wrapping `tedi-form-field`'s. */ + readonly clearableResolved = computed( + () => this.clearable() ?? this.fieldContext?.clearable() ?? true, + ); + readonly resolvedDisabledMatchers = computed(() => { const result: Matcher[] = []; const explicit = this.disabledMatchers(); @@ -559,7 +576,9 @@ export class DateFieldComponent () => !this.fieldDisabled() && (!this.readOnly() || this.showCalendar()), ); - readonly canClear = computed(() => !!this.value() && this.valueEditable()); + readonly canClear = computed( + () => this.clearableResolved() && !!this.value() && this.valueEditable(), + ); readonly inputIsTrigger = computed( () => this.showCalendar() && this.calendarTriggerResolved() === "input", diff --git a/src/tedi/components/form/date-field/date-field.stories.ts b/src/tedi/components/form/date-field/date-field.stories.ts index afb4d60ce..6722f5110 100644 --- a/src/tedi/components/form/date-field/date-field.stories.ts +++ b/src/tedi/components/form/date-field/date-field.stories.ts @@ -6,7 +6,10 @@ import { Validators, } from "@angular/forms"; import { DateFieldComponent } from "./date-field.component"; -import { FormFieldComponent } from "../form-field/form-field.component"; +import { + FormFieldComponent, + type InputSize, +} from "../form-field/form-field.component"; import { LabelComponent } from "../../content/label/label.component"; import { FeedbackTextComponent } from "../feedback-text/feedback-text.component"; import { ButtonComponent } from "../../buttons/button/button.component"; @@ -19,6 +22,8 @@ import type { DateRange } from "../../content/calendar/types"; import type { Matcher } from "../../../utils/matchers.util"; import { expect, userEvent, waitFor } from "storybook/test"; +const PSEUDO_STATE = ["Default", "Hover", "Focus", "Active"]; + const referenceDate = new Date(2026, 5, 15); const inThreeDays = new Date(2026, 5, 18); const inTenDays = new Date(2026, 5, 25); @@ -75,7 +80,6 @@ const parseUS = (value: string): Date | undefined => { */ const COMMON_INPUTS = [ "mode", - "size", "selectionLevel", "monthYearSelectType", "localeCode", @@ -97,6 +101,8 @@ const COMMON_INPUTS = [ "fullscreen", "calendarTrigger", "numberOfMonths", + "clearable", + "size", ]; /** @@ -109,6 +115,10 @@ const argBindings = (exclude: string[] = []): string => .join("\n "); type DateFieldStoryArgs = DateFieldComponent & { + /** Story-only: size applied to the wrapping `tedi-form-field`. */ + formFieldSize?: InputSize; + /** Story-only: clearable applied to the wrapping `tedi-form-field`. */ + formFieldClearable?: boolean; /** Story-only: text rendered in the sibling `