@cfpb/cfpb-design-system 4.2.4 → 4.3.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 (218) hide show
  1. package/CHANGELOG.md +186 -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 +4 -4
  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 +3 -3
  50. package/dist/elements/cfpb-checkbox-icon/index.js +29 -0
  51. package/dist/elements/{cfpb-checkbox → cfpb-checkbox-icon}/index.js.map +4 -4
  52. package/dist/elements/cfpb-expandable/index.css +2 -0
  53. package/dist/elements/cfpb-expandable/index.css.map +7 -0
  54. package/dist/elements/cfpb-expandable/index.js +33 -0
  55. package/dist/elements/cfpb-expandable/index.js.map +7 -0
  56. package/dist/elements/cfpb-file-upload/index.js +4 -4
  57. package/dist/elements/cfpb-file-upload/index.js.map +3 -3
  58. package/dist/elements/cfpb-form-alert/index.js +32 -0
  59. package/dist/elements/cfpb-form-alert/index.js.map +7 -0
  60. package/dist/elements/cfpb-form-choice/index.js +12 -3
  61. package/dist/elements/cfpb-form-choice/index.js.map +4 -4
  62. package/dist/elements/cfpb-form-search/index.js +41 -0
  63. package/dist/elements/cfpb-form-search/index.js.map +7 -0
  64. package/dist/elements/cfpb-form-search-input/index.js +41 -0
  65. package/dist/elements/cfpb-form-search-input/index.js.map +7 -0
  66. package/dist/elements/cfpb-icon-text/index.js +3 -3
  67. package/dist/elements/cfpb-icon-text/index.js.map +3 -3
  68. package/dist/elements/cfpb-label/index.js +3 -3
  69. package/dist/elements/cfpb-label/index.js.map +2 -2
  70. package/dist/elements/cfpb-list/index.js +39 -0
  71. package/dist/elements/cfpb-list/index.js.map +7 -0
  72. package/dist/elements/cfpb-list-item/index.js +39 -0
  73. package/dist/elements/cfpb-list-item/index.js.map +7 -0
  74. package/dist/elements/cfpb-multiselect/index.js +13 -4
  75. package/dist/elements/cfpb-multiselect/index.js.map +4 -4
  76. package/dist/elements/cfpb-pagination/index.js +3 -3
  77. package/dist/elements/cfpb-pagination/index.js.map +2 -2
  78. package/dist/elements/cfpb-select/index.css +2 -0
  79. package/dist/elements/cfpb-select/index.css.map +7 -0
  80. package/dist/elements/cfpb-select/index.js +42 -0
  81. package/dist/elements/cfpb-select/index.js.map +7 -0
  82. package/dist/elements/cfpb-select-list/index.js +39 -0
  83. package/dist/elements/cfpb-select-list/index.js.map +7 -0
  84. package/dist/elements/cfpb-tag-filter/index.js +3 -3
  85. package/dist/elements/cfpb-tag-filter/index.js.map +3 -3
  86. package/dist/elements/cfpb-tag-group/index.js +3 -3
  87. package/dist/elements/cfpb-tag-group/index.js.map +4 -4
  88. package/dist/elements/cfpb-tag-topic/index.js +4 -4
  89. package/dist/elements/cfpb-tag-topic/index.js.map +2 -2
  90. package/dist/elements/index.css +2 -0
  91. package/dist/elements/index.css.map +7 -0
  92. package/dist/elements/index.js +7 -6
  93. package/dist/elements/index.js.map +4 -4
  94. package/dist/index.css +1 -1
  95. package/dist/index.css.map +3 -3
  96. package/dist/index.js +7 -6
  97. package/dist/index.js.map +4 -4
  98. package/dist/utilities/index.css +1 -1
  99. package/dist/utilities/index.css.map +2 -2
  100. package/dist/utilities/index.js +1 -1
  101. package/dist/utilities/index.js.map +4 -4
  102. package/package.json +1 -1
  103. package/src/components/cfpb-buttons/button-group.scss +1 -1
  104. package/src/components/cfpb-buttons/button-link.scss +10 -54
  105. package/src/components/cfpb-buttons/button.scss +3 -3
  106. package/src/components/cfpb-buttons/vars.scss +1 -1
  107. package/src/components/cfpb-expandables/expandable-group.scss +1 -1
  108. package/src/components/cfpb-expandables/expandable.js +3 -0
  109. package/src/components/cfpb-expandables/expandable.scss +1 -1
  110. package/src/components/cfpb-expandables/summary.scss +1 -1
  111. package/src/components/cfpb-forms/form-alert.scss +1 -1
  112. package/src/components/cfpb-forms/form-field.scss +6 -6
  113. package/src/components/cfpb-forms/form.scss +1 -1
  114. package/src/components/cfpb-forms/label.scss +2 -2
  115. package/src/components/cfpb-forms/multiselect.js +1 -1
  116. package/src/components/cfpb-forms/multiselect.scss +1 -1
  117. package/src/components/cfpb-forms/range.scss +7 -7
  118. package/src/components/cfpb-forms/search-input.scss +1 -1
  119. package/src/components/cfpb-forms/select.scss +1 -1
  120. package/src/components/cfpb-forms/tag.scss +1 -1
  121. package/src/components/cfpb-forms/text-input.scss +1 -1
  122. package/src/components/cfpb-icons/icon.scss +1 -1
  123. package/src/components/cfpb-layout/card-group.scss +1 -1
  124. package/src/components/cfpb-layout/card.scss +1 -1
  125. package/src/components/cfpb-layout/email-signup.scss +1 -1
  126. package/src/components/cfpb-layout/featured-content-module.scss +1 -1
  127. package/src/components/cfpb-layout/hero.scss +1 -1
  128. package/src/components/cfpb-layout/layout.scss +9 -9
  129. package/src/components/cfpb-layout/well.scss +1 -1
  130. package/src/components/cfpb-notifications/banner.scss +1 -1
  131. package/src/components/cfpb-notifications/notification.scss +1 -1
  132. package/src/components/cfpb-pagination/pagination.scss +1 -1
  133. package/src/components/cfpb-tables/table.scss +1 -1
  134. package/src/components/cfpb-tooltips/tooltip.scss +1 -1
  135. package/src/components/cfpb-typography/date.scss +1 -1
  136. package/src/components/cfpb-typography/list.scss +1 -1
  137. package/src/components/cfpb-typography/meta-header.scss +1 -1
  138. package/src/components/cfpb-typography/mixins.scss +1 -1
  139. package/src/components/cfpb-typography/pull-quote.scss +1 -1
  140. package/src/components/cfpb-typography/slug-header.scss +1 -1
  141. package/src/components/cfpb-typography/tagline.scss +1 -1
  142. package/src/elements/abstracts/custom-props.css +123 -0
  143. package/src/{abstracts → elements/abstracts}/grid-mixins.scss +2 -1
  144. package/src/{abstracts → elements/abstracts}/heading-mixins.scss +1 -0
  145. package/src/{abstracts → elements/abstracts}/index.scss +1 -0
  146. package/src/{abstracts → elements/abstracts}/media-queries.scss +1 -1
  147. package/src/elements/abstracts/sizing-vars.scss +66 -0
  148. package/src/elements/abstracts/vars.css +79 -0
  149. package/src/{base → elements/base}/base.scss +14 -14
  150. package/src/elements/cfpb-button/cfpb-button-group.scss +12 -0
  151. package/src/elements/cfpb-button/cfpb-button-link.scss +103 -0
  152. package/src/elements/cfpb-button/cfpb-button.component.scss +11 -4
  153. package/src/elements/cfpb-button/cfpb-button.scss +218 -0
  154. package/src/elements/cfpb-button/index.js +44 -30
  155. package/src/elements/cfpb-button/vars.css +30 -0
  156. package/src/elements/cfpb-checkbox-icon/cfpb-checkbox-icon.component.scss +88 -0
  157. package/src/elements/cfpb-checkbox-icon/index.js +104 -0
  158. package/src/elements/cfpb-expandable/cfpb-expandable.component.scss +218 -0
  159. package/src/elements/cfpb-expandable/index.js +127 -0
  160. package/src/elements/cfpb-file-upload/cfpb-file-upload.component.scss +2 -2
  161. package/src/elements/cfpb-file-upload/index.js +25 -27
  162. package/src/elements/cfpb-form-alert/cfpb-form-alert.component.scss +36 -0
  163. package/src/elements/cfpb-form-alert/index.js +55 -0
  164. package/src/elements/cfpb-form-choice/cfpb-form-choice.component.scss +42 -81
  165. package/src/elements/cfpb-form-choice/index.js +58 -18
  166. package/src/elements/cfpb-form-search/cfpb-form-search.component.scss +54 -0
  167. package/src/elements/cfpb-form-search/index.js +194 -0
  168. package/src/elements/cfpb-form-search-input/cfpb-form-search-input.component.scss +217 -0
  169. package/src/elements/cfpb-form-search-input/index.js +140 -0
  170. package/src/elements/cfpb-icon-text/cfpb-icon-text.component.scss +33 -39
  171. package/src/elements/cfpb-icon-text/index.js +32 -104
  172. package/src/elements/cfpb-label/cfpb-label.component.scss +2 -2
  173. package/src/elements/cfpb-label/index.js +6 -9
  174. package/src/elements/cfpb-list/cfpb-list.component.scss +34 -0
  175. package/src/elements/cfpb-list/index.js +379 -0
  176. package/src/elements/cfpb-list/index.spec.js +214 -0
  177. package/src/elements/cfpb-list-item/cfpb-list-item.component.scss +69 -0
  178. package/src/elements/cfpb-list-item/index.js +215 -0
  179. package/src/elements/cfpb-pagination/cfpb-pagination.component.scss +2 -7
  180. package/src/elements/cfpb-pagination/index.js +6 -8
  181. package/src/elements/cfpb-select/cfpb-select.component.scss +241 -0
  182. package/src/elements/cfpb-select/index.js +371 -0
  183. package/src/elements/cfpb-select/multiple-select-event-proxy.js +88 -0
  184. package/src/elements/cfpb-select/single-select-event-proxy.js +47 -0
  185. package/src/elements/cfpb-tag-filter/cfpb-tag-filter.component.scss +6 -3
  186. package/src/elements/cfpb-tag-filter/index.js +15 -7
  187. package/src/elements/cfpb-tag-group/cfpb-tag-group.component.scss +2 -2
  188. package/src/elements/cfpb-tag-group/index.js +53 -6
  189. package/src/elements/cfpb-tag-topic/cfpb-tag-topic.component.scss +2 -2
  190. package/src/elements/cfpb-tag-topic/index.js +5 -7
  191. package/src/elements/cfpb-utilities/parse-child-data.js +50 -0
  192. package/src/elements/cfpb-utilities/parse-child-data.spec.js +56 -0
  193. package/src/elements/cfpb-utilities/search-service.js +46 -0
  194. package/src/elements/cfpb-utilities/search-service.spec.js +138 -0
  195. package/src/elements/cfpb-utilities/transition/transition.scss +98 -0
  196. package/src/elements/index.js +7 -1
  197. package/src/index.js +2 -2
  198. package/src/index.scss +14 -2
  199. package/src/tokens/abstracts/custom-props.json +1642 -0
  200. package/src/tokens/abstracts/vars.json +1319 -0
  201. package/src/tokens/cfpb-button/vars.json +436 -0
  202. package/src/utilities/breakpoint-state.js +1 -1
  203. package/src/utilities/transition/max-height-transition.js +74 -0
  204. package/src/utilities/utilities.scss +1 -1
  205. package/dist/elements/cfpb-checkbox/index.js +0 -29
  206. package/src/abstracts/custom-props.scss +0 -175
  207. package/src/abstracts/vars.scss +0 -184
  208. package/src/elements/cfpb-multiselect/cfpb-multiselect.component.scss +0 -225
  209. package/src/elements/cfpb-multiselect/index.js +0 -444
  210. package/src/elements/cfpb-multiselect/multiselect-model.js +0 -288
  211. package/src/elements/cfpb-multiselect/multiselect-model.spec.js +0 -236
  212. /package/src/{abstracts → elements/abstracts}/index.js +0 -0
  213. /package/src/{abstracts → elements/abstracts}/vars-breakpoints.js +0 -0
  214. /package/src/{abstracts → elements/abstracts}/vars-breakpoints.scss +0 -0
  215. /package/src/{base → elements/base}/font.scss +0 -0
  216. /package/src/{base → elements/base}/index.js +0 -0
  217. /package/src/{base → elements/base}/index.scss +0 -0
  218. /package/src/{base → elements/base}/normalize.scss +0 -0
@@ -0,0 +1,379 @@
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
+ #internalSync = false;
13
+ #container = createRef();
14
+ #items = [];
15
+ #checkedItems = [];
16
+ #visibleItems = [];
17
+
18
+ // index in visibleItems
19
+ #focusedIndex = -1;
20
+
21
+ // WeakMap to store per-item click listeners.
22
+ #clickListeners = new WeakMap();
23
+
24
+ /**
25
+ * @property {Array} childData - Structure data to create child components.
26
+ * @property {boolean} multiple - Whether the select supports multiple or not.
27
+ * @property {string} type - List item type: plain, check, or checkbox.
28
+ * @property {string} ariaLabel - The aria-label for the list container.
29
+ * @returns {object} The map of properties.
30
+ */
31
+ static properties = {
32
+ childData: { type: Array, attribute: 'childdata' },
33
+ multiple: { type: Boolean, reflect: true },
34
+ type: { type: String, reflect: true },
35
+ ariaLabel: { type: String, attribute: 'aria-label' },
36
+ };
37
+
38
+ constructor() {
39
+ super();
40
+ this.childData = '';
41
+ this.multiple = false;
42
+ this.type = 'plain';
43
+ this.ariaLabel = '';
44
+ }
45
+
46
+ firstUpdated() {
47
+ this.#syncItems();
48
+ }
49
+
50
+ updated(changedProps) {
51
+ if (!this.#internalSync && changedProps.has('childData')) {
52
+ const parsed = parseChildData(this.childData);
53
+ if (parsed) this.#renderItemsFromData(parsed);
54
+ }
55
+ if (changedProps.has('type')) {
56
+ this.#applyTypeToItems();
57
+ }
58
+ }
59
+
60
+ // -------------------------
61
+ // ITEMS ACCESS
62
+ // -------------------------
63
+ get items() {
64
+ return this.#items;
65
+ }
66
+ get checkedItems() {
67
+ return this.#checkedItems;
68
+ }
69
+ get visibleItems() {
70
+ return this.#visibleItems;
71
+ }
72
+ get visibleCheckedItems() {
73
+ return this.#visibleItems.filter((item) => item.checked);
74
+ }
75
+
76
+ // -------------------------
77
+ // RENDER ITEMS
78
+ // -------------------------
79
+ #renderItemsFromData(itemsArray) {
80
+ [...this.children].forEach((child) => {
81
+ if (child.tagName !== 'TEMPLATE' && child.tagName !== 'NOSCRIPT')
82
+ child.remove();
83
+ });
84
+
85
+ let firstChecked = null;
86
+ itemsArray.forEach((data) => {
87
+ const item = document.createElement('cfpb-list-item');
88
+ item.textContent = data.value ?? '';
89
+ if ('disabled' in data) item.disabled = data.disabled;
90
+ if ('hidden' in data) item.hidden = data.hidden;
91
+ if ('href' in data) item.href = data.href;
92
+ item.type = data.type ?? this.type;
93
+
94
+ if (this.multiple) {
95
+ if ('checked' in data) item.checked = data.checked;
96
+ } else if (!firstChecked && data.checked) {
97
+ firstChecked = item;
98
+ item.checked = true;
99
+ }
100
+
101
+ this.appendChild(item);
102
+ });
103
+
104
+ this.#syncItems();
105
+ }
106
+
107
+ // -------------------------
108
+ // SYNC ITEMS & LISTENERS
109
+ // -------------------------
110
+ #syncItems() {
111
+ // Collect items.
112
+ this.#items = [...this.querySelectorAll('cfpb-list-item')];
113
+
114
+ // Ensure each item has a type.
115
+ this.#items.forEach((item) => {
116
+ if (!item.type) item.type = this.type;
117
+ });
118
+
119
+ // Visible items.
120
+ this.#visibleItems = this.#items.filter((item) => !item.hidden);
121
+
122
+ // Populate initial checked states.
123
+ if (this.multiple) {
124
+ this.#checkedItems = this.#items.filter((item) => item.checked);
125
+ } else {
126
+ const firstChecked = this.#items.find((item) => item.checked);
127
+ this.#checkedItems = firstChecked ? [firstChecked] : [];
128
+
129
+ // Uncheck all others.
130
+ this.#items.forEach((item) => {
131
+ if (item !== firstChecked) item.checked = false;
132
+ });
133
+ }
134
+
135
+ // Assign tabindex, role, listeners.
136
+ this.#items.forEach((item, index) => {
137
+ item.setAttribute('tabindex', '-1');
138
+ item.setAttribute('role', 'option');
139
+
140
+ // Remove prior listener if present.
141
+ const prev = this.#clickListeners.get(item);
142
+ if (prev) item.removeEventListener('click-item', prev);
143
+
144
+ // Listener that toggles the item before handling.
145
+ const listener = (evt) => {
146
+ // Prevent actual click bubbling to list container.
147
+ evt.stopPropagation();
148
+ this.#handleToggle(item, item.checked, index);
149
+ };
150
+
151
+ item.addEventListener('click-item', listener);
152
+ this.#clickListeners.set(item, listener);
153
+
154
+ // Track focus index.
155
+ item.addEventListener('focus', () => {
156
+ const visIndex = this.#visibleItems.indexOf(item);
157
+ if (visIndex !== -1) this.#focusedIndex = visIndex;
158
+ });
159
+ });
160
+
161
+ this.dispatchEvent(
162
+ new CustomEvent('items-ready', {
163
+ detail: {
164
+ items: this.#items,
165
+ checkedItems: this.#checkedItems,
166
+ visibleItems: this.#visibleItems,
167
+ visibleCheckedItems: this.visibleCheckedItems,
168
+ },
169
+ bubbles: true,
170
+ composed: true,
171
+ }),
172
+ );
173
+ }
174
+
175
+ #syncChildDataFromItems() {
176
+ const data = this.#items.map((item) => ({
177
+ value: item.value,
178
+ label: item.textContent.trim(),
179
+ checked: item.checked,
180
+ disabled: item.disabled,
181
+ }));
182
+ this.#internalSync = true;
183
+ this.childData = data;
184
+ this.#internalSync = false;
185
+ }
186
+
187
+ #applyTypeToItems() {
188
+ if (!['plain', 'check', 'checkbox'].includes(this.type)) {
189
+ console.warn(`<cfpb-list>: invalid type "${this.type}".`);
190
+ return;
191
+ }
192
+ this.#items.forEach((item) => (item.type = this.type));
193
+ }
194
+
195
+ #handleToggle(element, isChecked, index) {
196
+ if (this.multiple) {
197
+ if (isChecked) {
198
+ // Add if not already present.
199
+ if (!this.#checkedItems.includes(element))
200
+ this.#checkedItems.push(element);
201
+ } else {
202
+ // Remove cleanly.
203
+ this.#checkedItems = this.#checkedItems.filter(
204
+ (item) => item !== element,
205
+ );
206
+ }
207
+ } else {
208
+ if (isChecked) {
209
+ // Select this item, uncheck all others.
210
+ this.#items.forEach((item) => {
211
+ if (item !== element) item.checked = false;
212
+ });
213
+ this.#checkedItems = [element];
214
+ } else {
215
+ // Item is unchecked -> clear selection.
216
+ this.#checkedItems.forEach((item) => (item.checked = false));
217
+ this.#checkedItems = [];
218
+ }
219
+ }
220
+
221
+ this.#syncChildDataFromItems();
222
+
223
+ window.queueMicrotask(() => {
224
+ const visIndex = this.#visibleItems.indexOf(element);
225
+ this.focusItemAt(visIndex !== -1 ? visIndex : -1);
226
+ });
227
+
228
+ this.dispatchEvent(
229
+ new CustomEvent('item-click', {
230
+ detail: { index, value: element.value, element },
231
+ bubbles: true,
232
+ composed: true,
233
+ }),
234
+ );
235
+ }
236
+
237
+ // -------------------------
238
+ // FILTER & FOCUS
239
+ // -------------------------
240
+
241
+ /**
242
+ * @param {Array} queryList - List of search words.
243
+ * @returns {Array} List of visible list items.
244
+ */
245
+ filterItems(queryList) {
246
+ this.#visibleItems = [];
247
+ let firstVisibleIndex = -1;
248
+
249
+ this.#items.forEach((item) => {
250
+ const matches = queryList.some((q) =>
251
+ item.value.toLowerCase().includes(q.toLowerCase()),
252
+ );
253
+ item.hidden = !matches;
254
+ if (matches) {
255
+ if (firstVisibleIndex === -1)
256
+ firstVisibleIndex = this.#visibleItems.length;
257
+ this.#visibleItems.push(item);
258
+ }
259
+ });
260
+
261
+ this.#focusedIndex = firstVisibleIndex;
262
+ this.#broadcastFiltered();
263
+
264
+ return this.#visibleItems;
265
+ }
266
+
267
+ showAllItems() {
268
+ this.#items.forEach((item) => (item.hidden = false));
269
+ this.#visibleItems = [...this.#items];
270
+ this.#focusedIndex = 0;
271
+ this.#broadcastFiltered();
272
+ }
273
+
274
+ #broadcastFiltered() {
275
+ this.dispatchEvent(
276
+ new CustomEvent('items-filter', {
277
+ detail: {
278
+ items: this.#items,
279
+ checkedItems: this.#checkedItems,
280
+ visibleItems: this.#visibleItems,
281
+ visibleCheckedItems: this.visibleCheckedItems,
282
+ },
283
+ bubbles: true,
284
+ composed: true,
285
+ }),
286
+ );
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
+ */
300
+ focusItemAt(index) {
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
+ }
313
+
314
+ const normalizedIndex =
315
+ ((index % visibleItems.length) + visibleItems.length) %
316
+ visibleItems.length;
317
+ visibleItems[normalizedIndex].focus();
318
+ this.#focusedIndex = normalizedIndex;
319
+ }
320
+
321
+ #onFocus(evt) {
322
+ // If the focus is on the container itself (not an item), set index to -1.
323
+ if (evt.target === this.#container.value) this.#focusContainer();
324
+ }
325
+
326
+ #onKeyDown(evt) {
327
+ const visibleItems = this.#visibleItems;
328
+ if (!visibleItems.length) return;
329
+ const last = visibleItems.length - 1;
330
+
331
+ switch (evt.key) {
332
+ case 'ArrowDown':
333
+ evt.preventDefault();
334
+ this.focusItemAt(this.#focusedIndex < 0 ? 0 : this.#focusedIndex + 1);
335
+ break;
336
+ case 'ArrowUp':
337
+ evt.preventDefault();
338
+ this.focusItemAt(
339
+ this.#focusedIndex < 0 ? last : this.#focusedIndex - 1,
340
+ );
341
+ break;
342
+ case 'Home':
343
+ evt.preventDefault();
344
+ this.focusItemAt(0);
345
+ break;
346
+ case 'End':
347
+ evt.preventDefault();
348
+ this.focusItemAt(last);
349
+ break;
350
+ }
351
+ }
352
+
353
+ get focusedIndex() {
354
+ return this.#focusedIndex;
355
+ }
356
+
357
+ render() {
358
+ return html`
359
+ <div
360
+ role="listbox"
361
+ tabindex="0"
362
+ @keydown=${this.#onKeyDown}
363
+ @focus=${this.#onFocus}
364
+ aria-label=${this.ariaLabel}
365
+ ?aria-multiselectable=${this.multiple}
366
+ ${ref(this.#container)}
367
+ >
368
+ <slot></slot>
369
+ </div>
370
+ `;
371
+ }
372
+
373
+ static init() {
374
+ CfpbListItem.init();
375
+ if (!window.customElements.get('cfpb-list')) {
376
+ window.customElements.define('cfpb-list', CfpbList);
377
+ }
378
+ }
379
+ }
@@ -0,0 +1,214 @@
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
+ expect(list.items[0].checked).toBe(true);
31
+ expect(list.items[1].checked).toBe(false);
32
+ expect(list.items[2].checked).toBe(false);
33
+ expect(list.checkedItems).toEqual([list.items[0]]);
34
+ });
35
+
36
+ test('multiple selection mode allows multiple checked items', async () => {
37
+ list.multiple = true;
38
+ list.childData = JSON.stringify([
39
+ { value: 'A', checked: true },
40
+ { value: 'B', checked: true },
41
+ { value: 'C' },
42
+ ]);
43
+
44
+ await list.updateComplete;
45
+
46
+ expect(list.checkedItems.length).toBe(2);
47
+ expect(list.checkedItems).toContain(list.items[0]);
48
+ expect(list.checkedItems).toContain(list.items[1]);
49
+
50
+ const event = new CustomEvent('click-item', {
51
+ bubbles: true,
52
+ composed: true,
53
+ });
54
+ list.items[0].checked = false;
55
+ list.items[0].dispatchEvent(event);
56
+
57
+ expect(list.checkedItems).toEqual([list.items[1]]);
58
+ });
59
+
60
+ test('click-item toggles single selection', async () => {
61
+ list.childData = JSON.stringify([{ value: 'A' }, { value: 'B' }]);
62
+ await list.updateComplete;
63
+
64
+ const clickSpy = jest.fn();
65
+ list.addEventListener('item-click', clickSpy);
66
+
67
+ list.items[0].checked = true;
68
+ list.items[0].dispatchEvent(
69
+ new CustomEvent('click-item', { bubbles: true, composed: true }),
70
+ );
71
+
72
+ expect(list.checkedItems).toEqual([list.items[0]]);
73
+ expect(clickSpy).toHaveBeenCalledTimes(1);
74
+
75
+ list.items[0].checked = false;
76
+ list.items[0].dispatchEvent(
77
+ new CustomEvent('click-item', { bubbles: true, composed: true }),
78
+ );
79
+ expect(list.checkedItems).toEqual([]);
80
+ });
81
+
82
+ test('replaces prior click listeners on items', async () => {
83
+ list.childData = JSON.stringify([{ value: 'X' }]);
84
+ await list.updateComplete;
85
+
86
+ const listenerSpy = jest.fn();
87
+ list.addEventListener('item-click', listenerSpy);
88
+
89
+ const event = new CustomEvent('click-item', {
90
+ bubbles: true,
91
+ composed: true,
92
+ });
93
+ list.items[0].dispatchEvent(event);
94
+
95
+ expect(listenerSpy).toHaveBeenCalled();
96
+ });
97
+
98
+ test('filterItems hides items and sets focused index', async () => {
99
+ list.childData = JSON.stringify([
100
+ { value: 'A' },
101
+ { value: 'B' },
102
+ { value: 'C' },
103
+ ]);
104
+ await list.updateComplete;
105
+
106
+ list.filterItems(['B']);
107
+ expect(list.visibleItems.length).toBe(1);
108
+ expect(list.visibleItems[0].value).toBe('B');
109
+ expect(list.items[0].hidden).toBe(true);
110
+ expect(list.items[1].hidden).toBe(false);
111
+ expect(list.items[2].hidden).toBe(true);
112
+ });
113
+
114
+ test('arrow keys skip hidden items', async () => {
115
+ list.childData = JSON.stringify([
116
+ { value: 'A' },
117
+ { value: 'B' },
118
+ { value: 'C' },
119
+ ]);
120
+ await list.updateComplete;
121
+
122
+ list.filterItems(['C']); // only C visible
123
+
124
+ const container = list.shadowRoot.querySelector('div');
125
+ container.focus();
126
+ expect(document.activeElement.tagName).toBe('CFPB-LIST');
127
+
128
+ // ArrowDown → first visible
129
+ container.dispatchEvent(
130
+ new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
131
+ );
132
+ expect(document.activeElement.value).toBe('C');
133
+
134
+ // ArrowDown → wrap
135
+ container.dispatchEvent(
136
+ new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
137
+ );
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');
145
+ });
146
+
147
+ test('showAllItems unhides all items', async () => {
148
+ list.childData = JSON.stringify([
149
+ { value: 'A', hidden: true },
150
+ { value: 'B', hidden: true },
151
+ ]);
152
+ await list.updateComplete;
153
+
154
+ list.showAllItems();
155
+ expect(list.items.every((i) => i.hidden === false)).toBe(true);
156
+ expect(list.visibleItems.length).toBe(2);
157
+ });
158
+
159
+ test('invalid childData logs error', async () => {
160
+ console.error = jest.fn();
161
+ list.childData = 'not-json';
162
+ await list.updateComplete;
163
+ expect(console.error).toHaveBeenCalled();
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
+ });
214
+ });
@@ -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
+ }