@shoelace-style/localize 2.2.1 → 3.0.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 CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.0.0
4
+
5
+ - 🚨BREAKING: Removed top level `term()`, `date()`, `number()`, and `relativeTime()` functions
6
+ - Refactored `LocalizeController.term()` to allow strong typings by extending the controller and default translation (see "Typed Translations and Arguments" in the readme for details)
7
+
3
8
  ## 2.2.1
4
9
 
5
10
  - Fixed a bug that prevented updates from happening when `<html dir>` changed
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Shoelace: Localize
2
2
 
3
- This zero-dependency 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.
3
+ This zero-dependency 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, [Reactive Controller](https://lit.dev/docs/composition/controllers/) 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 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.
5
+ Reactive Controllers are supported by Lit 2 out of the box, but they're designed to be generic so other libraries can elect to support them either natively or through an adapter. If you're favorite custom element authoring library doesn't support Reactive Controllers yet, consider asking the maintainers to add support for them!
6
6
 
7
7
  ## Overview
8
8
 
@@ -11,7 +11,7 @@ Here's an example of how this library can be used to create a localized custom e
11
11
  ```ts
12
12
  import { LocalizeController, registerTranslation } from '@shoelace-style/localize';
13
13
 
14
- // Translations can also be loaded outside of the component and/or on the fly using dynamic imports
14
+ // Note: translations can also be lazy loaded (see "Registering Translations" below)
15
15
  import en from '../translations/en';
16
16
  import es from '../translations/es';
17
17
 
@@ -46,9 +46,10 @@ Changes to `<html lang>` will trigger an update to all localized components auto
46
46
  It's not uncommon for a custom element to require localization, but implementing it at the component level is challenging. For example, how should we provide a translation for this close button that exists in a custom element's shadow root?
47
47
 
48
48
  ```html
49
- <button type="button" aria-label="Close">
50
- <svg>...</svg>
51
- </button>
49
+ #shadow-root
50
+ <button type="button" aria-label="Close">
51
+ <svg>...</svg>
52
+ </button>
52
53
  ```
53
54
 
54
55
  Typically, custom element authors dance around the problem by exposing attributes or properties for such purposes.
@@ -81,6 +82,8 @@ This library provides a set of tools to localize dates, currencies, numbers, and
81
82
 
82
83
  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.
83
84
 
85
+ Fortunately, the majority of use cases appear to favor a single language per page. However, multiple languages per page are also supported, but you'll need to explicitly set the `lang` attribute on all components whose language differs from the one set in `<html lang>`.
86
+
84
87
  ## Usage
85
88
 
86
89
  First, install the library.
@@ -112,10 +115,10 @@ const translation: Translation = {
112
115
  upload: 'Upload',
113
116
 
114
117
  // Terms with placeholders
115
- greet_user: (name: string) => `Hello, ${name}!`,
118
+ greetUser: (name: string) => `Hello, ${name}!`,
116
119
 
117
120
  // Plurals
118
- num_files_selected: (count: number) => {
121
+ numFilesSelected: (count: number) => {
119
122
  if (count === 0) return 'No files selected';
120
123
  if (count === 1) return '1 file selected';
121
124
  return `${count} files selected`;
@@ -162,7 +165,7 @@ async function changeLanguage(lang) {
162
165
 
163
166
  ### Localizing Components
164
167
 
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.
168
+ You can use the `LocalizeController` with any library that supports [Lit's Reactive Controller pattern](https://lit.dev/docs/composition/controllers/). In Lit, a localized custom element will look something like this.
166
169
 
167
170
  ```ts
168
171
  import { LitElement } from 'lit';
@@ -173,19 +176,23 @@ import { LocalizeController } from '@shoelace-style/localize/dist/lit.js';
173
176
  export class MyElement extends LitElement {
174
177
  private localize = new LocalizeController(this);
175
178
 
179
+ // Make sure to define `lang` so the component will respond to changes to its own lang attribute
176
180
  @property() lang: string;
177
181
 
178
182
  render() {
179
183
  return html`
180
- <!-- Term -->
184
+ <!-- Terms -->
181
185
  ${this.localize.term('hello')}
182
186
 
183
- <!-- Date -->
187
+ <!-- Dates -->
184
188
  ${this.localize.date('2021-09-15 14:00:00 ET'), { month: 'long', day: 'numeric', year: 'numeric' }}
185
189
 
186
- <!-- Number/currency -->
190
+ <!-- Numbers/currency -->
187
191
  ${this.localize.number(1000, { style: 'currency', currency: 'USD'})}
188
192
 
193
+ <!-- Determining language -->
194
+ ${this.localize.lang()}
195
+
189
196
  <!-- Determining directionality, e.g. 'ltr' or 'rtl' -->
190
197
  ${this.localize.dir()}
191
198
  `;
@@ -193,20 +200,42 @@ export class MyElement extends LitElement {
193
200
  }
194
201
  ```
195
202
 
203
+ ## Typed Translations and Arguments
204
+
205
+ Because translations are defined by the user, there's no way for TypeScript to automatically know about the terms you've defined. This means you won't get strongly typed arguments when calling `this.localize.term()`. However, you can solve this by extending `Translation` and `LocalizeController`.
206
+
207
+ In a separate file, e.g. `my-localize.ts`, add the following code.
208
+
209
+ ```ts
210
+ import { LocalizeController as DefaultLocalizeController } from '@shoelace-style/localize';
211
+
212
+ // Extend the default controller with your custom translation
213
+ export class LocalizeController extends DefaultLocalizeController<MyTranslation> {}
214
+
215
+ // Export `registerTranslation` so you can import everything from this file
216
+ export { registerTranslation } from '@shoelace-style/localize';
217
+
218
+ // Define your translation terms here
219
+ export interface MyTranslation extends Translation {
220
+ myTerm: string;
221
+ myOtherTerm: string;
222
+ myTermWithArgs: (count: string) => string;
223
+ }
224
+ ```
225
+
226
+ Now you can import `MyLocalizeController` and get strongly typed translations when you use `this.localize.term()`!
227
+
196
228
  ## Advantages
197
229
 
230
+ - Zero dependencies
198
231
  - Extremely lightweight
199
- - Zero dependencies
200
- - Version 2.1 measures 726 bytes (yes, _bytes_) after minify + gzip
201
- - Uses existing platform features
202
232
  - Supports simple terms, plurals, and complex translations
203
- - Fun fact: some languages have [six plural forms](https://lingohub.com/blog/2019/02/pluralization) and this will support that
204
- - Supports dates, numbers, and currencies
233
+ - Fun fact: some languages have [six plural forms](https://lingohub.com/blog/2019/02/pluralization) and this utility supports that
234
+ - Supports dates, numbers, and currencies using built-in [`Intl` APIs](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl)
205
235
  - Good DX for custom element authors and consumers
206
236
  - Intuitive API for custom element authors
207
237
  - Consumers only need to load the translations they want and set the `lang` attribute
208
238
  - Translations can be loaded up front or on demand
209
- - Translations can be created by consumers without having to wait for them to get accepted upstream
210
239
 
211
240
  ## Disadvantages
212
241
 
package/dist/index.d.ts CHANGED
@@ -4,22 +4,20 @@ export interface Translation {
4
4
  $code: string;
5
5
  $name: string;
6
6
  $dir: 'ltr' | 'rtl';
7
+ }
8
+ export interface DefaultTranslation extends Translation {
7
9
  [key: string]: any;
8
10
  }
9
11
  export declare function registerTranslation(...translation: Translation[]): 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
12
  export declare function update(): void;
15
- export declare class LocalizeController implements ReactiveController {
13
+ export declare class LocalizeController<UserTranslation extends Translation = DefaultTranslation> implements ReactiveController {
16
14
  host: ReactiveControllerHost & HTMLElement;
17
15
  constructor(host: ReactiveControllerHost & HTMLElement);
18
16
  hostConnected(): void;
19
17
  hostDisconnected(): void;
20
18
  dir(): string;
21
19
  lang(): string;
22
- term<K extends keyof Translation>(key: K, ...args: FunctionParams<Translation[K]>): any;
20
+ term<K extends keyof UserTranslation>(key: K, ...args: FunctionParams<UserTranslation[K]>): any;
23
21
  date(dateToFormat: Date | string, options?: Intl.DateTimeFormatOptions): string;
24
22
  number(numberToFormat: number | string, options?: Intl.NumberFormatOptions): string;
25
23
  relativeTime(value: number, unit: Intl.RelativeTimeFormatUnit, options?: Intl.RelativeTimeFormatOptions): string;
package/dist/index.js CHANGED
@@ -11,48 +11,18 @@ documentElementObserver.observe(document.documentElement, {
11
11
  export function registerTranslation(...translation) {
12
12
  translation.map(t => {
13
13
  const code = t.$code.toLowerCase();
14
- translations.set(code, t);
14
+ if (translations.has(code)) {
15
+ translations.set(code, Object.assign(Object.assign({}, translations.get(code)), t));
16
+ }
17
+ else {
18
+ translations.set(code, t);
19
+ }
15
20
  if (!fallback) {
16
21
  fallback = t;
17
22
  }
18
23
  });
19
24
  update();
20
25
  }
21
- export function term(lang, key, ...args) {
22
- const code = lang.toLowerCase().slice(0, 2);
23
- const subcode = lang.length > 2 ? lang.toLowerCase() : '';
24
- const primary = translations.get(subcode);
25
- const secondary = translations.get(code);
26
- let term;
27
- if (primary && primary[key]) {
28
- term = primary[key];
29
- }
30
- else if (secondary && secondary[key]) {
31
- term = secondary[key];
32
- }
33
- else if (fallback && fallback[key]) {
34
- term = fallback[key];
35
- }
36
- else {
37
- console.error(`No translation found for: ${key}`);
38
- return key;
39
- }
40
- if (typeof term === 'function') {
41
- return term(...args);
42
- }
43
- return term;
44
- }
45
- export function date(lang, dateToFormat, options) {
46
- dateToFormat = new Date(dateToFormat);
47
- return new Intl.DateTimeFormat(lang, options).format(dateToFormat);
48
- }
49
- export function number(lang, numberToFormat, options) {
50
- numberToFormat = Number(numberToFormat);
51
- return isNaN(numberToFormat) ? '' : new Intl.NumberFormat(lang, options).format(numberToFormat);
52
- }
53
- export function relativeTime(lang, value, unit, options) {
54
- return new Intl.RelativeTimeFormat(lang, options).format(value, unit);
55
- }
56
26
  export function update() {
57
27
  documentDirection = document.documentElement.dir || 'ltr';
58
28
  documentLanguage = document.documentElement.lang || navigator.language;
@@ -80,15 +50,38 @@ export class LocalizeController {
80
50
  return `${this.host.lang || documentLanguage}`.toLowerCase();
81
51
  }
82
52
  term(key, ...args) {
83
- return term(this.host.lang || documentLanguage, key, ...args);
53
+ const code = this.lang().toLowerCase().slice(0, 2);
54
+ const regionCode = this.lang().length > 2 ? this.lang().toLowerCase() : '';
55
+ const primary = translations.get(regionCode);
56
+ const secondary = translations.get(code);
57
+ let term;
58
+ if (primary && primary[key]) {
59
+ term = primary[key];
60
+ }
61
+ else if (secondary && secondary[key]) {
62
+ term = secondary[key];
63
+ }
64
+ else if (fallback && fallback[key]) {
65
+ term = fallback[key];
66
+ }
67
+ else {
68
+ console.error(`No translation found for: ${String(key)}`);
69
+ return key;
70
+ }
71
+ if (typeof term === 'function') {
72
+ return term(...args);
73
+ }
74
+ return term;
84
75
  }
85
76
  date(dateToFormat, options) {
86
- return date(this.host.lang || documentLanguage, dateToFormat, options);
77
+ dateToFormat = new Date(dateToFormat);
78
+ return new Intl.DateTimeFormat(this.lang(), options).format(dateToFormat);
87
79
  }
88
80
  number(numberToFormat, options) {
89
- return number(this.host.lang || documentLanguage, numberToFormat, options);
81
+ numberToFormat = Number(numberToFormat);
82
+ return isNaN(numberToFormat) ? '' : new Intl.NumberFormat(this.lang(), options).format(numberToFormat);
90
83
  }
91
84
  relativeTime(value, unit, options) {
92
- return relativeTime(this.host.lang || documentLanguage, value, unit, options);
85
+ return new Intl.RelativeTimeFormat(this.lang(), options).format(value, unit);
93
86
  }
94
87
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shoelace-style/localize",
3
- "version": "2.2.1",
3
+ "version": "3.0.0",
4
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",
package/src/index.ts CHANGED
@@ -6,6 +6,9 @@ export interface Translation {
6
6
  $code: string; // e.g. en, en-GB
7
7
  $name: string; // e.g. English, Español
8
8
  $dir: 'ltr' | 'rtl';
9
+ }
10
+
11
+ export interface DefaultTranslation extends Translation {
9
12
  [key: string]: any;
10
13
  }
11
14
 
@@ -28,7 +31,13 @@ documentElementObserver.observe(document.documentElement, {
28
31
  export function registerTranslation(...translation: Translation[]) {
29
32
  translation.map(t => {
30
33
  const code = t.$code.toLowerCase();
31
- translations.set(code, t);
34
+
35
+ if (translations.has(code)) {
36
+ // Merge translations that share the same language code
37
+ translations.set(code, { ...translations.get(code), ...t });
38
+ } else {
39
+ translations.set(code, t);
40
+ }
32
41
 
33
42
  // The first translation that's registered is the fallback
34
43
  if (!fallback) {
@@ -39,64 +48,6 @@ export function registerTranslation(...translation: Translation[]) {
39
48
  update();
40
49
  }
41
50
 
42
- //
43
- // Translates a term using the specified locale. Looks up translations in order of language + country codes (es-PE),
44
- // language code (es), then the fallback translation.
45
- //
46
- export function term<K extends keyof Translation>(lang: string, key: K, ...args: FunctionParams<Translation[K]>) {
47
- const code = lang.toLowerCase().slice(0, 2); // e.g. en
48
- const subcode = lang.length > 2 ? lang.toLowerCase() : ''; // e.g. en-GB
49
- const primary = translations.get(subcode);
50
- const secondary = translations.get(code);
51
- let term: any;
52
-
53
- // Look for a matching term using subcode, code, then the fallback
54
- if (primary && primary[key]) {
55
- term = primary[key];
56
- } else if (secondary && secondary[key]) {
57
- term = secondary[key];
58
- } else if (fallback && fallback[key]) {
59
- term = fallback[key];
60
- } else {
61
- console.error(`No translation found for: ${key}`);
62
- return key;
63
- }
64
-
65
- if (typeof term === 'function') {
66
- return term(...args) as string;
67
- }
68
-
69
- return term;
70
- }
71
-
72
- //
73
- // Formats a date using the specified locale.
74
- //
75
- export function date(lang: string, dateToFormat: Date | string, options?: Intl.DateTimeFormatOptions) {
76
- dateToFormat = new Date(dateToFormat);
77
- return new Intl.DateTimeFormat(lang, options).format(dateToFormat);
78
- }
79
-
80
- //
81
- // Formats a number using the specified locale.
82
- //
83
- export function number(lang: string, numberToFormat: number | string, options?: Intl.NumberFormatOptions) {
84
- numberToFormat = Number(numberToFormat);
85
- return isNaN(numberToFormat) ? '' : new Intl.NumberFormat(lang, options).format(numberToFormat);
86
- }
87
-
88
- //
89
- // Formats a relative date using the specified locale.
90
- //
91
- export function relativeTime(
92
- lang: string,
93
- value: number,
94
- unit: Intl.RelativeTimeFormatUnit,
95
- options?: Intl.RelativeTimeFormatOptions
96
- ) {
97
- return new Intl.RelativeTimeFormat(lang, options).format(value, unit);
98
- }
99
-
100
51
  //
101
52
  // Updates all localized elements that are currently connected
102
53
  //
@@ -130,7 +81,9 @@ export function update() {
130
81
  // ${this.localize.date('2021-12-03')}
131
82
  // ${this.localize.number(1000000)}
132
83
  //
133
- export class LocalizeController implements ReactiveController {
84
+ export class LocalizeController<UserTranslation extends Translation = DefaultTranslation>
85
+ implements ReactiveController
86
+ {
134
87
  host: ReactiveControllerHost & HTMLElement;
135
88
 
136
89
  constructor(host: ReactiveControllerHost & HTMLElement) {
@@ -162,23 +115,46 @@ export class LocalizeController implements ReactiveController {
162
115
  return `${this.host.lang || documentLanguage}`.toLowerCase();
163
116
  }
164
117
 
165
- term<K extends keyof Translation>(key: K, ...args: FunctionParams<Translation[K]>) {
166
- /** Outputs a localized term. */
167
- return term(this.host.lang || documentLanguage, key, ...args);
118
+ term<K extends keyof UserTranslation>(key: K, ...args: FunctionParams<UserTranslation[K]>) {
119
+ const code = this.lang().toLowerCase().slice(0, 2); // e.g. en
120
+ const regionCode = this.lang().length > 2 ? this.lang().toLowerCase() : ''; // e.g. en-gb
121
+ const primary = <UserTranslation>translations.get(regionCode);
122
+ const secondary = <UserTranslation>translations.get(code);
123
+ let term: any;
124
+
125
+ // Look for a matching term using regionCode, code, then the fallback
126
+ if (primary && primary[key]) {
127
+ term = primary[key];
128
+ } else if (secondary && secondary[key]) {
129
+ term = secondary[key];
130
+ } else if (fallback && fallback[key as keyof Translation]) {
131
+ term = fallback[key as keyof Translation];
132
+ } else {
133
+ console.error(`No translation found for: ${String(key)}`);
134
+ return key;
135
+ }
136
+
137
+ if (typeof term === 'function') {
138
+ return term(...args) as string;
139
+ }
140
+
141
+ return term;
168
142
  }
169
143
 
170
144
  /** Outputs a localized date in the specified format. */
171
145
  date(dateToFormat: Date | string, options?: Intl.DateTimeFormatOptions) {
172
- return date(this.host.lang || documentLanguage, dateToFormat, options);
146
+ dateToFormat = new Date(dateToFormat);
147
+ return new Intl.DateTimeFormat(this.lang(), options).format(dateToFormat);
173
148
  }
174
149
 
175
150
  /** Outputs a localized number in the specified format. */
176
151
  number(numberToFormat: number | string, options?: Intl.NumberFormatOptions) {
177
- return number(this.host.lang || documentLanguage, numberToFormat, options);
152
+ numberToFormat = Number(numberToFormat);
153
+ return isNaN(numberToFormat) ? '' : new Intl.NumberFormat(this.lang(), options).format(numberToFormat);
178
154
  }
179
155
 
180
156
  /** Outputs a localized time in relative format. */
181
157
  relativeTime(value: number, unit: Intl.RelativeTimeFormatUnit, options?: Intl.RelativeTimeFormatOptions) {
182
- return relativeTime(this.host.lang || documentLanguage, value, unit, options);
158
+ return new Intl.RelativeTimeFormat(this.lang(), options).format(value, unit);
183
159
  }
184
160
  }