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 `<label tedi-label>`** 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 `<ng-template tediInlineEditControl>`.** 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 `<tedi-attachment-actions>` 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 `<tedi-checkbox-group>`, 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 `<tedi-checkbox-group>`, 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 `<ng-template tediInlineEditControl>`, 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 + * <tedi-inline-edit [displayValue]="name()" label="Name"> + * <ng-template tediInlineEditControl> + * <input tedi-text-field [(ngModel)]="name" aria-label="Name" /> + * </ng-template> + * </tedi-inline-edit> + * ``` + */ +@Directive({ + selector: "[tediInlineEditControl]", + standalone: true, +}) +export class InlineEditControlDirective { + template = inject<TemplateRef<unknown>>(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 + * <tedi-inline-edit [displayValue]="status.label" label="Olek"> + * <ng-template tediInlineEditValue> + * <tedi-status-badge [color]="status.color" [text]="status.label" /> + * </ng-template> + * <ng-template tediInlineEditControl>…</ng-template> + * </tedi-inline-edit> + * ``` + */ +@Directive({ + selector: "[tediInlineEditValue]", + standalone: true, +}) +export class InlineEditValueDirective { + template = inject<TemplateRef<unknown>>(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()) { + <span class="tedi-inline-edit__static"> + <span + class="tedi-inline-edit__value" + [class.tedi-inline-edit__value--placeholder]=" + !hasValue() && !valueTemplate() + " + > + @if (valueTemplate(); as value) { + <ng-container [ngTemplateOutlet]="value" /> + } @else { + {{ displayText() }} + } + </span> + </span> +} @else if (editing() && template(); as controlTemplate) { + <div #editor class="tedi-inline-edit__editor"> + <ng-container [ngTemplateOutlet]="controlTemplate" /> + </div> +} @else { + <button + #trigger + type="button" + class="tedi-inline-edit__trigger" + [disabled]="disabled()" + [attr.aria-label]="triggerLabel()" + [attr.aria-describedby]="hasFeedback() ? feedbackId : null" + (click)="startEditing()" + > + <span + class="tedi-inline-edit__value" + [class.tedi-inline-edit__value--placeholder]=" + !hasValue() && !valueTemplate() + " + > + @if (valueTemplate(); as value) { + <ng-container [ngTemplateOutlet]="value" /> + } @else { + {{ displayText() }} + } + </span> + @if (!invalid()) { + <tedi-icon + class="tedi-inline-edit__icon" + name="edit" + [size]="18" + color="brand" + /> + } + </button> +} + +<div [id]="feedbackId" class="tedi-inline-edit__feedback"> + <ng-content select="tedi-feedback-text" /> +</div> 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: ` + <tedi-inline-edit + [displayValue]="displayValue()" + [placeholder]="placeholder()" + [label]="label()" + [size]="size()" + [disabled]="disabled()" + [invalid]="invalid()" + [readOnly]="readOnly()" + [contentWidth]="contentWidth()" + [editIconPosition]="editIconPosition()" + [closeOnEnter]="closeOnEnter()" + [closeOnEscape]="closeOnEscape()" + [closeOnBlur]="closeOnBlur()" + [(editing)]="editing" + (editStart)="editStarts = editStarts + 1" + (editCommit)="commits = commits + 1" + (editCancel)="cancels = cancels + 1" + > + <ng-template tediInlineEditControl> + <input tedi-text-field [(value)]="displayValue" /> + </ng-template> + </tedi-inline-edit> + `, +}) +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<TestHostComponent>; + let host: TestHostComponent; + + const dom = () => fixture.nativeElement as HTMLElement; + const trigger = () => + dom().querySelector<HTMLButtonElement>(".tedi-inline-edit__trigger"); + const editorInput = () => + dom().querySelector<HTMLInputElement>(".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: ` + <tedi-inline-edit displayValue="done" label="Olek"> + <ng-template tediInlineEditValue> + <b class="custom-value">Lõpetatud</b> + </ng-template> + <ng-template tediInlineEditControl> + <input tedi-text-field value="done" aria-label="Olek" /> + </ng-template> + </tedi-inline-edit> + `, + }) + 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: ` + <tedi-inline-edit displayValue="Mari" label="Nimi"> + <ng-template tediInlineEditControl> + <input tedi-text-field value="Mari" /> + </ng-template> + </tedi-inline-edit> + `, + }) + 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: ` + <tedi-inline-edit displayValue="Mari" label="Nimi" invalid> + <ng-template tediInlineEditControl> + <input tedi-text-field value="Mari" /> + </ng-template> + <tedi-feedback-text text="Vigane väärtus" type="error" /> + </tedi-inline-edit> + `, + }) + 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: ` + <tedi-input-group [disabled]="disabled()" + ><tedi-form-field> + <tedi-inline-edit displayValue="Mari" label="Nimi"> + <ng-template tediInlineEditControl> + <input tedi-text-field value="Mari" /> + </ng-template> + </tedi-inline-edit> </tedi-form-field + ></tedi-input-group> + `, + }) + 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: ` + <tedi-inline-edit + [displayValue]="control.value" + label="Name" + (editCommit)="committed = control.value" + > + <ng-template tediInlineEditControl> + <input tedi-text-field [formControl]="control" aria-label="Name" /> + </ng-template> + </tedi-inline-edit> + `, + }) + 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<HTMLElement>>(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<string>(""); + /** Text shown when `displayValue` is empty or whitespace-only. */ + readonly placeholder = input<string>(""); + /** + * 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<string, string>({ 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<InlineEditSize>("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<InlineEditIconPosition>("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<boolean>(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<void>(); + /** + * 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<void>(); + /** + * Emitted on Escape. Restore the previous value here to discard changes; + * the editor's binding is not reverted automatically. + */ + readonly editCancel = output<void>(); + + 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<ElementRef<HTMLElement>>("editor"); + private readonly triggerRef = + viewChild<ElementRef<HTMLButtonElement>>("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<HTMLElement>(); + // 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<HTMLElement>(); + + 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<HTMLElement>(".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<HTMLElement>("[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<HTMLElement>(".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<InlineEditComponent, "disabled" | "invalid"> & { + 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 + * <a href="./?path=/docs/tedi-ready-content-textgroup--docs" target="_top"><code>tedi-text-group</code></a> instead. + * + * <a href="https://www.figma.com/design/jWiRIXhHRxwVdMSimKX2FF/TEDI-READY-2.106.103?node-id=65952-130950&m=dev" target="_blank">Figma ↗</a> + */ +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<InlineEditComponent>; + +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: ` + <div style="max-width: 218px;"> + <tedi-inline-edit #defaultEdit ${argsToTemplate(args)}> + <ng-template tediInlineEditControl> + <input + tedi-text-field + [(value)]="displayValue" + [attr.aria-label]="label || 'Nimi'" + [attr.aria-describedby]="invalid ? defaultEdit.feedbackId : null" + /> + </ng-template> + <tedi-feedback-text *ngIf="invalid" text="Sisesta korrektne väärtus" type="error" /> + </tedi-inline-edit> + </div> + `, + }), +}; + +/** + * 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<InlineEditComponent> = { + 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<string, string> + )[value] ?? "", + }, + template: ` + <div style="display: grid; gap: 8px;"> + <div style="display: grid; grid-template-columns: minmax(0, 104px) minmax(0, 218px); max-width: 338px; gap: 16px; align-items: center;"> + <b>Text field</b> + <tedi-inline-edit [displayValue]="state.name" label="Nimi"> + <ng-template tediInlineEditControl> + <input tedi-text-field [(value)]="state.name" aria-label="Nimi" /> + </ng-template> + </tedi-inline-edit> + </div> + <div style="display: grid; grid-template-columns: minmax(0, 104px) minmax(0, 218px); max-width: 338px; gap: 16px; align-items: center;"> + <b>Select</b> + <tedi-inline-edit [displayValue]="typeLabel(state.type)" label="Tüüp" [closeOnEnter]="false"> + <ng-template tediInlineEditControl> + <tedi-select inputId="ie-type-select" ariaLabel="Tüüp" [options]="typeOptions" + bindLabel="label" bindValue="value" [clearable]="false" [(ngModel)]="state.type" /> + </ng-template> + </tedi-inline-edit> + </div> + </div> + `, + }), +}; + +export const Sizes: StoryObj<InlineEditComponent> = { + render: () => ({ + props: { + rows: [ + { name: "Default", size: "default", value: "22.03.2026" }, + { name: "Small", size: "small", value: "22.03.2026" }, + ], + }, + template: ` + <div style="display: grid; gap: 8px;"> + <div *ngFor="let row of rows" style="display: grid; grid-template-columns: minmax(0, 104px) minmax(0, 218px); max-width: 338px; gap: 16px; align-items: center;"> + <b>{{ row.name }}</b> + <tedi-inline-edit [displayValue]="row.value" [size]="row.size" label="Kuupäev"> + <ng-template tediInlineEditControl> + <input tedi-text-field [(value)]="row.value" aria-label="Kuupäev" /> + </ng-template> + </tedi-inline-edit> + </div> + </div> + `, + }), +}; + +/** + * 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<InlineEditComponent> = { + 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<string, string> = { + 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: ` + <div style="display: grid; gap: 8px; max-width: 28rem;"> + <div style="display: grid; grid-template-columns: minmax(0, 104px) minmax(0, 1fr); gap: 16px; align-items: start;"> + <label tedi-label>Tüüp</label> + <tedi-inline-edit [displayValue]="state.type.label" label="Tüüp" [closeOnEnter]="false"> + <ng-template tediInlineEditControl> + <tedi-select inputId="ie-value-type" ariaLabel="Tüüp" [options]="typeOptions" + bindLabel="label" bindValue="value" [ngModel]="state.type.value" + (selectionChange)="state.type = pick(typeOptions, $event) ?? state.type" /> + </ng-template> + </tedi-inline-edit> + </div> + + <div style="display: grid; grid-template-columns: minmax(0, 104px) minmax(0, 1fr); gap: 16px; align-items: start;"> + <label tedi-label>Aadress</label> + <tedi-inline-edit [displayValue]="state.address?.label ?? ''" label="Aadress" placeholder="Sisesta aadress" [closeOnEnter]="false"> + <ng-template tediInlineEditControl> + <tedi-select inputId="ie-value-address" ariaLabel="Aadress" [options]="addressOptions" + bindLabel="label" bindValue="value" [ngModel]="state.address?.value" + (selectionChange)="state.address = pick(addressOptions, $event)" /> + </ng-template> + </tedi-inline-edit> + </div> + + <div style="display: grid; grid-template-columns: minmax(0, 104px) minmax(0, 1fr); gap: 16px; align-items: start;"> + <label tedi-label>Märksõnad</label> + <tedi-inline-edit [displayValue]="state.keywords.length ? 'valitud' : ''" label="Märksõnad" placeholder="—" [closeOnEnter]="false"> + <ng-template tediInlineEditValue> + <span style="display: flex; flex-wrap: wrap; gap: 4px;"> + <tedi-tag *ngFor="let k of state.keywords" color="primary">{{ keywordLabel(k) }}</tedi-tag> + </span> + </ng-template> + <ng-template tediInlineEditControl> + <tedi-select inputId="ie-value-keywords" ariaLabel="Märksõnad" [options]="keywordOptions" + bindLabel="label" bindValue="value" [allowMultiple]="true" + [(ngModel)]="state.keywords" /> + </ng-template> + </tedi-inline-edit> + </div> + + <div style="display: grid; grid-template-columns: minmax(0, 104px) minmax(0, 1fr); gap: 16px; align-items: start;"> + <label tedi-label>Olek</label> + <tedi-inline-edit [displayValue]="state.olek.label" label="Olek" [closeOnEnter]="false"> + <ng-template tediInlineEditValue> + <tedi-status-badge [color]="olekColor[state.olek.value]" [text]="state.olek.label" /> + </ng-template> + <ng-template tediInlineEditControl> + <tedi-select inputId="ie-value-olek" ariaLabel="Olek" [options]="olekOptions" + bindLabel="label" bindValue="value" [ngModel]="state.olek.value" + (selectionChange)="state.olek = pick(olekOptions, $event) ?? state.olek" /> + </ng-template> + </tedi-inline-edit> + </div> + </div> + `, + }; + }, +}; + +/** + * 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<InlineEditComponent> = { + 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: ` + <div style="display: grid; gap: 24px;"> + <div *ngFor="let group of groups" style="display: grid; gap: 8px;"> + <b>{{ group.name }}</b> + <div style="display: grid; gap: 0;"> + <div *ngFor="let row of group.rows" style="display: grid; grid-template-columns: minmax(0, 104px) minmax(0, 218px); max-width: 338px; gap: 16px; align-items: center;"> + <label tedi-label>{{ row.label }}</label> + <tedi-inline-edit [displayValue]="row.value" [label]="row.label" [editIconPosition]="group.align"> + <ng-template tediInlineEditControl> + <input tedi-text-field [(value)]="row.value" [attr.aria-label]="row.label" /> + </ng-template> + </tedi-inline-edit> + </div> + </div> + </div> + </div> + `, + }; + }, +}; + +/** + * 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<InlineEditComponent> = { + globals: { viewport: { value: "mobile1" } }, + render: () => ({ + props: { name: "Mari Maasikas" }, + template: ` + <tedi-inline-edit [displayValue]="name" label="Nimi"> + <ng-template tediInlineEditControl> + <input tedi-text-field [(value)]="name" aria-label="Nimi" /> + </ng-template> + </tedi-inline-edit> + `, + }), +}; + +/** + * 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<InlineEditComponent> = { + 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<string, string> + )[value] ?? "", + }, + template: ` + <tedi-row cols="1" gapY="3" style="max-width: 640px;"> + <tedi-row cols="12" gapX="3" showAt="md"> + <tedi-col width="2" /> + <tedi-col width="5"><b>Text field</b></tedi-col> + <tedi-col width="5"><b>Select</b></tedi-col> + </tedi-row> + <tedi-row *ngFor="let state of states" cols="1" gap="2" [md]="{ cols: 12, gapX: 3, gapY: 0 }"> + <tedi-col [md]="{ width: 2 }"><b>{{ state.name }}</b></tedi-col> + <tedi-col [md]="{ width: 5 }"> + <tedi-inline-edit #textEdit + [id]="'ie-state-' + state.key + '-text'" + [displayValue]="state.value" + label="Nimi" + [editing]="!!state.editing" + [invalid]="!!state.invalid" + [disabled]="!!state.disabled" + [readOnly]="!!state.readOnly" + > + <ng-template tediInlineEditControl> + <input tedi-text-field [(value)]="state.value" aria-label="Nimi" [attr.aria-describedby]="state.invalid ? textEdit.feedbackId : null" /> + </ng-template> + <tedi-feedback-text *ngIf="state.invalid" text="Nimi sisaldab lubamatuid märke" type="error" /> + </tedi-inline-edit> + </tedi-col> + <tedi-col [md]="{ width: 5 }"> + <tedi-inline-edit #selectEdit + [id]="'ie-state-' + state.key + '-select'" + [displayValue]="countryLabel(state.country)" + label="Riik" + [closeOnEnter]="false" + [editing]="!!state.editing" + [invalid]="!!state.invalid" + [disabled]="!!state.disabled" + [readOnly]="!!state.readOnly" + > + <ng-template tediInlineEditControl> + <tedi-select + [inputId]="'ie-state-' + state.key + '-select-input'" + ariaLabel="Riik" + [options]="countries" + bindLabel="label" + bindValue="value" + [clearable]="false" + [(ngModel)]="state.country" + /> + </ng-template> + <tedi-feedback-text *ngIf="state.invalid" text="Palun vali sobiv väärtus" type="error" /> + </tedi-inline-edit> + </tedi-col> + </tedi-row> + </tedi-row> + `, + }), +}; + +/** + * `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 + * <a href="./?path=/docs/tedi-ready-content-textgroup--docs" target="_top"><code>tedi-text-group</code></a> + * instead. + * + * Differs from `disabled`, which keeps the greyed trigger and icon for + * transient states such as saving. + */ +export const ReadOnly: StoryObj<InlineEditComponent> = { + 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: ` + <div style="display: grid; gap: 0;"> + <div *ngFor="let row of rows" style="display: grid; grid-template-columns: minmax(0, 104px) minmax(0, 218px); max-width: 338px; gap: 16px; align-items: center;"> + <b>{{ row.label }}</b> + <tedi-inline-edit [displayValue]="row.value" [label]="row.label" [readOnly]="row.fixed"> + <ng-template tediInlineEditControl> + <input tedi-text-field [(value)]="row.value" [attr.aria-label]="row.label" /> + </ng-template> + </tedi-inline-edit> + </div> + </div> + `, + }), +}; + +/** Snapshot the value on entry and restore it when Escape cancels the edit. */ +export const CancelEdit: StoryObj<InlineEditComponent> = { + 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: ` + <div style="width: 100%; max-width: 218px;"> + <tedi-inline-edit [displayValue]="state.name" label="Nimi" (editStart)="onStart()" (editCancel)="onCancel()"> + <ng-template tediInlineEditControl> + <input tedi-text-field [(value)]="state.name" aria-label="Nimi" /> + </ng-template> + </tedi-inline-edit> + </div> + `, + }; + }, +}; + +export const Disabled: StoryObj<InlineEditComponent> = { + render: () => ({ + props: { code: "EE47101010033" }, + template: ` + <div style="width: 100%; max-width: 218px;"> + <tedi-inline-edit [displayValue]="code" label="Isikukood" disabled> + <ng-template tediInlineEditControl> + <input tedi-text-field [(value)]="code" aria-label="Isikukood" /> + </ng-template> + </tedi-inline-edit> + </div> + `, + }), +}; + +/** + * `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<InlineEditComponent> = { + render: () => ({ + props: { email: "mari.maasikas" }, + template: ` + <div style="width: 100%; max-width: 218px;"> + <tedi-inline-edit #emailEdit [displayValue]="email" label="E-post" invalid> + <ng-template tediInlineEditControl> + <input tedi-text-field [(value)]="email" aria-label="E-post" [attr.aria-describedby]="emailEdit.feedbackId" /> + </ng-template> + <tedi-feedback-text text="Sisesta korrektne e-posti aadress" type="error" /> + </tedi-inline-edit> + </div> + `, + }), +}; + +export const Placeholder: StoryObj<InlineEditComponent> = { + render: () => ({ + props: { address: "" }, + template: ` + <div style="width: 100%; max-width: 218px;"> + <tedi-inline-edit [displayValue]="address" placeholder="Sisesta aadress" label="Aadress"> + <ng-template tediInlineEditControl> + <input tedi-text-field [(value)]="address" aria-label="Aadress" /> + </ng-template> + </tedi-inline-edit> + </div> + `, + }), +}; + +/** + * 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<InlineEditComponent> = { + 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: ` + <div style="display: grid; gap: 8px;"> + <div style="display: grid; grid-template-columns: minmax(0, 104px) minmax(0, 260px); gap: 16px; align-items: center;"> + <label tedi-label for="inline-edit-date">Kuupäev</label> + <tedi-inline-edit [displayValue]="formatDate(state.date)" label="Kuupäev"> + <ng-template tediInlineEditControl> + <tedi-date-field inputId="inline-edit-date" [(value)]="state.date" /> + </ng-template> + </tedi-inline-edit> + </div> + <div style="display: grid; grid-template-columns: minmax(0, 104px) minmax(0, 260px); gap: 16px; align-items: center;"> + <label tedi-label for="inline-edit-seats">Kohtade arv</label> + <tedi-inline-edit [displayValue]="state.seats + ''" label="Kohtade arv"> + <ng-template tediInlineEditControl> + <tedi-number-field inputId="inline-edit-seats" [(value)]="state.seats" /> + </ng-template> + </tedi-inline-edit> + </div> + <div style="display: grid; grid-template-columns: minmax(0, 104px) minmax(0, 260px); gap: 16px; align-items: start;"> + <label tedi-label for="inline-edit-note">Märkus</label> + <tedi-inline-edit [displayValue]="state.note" label="Märkus" [closeOnEnter]="false"> + <ng-template tediInlineEditControl> + <textarea tedi-textarea id="inline-edit-note" [(value)]="state.note"></textarea> + </ng-template> + </tedi-inline-edit> + </div> + <div style="display: grid; grid-template-columns: minmax(0, 104px) minmax(0, 260px); gap: 16px; align-items: start;"> + <b>Transport</b> + <tedi-inline-edit [displayValue]="transportLabel(state.transport)" label="Transport" [closeOnEnter]="false"> + <ng-template tediInlineEditControl> + <tedi-radio-group ariaLabel="Transport" direction="vertical" [(value)]="state.transport"> + <label tedi-label color="primary" class="flex align-items-center gap-2"> + <input tedi-radio type="radio" name="ie-transport" value="bus" /> + Buss + </label> + <label tedi-label color="primary" class="flex align-items-center gap-2"> + <input tedi-radio type="radio" name="ie-transport" value="train" /> + Rong + </label> + </tedi-radio-group> + </ng-template> + </tedi-inline-edit> + </div> + <div style="display: grid; grid-template-columns: minmax(0, 104px) minmax(0, 260px); gap: 16px; align-items: start;"> + <b>Lisad</b> + <tedi-inline-edit [displayValue]="extrasLabel(state.extras)" label="Lisad" [closeOnEnter]="false"> + <ng-template tediInlineEditControl> + <tedi-checkbox-group ariaLabel="Lisad" direction="vertical" [(value)]="state.extras"> + <label tedi-label color="primary" class="flex align-items-center gap-2"> + <input tedi-checkbox type="checkbox" name="ie-extras" value="breakfast" /> + Hommikusöök + </label> + <label tedi-label color="primary" class="flex align-items-center gap-2"> + <input tedi-checkbox type="checkbox" name="ie-extras" value="parking" /> + Parkimine + </label> + </tedi-checkbox-group> + </ng-template> + </tedi-inline-edit> + </div> + </div> + `, + }; + }, +}; 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)",