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) { +
  • + {{ name }} +
  • + } +
+
+ `, + styles: [ + LIVE_RESULTS_STYLES + + ` + .tedi-search-demo__results { + margin: 0; + padding: 0; + list-style: none; + } + + .tedi-search-demo__option--active { + background: var(--dropdown-item-hover-background); + } + `, + ], +}) +class SearchSuggestionsDemoComponent { + private readonly overlayOrigin = viewChild.required(CdkOverlayOrigin); + readonly overlayPositions = OVERLAY_POSITIONS; + readonly panelWidth = signal(0); + + readonly value = signal("Mar"); + readonly open = signal(false); + readonly activeIndex = signal(-1); + + private readonly names = [ + "Mari Maasikas", + "Marelle Mets", + "Marjanne Meri", + "Mart Mesi", + "Martin Saar", + ]; + readonly matches = computed(() => + this.names.filter((name) => + name.toLowerCase().includes(this.value().toLowerCase()), + ), + ); + readonly isOpen = computed(() => this.open() && this.matches().length > 0); + readonly activeOptionId = computed(() => + this.isOpen() && this.activeIndex() >= 0 + ? this.optionId(this.activeIndex()) + : null, + ); + + optionId(index: number): string { + return `search-combobox-opt-${index}`; + } + + openPanel(): void { + this.panelWidth.set( + this.overlayOrigin().elementRef.nativeElement.offsetWidth, + ); + this.open.set(true); + } + + onInput(next: string): void { + this.value.set(next); + this.activeIndex.set(-1); + this.openPanel(); + } + + onKeydown(event: KeyboardEvent): void { + const items = this.matches(); + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + if (!this.isOpen()) { + this.openPanel(); + return; + } + this.activeIndex.set( + this.activeIndex() >= items.length - 1 ? 0 : this.activeIndex() + 1, + ); + break; + case "ArrowUp": + event.preventDefault(); + if (!this.isOpen()) { + this.openPanel(); + return; + } + this.activeIndex.set( + this.activeIndex() <= 0 ? items.length - 1 : this.activeIndex() - 1, + ); + break; + case "Enter": + if (this.isOpen() && this.activeIndex() >= 0) { + event.preventDefault(); + this.select(items[this.activeIndex()]); + } + break; + case "Escape": + this.open.set(false); + this.activeIndex.set(-1); + break; + } + } + + select(name: string): void { + this.value.set(name); + this.open.set(false); + this.activeIndex.set(-1); + } +} + +/** + * A single matched result with fallback actions and a hint — e.g. a + * national-registry person lookup. The result panel renders inline below the + * field (not in an overlay), so focus flows naturally: Tab from the field moves + * through the action buttons. Opens on focus, closes on Esc or when focus leaves + * the field and its panel. + */ +@Component({ + standalone: true, + selector: "tedi-search-result-actions-demo", + imports: [ + FormFieldComponent, + LabelComponent, + TextFieldComponent, + SeparatorComponent, + ButtonComponent, + TextComponent, + DropdownItemValueComponent, + DropdownItemValueLabelComponent, + DropdownItemValueMetaComponent, + ], + host: { + "(focusout)": "onFocusOut($event)", + }, + template: ` +
+ + + + + + @if (open()) { +
+ + + + +
+
+ + +
+

+ Rahvastikuregistri andmete päringuks sisesta isikukood täismahus +

+
+
+ } +
+ `, + styles: [ + LIVE_RESULTS_STYLES + + ` + .tedi-search-demo__result { + 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__result:hover, + .tedi-search-demo__result:focus-visible { + background: var(--dropdown-item-hover-background); + } + + .tedi-search-demo__actions { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: var(--dropdown-item-padding-y) var(--dropdown-item-padding-x); + } + + .tedi-search-demo__buttons { + display: flex; + gap: 0.5rem; + justify-content: center; + } + `, + ], +}) +class SearchResultActionsDemoComponent { + private readonly host = inject>(ElementRef); + readonly value = signal("4954080254"); + readonly open = signal(false); + + onFocusOut(event: FocusEvent): void { + const next = event.relatedTarget as Node | null; + if (!next || !this.host.nativeElement.contains(next)) { + this.open.set(false); + } + } +} + +/** + * 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. + */ +@Component({ + standalone: true, + selector: "tedi-search-typeahead-demo", + imports: [SearchComponent, TextComponent], + template: ` +
+ + @if (open() && query().length > 0) { +
+ @if (matches().length > 0) { + @for (name of matches(); track name) { + + } + } @else { +
+ Tulemusi ei leitud +
+ } +
+ } +
+ `, + styles: [LIVE_RESULTS_STYLES], +}) +class SearchTypeaheadDemoComponent { + readonly value = signal(""); + readonly open = signal(false); + readonly query = computed(() => this.value().trim()); + readonly matches = computed(() => { + const q = this.query().toLowerCase(); + return q ? PEOPLE.filter((name) => name.toLowerCase().includes(q)) : []; + }); + + onChange(next: string): void { + this.value.set(next); + this.open.set(true); + } + + select(name: string): void { + this.value.set(name); + this.open.set(false); + } + + highlight(text: string): HighlightPart[] { + return highlightParts(text, this.query()); + } +} + +/** + * Asynchronous suggestions — typing debounces a fake request that shows a + * spinner while "loading", then the matched results (or an empty state). + */ +@Component({ + standalone: true, + selector: "tedi-search-async-demo", + imports: [SearchComponent, TextComponent, SpinnerComponent], + template: ` +
+ + @if (open() && query().length > 0) { +
+ @if (loading()) { +
+ + Otsin… +
+ } @else if (results().length > 0) { + @for (name of results(); track name) { + + } + } @else { +
+ Tulemusi ei leitud +
+ } +
+ } +
+ `, + styles: [LIVE_RESULTS_STYLES], +}) +class SearchAsyncDemoComponent implements OnDestroy { + readonly value = signal(""); + readonly open = signal(false); + readonly loading = signal(false); + readonly results = signal([]); + readonly query = computed(() => this.value().trim()); + private timer?: ReturnType; + + onChange(next: string): void { + this.value.set(next); + this.open.set(true); + clearTimeout(this.timer); + + if (!next.trim()) { + this.loading.set(false); + this.results.set([]); + return; + } + + this.loading.set(true); + this.timer = setTimeout(() => { + const q = next.trim().toLowerCase(); + this.results.set(PEOPLE.filter((name) => name.toLowerCase().includes(q))); + this.loading.set(false); + }, 600); + } + + select(name: string): void { + clearTimeout(this.timer); + this.value.set(name); + this.loading.set(false); + this.open.set(false); + } + + highlight(text: string): HighlightPart[] { + return highlightParts(text, this.query()); + } + + ngOnDestroy(): void { + clearTimeout(this.timer); + } +} + +/** + * Figma ↗
+ * Zeroheight ↗
+ * + * Search wraps `tedi-form-field` + `input[tedi-text-field]` with an optional trailing button. + * Works with Reactive forms and Template-driven forms. + */ + +export default { + title: "TEDI-Ready/Components/Form/Search", + component: SearchComponent, + decorators: [ + moduleMetadata({ + imports: [ + RowComponent, + ColComponent, + TextComponent, + AlertComponent, + TitleCasePipe, + ReactiveFormsModule, + ], + }), + ], + argTypes: { + inputId: { + description: + "Unique identifier for the input element, used to associate the label.", + control: { type: "text" }, + table: { category: "inputs", type: { summary: "string" } }, + }, + label: { + description: + "Visible label text. When omitted, provide `ariaLabel` for accessibility.", + control: { type: "text" }, + table: { category: "inputs", type: { summary: "string" } }, + }, + value: { + description: + "Value of the search input. Supports two-way binding and reactive forms.", + control: { type: "text" }, + table: { + category: "inputs", + type: { summary: "string" }, + defaultValue: { summary: "" }, + }, + }, + placeholder: { + description: "Placeholder text for the search input.", + control: { type: "text" }, + table: { category: "inputs", type: { summary: "string" } }, + }, + size: { + description: "Size of the search field.", + control: { type: "radio" }, + options: ["default", "small", "large"], + table: { + category: "inputs", + type: { summary: "SearchSize", detail: "default \nsmall \nlarge" }, + defaultValue: { summary: "default" }, + }, + }, + clearable: { + description: + "Whether the input shows a clear button once it has a value.", + control: { type: "boolean" }, + table: { + category: "inputs", + type: { summary: "boolean" }, + defaultValue: { summary: "true" }, + }, + }, + searchIcon: { + description: "Icon shown inside the input. Ignored when `button` is set.", + control: { type: "object" }, + table: { + category: "inputs", + type: { summary: "string | FormFieldIcon" }, + }, + }, + disabled: { + description: "Whether the search field is disabled.", + control: { type: "boolean" }, + table: { + category: "inputs", + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + button: { + description: + "When set, renders a trailing search button and hides the inline icon.", + control: { type: "object" }, + table: { category: "inputs", type: { summary: "SearchButton" } }, + }, + feedbackText: { + description: "FeedbackText component inputs (hint / validation message).", + control: { type: "object" }, + table: { + category: "inputs", + type: { summary: "ComponentInputs" }, + }, + }, + ariaLabel: { + description: + 'Accessible name for the search region. Falls back to `label`, then `placeholder`, then the translated "search" label.', + control: { type: "text" }, + table: { category: "inputs", type: { summary: "string" } }, + }, + searchEvent: { + description: + "Emitted when the search is executed (Enter key or button click).", + control: false, + action: "searchEvent", + table: { category: "outputs", type: { summary: "string" } }, + }, + clear: { + description: "Emitted when the clear button is clicked.", + control: false, + action: "clear", + table: { category: "outputs", type: { summary: "void" } }, + }, + }, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + inputId: "search-default", + label: "Otsing", + placeholder: "Otsi nime või märksõna järgi", + }, + render: (args) => ({ + props: args, + template: ``, + }), +}; + +export const Sizes: Story = { + render: () => ({ + props: { SIZES }, + template: ` + +
+
+

{{ size | titlecase }}

+
+ + + +
+
+
+ `, + }), +}; + +export const States: Story = { + parameters: { + pseudo: { + hover: "#search-states-Hover", + active: "#search-states-Active", + focusVisible: "#search-states-Focus", + }, + }, + render: () => ({ + props: { PSEUDO_STATE }, + template: ` + + +

{{ state }}

+ + + +
+ +

Disabled

+ + + +
+ +

Success

+ + + +
+ +

Error

+ + + +
+
+ `, + }), +}; + +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: ` + + + + + + +
{{ { value: control.value, touched: control.touched, dirty: control.dirty } | json }}
+
+
+
+ `, + }; + }, + parameters: { + docs: { + description: { + story: + "Search implements `ControlValueAccessor`, so it slots into reactive forms like any control. The block below echoes the live control state.", + }, + }, + }, +}; + +/** + * Always prefer a native `
} - + @if (hasRoleSelection()) { - + } @else { +
{{ currentRepresentative().name }}
+ } @if (showSearch()) { -
- - -
+ } @if (filteredRepresentatives().length === 0) { @if (hasNoResultsContent()) { @@ -117,29 +116,26 @@ {{ noResultsText() }} } } - -
- - -
{{ currentRepresentative().name }}
+
+
{{ r.name }}
+ @if (r.description) { +
+ {{ r.description }} +
+ } +
+ + }
diff --git a/tedi/components/layout/header/header-role/header-role.component.scss b/tedi/components/layout/header/header-role/header-role.component.scss index fc581a8ca..2b49bf653 100644 --- a/tedi/components/layout/header/header-role/header-role.component.scss +++ b/tedi/components/layout/header/header-role/header-role.component.scss @@ -144,16 +144,6 @@ } } - &__input { - gap: var(--form-field-inner-spacing); - width: 100%; - padding: var(--form-field-padding-y-md-default) - var(--form-field-padding-x-md-default); - background: var(--form-input-background-default); - border: var(--tedi-borders-01) solid var(--form-input-border-default); - border-radius: var(--form-field-radius); - } - &__no-results { padding-top: var(--layout-grid-gutters-08); color: var(--general-text-secondary); diff --git a/tedi/components/layout/header/header-role/header-role.component.spec.ts b/tedi/components/layout/header/header-role/header-role.component.spec.ts index 2927301e0..d1d77e4f5 100644 --- a/tedi/components/layout/header/header-role/header-role.component.spec.ts +++ b/tedi/components/layout/header/header-role/header-role.component.spec.ts @@ -127,10 +127,9 @@ describe("HeaderRoleComponent", () => { expect(comp.currentRepresentative()).toBe(newRep); }); - it("should set inputValue from the event target on handleInputChange()", () => { + it("should set inputValue on handleInputChange()", () => { const comp = fixture.componentInstance; - const event = { target: { value: "foo" } } as unknown as Event; - comp.handleInputChange(event); + comp.handleInputChange("foo"); expect(comp.inputValue()).toBe("foo"); }); @@ -392,11 +391,11 @@ describe("HeaderRoleComponent desktop popover effects", () => { ).popover(); const searchInput = ( headerRole as unknown as { - searchInput: () => { nativeElement: HTMLInputElement } | undefined; + searchInput: () => { focus: () => void } | undefined; } ).searchInput(); expect(searchInput).toBeDefined(); - const focusSpy = jest.spyOn(searchInput!.nativeElement, "focus"); + const focusSpy = jest.spyOn(searchInput!, "focus"); popover!.isOpen.set(true); fixture.detectChanges(); diff --git a/tedi/components/layout/header/header-role/header-role.component.ts b/tedi/components/layout/header/header-role/header-role.component.ts index 855606cc6..f1a01914f 100644 --- a/tedi/components/layout/header/header-role/header-role.component.ts +++ b/tedi/components/layout/header/header-role/header-role.component.ts @@ -1,4 +1,4 @@ -import { NgFor, NgIf, NgTemplateOutlet } from "@angular/common"; +import { NgTemplateOutlet } from "@angular/common"; import { ChangeDetectionStrategy, Component, @@ -6,7 +6,6 @@ import { contentChild, Directive, effect, - ElementRef, inject, input, model, @@ -30,6 +29,7 @@ import { PopoverContentComponent } from "../../../overlay/popover/popover-conten import { SeparatorComponent } from "../../../helpers/separator/separator.component"; import { IconSize } from "../../../base/icon/icon.component"; import { HeaderProfileComponent } from "../header-profile/header-profile.component"; +import { SearchComponent } from "../../../form/search/search.component"; export type RepresentativeIcon = { /** Material Icon name. */ @@ -108,8 +108,6 @@ let nextHeaderRoleInputId = 0; selector: "tedi-header-role", standalone: true, imports: [ - NgFor, - NgIf, NgTemplateOutlet, PopoverComponent, PopoverTriggerDirective, @@ -120,6 +118,7 @@ let nextHeaderRoleInputId = 0; ShowAtDirective, HideAtDirective, SeparatorComponent, + SearchComponent, ], templateUrl: "./header-role.component.html", styleUrl: "./header-role.component.scss", @@ -229,8 +228,7 @@ export class HeaderRoleComponent { private readonly popover = viewChild(PopoverComponent); - private readonly searchInput = - viewChild>("searchInput"); + private readonly searchInput = viewChild(SearchComponent); private previousPopoverOpen: boolean | undefined; @@ -249,7 +247,7 @@ export class HeaderRoleComponent { // popover's setTimeout and cause focus loss. effect(() => { if (this.popover()?.isOpen() && this.showSearch()) { - setTimeout(() => this.searchInput()?.nativeElement.focus()); + setTimeout(() => this.searchInput()?.focus()); } }); @@ -346,13 +344,10 @@ export class HeaderRoleComponent { this.popover()?.hidePopover(); } - handleInputChange(event: Event) { - const value = (event.target as HTMLInputElement).value; + handleInputChange(value: string) { this.inputValue.set(value); } - trackById = (_: number, r: Representative) => r.id; - protected resolveIcon( icon: string | RepresentativeIcon | undefined, ): { name: string; size: IconSize } | null { diff --git a/tedi/components/layout/header/header.stories.ts b/tedi/components/layout/header/header.stories.ts index 986b43593..73dabc2ca 100644 --- a/tedi/components/layout/header/header.stories.ts +++ b/tedi/components/layout/header/header.stories.ts @@ -49,10 +49,7 @@ import { HeaderTopComponent } from "./header-top/header-top.component"; import { FormFieldComponent } from "../../form/form-field/form-field.component"; import { LabelComponent } from "../../form/label/label.component"; import { TextFieldComponent } from "../../form/text-field/text-field.component"; -// TODO: replace with TEDI-Ready Search component once it lands. Community Search is -// used here only to demo HeaderSearch consumption — do NOT mirror this import from -// any non-story file inside `tedi/`. -import { SearchComponent } from "community/components/form"; +import { SearchComponent } from "../../form/search/search.component"; import { TextComponent } from "../../base/text/text.component"; import { TagComponent } from "../../tags/tag/tag.component"; import { ToggleComponent } from "../../form/toggle/toggle.component"; diff --git a/tedi/components/overlay/popover/popover.stories.ts b/tedi/components/overlay/popover/popover.stories.ts index e1fe7ce8f..8a6acd4a8 100644 --- a/tedi/components/overlay/popover/popover.stories.ts +++ b/tedi/components/overlay/popover/popover.stories.ts @@ -17,6 +17,7 @@ import { EmptyStateComponent } from "../../helpers/empty-state/empty-state.compo import { LabelComponent } from "../../form/label/label.component"; import { DropdownItemValueComponent } from "../dropdown/dropdown-item-value/dropdown-item-value.component"; import { DropdownItemValueLabelComponent } from "../dropdown/dropdown-item-value/dropdown-item-value-label.component"; +import { SearchComponent } from "../../form/search/search.component"; const MAXWIDTH = ["none", "small", "medium", "large"]; const POSITIONS: PopoverPosition[] = [ @@ -64,6 +65,7 @@ export default { SeparatorComponent, EmptyStateComponent, LabelComponent, + SearchComponent, DropdownItemValueComponent, DropdownItemValueLabelComponent, ], @@ -515,13 +517,7 @@ export const WithProminentBorder: Story = { Representatives -
- - -
+