bp

Microcopy Translations as a Reactive Controller

The inimitable Nikki Massaro Kaufman has been working on the upcoming Red Hat audio player web component. It's an impressive piece of work that packs a tonne of features into such a small package. We got an order to implement client-side translations, so Nikki opted for an approach that lazy-loads key-value pairs into the app.

Initially, this was set up as a POJO, but that had a couple of drawbacks

  1. We needed to use bracket access, or wrap that up in a dedicated private instance method
    #translate(key: string) {
      return this.#microcopy[key] ?? key;
    }
  2. Whenever modifying the table (e.g. when lazy loading) we had to rememeber to sprinkle in this.requestUpdate() to make sure to rerender

Squinting, I started to see the outline of reactive controller with one eye and a Map with the other. Problem is maps don't have default getters.

Turns out the modern web platform is a joy to work with:

import type { ReactiveController, ReactiveControllerHost } from 'lit';

export class MicrocopyController extends Map<string, string> implements ReactiveController {
  hostConnected?(): void
}

For ergonomics, let's accept a POJO:

constructor(private host: ReactiveControllerHost, obj: Record<string, string>) {
  super(Object.entries(obj));
}

Then we'll implement our default getter:

get(key: string) {
  return super.get(key) ?? key;
}

Since the strings are lazy loaded, and the lang can change as a result of user input, we can round it out with some built-in reactivity:

set(key: string, value: string) {
  super.set(key, value);
  this.host.requestUpdate();
  return this;
}

clear() {
  super.clear();
  this.host.requestUpdate();
}

delete(key: string) {
  const r = super.delete(key);
  this.host.requestUpdate();
  return r;
}

For the lulz, let's add a join method that lets us fold in new definitions:

join(obj: Record<string, string>) {
  for (const [key, value] of Object.entries(obj)) {
    this.set(key, value);
  }
  this.host.requestUpdate();
  return this;
}

et voila, we're done!

Use it like this:

import { html, css, LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { MicrocopyController } from './microcopy-controller.js';

const LANGS = {
  'en-us': {
    hello: 'Hello'
  },
  'he-il': {
    hello: 'שלום',
  }
};

@customElement('simple-greeting')
export class SimpleGreeting extends LitElement {
  static styles = css`p { color: blue }`;

  #i18n = new MicrocopyController(this, LANGS['en-us']);

  @property() name = 'Somebody';

  @property() lang = 'en-us';

  willUpdate(changed) {
    if (changed.has('lang'))
      this.#i18n.join(LANGS[this.lang]);
  }

  render() {
    return html`
        <form @change=${e => this.lang = e.target.elements.lang.value}>
          <label>English<input type="radio" name="lang" value="en-us"></label>
          <label>Hebrew<input type="radio" name="lang" value="he-il"></label>
        </form>
        <p>${this.#i18n.get('hello')}, ${this.name}!</p>
    `;
  }
}

playground