@cfpb/cfpb-design-system 4.3.0 → 4.3.2

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 (168) hide show
  1. package/CHANGELOG.md +28 -1
  2. package/dist/components/cfpb-buttons/index.css +1 -1
  3. package/dist/components/cfpb-buttons/index.css.map +2 -2
  4. package/dist/components/cfpb-buttons/index.js +1 -1
  5. package/dist/components/cfpb-buttons/index.js.map +1 -1
  6. package/dist/components/cfpb-expandables/index.css +1 -1
  7. package/dist/components/cfpb-expandables/index.css.map +2 -2
  8. package/dist/components/cfpb-expandables/index.js +1 -1
  9. package/dist/components/cfpb-expandables/index.js.map +2 -2
  10. package/dist/components/cfpb-forms/index.css +1 -1
  11. package/dist/components/cfpb-forms/index.css.map +2 -2
  12. package/dist/components/cfpb-forms/index.js +1 -1
  13. package/dist/components/cfpb-forms/index.js.map +2 -2
  14. package/dist/components/cfpb-icons/index.css +1 -1
  15. package/dist/components/cfpb-icons/index.css.map +2 -2
  16. package/dist/components/cfpb-icons/index.js +1 -1
  17. package/dist/components/cfpb-icons/index.js.map +1 -1
  18. package/dist/components/cfpb-layout/index.css +1 -1
  19. package/dist/components/cfpb-layout/index.css.map +2 -2
  20. package/dist/components/cfpb-layout/index.js +1 -1
  21. package/dist/components/cfpb-layout/index.js.map +1 -1
  22. package/dist/components/cfpb-notifications/index.css +1 -1
  23. package/dist/components/cfpb-notifications/index.css.map +2 -2
  24. package/dist/components/cfpb-notifications/index.js +1 -1
  25. package/dist/components/cfpb-notifications/index.js.map +1 -1
  26. package/dist/components/cfpb-pagination/index.css +1 -1
  27. package/dist/components/cfpb-pagination/index.css.map +2 -2
  28. package/dist/components/cfpb-pagination/index.js +1 -1
  29. package/dist/components/cfpb-pagination/index.js.map +1 -1
  30. package/dist/components/cfpb-tables/index.css +1 -1
  31. package/dist/components/cfpb-tables/index.css.map +2 -2
  32. package/dist/components/cfpb-tables/index.js +1 -1
  33. package/dist/components/cfpb-tables/index.js.map +1 -1
  34. package/dist/components/cfpb-tooltips/index.css +1 -1
  35. package/dist/components/cfpb-tooltips/index.css.map +2 -2
  36. package/dist/components/cfpb-tooltips/index.js +1 -1
  37. package/dist/components/cfpb-tooltips/index.js.map +1 -1
  38. package/dist/components/cfpb-typography/index.css +1 -1
  39. package/dist/components/cfpb-typography/index.css.map +2 -2
  40. package/dist/components/cfpb-typography/index.js +1 -1
  41. package/dist/components/cfpb-typography/index.js.map +1 -1
  42. package/dist/elements/abstracts/index.js +2 -0
  43. package/dist/elements/abstracts/index.js.map +7 -0
  44. package/dist/elements/base/index.css +3 -0
  45. package/dist/elements/base/index.css.map +7 -0
  46. package/dist/elements/base/index.js +2 -0
  47. package/dist/elements/base/index.js.map +7 -0
  48. package/dist/elements/cfpb-button/index.js +4 -4
  49. package/dist/elements/cfpb-button/index.js.map +2 -2
  50. package/dist/elements/cfpb-checkbox-icon/index.js +3 -3
  51. package/dist/elements/cfpb-checkbox-icon/index.js.map +2 -2
  52. package/dist/elements/cfpb-expandable/index.js +4 -4
  53. package/dist/elements/cfpb-expandable/index.js.map +2 -2
  54. package/dist/elements/cfpb-file-upload/index.js +4 -4
  55. package/dist/elements/cfpb-file-upload/index.js.map +3 -3
  56. package/dist/elements/cfpb-form-alert/index.js +3 -3
  57. package/dist/elements/cfpb-form-alert/index.js.map +2 -2
  58. package/dist/elements/cfpb-form-choice/index.js +3 -3
  59. package/dist/elements/cfpb-form-choice/index.js.map +2 -2
  60. package/dist/elements/cfpb-form-search/index.js +3 -3
  61. package/dist/elements/cfpb-form-search/index.js.map +3 -3
  62. package/dist/elements/cfpb-form-search-input/index.js +3 -3
  63. package/dist/elements/cfpb-form-search-input/index.js.map +3 -3
  64. package/dist/elements/cfpb-icon-text/index.js +1 -1
  65. package/dist/elements/cfpb-icon-text/index.js.map +2 -2
  66. package/dist/elements/cfpb-label/index.js +1 -1
  67. package/dist/elements/cfpb-label/index.js.map +2 -2
  68. package/dist/elements/cfpb-list/index.js +3 -3
  69. package/dist/elements/cfpb-list/index.js.map +3 -3
  70. package/dist/elements/cfpb-list-item/index.js +3 -3
  71. package/dist/elements/cfpb-list-item/index.js.map +2 -2
  72. package/dist/elements/cfpb-pagination/index.js +3 -3
  73. package/dist/elements/cfpb-pagination/index.js.map +2 -2
  74. package/dist/elements/cfpb-select/index.js +4 -4
  75. package/dist/elements/cfpb-select/index.js.map +4 -4
  76. package/dist/elements/cfpb-tag-filter/index.js +2 -2
  77. package/dist/elements/cfpb-tag-filter/index.js.map +2 -2
  78. package/dist/elements/cfpb-tag-group/index.js +1 -1
  79. package/dist/elements/cfpb-tag-group/index.js.map +2 -2
  80. package/dist/elements/cfpb-tag-topic/index.js +3 -3
  81. package/dist/elements/cfpb-tag-topic/index.js.map +2 -2
  82. package/dist/elements/index.js +7 -7
  83. package/dist/elements/index.js.map +4 -4
  84. package/dist/index.css +1 -1
  85. package/dist/index.css.map +3 -3
  86. package/dist/index.js +7 -7
  87. package/dist/index.js.map +4 -4
  88. package/dist/utilities/index.css +1 -1
  89. package/dist/utilities/index.css.map +2 -2
  90. package/dist/utilities/index.js +1 -1
  91. package/dist/utilities/index.js.map +2 -2
  92. package/package.json +1 -1
  93. package/src/components/cfpb-buttons/button-group.scss +1 -1
  94. package/src/components/cfpb-buttons/button-link.scss +10 -54
  95. package/src/components/cfpb-buttons/button.scss +3 -3
  96. package/src/components/cfpb-buttons/vars.scss +1 -1
  97. package/src/components/cfpb-expandables/expandable-group.scss +1 -1
  98. package/src/components/cfpb-expandables/expandable.scss +1 -1
  99. package/src/components/cfpb-expandables/summary.scss +1 -1
  100. package/src/components/cfpb-forms/form-alert.scss +1 -1
  101. package/src/components/cfpb-forms/form-field.scss +6 -6
  102. package/src/components/cfpb-forms/form.scss +1 -1
  103. package/src/components/cfpb-forms/label.scss +2 -2
  104. package/src/components/cfpb-forms/multiselect.scss +1 -1
  105. package/src/components/cfpb-forms/range.scss +7 -7
  106. package/src/components/cfpb-forms/search-input.scss +1 -1
  107. package/src/components/cfpb-forms/select.scss +1 -1
  108. package/src/components/cfpb-forms/tag.scss +1 -1
  109. package/src/components/cfpb-forms/text-input.scss +1 -1
  110. package/src/components/cfpb-icons/icon.scss +1 -1
  111. package/src/components/cfpb-layout/card-group.scss +1 -1
  112. package/src/components/cfpb-layout/card.scss +1 -1
  113. package/src/components/cfpb-layout/email-signup.scss +1 -1
  114. package/src/components/cfpb-layout/featured-content-module.scss +1 -1
  115. package/src/components/cfpb-layout/hero.scss +1 -1
  116. package/src/components/cfpb-layout/layout.scss +9 -9
  117. package/src/components/cfpb-layout/well.scss +1 -1
  118. package/src/components/cfpb-notifications/banner.scss +1 -1
  119. package/src/components/cfpb-notifications/notification.scss +1 -1
  120. package/src/components/cfpb-pagination/pagination.scss +1 -1
  121. package/src/components/cfpb-tables/table.scss +1 -1
  122. package/src/components/cfpb-tooltips/tooltip.scss +1 -1
  123. package/src/components/cfpb-typography/date.scss +1 -1
  124. package/src/components/cfpb-typography/list.scss +1 -1
  125. package/src/components/cfpb-typography/meta-header.scss +1 -1
  126. package/src/components/cfpb-typography/mixins.scss +1 -1
  127. package/src/components/cfpb-typography/pull-quote.scss +1 -1
  128. package/src/components/cfpb-typography/slug-header.scss +1 -1
  129. package/src/components/cfpb-typography/tagline.scss +1 -1
  130. package/src/elements/abstracts/custom-props.css +1 -1
  131. package/src/elements/abstracts/sizing-vars.scss +2 -1
  132. package/src/elements/abstracts/vars.css +1 -1
  133. package/src/elements/base/font.scss +1 -1
  134. package/src/elements/cfpb-button/cfpb-button-group.scss +8 -6
  135. package/src/elements/cfpb-button/cfpb-button-link.scss +54 -47
  136. package/src/elements/cfpb-button/cfpb-button.scss +168 -172
  137. package/src/elements/cfpb-button/index.js +16 -1
  138. package/src/elements/cfpb-button/vars.css +1 -1
  139. package/src/elements/cfpb-expandable/cfpb-expandable.component.scss +2 -2
  140. package/src/elements/cfpb-file-upload/index.js +9 -9
  141. package/src/elements/cfpb-form-search-input/index.js +4 -0
  142. package/src/elements/cfpb-icon-text/cfpb-icon-text.component.scss +2 -1
  143. package/src/elements/cfpb-list/cfpb-list.component.scss +19 -8
  144. package/src/elements/cfpb-list/index.js +62 -40
  145. package/src/elements/cfpb-list/index.spec.js +66 -21
  146. package/src/elements/cfpb-select/cfpb-select.component.scss +2 -2
  147. package/src/elements/cfpb-select/index.js +60 -70
  148. package/src/elements/cfpb-select/multiple-select-event-proxy.js +88 -0
  149. package/src/elements/cfpb-select/single-select-event-proxy.js +47 -0
  150. package/src/elements/cfpb-tag-topic/cfpb-tag-topic.component.scss +2 -2
  151. package/src/index.js +2 -2
  152. package/src/index.scss +3 -2
  153. package/src/utilities/breakpoint-state.js +1 -1
  154. package/src/utilities/utilities.scss +1 -1
  155. package/src/abstracts/custom-props.scss +0 -175
  156. package/src/abstracts/grid-mixins.scss +0 -82
  157. package/src/abstracts/heading-mixins.scss +0 -345
  158. package/src/abstracts/index.scss +0 -6
  159. package/src/abstracts/media-queries.scss +0 -35
  160. package/src/abstracts/vars-breakpoints.scss +0 -16
  161. package/src/abstracts/vars.scss +0 -184
  162. package/src/base/base.scss +0 -375
  163. package/src/base/font.scss +0 -27
  164. package/src/base/index.scss +0 -3
  165. package/src/base/normalize.scss +0 -290
  166. /package/src/{abstracts → elements/abstracts}/index.js +0 -0
  167. /package/src/{abstracts → elements/abstracts}/vars-breakpoints.js +0 -0
  168. /package/src/{base → elements/base}/index.js +0 -0
@@ -9,12 +9,14 @@ export class CfpbList extends LitElement {
9
9
  ${unsafeCSS(styles)}
10
10
  `;
11
11
 
12
+ #internalSync = false;
12
13
  #container = createRef();
13
14
  #items = [];
14
15
  #checkedItems = [];
15
16
  #visibleItems = [];
16
- #focusedIndex = 0;
17
- #internalSync = false;
17
+
18
+ // index in visibleItems
19
+ #focusedIndex = -1;
18
20
 
19
21
  // WeakMap to store per-item click listeners.
20
22
  #clickListeners = new WeakMap();
@@ -50,7 +52,6 @@ export class CfpbList extends LitElement {
50
52
  const parsed = parseChildData(this.childData);
51
53
  if (parsed) this.#renderItemsFromData(parsed);
52
54
  }
53
-
54
55
  if (changedProps.has('type')) {
55
56
  this.#applyTypeToItems();
56
57
  }
@@ -62,15 +63,12 @@ export class CfpbList extends LitElement {
62
63
  get items() {
63
64
  return this.#items;
64
65
  }
65
-
66
66
  get checkedItems() {
67
67
  return this.#checkedItems;
68
68
  }
69
-
70
69
  get visibleItems() {
71
70
  return this.#visibleItems;
72
71
  }
73
-
74
72
  get visibleCheckedItems() {
75
73
  return this.#visibleItems.filter((item) => item.checked);
76
74
  }
@@ -79,13 +77,12 @@ export class CfpbList extends LitElement {
79
77
  // RENDER ITEMS
80
78
  // -------------------------
81
79
  #renderItemsFromData(itemsArray) {
82
- // Remove all children except <template> and <noscript>.
83
80
  [...this.children].forEach((child) => {
84
81
  if (child.tagName !== 'TEMPLATE' && child.tagName !== 'NOSCRIPT')
85
82
  child.remove();
86
83
  });
87
84
 
88
- let firstChecked;
85
+ let firstChecked = null;
89
86
  itemsArray.forEach((data) => {
90
87
  const item = document.createElement('cfpb-list-item');
91
88
  item.textContent = data.value ?? '';
@@ -93,11 +90,12 @@ export class CfpbList extends LitElement {
93
90
  if ('hidden' in data) item.hidden = data.hidden;
94
91
  if ('href' in data) item.href = data.href;
95
92
  item.type = data.type ?? this.type;
93
+
96
94
  if (this.multiple) {
97
95
  if ('checked' in data) item.checked = data.checked;
98
96
  } else if (!firstChecked && data.checked) {
99
- firstChecked = true;
100
- if ('checked' in data) item.checked = true;
97
+ firstChecked = item;
98
+ item.checked = true;
101
99
  }
102
100
 
103
101
  this.appendChild(item);
@@ -136,7 +134,7 @@ export class CfpbList extends LitElement {
136
134
 
137
135
  // Assign tabindex, role, listeners.
138
136
  this.#items.forEach((item, index) => {
139
- item.setAttribute('tabindex', index === 0 ? '0' : '-1');
137
+ item.setAttribute('tabindex', '-1');
140
138
  item.setAttribute('role', 'option');
141
139
 
142
140
  // Remove prior listener if present.
@@ -147,7 +145,6 @@ export class CfpbList extends LitElement {
147
145
  const listener = (evt) => {
148
146
  // Prevent actual click bubbling to list container.
149
147
  evt.stopPropagation();
150
-
151
148
  this.#handleToggle(item, item.checked, index);
152
149
  };
153
150
 
@@ -156,7 +153,8 @@ export class CfpbList extends LitElement {
156
153
 
157
154
  // Track focus index.
158
155
  item.addEventListener('focus', () => {
159
- this.#focusedIndex = index;
156
+ const visIndex = this.#visibleItems.indexOf(item);
157
+ if (visIndex !== -1) this.#focusedIndex = visIndex;
160
158
  });
161
159
  });
162
160
 
@@ -181,7 +179,6 @@ export class CfpbList extends LitElement {
181
179
  checked: item.checked,
182
180
  disabled: item.disabled,
183
181
  }));
184
-
185
182
  this.#internalSync = true;
186
183
  this.childData = data;
187
184
  this.#internalSync = false;
@@ -199,9 +196,8 @@ export class CfpbList extends LitElement {
199
196
  if (this.multiple) {
200
197
  if (isChecked) {
201
198
  // Add if not already present.
202
- if (!this.#checkedItems.includes(element)) {
199
+ if (!this.#checkedItems.includes(element))
203
200
  this.#checkedItems.push(element);
204
- }
205
201
  } else {
206
202
  // Remove cleanly.
207
203
  this.#checkedItems = this.#checkedItems.filter(
@@ -224,6 +220,11 @@ export class CfpbList extends LitElement {
224
220
 
225
221
  this.#syncChildDataFromItems();
226
222
 
223
+ window.queueMicrotask(() => {
224
+ const visIndex = this.#visibleItems.indexOf(element);
225
+ this.focusItemAt(visIndex !== -1 ? visIndex : -1);
226
+ });
227
+
227
228
  this.dispatchEvent(
228
229
  new CustomEvent('item-click', {
229
230
  detail: { index, value: element.value, element },
@@ -242,31 +243,31 @@ export class CfpbList extends LitElement {
242
243
  * @returns {Array} List of visible list items.
243
244
  */
244
245
  filterItems(queryList) {
245
- let firstIndex = 0;
246
246
  this.#visibleItems = [];
247
+ let firstVisibleIndex = -1;
247
248
 
248
- this.items.forEach((item) => {
249
- const valueLower = item.value.toLowerCase();
249
+ this.#items.forEach((item) => {
250
250
  const matches = queryList.some((q) =>
251
- valueLower.includes(q.toLowerCase()),
251
+ item.value.toLowerCase().includes(q.toLowerCase()),
252
252
  );
253
- if (!matches) firstIndex++;
254
- else this.#visibleItems.push(item);
255
253
  item.hidden = !matches;
254
+ if (matches) {
255
+ if (firstVisibleIndex === -1)
256
+ firstVisibleIndex = this.#visibleItems.length;
257
+ this.#visibleItems.push(item);
258
+ }
256
259
  });
257
260
 
258
- this.#focusedIndex = firstIndex;
259
-
261
+ this.#focusedIndex = firstVisibleIndex;
260
262
  this.#broadcastFiltered();
261
263
 
262
264
  return this.#visibleItems;
263
265
  }
264
266
 
265
267
  showAllItems() {
266
- this.items.forEach((item) => (item.hidden = false));
268
+ this.#items.forEach((item) => (item.hidden = false));
269
+ this.#visibleItems = [...this.#items];
267
270
  this.#focusedIndex = 0;
268
- this.#visibleItems = this.#items;
269
-
270
271
  this.#broadcastFiltered();
271
272
  }
272
273
 
@@ -285,41 +286,58 @@ export class CfpbList extends LitElement {
285
286
  );
286
287
  }
287
288
 
289
+ #focusContainer() {
290
+ this.#container.value.focus();
291
+ this.#focusedIndex = -1;
292
+ }
293
+
294
+ /**
295
+ * Focus a visible item by index.
296
+ * Pass -1 to move focus to the list container (no active item).
297
+ * @param {number} index - The index of the item to focus.
298
+ * @returns {undefined} If nothing to focus.
299
+ */
288
300
  focusItemAt(index) {
289
- const visibleItems = this.items.filter((item) => !item.hidden);
290
- if (!visibleItems.length) return;
301
+ // No active item (sentinel or invalid input).
302
+ const visibleItems = this.#visibleItems;
303
+ if (
304
+ !visibleItems.length ||
305
+ index == null ||
306
+ typeof index !== 'number' ||
307
+ Number.isNaN(index) ||
308
+ index === -1
309
+ ) {
310
+ this.#focusContainer();
311
+ return;
312
+ }
291
313
 
292
314
  const normalizedIndex =
293
315
  ((index % visibleItems.length) + visibleItems.length) %
294
316
  visibleItems.length;
295
- const item = visibleItems[normalizedIndex];
296
- item.focus();
317
+ visibleItems[normalizedIndex].focus();
297
318
  this.#focusedIndex = normalizedIndex;
298
319
  }
299
320
 
300
321
  #onFocus(evt) {
301
322
  // 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
- }
323
+ if (evt.target === this.#container.value) this.#focusContainer();
305
324
  }
306
325
 
307
- // -------------------------
308
- // KEYBOARD NAVIGATION
309
- // -------------------------
310
326
  #onKeyDown(evt) {
311
- const visibleItems = this.items.filter((item) => !item.hidden);
327
+ const visibleItems = this.#visibleItems;
312
328
  if (!visibleItems.length) return;
313
329
  const last = visibleItems.length - 1;
314
330
 
315
331
  switch (evt.key) {
316
332
  case 'ArrowDown':
317
333
  evt.preventDefault();
318
- this.focusItemAt(this.#focusedIndex + 1);
334
+ this.focusItemAt(this.#focusedIndex < 0 ? 0 : this.#focusedIndex + 1);
319
335
  break;
320
336
  case 'ArrowUp':
321
337
  evt.preventDefault();
322
- this.focusItemAt(this.#focusedIndex - 1);
338
+ this.focusItemAt(
339
+ this.#focusedIndex < 0 ? last : this.#focusedIndex - 1,
340
+ );
323
341
  break;
324
342
  case 'Home':
325
343
  evt.preventDefault();
@@ -332,6 +350,10 @@ export class CfpbList extends LitElement {
332
350
  }
333
351
  }
334
352
 
353
+ get focusedIndex() {
354
+ return this.#focusedIndex;
355
+ }
356
+
335
357
  render() {
336
358
  return html`
337
359
  <div
@@ -27,7 +27,6 @@ describe('<cfpb-list> tests', () => {
27
27
  await list.updateComplete;
28
28
 
29
29
  expect(list.items.length).toBe(3);
30
- // Only first checked stays true in single select
31
30
  expect(list.items[0].checked).toBe(true);
32
31
  expect(list.items[1].checked).toBe(false);
33
32
  expect(list.items[2].checked).toBe(false);
@@ -48,7 +47,6 @@ describe('<cfpb-list> tests', () => {
48
47
  expect(list.checkedItems).toContain(list.items[0]);
49
48
  expect(list.checkedItems).toContain(list.items[1]);
50
49
 
51
- // Uncheck an item
52
50
  const event = new CustomEvent('click-item', {
53
51
  bubbles: true,
54
52
  composed: true,
@@ -61,13 +59,11 @@ describe('<cfpb-list> tests', () => {
61
59
 
62
60
  test('click-item toggles single selection', async () => {
63
61
  list.childData = JSON.stringify([{ value: 'A' }, { value: 'B' }]);
64
-
65
62
  await list.updateComplete;
66
63
 
67
64
  const clickSpy = jest.fn();
68
65
  list.addEventListener('item-click', clickSpy);
69
66
 
70
- // Click first item
71
67
  list.items[0].checked = true;
72
68
  list.items[0].dispatchEvent(
73
69
  new CustomEvent('click-item', { bubbles: true, composed: true }),
@@ -76,7 +72,6 @@ describe('<cfpb-list> tests', () => {
76
72
  expect(list.checkedItems).toEqual([list.items[0]]);
77
73
  expect(clickSpy).toHaveBeenCalledTimes(1);
78
74
 
79
- // Click again to uncheck
80
75
  list.items[0].checked = false;
81
76
  list.items[0].dispatchEvent(
82
77
  new CustomEvent('click-item', { bubbles: true, composed: true }),
@@ -91,7 +86,6 @@ describe('<cfpb-list> tests', () => {
91
86
  const listenerSpy = jest.fn();
92
87
  list.addEventListener('item-click', listenerSpy);
93
88
 
94
- // Trigger click-item event
95
89
  const event = new CustomEvent('click-item', {
96
90
  bubbles: true,
97
91
  composed: true,
@@ -127,24 +121,27 @@ describe('<cfpb-list> tests', () => {
127
121
 
128
122
  list.filterItems(['C']); // only C visible
129
123
 
130
- list.focusItemAt(0);
131
- expect(document.activeElement.value).toBe('C');
124
+ const container = list.shadowRoot.querySelector('div');
125
+ container.focus();
126
+ expect(document.activeElement.tagName).toBe('CFPB-LIST');
132
127
 
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
- );
128
+ // ArrowDown first visible
129
+ container.dispatchEvent(
130
+ new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
131
+ );
139
132
  expect(document.activeElement.value).toBe('C');
140
133
 
141
- // Press ArrowUp
142
- list.shadowRoot
143
- .querySelector('div')
144
- .dispatchEvent(
145
- new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }),
146
- );
134
+ // ArrowDown → wrap
135
+ container.dispatchEvent(
136
+ new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
137
+ );
147
138
  expect(document.activeElement.value).toBe('C');
139
+
140
+ // ArrowUp → wrap
141
+ container.dispatchEvent(
142
+ new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }),
143
+ );
144
+ expect(document.activeElement.tagName).toBe('CFPB-LIST');
148
145
  });
149
146
 
150
147
  test('showAllItems unhides all items', async () => {
@@ -163,7 +160,55 @@ describe('<cfpb-list> tests', () => {
163
160
  console.error = jest.fn();
164
161
  list.childData = 'not-json';
165
162
  await list.updateComplete;
166
-
167
163
  expect(console.error).toHaveBeenCalled();
168
164
  });
165
+
166
+ // -------------------------------
167
+ // focusItemAt sentinel tests
168
+ // -------------------------------
169
+
170
+ test('focusItemAt(-1) focuses the container', async () => {
171
+ list.childData = JSON.stringify([{ value: 'A' }, { value: 'B' }]);
172
+ await list.updateComplete;
173
+ list.focusItemAt(-1);
174
+ expect(document.activeElement.tagName).toBe('CFPB-LIST');
175
+ });
176
+
177
+ test('focusItemAt(null) focuses the container', async () => {
178
+ list.childData = JSON.stringify([{ value: 'A' }]);
179
+ await list.updateComplete;
180
+ list.focusItemAt(null);
181
+ expect(document.activeElement.tagName).toBe('CFPB-LIST');
182
+ });
183
+
184
+ test('focusItemAt(undefined) focuses the container', async () => {
185
+ list.childData = JSON.stringify([{ value: 'A' }]);
186
+ await list.updateComplete;
187
+ list.focusItemAt(undefined);
188
+ expect(document.activeElement.tagName).toBe('CFPB-LIST');
189
+ });
190
+
191
+ test('ArrowDown from container focuses first visible item', async () => {
192
+ list.childData = JSON.stringify([{ value: 'A' }, { value: 'B' }]);
193
+ await list.updateComplete;
194
+
195
+ const container = list.shadowRoot.querySelector('div');
196
+ container.focus();
197
+ container.dispatchEvent(
198
+ new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
199
+ );
200
+ expect(document.activeElement.value).toBe('A');
201
+ });
202
+
203
+ test('ArrowUp from container focuses last visible item', async () => {
204
+ list.childData = JSON.stringify([{ value: 'A' }, { value: 'B' }]);
205
+ await list.updateComplete;
206
+
207
+ const container = list.shadowRoot.querySelector('div');
208
+ container.focus();
209
+ container.dispatchEvent(
210
+ new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }),
211
+ );
212
+ expect(document.activeElement.value).toBe('B');
213
+ });
169
214
  });
@@ -1,6 +1,6 @@
1
1
  @use 'sass:math';
2
- @use '@cfpb/cfpb-design-system/src/abstracts' as *;
3
- @use '@cfpb/cfpb-design-system/src/base' as *;
2
+ @use '@cfpb/cfpb-design-system/src/elements/abstracts' as *;
3
+ @use '@cfpb/cfpb-design-system/src/elements/base' as *;
4
4
  @use '@cfpb/cfpb-design-system/src/utilities' as *;
5
5
  @use '@cfpb/cfpb-design-system/src/components/cfpb-icons/icon';
6
6
  @use '../cfpb-utilities/transition/transition.scss' as *;
@@ -11,17 +11,20 @@ import { FlyoutMenu } from '../../utilities/behavior/flyout-menu';
11
11
  import { CfpbList } from '../cfpb-list';
12
12
  import { CfpbTagGroup } from '../cfpb-tag-group';
13
13
 
14
+ import { SingleSelectEventProxy } from './single-select-event-proxy.js';
15
+ import { MultipleSelectEventProxy } from './multiple-select-event-proxy.js';
16
+
14
17
  /**
15
18
  *
16
- * @element cfpb-button
17
- * @slot - The main content for the button.
19
+ * @element cfpb-select
20
+ * @slot - The main content for the select.
18
21
  */
19
22
  export class CfpbSelect extends LitElement {
20
23
  static styles = css`
21
24
  ${unsafeCSS(styles)}
22
25
  `;
23
26
 
24
- // Flyout menu options.
27
+ #eventProxy;
25
28
  #flyoutMenu;
26
29
  #transition;
27
30
  #search;
@@ -32,7 +35,7 @@ export class CfpbSelect extends LitElement {
32
35
  #tagGroup = createRef();
33
36
  #list = createRef();
34
37
  #displayLabel = createRef();
35
- #boundOnOutsideClick;
38
+ #boundOnOutsideFocus;
36
39
  #noResults = false;
37
40
 
38
41
  /**
@@ -64,23 +67,33 @@ export class CfpbSelect extends LitElement {
64
67
  constructor() {
65
68
  super();
66
69
 
70
+ this.multiple = false;
67
71
  this.options = [];
68
72
  this.selectedTexts = [];
69
73
  this.optionList = [];
70
74
 
71
- this.#boundOnOutsideClick = this.#onOutsideClick.bind(this);
75
+ this.#boundOnOutsideFocus = this.#onFocusOutside.bind(this);
72
76
  }
73
77
 
74
78
  firstUpdated() {
75
79
  this.#initFlyoutMenu();
80
+
81
+ this.addEventListener('focus', () => {
82
+ this.#eventProxy.onFocus();
83
+ });
84
+
85
+ this.addEventListener('keydown', (evt) => {
86
+ this.#eventProxy.onKeyDown(evt, this);
87
+ });
76
88
  }
77
89
 
78
90
  disconnectedCallback() {
79
- document.removeEventListener('pointerdown', this.#boundOnOutsideClick);
91
+ document.removeEventListener('pointerdown', this.#boundOnOutsideFocus);
92
+ document.removeEventListener('focusin', this.#boundOnOutsideFocus);
80
93
  super.disconnectedCallback();
81
94
  }
82
95
 
83
- #onOutsideClick(evt) {
96
+ #onFocusOutside(evt) {
84
97
  const path = evt.composedPath();
85
98
  if (!path.includes(this)) {
86
99
  this.isExpanded = false;
@@ -104,14 +117,20 @@ export class CfpbSelect extends LitElement {
104
117
 
105
118
  // Extract list items (with their text or link info)
106
119
  const items = [...list[0].querySelectorAll('li')].map((li) => {
107
- const checked = li.querySelector('b');
120
+ const checked =
121
+ li.hasAttribute('data-checked') || li.hasAttribute('checked');
122
+
123
+ const itemValue = li.textContent.trim();
124
+
108
125
  if (checked) {
126
+ if (!this.multiple) this.#displayLabel.value.textContent = itemValue;
109
127
  return {
110
- value: li.textContent.trim(),
128
+ value: itemValue,
111
129
  checked: 'true',
112
130
  };
113
131
  }
114
- return { value: li.textContent.trim() };
132
+
133
+ return { value: itemValue };
115
134
  });
116
135
 
117
136
  this.optionList = items;
@@ -192,6 +211,10 @@ export class CfpbSelect extends LitElement {
192
211
  }
193
212
 
194
213
  updated(changedProps) {
214
+ if (changedProps.has('multiple')) {
215
+ this.#eventProxy = this.#createEventProxy();
216
+ }
217
+
195
218
  if (changedProps.has('isExpanded')) {
196
219
  const oldVal = changedProps.get('isExpanded');
197
220
  const newVal = this.isExpanded;
@@ -199,81 +222,49 @@ export class CfpbSelect extends LitElement {
199
222
  if (newVal !== oldVal) {
200
223
  if (newVal) {
201
224
  this.#flyoutMenu.expand();
202
- document.addEventListener('pointerdown', this.#boundOnOutsideClick);
225
+ document.addEventListener('pointerdown', this.#boundOnOutsideFocus);
226
+ document.addEventListener('focusin', this.#boundOnOutsideFocus);
203
227
  } else {
204
228
  this.#flyoutMenu.collapse();
205
229
  document.removeEventListener(
206
230
  'pointerdown',
207
- this.#boundOnOutsideClick,
231
+ this.#boundOnOutsideFocus,
208
232
  );
233
+ document.removeEventListener('focusin', this.#boundOnOutsideFocus);
209
234
  }
210
235
  }
211
236
  }
212
237
  }
213
238
 
214
- #onClick(evt) {
215
- const target = evt.currentTarget;
216
-
217
- if (this.multiple) {
218
- if (target.tagName === 'CFPB-FORM-SEARCH-INPUT') {
219
- if (this.isExpanded) this.#flyoutMenu.suspend();
220
- else this.#flyoutMenu.expand();
221
- } else {
222
- this.#flyoutMenu.resume();
223
- }
224
- } else if (target.classList.contains('o-select__label')) {
225
- this.#headerDom.value.focus();
226
- this.isExpanded = !this.isExpanded;
227
- }
228
- }
229
-
230
- #onItemClick() {
231
- if (this.multiple) {
232
- this.optionList = this.#list.value.childData;
233
- this.requestUpdate();
234
- } else {
235
- const checkedItems = this.#list.value.checkedItems;
236
- const selectedValue = checkedItems[0]?.value;
237
-
238
- this.#displayLabel.value.innerHTML = selectedValue ? selectedValue : '';
239
-
240
- // Update optionList so the selection persists.
241
- this.optionList = this.optionList.map((item) => ({
242
- ...item,
243
- checked: item.value === selectedValue,
244
- }));
245
-
246
- this.requestUpdate();
247
-
248
- // Now close the dropdown.
249
- this.isExpanded = false;
239
+ #createEventProxy() {
240
+ const common = {
241
+ list: this.#list.value,
242
+ flyout: () => this.#flyoutMenu,
243
+ };
250
244
 
251
- // Move focus back to the header.
252
- if (this.multiple) this.#input.value.focus();
253
- else this.#headerDom.value.focus();
254
- }
245
+ return this.multiple
246
+ ? new MultipleSelectEventProxy({
247
+ ...common,
248
+ input: this.#input.value,
249
+ tagGroup: this.#tagGroup.value,
250
+ })
251
+ : new SingleSelectEventProxy({
252
+ ...common,
253
+ displayLabel: this.#displayLabel.value,
254
+ header: this.#headerDom.value,
255
+ });
255
256
  }
256
257
 
257
- #onTagClick(evt) {
258
- const tagList = this.#tagGroup.value.tagList.filter((item) => {
259
- return item !== evt.detail.target;
260
- });
261
-
262
- this.optionList = this.optionList.map((item) => ({
263
- ...item,
264
- checked: !!tagList.find((tag) => tag.value === item.value),
265
- }));
258
+ #onClick(evt) {
259
+ this.#eventProxy?.onClick(evt, this);
260
+ }
266
261
 
267
- this.requestUpdate();
262
+ #onItemClick(evt) {
263
+ this.#eventProxy?.onItemClick(evt, this);
268
264
  }
269
265
 
270
- #onKeyDown(evt) {
271
- switch (evt.key) {
272
- case 'ArrowDown':
273
- evt.preventDefault();
274
- this.#contentDom.value.querySelector('cfpb-list-item').focus();
275
- break;
276
- }
266
+ #onTagClick(evt) {
267
+ this.#eventProxy?.onTagClick(evt, this);
277
268
  }
278
269
 
279
270
  render() {
@@ -308,7 +299,6 @@ export class CfpbSelect extends LitElement {
308
299
  title="Expand content"
309
300
  data-js-hook="behavior_flyout-menu_trigger"
310
301
  ${ref(this.#headerDom)}
311
- @keydown=${this.#onKeyDown}
312
302
  >
313
303
  <span class="o-select__cues" @click=${this.#onClick}>
314
304
  <span class="o-select__cue-open" role="img" aria-label="Show">