@cfpb/cfpb-design-system 4.1.0 → 4.2.1

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.
Files changed (83) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/dist/base/index.css +1 -1
  3. package/dist/base/index.css.map +2 -2
  4. package/dist/base/index.js +1 -1
  5. package/dist/base/index.js.map +1 -1
  6. package/dist/components/cfpb-buttons/index.css +1 -1
  7. package/dist/components/cfpb-buttons/index.css.map +2 -2
  8. package/dist/components/cfpb-buttons/index.js +1 -1
  9. package/dist/components/cfpb-buttons/index.js.map +1 -1
  10. package/dist/components/cfpb-expandables/index.css +1 -1
  11. package/dist/components/cfpb-expandables/index.css.map +2 -2
  12. package/dist/components/cfpb-expandables/index.js.map +1 -1
  13. package/dist/components/cfpb-forms/index.css +1 -1
  14. package/dist/components/cfpb-forms/index.css.map +2 -2
  15. package/dist/components/cfpb-forms/index.js +1 -1
  16. package/dist/components/cfpb-forms/index.js.map +1 -1
  17. package/dist/components/cfpb-icons/index.css +1 -1
  18. package/dist/components/cfpb-icons/index.css.map +2 -2
  19. package/dist/components/cfpb-icons/index.js +1 -1
  20. package/dist/components/cfpb-icons/index.js.map +1 -1
  21. package/dist/components/cfpb-layout/index.css +1 -1
  22. package/dist/components/cfpb-layout/index.css.map +2 -2
  23. package/dist/components/cfpb-layout/index.js +1 -1
  24. package/dist/components/cfpb-layout/index.js.map +1 -1
  25. package/dist/components/cfpb-notifications/index.css +1 -1
  26. package/dist/components/cfpb-notifications/index.css.map +2 -2
  27. package/dist/components/cfpb-notifications/index.js +1 -1
  28. package/dist/components/cfpb-notifications/index.js.map +1 -1
  29. package/dist/components/cfpb-tooltips/index.css +1 -1
  30. package/dist/components/cfpb-tooltips/index.css.map +2 -2
  31. package/dist/components/cfpb-tooltips/index.js.map +1 -1
  32. package/dist/components/cfpb-typography/index.css +1 -1
  33. package/dist/components/cfpb-typography/index.css.map +2 -2
  34. package/dist/components/cfpb-typography/index.js +1 -1
  35. package/dist/components/cfpb-typography/index.js.map +1 -1
  36. package/dist/elements/cfpb-button/index.js +13 -4
  37. package/dist/elements/cfpb-button/index.js.map +4 -4
  38. package/dist/elements/cfpb-file-upload/index.js +4 -4
  39. package/dist/elements/cfpb-file-upload/index.js.map +4 -4
  40. package/dist/elements/cfpb-icon-text/index.js +29 -0
  41. package/dist/elements/cfpb-icon-text/index.js.map +7 -0
  42. package/dist/elements/cfpb-label/index.js.map +1 -1
  43. package/dist/elements/cfpb-multiselect/index.js +2 -2
  44. package/dist/elements/cfpb-multiselect/index.js.map +2 -2
  45. package/dist/elements/cfpb-pagination/index.js +32 -0
  46. package/dist/elements/cfpb-pagination/index.js.map +7 -0
  47. package/dist/elements/cfpb-tag-filter/index.js.map +1 -1
  48. package/dist/elements/cfpb-tag-topic/index.js +3 -3
  49. package/dist/elements/cfpb-tag-topic/index.js.map +2 -2
  50. package/dist/elements/cfpb-utilities/index.js +2 -0
  51. package/dist/elements/cfpb-utilities/index.js.map +7 -0
  52. package/dist/elements/index.js +7 -6
  53. package/dist/elements/index.js.map +4 -4
  54. package/dist/index.css +1 -1
  55. package/dist/index.css.map +2 -2
  56. package/dist/index.js +7 -6
  57. package/dist/index.js.map +4 -4
  58. package/package.json +2 -2
  59. package/src/base/base.scss +14 -26
  60. package/src/components/cfpb-buttons/button.scss +4 -2
  61. package/src/components/cfpb-forms/tag.scss +3 -0
  62. package/src/components/cfpb-icons/icon.scss +1 -1
  63. package/src/components/cfpb-layout/card.scss +8 -11
  64. package/src/components/cfpb-pagination/vars.scss +0 -4
  65. package/src/components/cfpb-typography/link.scss +4 -2
  66. package/src/components/cfpb-typography/mixins.scss +9 -3
  67. package/src/elements/cfpb-button/cfpb-button.component.scss +15 -0
  68. package/src/elements/cfpb-button/index.js +52 -27
  69. package/src/elements/cfpb-icon-text/cfpb-icon-text.component.scss +60 -0
  70. package/src/elements/cfpb-icon-text/index.js +150 -0
  71. package/src/elements/cfpb-label/index.js +4 -3
  72. package/src/elements/cfpb-pagination/cfpb-pagination.component.scss +72 -0
  73. package/src/elements/cfpb-pagination/index.js +211 -0
  74. package/src/elements/cfpb-tag-filter/index.js +1 -0
  75. package/src/elements/cfpb-tag-topic/cfpb-tag-topic.component.scss +2 -0
  76. package/src/elements/cfpb-tag-topic/index.js +1 -0
  77. package/src/elements/cfpb-utilities/i18n-service.js +128 -0
  78. package/src/elements/cfpb-utilities/i18n-service.spec.js +156 -0
  79. package/src/elements/cfpb-utilities/index.js +7 -0
  80. package/src/elements/cfpb-utilities/media-query-service.js +102 -0
  81. package/src/elements/cfpb-utilities/media-query-service.spec.js +126 -0
  82. package/src/elements/index.js +1 -0
  83. package/src/utilities/utilities.scss +8 -8
@@ -0,0 +1,72 @@
1
+ @use 'sass:math';
2
+ @use '@cfpb/cfpb-design-system/src/abstracts' as *;
3
+ @use '@cfpb/cfpb-design-system/src/utilities' as *;
4
+
5
+ :host {
6
+ width: 100%;
7
+
8
+ .cf-icon-svg {
9
+ height: $cf-icon-height;
10
+ vertical-align: middle;
11
+ fill: currentcolor;
12
+ }
13
+
14
+ .m-pagination {
15
+ display: grid;
16
+ grid-template-columns: auto 1fr auto;
17
+ grid-template-areas:
18
+ 'pag-btn-prev . pag-btn-next'
19
+ 'pag-form pag-form pag-form';
20
+ row-gap: math.div(15px, $base-font-size-px) + rem;
21
+
22
+ &__form {
23
+ grid-area: pag-form;
24
+ display: flex;
25
+ flex-flow: wrap;
26
+ place-content: center;
27
+ gap: math.div(10px, $base-font-size-px) + rem;
28
+
29
+ padding: math.div(5px, $base-font-size-px) + rem;
30
+ border-radius: math.div(4px, $base-font-size-px) + rem;
31
+ background: var(--gray-5);
32
+ color: var(--gray);
33
+ }
34
+
35
+ &__current-page {
36
+ // 45px is a magic number to provide enough room for three digits
37
+ // and the number spinners for type="number" inputs on desktop
38
+ width: math.div(45px, $base-font-size-px) + rem;
39
+
40
+ font-weight: 500;
41
+ text-align: right;
42
+ }
43
+
44
+ &__label {
45
+ display: contents;
46
+ white-space: nowrap;
47
+ }
48
+
49
+ &__btn-prev {
50
+ grid-area: pag-btn-prev;
51
+ z-index: 1;
52
+ }
53
+ &__btn-next {
54
+ grid-area: pag-btn-next;
55
+ z-index: 1;
56
+ }
57
+
58
+ // Tablet and above.
59
+ @include respond-to-min($bp-sm-min) {
60
+ grid-template-areas: 'pag-btn-prev pag-form pag-btn-next';
61
+
62
+ &__btn-prev {
63
+ border-top-right-radius: 0;
64
+ border-bottom-right-radius: 0;
65
+ }
66
+ &__btn-next {
67
+ border-top-left-radius: 0;
68
+ border-bottom-left-radius: 0;
69
+ }
70
+ }
71
+ }
72
+ }
@@ -0,0 +1,211 @@
1
+ import { html, LitElement, css, unsafeCSS } from 'lit';
2
+ import { unsafeSVG } from 'lit/directives/unsafe-svg.js';
3
+ import styles from './cfpb-pagination.component.scss';
4
+ import leftIcon from '../../components/cfpb-icons/icons/left.svg';
5
+ import rightIcon from '../../components/cfpb-icons/icons/right.svg';
6
+ import { I18nService, MediaQueryService } from '../cfpb-utilities/';
7
+
8
+ /**
9
+ *
10
+ * @element cfpb-button
11
+ * @slot - The main content for the button.
12
+ */
13
+ export class CfpbPagination extends LitElement {
14
+ #mediaService;
15
+ #isMobile;
16
+ #i18n;
17
+
18
+ static styles = css`
19
+ ${unsafeCSS(styles)}
20
+ `;
21
+
22
+ /**
23
+ * @property {number} currentPage - The currently selected page.
24
+ * @property {number} maxPage - The maximum page count.
25
+ * @returns {object} The map of properties.
26
+ */
27
+ static get properties() {
28
+ return {
29
+ currentPage: { type: Number, attribute: 'value', reflect: true },
30
+ maxPage: { type: Number, attribute: 'max', reflect: true },
31
+ lang: { type: String, reflect: true },
32
+ };
33
+ }
34
+
35
+ constructor() {
36
+ super();
37
+ this.currentPage = 1;
38
+ this.maxPage = 1;
39
+ this.#mediaService = new MediaQueryService();
40
+ this.#isMobile = false;
41
+ this.lang = 'en';
42
+ }
43
+
44
+ connectedCallback() {
45
+ super.connectedCallback();
46
+ this.#i18n = this.querySelector('i18n-service');
47
+ if (this.#i18n) {
48
+ this.addEventListener('i18n-change', this.#onI18nChange);
49
+ this.#i18n.language = this.lang;
50
+ }
51
+ }
52
+
53
+ #onI18nChange() {
54
+ const updateLabel = (selector, key) => {
55
+ const btn = this.renderRoot.querySelector(selector);
56
+ const span = btn.querySelector('span');
57
+ if (btn) {
58
+ const newText = this.#i18n.translate(key);
59
+ span.innerHTML = newText;
60
+ btn.requestUpdate();
61
+ }
62
+ };
63
+
64
+ updateLabel('#previous', 'previous');
65
+ updateLabel('#next', 'next');
66
+ updateLabel('#go', 'go');
67
+
68
+ this.requestUpdate();
69
+ }
70
+
71
+ firstUpdated() {
72
+ this.#mediaService.addEventListener('change', this.#onMediaChange);
73
+ this.#isMobile = this.#mediaService.matches.xs;
74
+ }
75
+
76
+ disconnectedCallback() {
77
+ super.disconnectedCallback();
78
+ this.#mediaService.removeEventListener('change', this.#onMediaChange);
79
+ this.#mediaService.destroy();
80
+ }
81
+
82
+ #onMediaChange = (event) => {
83
+ const newIsMobile = event.detail.matches.xs;
84
+ if (newIsMobile !== this.#isMobile) {
85
+ this.#isMobile = newIsMobile;
86
+ this.requestUpdate();
87
+ }
88
+ };
89
+
90
+ updated(changed) {
91
+ if (changed.has('currentPage') || changed.has('maxPage')) {
92
+ if (this.currentPage < 1) this.currentPage = 1;
93
+ else if (this.currentPage > this.maxPage) this.currentPage = this.maxPage;
94
+ }
95
+
96
+ if (changed.has('lang')) {
97
+ this.#i18n.language = this.lang;
98
+
99
+ this.requestUpdate();
100
+ }
101
+ }
102
+
103
+ get isAtMin() {
104
+ return this.currentPage <= 1;
105
+ }
106
+
107
+ get isAtMax() {
108
+ return this.currentPage >= this.maxPage;
109
+ }
110
+
111
+ #onInput(event) {
112
+ this.currentPage = event.target.value;
113
+ }
114
+
115
+ #handleSubmit(event) {
116
+ event.preventDefault();
117
+ const page = parseInt(this.currentPage, 10);
118
+ if (!Number.isNaN(page)) {
119
+ this.#goto(page);
120
+ }
121
+ }
122
+
123
+ #goto(page) {
124
+ const clamped = Math.max(1, Math.min(page, this.maxPage));
125
+ if (clamped !== this.currentPage) {
126
+ this.currentPage = clamped;
127
+ this.dispatchEvent(
128
+ new CustomEvent('page-change', {
129
+ detail: { page: clamped },
130
+ bubbles: true,
131
+ composed: true,
132
+ }),
133
+ );
134
+ }
135
+ }
136
+
137
+ render() {
138
+ // Get a translator function.
139
+ const trans =
140
+ this.#i18n && this.#i18n.translate
141
+ ? this.#i18n.translate.bind(this.#i18n)
142
+ : (key) => key;
143
+
144
+ return html`
145
+ <slot></slot>
146
+ <nav
147
+ class="m-pagination"
148
+ role="navigation"
149
+ aria-label="${trans('page number')}"
150
+ >
151
+ <cfpb-button
152
+ class="m-pagination__btn-prev"
153
+ id="next"
154
+ href="#"
155
+ ?flush-right=${!this.#isMobile}
156
+ ?disabled=${this.isAtMin}
157
+ @click=${() => this.#goto(this.currentPage - 1)}
158
+ >
159
+ ${unsafeSVG(leftIcon)} ${trans('next')}
160
+ </cfpb-button>
161
+
162
+ <form
163
+ class="m-pagination__form"
164
+ action="#pagination_content"
165
+ @submit=${this.#handleSubmit}
166
+ >
167
+ <label class="m-pagination__label">
168
+ ${trans('page')}
169
+ <span class="u-visually-hidden">
170
+ ${this.currentPage} ${trans('out of')} ${this.maxPage}
171
+ ${trans('total pages')}
172
+ </span>
173
+ <input
174
+ class="m-pagination__current-page"
175
+ name="page"
176
+ type="number"
177
+ min="1"
178
+ max=${this.maxPage}
179
+ pattern="[0-9]*"
180
+ inputmode="numeric"
181
+ .value=${this.currentPage}
182
+ @input=${this.#onInput}
183
+ />
184
+ ${trans('of')} ${this.maxPage}
185
+ </label>
186
+ <cfpb-button id="go" type="submit" style-as-link>
187
+ ${trans('go')}
188
+ </cfpb-button>
189
+ </form>
190
+
191
+ <cfpb-button
192
+ class="m-pagination__btn-next"
193
+ id="previous"
194
+ href="#"
195
+ ?flush-left=${!this.#isMobile}
196
+ ?disabled=${this.isAtMax}
197
+ @click=${() => this.#goto(this.currentPage + 1)}
198
+ >
199
+ ${trans('previous')} ${unsafeSVG(rightIcon)}
200
+ </cfpb-button>
201
+ </nav>
202
+ `;
203
+ }
204
+
205
+ static init() {
206
+ I18nService.init();
207
+
208
+ window.customElements.get('cfpb-pagination') ||
209
+ window.customElements.define('cfpb-pagination', CfpbPagination);
210
+ }
211
+ }
@@ -15,6 +15,7 @@ export class CfpbTagFilter extends LitElement {
15
15
 
16
16
  /**
17
17
  * @property {string} for - Associate the label with an ID elsewhere.
18
+ * @returns {object} The map of properties.
18
19
  */
19
20
  static get properties() {
20
21
  return {
@@ -26,6 +26,7 @@
26
26
  a.a-tag-topic--no-top-border {
27
27
  position: relative;
28
28
  border-top: none;
29
+ border-top-style: none !important;
29
30
  }
30
31
 
31
32
  a.a-tag-topic:hover::before,
@@ -63,6 +64,7 @@
63
64
  a.a-tag-topic:hover,
64
65
  a.a-tag-topic:focus,
65
66
  a.a-tag-topic:active {
67
+ text-decoration: none;
66
68
  border-bottom: none;
67
69
  outline-offset: 1px;
68
70
 
@@ -15,6 +15,7 @@ export class CfpbTagTopic extends LitElement {
15
15
  * @property {string} href - href attribute, if this is a topic link.
16
16
  * @property {boolean} siblingOfJumpLink
17
17
  * Whether the preceding sibling is a jump link or not.
18
+ * @returns {object} The map of properties.
18
19
  */
19
20
  static get properties() {
20
21
  return {
@@ -0,0 +1,128 @@
1
+ /**
2
+ * A service for embedding and using translations via markup.
3
+ *
4
+ * Expect markup like:
5
+ * <i18n-service>
6
+ * <template>
7
+ * {
8
+ * "en": { "hello": "Hello", "good afternoon": "Good afternoon" },
9
+ * "es": { "hello": "Hola", "good afternoon": "Buenas tardes" }
10
+ * }
11
+ * </template>
12
+ * </i18n-service>
13
+ */
14
+ export class I18nService extends HTMLElement {
15
+ #translations;
16
+ #language;
17
+
18
+ constructor() {
19
+ super();
20
+ this.#translations = {};
21
+ this.#language = '';
22
+ }
23
+
24
+ connectedCallback() {
25
+ const errors = [];
26
+ const template = this.querySelector('template');
27
+ if (!template) {
28
+ errors.push([
29
+ 'missing-template',
30
+ 'No <template> found inside <i18n-service>',
31
+ ]);
32
+ return;
33
+ }
34
+
35
+ try {
36
+ const json = template.innerHTML.trim();
37
+ const data = JSON.parse(json);
38
+
39
+ if (typeof data !== 'object' || Array.isArray(data)) {
40
+ errors.push([
41
+ 'invalid-format',
42
+ 'Translations JSON must be an object keyed by the language codes',
43
+ ]);
44
+ }
45
+
46
+ this.#translations = data;
47
+
48
+ // Set default lanugage to the first available one, if not already set.
49
+ const available = this.availableLanguages;
50
+ if (available.length > 0 && !this.#language) {
51
+ this.language = available[0];
52
+ }
53
+ } catch (err) {
54
+ errors.push([
55
+ 'parse-error',
56
+ 'Failed to parse i18n template JSON',
57
+ { err },
58
+ ]);
59
+ }
60
+
61
+ // Aggregate any errors together and dispatch them when the app has
62
+ // refreshed, which gives other components a chance to listen for them.
63
+ requestAnimationFrame(() => {
64
+ errors.forEach((error) => {
65
+ this.#dispatchError(...error);
66
+ });
67
+ });
68
+ }
69
+
70
+ /**
71
+ * Set the current language
72
+ * @param {string} lang - Language code (e.g. "en", "es", "ar", "ru", etc.)
73
+ */
74
+ set language(lang) {
75
+ if (!this.#translations[lang]) {
76
+ this.#dispatchError('invalid-language', `Unsupported language: ${lang}`, {
77
+ lang,
78
+ });
79
+ return;
80
+ }
81
+
82
+ if (this.#language !== lang) {
83
+ this.#language = lang;
84
+ this.dispatchEvent(
85
+ new CustomEvent('i18n-change', {
86
+ detail: { language: lang },
87
+ bubbles: true,
88
+ composed: true,
89
+ }),
90
+ );
91
+ }
92
+ }
93
+
94
+ /**
95
+ * @returns {string} The currently set language.
96
+ */
97
+ get language() {
98
+ return this.#language;
99
+ }
100
+
101
+ get availableLanguages() {
102
+ return Object.keys(this.#translations);
103
+ }
104
+
105
+ /**
106
+ * Translate a language key.
107
+ * @param {string} key - An arbitrary key from the <template>.
108
+ * @returns {string} The translation.
109
+ */
110
+ translate(key) {
111
+ return this.#translations[this.#language]?.[key] || key;
112
+ }
113
+
114
+ #dispatchError(type, message, extra = {}) {
115
+ this.dispatchEvent(
116
+ new CustomEvent('i18n-error', {
117
+ detail: { type, message, ...extra },
118
+ bubbles: true,
119
+ composed: true,
120
+ }),
121
+ );
122
+ }
123
+
124
+ static init() {
125
+ window.customElements.get('i18n-service') ||
126
+ window.customElements.define('i18n-service', I18nService);
127
+ }
128
+ }
@@ -0,0 +1,156 @@
1
+ import { describe, jest } from '@jest/globals';
2
+ import { I18nService } from './i18n-service';
3
+
4
+ I18nService.init();
5
+
6
+ describe('I18Service', () => {
7
+ let elm;
8
+ beforeEach(() => {
9
+ jest.useFakeTimers();
10
+ elm = document.createElement('i18n-service');
11
+ document.body.appendChild(elm);
12
+ });
13
+
14
+ afterEach(() => {
15
+ document.body.removeChild(elm);
16
+ jest.useRealTimers();
17
+ });
18
+
19
+ xdescribe('connectedCallback and template parsing', () => {
20
+ it('dispatches error when template is missing', async () => {
21
+ elm.addEventListener('i18n-error', (evt) => {
22
+ expect(evt.detail.type).toBe('missing-template');
23
+ expect(evt.detail.message).toMatch(/No <template> found/);
24
+ });
25
+ elm.connectedCallback();
26
+ // We expect requestAnimationFrame to schedule the dispatch: flush it.
27
+ await jest.runOnlyPendingTimers();
28
+ });
29
+
30
+ it('dispatches parse-error when JSON is invalid', async () => {
31
+ const tpl = document.createElement('template');
32
+ tpl.innerHTML = '{ invalid json }';
33
+ elm.appendChild(tpl);
34
+
35
+ elm.addEventListener('i18n-error', (evt) => {
36
+ expect(evt.detail.type).toBe('parse-error');
37
+ expect(evt.detail.message).toMatch(/Failed to parse/);
38
+ expect(evt.detail.err).toBeDefined();
39
+ });
40
+
41
+ elm.connectedCallback();
42
+ await jest.runOnlyPendingTimers();
43
+ });
44
+
45
+ it('dispatches invalid-format when JSON is an array or not object', async () => {
46
+ const tpl = document.createElement('template');
47
+ tpl.innerHTML = JSON.stringify(['a', 'b', 'c']);
48
+ elm.appendChild(tpl);
49
+
50
+ elm.addEventListener('i18n-error', (evt) => {
51
+ expect(evt.detail.type).toBe('invalid-format');
52
+ expect(evt.detail.message).toMatch(/must be an object/);
53
+ });
54
+
55
+ elm.connectedCallback();
56
+ await jest.runOnlyPendingTimers();
57
+ });
58
+
59
+ it('parses valid JSON and sets default language', () => {
60
+ const translations = {
61
+ en: { hello: 'Hello' },
62
+ es: { hello: 'Hola' },
63
+ };
64
+
65
+ const tpl = document.createElement('template');
66
+ tpl.innerHTML = JSON.stringify(translations);
67
+ elm.appendChild(tpl);
68
+
69
+ elm.connectedCallback();
70
+ jest.runOnlyPendingTimers();
71
+
72
+ expect(elm.language).toBe('en');
73
+ expect(elm.availableLanguages).toEqual(['en', 'es']);
74
+ expect(elm.translate('hello')).toBe('Hello');
75
+ });
76
+ });
77
+
78
+ describe('language setter/getter and events', () => {
79
+ beforeEach(() => {
80
+ const translations = {
81
+ en: { a: 'A' },
82
+ es: { a: 'Une' },
83
+ };
84
+
85
+ const tpl = document.createElement('template');
86
+ tpl.innerHTML = JSON.stringify(translations);
87
+ elm.appendChild(tpl);
88
+ });
89
+
90
+ it('does not dispatch event or set if invalid language', async () => {
91
+ elm.addEventListener('i18n-error', (evt) => {
92
+ expect(evt.detail.type).toBe('invalid-language');
93
+ expect(evt.detail.message).toMatch(/Unsupported language/);
94
+ });
95
+ elm.connectedCallback();
96
+ await jest.runOnlyPendingTimers();
97
+
98
+ elm.language = 'de';
99
+ expect(elm.language).toBe('en');
100
+ });
101
+
102
+ it('switching to valid language dispatches i18n-change event', async () => {
103
+ elm.connectedCallback();
104
+ await jest.runOnlyPendingTimers();
105
+
106
+ elm.addEventListener('i18n-change', (evt) => {
107
+ expect(evt.detail.language).toBe('es');
108
+ });
109
+
110
+ elm.language = 'es';
111
+ expect(elm.language).toBe('es');
112
+ });
113
+
114
+ it('assigning the same language does not re-dispatch change', async () => {
115
+ elm.connectedCallback();
116
+ await jest.runOnlyPendingTimers();
117
+ const spy = jest.fn();
118
+
119
+ elm.addEventListener('i18n-change', spy);
120
+ elm.language = 'es';
121
+ elm.language = 'es';
122
+ expect(spy).toHaveBeenCalledTimes(1);
123
+ });
124
+ });
125
+
126
+ describe('translate()', () => {
127
+ beforeEach(() => {
128
+ const translations = {
129
+ en: { hello: 'Hello' },
130
+ es: { hello: 'Hola' },
131
+ };
132
+
133
+ const tpl = document.createElement('template');
134
+ tpl.innerHTML = JSON.stringify(translations);
135
+ elm.appendChild(tpl);
136
+ });
137
+
138
+ it('returns correct translation for existing key', async () => {
139
+ elm.connectedCallback();
140
+ await jest.runOnlyPendingTimers();
141
+
142
+ expect(elm.translate('hello')).toBe('Hello');
143
+ elm.language = 'es';
144
+ expect(elm.translate('hello')).toBe('Hola');
145
+ });
146
+
147
+ it('falls back to key if missing translation', () => {
148
+ expect(elm.translate('nonexistent')).toBe('nonexistent');
149
+ });
150
+
151
+ it('returns key if translations or language not set', () => {
152
+ const e2 = document.createElement('i18n-service');
153
+ expect(e2.translate('whatever')).toBe('whatever');
154
+ });
155
+ });
156
+ });
@@ -0,0 +1,7 @@
1
+ /* ==========================================================================
2
+ Design System - Web Components
3
+ ========================================================================== */
4
+
5
+ // TODO: aggregate and export the component subdirectories automatically.
6
+ export * from './i18n-service';
7
+ export * from './media-query-service';