@cfpb/cfpb-design-system 4.0.4 → 4.2.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 +60 -1
- package/dist/base/index.css +1 -1
- package/dist/base/index.css.map +2 -2
- package/dist/base/index.js +1 -1
- package/dist/base/index.js.map +1 -1
- package/dist/components/cfpb-buttons/index.css +1 -1
- package/dist/components/cfpb-buttons/index.css.map +2 -2
- package/dist/components/cfpb-buttons/index.js +1 -1
- package/dist/components/cfpb-buttons/index.js.map +1 -1
- package/dist/components/cfpb-expandables/index.css +1 -1
- package/dist/components/cfpb-expandables/index.css.map +2 -2
- package/dist/components/cfpb-expandables/index.js +1 -1
- package/dist/components/cfpb-expandables/index.js.map +1 -1
- package/dist/components/cfpb-forms/index.css +1 -1
- package/dist/components/cfpb-forms/index.css.map +2 -2
- package/dist/components/cfpb-forms/index.js +1 -1
- package/dist/components/cfpb-forms/index.js.map +1 -1
- package/dist/components/cfpb-icons/index.css +1 -1
- package/dist/components/cfpb-icons/index.css.map +2 -2
- package/dist/components/cfpb-icons/index.js +1 -1
- package/dist/components/cfpb-icons/index.js.map +1 -1
- package/dist/components/cfpb-layout/index.css +1 -1
- package/dist/components/cfpb-layout/index.css.map +2 -2
- package/dist/components/cfpb-layout/index.js +1 -1
- package/dist/components/cfpb-layout/index.js.map +1 -1
- package/dist/components/cfpb-notifications/index.css +1 -1
- package/dist/components/cfpb-notifications/index.css.map +2 -2
- package/dist/components/cfpb-notifications/index.js +1 -1
- package/dist/components/cfpb-notifications/index.js.map +1 -1
- package/dist/components/cfpb-pagination/index.css +1 -1
- package/dist/components/cfpb-pagination/index.css.map +2 -2
- package/dist/components/cfpb-pagination/index.js +1 -1
- package/dist/components/cfpb-pagination/index.js.map +1 -1
- package/dist/components/cfpb-tables/index.css +1 -1
- package/dist/components/cfpb-tables/index.css.map +2 -2
- package/dist/components/cfpb-tables/index.js +1 -1
- package/dist/components/cfpb-tables/index.js.map +1 -1
- package/dist/components/cfpb-tooltips/index.css +1 -1
- package/dist/components/cfpb-tooltips/index.css.map +2 -2
- package/dist/components/cfpb-tooltips/index.js +1 -1
- package/dist/components/cfpb-tooltips/index.js.map +1 -1
- package/dist/components/cfpb-typography/index.css +1 -1
- package/dist/components/cfpb-typography/index.css.map +2 -2
- package/dist/components/cfpb-typography/index.js +1 -1
- package/dist/components/cfpb-typography/index.js.map +1 -1
- package/dist/elements/cfpb-button/index.js +21 -4
- package/dist/elements/cfpb-button/index.js.map +4 -4
- package/dist/elements/cfpb-file-upload/index.js +11 -4
- package/dist/elements/cfpb-file-upload/index.js.map +4 -4
- package/dist/elements/cfpb-form-choice/index.js +11 -3
- package/dist/elements/cfpb-form-choice/index.js.map +4 -4
- package/dist/elements/cfpb-icon-text/index.js +29 -0
- package/dist/elements/cfpb-icon-text/index.js.map +7 -0
- package/dist/elements/cfpb-label/index.js +36 -0
- package/dist/elements/cfpb-label/index.js.map +7 -0
- package/dist/elements/cfpb-multiselect/index.js +13 -4
- package/dist/elements/cfpb-multiselect/index.js.map +4 -4
- package/dist/elements/cfpb-pagination/index.js +32 -0
- package/dist/elements/cfpb-pagination/index.js.map +7 -0
- package/dist/elements/cfpb-tag-filter/index.js +2 -2
- package/dist/elements/cfpb-tag-filter/index.js.map +2 -2
- package/dist/elements/cfpb-tag-group/index.js +2 -2
- package/dist/elements/cfpb-tag-group/index.js.map +2 -2
- package/dist/elements/cfpb-tag-topic/index.js +3 -3
- package/dist/elements/cfpb-tag-topic/index.js.map +2 -2
- package/dist/elements/cfpb-utilities/index.js +2 -0
- package/dist/elements/cfpb-utilities/index.js.map +7 -0
- package/dist/elements/index.js +15 -5
- package/dist/elements/index.js.map +4 -4
- package/dist/index.css +1 -1
- package/dist/index.css.map +2 -2
- package/dist/index.js +15 -5
- package/dist/index.js.map +4 -4
- package/dist/utilities/index.css +1 -1
- package/dist/utilities/index.css.map +2 -2
- package/dist/utilities/index.js +1 -1
- package/dist/utilities/index.js.map +1 -1
- package/package.json +2 -2
- package/src/abstracts/heading-mixins.scss +6 -0
- package/src/abstracts/vars.scss +23 -0
- package/src/base/base.scss +15 -27
- package/src/components/cfpb-buttons/button.scss +4 -2
- package/src/components/cfpb-forms/tag.scss +3 -0
- package/src/components/cfpb-pagination/vars.scss +0 -4
- package/src/components/cfpb-typography/link.scss +4 -2
- package/src/components/cfpb-typography/mixins.scss +8 -0
- package/src/elements/cfpb-button/cfpb-button.component.scss +23 -0
- package/src/elements/cfpb-button/index.js +127 -19
- package/src/elements/cfpb-file-upload/index.js +1 -1
- package/src/elements/cfpb-form-choice/cfpb-form-choice.component.scss +6 -1
- package/src/elements/cfpb-form-choice/index.js +62 -29
- package/src/elements/cfpb-form-choice/index.spec.js +47 -0
- package/src/elements/cfpb-icon-text/cfpb-icon-text.component.scss +59 -0
- package/src/elements/cfpb-icon-text/index.js +150 -0
- package/src/elements/cfpb-label/cfpb-label.component.scss +36 -0
- package/src/elements/cfpb-label/index.js +62 -0
- package/src/elements/cfpb-multiselect/cfpb-multiselect.component.scss +225 -0
- package/src/elements/cfpb-multiselect/index.js +444 -0
- package/src/elements/cfpb-multiselect/multiselect-model.js +288 -0
- package/src/elements/cfpb-multiselect/multiselect-model.spec.js +236 -0
- package/src/elements/cfpb-pagination/cfpb-pagination.component.scss +72 -0
- package/src/elements/cfpb-pagination/index.js +211 -0
- package/src/elements/cfpb-tag-filter/index.js +2 -1
- package/src/elements/cfpb-tag-filter/index.spec.js +1 -1
- package/src/elements/cfpb-tag-group/index.js +2 -1
- package/src/elements/cfpb-tag-topic/cfpb-tag-topic.component.scss +2 -0
- package/src/elements/cfpb-tag-topic/index.js +7 -0
- package/src/elements/cfpb-utilities/i18n-service.js +128 -0
- package/src/elements/cfpb-utilities/i18n-service.spec.js +156 -0
- package/src/elements/cfpb-utilities/index.js +7 -0
- package/src/elements/cfpb-utilities/media-query-service.js +102 -0
- package/src/elements/cfpb-utilities/media-query-service.spec.js +126 -0
- package/src/elements/index.js +3 -0
- package/src/utilities/utilities.scss +8 -8
|
@@ -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
|
+
}
|
|
@@ -14,7 +14,8 @@ export class CfpbTagFilter extends LitElement {
|
|
|
14
14
|
`;
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* @property {string} for -
|
|
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 {
|
|
@@ -31,7 +31,7 @@ describe('<cfpb-tag-filter>', () => {
|
|
|
31
31
|
expect(assignedNodes[0].textContent).toBe('Earth');
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
it('dispatches the correct event', async () => {
|
|
35
35
|
const mockHandler = jest.fn();
|
|
36
36
|
elm.addEventListener('tag-click', mockHandler);
|
|
37
37
|
|
|
@@ -23,6 +23,7 @@ export class CfpbTagGroup extends LitElement {
|
|
|
23
23
|
/**
|
|
24
24
|
* @property {boolean} stacked - Whether to stack the tags vertically.
|
|
25
25
|
* @property {Array} tagList - List of the tags in the tag group.
|
|
26
|
+
* @returns {object} The map of properties.
|
|
26
27
|
*/
|
|
27
28
|
static get properties() {
|
|
28
29
|
return {
|
|
@@ -87,7 +88,7 @@ export class CfpbTagGroup extends LitElement {
|
|
|
87
88
|
|
|
88
89
|
/**
|
|
89
90
|
* Handle a change of the light DOM.
|
|
90
|
-
* @param {MutationRecord} mutationList
|
|
91
|
+
* @param {MutationRecord} mutationList - The record of observed DOM changes.
|
|
91
92
|
*/
|
|
92
93
|
#onMutation(mutationList) {
|
|
93
94
|
if (!this.#initialized) 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 {
|
|
@@ -23,6 +24,12 @@ export class CfpbTagTopic extends LitElement {
|
|
|
23
24
|
};
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
/*
|
|
28
|
+
* @property {string} href - The URL to link to (makes the tag a link).
|
|
29
|
+
* @property {boolean} siblingOfJumpLink
|
|
30
|
+
* Whether the preceding sibling is a link or not. This is used to stack the
|
|
31
|
+
* divider lines between the links at mobile.
|
|
32
|
+
*/
|
|
26
33
|
constructor() {
|
|
27
34
|
super();
|
|
28
35
|
this.href = '';
|
|
@@ -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';
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A service for observing named breakpoints via matchMedia.
|
|
3
|
+
* Emits a `change` event (CustomEvent) whenever any breakpoint match status changes.
|
|
4
|
+
* Consumers can also call `matches` or `is(breakpointName)` to get current state.
|
|
5
|
+
*/
|
|
6
|
+
export class MediaQueryService extends EventTarget {
|
|
7
|
+
#breakpoints;
|
|
8
|
+
#mqls = new Map(); // Map<key, MediaQueryList>
|
|
9
|
+
#handlers = new Map(); // Map<key, listener>
|
|
10
|
+
#matches = new Map(); // Map<key, boolean>
|
|
11
|
+
#pendingUpdate = false;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
*
|
|
15
|
+
* @param {Record<string, {min: number, max?: number}} [breakpoints]
|
|
16
|
+
* A map of breakpoint name -> { min: px, optional max: px }.
|
|
17
|
+
* If not provided, default breakpoints are used.
|
|
18
|
+
*/
|
|
19
|
+
constructor(breakpoints) {
|
|
20
|
+
super();
|
|
21
|
+
|
|
22
|
+
this.#breakpoints = breakpoints ?? {
|
|
23
|
+
xs: { min: 0, max: 600 },
|
|
24
|
+
sm: { min: 601, max: 900 },
|
|
25
|
+
med: { min: 901, max: 1020 },
|
|
26
|
+
lg: { min: 1021, max: 1200 },
|
|
27
|
+
xl: { min: 1201 },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Setup each media query.
|
|
31
|
+
for (const [key, range] of Object.entries(this.#breakpoints)) {
|
|
32
|
+
const mqString = this.#rangeToMediaQuery(range);
|
|
33
|
+
const mql = window.matchMedia(mqString);
|
|
34
|
+
|
|
35
|
+
this.#mqls.set(key, mql);
|
|
36
|
+
this.#matches.set(key, mql.matches);
|
|
37
|
+
|
|
38
|
+
const listener = (evt) => {
|
|
39
|
+
this.#matches.set(key, evt.matches);
|
|
40
|
+
|
|
41
|
+
if (!this.#pendingUpdate) {
|
|
42
|
+
this.#pendingUpdate = true;
|
|
43
|
+
|
|
44
|
+
requestAnimationFrame(() => {
|
|
45
|
+
this.#pendingUpdate = false;
|
|
46
|
+
this.#dispatchChange(); // Dispatch only once per frame.
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/*
|
|
51
|
+
const prev = this.#matches.get(key);
|
|
52
|
+
if (prev !== evt.matches) {
|
|
53
|
+
this.#matches.set(key, evt.matches);
|
|
54
|
+
this.#dispatchChange();
|
|
55
|
+
}
|
|
56
|
+
*/
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
mql.addEventListener('change', listener);
|
|
60
|
+
|
|
61
|
+
this.#handlers.set(key, listener);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Emit initial state.
|
|
65
|
+
this.#dispatchChange();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#rangeToMediaQuery(range) {
|
|
69
|
+
const parts = [];
|
|
70
|
+
if (range.min != null) {
|
|
71
|
+
parts.push(`(min-width: ${range.min}px)`);
|
|
72
|
+
}
|
|
73
|
+
if (range.max != null) {
|
|
74
|
+
parts.push(`(max-width: ${range.max}px)`);
|
|
75
|
+
}
|
|
76
|
+
return parts.join(' and ');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#dispatchChange() {
|
|
80
|
+
const detail = {
|
|
81
|
+
matches: Object.fromEntries(this.#matches),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
this.dispatchEvent(new CustomEvent('change', { detail }));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
get matches() {
|
|
88
|
+
return Object.fromEntries(this.#matches);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
destroy() {
|
|
92
|
+
for (const [key, mql] of this.#mqls.entries()) {
|
|
93
|
+
const listener = this.#handlers.get(key);
|
|
94
|
+
if (!listener) continue;
|
|
95
|
+
mql.removeEventListener('change', listener);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
this.#mqls.clear();
|
|
99
|
+
this.#handlers.clear();
|
|
100
|
+
this.#matches.clear();
|
|
101
|
+
}
|
|
102
|
+
}
|