@cfpb/cfpb-design-system 4.2.4 → 4.3.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.
Files changed (124) hide show
  1. package/CHANGELOG.md +166 -1
  2. package/dist/components/cfpb-expandables/index.js +1 -1
  3. package/dist/components/cfpb-expandables/index.js.map +3 -3
  4. package/dist/components/cfpb-forms/index.js +1 -1
  5. package/dist/components/cfpb-forms/index.js.map +2 -2
  6. package/dist/elements/cfpb-button/index.js +4 -4
  7. package/dist/elements/cfpb-button/index.js.map +3 -3
  8. package/dist/elements/cfpb-checkbox-icon/index.js +29 -0
  9. package/dist/elements/{cfpb-checkbox → cfpb-checkbox-icon}/index.js.map +4 -4
  10. package/dist/elements/cfpb-expandable/index.css +2 -0
  11. package/dist/elements/cfpb-expandable/index.css.map +7 -0
  12. package/dist/elements/cfpb-expandable/index.js +33 -0
  13. package/dist/elements/cfpb-expandable/index.js.map +7 -0
  14. package/dist/elements/cfpb-file-upload/index.js +4 -4
  15. package/dist/elements/cfpb-file-upload/index.js.map +3 -3
  16. package/dist/elements/cfpb-form-alert/index.js +32 -0
  17. package/dist/elements/cfpb-form-alert/index.js.map +7 -0
  18. package/dist/elements/cfpb-form-choice/index.js +12 -3
  19. package/dist/elements/cfpb-form-choice/index.js.map +4 -4
  20. package/dist/elements/cfpb-form-search/index.js +41 -0
  21. package/dist/elements/cfpb-form-search/index.js.map +7 -0
  22. package/dist/elements/cfpb-form-search-input/index.js +41 -0
  23. package/dist/elements/cfpb-form-search-input/index.js.map +7 -0
  24. package/dist/elements/cfpb-icon-text/index.js +3 -3
  25. package/dist/elements/cfpb-icon-text/index.js.map +3 -3
  26. package/dist/elements/cfpb-label/index.js +3 -3
  27. package/dist/elements/cfpb-label/index.js.map +2 -2
  28. package/dist/elements/cfpb-list/index.js +39 -0
  29. package/dist/elements/cfpb-list/index.js.map +7 -0
  30. package/dist/elements/cfpb-list-item/index.js +39 -0
  31. package/dist/elements/cfpb-list-item/index.js.map +7 -0
  32. package/dist/elements/cfpb-multiselect/index.js +13 -4
  33. package/dist/elements/cfpb-multiselect/index.js.map +4 -4
  34. package/dist/elements/cfpb-pagination/index.js +3 -3
  35. package/dist/elements/cfpb-pagination/index.js.map +2 -2
  36. package/dist/elements/cfpb-select/index.css +2 -0
  37. package/dist/elements/cfpb-select/index.css.map +7 -0
  38. package/dist/elements/cfpb-select/index.js +42 -0
  39. package/dist/elements/cfpb-select/index.js.map +7 -0
  40. package/dist/elements/cfpb-select-list/index.js +39 -0
  41. package/dist/elements/cfpb-select-list/index.js.map +7 -0
  42. package/dist/elements/cfpb-tag-filter/index.js +3 -3
  43. package/dist/elements/cfpb-tag-filter/index.js.map +3 -3
  44. package/dist/elements/cfpb-tag-group/index.js +3 -3
  45. package/dist/elements/cfpb-tag-group/index.js.map +4 -4
  46. package/dist/elements/cfpb-tag-topic/index.js +4 -4
  47. package/dist/elements/cfpb-tag-topic/index.js.map +1 -1
  48. package/dist/elements/index.css +2 -0
  49. package/dist/elements/index.css.map +7 -0
  50. package/dist/elements/index.js +7 -6
  51. package/dist/elements/index.js.map +4 -4
  52. package/dist/index.js +7 -6
  53. package/dist/index.js.map +4 -4
  54. package/dist/utilities/index.js +1 -1
  55. package/dist/utilities/index.js.map +3 -3
  56. package/package.json +1 -1
  57. package/src/components/cfpb-expandables/expandable.js +3 -0
  58. package/src/components/cfpb-forms/multiselect.js +1 -1
  59. package/src/elements/abstracts/custom-props.css +123 -0
  60. package/src/elements/abstracts/grid-mixins.scss +83 -0
  61. package/src/elements/abstracts/heading-mixins.scss +346 -0
  62. package/src/elements/abstracts/index.scss +7 -0
  63. package/src/elements/abstracts/media-queries.scss +35 -0
  64. package/src/elements/abstracts/sizing-vars.scss +65 -0
  65. package/src/elements/abstracts/vars-breakpoints.scss +16 -0
  66. package/src/elements/abstracts/vars.css +79 -0
  67. package/src/elements/base/base.scss +375 -0
  68. package/src/elements/base/font.scss +27 -0
  69. package/src/elements/base/index.scss +3 -0
  70. package/src/elements/base/normalize.scss +290 -0
  71. package/src/elements/cfpb-button/cfpb-button-group.scss +10 -0
  72. package/src/elements/cfpb-button/cfpb-button-link.scss +96 -0
  73. package/src/elements/cfpb-button/cfpb-button.component.scss +11 -4
  74. package/src/elements/cfpb-button/cfpb-button.scss +222 -0
  75. package/src/elements/cfpb-button/index.js +28 -29
  76. package/src/elements/cfpb-button/vars.css +30 -0
  77. package/src/elements/cfpb-checkbox-icon/cfpb-checkbox-icon.component.scss +88 -0
  78. package/src/elements/cfpb-checkbox-icon/index.js +104 -0
  79. package/src/elements/cfpb-expandable/cfpb-expandable.component.scss +218 -0
  80. package/src/elements/cfpb-expandable/index.js +127 -0
  81. package/src/elements/cfpb-file-upload/cfpb-file-upload.component.scss +2 -2
  82. package/src/elements/cfpb-file-upload/index.js +16 -18
  83. package/src/elements/cfpb-form-alert/cfpb-form-alert.component.scss +36 -0
  84. package/src/elements/cfpb-form-alert/index.js +55 -0
  85. package/src/elements/cfpb-form-choice/cfpb-form-choice.component.scss +42 -81
  86. package/src/elements/cfpb-form-choice/index.js +58 -18
  87. package/src/elements/cfpb-form-search/cfpb-form-search.component.scss +54 -0
  88. package/src/elements/cfpb-form-search/index.js +194 -0
  89. package/src/elements/cfpb-form-search-input/cfpb-form-search-input.component.scss +217 -0
  90. package/src/elements/cfpb-form-search-input/index.js +136 -0
  91. package/src/elements/cfpb-icon-text/cfpb-icon-text.component.scss +32 -39
  92. package/src/elements/cfpb-icon-text/index.js +32 -104
  93. package/src/elements/cfpb-label/cfpb-label.component.scss +2 -2
  94. package/src/elements/cfpb-label/index.js +6 -9
  95. package/src/elements/cfpb-list/cfpb-list.component.scss +23 -0
  96. package/src/elements/cfpb-list/index.js +357 -0
  97. package/src/elements/cfpb-list/index.spec.js +169 -0
  98. package/src/elements/cfpb-list-item/cfpb-list-item.component.scss +69 -0
  99. package/src/elements/cfpb-list-item/index.js +215 -0
  100. package/src/elements/cfpb-pagination/cfpb-pagination.component.scss +2 -7
  101. package/src/elements/cfpb-pagination/index.js +6 -8
  102. package/src/elements/cfpb-select/cfpb-select.component.scss +241 -0
  103. package/src/elements/cfpb-select/index.js +381 -0
  104. package/src/elements/cfpb-tag-filter/cfpb-tag-filter.component.scss +6 -3
  105. package/src/elements/cfpb-tag-filter/index.js +15 -7
  106. package/src/elements/cfpb-tag-group/cfpb-tag-group.component.scss +2 -2
  107. package/src/elements/cfpb-tag-group/index.js +53 -6
  108. package/src/elements/cfpb-tag-topic/index.js +5 -7
  109. package/src/elements/cfpb-utilities/parse-child-data.js +50 -0
  110. package/src/elements/cfpb-utilities/parse-child-data.spec.js +56 -0
  111. package/src/elements/cfpb-utilities/search-service.js +46 -0
  112. package/src/elements/cfpb-utilities/search-service.spec.js +138 -0
  113. package/src/elements/cfpb-utilities/transition/transition.scss +98 -0
  114. package/src/elements/index.js +7 -1
  115. package/src/index.scss +11 -0
  116. package/src/tokens/abstracts/custom-props.json +1642 -0
  117. package/src/tokens/abstracts/vars.json +1319 -0
  118. package/src/tokens/cfpb-button/vars.json +436 -0
  119. package/src/utilities/transition/max-height-transition.js +74 -0
  120. package/dist/elements/cfpb-checkbox/index.js +0 -29
  121. package/src/elements/cfpb-multiselect/cfpb-multiselect.component.scss +0 -225
  122. package/src/elements/cfpb-multiselect/index.js +0 -444
  123. package/src/elements/cfpb-multiselect/multiselect-model.js +0 -288
  124. package/src/elements/cfpb-multiselect/multiselect-model.spec.js +0 -236
@@ -0,0 +1,357 @@
1
+ import { LitElement, html, css, unsafeCSS } from 'lit';
2
+ import { ref, createRef } from 'lit/directives/ref.js';
3
+ import styles from './cfpb-list.component.scss';
4
+ import { CfpbListItem } from '../cfpb-list-item';
5
+ import { parseChildData } from '../cfpb-utilities/parse-child-data';
6
+
7
+ export class CfpbList extends LitElement {
8
+ static styles = css`
9
+ ${unsafeCSS(styles)}
10
+ `;
11
+
12
+ #container = createRef();
13
+ #items = [];
14
+ #checkedItems = [];
15
+ #visibleItems = [];
16
+ #focusedIndex = 0;
17
+ #internalSync = false;
18
+
19
+ // WeakMap to store per-item click listeners.
20
+ #clickListeners = new WeakMap();
21
+
22
+ /**
23
+ * @property {Array} childData - Structure data to create child components.
24
+ * @property {boolean} multiple - Whether the select supports multiple or not.
25
+ * @property {string} type - List item type: plain, check, or checkbox.
26
+ * @property {string} ariaLabel - The aria-label for the list container.
27
+ * @returns {object} The map of properties.
28
+ */
29
+ static properties = {
30
+ childData: { type: Array, attribute: 'childdata' },
31
+ multiple: { type: Boolean, reflect: true },
32
+ type: { type: String, reflect: true },
33
+ ariaLabel: { type: String, attribute: 'aria-label' },
34
+ };
35
+
36
+ constructor() {
37
+ super();
38
+ this.childData = '';
39
+ this.multiple = false;
40
+ this.type = 'plain';
41
+ this.ariaLabel = '';
42
+ }
43
+
44
+ firstUpdated() {
45
+ this.#syncItems();
46
+ }
47
+
48
+ updated(changedProps) {
49
+ if (!this.#internalSync && changedProps.has('childData')) {
50
+ const parsed = parseChildData(this.childData);
51
+ if (parsed) this.#renderItemsFromData(parsed);
52
+ }
53
+
54
+ if (changedProps.has('type')) {
55
+ this.#applyTypeToItems();
56
+ }
57
+ }
58
+
59
+ // -------------------------
60
+ // ITEMS ACCESS
61
+ // -------------------------
62
+ get items() {
63
+ return this.#items;
64
+ }
65
+
66
+ get checkedItems() {
67
+ return this.#checkedItems;
68
+ }
69
+
70
+ get visibleItems() {
71
+ return this.#visibleItems;
72
+ }
73
+
74
+ get visibleCheckedItems() {
75
+ return this.#visibleItems.filter((item) => item.checked);
76
+ }
77
+
78
+ // -------------------------
79
+ // RENDER ITEMS
80
+ // -------------------------
81
+ #renderItemsFromData(itemsArray) {
82
+ // Remove all children except <template> and <noscript>.
83
+ [...this.children].forEach((child) => {
84
+ if (child.tagName !== 'TEMPLATE' && child.tagName !== 'NOSCRIPT')
85
+ child.remove();
86
+ });
87
+
88
+ let firstChecked;
89
+ itemsArray.forEach((data) => {
90
+ const item = document.createElement('cfpb-list-item');
91
+ item.textContent = data.value ?? '';
92
+ if ('disabled' in data) item.disabled = data.disabled;
93
+ if ('hidden' in data) item.hidden = data.hidden;
94
+ if ('href' in data) item.href = data.href;
95
+ item.type = data.type ?? this.type;
96
+ if (this.multiple) {
97
+ if ('checked' in data) item.checked = data.checked;
98
+ } else if (!firstChecked && data.checked) {
99
+ firstChecked = true;
100
+ if ('checked' in data) item.checked = true;
101
+ }
102
+
103
+ this.appendChild(item);
104
+ });
105
+
106
+ this.#syncItems();
107
+ }
108
+
109
+ // -------------------------
110
+ // SYNC ITEMS & LISTENERS
111
+ // -------------------------
112
+ #syncItems() {
113
+ // Collect items.
114
+ this.#items = [...this.querySelectorAll('cfpb-list-item')];
115
+
116
+ // Ensure each item has a type.
117
+ this.#items.forEach((item) => {
118
+ if (!item.type) item.type = this.type;
119
+ });
120
+
121
+ // Visible items.
122
+ this.#visibleItems = this.#items.filter((item) => !item.hidden);
123
+
124
+ // Populate initial checked states.
125
+ if (this.multiple) {
126
+ this.#checkedItems = this.#items.filter((item) => item.checked);
127
+ } else {
128
+ const firstChecked = this.#items.find((item) => item.checked);
129
+ this.#checkedItems = firstChecked ? [firstChecked] : [];
130
+
131
+ // Uncheck all others.
132
+ this.#items.forEach((item) => {
133
+ if (item !== firstChecked) item.checked = false;
134
+ });
135
+ }
136
+
137
+ // Assign tabindex, role, listeners.
138
+ this.#items.forEach((item, index) => {
139
+ item.setAttribute('tabindex', index === 0 ? '0' : '-1');
140
+ item.setAttribute('role', 'option');
141
+
142
+ // Remove prior listener if present.
143
+ const prev = this.#clickListeners.get(item);
144
+ if (prev) item.removeEventListener('click-item', prev);
145
+
146
+ // Listener that toggles the item before handling.
147
+ const listener = (evt) => {
148
+ // Prevent actual click bubbling to list container.
149
+ evt.stopPropagation();
150
+
151
+ this.#handleToggle(item, item.checked, index);
152
+ };
153
+
154
+ item.addEventListener('click-item', listener);
155
+ this.#clickListeners.set(item, listener);
156
+
157
+ // Track focus index.
158
+ item.addEventListener('focus', () => {
159
+ this.#focusedIndex = index;
160
+ });
161
+ });
162
+
163
+ this.dispatchEvent(
164
+ new CustomEvent('items-ready', {
165
+ detail: {
166
+ items: this.#items,
167
+ checkedItems: this.#checkedItems,
168
+ visibleItems: this.#visibleItems,
169
+ visibleCheckedItems: this.visibleCheckedItems,
170
+ },
171
+ bubbles: true,
172
+ composed: true,
173
+ }),
174
+ );
175
+ }
176
+
177
+ #syncChildDataFromItems() {
178
+ const data = this.#items.map((item) => ({
179
+ value: item.value,
180
+ label: item.textContent.trim(),
181
+ checked: item.checked,
182
+ disabled: item.disabled,
183
+ }));
184
+
185
+ this.#internalSync = true;
186
+ this.childData = data;
187
+ this.#internalSync = false;
188
+ }
189
+
190
+ #applyTypeToItems() {
191
+ if (!['plain', 'check', 'checkbox'].includes(this.type)) {
192
+ console.warn(`<cfpb-list>: invalid type "${this.type}".`);
193
+ return;
194
+ }
195
+ this.#items.forEach((item) => (item.type = this.type));
196
+ }
197
+
198
+ #handleToggle(element, isChecked, index) {
199
+ if (this.multiple) {
200
+ if (isChecked) {
201
+ // Add if not already present.
202
+ if (!this.#checkedItems.includes(element)) {
203
+ this.#checkedItems.push(element);
204
+ }
205
+ } else {
206
+ // Remove cleanly.
207
+ this.#checkedItems = this.#checkedItems.filter(
208
+ (item) => item !== element,
209
+ );
210
+ }
211
+ } else {
212
+ if (isChecked) {
213
+ // Select this item, uncheck all others.
214
+ this.#items.forEach((item) => {
215
+ if (item !== element) item.checked = false;
216
+ });
217
+ this.#checkedItems = [element];
218
+ } else {
219
+ // Item is unchecked -> clear selection.
220
+ this.#checkedItems.forEach((item) => (item.checked = false));
221
+ this.#checkedItems = [];
222
+ }
223
+ }
224
+
225
+ this.#syncChildDataFromItems();
226
+
227
+ this.dispatchEvent(
228
+ new CustomEvent('item-click', {
229
+ detail: { index, value: element.value, element },
230
+ bubbles: true,
231
+ composed: true,
232
+ }),
233
+ );
234
+ }
235
+
236
+ // -------------------------
237
+ // FILTER & FOCUS
238
+ // -------------------------
239
+
240
+ /**
241
+ * @param {Array} queryList - List of search words.
242
+ * @returns {Array} List of visible list items.
243
+ */
244
+ filterItems(queryList) {
245
+ let firstIndex = 0;
246
+ this.#visibleItems = [];
247
+
248
+ this.items.forEach((item) => {
249
+ const valueLower = item.value.toLowerCase();
250
+ const matches = queryList.some((q) =>
251
+ valueLower.includes(q.toLowerCase()),
252
+ );
253
+ if (!matches) firstIndex++;
254
+ else this.#visibleItems.push(item);
255
+ item.hidden = !matches;
256
+ });
257
+
258
+ this.#focusedIndex = firstIndex;
259
+
260
+ this.#broadcastFiltered();
261
+
262
+ return this.#visibleItems;
263
+ }
264
+
265
+ showAllItems() {
266
+ this.items.forEach((item) => (item.hidden = false));
267
+ this.#focusedIndex = 0;
268
+ this.#visibleItems = this.#items;
269
+
270
+ this.#broadcastFiltered();
271
+ }
272
+
273
+ #broadcastFiltered() {
274
+ this.dispatchEvent(
275
+ new CustomEvent('items-filter', {
276
+ detail: {
277
+ items: this.#items,
278
+ checkedItems: this.#checkedItems,
279
+ visibleItems: this.#visibleItems,
280
+ visibleCheckedItems: this.visibleCheckedItems,
281
+ },
282
+ bubbles: true,
283
+ composed: true,
284
+ }),
285
+ );
286
+ }
287
+
288
+ focusItemAt(index) {
289
+ const visibleItems = this.items.filter((item) => !item.hidden);
290
+ if (!visibleItems.length) return;
291
+
292
+ const normalizedIndex =
293
+ ((index % visibleItems.length) + visibleItems.length) %
294
+ visibleItems.length;
295
+ const item = visibleItems[normalizedIndex];
296
+ item.focus();
297
+ this.#focusedIndex = normalizedIndex;
298
+ }
299
+
300
+ #onFocus(evt) {
301
+ // If the focus is on the container itself (not an item), set index to -1.
302
+ if (evt.target === this.#container.value) {
303
+ this.#focusedIndex = -1;
304
+ }
305
+ }
306
+
307
+ // -------------------------
308
+ // KEYBOARD NAVIGATION
309
+ // -------------------------
310
+ #onKeyDown(evt) {
311
+ const visibleItems = this.items.filter((item) => !item.hidden);
312
+ if (!visibleItems.length) return;
313
+ const last = visibleItems.length - 1;
314
+
315
+ switch (evt.key) {
316
+ case 'ArrowDown':
317
+ evt.preventDefault();
318
+ this.focusItemAt(this.#focusedIndex + 1);
319
+ break;
320
+ case 'ArrowUp':
321
+ evt.preventDefault();
322
+ this.focusItemAt(this.#focusedIndex - 1);
323
+ break;
324
+ case 'Home':
325
+ evt.preventDefault();
326
+ this.focusItemAt(0);
327
+ break;
328
+ case 'End':
329
+ evt.preventDefault();
330
+ this.focusItemAt(last);
331
+ break;
332
+ }
333
+ }
334
+
335
+ render() {
336
+ return html`
337
+ <div
338
+ role="listbox"
339
+ tabindex="0"
340
+ @keydown=${this.#onKeyDown}
341
+ @focus=${this.#onFocus}
342
+ aria-label=${this.ariaLabel}
343
+ ?aria-multiselectable=${this.multiple}
344
+ ${ref(this.#container)}
345
+ >
346
+ <slot></slot>
347
+ </div>
348
+ `;
349
+ }
350
+
351
+ static init() {
352
+ CfpbListItem.init();
353
+ if (!window.customElements.get('cfpb-list')) {
354
+ window.customElements.define('cfpb-list', CfpbList);
355
+ }
356
+ }
357
+ }
@@ -0,0 +1,169 @@
1
+ import { jest } from '@jest/globals';
2
+ import { CfpbList } from './index';
3
+
4
+ beforeAll(() => {
5
+ CfpbList.init();
6
+ });
7
+
8
+ describe('<cfpb-list> tests', () => {
9
+ let list;
10
+
11
+ beforeEach(() => {
12
+ list = document.createElement('cfpb-list');
13
+ document.body.appendChild(list);
14
+ });
15
+
16
+ afterEach(() => {
17
+ document.body.innerHTML = '';
18
+ });
19
+
20
+ test('renders childData and sets checked state (single)', async () => {
21
+ list.childData = JSON.stringify([
22
+ { value: 'A', checked: true },
23
+ { value: 'B', checked: true },
24
+ { value: 'C' },
25
+ ]);
26
+
27
+ await list.updateComplete;
28
+
29
+ expect(list.items.length).toBe(3);
30
+ // Only first checked stays true in single select
31
+ expect(list.items[0].checked).toBe(true);
32
+ expect(list.items[1].checked).toBe(false);
33
+ expect(list.items[2].checked).toBe(false);
34
+ expect(list.checkedItems).toEqual([list.items[0]]);
35
+ });
36
+
37
+ test('multiple selection mode allows multiple checked items', async () => {
38
+ list.multiple = true;
39
+ list.childData = JSON.stringify([
40
+ { value: 'A', checked: true },
41
+ { value: 'B', checked: true },
42
+ { value: 'C' },
43
+ ]);
44
+
45
+ await list.updateComplete;
46
+
47
+ expect(list.checkedItems.length).toBe(2);
48
+ expect(list.checkedItems).toContain(list.items[0]);
49
+ expect(list.checkedItems).toContain(list.items[1]);
50
+
51
+ // Uncheck an item
52
+ const event = new CustomEvent('click-item', {
53
+ bubbles: true,
54
+ composed: true,
55
+ });
56
+ list.items[0].checked = false;
57
+ list.items[0].dispatchEvent(event);
58
+
59
+ expect(list.checkedItems).toEqual([list.items[1]]);
60
+ });
61
+
62
+ test('click-item toggles single selection', async () => {
63
+ list.childData = JSON.stringify([{ value: 'A' }, { value: 'B' }]);
64
+
65
+ await list.updateComplete;
66
+
67
+ const clickSpy = jest.fn();
68
+ list.addEventListener('item-click', clickSpy);
69
+
70
+ // Click first item
71
+ list.items[0].checked = true;
72
+ list.items[0].dispatchEvent(
73
+ new CustomEvent('click-item', { bubbles: true, composed: true }),
74
+ );
75
+
76
+ expect(list.checkedItems).toEqual([list.items[0]]);
77
+ expect(clickSpy).toHaveBeenCalledTimes(1);
78
+
79
+ // Click again to uncheck
80
+ list.items[0].checked = false;
81
+ list.items[0].dispatchEvent(
82
+ new CustomEvent('click-item', { bubbles: true, composed: true }),
83
+ );
84
+ expect(list.checkedItems).toEqual([]);
85
+ });
86
+
87
+ test('replaces prior click listeners on items', async () => {
88
+ list.childData = JSON.stringify([{ value: 'X' }]);
89
+ await list.updateComplete;
90
+
91
+ const listenerSpy = jest.fn();
92
+ list.addEventListener('item-click', listenerSpy);
93
+
94
+ // Trigger click-item event
95
+ const event = new CustomEvent('click-item', {
96
+ bubbles: true,
97
+ composed: true,
98
+ });
99
+ list.items[0].dispatchEvent(event);
100
+
101
+ expect(listenerSpy).toHaveBeenCalled();
102
+ });
103
+
104
+ test('filterItems hides items and sets focused index', async () => {
105
+ list.childData = JSON.stringify([
106
+ { value: 'A' },
107
+ { value: 'B' },
108
+ { value: 'C' },
109
+ ]);
110
+ await list.updateComplete;
111
+
112
+ list.filterItems(['B']);
113
+ expect(list.visibleItems.length).toBe(1);
114
+ expect(list.visibleItems[0].value).toBe('B');
115
+ expect(list.items[0].hidden).toBe(true);
116
+ expect(list.items[1].hidden).toBe(false);
117
+ expect(list.items[2].hidden).toBe(true);
118
+ });
119
+
120
+ test('arrow keys skip hidden items', async () => {
121
+ list.childData = JSON.stringify([
122
+ { value: 'A' },
123
+ { value: 'B' },
124
+ { value: 'C' },
125
+ ]);
126
+ await list.updateComplete;
127
+
128
+ list.filterItems(['C']); // only C visible
129
+
130
+ list.focusItemAt(0);
131
+ expect(document.activeElement.value).toBe('C');
132
+
133
+ // Press ArrowDown, should wrap to same visible item
134
+ list.shadowRoot
135
+ .querySelector('div')
136
+ .dispatchEvent(
137
+ new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
138
+ );
139
+ expect(document.activeElement.value).toBe('C');
140
+
141
+ // Press ArrowUp
142
+ list.shadowRoot
143
+ .querySelector('div')
144
+ .dispatchEvent(
145
+ new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }),
146
+ );
147
+ expect(document.activeElement.value).toBe('C');
148
+ });
149
+
150
+ test('showAllItems unhides all items', async () => {
151
+ list.childData = JSON.stringify([
152
+ { value: 'A', hidden: true },
153
+ { value: 'B', hidden: true },
154
+ ]);
155
+ await list.updateComplete;
156
+
157
+ list.showAllItems();
158
+ expect(list.items.every((i) => i.hidden === false)).toBe(true);
159
+ expect(list.visibleItems.length).toBe(2);
160
+ });
161
+
162
+ test('invalid childData logs error', async () => {
163
+ console.error = jest.fn();
164
+ list.childData = 'not-json';
165
+ await list.updateComplete;
166
+
167
+ expect(console.error).toHaveBeenCalled();
168
+ });
169
+ });
@@ -0,0 +1,69 @@
1
+ @use 'sass:math';
2
+ @use '@cfpb/cfpb-design-system/src/elements/abstracts' as *;
3
+
4
+ :host {
5
+ // Theme variables.
6
+ --list-item-bg-hover: var(--gray-5);
7
+ --list-item-border-focus: var(--pacific);
8
+
9
+ display: block;
10
+
11
+ .container {
12
+ display: block;
13
+ padding: 6.5px 10px;
14
+ }
15
+
16
+ .checkbox {
17
+ display: inline-grid;
18
+
19
+ // 30px is width of checkbox/radio button plus the needed padding.
20
+ grid-template-columns: math.div(30px, $base-font-size-px) + em auto;
21
+ vertical-align: top;
22
+
23
+ // Wrap long words in narrow form fields to prevent clipping
24
+ overflow-wrap: anywhere;
25
+ }
26
+
27
+ .selectable {
28
+ cursor: pointer;
29
+
30
+ &:hover {
31
+ background: var(--list-item-bg-hover);
32
+ transition: background-color 0.2s;
33
+ cursor: pointer;
34
+ }
35
+ }
36
+ }
37
+
38
+ // Remove default focus ring.
39
+ :host(:focus) {
40
+ outline: none;
41
+ }
42
+
43
+ // Add custom focus ring.
44
+ :host(:not([disabled]):focus-within) {
45
+ outline: 1px dotted var(--list-item-border-focus);
46
+ outline-offset: 2px;
47
+ }
48
+
49
+ :host([checked]) {
50
+ .checkbox::before {
51
+ --cfpb-background-icon-svg: 'approved';
52
+
53
+ background-size: auto $cf-icon-height;
54
+ background-repeat: no-repeat;
55
+ background-position: center 0;
56
+ }
57
+ }
58
+
59
+ :host([type='plain']) {
60
+ .checkbox::before {
61
+ border-color: transparent;
62
+ outline-color: transparent;
63
+ background-color: transparent;
64
+ }
65
+ }
66
+
67
+ :host([hidden]) {
68
+ display: none;
69
+ }