@shoelace-style/localize 1.1.6 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## 2.1.0
4
+
5
+ - Added relative time method to
6
+
7
+ ## 2.0.0
8
+
9
+ - Reworked the library to use the [ReactiveController](https://lit.dev/docs/composition/controllers/) interface
package/README.md CHANGED
@@ -2,33 +2,36 @@
2
2
 
3
3
  This micro library does not aim to replicate a full-blown localization tool. For that, you should use something like [i18next](https://www.i18next.com/). What this library _does_ do is provide a lightweight, framework-agnostic mechanism for sharing and applying translations across one or more custom elements in a component library.
4
4
 
5
- Included are methods for translating terms, dates, currencies, and numbers, as well as corresponding directives for Lit and FAST.
5
+ Included are methods for translating terms, dates, currencies, and numbers and a [Reactive Controller](https://lit.dev/docs/composition/controllers/) that can be used as a mixin in Lit and other supportive component authoring libraries.
6
6
 
7
7
  ## Overview
8
8
 
9
- Here's an example of how this library can be used to create a custom element with Lit.
10
-
9
+ Here's an example of how this library can be used to create a localized custom element with Lit.
11
10
 
12
11
  ```ts
13
- import { registerTranslation } from '@shoelace-style/localize';
14
- import { localize, translate as t } from '@shoelace-style/localize/dist/lit.js';
12
+ import { LocalizeController, registerTranslation } from '@shoelace-style/localize';
13
+
14
+ // Translations can also be loaded outside of the component and/or on the fly using dynamic imports
15
15
  import en from '../translations/en';
16
16
  import es from '../translations/es';
17
17
 
18
- registerTranslation(en, es); // Translations can also be loaded outside of the component and/or on demand
18
+ registerTranslation(en, es);
19
19
 
20
20
  @customElement('my-element')
21
- @localize()
22
21
  export class MyElement extends LitElement {
22
+ private localize = new LocalizeController(this);
23
+
24
+ @property() lang: string;
25
+
23
26
  render() {
24
27
  return html`
25
- <h1>${t('hello_user', 'world')}</h1> <!-- outputs "Hello, world!" -->
28
+ <h1>${this.localize.term('hello_world')}</h1>
26
29
  `;
27
30
  }
28
31
  }
29
32
  ```
30
33
 
31
- Here's how your consumers will change languages.
34
+ To set the page locale, apply the desired `lang` attribute to the `<html>` element.
32
35
 
33
36
  ```html
34
37
  <html lang="es">
@@ -36,7 +39,7 @@ Here's how your consumers will change languages.
36
39
  </html>
37
40
  ```
38
41
 
39
- Simply changing the `lang` attribute on any element in the DOM will trigger an update to all localized components.
42
+ Changes to `<html lang>` will trigger an update to all localized components automatically.
40
43
 
41
44
  ## Why this instead of an i18n library?
42
45
 
@@ -44,7 +47,7 @@ It's not uncommon for a custom element to require localization, but implementing
44
47
 
45
48
  ```html
46
49
  <button type="button" aria-label="Close">
47
- <svg><!-- close icon --></svg>
50
+ <svg>...</svg>
48
51
  </button>
49
52
  ```
50
53
 
@@ -58,39 +61,25 @@ Typically, custom element authors dance around the problem by exposing attribute
58
61
 
59
62
  But this approach offloads the problem to the user so they have to provide every term, every time. It also doesn't scale with more complex components that have more than a handful of terms to be translated.
60
63
 
61
- This is the use case this library is solving for. It is by no means intended to solve localization at the framework level. There are much better tools for that.
64
+ This is the use case this library is solving for. It is not intended to solve localization at the framework level. There are much better tools for that.
62
65
 
63
66
  ## How it works
64
67
 
65
- To achieve this goal, we lean on HTML’s [`lang`](~https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/lang~) attribute to determine what languages should be used in various parts of the page. The default language is specified by `<html lang>`, but any element in the DOM can be scoped to a language by setting its `lang` attribute. This means you can have more than one language per page, if desired.
68
+ To achieve this goal, we lean on HTML’s [`lang`](~https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/lang~) attribute to determine what language should be used. The default locale is specified by `<html lang="...">`, but any localized element can be scoped to a locale by setting its `lang` attribute. This means you can have more than one language per page, if desired.
66
69
 
67
70
  ```html
68
71
  <html lang="en">
69
72
  <body>
70
-
71
73
  <my-element>This element will be English</my-element>
72
-
73
- <div lang="es">
74
- <my-element>This element will be Spanish</my-element>
75
- <my-element>This element is also Spanish</my-element>
76
- </div>
77
-
74
+ <my-element lang="es">This element will be Spanish</my-element>
75
+ <my-element lang="fr">This element will be French</my-element>
78
76
  </body>
79
77
  </html>
80
78
  ```
81
79
 
82
- This library provides a set of tools to localize dates, currencies, numbers, and terms in your custom element library with a minimal footprint. Reactivity is achieved with a [MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) that listens for `lang` changes in the document. This means that changing a `lang` attribute in the DOM will automatically update all localized elements.
80
+ This library provides a set of tools to localize dates, currencies, numbers, and terms in your custom element library with a minimal footprint. Reactivity is achieved with a [MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) that listens for `lang` changes on `<html>`.
83
81
 
84
- _Exception: The mutation observer will not track `lang` changes within shadow roots. For this, there is a `forceUpdate()` method you can call to tell localized elements to update._
85
-
86
- When a localized element is connected to the DOM, the library looks for the closest `lang` attribute, moving up through its ancestors and breaking through shadow roots as necessary. The element and its language are then cached internally. When a `lang` attribute changes. the library loops through all connected components, re-detects their language, and tells them to update. When an element is disconnected from the DOM, it is discarded from the map.
87
-
88
- By caching languages in a map, we're able to limit expensive DOM traversal so it only occurs:
89
-
90
- 1. When the element is connected
91
- 2. When a `lang` attribute changes
92
-
93
- At this time, there is no easier way to detect the "current language" of an arbitrary element. I consider this a gap in the platform and [I've proposed properties](https://github.com/whatwg/html/issues/7039) to make this lookup more efficient.
82
+ By design, `lang` attributes on ancestor elements are ignored. This is for performance reasons, as there isn't an efficient way to detect the "current language" of an arbitrary element. I consider this a gap in the platform and [I've proposed properties](https://github.com/whatwg/html/issues/7039) to make this lookup less expensive.
94
83
 
95
84
  ## Usage
96
85
 
@@ -102,13 +91,13 @@ npm install @shoelace-style/localize
102
91
 
103
92
  Next, follow these steps to localize your components.
104
93
 
105
- 1. Create one or more translations
106
- 2. Register the translations
94
+ 1. Create a translation
95
+ 2. Register the translation
107
96
  3. Localize your components
108
97
 
109
- ### Creating Translations
98
+ ### Creating a Translation
110
99
 
111
- Every translation must extend the `Translation` type. All translations must implement the required meta properties (denoted by a `$` prefix) and additional terms can be implemented as show below.
100
+ All translations must extend the `Translation` type and implement the required meta properties (denoted by a `$` prefix). Additional terms can be implemented as show below.
112
101
 
113
102
  ```ts
114
103
  // en.ts
@@ -119,11 +108,11 @@ const translation: Translation = {
119
108
  $name: 'English',
120
109
  $dir: 'ltr',
121
110
 
122
- // Terms
111
+ // Simple terms
123
112
  upload: 'Upload',
124
113
 
125
- // Placeholders
126
- hello_user: (name: string) => `Hello, ${name}!`,
114
+ // Terms with placeholders
115
+ greet_user: (name: string) => `Hello, ${name}!`,
127
116
 
128
117
  // Plurals
129
118
  num_files_selected: (count: number) => {
@@ -148,13 +137,15 @@ import es from './es';
148
137
  registerTranslation(en, es);
149
138
  ```
150
139
 
151
- The first translation to be registered will be used as the "fallback". That is, if a term is missing from the target language, the fallback language will be used instead.
140
+ The first translation that's registered will be used as the _fallback_. That is, if a term is missing from the target language, the fallback language will be used instead.
141
+
142
+ Translations registered with country such as `en-GB` are supported. However, your fallback translation must be registered with only a language code (e.g. `en`) to ensure users of unsupported regions will still receive a comprehensible translation.
152
143
 
153
- Translations registered with subcodes such as `en-GB` are supported. However, your fallback translation must be registered with a base code (e.g. `en`) to ensure users of unsupported regions will still receive a comprehensible translation.
144
+ For example, if you're fallback language is `en-US`, you should register it as `en` so users with unsupported `en-*` country codes will receive it as a fallback. Then you can register country codes such as `en-GB` and `en-AU` to improve the experience for additional regions.
154
145
 
155
- For example, if you're fallback language is `en-US`, you should register it as `en` so users with unsupported `en-*` subcodes will receive it as a fallback. Then you can register subcodes such as `en-GB` and `en-AU` to improve the experience for those additional regions.
146
+ It's important to note that translations _do not_ have to be registered up front. You can register them on demand as the language changes in your app. Upon registration, localized components will update automatically.
156
147
 
157
- It's important to note that translations _do not_ have to be registered up front. You can register them on demand as the language changes in your app. Upon import, all localized components will update automatically.
148
+ Here's a sample function that dynamically loads a translation.
158
149
 
159
150
  ```ts
160
151
  import { registerTranslation } from '@shoelace-style/localize';
@@ -169,177 +160,51 @@ async function changeLanguage(lang) {
169
160
  }
170
161
  ```
171
162
 
172
- ### Lit
163
+ ### Localizing Components
173
164
 
174
- If you're using [Lit](https://lit.dev/) to develop components:
165
+ You can use the `LocalizeController` with any library that supports [Lit's Reactive Controller pattern](https://lit.dev/docs/composition/controllers/). In [Lit](https://lit.dev/), a localized custom element will look something like this.
175
166
 
176
167
  ```ts
177
168
  import { LitElement } from 'lit';
178
169
  import { customElement } from 'lit/decorators.js';
179
- import { localize, translate as t, formatDate as d, formatNumber as n } from '@shoelace-style/localize/dist/lit.js';
170
+ import { LocalizeController } from '@shoelace-style/localize/dist/lit.js';
180
171
 
181
172
  @customElement('my-element')
182
- @localize()
183
173
  export class MyElement extends LitElement {
174
+ private localize = new LocalizeController(this);
175
+
176
+ @property() lang: string;
177
+
184
178
  render() {
185
179
  return html`
186
180
  <!-- Term -->
187
- ${t('hello')}
181
+ ${this.localize.term('hello')}
188
182
 
189
183
  <!-- Date -->
190
- ${d('2021-09-15 14:00:00 ET'), { month: 'long', day: 'numeric', year: 'numeric' }}
184
+ ${this.localize.date('2021-09-15 14:00:00 ET'), { month: 'long', day: 'numeric', year: 'numeric' }}
191
185
 
192
- <!-- Currency -->
193
- ${n(1000, { style: 'currency', currency: 'USD'})}
186
+ <!-- Number/currency -->
187
+ ${this.localize.number(1000, { style: 'currency', currency: 'USD'})}
194
188
  `;
195
189
  }
196
190
  }
197
191
  ```
198
192
 
199
- ### FAST
200
-
201
- If you're using [FAST Element](https://www.fast.design/) to develop components:
202
-
203
- ```ts
204
- import { FASTElement, customElement } from '@microsoft/fast-element';
205
- import { localize, translate as t, formatDate as d, formatNumber as n } from '@shoelace-style/localize/dist/lit.js';
206
-
207
- const template = html<MyElement>`
208
- <!-- Term -->
209
- ${x => t(x, 'hello')}
210
-
211
- <!-- Date -->
212
- ${x => d(x, '2021-09-15 14:00:00 ET'), { month: 'long', day: 'numeric', year: 'numeric' }}
213
-
214
- <!-- Currency -->
215
- ${x => n(x, 1000, { style: 'currency', currency: 'USD'})}
216
- `;
217
-
218
- @customElement({
219
- name: 'my-element',
220
- template
221
- })
222
- export class MyElement extends FASTElement {
223
- // ...
224
- }
225
- ```
226
-
227
- **Note:** This directive requires a context to be passed as the first argument. This is required because of a limitation in FAST's rendering engine that causes inconsistencies when a directive is used as `${t('example')}` vs. `${x => t('example')}`.
228
-
229
- ### No Library (Advanced)
230
-
231
- To use this without a custom element library, you'll need to follow this pattern.
232
-
233
- ```ts
234
- import { connectedElements, detectLanguage, forceUpdate } from '@shoelace-style/localize';
235
-
236
- class MyElement extends HTMLElement {
237
- connectedCallback() {
238
- // Register the component when it's connected and cache the current language
239
- const lang = detectLanguage(this);
240
- connectedElements.set(this, lang);
241
- }
242
-
243
- disconnectedCallback() {
244
- // Remove the element from cache when it disconnects from the DOM
245
- connectedElements.delete(this);
246
- }
247
-
248
- updateLocalizedTerms() {
249
- // Called when the lang changes (this is where you update all localized terms in the element)
250
- }
251
-
252
- static get observedAttributes() {
253
- return ['lang'];
254
- }
255
-
256
- attributeChangedCallback(name, oldValue, newValue) {
257
- // When lang changes, update the cache and trigger an update
258
- if (name === 'lang') {
259
- this.updateLocalizedTerms();
260
- }
261
- }
262
- }
263
- ```
264
-
265
- Three core translation functions are exposed for translating terms, dates, numbers, and currencies. The `translate()` function relies on the translations you provide, while the `formatDate()` and `formatNumber()` functions use the [`Intl` API](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl) to localize dates, numbers, and currencies.
266
-
267
- These are the functions that get called internally by the Lit directives. Note that `lang` is a required argument. If you use this API, make sure to use the cached language values available in the `connectedElements` map to avoid expensive DOM traversal.
268
-
269
- ```ts
270
- function translate(lang: string, key: string, args: any) {
271
- // returns a localized term
272
- }
273
-
274
- function formatDate(lang: string, date: Date | string, options: Intl.DateTimeFormatOptions) {
275
- // returns a localized date
276
- }
277
-
278
- function formatNumber(lang: string, number: number | string, options?: Intl.NumberFormatOptions) {
279
- // returns a localized number or currency
280
- }
281
- ```
282
-
283
- ### Typed Arguments
284
-
285
- As the `Translation` interface is extended by the user, and because terms can have varying arguments, there's no way for TypeScript to automatically know about the terms you've added. This means we won't get strongly typed arguments when calling `translate()` or a translate directive.
286
-
287
- However, you can optionally extend the `Translation` interface with your own and wrap the translation functions you want to enable strong typings for.
288
-
289
- ```ts
290
- // translation.ts
291
- import type { FunctionParams, Translation as BaseTranslation } from '@shoelace-style/localize';
292
- import { translate as internalTranslate } from '@shoelace-style/localize';
293
- import { translate as litTranslate } from '@shoelace-style/localize/dist/lit.js';
294
-
295
- export interface Translation extends BaseTranslation {
296
- upload: string;
297
- hello_user: (name: string) => string;
298
- num_files_selected: (count: number) => string;
299
- }
300
-
301
- // Wrap the translate function
302
- export function translate<T extends keyof Translation>(lang: string, key: T, ...args: FunctionParams<Translation[T]>) {
303
- return internalTranslate(lang, key, ...args) as unknown;
304
- }
305
-
306
- // Wrap the Lit translate directive
307
- export function translateDirective<T extends keyof Translation>(key: T, ...args: FunctionParams<Translation[T]>) {
308
- return litTranslate(key, ...args) as unknown;
309
- }
310
- ```
311
-
312
- Now, instead of importing from `@shoelace-style/localize`, you can import these functions from your own `translation.ts` module and you'll get strongly typed translation keys and arguments!
313
-
314
- ### Appending Terms
315
-
316
- Occassionaly, third-party components may want to make use of your localization library. Should you choose to expose this as an option, here's how terms can be added to translations that are already registered.
317
-
318
- ```ts
319
- import en from './en';
320
-
321
- en.logout = 'Logout';
322
- en.goodbye_user = (name: string) => `Goodbye, ${name}`;
323
- ```
324
-
325
193
  ## Advantages
326
194
 
327
195
  - Extremely lightweight
328
- - ~2.7 KB compared to ~33 KB for i18next (both without translations; both minified, not gzipped)
196
+ - ~2.5 KB compared to ~33 KB for i18next (both without translations/minifications)
329
197
  - Uses existing platform features
330
198
  - Supports simple terms, plurals, and complex translations
331
199
  - Fun fact: some languages have [six plural forms](https://lingohub.com/blog/2019/02/pluralization) and this will support that
332
200
  - Supports dates, numbers, and currencies
333
201
  - Good DX for custom element authors and consumers
334
- - Authors have directives to localize components using popular libraries
335
- - Consumers only need to load the translations they want to use and set the `lang` attribute
202
+ - Intuitive API for custom element authors
203
+ - Consumers only need to load the translations they want and set the `lang` attribute
336
204
  - Translations can be loaded up front or on demand
337
205
  - Translations can be created by consumers without having to wait for them to get accepted upstream
338
- - Strong typings when using translations (requires a custom Translation interface and a wrapper function)
339
206
 
340
207
  ## Disadvantages
341
208
 
342
209
  - Complex translations require some code, such as conditionals
343
210
  - This is arguably no more difficult than, for example, adding them to a [YAML](https://edgeguides.rubyonrails.org/i18n.html#pluralization) or [XLIFF](https://en.wikipedia.org/wiki/XLIFF) file
344
-
345
- Note that we aren’t aiming to solve localization at the framework level. Many teams already have their own translation libraries, so we need to provide something that will work in tandem with those with a minimal learning curve.
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { ReactiveController, ReactiveControllerHost } from 'lit';
1
2
  export declare type FunctionParams<T> = T extends (...args: infer U) => string ? U : never;
2
3
  export interface Translation {
3
4
  $code: string;
@@ -5,10 +6,19 @@ export interface Translation {
5
6
  $dir: 'ltr' | 'rtl';
6
7
  [key: string]: any;
7
8
  }
8
- export declare const connectedElements: Map<HTMLElement, string>;
9
- export declare function detectLanguage(el: HTMLElement): string;
10
9
  export declare function registerTranslation(...translation: Translation[]): void;
11
- export declare function translate<K extends keyof Translation>(lang: string, key: K, ...args: FunctionParams<Translation[K]>): any;
12
- export declare function formatDate(lang: string, date: Date | string, options?: Intl.DateTimeFormatOptions): string;
13
- export declare function formatNumber(lang: string, number: number | string, options?: Intl.NumberFormatOptions): string;
14
- export declare function forceUpdate(): void;
10
+ export declare function term<K extends keyof Translation>(lang: string, key: K, ...args: FunctionParams<Translation[K]>): any;
11
+ export declare function date(lang: string, dateToFormat: Date | string, options?: Intl.DateTimeFormatOptions): string;
12
+ export declare function number(lang: string, numberToFormat: number | string, options?: Intl.NumberFormatOptions): string;
13
+ export declare function relativeTime(lang: string, value: number, unit: Intl.RelativeTimeFormatUnit, options?: Intl.RelativeTimeFormatOptions): string;
14
+ export declare function updateLocalizedTerms(): void;
15
+ export declare class LocalizeController implements ReactiveController {
16
+ host: ReactiveControllerHost & HTMLElement;
17
+ constructor(host: ReactiveControllerHost & HTMLElement);
18
+ hostConnected(): void;
19
+ hostDisconnected(): void;
20
+ term<K extends keyof Translation>(key: K, ...args: FunctionParams<Translation[K]>): any;
21
+ date(dateToFormat: Date | string, options?: Intl.DateTimeFormatOptions): string;
22
+ number(numberToFormat: number | string, options?: Intl.NumberFormatOptions): string;
23
+ relativeTime(value: number, unit: Intl.RelativeTimeFormatUnit, options?: Intl.RelativeTimeFormatOptions): string;
24
+ }
package/dist/index.js CHANGED
@@ -1,20 +1,12 @@
1
- export const connectedElements = new Map();
2
- const documentElementObserver = new MutationObserver(() => forceUpdate());
1
+ const connectedElements = new Set();
2
+ const documentElementObserver = new MutationObserver(updateLocalizedTerms);
3
3
  const translations = new Map();
4
+ let documentLanguage = document.documentElement.lang || navigator.language;
4
5
  let fallback;
5
- function closest(selector, root = this) {
6
- function getNext(el, next = el && el.closest(selector)) {
7
- if (el instanceof Window || el instanceof Document || !el) {
8
- return null;
9
- }
10
- return next ? next : getNext(el.getRootNode().host);
11
- }
12
- return getNext(root);
13
- }
14
- export function detectLanguage(el) {
15
- const closestEl = closest('[lang]', el);
16
- return closestEl === null || closestEl === void 0 ? void 0 : closestEl.lang;
17
- }
6
+ documentElementObserver.observe(document.documentElement, {
7
+ attributes: true,
8
+ attributeFilter: ['lang']
9
+ });
18
10
  export function registerTranslation(...translation) {
19
11
  translation.map(t => {
20
12
  const code = t.$code.toLowerCase();
@@ -23,8 +15,9 @@ export function registerTranslation(...translation) {
23
15
  fallback = t;
24
16
  }
25
17
  });
18
+ updateLocalizedTerms();
26
19
  }
27
- export function translate(lang, key, ...args) {
20
+ export function term(lang, key, ...args) {
28
21
  const code = lang.toLowerCase().slice(0, 2);
29
22
  const subcode = lang.length > 2 ? lang.toLowerCase() : '';
30
23
  const primary = translations.get(subcode);
@@ -40,7 +33,7 @@ export function translate(lang, key, ...args) {
40
33
  term = fallback[key];
41
34
  }
42
35
  else {
43
- console.error(`Cannot find "${key}" to translate.`);
36
+ console.error(`No translation found for: ${key}`);
44
37
  return key;
45
38
  }
46
39
  if (typeof term === 'function') {
@@ -48,26 +41,46 @@ export function translate(lang, key, ...args) {
48
41
  }
49
42
  return term;
50
43
  }
51
- export function formatDate(lang, date, options) {
52
- date = new Date(date);
53
- return new Intl.DateTimeFormat(lang, options).format(date);
44
+ export function date(lang, dateToFormat, options) {
45
+ dateToFormat = new Date(dateToFormat);
46
+ return new Intl.DateTimeFormat(lang, options).format(dateToFormat);
47
+ }
48
+ export function number(lang, numberToFormat, options) {
49
+ numberToFormat = Number(numberToFormat);
50
+ return isNaN(numberToFormat) ? '' : new Intl.NumberFormat(lang, options).format(numberToFormat);
54
51
  }
55
- export function formatNumber(lang, number, options) {
56
- number = Number(number);
57
- return isNaN(number) ? '' : new Intl.NumberFormat(lang, options).format(number);
52
+ export function relativeTime(lang, value, unit, options) {
53
+ return new Intl.RelativeTimeFormat(lang, options).format(value, unit);
58
54
  }
59
- export function forceUpdate() {
60
- [...connectedElements.keys()].map(el => {
61
- const lang = detectLanguage(el);
62
- connectedElements.set(el, lang);
63
- if (typeof el.updateLocalizedTerms === 'function') {
64
- el.updateLocalizedTerms();
55
+ export function updateLocalizedTerms() {
56
+ documentLanguage = document.documentElement.lang || navigator.language;
57
+ [...connectedElements.keys()].map((el) => {
58
+ if (typeof el.requestUpdate === 'function') {
59
+ el.requestUpdate();
65
60
  }
66
61
  });
67
62
  }
68
- documentElementObserver.observe(document.documentElement, {
69
- attributes: true,
70
- attributeFilter: ['lang'],
71
- childList: true,
72
- subtree: true
73
- });
63
+ export class LocalizeController {
64
+ constructor(host) {
65
+ this.host = host;
66
+ this.host.addController(this);
67
+ }
68
+ hostConnected() {
69
+ connectedElements.add(this.host);
70
+ }
71
+ hostDisconnected() {
72
+ connectedElements.delete(this.host);
73
+ }
74
+ term(key, ...args) {
75
+ return term(this.host.lang || documentLanguage, key, ...args);
76
+ }
77
+ date(dateToFormat, options) {
78
+ return date(this.host.lang || documentLanguage, dateToFormat, options);
79
+ }
80
+ number(numberToFormat, options) {
81
+ return number(this.host.lang || documentLanguage, numberToFormat, options);
82
+ }
83
+ relativeTime(value, unit, options) {
84
+ return relativeTime(this.host.lang || documentLanguage, value, unit, options);
85
+ }
86
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shoelace-style/localize",
3
- "version": "1.1.6",
4
- "description": "A micro library for localizing custom elements.",
3
+ "version": "2.1.0",
4
+ "description": "A micro library for localizing custom elements using Lit's Reactive Controller model.",
5
5
  "main": "/dist/index.js",
6
6
  "module": "/dist/index.js",
7
7
  "type": "module",
@@ -37,7 +37,6 @@
37
37
  "typescript": "^4.4.3"
38
38
  },
39
39
  "dependencies": {
40
- "@microsoft/fast-element": "^1.5.1",
41
- "lit": "*"
40
+ "lit": "^2.0.2"
42
41
  }
43
42
  }
package/src/index.ts CHANGED
@@ -1,53 +1,53 @@
1
+ import { LitElement, ReactiveController, ReactiveControllerHost } from 'lit';
2
+
1
3
  export type FunctionParams<T> = T extends (...args: infer U) => string ? U : never;
2
4
 
3
5
  export interface Translation {
4
6
  $code: string; // e.g. en, en-GB
5
7
  $name: string; // e.g. English, Español
6
8
  $dir: 'ltr' | 'rtl';
7
-
8
9
  [key: string]: any;
9
10
  }
10
11
 
11
- export const connectedElements = new Map<HTMLElement, string>();
12
- const documentElementObserver = new MutationObserver(() => forceUpdate());
12
+ const connectedElements = new Set<HTMLElement>();
13
+ const documentElementObserver = new MutationObserver(updateLocalizedTerms);
13
14
  const translations: Map<string, Translation> = new Map();
15
+ let documentLanguage = document.documentElement.lang || navigator.language;
14
16
  let fallback: Translation;
15
17
 
16
- function closest(selector: string, root: Element = this) {
17
- function getNext(el: Element | HTMLElement, next = el && el.closest(selector)): Element | null {
18
- if (el instanceof Window || el instanceof Document || !el) {
19
- return null;
20
- }
21
-
22
- return next ? next : getNext((el.getRootNode() as ShadowRoot).host);
23
- }
24
-
25
- return getNext(root);
26
- }
27
-
28
- export function detectLanguage(el: HTMLElement) {
29
- const closestEl = closest('[lang]', el) as HTMLElement;
30
- return closestEl?.lang;
31
- }
18
+ // Watch for changes on <html lang>
19
+ documentElementObserver.observe(document.documentElement, {
20
+ attributes: true,
21
+ attributeFilter: ['lang']
22
+ });
32
23
 
24
+ //
25
+ // Registers one or more translations
26
+ //
33
27
  export function registerTranslation(...translation: Translation[]) {
34
28
  translation.map(t => {
35
29
  const code = t.$code.toLowerCase();
36
30
  translations.set(code, t);
37
31
 
38
- // Use the first translation that's registered as the fallback
32
+ // The first translation that's registered is the fallback
39
33
  if (!fallback) {
40
34
  fallback = t;
41
35
  }
42
36
  });
37
+
38
+ updateLocalizedTerms();
43
39
  }
44
40
 
45
- export function translate<K extends keyof Translation>(lang: string, key: K, ...args: FunctionParams<Translation[K]>) {
41
+ //
42
+ // Translates a term using the specified locale. Looks up translations in order of language + country codes (es-PE),
43
+ // language code (es), then the fallback translation.
44
+ //
45
+ export function term<K extends keyof Translation>(lang: string, key: K, ...args: FunctionParams<Translation[K]>) {
46
46
  const code = lang.toLowerCase().slice(0, 2); // e.g. en
47
- const subcode = lang.length > 2 ? lang.toLowerCase() : ''; // e.g. en-US
47
+ const subcode = lang.length > 2 ? lang.toLowerCase() : ''; // e.g. en-GB
48
48
  const primary = translations.get(subcode);
49
49
  const secondary = translations.get(code);
50
- let term;
50
+ let term: any;
51
51
 
52
52
  // Look for a matching term using subcode, code, then the fallback
53
53
  if (primary && primary[key]) {
@@ -57,42 +57,105 @@ export function translate<K extends keyof Translation>(lang: string, key: K, ...
57
57
  } else if (fallback && fallback[key]) {
58
58
  term = fallback[key];
59
59
  } else {
60
- console.error(`Cannot find "${key}" to translate.`);
60
+ console.error(`No translation found for: ${key}`);
61
61
  return key;
62
62
  }
63
63
 
64
64
  if (typeof term === 'function') {
65
- return term(...args);
65
+ return term(...args) as string;
66
66
  }
67
67
 
68
68
  return term;
69
69
  }
70
70
 
71
- export function formatDate(lang: string, date: Date | string, options?: Intl.DateTimeFormatOptions) {
72
- date = new Date(date);
73
- return new Intl.DateTimeFormat(lang, options).format(date);
71
+ //
72
+ // Formats a date using the specified locale.
73
+ //
74
+ export function date(lang: string, dateToFormat: Date | string, options?: Intl.DateTimeFormatOptions) {
75
+ dateToFormat = new Date(dateToFormat);
76
+ return new Intl.DateTimeFormat(lang, options).format(dateToFormat);
77
+ }
78
+
79
+ //
80
+ // Formats a number using the specified locale.
81
+ //
82
+ export function number(lang: string, numberToFormat: number | string, options?: Intl.NumberFormatOptions) {
83
+ numberToFormat = Number(numberToFormat);
84
+ return isNaN(numberToFormat) ? '' : new Intl.NumberFormat(lang, options).format(numberToFormat);
74
85
  }
75
86
 
76
- export function formatNumber(lang: string, number: number | string, options?: Intl.NumberFormatOptions) {
77
- number = Number(number);
78
- return isNaN(number) ? '' : new Intl.NumberFormat(lang, options).format(number);
87
+ //
88
+ // Formats a relative date using the specified locale.
89
+ //
90
+ export function relativeTime(
91
+ lang: string,
92
+ value: number,
93
+ unit: Intl.RelativeTimeFormatUnit,
94
+ options?: Intl.RelativeTimeFormatOptions
95
+ ) {
96
+ return new Intl.RelativeTimeFormat(lang, options).format(value, unit);
79
97
  }
80
98
 
81
- export function forceUpdate() {
82
- [...connectedElements.keys()].map(el => {
83
- const lang = detectLanguage(el);
84
- connectedElements.set(el, lang);
99
+ //
100
+ // Updates the locale for all localized elements that are currently connected
101
+ //
102
+ export function updateLocalizedTerms() {
103
+ documentLanguage = document.documentElement.lang || navigator.language;
85
104
 
86
- if (typeof (el as any).updateLocalizedTerms === 'function') {
87
- (el as any).updateLocalizedTerms();
105
+ [...connectedElements.keys()].map((el: LitElement) => {
106
+ if (typeof el.requestUpdate === 'function') {
107
+ el.requestUpdate();
88
108
  }
89
109
  });
90
110
  }
91
111
 
92
- // Update connected elements when a lang attribute changes
93
- documentElementObserver.observe(document.documentElement, {
94
- attributes: true,
95
- attributeFilter: ['lang'],
96
- childList: true,
97
- subtree: true
98
- });
112
+ //
113
+ // Reactive controller
114
+ //
115
+ // To use this controller, import the class and instantiate it in a custom element constructor:
116
+ //
117
+ // private localize = new LocalizeController(this);
118
+ //
119
+ // This will add the element to the set and make it respond to changes to <html lang> automatically. To make it respond
120
+ // to changes to its own lang property, make it a property:
121
+ //
122
+ // @property() lang: string;
123
+ //
124
+ // To use a translation method, call it like this:
125
+ //
126
+ // ${this.localize.term('term_key_here')}
127
+ // ${this.localize.date('2021-12-03')}
128
+ // ${this.localize.number(1000000)}
129
+ //
130
+ export class LocalizeController implements ReactiveController {
131
+ host: ReactiveControllerHost & HTMLElement;
132
+
133
+ constructor(host: ReactiveControllerHost & HTMLElement) {
134
+ this.host = host;
135
+ this.host.addController(this);
136
+ }
137
+
138
+ hostConnected() {
139
+ connectedElements.add(this.host);
140
+ }
141
+
142
+ hostDisconnected() {
143
+ connectedElements.delete(this.host);
144
+ }
145
+
146
+ term<K extends keyof Translation>(key: K, ...args: FunctionParams<Translation[K]>) {
147
+ return term(this.host.lang || documentLanguage, key, ...args);
148
+ }
149
+
150
+ date(dateToFormat: Date | string, options?: Intl.DateTimeFormatOptions) {
151
+ return date(this.host.lang || documentLanguage, dateToFormat, options);
152
+ }
153
+
154
+ number(numberToFormat: number | string, options?: Intl.NumberFormatOptions) {
155
+ return number(this.host.lang || documentLanguage, numberToFormat, options);
156
+ }
157
+
158
+ relativeTime(value: number, unit: Intl.RelativeTimeFormatUnit, options?: Intl.RelativeTimeFormatOptions) {
159
+ return relativeTime(this.host.lang || documentLanguage, value, unit, options);
160
+ }
161
+ }
package/dist/fast.d.ts DELETED
@@ -1,6 +0,0 @@
1
- import type { CaptureType, ExecutionContext, FASTElement } from '@microsoft/fast-element';
2
- import type { FunctionParams, Translation } from './';
3
- export declare function localize(): (targetClass: any) => any;
4
- export declare function translate<TSource, K extends keyof Translation>(context: ExecutionContext | FASTElement | HTMLElement, key: K, ...args: FunctionParams<Translation[K]>): CaptureType<TSource>;
5
- export declare function formatDate<TSource = any>(context: ExecutionContext | FASTElement | HTMLElement, date: Date | string, options?: Intl.DateTimeFormatOptions): CaptureType<TSource>;
6
- export declare function formatNumber<TSource = any>(context: ExecutionContext | FASTElement | HTMLElement, number: number | string, options?: Intl.DateTimeFormatOptions): CaptureType<TSource>;
package/dist/fast.js DELETED
@@ -1,51 +0,0 @@
1
- import { connectedElements, detectLanguage, translate as t, formatDate as d, formatNumber as n } from './';
2
- export function localize() {
3
- return (targetClass) => {
4
- return class extends targetClass {
5
- constructor() {
6
- super();
7
- }
8
- connectedCallback() {
9
- super.connectedCallback();
10
- const lang = detectLanguage(this);
11
- connectedElements.set(this, lang);
12
- }
13
- disconnectedCallback() {
14
- super.disconnectedCallback();
15
- connectedElements.delete(this);
16
- }
17
- get lang() {
18
- return this.getAttribute('lang');
19
- }
20
- set lang(value) {
21
- if (value === null || value === undefined) {
22
- this.removeAttribute('lang');
23
- }
24
- else {
25
- this.setAttribute('lang', value);
26
- }
27
- connectedElements.set(this, value);
28
- this.updateLocalizedTerms();
29
- }
30
- updateLocalizedTerms() {
31
- this.$fastController.renderTemplate(this.$fastController.template);
32
- }
33
- };
34
- };
35
- }
36
- function getLang(source) {
37
- let lang = '';
38
- if (source instanceof HTMLElement) {
39
- lang = connectedElements.get(source) || '';
40
- }
41
- return lang;
42
- }
43
- export function translate(context, key, ...args) {
44
- return t(getLang(context), key, ...args);
45
- }
46
- export function formatDate(context, date, options) {
47
- return d(getLang(context), date, options);
48
- }
49
- export function formatNumber(context, number, options) {
50
- return n(getLang(context), number, options);
51
- }
package/dist/lit.d.ts DELETED
@@ -1,22 +0,0 @@
1
- import { Directive, Part } from 'lit/directive.js';
2
- import type { FunctionParams, Translation } from './';
3
- export declare function localize(): (targetClass: any) => any;
4
- declare class TranslateDirective extends Directive {
5
- host: HTMLElement | undefined;
6
- constructor(part: Part);
7
- render<K extends keyof Translation>(key: K, ...args: FunctionParams<Translation[K]>): any;
8
- }
9
- export declare function translate<K extends keyof Translation>(key: K, ...args: FunctionParams<Translation[K]>): import("lit-html/directive").DirectiveResult<typeof TranslateDirective>;
10
- declare class FormatDateDirective extends Directive {
11
- host: HTMLElement | undefined;
12
- constructor(part: Part);
13
- render(date: Date | string, options?: Intl.DateTimeFormatOptions): string;
14
- }
15
- export declare const formatDate: (date: string | Date, options?: Intl.DateTimeFormatOptions | undefined) => import("lit-html/directive").DirectiveResult<typeof FormatDateDirective>;
16
- declare class FormatNumberDirective extends Directive {
17
- host: HTMLElement | undefined;
18
- constructor(part: Part);
19
- render(number: number | string, options?: Intl.NumberFormatOptions): string;
20
- }
21
- export declare const formatNumber: (number: string | number, options?: Intl.NumberFormatOptions | undefined) => import("lit-html/directive").DirectiveResult<typeof FormatNumberDirective>;
22
- export {};
package/dist/lit.js DELETED
@@ -1,62 +0,0 @@
1
- import { Directive, directive } from 'lit/directive.js';
2
- import { connectedElements, detectLanguage, formatDate as d, formatNumber as n, translate as t } from './';
3
- export function localize() {
4
- return (targetClass) => {
5
- return class extends targetClass {
6
- static get properties() {
7
- return Object.assign({ lang: { type: String } }, targetClass.properties);
8
- }
9
- connectedCallback() {
10
- super.connectedCallback();
11
- const lang = detectLanguage(this);
12
- connectedElements.set(this, lang);
13
- }
14
- disconnectedCallback() {
15
- super.disconnectedCallback();
16
- connectedElements.delete(this);
17
- }
18
- updateLocalizedTerms() {
19
- this.requestUpdate();
20
- }
21
- };
22
- };
23
- }
24
- class TranslateDirective extends Directive {
25
- constructor(part) {
26
- var _a;
27
- super(part);
28
- this.host = (_a = part.options) === null || _a === void 0 ? void 0 : _a.host;
29
- }
30
- render(key, ...args) {
31
- const lang = connectedElements.get(this.host) || '';
32
- return t(lang, key, ...args);
33
- }
34
- }
35
- const litDirective = directive(TranslateDirective);
36
- export function translate(key, ...args) {
37
- return litDirective(key, ...args);
38
- }
39
- class FormatDateDirective extends Directive {
40
- constructor(part) {
41
- var _a;
42
- super(part);
43
- this.host = (_a = part.options) === null || _a === void 0 ? void 0 : _a.host;
44
- }
45
- render(date, options) {
46
- const lang = connectedElements.get(this.host) || '';
47
- return d(lang, date, options);
48
- }
49
- }
50
- export const formatDate = directive(FormatDateDirective);
51
- class FormatNumberDirective extends Directive {
52
- constructor(part) {
53
- var _a;
54
- super(part);
55
- this.host = (_a = part.options) === null || _a === void 0 ? void 0 : _a.host;
56
- }
57
- render(number, options) {
58
- const lang = connectedElements.get(this.host) || '';
59
- return n(lang, number, options);
60
- }
61
- }
62
- export const formatNumber = directive(FormatNumberDirective);
package/src/fast.ts DELETED
@@ -1,107 +0,0 @@
1
- import { connectedElements, detectLanguage, translate as t, formatDate as d, formatNumber as n } from './';
2
- import type { CaptureType, ExecutionContext, FASTElement } from '@microsoft/fast-element';
3
- import type { FunctionParams, Translation } from './';
4
-
5
- /**
6
- * FAST Decorator
7
- *
8
- * This class decorator ensures lang is a reactive property and adds and removes the component to and from the
9
- * connectedElements set.
10
- */
11
- export function localize() {
12
- return (targetClass: any): typeof targetClass => {
13
- return class extends targetClass {
14
- constructor() {
15
- super();
16
- }
17
-
18
- connectedCallback() {
19
- super.connectedCallback();
20
-
21
- const lang = detectLanguage(this as typeof targetClass);
22
- connectedElements.set(this as typeof targetClass, lang);
23
- }
24
-
25
- disconnectedCallback() {
26
- super.disconnectedCallback();
27
- connectedElements.delete(this as typeof targetClass);
28
- }
29
-
30
- get lang() {
31
- return this.getAttribute('lang');
32
- }
33
-
34
- set lang(value: string) {
35
- if (value === null || value === undefined) {
36
- this.removeAttribute('lang');
37
- } else {
38
- this.setAttribute('lang', value);
39
- }
40
-
41
- connectedElements.set(this as typeof targetClass, value);
42
- this.updateLocalizedTerms();
43
- }
44
-
45
- updateLocalizedTerms() {
46
- //
47
- // This is a hacky way to force a re-render, but there's no other way to achieve this at the moment. This issue
48
- // tracks a proposal to introduce signals that will allow external changes to trigger updates.
49
- //
50
- // https://github.com/microsoft/fast/issues/5083
51
- //
52
- this.$fastController.renderTemplate(this.$fastController.template);
53
- }
54
- };
55
- };
56
- }
57
-
58
- function getLang(source: any) {
59
- let lang = '';
60
-
61
- // A directive may be called from any context. In some cases, such as when contexts are nested, we won't have a
62
- // reference to the host element. In that case, we can't provide a value for lang so we return an empty string.
63
- if (source instanceof HTMLElement) {
64
- lang = connectedElements.get(source) || '';
65
- }
66
-
67
- return lang;
68
- }
69
-
70
- /**
71
- * FAST format number directive
72
- *
73
- * Formats a number using the element's current language.
74
- */
75
- export function translate<TSource, K extends keyof Translation>(
76
- context: ExecutionContext | FASTElement | HTMLElement,
77
- key: K,
78
- ...args: FunctionParams<Translation[K]>
79
- ): CaptureType<TSource> {
80
- return t(getLang(context), key, ...args);
81
- }
82
-
83
- /**
84
- * FAST format date directive
85
- *
86
- * Formats a date using the element's current language.
87
- */
88
- export function formatDate<TSource = any>(
89
- context: ExecutionContext | FASTElement | HTMLElement,
90
- date: Date | string,
91
- options?: Intl.DateTimeFormatOptions
92
- ): CaptureType<TSource> {
93
- return d(getLang(context), date, options);
94
- }
95
-
96
- /**
97
- * FAST format number directive
98
- *
99
- * Formats a number using the element's current language.
100
- */
101
- export function formatNumber<TSource = any>(
102
- context: ExecutionContext | FASTElement | HTMLElement,
103
- number: number | string,
104
- options?: Intl.DateTimeFormatOptions
105
- ): CaptureType<TSource> {
106
- return n(getLang(context), number, options);
107
- }
package/src/lit.ts DELETED
@@ -1,108 +0,0 @@
1
- import { Directive, directive, Part } from 'lit/directive.js';
2
- import { connectedElements, detectLanguage, formatDate as d, formatNumber as n, translate as t } from './';
3
-
4
- import type { FunctionParams, Translation } from './';
5
- import type { LitElement } from 'lit';
6
-
7
- /**
8
- * Lit Decorator
9
- *
10
- * This class decorator ensures lang is a reactive property and adds and removes the component to and from the
11
- * connectedElements set.
12
- */
13
- export function localize() {
14
- return (targetClass: any): typeof targetClass => {
15
- return class extends targetClass {
16
- static get properties() {
17
- return {
18
- // Ensure lang is a watched property
19
- lang: { type: String },
20
- ...targetClass.properties
21
- };
22
- }
23
-
24
- connectedCallback() {
25
- super.connectedCallback();
26
-
27
- const lang = detectLanguage(this as typeof targetClass);
28
- connectedElements.set(this as typeof targetClass, lang);
29
- }
30
-
31
- disconnectedCallback() {
32
- super.disconnectedCallback();
33
- connectedElements.delete(this as typeof targetClass);
34
- }
35
-
36
- updateLocalizedTerms() {
37
- this.requestUpdate();
38
- }
39
- };
40
- };
41
- }
42
-
43
- /**
44
- * Lit translate directive
45
- *
46
- * Translates a term using the element's current language.
47
- */
48
- class TranslateDirective extends Directive {
49
- host: HTMLElement | undefined;
50
-
51
- constructor(part: Part) {
52
- super(part);
53
- this.host = part.options?.host as HTMLElement;
54
- }
55
-
56
- render<K extends keyof Translation>(key: K, ...args: FunctionParams<Translation[K]>) {
57
- const lang = connectedElements.get(this.host as LitElement) || '';
58
- return t(lang, key, ...args);
59
- }
60
- }
61
-
62
- const litDirective = directive(TranslateDirective);
63
-
64
- export function translate<K extends keyof Translation>(key: K, ...args: FunctionParams<Translation[K]>) {
65
- return litDirective(key, ...args);
66
- }
67
-
68
- /**
69
- * Lit format date directive
70
- *
71
- * Formats a date using the element's current language.
72
- */
73
- class FormatDateDirective extends Directive {
74
- host: HTMLElement | undefined;
75
-
76
- constructor(part: Part) {
77
- super(part);
78
- this.host = part.options?.host as HTMLElement;
79
- }
80
-
81
- render(date: Date | string, options?: Intl.DateTimeFormatOptions) {
82
- const lang = connectedElements.get(this.host as LitElement) || '';
83
- return d(lang, date, options);
84
- }
85
- }
86
-
87
- export const formatDate = directive(FormatDateDirective);
88
-
89
- /**
90
- * Lit format number directive
91
- *
92
- * Formats a number using the element's current language.
93
- */
94
- class FormatNumberDirective extends Directive {
95
- host: HTMLElement | undefined;
96
-
97
- constructor(part: Part) {
98
- super(part);
99
- this.host = part.options?.host as HTMLElement;
100
- }
101
-
102
- render(number: number | string, options?: Intl.NumberFormatOptions) {
103
- const lang = connectedElements.get(this.host as LitElement) || '';
104
- return n(lang, number, options);
105
- }
106
- }
107
-
108
- export const formatNumber = directive(FormatNumberDirective);