src/app/core/config/translatable-text-input/translatable-text-input.component.ts
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:
<mat-form-field>
<mat-label i18n>Label</mat-label>
<app-translatable-text-input formControlName="label"></app-translatable-text-input>
</mat-form-field>
CustomFormControlDirective<TranslatableText>
| changeDetection | ChangeDetectionStrategy.OnPush |
| host | { |
| providers |
TranslatableTextInputComponent
|
| selector | app-translatable-text-input |
| standalone | true |
| imports |
MatInputModule
MatButtonModule
MatTooltipModule
|
| styleUrls | ./translatable-text-input.component.scss |
| templateUrl | ./translatable-text-input.component.html |
MatInputModule
MatButtonModule
MatTooltipModule
FaDynamicIconComponent
CustomFormControlDirective<TranslatableText>
EditComponent
Properties |
|
Methods |
Inputs |
Outputs |
| 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
|
|
|
Inherited from
CustomFormControlDirective
|
|
| disabled | |
Type : boolean
|
|
|
Inherited from
CustomFormControlDirective
|
|
| ngControl | |
Type : any
|
|
Default value : inject(NgControl, { optional: true, self: true })
|
|
|
Inherited from
CustomFormControlDirective
|
|
| placeholder | |
Type : string
|
|
|
Inherited from
CustomFormControlDirective
|
|
| required | |
Type : boolean
|
|
|
Inherited from
CustomFormControlDirective
|
|
| value | |
Type : T
|
|
|
Inherited from
CustomFormControlDirective
|
|
| valueChange | |
Type : EventEmitter
|
|
|
Inherited from
CustomFormControlDirective
|
|
| 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 :
Returns :
void
|
| openTranslations | ||||||
openTranslations(event: Event)
|
||||||
|
Parameters :
Returns :
void
|
| blur |
blur()
|
|
Inherited from
CustomFormControlDirective
|
|
Returns :
void
|
| focus |
focus()
|
|
Inherited from
CustomFormControlDirective
|
|
Returns :
void
|
| onContainerClick | ||||||
onContainerClick(event: MouseEvent)
|
||||||
|
Inherited from
CustomFormControlDirective
|
||||||
|
Parameters :
Returns :
void
|
| registerOnChange | ||||||
registerOnChange(fn: any)
|
||||||
|
Inherited from
CustomFormControlDirective
|
||||||
|
Parameters :
Returns :
void
|
| registerOnTouched | ||||||
registerOnTouched(fn: any)
|
||||||
|
Inherited from
CustomFormControlDirective
|
||||||
|
Parameters :
Returns :
void
|
| setDescribedByIds | ||||||
setDescribedByIds(ids: string[])
|
||||||
|
Inherited from
CustomFormControlDirective
|
||||||
|
Parameters :
Returns :
void
|
| setDisabledState | ||||||
setDisabledState(isDisabled: boolean)
|
||||||
|
Inherited from
CustomFormControlDirective
|
||||||
|
Parameters :
Returns :
void
|
| writeValue | |||||||||||||||
writeValue(value: T, notifyFormControl: unknown)
|
|||||||||||||||
|
Inherited from
CustomFormControlDirective
|
|||||||||||||||
|
Implementation for Angular ControlValueAccessor interface that links the form control value to the component value
Parameters :
Returns :
void
|
| controlType |
Type : string
|
Default value : "translatable-text-input"
|
|
Inherited from
CustomFormControlDirective
|
| 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)
|
|
Inherited from
CustomFormControlDirective
|
| Readonly enabled |
Type : Signal<boolean>
|
Default value : computed(() => !this._disabled())
|
|
Inherited from
CustomFormControlDirective
|
|
Whether the control is currently enabled, as a signal (tracks disabled). |
| errorStateMatcher |
Type : unknown
|
Default value : inject(ErrorStateMatcher)
|
|
Inherited from
CustomFormControlDirective
|
| id |
Type : unknown
|
Default value : `custom-form-control-${CustomFormControlDirective.nextId++}`
|
|
Inherited from
CustomFormControlDirective
|
| Static nextId |
Type : number
|
Default value : 0
|
|
Inherited from
CustomFormControlDirective
|
| onChange |
Type : unknown
|
Default value : () => {...}
|
|
Inherited from
CustomFormControlDirective
|
| onTouched |
Type : unknown
|
Default value : () => {...}
|
|
Inherited from
CustomFormControlDirective
|
| parentForm |
Type : unknown
|
Default value : inject(NgForm, { optional: true })
|
|
Inherited from
CustomFormControlDirective
|
| parentFormGroup |
Type : unknown
|
Default value : inject(FormGroupDirective, { optional: true })
|
|
Inherited from
CustomFormControlDirective
|
| stateChanges |
Type : unknown
|
Default value : new Subject<void>()
|
|
Inherited from
CustomFormControlDirective
|
| Readonly valueSignal |
Type : Signal<T>
|
Default value : computed(() => this._value())
|
|
Inherited from
CustomFormControlDirective
|
|
The current value of the control as a signal.
Authoritative in both modes: it reflects the bound |
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;
}