src/app/core/config/translatable-text-input/translatable-text-input.component.ts

Description

Input for a configurable text that can be translated into multiple languages.

The form control holds the raw value, while the field shows and edits only the active language - so editing never drops the other translations. The button opens a dialog to edit them all.

Use inside a mat-form-field, so the caller keeps control over label and errors:

Example :
<mat-form-field>
  <mat-label i18n>Label</mat-label>
  <app-translatable-text-input formControlName="label"></app-translatable-text-input>
</mat-form-field>

Extends

CustomFormControlDirective<TranslatableText>

Implements

EditComponent

Example

Metadata

Relationships

Index

Properties
Methods
Inputs
Outputs

Inputs

formFieldConfig
Type : FormFieldConfig
multiline
Type : boolean
Default value : false

show a multi-line textarea instead of a single-line input

rows
Type : number
Default value : 3
showTranslationsButton
Type : boolean
Default value : true

set false when the form field has its own suffix icon row, and call openTranslations from there

aria-describedby
Type : string
disabled
Type : boolean
ngControl
Type : any
Default value : inject(NgControl, { optional: true, self: true })
placeholder
Type : string
required
Type : boolean
value
Type : T

Outputs

valueChange
Type : EventEmitter

Methods

onTextInput
onTextInput(text: string)

Apply text typed by the user to the active language only, keeping any other languages of a multi-lingual value untouched.

Parameters :
Name Type Optional
text string No
Returns : void
openTranslations
openTranslations(event: Event)
Parameters :
Name Type Optional
event Event No
Returns : void
blur
blur()
Returns : void
focus
focus()
Returns : void
onContainerClick
onContainerClick(event: MouseEvent)
Parameters :
Name Type Optional
event MouseEvent No
Returns : void
registerOnChange
registerOnChange(fn: any)
Parameters :
Name Type Optional
fn any No
Returns : void
registerOnTouched
registerOnTouched(fn: any)
Parameters :
Name Type Optional
fn any No
Returns : void
setDescribedByIds
setDescribedByIds(ids: string[])
Parameters :
Name Type Optional
ids string[] No
Returns : void
setDisabledState
setDisabledState(isDisabled: boolean)
Parameters :
Name Type Optional
isDisabled boolean No
Returns : void
writeValue
writeValue(value: T, notifyFormControl: unknown)

Implementation for Angular ControlValueAccessor interface that links the form control value to the component value

Parameters :
Name Type Optional Default value Description
value T No

The new value to set

notifyFormControl unknown No false

Whether to notify the FormControl of this change (for internal updates)

Returns : void

Properties

controlType
Type : string
Default value : "translatable-text-input"
Readonly displayText
Type : unknown
Default value : computed(() => { const value = this.valueSignal(); const resolved = resolveLocaleText(value) ?? ""; if (!isLocaleTranslationMap(value)) { return resolved; } // an already-present slot wins over the resolved fallback, even when empty - // otherwise clearing the text would snap back to another language return value[getActiveLocale()] ?? resolved; })

the text of the currently active language, shown in the text field

Readonly isMultiline
Type : unknown
Default value : computed( () => this.multiline() || !!this.formFieldConfig()?.additional?.multiline, )
Readonly isMultiLingual
Type : unknown
Default value : computed(() => isLocaleTranslationMap(this.valueSignal()), )

whether this text is currently configured in more than one language

Readonly rowCount
Type : unknown
Default value : computed( () => this.formFieldConfig()?.additional?.rows ?? this.rows(), )
Readonly translationsEnabled
Type : unknown
Default value : inject(SiteSettingsService).translationsEnabled

Whether texts can be configured in several languages in this system (SiteSettingsService.translationsEnabled). Callers rendering their own translations button read this to hide it as well.

elementRef
Type : unknown
Default value : inject<ElementRef<HTMLElement>>(ElementRef)
Readonly enabled
Type : Signal<boolean>
Default value : computed(() => !this._disabled())

Whether the control is currently enabled, as a signal (tracks disabled).

errorStateMatcher
Type : unknown
Default value : inject(ErrorStateMatcher)
id
Type : unknown
Default value : `custom-form-control-${CustomFormControlDirective.nextId++}`
Static nextId
Type : number
Default value : 0
onChange
Type : unknown
Default value : () => {...}
onTouched
Type : unknown
Default value : () => {...}
parentForm
Type : unknown
Default value : inject(NgForm, { optional: true })
parentFormGroup
Type : unknown
Default value : inject(FormGroupDirective, { optional: true })
stateChanges
Type : unknown
Default value : new Subject<void>()
Readonly valueSignal
Type : Signal<T>
Default value : computed(() => this._value())

The current value of the control as a signal. Authoritative in both modes: it reflects the bound FormControl (synced in ngDoCheck) as well as [(value)] / writeValue updates.

import {
  ChangeDetectionStrategy,
  Component,
  computed,
  inject,
  input,
} from "@angular/core";
import { MatButtonModule } from "@angular/material/button";
import { MatDialog } from "@angular/material/dialog";
import { MatFormFieldControl } from "@angular/material/form-field";
import { MatInputModule } from "@angular/material/input";
import { MatTooltipModule } from "@angular/material/tooltip";
import { FaDynamicIconComponent } from "../../common-components/fa-dynamic-icon/fa-dynamic-icon.component";
import { CustomFormControlDirective } from "../../common-components/basic-autocomplete/custom-form-control.directive";
import { FormFieldConfig } from "../../common-components/entity-form/FormConfig";
import { EditComponent } from "../../entity/entity-field-edit/dynamic-edit/edit-component.interface";
import {
  getActiveLocale,
  isLocaleTranslationMap,
  resolveLocaleText,
} from "../../language/active-locale";
import { DynamicComponent } from "../dynamic-components/dynamic-component.decorator";
import {
  ConfigureTranslationsDialogData,
  ConfigureTranslationsPopupComponent,
  ConfigureTranslationsResult,
} from "../configure-translations-popup/configure-translations-popup.component";
import { TranslatableText } from "../multi-lingual-config";
import { SiteSettingsService } from "../../site-settings/site-settings.service";

/**
 * Input for a configurable text that can be translated into multiple languages.
 *
 * The form control holds the *raw* value, while the field shows and edits only the
 * active language - so editing never drops the other translations. The button
 * opens a dialog to edit them all.
 *
 * Use inside a `mat-form-field`, so the caller keeps control over label and errors:
 * ```html
 * <mat-form-field>
 *   <mat-label i18n>Label</mat-label>
 *   <app-translatable-text-input formControlName="label"></app-translatable-text-input>
 * </mat-form-field>
 * ```
 */
@DynamicComponent("EditTranslatableText")
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: "app-translatable-text-input",
  host: {
    "[class.is-multiline]": "isMultiline()",
    // the placeholder belongs on the inner text field only - leaving it on
    // the host too would make it match twice when queried by placeholder
    "[attr.placeholder]": "null",
  },
  templateUrl: "./translatable-text-input.component.html",
  styleUrls: ["./translatable-text-input.component.scss"],
  imports: [
    MatInputModule,
    MatButtonModule,
    MatTooltipModule,
    FaDynamicIconComponent,
  ],
  providers: [
    {
      provide: MatFormFieldControl,
      useExisting: TranslatableTextInputComponent,
    },
  ],
})
export class TranslatableTextInputComponent
  extends CustomFormControlDirective<TranslatableText>
  implements EditComponent
{
  override controlType = "translatable-text-input";

  formFieldConfig = input<FormFieldConfig>();

  /** show a multi-line textarea instead of a single-line input */
  multiline = input(false);
  rows = input(3);

  readonly isMultiline = computed(
    () => this.multiline() || !!this.formFieldConfig()?.additional?.multiline,
  );
  readonly rowCount = computed(
    () => this.formFieldConfig()?.additional?.rows ?? this.rows(),
  );

  /** set false when the form field has its own suffix icon row, and call {@link openTranslations} from there */
  showTranslationsButton = input(true);

  /**
   * Whether texts can be configured in several languages in this system
   * ({@link SiteSettingsService.translationsEnabled}).
   * Callers rendering their own translations button read this to hide it as well.
   */
  readonly translationsEnabled =
    inject(SiteSettingsService).translationsEnabled;

  private readonly dialog = inject(MatDialog);

  /** the text of the currently active language, shown in the text field */
  readonly displayText = computed(() => {
    const value = this.valueSignal();
    const resolved = resolveLocaleText(value) ?? "";

    if (!isLocaleTranslationMap(value)) {
      return resolved;
    }
    // an already-present slot wins over the resolved fallback, even when empty -
    // otherwise clearing the text would snap back to another language
    return value[getActiveLocale()] ?? resolved;
  });

  /** whether this text is currently configured in more than one language */
  readonly isMultiLingual = computed(() =>
    isLocaleTranslationMap(this.valueSignal()),
  );

  /**
   * Apply text typed by the user to the active language only,
   * keeping any other languages of a multi-lingual value untouched.
   */
  onTextInput(text: string) {
    const current = this.valueSignal();
    this.applyValue(
      isLocaleTranslationMap(current)
        ? { ...current, [getActiveLocale()]: text }
        : text,
    );
  }

  /**
   * As an `editComponent` the surrounding DynamicEditComponent is the registered
   * ControlValueAccessor, so `onChange` here is a no-op: write to the bound
   * FormControl too, or the edit is discarded on save.
   */
  private applyValue(newValue: TranslatableText | undefined) {
    this.value = newValue;

    const control = this.formControl;
    if (control && control.value !== newValue) {
      control.setValue(newValue);
      control.markAsDirty();
    }
  }

  openTranslations(event: Event) {
    event.stopPropagation();

    this.dialog
      .open(ConfigureTranslationsPopupComponent, {
        data: {
          value: this.valueSignal(),
          // only set on the entity-form path; the admin UIs render their own label
          fieldLabel: resolveLocaleText(this.formFieldConfig()?.label),
          multiline: this.isMultiline(),
          rows: this.rowCount(),
        } satisfies ConfigureTranslationsDialogData,
        disableClose: true,
      })
      .afterClosed()
      .subscribe((result?: ConfigureTranslationsResult) => {
        if (!result) {
          // cancelled: keep the previously configured value
          return;
        }
        // saved, possibly with every language cleared
        this.applyValue(result.value);
        this.onTouched();
      });
  }
}
@if (isMultiline()) {
  <textarea
    class="text-field"
    [id]="id"
    [rows]="rowCount()"
    [value]="displayText()"
    [placeholder]="placeholder ?? ''"
    [disabled]="!enabled()"
    (input)="onTextInput($any($event.target).value)"
    (blur)="onTouched()"
  ></textarea>
} @else {
  <input
    class="text-field"
    [id]="id"
    [value]="displayText()"
    [placeholder]="placeholder ?? ''"
    [disabled]="!enabled()"
    (input)="onTextInput($any($event.target).value)"
    (blur)="onTouched()"
  />
}

@if (showTranslationsButton() && translationsEnabled()) {
  <button
    mat-icon-button
    type="button"
    class="translations-button input-embedded-icon-button"
    [class.is-multi-lingual]="isMultiLingual()"
    [disabled]="!enabled()"
    (click)="openTranslations($event)"
    matTooltip="Configure this text in other languages"
    i18n-matTooltip
    aria-label="Configure this text in other languages"
    i18n-aria-label
  >
    <app-fa-dynamic-icon icon="language"></app-fa-dynamic-icon>
  </button>
}

./translatable-text-input.component.scss

@use "variables/colors";

:host {
  display: flex;
  align-items: center;
  width: 100%;
}

// a textarea is taller than the button, so keep the button at the top
:host(.is-multiline) {
  align-items: flex-start;
}

.text-field {
  flex: 1 1 auto;
  min-width: 0; // let it shrink instead of overflowing under the suffix icons
  width: 100%;
  border: none;
  background: transparent;
  // `inherit` would pick up the form field's muted label colour
  color: var(--mat-sys-on-surface, rgba(0, 0, 0, 0.87));
  font: inherit;
  padding: 0;
  outline: none;
}

textarea.text-field {
  resize: vertical;
}

.translations-button {
  color: colors.$muted;
}

// highlight the button when translations for several languages exist
.is-multi-lingual {
  color: colors.$accent;
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""