diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html
index b2aa5655c..bedcd653d 100644
--- a/.storybook/preview-head.html
+++ b/.storybook/preview-head.html
@@ -17,9 +17,10 @@
color: inherit;
}
- body.sb-show-main,
- body.sb-show-main #storybook-root {
+ body.sb-show-main {
height: auto !important;
min-height: 100vh !important;
+ margin: 0 !important;
+ box-sizing: border-box !important;
}
diff --git a/community/components/form/search/search.component.ts b/community/components/form/search/search.component.ts
index dbac3b056..651b63db2 100644
--- a/community/components/form/search/search.component.ts
+++ b/community/components/form/search/search.component.ts
@@ -39,6 +39,10 @@ export type AutocompleteOption = {
};
export type SearchState = "valid" | "error" | "default";
+/**
+ * @deprecated Use the TEDI-Ready `SearchComponent` from
+ * `@tedi-design-system/angular/tedi` instead. This component will be removed in a future version.
+ */
@Component({
selector: "tedi-search",
standalone: true,
diff --git a/community/components/form/search/search.stories.ts b/community/components/form/search/search.stories.ts
index 578f49c99..ca71968a9 100644
--- a/community/components/form/search/search.stories.ts
+++ b/community/components/form/search/search.stories.ts
@@ -37,7 +37,7 @@ export default {
],
parameters: {
status: {
- type: ["devComponent"],
+ type: ["deprecated", "existsInTediReady"],
},
},
argTypes: {
diff --git a/skills/tedi-angular/references/components.md b/skills/tedi-angular/references/components.md
index 2a3d98ff7..8da5dec3c 100644
--- a/skills/tedi-angular/references/components.md
+++ b/skills/tedi-angular/references/components.md
@@ -787,6 +787,30 @@ Wrapper that joins filters into a connected button group with collapsed borders
- `required: boolean = false`
- `invalid: boolean = false`
+### Search
+**Selector:** `tedi-search`
+**Model:** `value: string`
+**Inputs:**
+- `inputId: string` (required)
+- `label: string`
+- `placeholder: string = ""`
+- `size: SearchSize = "default"` — "small" | "default" | "large"
+- `clearable: boolean = true`
+- `searchIcon: string | FormFieldIcon = "search"` — ignored when `button` is set
+- `button: SearchButton` — when set, renders a trailing search button (hides the inline icon). `{ text?, icon?, variant?, ariaLabel? }`; omit `text` for an icon-only button
+- `feedbackText: ComponentInputs` — hint / validation message
+- `disabled: boolean = false`
+- `ariaLabel: string` — accessible name fallback when no visible `label`
+
+**Outputs:**
+- `searchEvent: string` — emitted on Enter or search-button click
+- `clear: void` — emitted when the clear button is clicked
+
+```html
+
+
+```
+
### Slider
**Selector:** `tedi-slider` | ControlValueAccessor
**Model:** `value: number`
@@ -2127,7 +2151,8 @@ Import from `@tedi-design-system/angular/community`. These are community-contrib
**Selector:** `tedi-select`, `tedi-multiselect` | ControlValueAccessor
- `inputId: string`, `label: string`, `clearable: boolean = true`, `state: InputState`, `size: InputSize`
-### Search
+### Search — **DEPRECATED** (use TEDI-Ready Search)
+**⚠️ DEPRECATED** — use the TEDI-Ready `tedi-search` from `@tedi-design-system/angular/tedi`. Same selector; the TEDI-Ready version exposes a trailing `button` (`SearchButton`), a `feedbackText` input for hints/validation, and `searchEvent` / `clear` outputs.
**Selector:** `tedi-search` | ControlValueAccessor
- `inputId: string`, `autocompleteOptions: AutocompleteOption[]`, `size: SearchSize`, `withButton: boolean`
diff --git a/tedi/components/buttons/card-button/card-button.stories.ts b/tedi/components/buttons/card-button/card-button.stories.ts
index 74dc0ad36..dd78c4b75 100644
--- a/tedi/components/buttons/card-button/card-button.stories.ts
+++ b/tedi/components/buttons/card-button/card-button.stories.ts
@@ -17,7 +17,8 @@ import { VerticalSpacingDirective } from "../../../directives/vertical-spacing/v
import { ShowAtDirective } from "../../../directives/show-at/show-at.directive";
/**
- * Figma ↗
+ * Figma ↗
+ * Zeroheight ↗
*
* CardButton is an interactive wrapper around a `tedi-card`. The host anchor
* or button provides the semantics and applies hover, active, focus and
diff --git a/tedi/components/buttons/collapse-button/collapse-button.stories.ts b/tedi/components/buttons/collapse-button/collapse-button.stories.ts
index 79e77a534..2a86ca51a 100644
--- a/tedi/components/buttons/collapse-button/collapse-button.stories.ts
+++ b/tedi/components/buttons/collapse-button/collapse-button.stories.ts
@@ -153,7 +153,8 @@ const PSEUDO_PARAMS = {
};
/**
- * Figma ↗
+ * Figma ↗
+ * Zeroheight ↗
*
*
* Standalone toggle button used by tedi-collapse and the table's
diff --git a/tedi/components/filter/filter.stories.ts b/tedi/components/filter/filter.stories.ts
index 7605279ce..b6adaef50 100644
--- a/tedi/components/filter/filter.stories.ts
+++ b/tedi/components/filter/filter.stories.ts
@@ -19,8 +19,10 @@ import { RadioGroupComponent } from "../form/radio-group/radio-group.component";
import { LabelComponent } from "../form/label/label.component";
/**
- * Figma ↗
+ * Figma ↗
+ * Zeroheight ↗
*/
+
export default {
title: "TEDI-Ready/Components/Filter",
component: FilterComponent,
diff --git a/tedi/components/form/date-field/date-field.stories.ts b/tedi/components/form/date-field/date-field.stories.ts
index 83b980e5d..bb07c0f49 100644
--- a/tedi/components/form/date-field/date-field.stories.ts
+++ b/tedi/components/form/date-field/date-field.stories.ts
@@ -1,8 +1,4 @@
-import {
- type Meta,
- type StoryObj,
- moduleMetadata,
-} from "@storybook/angular";
+import { type Meta, type StoryObj, moduleMetadata } from "@storybook/angular";
import {
FormControl,
FormGroup,
@@ -23,9 +19,21 @@ import type { DateRange } from "../../content/calendar/types";
import type { Matcher } from "../../../utils/matchers.util";
const today = new Date();
-const tomorrow = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1);
-const inThreeDays = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 3);
-const inTenDays = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 10);
+const tomorrow = new Date(
+ today.getFullYear(),
+ today.getMonth(),
+ today.getDate() + 1,
+);
+const inThreeDays = new Date(
+ today.getFullYear(),
+ today.getMonth(),
+ today.getDate() + 3,
+);
+const inTenDays = new Date(
+ today.getFullYear(),
+ today.getMonth(),
+ today.getDate() + 10,
+);
const pad = (n: number): string => n.toString().padStart(2, "0");
@@ -36,7 +44,10 @@ const formatUS = (value: Date | Date[] | DateRange | null): string => {
}
if (Array.isArray(value)) {
return value
- .map((d) => `${pad(d.getMonth() + 1)}/${pad(d.getDate())}/${d.getFullYear()}`)
+ .map(
+ (d) =>
+ `${pad(d.getMonth() + 1)}/${pad(d.getDate())}/${d.getFullYear()}`,
+ )
.join(", ");
}
const from = `${pad(value.from.getMonth() + 1)}/${pad(value.from.getDate())}/${value.from.getFullYear()}`;
@@ -170,7 +181,7 @@ const renderSingle: NonNullable["render"]> = (
};
/**
- * Figma ↗
+ * Figma ↗
* Zeroheight ↗
*
* DateField is the form-control wrapper around the Calendar. It exposes a typed text input
@@ -450,21 +461,22 @@ export default {
category: "inputs",
type: {
summary: "BreakpointInput",
- detail: "number \n{ xs: number; sm?: number; md?: number; lg?: number; xl?: number; xxl?: number }",
+ detail:
+ "number \n{ xs: number; sm?: number; md?: number; lg?: number; xl?: number; xxl?: number }",
},
defaultValue: { summary: "1" },
},
},
useNativePicker: {
description:
- "Swaps the custom popover for the browser's native `` UI (single mode only). `true` always uses native, `false` never; a breakpoint name (`sm | md | lg | xl`) uses native below that breakpoint and the custom popover from it upward. Defaults to `false`.",
+ 'Swaps the custom popover for the browser\'s native `` UI (single mode only). `true` always uses native, `false` never; a breakpoint name (`sm | md | lg | xl`) uses native below that breakpoint and the custom popover from it upward. Defaults to `false`.',
control: { type: "select" },
options: [true, false, "sm", "md", "lg", "xl"],
table: {
category: "inputs",
type: {
summary: "DateFieldUseNativePicker",
- detail: "boolean \n\"sm\" | \"md\" | \"lg\" | \"xl\"",
+ detail: 'boolean \n"sm" | "md" | "lg" | "xl"',
},
defaultValue: { summary: "false" },
},
@@ -478,7 +490,7 @@ export default {
category: "inputs",
type: {
summary: "DateFieldModalInput",
- detail: "boolean \n\"sm\" | \"md\" | \"lg\" | \"xl\"",
+ detail: 'boolean \n"sm" | "md" | "lg" | "xl"',
},
defaultValue: { summary: "false" },
},
@@ -492,7 +504,7 @@ export default {
category: "inputs",
type: {
summary: "ModalFullscreen",
- detail: "boolean \n\"sm\" | \"md\" | \"lg\" | \"xl\"",
+ detail: 'boolean \n"sm" | "md" | "lg" | "xl"',
},
defaultValue: { summary: "false" },
},
@@ -614,7 +626,7 @@ export const States: Story = {
docs: {
description: {
story:
- "Persistent field states. Disabled is driven by the form control; the success/error states come from a `tedi-feedback-text` with `type=\"valid\"`/`\"error\"`, which the surrounding `tedi-form-field` reflects on the input border.",
+ 'Persistent field states. Disabled is driven by the form control; the success/error states come from a `tedi-feedback-text` with `type="valid"`/`"error"`, which the surrounding `tedi-form-field` reflects on the input border.',
},
},
},
@@ -724,7 +736,8 @@ export const MultipleTagLayout: Story = {
render: (args) => {
const dates = Array.from(
{ length: 6 },
- (_, i) => new Date(today.getFullYear(), today.getMonth(), today.getDate() + i),
+ (_, i) =>
+ new Date(today.getFullYear(), today.getMonth(), today.getDate() + i),
);
const wrapControl = new FormControl(dates);
const singleRowControl = new FormControl(dates);
@@ -788,7 +801,7 @@ export const OnClickType: Story = {
docs: {
description: {
story:
- "`calendarTrigger` decides what opens the calendar: `\"button\"` (the icon, default) or `\"input\"` (the whole input — typing is then blocked).",
+ '`calendarTrigger` decides what opens the calendar: `"button"` (the icon, default) or `"input"` (the whole input — typing is then blocked).',
},
},
},
@@ -888,7 +901,8 @@ export const ShowWeekCount: Story = {
parameters: {
docs: {
description: {
- story: "`showWeekNumbers` adds an ISO week-number column to the day grid.",
+ story:
+ "`showWeekNumbers` adds an ISO week-number column to the day grid.",
},
},
},
@@ -904,7 +918,7 @@ export const MultipleMonths: Story = {
docs: {
description: {
story:
- "`numberOfMonths` shows several months side by side. A plain number is honoured at every breakpoint — `2` stays two months even on a phone or in a modal. To narrow it on small screens, pass a per-breakpoint object instead, e.g. `[numberOfMonths]=\"{ xs: 1, lg: 2 }\"`.",
+ '`numberOfMonths` shows several months side by side. A plain number is honoured at every breakpoint — `2` stays two months even on a phone or in a modal. To narrow it on small screens, pass a per-breakpoint object instead, e.g. `[numberOfMonths]="{ xs: 1, lg: 2 }"`.',
},
},
},
@@ -925,7 +939,7 @@ export const YearGrid: Story = {
docs: {
description: {
story:
- "`monthYearSelectType=\"grid\"` replaces the header dropdowns with a clickable label that drills into a year/month grid; `selectionLevel=\"years\"` commits at year granularity. A custom `formatDate` collapses the committed `Date` (Jan 1 of the year) to just the year number.",
+ '`monthYearSelectType="grid"` replaces the header dropdowns with a clickable label that drills into a year/month grid; `selectionLevel="years"` commits at year granularity. A custom `formatDate` collapses the committed `Date` (Jan 1 of the year) to just the year number.',
},
},
},
@@ -1008,13 +1022,14 @@ export const NativePicker: Story = {
args: {
inputId: "date-native",
useNativePicker: true,
- feedback: "Kasutab operatsioonisüsteemi kuupäevavalijat igal ekraanilaiusel.",
+ feedback:
+ "Kasutab operatsioonisüsteemi kuupäevavalijat igal ekraanilaiusel.",
},
parameters: {
docs: {
description: {
story:
- "`[useNativePicker]=\"true\"` swaps the popover for the browser's built-in `` UI (single mode only). The prop accepts `boolean | sm | md | lg | xl` and **defaults to `false`**. Pass a breakpoint name like `\"md\"` for native on phones and the custom popover from `md` upward.",
+ '`[useNativePicker]="true"` swaps the popover for the browser\'s built-in `` UI (single mode only). The prop accepts `boolean | sm | md | lg | xl` and **defaults to `false`**. Pass a breakpoint name like `"md"` for native on phones and the custom popover from `md` upward.',
},
},
},
@@ -1062,7 +1077,7 @@ export const MobileModal: Story = {
docs: {
description: {
story:
- "`[modal]=\"true\"` opens the calendar in a modal (with explicit Cancel/Confirm) instead of the popover, holding the selection as a draft until Confirm. By default the modal is centered; add `[fullscreen]=\"true\"` to make it fill the screen — useful on small phones where vertical space is tight. Both `modal` and `fullscreen` accept the same union (`true | false | sm | md | lg | xl`), so e.g. `fullscreen=\"md\"` only goes fullscreen below `md`.",
+ '`[modal]="true"` opens the calendar in a modal (with explicit Cancel/Confirm) instead of the popover, holding the selection as a draft until Confirm. By default the modal is centered; add `[fullscreen]="true"` to make it fill the screen — useful on small phones where vertical space is tight. Both `modal` and `fullscreen` accept the same union (`true | false | sm | md | lg | xl`), so e.g. `fullscreen="md"` only goes fullscreen below `md`.',
},
},
},
@@ -1111,7 +1126,8 @@ export const CustomLocale: Story = {
localeCode: "en-US",
useNativePicker: false,
initialValue: inThreeDays,
- feedback: "Vahetab kuude nimed, nädalapäevade nimed ja nädala esimese päeva.",
+ feedback:
+ "Vahetab kuude nimed, nädalapäevade nimed ja nädala esimese päeva.",
},
parameters: {
docs: {
@@ -1176,7 +1192,7 @@ export const WithReactiveForms: Story = {
docs: {
description: {
story:
- "DateField implements `ControlValueAccessor`, so it slots into a `FormGroup` like any reactive control — including `mode=\"range\"`, whose value is a `{ from, to }` object. The block below the fields echoes the live `form.value`.",
+ 'DateField implements `ControlValueAccessor`, so it slots into a `FormGroup` like any reactive control — including `mode="range"`, whose value is a `{ from, to }` object. The block below the fields echoes the live `form.value`.',
},
},
},
diff --git a/tedi/components/form/feedback-text/feedback-text.stories.ts b/tedi/components/form/feedback-text/feedback-text.stories.ts
index c0ce4359a..7a64f2bbe 100644
--- a/tedi/components/form/feedback-text/feedback-text.stories.ts
+++ b/tedi/components/form/feedback-text/feedback-text.stories.ts
@@ -8,6 +8,7 @@ import {
import { FeedbackTextComponent } from "./feedback-text.component";
/**
+ * Figma ↗
* Zeroheight ↗
*/
diff --git a/tedi/components/form/form-field/form-field.component.html b/tedi/components/form/form-field/form-field.component.html
index cc396de73..64a223cc9 100644
--- a/tedi/components/form/form-field/form-field.component.html
+++ b/tedi/components/form/form-field/form-field.component.html
@@ -31,8 +31,8 @@
+ @if (label()) {
+
+ }
+
+
+
+ @if (feedbackText(); as feedback) {
+
+ }
+
+
+@if (button(); as btn) {
+
+}
diff --git a/tedi/components/form/search/search.component.scss b/tedi/components/form/search/search.component.scss
new file mode 100644
index 000000000..c3e552748
--- /dev/null
+++ b/tedi/components/form/search/search.component.scss
@@ -0,0 +1,61 @@
+.tedi-search {
+ display: flex;
+ align-items: flex-end;
+ width: 100%;
+
+ &__field {
+ flex: 1 1 auto;
+ min-width: 0;
+ }
+
+ &__button {
+ flex: 0 0 auto;
+ align-self: flex-end;
+ white-space: nowrap;
+ }
+
+ & &__button {
+ height: var(--tedi-search-field-height);
+ min-height: 0;
+ border-radius: 0 var(--button-radius-sm) var(--button-radius-sm) 0;
+ }
+
+ &--button-icon-only &__button {
+ width: var(--tedi-search-field-height);
+ padding-right: 0;
+ padding-left: 0;
+ }
+
+ & &__input--has-button {
+ border-right-width: 0;
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ }
+
+ & .tedi-form-field:not(
+ .tedi-form-field--disabled,
+ .tedi-form-field--valid,
+ .tedi-form-field--invalid
+ )
+ &__input--has-button:not(:has(input:disabled)) {
+ &:active,
+ &:has(input:active) {
+ box-shadow: inset 0 0 0 1px var(--form-input-border-active);
+ }
+
+ &:focus-within,
+ &:has(input:focus-visible) {
+ box-shadow: inset 0 0 0 1px var(--form-input-border-focus);
+ }
+ }
+
+ & .tedi-form-field--valid &__input--has-button:focus-within,
+ & .tedi-form-field--valid &__input--has-button:has(input:focus-visible) {
+ box-shadow: inset 0 0 0 1px var(--form-general-feedback-success-border);
+ }
+
+ & .tedi-form-field--invalid &__input--has-button:focus-within,
+ & .tedi-form-field--invalid &__input--has-button:has(input:focus-visible) {
+ box-shadow: inset 0 0 0 1px var(--form-general-feedback-error-border);
+ }
+}
diff --git a/tedi/components/form/search/search.component.spec.ts b/tedi/components/form/search/search.component.spec.ts
new file mode 100644
index 000000000..c43f4b5d0
--- /dev/null
+++ b/tedi/components/form/search/search.component.spec.ts
@@ -0,0 +1,334 @@
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { Component } from "@angular/core";
+import { FormControl, ReactiveFormsModule } from "@angular/forms";
+import { SearchComponent } from "./search.component";
+import { FormFieldComponent } from "../form-field/form-field.component";
+import { TextFieldComponent } from "../text-field/text-field.component";
+import { LabelComponent } from "../label/label.component";
+import { FeedbackTextComponent } from "../feedback-text/feedback-text.component";
+import { ButtonComponent } from "../../buttons/button/button.component";
+import { IconComponent } from "../../base/icon/icon.component";
+import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../tokens/translation.token";
+
+describe("SearchComponent", () => {
+ let fixture: ComponentFixture;
+ let component: SearchComponent;
+ let el: HTMLElement;
+
+ const getInput = () => el.querySelector("input") as HTMLInputElement;
+
+ const typeInto = (value: string) => {
+ const input = getInput();
+ input.value = value;
+ input.dispatchEvent(new Event("input"));
+ fixture.detectChanges();
+ };
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [
+ SearchComponent,
+ FormFieldComponent,
+ TextFieldComponent,
+ LabelComponent,
+ FeedbackTextComponent,
+ ButtonComponent,
+ IconComponent,
+ ],
+ providers: [{ provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }],
+ });
+
+ fixture = TestBed.createComponent(SearchComponent);
+ fixture.componentRef.setInput("inputId", "test-id");
+ component = fixture.componentInstance;
+ el = fixture.nativeElement;
+ fixture.detectChanges();
+ });
+
+ it("should create component", () => {
+ expect(component).toBeTruthy();
+ });
+
+ it("should render a search landmark with role and searchbox input", () => {
+ expect(el.getAttribute("role")).toBe("search");
+ expect(getInput().getAttribute("role")).toBe("searchbox");
+ });
+
+ it("should associate the label with the input", () => {
+ fixture.componentRef.setInput("label", "Otsing");
+ fixture.detectChanges();
+
+ const label = el.querySelector("label") as HTMLLabelElement;
+ expect(label.getAttribute("for")).toBe("test-id");
+ expect(label.textContent?.trim()).toBe("Otsing");
+ });
+
+ describe("accessible name", () => {
+ it("should use label when provided", () => {
+ fixture.componentRef.setInput("label", "Otsing");
+ fixture.detectChanges();
+ expect(el.getAttribute("aria-label")).toBe("Otsing");
+ });
+
+ it("should fall back to placeholder when no label", () => {
+ fixture.componentRef.setInput("placeholder", "Otsi siit");
+ fixture.detectChanges();
+ expect(el.getAttribute("aria-label")).toBe("Otsi siit");
+ });
+
+ it("should fall back to the translated search label", () => {
+ expect(el.getAttribute("aria-label")).toBe("Otsi");
+ });
+
+ it("should prefer explicit ariaLabel over label", () => {
+ fixture.componentRef.setInput("label", "Otsing");
+ fixture.componentRef.setInput("ariaLabel", "Otsi tooteid");
+ fixture.detectChanges();
+ expect(el.getAttribute("aria-label")).toBe("Otsi tooteid");
+ });
+ });
+
+ it("should show the search icon when there is no button", () => {
+ expect(el.querySelector(".tedi-form-field__icon tedi-icon")).not.toBeNull();
+ expect(el.querySelector("button.tedi-search__button")).toBeNull();
+ });
+
+ it("should propagate typed value and call onChange", () => {
+ const onChange = jest.fn();
+ component.registerOnChange(onChange);
+
+ typeInto("hello");
+
+ expect(component.value()).toBe("hello");
+ expect(onChange).toHaveBeenCalledWith("hello");
+ });
+
+ it("should reflect the value input onto the native input", () => {
+ fixture.componentRef.setInput("value", "preset");
+ fixture.detectChanges();
+ expect(getInput().value).toBe("preset");
+ });
+
+ it("should emit search on Enter key", () => {
+ const searchSpy = jest.fn();
+ component.searchEvent.subscribe(searchSpy);
+
+ typeInto("query");
+ getInput().dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));
+ fixture.detectChanges();
+
+ expect(searchSpy).toHaveBeenCalledWith("query");
+ });
+
+ it("should emit search on button click", () => {
+ fixture.componentRef.setInput("button", { text: "Otsi" });
+ fixture.detectChanges();
+
+ const searchSpy = jest.fn();
+ component.searchEvent.subscribe(searchSpy);
+
+ typeInto("query");
+ const button = el.querySelector("button.tedi-search__button") as HTMLButtonElement;
+ button.click();
+
+ expect(searchSpy).toHaveBeenCalledWith("query");
+ });
+
+ it("should hide the inline icon when a button is set", () => {
+ fixture.componentRef.setInput("button", { text: "Otsi" });
+ fixture.detectChanges();
+
+ expect(el.querySelector(".tedi-form-field__icon")).toBeNull();
+ expect(el.querySelector("button.tedi-search__button")).not.toBeNull();
+ });
+
+ it("should give an icon-only button an accessible label", () => {
+ fixture.componentRef.setInput("button", {});
+ fixture.detectChanges();
+
+ const button = el.querySelector("button.tedi-search__button") as HTMLButtonElement;
+ expect(button.getAttribute("aria-label")).toBeTruthy();
+ });
+
+ it("should clear the value and emit clear", () => {
+ const clearSpy = jest.fn();
+ component.clear.subscribe(clearSpy);
+
+ typeInto("something");
+ const clearBtn = el.querySelector(".tedi-form-field__clear") as HTMLButtonElement;
+ clearBtn.click();
+ fixture.detectChanges();
+
+ expect(component.value()).toBe("");
+ expect(clearSpy).toHaveBeenCalled();
+ });
+
+ it("should disable the input and button when disabled", () => {
+ fixture.componentRef.setInput("button", { text: "Otsi" });
+ fixture.componentRef.setInput("disabled", true);
+ fixture.detectChanges();
+
+ expect(component.isDisabled()).toBe(true);
+ expect(getInput().disabled).toBe(true);
+ const button = el.querySelector("button.tedi-search__button") as HTMLButtonElement;
+ expect(button.disabled).toBe(true);
+ });
+
+ it("should set aria-describedby when feedbackText is provided", () => {
+ const freshFixture = TestBed.createComponent(SearchComponent);
+ freshFixture.componentRef.setInput("inputId", "test-id");
+ freshFixture.componentRef.setInput("feedbackText", {
+ text: "Tagasiside tekst",
+ type: "error",
+ });
+ freshFixture.detectChanges();
+ const freshEl = freshFixture.nativeElement as HTMLElement;
+
+ expect(
+ freshEl.querySelector("input")?.getAttribute("aria-describedby")
+ ).toBe("test-id-feedback");
+ const feedback = freshEl.querySelector("tedi-feedback-text");
+ expect(feedback?.getAttribute("id")).toBe("test-id-feedback");
+ expect(feedback?.textContent?.trim()).toBe("Tagasiside tekst");
+ });
+
+ it("should not set aria-describedby without feedbackText", () => {
+ expect(getInput().getAttribute("aria-describedby")).toBeNull();
+ });
+
+ describe("ControlValueAccessor", () => {
+ it("writeValue() should set the value (and default null to empty)", () => {
+ component.writeValue("abc");
+ expect(component.value()).toBe("abc");
+
+ component.writeValue(null);
+ expect(component.value()).toBe("");
+ });
+
+ it("setDisabledState() should toggle the disabled state", () => {
+ component.setDisabledState(true);
+ fixture.detectChanges();
+ expect(component.isDisabled()).toBe(true);
+ expect(getInput().disabled).toBe(true);
+
+ component.setDisabledState(false);
+ fixture.detectChanges();
+ expect(component.isDisabled()).toBe(false);
+ expect(getInput().disabled).toBe(false);
+ });
+
+ it("should call onTouched on blur", () => {
+ const onTouched = jest.fn();
+ component.registerOnTouched(onTouched);
+
+ getInput().dispatchEvent(new Event("blur"));
+ fixture.detectChanges();
+
+ expect(onTouched).toHaveBeenCalled();
+ });
+ });
+
+ it("focus() should focus the underlying input", () => {
+ const input = getInput();
+ const focusSpy = jest.spyOn(input, "focus");
+
+ component.focus();
+
+ expect(focusSpy).toHaveBeenCalled();
+ expect(document.activeElement).toBe(input);
+ });
+
+ describe("field height", () => {
+ const heightVar = () =>
+ el.style.getPropertyValue("--tedi-search-field-height");
+
+ it("should use the small field-height token for size small", () => {
+ fixture.componentRef.setInput("size", "small");
+ fixture.detectChanges();
+
+ expect(component.fieldHeight()).toBe("var(--form-field-height-sm)");
+ expect(heightVar()).toBe("var(--form-field-height-sm)");
+ });
+
+ it("should use the large field-height token for size large", () => {
+ fixture.componentRef.setInput("size", "large");
+ fixture.detectChanges();
+
+ expect(component.fieldHeight()).toBe("var(--form-field-height-lg)");
+ expect(heightVar()).toBe("var(--form-field-height-lg)");
+ });
+
+ it("should use the default field-height token for the default size", () => {
+ expect(component.fieldHeight()).toBe("var(--form-field-height)");
+ expect(heightVar()).toBe("var(--form-field-height)");
+ });
+ });
+
+ describe("button sizing", () => {
+ it("should use a small button with an 18px icon for size small", () => {
+ fixture.componentRef.setInput("size", "small");
+ fixture.detectChanges();
+
+ expect(component.buttonSize()).toBe("small");
+ expect(component.buttonIconSize()).toBe(18);
+ });
+
+ it("should use a default button with an 18px icon for the default size", () => {
+ expect(component.buttonSize()).toBe("default");
+ expect(component.buttonIconSize()).toBe(18);
+ });
+
+ it("should use a default button with a 24px icon for size large", () => {
+ fixture.componentRef.setInput("size", "large");
+ fixture.detectChanges();
+
+ expect(component.buttonSize()).toBe("default");
+ expect(component.buttonIconSize()).toBe(24);
+ });
+ });
+});
+
+@Component({
+ standalone: true,
+ imports: [SearchComponent, ReactiveFormsModule],
+ template: ``,
+})
+class ReactiveHostComponent {
+ control = new FormControl("", { nonNullable: true });
+}
+
+describe("SearchComponent with reactive forms", () => {
+ let hostFixture: ComponentFixture;
+ let host: ReactiveHostComponent;
+ let hostEl: HTMLElement;
+
+ const getInput = () => hostEl.querySelector("input") as HTMLInputElement;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [ReactiveHostComponent],
+ providers: [{ provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }],
+ });
+
+ hostFixture = TestBed.createComponent(ReactiveHostComponent);
+ host = hostFixture.componentInstance;
+ hostEl = hostFixture.nativeElement;
+ hostFixture.detectChanges();
+ });
+
+ it("should register as a value accessor and reflect the control value", () => {
+ host.control.setValue("from-model");
+ hostFixture.detectChanges();
+
+ expect(getInput().value).toBe("from-model");
+ });
+
+ it("should propagate input changes back to the form control", () => {
+ const input = getInput();
+ input.value = "typed";
+ input.dispatchEvent(new Event("input"));
+ hostFixture.detectChanges();
+
+ expect(host.control.value).toBe("typed");
+ });
+});
diff --git a/tedi/components/form/search/search.component.ts b/tedi/components/form/search/search.component.ts
new file mode 100644
index 000000000..6d536f655
--- /dev/null
+++ b/tedi/components/form/search/search.component.ts
@@ -0,0 +1,229 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ ElementRef,
+ forwardRef,
+ inject,
+ input,
+ model,
+ output,
+ signal,
+ viewChild,
+ ViewEncapsulation,
+} from "@angular/core";
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
+import { ButtonComponent, ButtonVariant } from "../../buttons/button/button.component";
+import { IconComponent } from "../../base/icon/icon.component";
+import { TediTranslationService } from "../../../services/translation/translation.service";
+import { ComponentInputs } from "../../../types/inputs.type";
+import {
+ FormFieldComponent,
+ FormFieldIcon,
+ InputSize,
+} from "../form-field/form-field.component";
+import { TextFieldComponent } from "../text-field/text-field.component";
+import { LabelComponent } from "../label/label.component";
+import { FeedbackTextComponent } from "../feedback-text/feedback-text.component";
+
+export type SearchSize = InputSize;
+
+export interface SearchButton {
+ /**
+ * Visible button text. When omitted, the button is rendered icon-only.
+ */
+ text?: string;
+ /**
+ * Icon shown inside the button.
+ * @default "search"
+ */
+ icon?: string;
+ /**
+ * Button color variant.
+ * @default "primary"
+ */
+ variant?: ButtonVariant;
+ /**
+ * Accessible label for an icon-only button.
+ * @default translation "search"
+ */
+ ariaLabel?: string;
+}
+
+@Component({
+ selector: "tedi-search",
+ standalone: true,
+ templateUrl: "./search.component.html",
+ styleUrl: "./search.component.scss",
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ encapsulation: ViewEncapsulation.None,
+ imports: [
+ FormFieldComponent,
+ TextFieldComponent,
+ LabelComponent,
+ FeedbackTextComponent,
+ ButtonComponent,
+ IconComponent,
+ ],
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => SearchComponent),
+ multi: true,
+ },
+ ],
+ host: {
+ role: "search",
+ class: "tedi-search",
+ "[attr.aria-label]": "searchAriaLabel()",
+ "[style.--tedi-search-field-height]": "fieldHeight()",
+ "[class.tedi-search--button-icon-only]": "!!button() && !button()?.text",
+ },
+})
+export class SearchComponent implements ControlValueAccessor {
+ /**
+ * Unique identifier for the input element, used to associate the label.
+ */
+ inputId = input.required();
+ /**
+ * Visible label text. When omitted, provide `ariaLabel` for accessibility.
+ */
+ label = input();
+ /**
+ * Value of the search input. Supports two-way binding and reactive forms.
+ */
+ value = model("");
+ /**
+ * Placeholder text for the search input.
+ */
+ placeholder = input("");
+ /**
+ * Size of the search field.
+ * @default "default"
+ */
+ size = input("default");
+ /**
+ * Whether the input shows a clear button once it has a value.
+ * @default true
+ */
+ clearable = input(true);
+ /**
+ * Icon shown inside the input. Ignored when `button` is set.
+ * @default "search"
+ */
+ searchIcon = input("search");
+ /**
+ * Whether the search field is disabled.
+ * @default false
+ */
+ disabled = input(false);
+ /**
+ * When set, renders a trailing search button and hides the inline icon.
+ */
+ button = input();
+ /**
+ * FeedbackText component inputs (hint / validation message).
+ */
+ feedbackText = input>();
+ /**
+ * Accessible name for the search region. Falls back to `label`, then
+ * `placeholder`, then the translated "search" label.
+ */
+ ariaLabel = input();
+
+ /**
+ * Emitted when the search is executed (Enter key or button click).
+ */
+ readonly searchEvent = output();
+ /**
+ * Emitted when the clear button is clicked.
+ */
+ readonly clear = output();
+
+ private readonly inputRef = viewChild("searchInput", { read: ElementRef });
+
+ private readonly formDisabled = signal(false);
+ private readonly translationService = inject(TediTranslationService);
+ private onChange: (value: string) => void = () => {};
+ private onTouched: () => void = () => {};
+
+ readonly isDisabled = computed(() => this.disabled() || this.formDisabled());
+
+ readonly fieldIcon = computed(() =>
+ this.button() ? undefined : this.searchIcon()
+ );
+
+ readonly fieldHeight = computed(() => {
+ switch (this.size()) {
+ case "small":
+ return "var(--form-field-height-sm)";
+ case "large":
+ return "var(--form-field-height-lg)";
+ default:
+ return "var(--form-field-height)";
+ }
+ });
+
+ readonly buttonSize = computed(() =>
+ this.size() === "small" ? "small" : "default"
+ );
+
+ readonly buttonIconSize = computed(() => (this.size() === "large" ? 24 : 18));
+
+ readonly buttonAriaLabel = computed(() => {
+ const button = this.button();
+ if (button?.text) return null;
+ return button?.ariaLabel ?? this.translationService.translate("search");
+ });
+
+ readonly feedbackId = computed(() =>
+ this.feedbackText() ? `${this.inputId()}-feedback` : null
+ );
+
+ readonly searchAriaLabel = computed(
+ () =>
+ this.ariaLabel() ||
+ this.label() ||
+ this.placeholder() ||
+ this.translationService.translate("search")
+ );
+
+ onInputValue(value: string): void {
+ this.value.set(value);
+ this.onChange(value);
+ }
+
+ onClear(): void {
+ this.clear.emit();
+ this.onTouched();
+ }
+
+ onBlur(): void {
+ this.onTouched();
+ }
+
+ emitSearch(): void {
+ this.searchEvent.emit(this.value());
+ }
+
+ focus(): void {
+ const input = this.inputRef()?.nativeElement as HTMLInputElement | undefined;
+ input?.focus();
+ }
+
+ writeValue(value: string | null): void {
+ this.value.set(value ?? "");
+ }
+
+ registerOnChange(fn: (value: string) => void): void {
+ this.onChange = fn;
+ }
+
+ registerOnTouched(fn: () => void): void {
+ this.onTouched = fn;
+ }
+
+ setDisabledState(isDisabled: boolean): void {
+ this.formDisabled.set(isDisabled);
+ }
+}
diff --git a/tedi/components/form/search/search.stories.ts b/tedi/components/form/search/search.stories.ts
new file mode 100644
index 000000000..ab1af1d7b
--- /dev/null
+++ b/tedi/components/form/search/search.stories.ts
@@ -0,0 +1,1007 @@
+import { TitleCasePipe } from "@angular/common";
+import {
+ Component,
+ computed,
+ ElementRef,
+ inject,
+ OnDestroy,
+ signal,
+ viewChild,
+} from "@angular/core";
+import { FormControl, ReactiveFormsModule, Validators } from "@angular/forms";
+import {
+ argsToTemplate,
+ Meta,
+ moduleMetadata,
+ StoryObj,
+} from "@storybook/angular";
+import { SearchComponent } from "./search.component";
+import {
+ CdkOverlayOrigin,
+ ConnectedPosition,
+ OverlayModule,
+} from "@angular/cdk/overlay";
+import { FormFieldComponent } from "../form-field/form-field.component";
+import { LabelComponent } from "../label/label.component";
+import { TextFieldComponent } from "../text-field/text-field.component";
+import { AlertComponent } from "../../notifications/alert/alert.component";
+import { ColComponent } from "../../helpers/grid/col/col.component";
+import { RowComponent } from "../../helpers/grid/row/row.component";
+import { TextComponent } from "../../base/text/text.component";
+import { ButtonComponent } from "../../buttons/button/button.component";
+import { SeparatorComponent } from "../../helpers/separator/separator.component";
+import { SpinnerComponent } from "../../loader/spinner/spinner.component";
+import { DropdownItemValueComponent } from "../../overlay/dropdown/dropdown-item-value/dropdown-item-value.component";
+import { DropdownItemValueLabelComponent } from "../../overlay/dropdown/dropdown-item-value/dropdown-item-value-label.component";
+import { DropdownItemValueMetaComponent } from "../../overlay/dropdown/dropdown-item-value/dropdown-item-value-meta.component";
+
+const SIZES = ["small", "default", "large"] as const;
+const PSEUDO_STATE = ["Default", "Hover", "Active", "Focus"];
+
+// Floating panel below the field, flipping above when it would overflow.
+const OVERLAY_POSITIONS: ConnectedPosition[] = [
+ {
+ originX: "start",
+ originY: "bottom",
+ overlayX: "start",
+ overlayY: "top",
+ offsetY: 4,
+ },
+ {
+ originX: "start",
+ originY: "top",
+ overlayX: "start",
+ overlayY: "bottom",
+ offsetY: -4,
+ },
+];
+
+const PEOPLE = [
+ "Mari Maasikas",
+ "Marelle Mets",
+ "Marjanne Meri",
+ "Mart Mesi",
+ "Martin Saar",
+ "Kalle Kask",
+ "Kati Kuusk",
+ "Tõnu Tamm",
+ "Liisa Lepp",
+ "Jaan Järv",
+];
+
+interface HighlightPart {
+ text: string;
+ match: boolean;
+}
+
+/** Splits `text` into segments so the part matching `query` can be bolded. */
+function highlightParts(text: string, query: string): HighlightPart[] {
+ const q = query.trim();
+ const index = q ? text.toLowerCase().indexOf(q.toLowerCase()) : -1;
+
+ if (index === -1) {
+ return [{ text, match: false }];
+ }
+
+ const parts: HighlightPart[] = [];
+ if (index > 0) {
+ parts.push({ text: text.slice(0, index), match: false });
+ }
+ parts.push({ text: text.slice(index, index + q.length), match: true });
+ if (index + q.length < text.length) {
+ parts.push({ text: text.slice(index + q.length), match: false });
+ }
+ return parts;
+}
+
+// Live "suggest as you type" needs focus to stay in the input. The floating
+// `tedi-dropdown` moves focus into the list when it opens (it is a menu, not a
+// combobox), which would interrupt typing — so the typeahead/async examples
+// render results in an inline region instead. These shared styles mirror the
+// `.tedi-dropdown` surface and item padding so the inline panel looks native.
+const LIVE_RESULTS_STYLES = `
+ .tedi-search-demo__wrap {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ }
+
+ .tedi-search-demo__results {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ overflow: hidden;
+ background: var(--dropdown-item-default-background);
+ border: 1px solid var(--card-border-primary);
+ border-radius: var(--form-select-area-radius);
+ box-shadow: 0 1px 5px 0 var(--tedi-alpha-20);
+ }
+
+ .tedi-search-demo__option {
+ display: block;
+ width: 100%;
+ padding: var(--dropdown-item-padding-y) var(--dropdown-item-padding-x);
+ font: inherit;
+ color: inherit;
+ text-align: left;
+ cursor: pointer;
+ background: transparent;
+ border: 0;
+ }
+
+ .tedi-search-demo__option:hover,
+ .tedi-search-demo__option:focus-visible {
+ background: var(--dropdown-item-hover-background);
+ }
+
+ .tedi-search-demo__status {
+ display: flex;
+ gap: var(--dropdown-item-inner-spacing);
+ align-items: center;
+ justify-content: center;
+ padding: var(--dropdown-item-padding-y) var(--dropdown-item-padding-x);
+ }
+`;
+
+/**
+ * Suggestions as a proper ARIA combobox: `role="combobox"` on the input with a
+ * `role="listbox"` popup. Focus stays in the input, so typing (spaces included)
+ * always works; ArrowUp/Down move the highlighted option via
+ * `aria-activedescendant`, Enter selects it and Esc closes. Opens on focus,
+ * typing or ArrowDown. This is the recommended pattern for a text field with
+ * suggestions, rather than the menu-style `tedi-dropdown`.
+ */
+@Component({
+ standalone: true,
+ selector: "tedi-search-suggestions-demo",
+ imports: [
+ FormFieldComponent,
+ LabelComponent,
+ TextFieldComponent,
+ OverlayModule,
+ ],
+ template: `
+
+
+
+
+
+
+
+ @for (name of matches(); track name; let i = $index) {
+
+
+
+
+
+
+ `,
+ }),
+};
+
+export const Placeholder: Story = {
+ render: () => ({
+ template: `
+
+ `,
+ }),
+};
+
+/**
+ * With `clearable`, a clear (×) button appears once the field has a value and
+ * empties it on click.
+ */
+export const Clearable: Story = {
+ render: () => ({
+ template: `
+
+ `,
+ }),
+};
+
+/**
+ * Clearable field paired with a search button — the clear (×) empties the field;
+ * the button runs the search.
+ */
+export const ClearableWithButton: Story = {
+ name: "Clearable with button",
+ render: () => ({
+ template: `
+
+ `,
+ }),
+};
+
+export const WithHint: Story = {
+ name: "With hint",
+ render: () => ({
+ template: `
+
+ `,
+ }),
+};
+
+/**
+ * Suggestions filtered by the current value, shown in a floating panel anchored
+ * to the field. Closed by default — it opens on focus or click and closes on
+ * outside-click or Esc. Click a match to select it.
+ */
+export const WithSuggestions: Story = {
+ name: "With suggestions",
+ render: () => ({
+ moduleMetadata: { imports: [SearchSuggestionsDemoComponent] },
+ template: ``,
+ }),
+};
+
+/**
+ * A single matched result followed by fallback actions and a hint — e.g. a
+ * national-registry person lookup. Closed by default; it opens on focus or click
+ * and closes on outside-click or Esc.
+ */
+export const WithResultAndActions: Story = {
+ name: "With result and actions",
+ render: () => ({
+ moduleMetadata: { imports: [SearchResultActionsDemoComponent] },
+ template: ``,
+ }),
+};
+
+/**
+ * Live typeahead — results filter as you type and the matched text is bolded.
+ * The panel appears once the field is non-empty and shows a "no results" row
+ * when nothing matches. Focus stays in the input (inline region, not the
+ * floating menu), so typing is never interrupted.
+ */
+export const Typeahead: Story = {
+ name: "Typeahead (live filtering)",
+ render: () => ({
+ moduleMetadata: { imports: [SearchTypeaheadDemoComponent] },
+ template: ``,
+ }),
+};
+
+/**
+ * Asynchronous suggestions — typing debounces a fake request that shows a
+ * spinner while "loading", then the matched results (or an empty state).
+ */
+export const AsyncSuggestions: Story = {
+ name: "Async suggestions (loading)",
+ render: () => ({
+ moduleMetadata: { imports: [SearchAsyncDemoComponent] },
+ template: ``,
+ }),
+};
+
+export const WithReactiveForms: Story = {
+ render: () => {
+ const control = new FormControl("", {
+ nonNullable: true,
+ validators: [Validators.required],
+ });
+
+ return {
+ props: { control },
+ template: `
+
+
+
+
+
+
+