diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
index 37b28fe79..3a5ca0eba 100644
--- a/.storybook/preview.tsx
+++ b/.storybook/preview.tsx
@@ -92,7 +92,10 @@ const preview: Preview = {
},
docs: {
codePanel: true,
- toc: true,
+ // The default is `h3` alone, so `h2` sections never reach the TOC.
+ // Excluding `#stories` keeps autodocs' own heading from nesting every
+ // story beneath it.
+ toc: { headingSelector: "h2:not(#stories), h3" },
page: () => (
<>
diff --git a/AGENTS.md b/AGENTS.md
index f6b5d4464..830962879 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,7 +4,9 @@ Guidance for AI coding agents working in this repository.
## Project Overview
-TEDI Design System Angular component library (`@tedi-design-system/angular`). Angular 19 standalone component library published via ng-packagr, with Storybook for documentation.
+TEDI Design System Angular component library (`@tedi-design-system/angular`). Angular 20 standalone component library published via ng-packagr, with Storybook for documentation.
+
+Supported Angular versions: **20, 21 and 22** — the published peer range is `^20.0.0 || ^21.0.0 || ^22.0.0` (declared in both `package.json` and `src/package.json`). The repo itself develops against Angular 20, which is also the floor consumers may be on, so do not rely on APIs introduced after Angular 20.
## Commands
diff --git a/skills/tedi-angular/references/components.md b/skills/tedi-angular/references/components.md
index 5b36a2b83..b07e5145b 100644
--- a/skills/tedi-angular/references/components.md
+++ b/skills/tedi-angular/references/components.md
@@ -188,6 +188,18 @@ Both entry points declare `[tedi-floating-button]`. The Community component is
`brand-primary` independently.
- **`[tedi-label-row]` projects your own ``** plus trailing affixes as siblings, so
native label attributes (`for`, `id`, `aria-*`, handlers) keep working.
+- **Configure the inline editor for its control.** Set `[closeOnEnter]="false"
+ when Enter belongs to the control, such as a textarea or select. The edit icon
+ is visible by default; use `hideEditIcon` only when another visible cue identifies
+ the value as editable. Use `fullWidth` to fill the container.
+- **`tedi-inline-edit` needs an ``.** The control goes in that
+ template, not as plain content — it is instantiated on entering edit mode and destroyed on
+ leaving. Without the template the value never becomes editable. The inline edit does **not** own
+ the value: your control keeps its own binding, and `displayValue` has to be bound to the same
+ source. `(editCancel)` therefore only reports that Escape was pressed; restoring the previous
+ value is yours to do. A projected `tedi-feedback-text` is linked to the trigger for you. Give the editor control its
+ own accessible label and feedback association. Use `updateOn: "change"` or `"blur"` when
+ persisting the bound value in `editCommit`; `"submit"` remains the parent form's responsibility.
- **`tedi-attachment` has no built-in action buttons.** Project neutral `tedi-button`s inside a single
`` and wire `(click)` and `disabled` yourself.
- **`tedi-skeleton-block` belongs inside `tedi-skeleton`.** The wrapper stacks whatever is projected
diff --git a/skills/tedi-angular/references/forms.md b/skills/tedi-angular/references/forms.md
index 6a085ed67..1c1aceaf7 100644
--- a/skills/tedi-angular/references/forms.md
+++ b/skills/tedi-angular/references/forms.md
@@ -31,7 +31,7 @@ component's `ɵɵComponentDeclaration` carries its real selector; see
| SelectComponent | `tedi-select` | `T \| T[]` |
| TextEditorComponent | `tedi-text-editor` | `string` (Quill HTML) — `/community/text-editor` entry point |
-`CheckboxComponent` (`input[type=checkbox][tedi-checkbox]`) is **not** a TEDI value accessor — it styles a native checkbox, so `[formControl]` on it is handled by Angular's built-in `CheckboxControlValueAccessor` and yields a `boolean`. Inside a managed ``, its `value` input is a `string` identity instead. `DropdownComponent` (`tedi-dropdown`) lives in `overlay/` and is not a form control — it exposes `[(value)]` but implements no `ControlValueAccessor`.
+`CheckboxComponent` (`input[type=checkbox][tedi-checkbox]`) is **not** a TEDI value accessor — it styles a native checkbox, so `[formControl]` on it is handled by Angular's built-in `CheckboxControlValueAccessor` and yields a `boolean`. Inside a managed ``, its `value` input is a `string` identity instead. `DropdownComponent` (`tedi-dropdown`) lives in `overlay/` and is not a form control — it exposes `[(value)]` but implements no `ControlValueAccessor`. `InlineEditComponent` (`tedi-inline-edit`) lives in `form/` but is likewise not a value accessor — it wraps a control you project into an ``, and that control keeps its own `[formControl]` / `[(ngModel)]` binding.
## Basic Usage with Reactive Forms
diff --git a/src/tedi/components/form/index.ts b/src/tedi/components/form/index.ts
index 557690f65..1f04fdfce 100644
--- a/src/tedi/components/form/index.ts
+++ b/src/tedi/components/form/index.ts
@@ -17,6 +17,7 @@ export * from "./slider/slider.component";
export * from "./toggle/toggle.component";
export * from "./form-field";
export * from "./input-group";
+export * from "./inline-edit";
export * from "./text-field/text-field.component";
export * from "./textarea/textarea.component";
export * from "./time-field";
diff --git a/src/tedi/components/form/inline-edit/index.ts b/src/tedi/components/form/inline-edit/index.ts
new file mode 100644
index 000000000..3654a8a55
--- /dev/null
+++ b/src/tedi/components/form/inline-edit/index.ts
@@ -0,0 +1,2 @@
+export * from "./inline-edit.component";
+export * from "./inline-edit-control.directive";
diff --git a/src/tedi/components/form/inline-edit/inline-edit-control.directive.ts b/src/tedi/components/form/inline-edit/inline-edit-control.directive.ts
new file mode 100644
index 000000000..cb4db5a4f
--- /dev/null
+++ b/src/tedi/components/form/inline-edit/inline-edit-control.directive.ts
@@ -0,0 +1,50 @@
+import { Directive, TemplateRef, inject } from "@angular/core";
+
+/**
+ * Marks the template holding the control that `tedi-inline-edit` swaps in when
+ * the value is clicked. The template is only instantiated while editing, so the
+ * control is created on entry and destroyed on exit.
+ *
+ * The projected control keeps its own value binding and accessible label.
+ * Configure exit keys for the control (for example, disable closeOnEnter for
+ * a textarea). Overlays must be linked through aria-controls or aria-owns.
+ *
+ * @example
+ * ```html
+ *
+ *
+ *
+ *
+ *
+ * ```
+ */
+@Directive({
+ selector: "[tediInlineEditControl]",
+ standalone: true,
+})
+export class InlineEditControlDirective {
+ template = inject>(TemplateRef);
+}
+
+/**
+ * Marks an optional template that renders the read view, for values a plain
+ * string cannot express — tags for a multiselect, a status badge, a formatted
+ * date. Without it the read view shows `displayValue`.
+ *
+ * @example
+ * ```html
+ *
+ *
+ *
+ *
+ * …
+ *
+ * ```
+ */
+@Directive({
+ selector: "[tediInlineEditValue]",
+ standalone: true,
+})
+export class InlineEditValueDirective {
+ template = inject>(TemplateRef);
+}
diff --git a/src/tedi/components/form/inline-edit/inline-edit.component.html b/src/tedi/components/form/inline-edit/inline-edit.component.html
new file mode 100644
index 000000000..fb4ab6be2
--- /dev/null
+++ b/src/tedi/components/form/inline-edit/inline-edit.component.html
@@ -0,0 +1,55 @@
+@if (readOnly()) {
+
+
+ @if (valueTemplate(); as value) {
+
+ } @else {
+ {{ displayText() }}
+ }
+
+
+} @else if (editing() && template(); as controlTemplate) {
+
+
+
+} @else {
+
+
+ @if (valueTemplate(); as value) {
+
+ } @else {
+ {{ displayText() }}
+ }
+
+ @if (!invalid()) {
+
+ }
+
+}
+
+
+
+
diff --git a/src/tedi/components/form/inline-edit/inline-edit.component.scss b/src/tedi/components/form/inline-edit/inline-edit.component.scss
new file mode 100644
index 000000000..112e1e37e
--- /dev/null
+++ b/src/tedi/components/form/inline-edit/inline-edit.component.scss
@@ -0,0 +1,146 @@
+@use "@tedi-design-system/core/mixins";
+
+.tedi-inline-edit {
+ --_inline-edit-font-size: var(--body-regular-size);
+ --_inline-edit-line-height: var(--body-regular-line-height);
+
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ width: 100%;
+ max-width: 100%;
+}
+
+.tedi-inline-edit--content-width {
+ display: inline-flex;
+ align-items: flex-start;
+ width: auto;
+}
+
+.tedi-inline-edit .tedi-feedback-text {
+ margin-top: var(--form-field-outer-spacing);
+}
+
+.tedi-inline-edit--small {
+ --_inline-edit-font-size: var(--body-small-regular-size);
+ --_inline-edit-line-height: var(--body-small-regular-line-height);
+}
+
+.tedi-inline-edit__trigger {
+ @include mixins.button-reset;
+
+ display: flex;
+ gap: var(--tedi-dimensions-05);
+ align-items: center;
+ padding: var(--form-field-padding-y-sm) var(--tedi-dimensions-03);
+ font-size: var(--_inline-edit-font-size);
+ line-height: var(--_inline-edit-line-height);
+ color: var(--form-input-text-filled);
+ text-align: left;
+ background-color: var(--form-hidden-default);
+ border-radius: var(--form-field-radius);
+}
+
+.tedi-inline-edit__trigger:hover:not(:disabled) {
+ background-color: var(--form-hidden-hover);
+}
+
+.tedi-inline-edit__trigger:focus-visible {
+ @include mixins.focus-element;
+}
+
+.tedi-inline-edit__trigger:active:not(:disabled) {
+ outline: none;
+ background-color: var(--form-hidden-default);
+ box-shadow: inset 0 0 0 var(--tedi-borders-02) var(--form-input-border-active);
+}
+
+// The pressed trigger previews the open editor, which has no pencil. Hidden
+// with opacity, not `display`: taking the icon out of flow on press shrinks
+// the button out from under the pointer, and the click then misses it.
+.tedi-inline-edit__trigger:active:not(:disabled) .tedi-inline-edit__icon {
+ opacity: 0;
+}
+
+.tedi-inline-edit__trigger:disabled {
+ color: var(--form-input-text-disabled);
+ cursor: not-allowed;
+}
+
+// Match the trigger's height and text position when editing starts.
+// Reduce padding by the editor's border width to keep the text aligned.
+.tedi-inline-edit__editor :is(.tedi-field-surface, .tedi-input) {
+ --_field-height: calc(
+ var(--_inline-edit-line-height) + 2 * var(--form-field-padding-y-sm)
+ );
+
+ padding: calc(var(--form-field-padding-y-sm) - var(--tedi-borders-01))
+ calc(var(--tedi-dimensions-03) - var(--tedi-borders-01));
+ font-size: var(--_inline-edit-font-size);
+ line-height: var(--_inline-edit-line-height);
+}
+
+.tedi-inline-edit__editor .tedi-select__arrow .tedi-icon {
+ --_tedi-icon-size: min(var(--icon-05), var(--_inline-edit-line-height));
+}
+
+.tedi-inline-edit__editor .tedi-select__clear {
+ width: min(var(--button-xs-icon-size), var(--_inline-edit-line-height));
+ height: min(var(--button-xs-icon-size), var(--_inline-edit-line-height));
+}
+
+.tedi-inline-edit__editor .tedi-select__search-wrapper {
+ min-height: var(--_inline-edit-line-height);
+}
+
+.tedi-inline-edit__value {
+ flex: 0 1 auto;
+ min-width: 0;
+ overflow-wrap: break-word;
+}
+
+.tedi-inline-edit__value--placeholder {
+ color: var(--form-input-text-placeholder);
+}
+
+.tedi-inline-edit__icon {
+ flex-shrink: 0;
+}
+
+.tedi-inline-edit__trigger:disabled .tedi-inline-edit__icon {
+ color: var(--general-text-disabled);
+}
+
+.tedi-inline-edit--content-width .tedi-inline-edit__trigger,
+.tedi-inline-edit--content-width .tedi-inline-edit__editor {
+ width: auto;
+}
+
+.tedi-inline-edit--icon-end .tedi-inline-edit__value {
+ flex: 1 1 auto;
+}
+
+// Keep read-only values aligned with editable values.
+.tedi-inline-edit__static {
+ display: flex;
+ gap: var(--tedi-dimensions-05);
+ align-items: center;
+ padding: var(--form-field-padding-y-sm) var(--tedi-dimensions-03);
+ font-size: var(--_inline-edit-font-size);
+ line-height: var(--_inline-edit-line-height);
+ color: var(--form-input-text-filled);
+}
+
+.tedi-inline-edit--invalid .tedi-inline-edit__trigger {
+ box-shadow: inset 0 0 0 var(--tedi-borders-01)
+ var(--form-general-feedback-error-border);
+}
+
+.tedi-inline-edit--invalid .tedi-inline-edit__trigger:active:not(:disabled) {
+ box-shadow: inset 0 0 0 var(--tedi-borders-02)
+ var(--form-general-feedback-error-border);
+}
+
+.tedi-inline-edit__trigger:focus-visible:not(:active) {
+ box-shadow: none;
+}
diff --git a/src/tedi/components/form/inline-edit/inline-edit.component.spec.ts b/src/tedi/components/form/inline-edit/inline-edit.component.spec.ts
new file mode 100644
index 000000000..5cb9f3605
--- /dev/null
+++ b/src/tedi/components/form/inline-edit/inline-edit.component.spec.ts
@@ -0,0 +1,784 @@
+import { Component, signal, viewChild } from "@angular/core";
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { FormControl, ReactiveFormsModule } from "@angular/forms";
+import { InputGroupComponent } from "../input-group/input-group.component";
+import { By } from "@angular/platform-browser";
+import { TediTranslationService } from "../../../services/translation/translation.service";
+import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../tokens/translation.token";
+import { FeedbackTextComponent } from "../feedback-text/feedback-text.component";
+import { FormFieldComponent } from "../form-field/form-field.component";
+import { TextFieldComponent } from "../text-field/text-field.component";
+import {
+ InlineEditControlDirective,
+ InlineEditValueDirective,
+} from "./inline-edit-control.directive";
+import { InlineEditComponent } from "./inline-edit.component";
+
+class TranslationMock {
+ translate(key: string) {
+ return key;
+ }
+ track(key: string) {
+ return () => key;
+ }
+}
+
+@Component({
+ standalone: true,
+ imports: [
+ InlineEditComponent,
+ InlineEditControlDirective,
+ TextFieldComponent,
+ ],
+ template: `
+
+
+
+
+
+ `,
+})
+class TestHostComponent {
+ readonly inlineEdit = viewChild.required(InlineEditComponent);
+
+ displayValue = signal("Mari Maasikas");
+ placeholder = signal("");
+ label = signal("Nimi");
+ size = signal<"default" | "small">("default");
+ disabled = signal(false);
+ invalid = signal(false);
+ readOnly = signal(false);
+ contentWidth = signal(false);
+ editIconPosition = signal<"inline" | "end">("inline");
+ closeOnEnter = signal(true);
+ closeOnEscape = signal(true);
+ closeOnBlur = signal(true);
+ editing = signal(false);
+
+ editStarts = 0;
+ commits = 0;
+ cancels = 0;
+}
+
+describe("InlineEditComponent", () => {
+ let fixture: ComponentFixture;
+ let host: TestHostComponent;
+
+ const dom = () => fixture.nativeElement as HTMLElement;
+ const trigger = () =>
+ dom().querySelector(".tedi-inline-edit__trigger");
+ const editorInput = () =>
+ dom().querySelector(".tedi-inline-edit__editor input");
+ const root = () =>
+ fixture.debugElement.query(By.directive(InlineEditComponent))
+ .nativeElement as HTMLElement;
+
+ const enterEditMode = async () => {
+ trigger()!.click();
+ fixture.detectChanges();
+ await fixture.whenStable();
+ };
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [TestHostComponent],
+ providers: [
+ { provide: TediTranslationService, useClass: TranslationMock },
+ { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" },
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(TestHostComponent);
+ host = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it("should create", () => {
+ expect(host.inlineEdit()).toBeTruthy();
+ });
+
+ describe("read mode", () => {
+ it("shows the display value on a button", () => {
+ expect(trigger()).toBeTruthy();
+ expect(
+ trigger()!
+ .querySelector(".tedi-inline-edit__value")!
+ .textContent?.trim(),
+ ).toBe("Mari Maasikas");
+ });
+
+ it("falls back to the placeholder when the value is blank", () => {
+ host.displayValue.set(" ");
+ host.placeholder.set("Sisesta nimi");
+ fixture.detectChanges();
+
+ const value = dom().querySelector(".tedi-inline-edit__value")!;
+ expect(value.textContent?.trim()).toBe("Sisesta nimi");
+ expect(
+ value.classList.contains("tedi-inline-edit__value--placeholder"),
+ ).toBe(true);
+ });
+
+ it.each(["", " "])("rejects a blank label (%j)", (label) => {
+ host.label.set(label);
+ expect(() => fixture.detectChanges()).toThrow(
+ "[tedi-inline-edit] label must be a non-empty string.",
+ );
+ });
+
+ it("names the trigger when the value and placeholder are empty", () => {
+ host.displayValue.set("");
+ fixture.detectChanges();
+ expect(trigger()!.getAttribute("aria-label")).toBe(
+ "Nimi, inline-edit.edit",
+ );
+ });
+
+ it("trims surrounding whitespace from the label", () => {
+ host.label.set(" Nimi ");
+ fixture.detectChanges();
+ expect(trigger()!.getAttribute("aria-label")).toBe(
+ "Nimi: Mari Maasikas, inline-edit.edit",
+ );
+ });
+
+ it("announces the label, the value and the edit action", () => {
+ host.label.set("Nimi");
+ fixture.detectChanges();
+
+ expect(trigger()!.getAttribute("aria-label")).toBe(
+ "Nimi: Mari Maasikas, inline-edit.edit",
+ );
+ });
+
+ it("renders the edit icon in the brand colour, not the text colour", () => {
+ const icon = dom().querySelector("tedi-icon")!;
+ expect(icon.className).toContain("tedi-icon--color-brand");
+ expect(icon.className).not.toContain("tedi-icon--color-inherit");
+ });
+
+ it("renders static text with no trigger when read-only", () => {
+ host.readOnly.set(true);
+ fixture.detectChanges();
+
+ expect(trigger()).toBeNull();
+ expect(dom().querySelector("tedi-icon")).toBeNull();
+ expect(
+ dom().querySelector(".tedi-inline-edit__static")!.textContent?.trim(),
+ ).toBe("Mari Maasikas");
+ });
+
+ it("does not enter edit mode when read-only", async () => {
+ host.readOnly.set(true);
+ fixture.detectChanges();
+ host.inlineEdit().startEditing();
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ expect(editorInput()).toBeNull();
+ expect(host.inlineEdit().editing()).toBe(false);
+ expect(host.editing()).toBe(false);
+ expect(host.editStarts).toBe(0);
+ });
+
+ it("rejects programmatic entry while read-only", async () => {
+ host.readOnly.set(true);
+ host.editing.set(true);
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ expect(editorInput()).toBeNull();
+ expect(host.inlineEdit().editing()).toBe(false);
+ expect(host.editStarts).toBe(0);
+ });
+
+ it("leaves edit mode when the value becomes read-only", async () => {
+ await enterEditMode();
+ host.readOnly.set(true);
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ expect(editorInput()).toBeNull();
+ expect(host.inlineEdit().editing()).toBe(false);
+ expect(host.commits).toBe(0);
+ expect(host.cancels).toBe(0);
+ });
+
+ it("shows the edit icon", () => {
+ expect(dom().querySelector("tedi-icon")?.textContent?.trim()).toBe(
+ "edit",
+ );
+ });
+
+ it("hides the edit icon while invalid", () => {
+ host.invalid.set(true);
+ fixture.detectChanges();
+
+ expect(dom().querySelector("tedi-icon")).toBeNull();
+ });
+ });
+
+ describe("host classes", () => {
+ it("reflects size", () => {
+ expect(root().classList).not.toContain("tedi-inline-edit--small");
+
+ host.size.set("small");
+ fixture.detectChanges();
+
+ expect(root().classList).toContain("tedi-inline-edit--small");
+ });
+
+ it("reflects invalid, and disables the trigger itself", () => {
+ host.invalid.set(true);
+ host.disabled.set(true);
+ fixture.detectChanges();
+
+ expect(root().classList).toContain("tedi-inline-edit--invalid");
+ expect(trigger()!.disabled).toBe(true);
+ });
+
+ it("fills its container until content width is turned on", () => {
+ expect(root().classList).not.toContain("tedi-inline-edit--content-width");
+
+ host.contentWidth.set(true);
+ fixture.detectChanges();
+
+ expect(root().classList).toContain("tedi-inline-edit--content-width");
+ });
+ });
+
+ describe("entering edit mode", () => {
+ it("swaps the trigger for the control and emits editStart", async () => {
+ await enterEditMode();
+
+ expect(trigger()).toBeNull();
+ expect(editorInput()).toBeTruthy();
+ expect(host.editStarts).toBe(1);
+ expect(host.editing()).toBe(true);
+ expect(root().classList).toContain("tedi-inline-edit--editing");
+ });
+
+ it("does not take focus when initially rendered in edit mode", async () => {
+ fixture.destroy();
+ const outside = document.createElement("button");
+ document.body.appendChild(outside);
+ outside.focus();
+ try {
+ fixture = TestBed.createComponent(TestHostComponent);
+ host = fixture.componentInstance;
+ host.editing.set(true);
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ expect(editorInput()).toBeTruthy();
+ expect(document.activeElement).toBe(outside);
+ expect(host.editStarts).toBe(1);
+ } finally {
+ outside.remove();
+ }
+ });
+
+ it("moves focus into the control", async () => {
+ await enterEditMode();
+
+ expect(document.activeElement).toBe(editorInput());
+ });
+
+ it("renders the control at the small size so it matches the row", async () => {
+ await enterEditMode();
+
+ expect(editorInput()!.classList).toContain("tedi-text-field--small");
+ });
+
+ it("survives the focusout fired by destroying the trigger", async () => {
+ // The real browser sequence: the trigger is focused by the click, then
+ // destroyed by the swap, firing focusout with a null relatedTarget
+ // before the control exists to take focus.
+ trigger()!.focus();
+ trigger()!.click();
+ root().dispatchEvent(
+ new FocusEvent("focusout", { bubbles: true, relatedTarget: null }),
+ );
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ expect(host.editing()).toBe(true);
+ expect(host.commits).toBe(0);
+ expect(editorInput()).toBeTruthy();
+ expect(document.activeElement).toBe(editorInput());
+ });
+
+ it("does nothing while disabled", async () => {
+ host.disabled.set(true);
+ fixture.detectChanges();
+
+ await enterEditMode();
+
+ expect(host.editing()).toBe(false);
+ expect(host.editStarts).toBe(0);
+ });
+
+ it("can be driven from outside through the editing model", async () => {
+ host.editing.set(true);
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ expect(editorInput()).toBeTruthy();
+ expect(document.activeElement).toBe(editorInput());
+ expect(host.editStarts).toBe(1);
+ host.editing.set(false);
+ fixture.detectChanges();
+ expect(host.commits).toBe(0);
+ expect(host.cancels).toBe(0);
+ });
+ });
+
+ describe("leaving edit mode", () => {
+ it("commits on Enter and returns focus to the trigger", async () => {
+ await enterEditMode();
+
+ editorInput()!.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
+ );
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ expect(host.commits).toBe(1);
+ expect(host.cancels).toBe(0);
+ expect(host.editing()).toBe(false);
+ expect(document.activeElement).toBe(trigger());
+ });
+
+ it("cancels on Escape", async () => {
+ await enterEditMode();
+
+ editorInput()!.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
+ );
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ expect(host.cancels).toBe(1);
+ expect(host.commits).toBe(0);
+ expect(host.editing()).toBe(false);
+ });
+
+ it.each(["Enter", "Escape"])(
+ "ignores %s during IME composition",
+ async (key) => {
+ await enterEditMode();
+ editorInput()!.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ key,
+ bubbles: true,
+ isComposing: true,
+ }),
+ );
+ fixture.detectChanges();
+ expect(host.editing()).toBe(true);
+ expect(host.commits).toBe(0);
+ expect(host.cancels).toBe(0);
+ },
+ );
+
+ it("ignores a key the control already handled", async () => {
+ await enterEditMode();
+
+ const event = new KeyboardEvent("keydown", {
+ key: "Enter",
+ bubbles: true,
+ cancelable: true,
+ });
+ event.preventDefault();
+ editorInput()!.dispatchEvent(event);
+ fixture.detectChanges();
+
+ expect(host.editing()).toBe(true);
+ expect(host.commits).toBe(0);
+ });
+
+ it("commits when focus leaves the component", async () => {
+ await enterEditMode();
+
+ const outside = document.createElement("button");
+ document.body.appendChild(outside);
+
+ root().dispatchEvent(
+ new FocusEvent("focusout", { bubbles: true, relatedTarget: outside }),
+ );
+ fixture.detectChanges();
+
+ expect(host.commits).toBe(1);
+ expect(host.editing()).toBe(false);
+
+ outside.remove();
+ });
+
+ it("stays open while focus moves within the component", async () => {
+ await enterEditMode();
+
+ root().dispatchEvent(
+ new FocusEvent("focusout", {
+ bubbles: true,
+ relatedTarget: editorInput(),
+ }),
+ );
+ fixture.detectChanges();
+
+ expect(host.editing()).toBe(true);
+ expect(host.commits).toBe(0);
+ });
+
+ it("stays open in its linked overlay and commits when focus leaves it", async () => {
+ await enterEditMode();
+
+ const overlay = document.createElement("div");
+ overlay.className = "cdk-overlay-container";
+ overlay.id = "inline-edit-options";
+ editorInput()!.setAttribute("aria-controls", overlay.id);
+ const option = document.createElement("button");
+ overlay.appendChild(option);
+ document.body.appendChild(overlay);
+
+ root().dispatchEvent(
+ new FocusEvent("focusout", { bubbles: true, relatedTarget: option }),
+ );
+ fixture.detectChanges();
+
+ expect(host.editing()).toBe(true);
+ expect(host.commits).toBe(0);
+
+ editorInput()!.removeAttribute("aria-controls");
+ option.dispatchEvent(
+ new FocusEvent("focusout", {
+ bubbles: true,
+ relatedTarget: document.body,
+ }),
+ );
+ fixture.detectChanges();
+ expect(host.commits).toBe(1);
+ expect(host.editing()).toBe(false);
+ overlay.remove();
+ });
+ });
+
+ const addPane = () => {
+ const pane = document.createElement("div");
+ pane.className = "cdk-overlay-pane";
+ document.body.appendChild(pane);
+ return pane;
+ };
+
+ it("commits when focus moves to an overlay that was already open", async () => {
+ // A modal hosting the field, say — it predates the edit, so it is not ours.
+ const pane = addPane();
+ await enterEditMode();
+
+ editorInput()!.dispatchEvent(
+ new FocusEvent("focusout", { bubbles: true, relatedTarget: pane }),
+ );
+ fixture.detectChanges();
+
+ expect(host.commits).toBe(1);
+ expect(host.editing()).toBe(false);
+ pane.remove();
+ });
+
+ it("stays open when the control opens an overlay during the edit", async () => {
+ // tedi-date-field's calendar links its pane with neither aria-controls nor
+ // aria-owns, so only the "opened after we started" rule keeps it open.
+ await enterEditMode();
+ const pane = addPane();
+
+ editorInput()!.dispatchEvent(
+ new FocusEvent("focusout", { bubbles: true, relatedTarget: pane }),
+ );
+ fixture.detectChanges();
+
+ expect(host.commits).toBe(0);
+ expect(host.editing()).toBe(true);
+ pane.remove();
+ });
+
+ describe("opting out of the exit triggers", () => {
+ it("keeps Enter inside the control when closeOnEnter is off", async () => {
+ host.closeOnEnter.set(false);
+ fixture.detectChanges();
+ await enterEditMode();
+
+ editorInput()!.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
+ );
+ fixture.detectChanges();
+
+ expect(host.editing()).toBe(true);
+ expect(host.commits).toBe(0);
+ });
+
+ it("ignores Escape when closeOnEscape is off", async () => {
+ host.closeOnEscape.set(false);
+ fixture.detectChanges();
+ await enterEditMode();
+
+ editorInput()!.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
+ );
+ fixture.detectChanges();
+
+ expect(host.editing()).toBe(true);
+ expect(host.cancels).toBe(0);
+ });
+
+ it("ignores focus leaving when closeOnBlur is off", async () => {
+ host.closeOnBlur.set(false);
+ fixture.detectChanges();
+ await enterEditMode();
+
+ root().dispatchEvent(
+ new FocusEvent("focusout", { bubbles: true, relatedTarget: null }),
+ );
+ fixture.detectChanges();
+
+ expect(host.editing()).toBe(true);
+ expect(host.commits).toBe(0);
+ });
+ });
+});
+
+describe("InlineEditComponent custom read view", () => {
+ @Component({
+ standalone: true,
+ imports: [
+ InlineEditComponent,
+ InlineEditControlDirective,
+ InlineEditValueDirective,
+ TextFieldComponent,
+ ],
+ template: `
+
+
+ Lõpetatud
+
+
+
+
+
+ `,
+ })
+ class ValueHostComponent {}
+
+ it("renders the value template instead of displayValue", async () => {
+ await TestBed.configureTestingModule({
+ imports: [ValueHostComponent],
+ providers: [
+ { provide: TediTranslationService, useClass: TranslationMock },
+ { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" },
+ ],
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(ValueHostComponent);
+ fixture.detectChanges();
+
+ const dom = fixture.nativeElement as HTMLElement;
+ expect(dom.querySelector(".custom-value")!.textContent).toBe("Lõpetatud");
+ expect(
+ dom.querySelector(".tedi-inline-edit__value")!.textContent,
+ ).not.toContain("done");
+ });
+});
+
+describe("InlineEditComponent defaults", () => {
+ @Component({
+ standalone: true,
+ imports: [
+ InlineEditComponent,
+ InlineEditControlDirective,
+ TextFieldComponent,
+ ],
+ template: `
+
+
+
+
+
+ `,
+ })
+ class DefaultsHostComponent {}
+
+ it("puts the edit icon at the end of the row", async () => {
+ await TestBed.configureTestingModule({
+ imports: [DefaultsHostComponent],
+ providers: [
+ { provide: TediTranslationService, useClass: TranslationMock },
+ { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" },
+ ],
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(DefaultsHostComponent);
+ fixture.detectChanges();
+
+ const root = (fixture.nativeElement as HTMLElement).querySelector(
+ "tedi-inline-edit",
+ )!;
+ expect(root.classList).toContain("tedi-inline-edit--icon-end");
+ });
+});
+
+describe("InlineEditComponent projected feedback", () => {
+ @Component({
+ standalone: true,
+ imports: [
+ InlineEditComponent,
+ InlineEditControlDirective,
+ TextFieldComponent,
+ FeedbackTextComponent,
+ ],
+ template: `
+
+
+
+
+
+
+ `,
+ })
+ class FeedbackHostComponent {}
+
+ it("links the feedback text to the trigger", async () => {
+ await TestBed.configureTestingModule({
+ imports: [FeedbackHostComponent],
+ providers: [
+ { provide: TediTranslationService, useClass: TranslationMock },
+ { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" },
+ ],
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(FeedbackHostComponent);
+ fixture.detectChanges();
+
+ const dom = fixture.nativeElement as HTMLElement;
+ const describedBy = dom
+ .querySelector(".tedi-inline-edit__trigger")!
+ .getAttribute("aria-describedby");
+
+ expect(describedBy).toBeTruthy();
+ expect(dom.querySelector(`#${describedBy}`)!.textContent).toContain(
+ "Vigane väärtus",
+ );
+ });
+});
+
+describe("InlineEditComponent inside a form field", () => {
+ @Component({
+ standalone: true,
+ imports: [
+ InlineEditComponent,
+ InlineEditControlDirective,
+ TextFieldComponent,
+ FormFieldComponent,
+ InputGroupComponent,
+ ],
+ template: `
+
+
+
+
+
+
+ `,
+ })
+ class WrappedHostComponent {
+ disabled = signal(false);
+ readonly inlineEdit = viewChild.required(InlineEditComponent);
+ }
+
+ it("folds the wrapping field's disabled state in", async () => {
+ await TestBed.configureTestingModule({
+ imports: [WrappedHostComponent],
+ providers: [
+ { provide: TediTranslationService, useClass: TranslationMock },
+ { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" },
+ ],
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(WrappedHostComponent);
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.inlineEdit().disabled()).toBe(false);
+ fixture.componentInstance.disabled.set(true);
+ fixture.detectChanges();
+ const trigger = fixture.nativeElement.querySelector("button");
+ expect(trigger.disabled).toBe(true);
+ trigger.click();
+ fixture.detectChanges();
+ expect(fixture.componentInstance.inlineEdit().editing()).toBe(false);
+ });
+});
+
+describe("InlineEditComponent with updateOn blur", () => {
+ @Component({
+ imports: [
+ InlineEditComponent,
+ InlineEditControlDirective,
+ TextFieldComponent,
+ ReactiveFormsModule,
+ ],
+ template: `
+
+
+
+
+
+ `,
+ })
+ class BlurHost {
+ control = new FormControl("Mari", { nonNullable: true, updateOn: "blur" });
+ committed = "";
+ }
+ it("flushes the pending value before emitting commit on Enter", async () => {
+ await TestBed.configureTestingModule({
+ imports: [BlurHost],
+ providers: [
+ { provide: TediTranslationService, useClass: TranslationMock },
+ { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" },
+ ],
+ }).compileComponents();
+ const fixture = TestBed.createComponent(BlurHost);
+ fixture.detectChanges();
+ fixture.nativeElement.querySelector("button").click();
+ fixture.detectChanges();
+ await fixture.whenStable();
+ const input = fixture.nativeElement.querySelector("input");
+ input.value = "Updated";
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ expect(fixture.componentInstance.control.value).toBe("Mari");
+ input.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
+ );
+ fixture.detectChanges();
+ expect(fixture.componentInstance.committed).toBe("Updated");
+ });
+});
diff --git a/src/tedi/components/form/inline-edit/inline-edit.component.ts b/src/tedi/components/form/inline-edit/inline-edit.component.ts
new file mode 100644
index 000000000..f3b2b6e6c
--- /dev/null
+++ b/src/tedi/components/form/inline-edit/inline-edit.component.ts
@@ -0,0 +1,418 @@
+import {
+ afterNextRender,
+ booleanAttribute,
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ effect,
+ untracked,
+ contentChild,
+ ElementRef,
+ forwardRef,
+ inject,
+ Injector,
+ input,
+ model,
+ output,
+ viewChild,
+ ViewEncapsulation,
+} from "@angular/core";
+import { NgTemplateOutlet } from "@angular/common";
+import { IconComponent } from "../../base/icon/icon.component";
+import { TediTranslationService } from "../../../services/translation/translation.service";
+import { getFocusableElements } from "../../../utils/elements.util";
+import {
+ FieldContext,
+ TEDI_FIELD_CONTEXT,
+} from "../form-field/field-context.token";
+import { FeedbackTextComponent } from "../feedback-text/feedback-text.component";
+import {
+ InlineEditControlDirective,
+ InlineEditValueDirective,
+} from "./inline-edit-control.directive";
+
+let inlineEditIdCounter = 0;
+
+function nonEmptyLabel(value: string): string {
+ if (typeof value !== "string" || !value.trim()) {
+ throw new Error("[tedi-inline-edit] label must be a non-empty string.");
+ }
+
+ return value.trim();
+}
+
+export type InlineEditSize = "default" | "small";
+export type InlineEditIconPosition = "inline" | "end";
+
+export type InlineEditExitReason = "commit" | "cancel";
+
+@Component({
+ selector: "tedi-inline-edit",
+ standalone: true,
+ imports: [NgTemplateOutlet, IconComponent],
+ templateUrl: "./inline-edit.component.html",
+ styleUrl: "./inline-edit.component.scss",
+ encapsulation: ViewEncapsulation.None,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ providers: [
+ {
+ provide: TEDI_FIELD_CONTEXT,
+ useFactory: (field: InlineEditComponent) => field.childContext,
+ deps: [forwardRef(() => InlineEditComponent)],
+ },
+ ],
+ host: {
+ class: "tedi-inline-edit",
+ "[class.tedi-inline-edit--small]": "size() === 'small'",
+ "[class.tedi-inline-edit--content-width]": "contentWidth()",
+ "[class.tedi-inline-edit--icon-end]": "editIconPosition() === 'end'",
+ "[class.tedi-inline-edit--invalid]": "invalid()",
+ "[class.tedi-inline-edit--editing]": "editing()",
+ "(document:focusout)": "handleFocusOut($event)",
+ "(keydown)": "handleKeydown($event)",
+ },
+})
+export class InlineEditComponent {
+ private readonly host = inject>(ElementRef);
+ private readonly injector = inject(Injector);
+ private readonly translationService = inject(TediTranslationService);
+ private readonly parentContext = inject(TEDI_FIELD_CONTEXT, {
+ optional: true,
+ skipSelf: true,
+ });
+
+ /**
+ * Read-mode text. Bind it to the editor's value, formatted for display.
+ */
+ readonly displayValue = input("");
+ /** Text shown when `displayValue` is empty or whitespace-only. */
+ readonly placeholder = input("");
+ /**
+ * Non-blank label for the read trigger; blank values throw an error.
+ * Announced with the displayed value and translated Edit action.
+ * Give the editor its own label; host attributes are not forwarded.
+ */
+ readonly label = input.required({ transform: nonEmptyLabel });
+ /**
+ * Text size and row spacing. The editor defaults to `small` for either
+ * variant unless it sets its own size.
+ *
+ * Both sizes use increased vertical padding on mobile; the row fills its
+ * container, so the whole row is tappable.
+ * @default "default"
+ */
+ readonly size = input("default");
+ /**
+ * Renders the value as plain text with no edit affordance, keeping the row's
+ * inset so a fixed value lines up among editable ones. For a block that is
+ * entirely read-only, use a text group instead. Differs from `disabled`,
+ * which keeps the greyed trigger and icon for transient states such as saving.
+ * @default false
+ */
+ readonly readOnly = input(false, { transform: booleanAttribute });
+ /**
+ * Places the edit icon at the end of the row (`end`), lining icons up down a
+ * column, or straight after the value (`inline`). Hidden while the row is
+ * invalid.
+ * @default "end"
+ */
+ readonly editIconPosition = input("end");
+ /**
+ * Sizes the row to its value rather than filling its container. The row then
+ * changes width when the editor opens, because the control brings its own
+ * width — use it only for a value edited inside running text.
+ * @default false
+ */
+ readonly contentWidth = input(false, { transform: booleanAttribute });
+ /**
+ * Toggles the editor. Supports `[(editing)]`.
+ * Entry emits `editStart`. The control receives focus unless initially open.
+ * Programmatic exit emits neither commit nor cancel and does not restore focus.
+ * @default false
+ */
+ readonly editing = model(false);
+ /**
+ * Prevents user entry into edit mode and mutes the value.
+ * Also applies when the wrapping field is disabled.
+ * @default false
+ */
+ readonly disabledInput = input(false, {
+ // eslint-disable-next-line @angular-eslint/no-input-rename
+ alias: "disabled",
+ transform: booleanAttribute,
+ });
+ /**
+ * Marks the row invalid. Inherits a wrapping field's invalid state and
+ * passes the result to the editor through the field context.
+ * @default false
+ */
+ readonly invalidInput = input(false, {
+ // eslint-disable-next-line @angular-eslint/no-input-rename
+ alias: "invalid",
+ transform: booleanAttribute,
+ });
+ /**
+ * Commits on Enter. Turn off when the editor uses Enter for a newline
+ * or to open a select dropdown.
+ * @default true
+ */
+ readonly closeOnEnter = input(true, { transform: booleanAttribute });
+ /**
+ * Exits on Escape and emits `editCancel`.
+ * @default true
+ */
+ readonly closeOnEscape = input(true, { transform: booleanAttribute });
+ /**
+ * Commits when focus leaves the editor. Linked overlays and CDK overlay
+ * panes created during the edit count as part of the editor; anywhere else,
+ * including back into a surrounding modal, commits.
+ * @default true
+ */
+ readonly closeOnBlur = input(true, { transform: booleanAttribute });
+
+ /** Edit mode was entered. */
+ readonly editStart = output();
+ /**
+ * Emitted after blurring the control and before removing it. Use to persist
+ * the value. Flushes `updateOn: "blur"`; the parent form handles `"submit"`.
+ */
+ readonly editCommit = output();
+ /**
+ * Emitted on Escape. Restore the previous value here to discard changes;
+ * the editor's binding is not reverted automatically.
+ */
+ readonly editCancel = output();
+
+ readonly disabled = computed(
+ () => this.disabledInput() || (this.parentContext?.disabled() ?? false),
+ );
+
+ readonly invalid = computed(
+ () => this.invalidInput() || (this.parentContext?.invalid() ?? false),
+ );
+
+ // Removing the focused trigger must not commit the edit immediately.
+ private swappingToControl = false;
+
+ private readonly controlTemplate = contentChild(InlineEditControlDirective);
+ private readonly valueDirective = contentChild(InlineEditValueDirective);
+ private readonly feedback = contentChild(FeedbackTextComponent);
+ private readonly editorRef = viewChild>("editor");
+ private readonly triggerRef =
+ viewChild>("trigger");
+
+ /**
+ * Defaults the editor to small with its own surface; explicit size wins.
+ * Opts out of the clear button — it has no place in the design, and the row
+ * is too tight to hold one next to the edit affordance.
+ */
+ readonly childContext: FieldContext = {
+ size: computed(() => "small"),
+ ownsSurface: computed(() => false),
+ invalid: computed(() => this.invalid()),
+ valid: computed(() => false),
+ disabled: computed(() => this.disabled()),
+ clearable: computed(() => false),
+ };
+
+ /** Links a projected feedback text to the trigger through `aria-describedby`. */
+ readonly feedbackId = `tedi-inline-edit-feedback-${inlineEditIdCounter++}`;
+
+ readonly hasFeedback = computed(() => this.feedback() !== undefined);
+
+ readonly template = computed(() => this.controlTemplate()?.template);
+
+ /** Optional custom read view; falls back to `displayValue` as text. */
+ readonly valueTemplate = computed(() => this.valueDirective()?.template);
+
+ readonly hasValue = computed(() => this.displayValue().trim().length > 0);
+
+ readonly displayText = computed(() =>
+ this.hasValue() ? this.displayValue() : this.placeholder(),
+ );
+
+ readonly triggerLabel = computed(() => {
+ const label = this.label();
+
+ const action = this.translationService.translate("inline-edit.edit");
+ const value = this.displayText().trim();
+
+ return value ? `${label}: ${value}, ${action}` : `${label}, ${action}`;
+ });
+
+ private entered = false;
+ private leaving = false;
+ private readonly ownedOverlays = new Set();
+ // Snapshot used to support unlinked picker overlays. New panes are assumed
+ // to belong to the editor, which also includes unrelated panes opened later.
+ private readonly foreignOverlays = new Set();
+
+ constructor() {
+ let initialRender = true;
+
+ effect(() => {
+ const editing = this.editing();
+ const template = this.template();
+ const readOnly = this.readOnly();
+
+ untracked(() => {
+ if (readOnly && editing) {
+ this.editing.set(false);
+ }
+ if (editing && !readOnly && template && !this.entered) {
+ this.enterEditMode(!initialRender);
+ } else if (!editing || readOnly) {
+ this.entered = false;
+ this.ownedOverlays.clear();
+ this.foreignOverlays.clear();
+ }
+ });
+
+ initialRender = false;
+ });
+ }
+
+ startEditing() {
+ if (
+ this.readOnly() ||
+ this.disabled() ||
+ this.editing() ||
+ !this.template()
+ )
+ return;
+
+ this.editing.set(true);
+ this.enterEditMode();
+ }
+
+ private enterEditMode(focusControl = true) {
+ this.entered = true;
+ this.swappingToControl = true;
+ this.snapshotForeignOverlays();
+ this.editStart.emit();
+
+ afterNextRender(
+ () => {
+ if (focusControl && this.editing()) {
+ const editor = this.editorRef()?.nativeElement;
+ if (editor) {
+ getFocusableElements(editor)[0]?.focus();
+ }
+ }
+
+ this.swappingToControl = false;
+ },
+ { injector: this.injector },
+ );
+ }
+
+ handleKeydown(event: KeyboardEvent) {
+ if (!this.editing() || event.defaultPrevented || event.isComposing) return;
+
+ if (event.key === "Enter" && this.closeOnEnter()) {
+ event.preventDefault();
+ this.stopEditing("commit", true);
+ } else if (event.key === "Escape" && this.closeOnEscape()) {
+ event.preventDefault();
+ this.stopEditing("cancel", true);
+ }
+ }
+
+ handleFocusOut(event: FocusEvent) {
+ if (
+ !this.editing() ||
+ !this.closeOnBlur() ||
+ this.swappingToControl ||
+ this.leaving
+ ) {
+ return;
+ }
+
+ const from = event.target as HTMLElement | null;
+ if (!from || !this.keepsFocusInside(from)) return;
+
+ const next = event.relatedTarget as HTMLElement | null;
+ if (next && this.keepsFocusInside(next)) return;
+
+ this.stopEditing("commit", false);
+ }
+
+ private snapshotForeignOverlays() {
+ this.foreignOverlays.clear();
+ this.host.nativeElement.ownerDocument
+ .querySelectorAll(".cdk-overlay-pane")
+ .forEach((pane) => this.foreignOverlays.add(pane));
+ }
+
+ // Linked overlays are retained until exit so focus leaving one is still
+ // recognized after the control has cleared its aria-controls.
+ private keepsFocusInside(target: HTMLElement) {
+ const host = this.host.nativeElement;
+ const document = host.ownerDocument;
+ const roots = [host, ...this.ownedOverlays];
+
+ for (const root of roots) {
+ const controls = [
+ root,
+ ...Array.from(
+ root.querySelectorAll("[aria-controls], [aria-owns]"),
+ ),
+ ];
+ for (const control of controls) {
+ for (const attribute of ["aria-controls", "aria-owns"]) {
+ for (const id of (control.getAttribute(attribute) ?? "")
+ .split(/\s+/)
+ .filter(Boolean)) {
+ const element = document.getElementById(id);
+ if (element) {
+ this.ownedOverlays.add(element);
+ }
+ }
+ }
+ }
+ }
+
+ if (
+ host.contains(target) ||
+ [...this.ownedOverlays].some((element) => element.contains(target))
+ ) {
+ return true;
+ }
+
+ const pane = target.closest(".cdk-overlay-pane");
+ return pane !== null && !this.foreignOverlays.has(pane);
+ }
+
+ private stopEditing(reason: InlineEditExitReason, restoreFocus: boolean) {
+ if (!this.editing() || this.leaving) return;
+
+ this.leaving = true;
+
+ const active = this.host.nativeElement.ownerDocument
+ .activeElement as HTMLElement | null;
+ if (active && this.keepsFocusInside(active)) {
+ active.blur();
+ }
+
+ this.editing.set(false);
+ this.entered = false;
+ this.ownedOverlays.clear();
+ this.foreignOverlays.clear();
+
+ if (reason === "commit") {
+ this.editCommit.emit();
+ } else {
+ this.editCancel.emit();
+ }
+
+ this.leaving = false;
+
+ if (restoreFocus) {
+ this.focusAfterRender(() => this.triggerRef()?.nativeElement);
+ }
+ }
+
+ private focusAfterRender(target: () => HTMLElement | null | undefined) {
+ afterNextRender(() => target()?.focus(), { injector: this.injector });
+ }
+}
diff --git a/src/tedi/components/form/inline-edit/inline-edit.stories.ts b/src/tedi/components/form/inline-edit/inline-edit.stories.ts
new file mode 100644
index 000000000..ff659fe91
--- /dev/null
+++ b/src/tedi/components/form/inline-edit/inline-edit.stories.ts
@@ -0,0 +1,820 @@
+import { FormsModule } from "@angular/forms";
+import {
+ Meta,
+ StoryObj,
+ moduleMetadata,
+ argsToTemplate,
+} from "@storybook/angular";
+import { InlineEditComponent } from "./inline-edit.component";
+import {
+ InlineEditControlDirective,
+ InlineEditValueDirective,
+} from "./inline-edit-control.directive";
+import { TextFieldComponent } from "../text-field/text-field.component";
+import { SelectComponent } from "../select/select.component";
+import { TagComponent } from "../../tags/tag/tag.component";
+import { StatusBadgeComponent } from "../../tags/status-badge/status-badge.component";
+import { DateFieldComponent } from "../date-field/date-field.component";
+import { NumberFieldComponent } from "../number-field/number-field.component";
+import { TextareaComponent } from "../textarea/textarea.component";
+import { RadioGroupComponent } from "../radio-group/radio-group.component";
+import { RadioComponent } from "../radio/radio.component";
+import { CheckboxGroupComponent } from "../checkbox-group/checkbox-group.component";
+import { CheckboxComponent } from "../checkbox/checkbox.component";
+import { FeedbackTextComponent } from "../feedback-text/feedback-text.component";
+import { LabelComponent } from "../../content/label/label.component";
+import { RowComponent } from "../../helpers/grid/row/row.component";
+import { ColComponent } from "../../helpers/grid/col/col.component";
+import { ShowAtDirective } from "../../../directives/show-at/show-at.directive";
+
+type InlineEditStory = StoryObj<
+ Omit & {
+ disabled: boolean;
+ invalid: boolean;
+ }
+>;
+
+const STATES = [
+ { name: "Default" },
+ { name: "Hover" },
+ { name: "Active", editing: true },
+ { name: "Error", invalid: true },
+ { name: "Focus" },
+ { name: "Read-only", readOnly: true },
+ { name: "Disabled", disabled: true },
+];
+
+/**
+ * Displays a value as text with an edit icon. Activate it to edit the value in place.
+ *
+ * Place the editor in an `ng-template tediInlineEditControl` with its own value
+ * binding and accessible label. It is created on entry and destroyed on exit.
+ * Enter or leaving the editor commits; Escape emits `editCancel`. Restore the
+ * previous value in `editCancel` if the edit should be discarded.
+ *
+ * For a value that is never editable, use
+ * tedi-text-group instead.
+ *
+ * Figma ↗
+ */
+export default {
+ title: "TEDI-Ready/Components/Form/InlineEdit",
+ component: InlineEditComponent,
+ decorators: [
+ moduleMetadata({
+ imports: [
+ FormsModule,
+ InlineEditComponent,
+ InlineEditControlDirective,
+ InlineEditValueDirective,
+ TextFieldComponent,
+ SelectComponent,
+ TagComponent,
+ StatusBadgeComponent,
+ DateFieldComponent,
+ NumberFieldComponent,
+ TextareaComponent,
+ RadioGroupComponent,
+ RadioComponent,
+ CheckboxGroupComponent,
+ CheckboxComponent,
+ FeedbackTextComponent,
+ LabelComponent,
+ RowComponent,
+ ColComponent,
+ ShowAtDirective,
+ ],
+ }),
+ ],
+ parameters: { controls: { disable: true } },
+ argTypes: {
+ displayValue: {
+ description: "Text shown while not editing.",
+ control: { type: "text" },
+ table: { category: "inputs", type: { summary: "string" } },
+ },
+ placeholder: {
+ description: "Shown in place of an empty value, in the muted colour.",
+ control: { type: "text" },
+ table: { category: "inputs", type: { summary: "string" } },
+ },
+ label: {
+ description:
+ "Required label for the read-mode button. Give the projected control its own accessible label.",
+ control: { type: "text" },
+ table: { category: "inputs", type: { summary: "string" } },
+ },
+ size: {
+ description: "Row height and type scale.",
+ control: { type: "radio" },
+ options: ["default", "small"],
+ table: {
+ category: "inputs",
+ type: { summary: "InlineEditSize", detail: "default \nsmall" },
+ defaultValue: { summary: "default" },
+ },
+ },
+ readOnly: {
+ description:
+ "Renders the value as plain text with no edit affordance, lined up with editable rows. For a block that is entirely read-only, use a text group instead.",
+ control: { type: "boolean" },
+ table: {
+ category: "inputs",
+ type: { summary: "boolean" },
+ defaultValue: { summary: "false" },
+ },
+ },
+ editIconPosition: {
+ description:
+ "Whether the edit icon sits at the row's trailing edge or straight after the value. Hidden while the row is invalid.",
+ control: { type: "radio" },
+ options: ["end", "inline"],
+ table: {
+ category: "inputs",
+ type: {
+ summary: "InlineEditIconPosition",
+ detail: "end \ninline",
+ },
+ defaultValue: { summary: "end" },
+ },
+ },
+ contentWidth: {
+ description:
+ "Sizes the row to its value rather than filling its container. The row then changes width when the editor opens, so it is reserved for a value edited inside running text.",
+ control: { type: "boolean" },
+ table: {
+ category: "inputs",
+ type: { summary: "boolean" },
+ defaultValue: { summary: "false" },
+ },
+ },
+ editing: {
+ description: "Whether the control is shown. Two-way bindable.",
+ control: { type: "boolean" },
+ table: {
+ category: "inputs",
+ type: { summary: "boolean" },
+ defaultValue: { summary: "false" },
+ },
+ },
+ disabled: {
+ description: "Blocks entering edit mode and mutes the value.",
+ control: { type: "boolean" },
+ table: {
+ category: "inputs",
+ type: { summary: "boolean" },
+ defaultValue: { summary: "false" },
+ },
+ },
+ invalid: {
+ description: "Puts the row and the projected control in the error state.",
+ control: { type: "boolean" },
+ table: {
+ category: "inputs",
+ type: { summary: "boolean" },
+ defaultValue: { summary: "false" },
+ },
+ },
+ closeOnEnter: {
+ description:
+ "Whether Enter commits. Turn off when the control uses Enter, such as a textarea or select.",
+ control: { type: "boolean" },
+ table: {
+ category: "inputs",
+ type: { summary: "boolean" },
+ defaultValue: { summary: "true" },
+ },
+ },
+ closeOnEscape: {
+ description: "Whether Escape exits edit mode and emits editCancel.",
+ control: { type: "boolean" },
+ table: {
+ category: "inputs",
+ type: { summary: "boolean" },
+ defaultValue: { summary: "true" },
+ },
+ },
+ closeOnBlur: {
+ description:
+ "Commits when focus leaves the editor. Dropdowns and pickers opened by the editor count as part of it; anywhere else, including back into a surrounding modal, commits.",
+ control: { type: "boolean" },
+ table: {
+ category: "inputs",
+ type: { summary: "boolean" },
+ defaultValue: { summary: "true" },
+ },
+ },
+ editStart: {
+ description: "Edit mode was entered.",
+ table: { category: "outputs", type: { summary: "void" } },
+ },
+ editCommit: {
+ description: "Edit mode was left by Enter or by focus moving away.",
+ table: { category: "outputs", type: { summary: "void" } },
+ },
+ editCancel: {
+ description: "Edit mode was left by Escape.",
+ table: { category: "outputs", type: { summary: "void" } },
+ },
+ },
+} as Meta;
+
+export const Default: InlineEditStory = {
+ parameters: { controls: { disable: false } },
+ args: {
+ label: "Nimi",
+ displayValue: "Mari Maasikas",
+ placeholder: "Sisesta nimi",
+ size: "default",
+ readOnly: false,
+ editIconPosition: "end",
+ contentWidth: false,
+ editing: false,
+ disabled: false,
+ invalid: false,
+ closeOnEnter: true,
+ closeOnEscape: true,
+ closeOnBlur: true,
+ },
+ render: (args) => ({
+ props: { ...args },
+ template: `
+
+
+
+
+
+
+
+
+ `,
+ }),
+};
+
+/**
+ * The editor is any TEDI form control, wired to its own value binding. Give it
+ * its own accessible label — link a visible label with `for` and `id`, or use
+ * `aria-label`. InlineEdit's `label` names only the read trigger. Set
+ * `[closeOnEnter]="false"` on a select so Enter opens the dropdown instead of
+ * committing.
+ */
+export const Type: StoryObj = {
+ render: () => ({
+ props: {
+ state: { name: "Mari Maasikas", type: "prototype" },
+ typeOptions: [
+ { value: "prototype", label: "Prototüüp" },
+ { value: "design", label: "Disain" },
+ { value: "concept", label: "Kontseptsioon" },
+ ],
+ typeLabel: (value: string) =>
+ (
+ ({
+ prototype: "Prototüüp",
+ design: "Disain",
+ concept: "Kontseptsioon",
+ }) as Record
+ )[value] ?? "",
+ },
+ template: `
+
+
+ Text field
+
+
+
+
+
+
+
+ Select
+
+
+
+
+
+
+
+ `,
+ }),
+};
+
+export const Sizes: StoryObj = {
+ render: () => ({
+ props: {
+ rows: [
+ { name: "Default", size: "default", value: "22.03.2026" },
+ { name: "Small", size: "small", value: "22.03.2026" },
+ ],
+ },
+ template: `
+
+
+ {{ row.name }}
+
+
+
+
+
+
+
+ `,
+ }),
+};
+
+/**
+ * The read view is plain text by default — or the placeholder while the value
+ * is empty. For values a string cannot express, such as tags or a status badge,
+ * project an `ng-template tediInlineEditValue`.
+ */
+export const ValueType: StoryObj = {
+ name: "Value type",
+ render: () => {
+ const typeOptions = [
+ { value: "prototype", label: "Prototüüp" },
+ { value: "design", label: "Disain" },
+ { value: "concept", label: "Kontseptsioon" },
+ ];
+ const keywordOptions = [
+ { value: "prototype", label: "Prototüüp" },
+ { value: "figma", label: "Figma" },
+ { value: "design", label: "Disain" },
+ { value: "ui", label: "Kasutajaliides" },
+ ];
+ const olekOptions = [
+ { value: "done", label: "Lõpetatud" },
+ { value: "in-progress", label: "Töös" },
+ { value: "planned", label: "Planeeritud" },
+ ];
+ const addressOptions = [
+ { value: "tallinn", label: "Tulbi tn 6, Tallinn" },
+ { value: "tartu", label: "Riia 12, Tartu" },
+ ];
+ const olekColor: Record = {
+ done: "success",
+ "in-progress": "warning",
+ planned: "brand",
+ };
+ const state = {
+ type: typeOptions[0],
+ address: null as { value: string; label: string } | null,
+ keywords: keywordOptions.map((o) => o.value),
+ olek: olekOptions[0],
+ };
+
+ return {
+ props: {
+ typeOptions,
+ keywordOptions,
+ olekOptions,
+ addressOptions,
+ olekColor,
+ state,
+ pick: (list: { value: string; label: string }[], value: string) =>
+ list.find((o) => o.value === value) ?? null,
+ keywordLabel: (value: string) =>
+ keywordOptions.find((o) => o.value === value)?.label ?? value,
+ },
+ template: `
+
+
+ Tüüp
+
+
+
+
+
+
+
+
+ Aadress
+
+
+
+
+
+
+
+
+ Märksõnad
+
+
+
+ {{ keywordLabel(k) }}
+
+
+
+
+
+
+
+
+
+ Olek
+
+
+
+
+
+
+
+
+
+
+ `,
+ };
+ },
+};
+
+/**
+ * The edit icon is what identifies the value as editable, so it is always
+ * shown. By default it sits at the row's trailing edge, lining the icons up
+ * down a column; `inline` puts it straight after the value.
+ */
+export const EditIconPosition: StoryObj = {
+ render: () => {
+ const rows = () => [
+ { label: "Kuupäev", value: "22.03.2026" },
+ { label: "Kellaaeg", value: "08:00" },
+ { label: "Kohtade arv", value: "2" },
+ { label: "Aadress", value: "Tulbi tn 6, Tallinn" },
+ ];
+ return {
+ props: {
+ groups: [
+ { name: "Inline", align: "inline", rows: rows() },
+ { name: "End", align: "end", rows: rows() },
+ ],
+ },
+ template: `
+
+
+
{{ group.name }}
+
+
+ {{ row.label }}
+
+
+
+
+
+
+
+
+
+ `,
+ };
+ },
+};
+
+/**
+ * The row fills its container, so on narrow layouts the whole row is a
+ * comfortable tap target rather than just the value. Its height grows below
+ * the mobile breakpoint on its own — the spacing comes from
+ * `--form-field-padding-y-sm`, which core redefines there.
+ */
+export const Responsiveness: StoryObj = {
+ globals: { viewport: { value: "mobile1" } },
+ render: () => ({
+ props: { name: "Mari Maasikas" },
+ template: `
+
+
+
+
+
+ `,
+ }),
+};
+
+/**
+ * Active is the open editor. An invalid row drops the edit icon. Read-only and
+ * Disabled have no Figma counterpart yet.
+ */
+export const States: StoryObj = {
+ parameters: {
+ pseudo: {
+ hover: [
+ "#ie-state-hover-text .tedi-inline-edit__trigger",
+ "#ie-state-hover-select .tedi-inline-edit__trigger",
+ ],
+ focusVisible: [
+ "#ie-state-focus-text .tedi-inline-edit__trigger",
+ "#ie-state-focus-select .tedi-inline-edit__trigger",
+ "#ie-state-active-text input",
+ ],
+ focus: ["#ie-state-active-select .tedi-select__trigger"],
+ },
+ },
+ render: () => ({
+ props: {
+ states: STATES.map((state) => ({
+ ...state,
+ key: state.name
+ .toLowerCase()
+ .replace("read-only", "readonly")
+ .replace("error", "invalid"),
+ value: "Mari Maasikas",
+ country: "ee",
+ })),
+ countries: [
+ { value: "ee", label: "Eesti" },
+ { value: "fi", label: "Soome" },
+ { value: "se", label: "Rootsi" },
+ ],
+ countryLabel: (value: string) =>
+ (
+ ({ ee: "Eesti", fi: "Soome", se: "Rootsi" }) as Record
+ )[value] ?? "",
+ },
+ template: `
+
+
+
+ Text field
+ Select
+
+
+ {{ state.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ }),
+};
+
+/**
+ * `readOnly` renders the value as static text with no edit affordance. It
+ * keeps an editable row's inset so it still lines up when mixed in with
+ * editable ones, which is what it is for — the odd fixed value among the rest.
+ * When a whole block is fixed, reach for
+ * tedi-text-group
+ * instead.
+ *
+ * Differs from `disabled`, which keeps the greyed trigger and icon for
+ * transient states such as saving.
+ */
+export const ReadOnly: StoryObj = {
+ name: "Read-only",
+ render: () => ({
+ props: {
+ rows: [
+ { label: "Kuupäev", value: "22.03.2026", fixed: false },
+ { label: "Kellaaeg", value: "08:00", fixed: true },
+ { label: "Kohtade arv", value: "2", fixed: false },
+ ],
+ },
+ template: `
+
+
+ {{ row.label }}
+
+
+
+
+
+
+
+ `,
+ }),
+};
+
+/** Snapshot the value on entry and restore it when Escape cancels the edit. */
+export const CancelEdit: StoryObj = {
+ name: "Cancel edit",
+ render: () => {
+ const state = { name: "Mari Maasikas", previousName: "Mari Maasikas" };
+ return {
+ props: {
+ state,
+ onStart: () => {
+ state.previousName = state.name;
+ },
+ onCancel: () => {
+ state.name = state.previousName;
+ },
+ },
+ template: `
+
+
+
+
+
+
+
+ `,
+ };
+ },
+};
+
+export const Disabled: StoryObj = {
+ render: () => ({
+ props: { code: "EE47101010033" },
+ template: `
+
+
+
+
+
+
+
+ `,
+ }),
+};
+
+/**
+ * `invalid` applies the error state to the row and projected control.
+ * Use `tedi-feedback-text` to explain the error and reference `feedbackId`
+ * from the editor's `aria-describedby`.
+ */
+export const Invalid: StoryObj = {
+ render: () => ({
+ props: { email: "mari.maasikas" },
+ template: `
+
+
+
+
+
+
+
+
+ `,
+ }),
+};
+
+export const Placeholder: StoryObj = {
+ render: () => ({
+ props: { address: "" },
+ template: `
+
+
+
+
+
+
+
+ `,
+ }),
+};
+
+/**
+ * Use InlineEdit with these TEDI controls:
+ *
+ * - Text: `input[tedi-text-field]`, `textarea[tedi-textarea]`, `tedi-search`
+ * - Numeric: `tedi-number-field`, `tedi-slider`
+ * - Choice: `tedi-select` (single or multiple), `tedi-checkbox-group`, `tedi-radio-group`
+ * - Date and time: `tedi-date-field`, `tedi-time-field`, `tedi-time-picker`
+ * - Boolean: `tedi-toggle`
+ *
+ * Each editor keeps its own value binding. Bind `displayValue` to the same
+ * source, formatting it for the read view, and give the editor its own
+ * accessible label. The InlineEdit label names only the read trigger.
+ * Set `closeOnEnter` to `false` when the editor uses Enter, such as a textarea
+ * inserting a newline or a select opening its dropdown.
+ *
+ * The examples below show date, number and multiline editors. For file
+ * management, use `tedi-file-upload` or `tedi-file-dropzone` separately;
+ * for checkbox and radio choices, use the group components listed above.
+ */
+export const SupportedControls: StoryObj = {
+ name: "Supported controls",
+ render: () => {
+ const state = {
+ date: new Date(2026, 2, 22),
+ seats: 2,
+ note: "Palun helista enne saabumist",
+ transport: "bus",
+ extras: ["breakfast"],
+ };
+ return {
+ props: {
+ state,
+ formatDate: (d: Date | null) =>
+ d ? d.toLocaleDateString("et-EE") : "",
+ transportLabel: (v: string) =>
+ ({ bus: "Buss", train: "Rong" })[v] ?? "",
+ extrasLabel: (v: string[]) =>
+ v
+ .map((x) => ({ breakfast: "Hommikusöök", parking: "Parkimine" })[x])
+ .join(", "),
+ },
+ template: `
+
+ `,
+ };
+ },
+};
diff --git a/src/tedi/services/translation/translations.ts b/src/tedi/services/translation/translations.ts
index e2f64985a..5feb09712 100644
--- a/src/tedi/services/translation/translations.ts
+++ b/src/tedi/services/translation/translations.ts
@@ -1557,6 +1557,14 @@ export const translationsMap = {
en: "Completed",
ru: "Завершено",
},
+ "inline-edit.edit": {
+ description:
+ "Action appended to the inline edit trigger's accessible name, so a screen reader announces the value as editable",
+ components: ["InlineEdit"],
+ et: "Muuda",
+ en: "Edit",
+ ru: "Редактировать",
+ },
"vertical-stepper.error": {
description:
"Label for screen-reader that this step has error (visually hidden)",