@shoelace-style/localize 2.1.3 → 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,19 @@
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
+
8
+ ## 2.2.1
9
+
10
+ - Fixed a bug that prevented updates from happening when `<html dir>` changed
11
+
12
+ ## 2.2.0
13
+
14
+ - Added `dir()` method to return the target element's directionality
15
+ - Added `lang()` method to return the target element's language
16
+
3
17
  ## 2.1.3
4
18
 
5
19
  - Renamed `updateLocalizedTerms()` to `update()` (forgive me SemVer, but nobody was using this I promise)
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,37 +176,66 @@ 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'})}
192
+
193
+ <!-- Determining language -->
194
+ ${this.localize.lang()}
195
+
196
+ <!-- Determining directionality, e.g. 'ltr' or 'rtl' -->
197
+ ${this.localize.dir()}
188
198
  `;
189
199
  }
190
200
  }
191
201
  ```
192
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
+
193
228
  ## Advantages
194
229
 
230
+ - Zero dependencies
195
231
  - Extremely lightweight
196
- - Zero dependencies
197
- - Version 2.1 measures 726 bytes (yes, _bytes_) after minify + gzip
198
- - Uses existing platform features
199
232
  - Supports simple terms, plurals, and complex translations
200
- - Fun fact: some languages have [six plural forms](https://lingohub.com/blog/2019/02/pluralization) and this will support that
201
- - 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)
202
235
  - Good DX for custom element authors and consumers
203
236
  - Intuitive API for custom element authors
204
237
  - Consumers only need to load the translations they want and set the `lang` attribute
205
238
  - Translations can be loaded up front or on demand
206
- - Translations can be created by consumers without having to wait for them to get accepted upstream
207
239
 
208
240
  ## Disadvantages
209
241
 
package/dist/index.d.ts CHANGED
@@ -4,20 +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
- term<K extends keyof Translation>(key: K, ...args: FunctionParams<Translation[K]>): any;
18
+ dir(): string;
19
+ lang(): string;
20
+ term<K extends keyof UserTranslation>(key: K, ...args: FunctionParams<UserTranslation[K]>): any;
21
21
  date(dateToFormat: Date | string, options?: Intl.DateTimeFormatOptions): string;
22
22
  number(numberToFormat: number | string, options?: Intl.NumberFormatOptions): string;
23
23
  relativeTime(value: number, unit: Intl.RelativeTimeFormatUnit, options?: Intl.RelativeTimeFormatOptions): string;
package/dist/index.js CHANGED
@@ -1,58 +1,30 @@
1
1
  const connectedElements = new Set();
2
2
  const documentElementObserver = new MutationObserver(update);
3
3
  const translations = new Map();
4
+ let documentDirection = document.documentElement.dir || 'ltr';
4
5
  let documentLanguage = document.documentElement.lang || navigator.language;
5
6
  let fallback;
6
7
  documentElementObserver.observe(document.documentElement, {
7
8
  attributes: true,
8
- attributeFilter: ['lang']
9
+ attributeFilter: ['dir', 'lang']
9
10
  });
10
11
  export function registerTranslation(...translation) {
11
12
  translation.map(t => {
12
13
  const code = t.$code.toLowerCase();
13
- 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
+ }
14
20
  if (!fallback) {
15
21
  fallback = t;
16
22
  }
17
23
  });
18
24
  update();
19
25
  }
20
- export function term(lang, key, ...args) {
21
- const code = lang.toLowerCase().slice(0, 2);
22
- const subcode = lang.length > 2 ? lang.toLowerCase() : '';
23
- const primary = translations.get(subcode);
24
- const secondary = translations.get(code);
25
- let term;
26
- if (primary && primary[key]) {
27
- term = primary[key];
28
- }
29
- else if (secondary && secondary[key]) {
30
- term = secondary[key];
31
- }
32
- else if (fallback && fallback[key]) {
33
- term = fallback[key];
34
- }
35
- else {
36
- console.error(`No translation found for: ${key}`);
37
- return key;
38
- }
39
- if (typeof term === 'function') {
40
- return term(...args);
41
- }
42
- return term;
43
- }
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);
51
- }
52
- export function relativeTime(lang, value, unit, options) {
53
- return new Intl.RelativeTimeFormat(lang, options).format(value, unit);
54
- }
55
26
  export function update() {
27
+ documentDirection = document.documentElement.dir || 'ltr';
56
28
  documentLanguage = document.documentElement.lang || navigator.language;
57
29
  [...connectedElements.keys()].map((el) => {
58
30
  if (typeof el.requestUpdate === 'function') {
@@ -71,16 +43,45 @@ export class LocalizeController {
71
43
  hostDisconnected() {
72
44
  connectedElements.delete(this.host);
73
45
  }
46
+ dir() {
47
+ return `${this.host.dir || documentDirection}`.toLowerCase();
48
+ }
49
+ lang() {
50
+ return `${this.host.lang || documentLanguage}`.toLowerCase();
51
+ }
74
52
  term(key, ...args) {
75
- 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;
76
75
  }
77
76
  date(dateToFormat, options) {
78
- return date(this.host.lang || documentLanguage, dateToFormat, options);
77
+ dateToFormat = new Date(dateToFormat);
78
+ return new Intl.DateTimeFormat(this.lang(), options).format(dateToFormat);
79
79
  }
80
80
  number(numberToFormat, options) {
81
- return number(this.host.lang || documentLanguage, numberToFormat, options);
81
+ numberToFormat = Number(numberToFormat);
82
+ return isNaN(numberToFormat) ? '' : new Intl.NumberFormat(this.lang(), options).format(numberToFormat);
82
83
  }
83
84
  relativeTime(value, unit, options) {
84
- return relativeTime(this.host.lang || documentLanguage, value, unit, options);
85
+ return new Intl.RelativeTimeFormat(this.lang(), options).format(value, unit);
85
86
  }
86
87
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shoelace-style/localize",
3
- "version": "2.1.3",
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,19 +6,23 @@ 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
 
12
15
  const connectedElements = new Set<HTMLElement>();
13
16
  const documentElementObserver = new MutationObserver(update);
14
17
  const translations: Map<string, Translation> = new Map();
18
+ let documentDirection = document.documentElement.dir || 'ltr';
15
19
  let documentLanguage = document.documentElement.lang || navigator.language;
16
20
  let fallback: Translation;
17
21
 
18
22
  // Watch for changes on <html lang>
19
23
  documentElementObserver.observe(document.documentElement, {
20
24
  attributes: true,
21
- attributeFilter: ['lang']
25
+ attributeFilter: ['dir', 'lang']
22
26
  });
23
27
 
24
28
  //
@@ -27,7 +31,13 @@ documentElementObserver.observe(document.documentElement, {
27
31
  export function registerTranslation(...translation: Translation[]) {
28
32
  translation.map(t => {
29
33
  const code = t.$code.toLowerCase();
30
- 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
+ }
31
41
 
32
42
  // The first translation that's registered is the fallback
33
43
  if (!fallback) {
@@ -38,68 +48,11 @@ export function registerTranslation(...translation: Translation[]) {
38
48
  update();
39
49
  }
40
50
 
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
- const code = lang.toLowerCase().slice(0, 2); // e.g. en
47
- const subcode = lang.length > 2 ? lang.toLowerCase() : ''; // e.g. en-GB
48
- const primary = translations.get(subcode);
49
- const secondary = translations.get(code);
50
- let term: any;
51
-
52
- // Look for a matching term using subcode, code, then the fallback
53
- if (primary && primary[key]) {
54
- term = primary[key];
55
- } else if (secondary && secondary[key]) {
56
- term = secondary[key];
57
- } else if (fallback && fallback[key]) {
58
- term = fallback[key];
59
- } else {
60
- console.error(`No translation found for: ${key}`);
61
- return key;
62
- }
63
-
64
- if (typeof term === 'function') {
65
- return term(...args) as string;
66
- }
67
-
68
- return term;
69
- }
70
-
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);
85
- }
86
-
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);
97
- }
98
-
99
51
  //
100
52
  // Updates all localized elements that are currently connected
101
53
  //
102
54
  export function update() {
55
+ documentDirection = document.documentElement.dir || 'ltr';
103
56
  documentLanguage = document.documentElement.lang || navigator.language;
104
57
 
105
58
  [...connectedElements.keys()].map((el: LitElement) => {
@@ -116,9 +69,10 @@ export function update() {
116
69
  //
117
70
  // private localize = new LocalizeController(this);
118
71
  //
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:
72
+ // This will add the element to the set and make it respond to changes to <html dir|lang> automatically. To make it
73
+ // respond to changes to its own dir|lang properties, make it a property:
121
74
  //
75
+ // @property() dir: string;
122
76
  // @property() lang: string;
123
77
  //
124
78
  // To use a translation method, call it like this:
@@ -127,7 +81,9 @@ export function update() {
127
81
  // ${this.localize.date('2021-12-03')}
128
82
  // ${this.localize.number(1000000)}
129
83
  //
130
- export class LocalizeController implements ReactiveController {
84
+ export class LocalizeController<UserTranslation extends Translation = DefaultTranslation>
85
+ implements ReactiveController
86
+ {
131
87
  host: ReactiveControllerHost & HTMLElement;
132
88
 
133
89
  constructor(host: ReactiveControllerHost & HTMLElement) {
@@ -143,19 +99,62 @@ export class LocalizeController implements ReactiveController {
143
99
  connectedElements.delete(this.host);
144
100
  }
145
101
 
146
- term<K extends keyof Translation>(key: K, ...args: FunctionParams<Translation[K]>) {
147
- return term(this.host.lang || documentLanguage, key, ...args);
102
+ /**
103
+ * Gets the host element's directionality as determined by the `dir` attribute. The return value is transformed to
104
+ * lowercase.
105
+ */
106
+ dir() {
107
+ return `${this.host.dir || documentDirection}`.toLowerCase();
108
+ }
109
+
110
+ /**
111
+ * Gets the host element's language as determined by the `lang` attribute. The return value is transformed to
112
+ * lowercase.
113
+ */
114
+ lang() {
115
+ return `${this.host.lang || documentLanguage}`.toLowerCase();
116
+ }
117
+
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;
148
142
  }
149
143
 
144
+ /** Outputs a localized date in the specified format. */
150
145
  date(dateToFormat: Date | string, options?: Intl.DateTimeFormatOptions) {
151
- return date(this.host.lang || documentLanguage, dateToFormat, options);
146
+ dateToFormat = new Date(dateToFormat);
147
+ return new Intl.DateTimeFormat(this.lang(), options).format(dateToFormat);
152
148
  }
153
149
 
150
+ /** Outputs a localized number in the specified format. */
154
151
  number(numberToFormat: number | string, options?: Intl.NumberFormatOptions) {
155
- return number(this.host.lang || documentLanguage, numberToFormat, options);
152
+ numberToFormat = Number(numberToFormat);
153
+ return isNaN(numberToFormat) ? '' : new Intl.NumberFormat(this.lang(), options).format(numberToFormat);
156
154
  }
157
155
 
156
+ /** Outputs a localized time in relative format. */
158
157
  relativeTime(value: number, unit: Intl.RelativeTimeFormatUnit, options?: Intl.RelativeTimeFormatOptions) {
159
- return relativeTime(this.host.lang || documentLanguage, value, unit, options);
158
+ return new Intl.RelativeTimeFormat(this.lang(), options).format(value, unit);
160
159
  }
161
160
  }