├── .gitignore ├── scripts └── clean.js ├── .prettierignore ├── RELEASE.md ├── .editorconfig ├── prettier.config.cjs ├── LICENSE.md ├── package.json ├── CHANGELOG.md ├── tsconfig.json ├── src └── index.ts └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules -------------------------------------------------------------------------------- /scripts/clean.js: -------------------------------------------------------------------------------- 1 | import del from 'del'; 2 | 3 | del.sync('./dist'); 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.md 2 | .github 3 | dist 4 | node_modules 5 | package-lock.json 6 | tsconfig.json 7 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release 2 | 3 | This package gets published from the root folder. To release: 4 | 5 | ```bash 6 | npm version major|minor|patch 7 | npm publish 8 | ``` 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | insert_final_newline = false 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'avoid', 3 | bracketSpacing: true, 4 | htmlWhitespaceSensitivity: 'css', 5 | insertPragma: false, 6 | jsxBracketSameLine: false, 7 | jsxSingleQuote: false, 8 | printWidth: 120, 9 | proseWrap: 'preserve', 10 | quoteProps: 'as-needed', 11 | requirePragma: false, 12 | semi: true, 13 | singleQuote: true, 14 | tabWidth: 2, 15 | trailingComma: 'none', 16 | useTabs: false 17 | }; 18 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 A Beautiful Site, LLC 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@shoelace-style/localize", 3 | "version": "3.1.2", 4 | "description": "A micro library for localizing custom elements using Lit's Reactive Controller model.", 5 | "main": "dist/index.js", 6 | "module": "dist/index.js", 7 | "type": "module", 8 | "types": "dist/index.d.ts", 9 | "scripts": { 10 | "start": "tsc -w", 11 | "build": "tsc", 12 | "clean": "node ./scripts/clean.js", 13 | "prebuild": "npm run clean", 14 | "prepublishOnly": "npm run build" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/shoelace-style/localize.git" 19 | }, 20 | "keywords": [ 21 | "shoelace", 22 | "web components", 23 | "custom elements", 24 | "localization", 25 | "internationalization", 26 | "i18n", 27 | "l10n" 28 | ], 29 | "author": "Cory LaViska", 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/shoelace-style/localize/issues" 33 | }, 34 | "homepage": "https://github.com/shoelace-style/localize#readme", 35 | "devDependencies": { 36 | "del": "^6.0.0", 37 | "lit": "^2.0.2", 38 | "typescript": "^5.1.3" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 3.2.0 4 | 5 | - Added support for SSR environments [#25](https://github.com/shoelace-style/localize/pull/25/) 6 | 7 | ## 3.1.2 8 | 9 | - Fixed a bug that caused underscores in locale identifiers to throw a `RangeError` 10 | 11 | ## 3.1.1 12 | 13 | - Upgraded TypeScript to 5.1.3 14 | 15 | ## 3.1.0 16 | 17 | - Added `exists()` method to determine if a term and/or a fallback term exists [#17](https://github.com/shoelace-style/localize/issues/17) 18 | 19 | ## 3.0.4 20 | 21 | - Ensure return values of translation functions are always a string 22 | 23 | ## 3.0.3 24 | 25 | - Fixed a bug where regional locales stopped working 26 | 27 | ## 3.0.2 28 | 29 | - Fixed a parsing bug in extended language codes [#16](https://github.com/shoelace-style/localize/issues/16) 30 | - Updated TypeScript to 4.8.4 31 | 32 | ## 3.0.1 33 | 34 | - Fixed module paths in `package.json` 35 | 36 | ## 3.0.0 37 | 38 | - 🚨BREAKING: Removed top level `term()`, `date()`, `number()`, and `relativeTime()` functions 39 | - Refactored `LocalizeController.term()` to allow strong typings by extending the controller and default translation (see "Typed Translations and Arguments" in the readme for details) 40 | 41 | ## 2.2.1 42 | 43 | - Fixed a bug that prevented updates from happening when `` changed 44 | 45 | ## 2.2.0 46 | 47 | - Added `dir()` method to return the target element's directionality 48 | - Added `lang()` method to return the target element's language 49 | 50 | ## 2.1.3 51 | 52 | - Renamed `updateLocalizedTerms()` to `update()` (forgive me SemVer, but nobody was using this I promise) 53 | 54 | ## 2.1.2 55 | 56 | - Removed all dependencies 57 | 58 | ## 2.1.1 59 | 60 | - Change import to ensure only types get used 61 | 62 | ## 2.1.0 63 | 64 | - Added relative time method to 65 | 66 | ## 2.0.0 67 | 68 | - Reworked the library to use the [ReactiveController](https://lit.dev/docs/composition/controllers/) interface 69 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true /* Enable incremental compilation */, 7 | "target": "es2017" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 8 | "module": "es2015" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 9 | "lib": [ /* Specify library files to be included in the compilation. */ 10 | "dom", 11 | "dom.Iterable", 12 | "es2020" 13 | ], 14 | // "allowJs": true, /* Allow javascript files to be compiled. */ 15 | // "checkJs": true, /* Report errors in .js files. */ 16 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 17 | "declaration": true /* Generates corresponding '.d.ts' file. */, 18 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 19 | // "sourceMap": true /* Generates corresponding '.map' file. */, 20 | // "outFile": "./", /* Concatenate and emit output to single file. */ 21 | "outDir": "./dist" /* Redirect output structure to the directory. */, 22 | "rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 23 | // "composite": true, /* Enable project compilation */ 24 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 25 | // "removeComments": true, /* Do not emit comments to output. */ 26 | // "noEmit": true, /* Do not emit outputs. */ 27 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 28 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 29 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 30 | 31 | /* Strict Type-Checking Options */ 32 | // "strict": true, /* Enable all strict type-checking options. */ 33 | "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, 34 | "strictNullChecks": true /* Enable strict null checks. */, 35 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 36 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 37 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 38 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 39 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 40 | 41 | /* Additional Checks */ 42 | "noUnusedLocals": true /* Report errors on unused locals. */, 43 | "noUnusedParameters": true /* Report errors on unused parameters. */, 44 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 45 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 46 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 47 | 48 | /* Module Resolution Options */ 49 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 50 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 51 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 52 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 53 | // "typeRoots": [], /* List of folders to include type definitions from. */ 54 | // "types": [], /* Type declaration files to be included in compilation. */ 55 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 56 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 57 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 58 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 59 | 60 | /* Source Map Options */ 61 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 62 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 63 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 64 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 65 | 66 | /* Experimental Options */ 67 | "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 68 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 69 | 70 | /* Advanced Options */ 71 | "removeComments": true, 72 | "skipLibCheck": true /* Skip type checking of declaration files. */, 73 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 74 | }, 75 | "exclude": [ 76 | "dist", 77 | "src/**/*.test.ts" 78 | ] 79 | } 80 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { LitElement, ReactiveController, ReactiveControllerHost } from 'lit'; 2 | 3 | export type FunctionParams = T extends (...args: infer U) => string ? U : []; 4 | 5 | export interface Translation { 6 | $code: string; // e.g. en, en-GB 7 | $name: string; // e.g. English, Español 8 | $dir: 'ltr' | 'rtl'; 9 | } 10 | 11 | export interface DefaultTranslation extends Translation { 12 | [key: string]: any; 13 | } 14 | 15 | export interface ExistsOptions { 16 | lang: string; 17 | includeFallback: boolean; 18 | } 19 | 20 | const connectedElements = new Set(); 21 | const translations: Map = new Map(); 22 | 23 | let fallback: Translation; 24 | 25 | 26 | // TODO: We need some way for users to be able to set these on the server. 27 | let documentDirection = 'ltr' 28 | 29 | // Fallback for server. 30 | let documentLanguage = 'en' 31 | 32 | const isClient = (typeof MutationObserver !== "undefined" && typeof document !== "undefined" && typeof document.documentElement !== "undefined") 33 | 34 | if (isClient) { 35 | const documentElementObserver = new MutationObserver(update); 36 | documentDirection = document.documentElement.dir || 'ltr'; 37 | documentLanguage = document.documentElement.lang || navigator.language; 38 | 39 | // Watch for changes on 40 | documentElementObserver.observe(document.documentElement, { 41 | attributes: true, 42 | attributeFilter: ['dir', 'lang'] 43 | }); 44 | } 45 | 46 | /** Registers one or more translations */ 47 | export function registerTranslation(...translation: Translation[]) { 48 | translation.map(t => { 49 | const code = t.$code.toLowerCase(); 50 | 51 | if (translations.has(code)) { 52 | // Merge translations that share the same language code 53 | translations.set(code, { ...translations.get(code), ...t }); 54 | } else { 55 | translations.set(code, t); 56 | } 57 | 58 | // The first translation that's registered is the fallback 59 | if (!fallback) { 60 | fallback = t; 61 | } 62 | }); 63 | 64 | update(); 65 | } 66 | 67 | /** Updates all localized elements that are currently connected */ 68 | export function update() { 69 | if (isClient) { 70 | documentDirection = document.documentElement.dir || 'ltr'; 71 | documentLanguage = document.documentElement.lang || navigator.language; 72 | } 73 | 74 | [...connectedElements.keys()].map((el: LitElement) => { 75 | if (typeof el.requestUpdate === 'function') { 76 | el.requestUpdate(); 77 | } 78 | }); 79 | } 80 | 81 | /** 82 | * Localize Reactive Controller for components built with Lit 83 | * 84 | * To use this controller, import the class and instantiate it in a custom element constructor: 85 | * 86 | * private localize = new LocalizeController(this); 87 | * 88 | * This will add the element to the set and make it respond to changes to automatically. To make it 89 | * respond to changes to its own dir|lang properties, make it a property: 90 | * 91 | * @property() dir: string; 92 | * @property() lang: string; 93 | * 94 | * To use a translation method, call it like this: 95 | * 96 | * ${this.localize.term('term_key_here')} 97 | * ${this.localize.date('2021-12-03')} 98 | * ${this.localize.number(1000000)} 99 | */ 100 | export class LocalizeController 101 | implements ReactiveController 102 | { 103 | host: ReactiveControllerHost & HTMLElement; 104 | 105 | constructor(host: ReactiveControllerHost & HTMLElement) { 106 | this.host = host; 107 | this.host.addController(this); 108 | } 109 | 110 | hostConnected() { 111 | connectedElements.add(this.host); 112 | } 113 | 114 | hostDisconnected() { 115 | connectedElements.delete(this.host); 116 | } 117 | 118 | /** 119 | * Gets the host element's directionality as determined by the `dir` attribute. The return value is transformed to 120 | * lowercase. 121 | */ 122 | dir() { 123 | return `${this.host.dir || documentDirection}`.toLowerCase(); 124 | } 125 | 126 | /** 127 | * Gets the host element's language as determined by the `lang` attribute. The return value is transformed to 128 | * lowercase. 129 | */ 130 | lang() { 131 | return `${this.host.lang || documentLanguage}`.toLowerCase(); 132 | } 133 | 134 | private getTranslationData(lang: string) { 135 | // Convert "en_US" to "en-US". Note that both underscores and dashes are allowed per spec, but underscores result in 136 | // a RangeError by the call to `new Intl.Locale()`. See: https://unicode.org/reports/tr35/#unicode-locale-identifier 137 | const locale = new Intl.Locale(lang.replace(/_/g, '-')); 138 | const language = locale?.language.toLowerCase(); 139 | const region = locale?.region?.toLowerCase() ?? ''; 140 | const primary = translations.get(`${language}-${region}`); 141 | const secondary = translations.get(language); 142 | 143 | return { locale, language, region, primary, secondary }; 144 | } 145 | 146 | /** Determines if the specified term exists, optionally checking the fallback translation. */ 147 | exists(key: K, options: Partial): boolean { 148 | const { primary, secondary } = this.getTranslationData(options.lang ?? this.lang()); 149 | 150 | options = { 151 | includeFallback: false, 152 | ...options 153 | }; 154 | 155 | if ( 156 | (primary && primary[key]) || 157 | (secondary && secondary[key]) || 158 | (options.includeFallback && fallback && fallback[key as keyof Translation]) 159 | ) { 160 | return true; 161 | } 162 | 163 | return false; 164 | } 165 | 166 | /** Outputs a translated term. */ 167 | term(key: K, ...args: FunctionParams): string { 168 | const { primary, secondary } = this.getTranslationData(this.lang()); 169 | let term: any; 170 | 171 | // Look for a matching term using regionCode, code, then the fallback 172 | if (primary && primary[key]) { 173 | term = primary[key]; 174 | } else if (secondary && secondary[key]) { 175 | term = secondary[key]; 176 | } else if (fallback && fallback[key as keyof Translation]) { 177 | term = fallback[key as keyof Translation]; 178 | } else { 179 | console.error(`No translation found for: ${String(key)}`); 180 | return String(key); 181 | } 182 | 183 | if (typeof term === 'function') { 184 | return term(...args) as string; 185 | } 186 | 187 | return term; 188 | } 189 | 190 | /** Outputs a localized date in the specified format. */ 191 | date(dateToFormat: Date | string, options?: Intl.DateTimeFormatOptions): string { 192 | dateToFormat = new Date(dateToFormat); 193 | return new Intl.DateTimeFormat(this.lang(), options).format(dateToFormat); 194 | } 195 | 196 | /** Outputs a localized number in the specified format. */ 197 | number(numberToFormat: number | string, options?: Intl.NumberFormatOptions): string { 198 | numberToFormat = Number(numberToFormat); 199 | return isNaN(numberToFormat) ? '' : new Intl.NumberFormat(this.lang(), options).format(numberToFormat); 200 | } 201 | 202 | /** Outputs a localized time in relative format. */ 203 | relativeTime(value: number, unit: Intl.RelativeTimeFormatUnit, options?: Intl.RelativeTimeFormatOptions): string { 204 | return new Intl.RelativeTimeFormat(this.lang(), options).format(value, unit); 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shoelace: Localize 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, [Reactive Controller](https://lit.dev/docs/composition/controllers/) for sharing and applying translations across one or more custom elements in a component library. 4 | 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 | 7 | ## Overview 8 | 9 | Here's an example of how this library can be used to create a localized custom element with Lit. 10 | 11 | ```ts 12 | import { LocalizeController, registerTranslation } from '@shoelace-style/localize'; 13 | 14 | // Note: translations can also be lazy loaded (see "Registering Translations" below) 15 | import en from '../translations/en'; 16 | import es from '../translations/es'; 17 | 18 | registerTranslation(en, es); 19 | 20 | @customElement('my-element') 21 | export class MyElement extends LitElement { 22 | private localize = new LocalizeController(this); 23 | 24 | @property() lang: string; 25 | 26 | render() { 27 | return html` 28 |

${this.localize.term('hello_world')}

29 | `; 30 | } 31 | } 32 | ``` 33 | 34 | To set the page locale, apply the desired `lang` attribute to the `` element. 35 | 36 | ```html 37 | 38 | ... 39 | 40 | ``` 41 | 42 | Changes to `` will trigger an update to all localized components automatically. 43 | 44 | ## Why this instead of an i18n library? 45 | 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 | 48 | ```html 49 | #shadow-root 50 | 53 | ``` 54 | 55 | Typically, custom element authors dance around the problem by exposing attributes or properties for such purposes. 56 | 57 | ```html 58 | 59 | ... 60 | 61 | ``` 62 | 63 | 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. 64 | 65 | 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. 66 | 67 | ## How it works 68 | 69 | 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 ``, 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. 70 | 71 | ```html 72 | 73 | 74 | This element will be English 75 | This element will be Spanish 76 | This element will be French 77 | 78 | 79 | ``` 80 | 81 | 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 ``. 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. 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 ``. 86 | 87 | ## Usage 88 | 89 | First, install the library. 90 | 91 | ```bash 92 | npm install @shoelace-style/localize 93 | ``` 94 | 95 | Next, follow these steps to localize your components. 96 | 97 | 1. Create a translation 98 | 2. Register the translation 99 | 3. Localize your components 100 | 101 | ### Creating a Translation 102 | 103 | 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. 104 | 105 | ```ts 106 | // en.ts 107 | import type { Translation } from '@shoelace-style/localize'; 108 | 109 | const translation: Translation = { 110 | $code: 'en', 111 | $name: 'English', 112 | $dir: 'ltr', 113 | 114 | // Simple terms 115 | upload: 'Upload', 116 | 117 | // Terms with placeholders 118 | greetUser: (name: string) => `Hello, ${name}!`, 119 | 120 | // Plurals 121 | numFilesSelected: (count: number) => { 122 | if (count === 0) return 'No files selected'; 123 | if (count === 1) return '1 file selected'; 124 | return `${count} files selected`; 125 | } 126 | }; 127 | 128 | export default translation; 129 | ``` 130 | 131 | ### Registering Translations 132 | 133 | Once you've created a translation, you need to register it before use. To register a translation, call the `registerTranslation()` method. This example imports and register two translations up front. 134 | 135 | ```ts 136 | import { registerTranslation } from '@shoelace-style/localize'; 137 | import en from './en'; 138 | import es from './es'; 139 | 140 | registerTranslation(en, es); 141 | ``` 142 | 143 | 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. 144 | 145 | 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. 146 | 147 | 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. 148 | 149 | 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. 150 | 151 | Here's a sample function that dynamically loads a translation. 152 | 153 | ```ts 154 | import { registerTranslation } from '@shoelace-style/localize'; 155 | 156 | async function changeLanguage(lang) { 157 | const availableTranslations = ['en', 'es', 'fr', 'de']; 158 | 159 | if (availableTranslations.includes(lang)) { 160 | const translation = await import(`/path/to/translations/${lang}.js`); 161 | registerTranslation(translation); 162 | } 163 | } 164 | ``` 165 | 166 | ### Localizing Components 167 | 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. 169 | 170 | ```ts 171 | import { LitElement } from 'lit'; 172 | import { customElement } from 'lit/decorators.js'; 173 | import { LocalizeController } from '@shoelace-style/localize/dist/lit.js'; 174 | 175 | @customElement('my-element') 176 | export class MyElement extends LitElement { 177 | private localize = new LocalizeController(this); 178 | 179 | // Make sure to make `dir` and `lang` reactive so the component will respond to changes to its own attributes 180 | @property() dir: string; 181 | @property() lang: string; 182 | 183 | render() { 184 | return html` 185 | 186 | ${this.localize.term('hello')} 187 | 188 | 189 | ${this.localize.date('2021-09-15 14:00:00 ET'), { month: 'long', day: 'numeric', year: 'numeric' }} 190 | 191 | 192 | ${this.localize.number(1000, { style: 'currency', currency: 'USD'})} 193 | 194 | 195 | ${this.localize.lang()} 196 | 197 | 198 | ${this.localize.dir()} 199 | `; 200 | } 201 | } 202 | ``` 203 | 204 | ## Typed Translations and Arguments 205 | 206 | 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`. 207 | 208 | In a separate file, e.g. `my-localize.ts`, add the following code. 209 | 210 | ```ts 211 | import { LocalizeController as DefaultLocalizeController } from '@shoelace-style/localize'; 212 | 213 | // Extend the default controller with your custom translation 214 | export class LocalizeController extends DefaultLocalizeController {} 215 | 216 | // Export `registerTranslation` so you can import everything from this file 217 | export { registerTranslation } from '@shoelace-style/localize'; 218 | 219 | // Define your translation terms here 220 | export interface MyTranslation extends Translation { 221 | myTerm: string; 222 | myOtherTerm: string; 223 | myTermWithArgs: (count: string) => string; 224 | } 225 | ``` 226 | 227 | Now you can import `MyLocalizeController` and get strongly typed translations when you use `this.localize.term()`! 228 | 229 | ## Advantages 230 | 231 | - Zero dependencies 232 | - Extremely lightweight 233 | - Supports simple terms, plurals, and complex translations 234 | - Fun fact: some languages have [six plural forms](https://lingohub.com/blog/2019/02/pluralization) and this utility supports that 235 | - Supports dates, numbers, and currencies using built-in [`Intl` APIs](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl) 236 | - Good DX for custom element authors and consumers 237 | - Intuitive API for custom element authors 238 | - Consumers only need to load the translations they want and set the `lang` attribute 239 | - Translations can be loaded up front or on demand 240 | 241 | ## Disadvantages 242 | 243 | - Complex translations require some code, such as conditionals 244 | - 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 245 | --------------------------------------------------------------------------------