LocalizedField
LocalizedField is a CustomField for editing the language versions of a
single piece of text — a title, a slogan, a product description — when your
application stores content in several languages. The user edits one language at
a time, and the field value is a Map<Locale, String> from each locale to its
text.
It comes in two flavours:
LocalizedTextField— single-line, backed by aTextField.LocalizedTextArea— multi-line, backed by aTextArea.
Optionally, a pluggable translation engine can fill the remaining languages from the one the user just typed. With Spring (and, say, Spring AI) this is literally a matter of registering one bean — the “auto translate” action then appears on its own.
Basic usage
Pass the locales you want to support. They can be given in any order; the field
always shows them alphabetically by their localized name. A leading String
argument sets the field label:
LocalizedTextField title = new LocalizedTextField("Title",
Locale.ENGLISH, Locale.of("fi"), Locale.of("sv"), Locale.GERMAN);
add(title);
// The value is a Map<Locale, String>
Map<Locale, String> value = title.getValue();
LocalizedTextArea works the same way and is sizable straight through the
standard HasSize API; the height propagates down to the editor:
LocalizedTextArea description = new LocalizedTextArea(locales);
description.setLabel("Description");
description.setWidth("480px");
description.setHeight("220px");
Binding to a domain model
The field value type is Map<Locale, String>. If your domain object stores
translations the same way, binding is glue-free:
binder.forField(title).bind(Product::getTitle, Product::setTitle);
Many applications instead key translations by language code ("fi"),
e.g. persisted as a jsonb column — a Map<String, String>. For that, bind
through languageCodeMapConverter():
binder.forField(title)
.withConverter(title.languageCodeMapConverter()) // Map<Locale,String> <-> Map<String,String>
.bind(Product::getTitleTexts, Product::setTitleTexts);
Conversion is by language code only, and the field rebuilds the presentation
with its own locales, so locale country variants (en vs en_US) do not cause
surprises. The same tolerance applies when binding a Map<Locale, String>
directly: a value keyed by en_US still shows in an en tab.
For a wrapper type — say a TranslatedText backed by a Map<String, String> —
either bind through its map accessor, or chain a second converter that wraps and
unwraps it.
Tab mode vs. combo box mode
By default the languages are presented as a tab bar. The active tab is marked with an underline, and the editor fills a single bordered box below it — no “box in a box”.
Once there are more languages than a configurable threshold (20 by default), a tab bar becomes unwieldy, so the component automatically switches to a combo box for picking the language instead. Everything else stays the same; the two modes look alike.
The threshold is configurable globally or per instance:
// Global default for all fields
LocalizedField.setDefaultComboBoxThreshold(12);
// Per instance (these locales exceed the given threshold of 8 -> combo box)
LocalizedTextField keyword = new LocalizedTextField(manyLocales, 8);
Which language is open
When the field is attached, it opens the user's own language if it is one of the editable languages (matched first exactly, then by language code). Otherwise the alphabetically first language is opened.
You can choose the open language explicitly — this overrides the user-locale default:
title.setSelectedLocale(Locale.of("fi"));
Locale open = title.getSelectedLocale();
Automatic translation
A Translator turns the text of the currently open language into the other
languages. The interface is intentionally tiny and backend-agnostic:
@FunctionalInterface
public interface Translator {
String translate(String text, Locale from, Locale to);
}
When a translator is available, a small “magic” icon appears in the
bottom-right corner of the field. Clicking it translates the open language into
all the others. The work runs off the UI thread (translators may call a slow
network/LLM API) on an ActionButton. While it runs, the icon turns into a
spinner and further clicks are blocked, so impatient users cannot stack up
several calls. Server push or polling is handled automatically.
Providing a translator with the Instantiator (Spring)
The component looks a translator up from Vaadin's Instantiator on attach, so
in a Spring application registering a Translator bean is all it takes for
the action to appear — no per-field wiring. Conversely, when the Instantiator
finds nothing (and nothing was set explicitly), the action stays hidden. So the
button's visibility follows availability: to hide it when, say, no AI is
configured, simply don't register the bean — gate it with @ConditionalOnProperty
as below, or opt a single field out with setTranslator(null).
@Configuration
class TranslationConfig {
@Bean
Translator translator() {
return (text, from, to) -> myTranslationService.translate(text, from, to);
}
}
Spring AI example
A large language model makes a capable translation engine. With
Spring AI and, for example, the
Mistral AI starter on the classpath, a ChatClient.Builder is auto-configured
from your spring.ai.mistralai.api-key. Wrap it as a Translator bean:
@Configuration
class AiTranslationConfig {
@Bean
@ConditionalOnProperty("spring.ai.mistralai.api-key")
Translator aiTranslator(ChatClient.Builder chatClientBuilder) {
ChatClient chat = chatClientBuilder.build();
return (text, from, to) -> chat.prompt()
.system("You are a translation engine. Translate the user's message from "
+ from.getDisplayLanguage(Locale.ENGLISH) + " to "
+ to.getDisplayLanguage(Locale.ENGLISH)
+ ". Reply with the translation only, no quotes or explanations.")
.user(text)
.call()
.content();
}
}
Because the bean is @ConditionalOnProperty, the translate action appears only
when an API key is configured; without one, the fields simply show no magic
icon. You could just as well provide a different Translator (an online
translation API, a glossary, a stub for tests) — the component does not care.
Setting or disabling the translator explicitly
Calling setTranslator(...) overrides whatever the Instantiator would supply.
This also lets you turn the action off for a particular field even when a
bean exists, by passing null:
field.setTranslator(myTranslator); // use this one here
field.setTranslator(null); // no translate action on this field
Customizing the action button
getTranslateButton() returns the ActionButton, so you can adjust its
presentation — change the icon or tooltip via its inner button, set a busy text,
show its progress bar, and so on:
field.getTranslateButton().getButton()
.setIcon(VaadinIcon.LANGUAGE.create());
Theming
The styling is theme-agnostic and tuned to look the same in spirit in both the Aura and Lumo themes: a plain bordered box, a shaded selector header with a straight divider, the editor filling the box, an accent focus ring that joins the active-tab underline, and the bare corner icon for translation. It is all done with a single scoped stylesheet, so it does not affect other tab bars, combo boxes or buttons in your application.
Extension points
LocalizedField is designed to be subclassed and tweaked:
createField(Locale)— the abstract factory for the per-language editor (this is whatLocalizedTextFieldandLocalizedTextAreaimplement).tabLabel(Locale)/languageName(Locale)/flagFor(Locale)— customize the label, name and flag shown for each language.translateButtonText()— localize the translate action's tooltip / accessible name.
