@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,381 @@
1
+ import { html, LitElement, css, unsafeCSS, nothing } from 'lit';
2
+ import { ref, createRef } from 'lit/directives/ref.js';
3
+ import { unsafeSVG } from 'lit/directives/unsafe-svg.js';
4
+ import styles from './cfpb-select.component.scss';
5
+ import expandIcon from '../../components/cfpb-icons/icons/down.svg';
6
+ import collapseIcon from '../../components/cfpb-icons/icons/up.svg';
7
+ import { CfpbFormSearchInput } from '../cfpb-form-search-input';
8
+ import { SearchService } from '../cfpb-utilities/search-service.js';
9
+ import { MaxHeightTransition } from '../../utilities/transition/max-height-transition';
10
+ import { FlyoutMenu } from '../../utilities/behavior/flyout-menu';
11
+ import { CfpbList } from '../cfpb-list';
12
+ import { CfpbTagGroup } from '../cfpb-tag-group';
13
+
14
+ /**
15
+ *
16
+ * @element cfpb-button
17
+ * @slot - The main content for the button.
18
+ */
19
+ export class CfpbSelect extends LitElement {
20
+ static styles = css`
21
+ ${unsafeCSS(styles)}
22
+ `;
23
+
24
+ // Flyout menu options.
25
+ #flyoutMenu;
26
+ #transition;
27
+ #search;
28
+ #root = createRef();
29
+ #headerDom = createRef();
30
+ #contentDom = createRef();
31
+ #input = createRef();
32
+ #tagGroup = createRef();
33
+ #list = createRef();
34
+ #displayLabel = createRef();
35
+ #boundOnOutsideClick;
36
+ #noResults = false;
37
+
38
+ /**
39
+ * @property {boolean} multiple - Whether the select supports multiple or not.
40
+ * @property {boolean} isExpanded - Whether the select is expanded or not.
41
+ * @property {Array} selectedTexts - Text of selected options.
42
+ * @returns {object} The map of properties.
43
+ */
44
+ static get properties() {
45
+ return {
46
+ multiple: { type: Boolean, reflect: true },
47
+ disabled: { type: Boolean },
48
+ validation: { type: String },
49
+ label: { type: String },
50
+ name: { type: String },
51
+ title: { type: Boolean, attribute: true },
52
+ value: { type: String },
53
+ maxlength: { type: Number },
54
+ placeholder: { type: String },
55
+ ariaLabelInput: { type: String, attribute: 'aria-label-input' },
56
+ ariaLabelList: { type: String, attribute: 'aria-label-list' },
57
+
58
+ isExpanded: { type: Boolean, attribute: 'open', reflect: true },
59
+ selectedTexts: { type: Array },
60
+ optionList: { type: Array },
61
+ };
62
+ }
63
+
64
+ constructor() {
65
+ super();
66
+
67
+ this.options = [];
68
+ this.selectedTexts = [];
69
+ this.optionList = [];
70
+
71
+ this.#boundOnOutsideClick = this.#onOutsideClick.bind(this);
72
+ }
73
+
74
+ firstUpdated() {
75
+ this.#initFlyoutMenu();
76
+ }
77
+
78
+ disconnectedCallback() {
79
+ document.removeEventListener('pointerdown', this.#boundOnOutsideClick);
80
+ super.disconnectedCallback();
81
+ }
82
+
83
+ #onOutsideClick(evt) {
84
+ const path = evt.composedPath();
85
+ if (!path.includes(this)) {
86
+ this.isExpanded = false;
87
+ }
88
+ }
89
+
90
+ #onSlotChange(evt) {
91
+ const slot = evt.target;
92
+
93
+ const list = slot
94
+ .assignedNodes({ flatten: true })
95
+ .filter(
96
+ (node) =>
97
+ node.nodeType === Node.ELEMENT_NODE &&
98
+ (node.tagName === 'UL' || node.tagName === 'OL'),
99
+ );
100
+
101
+ if (!list || !list[0]) {
102
+ return;
103
+ }
104
+
105
+ // Extract list items (with their text or link info)
106
+ const items = [...list[0].querySelectorAll('li')].map((li) => {
107
+ const checked = li.querySelector('b');
108
+ if (checked) {
109
+ return {
110
+ value: li.textContent.trim(),
111
+ checked: 'true',
112
+ };
113
+ }
114
+ return { value: li.textContent.trim() };
115
+ });
116
+
117
+ this.optionList = items;
118
+
119
+ this.#search = new SearchService(
120
+ items.map((item) => {
121
+ return item.value;
122
+ }),
123
+ );
124
+ }
125
+
126
+ #onInput(evt) {
127
+ this.#flyoutMenu.suspend();
128
+ if (!this.isExpanded) this.isExpanded = true;
129
+ const visibleItems = this.#list.value.filterItems(
130
+ this.#search.search(evt.target.value),
131
+ );
132
+
133
+ if (visibleItems.length === 0) {
134
+ this.#noResults = true;
135
+ this.requestUpdate();
136
+ } else {
137
+ this.#noResults = false;
138
+ this.requestUpdate();
139
+ this.#flyoutMenu.resume();
140
+ }
141
+ }
142
+
143
+ #onClear() {
144
+ this.#list.value.showAllItems();
145
+ }
146
+
147
+ #initFlyoutMenu() {
148
+ const root = this.#root.value;
149
+ const contentDom = this.#contentDom.value;
150
+
151
+ // If it's expanded we don't set an initial height,
152
+ // as it will be calculated internally.
153
+ const initialClass = this.isExpanded
154
+ ? MaxHeightTransition.CLASSES.MH_DEFAULT
155
+ : MaxHeightTransition.CLASSES.MH_ZERO;
156
+ this.#transition = new MaxHeightTransition(contentDom).init(initialClass);
157
+
158
+ this.#flyoutMenu = new FlyoutMenu(root);
159
+
160
+ this.#flyoutMenu.setTransition(
161
+ this.#transition,
162
+ this.#transition.maxHeightZero,
163
+ this.#transition.maxHeightDynamic,
164
+ );
165
+
166
+ this.#flyoutMenu.init(this.isExpanded);
167
+
168
+ // Add events.
169
+ this.#flyoutMenu.addEventListener('expandbegin', () => {
170
+ this.isExpanded = true;
171
+ contentDom.classList.remove('u-hidden');
172
+ this.dispatchEvent(
173
+ new CustomEvent('expandbegin', {
174
+ detail: { target: this },
175
+ bubbles: true,
176
+ composed: true,
177
+ }),
178
+ );
179
+ });
180
+ this.#flyoutMenu.addEventListener('collapseend', () => {
181
+ this.isExpanded = false;
182
+ contentDom.classList.add('u-hidden');
183
+
184
+ // Remove direction classes.
185
+ this.#root.value.classList.remove(`o-select--up`);
186
+ this.#root.value.classList.remove(`o-select--down`);
187
+ });
188
+
189
+ this.#transition.addEventListener('transitiondir', (evt) => {
190
+ this.#root.value.classList.add(`o-select--${evt.dir}`);
191
+ });
192
+ }
193
+
194
+ updated(changedProps) {
195
+ if (changedProps.has('isExpanded')) {
196
+ const oldVal = changedProps.get('isExpanded');
197
+ const newVal = this.isExpanded;
198
+
199
+ if (newVal !== oldVal) {
200
+ if (newVal) {
201
+ this.#flyoutMenu.expand();
202
+ document.addEventListener('pointerdown', this.#boundOnOutsideClick);
203
+ } else {
204
+ this.#flyoutMenu.collapse();
205
+ document.removeEventListener(
206
+ 'pointerdown',
207
+ this.#boundOnOutsideClick,
208
+ );
209
+ }
210
+ }
211
+ }
212
+ }
213
+
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;
250
+
251
+ // Move focus back to the header.
252
+ if (this.multiple) this.#input.value.focus();
253
+ else this.#headerDom.value.focus();
254
+ }
255
+ }
256
+
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
+ }));
266
+
267
+ this.requestUpdate();
268
+ }
269
+
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
+ }
277
+ }
278
+
279
+ render() {
280
+ return html`
281
+ <!--Light DOM content-->
282
+ <slot @slotchange=${this.#onSlotChange}></slot>
283
+
284
+ ${this.multiple
285
+ ? html`<cfpb-tag-group
286
+ ${ref(this.#tagGroup)}
287
+ .childData=${this.optionList
288
+ .filter((item) => {
289
+ return item.checked;
290
+ })
291
+ .map((item) => {
292
+ return { text: item.value, tagName: 'cfpb-tag-filter' };
293
+ })}
294
+ @tag-click=${this.#onTagClick}
295
+ >
296
+ </cfpb-tag-group>`
297
+ : nothing}
298
+
299
+ <div
300
+ class="o-select o-select--border"
301
+ data-js-hook="behavior_flyout-menu"
302
+ ${ref(this.#root)}
303
+ >
304
+ ${this.#renderInput()}
305
+
306
+ <button
307
+ class="o-select__header"
308
+ title="Expand content"
309
+ data-js-hook="behavior_flyout-menu_trigger"
310
+ ${ref(this.#headerDom)}
311
+ @keydown=${this.#onKeyDown}
312
+ >
313
+ <span class="o-select__cues" @click=${this.#onClick}>
314
+ <span class="o-select__cue-open" role="img" aria-label="Show">
315
+ ${unsafeSVG(expandIcon)}
316
+ <span class="u-visually-hidden">Show</span>
317
+ </span>
318
+ <span class="o-select__cue-close" role="img" aria-label="Hide">
319
+ ${unsafeSVG(collapseIcon)}
320
+ <span class="u-visually-hidden">Hide</span>
321
+ </span>
322
+ </span>
323
+ </button>
324
+ <div
325
+ class="o-select__content"
326
+ data-js-hook="behavior_flyout-menu_content"
327
+ ${ref(this.#contentDom)}
328
+ >
329
+ <cfpb-list
330
+ @item-click=${this.#onItemClick}
331
+ ?multiple=${this.multiple}
332
+ .childData=${this.optionList}
333
+ type=${this.multiple ? 'checkbox' : 'check'}
334
+ aria-label=${this.ariaLabelList
335
+ ? this.ariaLabelList
336
+ : 'Choose an item…'}
337
+ ${ref(this.#list)}
338
+ >
339
+ </cfpb-list>
340
+ <div class=${this.#noResults ? 'no-results' : 'u-hidden'}>
341
+ No results found
342
+ </div>
343
+ </div>
344
+ </div>
345
+ `;
346
+ }
347
+
348
+ #renderInput() {
349
+ return this.multiple
350
+ ? html`
351
+ <cfpb-form-search-input
352
+ ${ref(this.#input)}
353
+ borderless
354
+ ?name=${this.name}
355
+ ?value=${this.value}
356
+ ?placeholder=${this.placeholder}
357
+ title=${this.title}
358
+ ?maxlength=${this.maxlength}
359
+ aria-label=${this.ariaLabelInput}
360
+ ?validation=${this.validation}
361
+ @clear=${this.#onClear}
362
+ @input=${this.#onInput}
363
+ @click=${this.#onClick}
364
+ ></cfpb-form-search-input>
365
+ `
366
+ : html`<div
367
+ class="o-select__label"
368
+ ${ref(this.#displayLabel)}
369
+ @click=${this.#onClick}
370
+ ></div>`;
371
+ }
372
+
373
+ static init() {
374
+ CfpbFormSearchInput.init();
375
+ CfpbList.init();
376
+ CfpbTagGroup.init();
377
+
378
+ window.customElements.get('cfpb-select') ||
379
+ window.customElements.define('cfpb-select', CfpbSelect);
380
+ }
381
+ }
@@ -1,15 +1,18 @@
1
1
  @use 'sass:math';
2
- @use '@cfpb/cfpb-design-system/src/abstracts' as *;
3
- @use '@cfpb/cfpb-design-system/src/components/cfpb-buttons/vars' as *;
2
+ @use '@cfpb/cfpb-design-system/src/elements/abstracts' as *;
3
+ @use '@cfpb/cfpb-design-system/src/elements/cfpb-button/vars' as *;
4
4
 
5
5
  :host {
6
6
  button {
7
+ // This line-height isn't 19 or 22, as 20 creates a 30px high tag.
8
+ line-height: math.div(20px, $base-font-size-px);
9
+
7
10
  // Filter tags appear in filtered contexts, often as part of multiselects.
8
- line-height: math.div(19px, $base-font-size-px);
9
11
  font-size: math.div(16px, $btn-font-size) + rem;
10
12
 
11
13
  display: flex;
12
14
  gap: math.div(10px, $btn-font-size) + rem;
15
+ align-items: center;
13
16
 
14
17
  border: 1px solid var(--teal);
15
18
  padding: 4px 6px;
@@ -17,15 +17,15 @@ export class CfpbTagFilter extends LitElement {
17
17
  * @property {string} for - Associate the label with an ID elsewhere.
18
18
  * @returns {object} The map of properties.
19
19
  */
20
- static get properties() {
21
- return {
22
- for: { type: String },
23
- };
24
- }
20
+ static properties = {
21
+ for: { type: String },
22
+ value: { type: String },
23
+ };
25
24
 
26
25
  constructor() {
27
26
  super();
28
27
  this.for = '';
28
+ this.value = '';
29
29
  }
30
30
 
31
31
  #onClick() {
@@ -38,11 +38,19 @@ export class CfpbTagFilter extends LitElement {
38
38
  );
39
39
  }
40
40
 
41
+ #onSlotChange() {
42
+ const slot = this.shadowRoot.querySelector('slot');
43
+ this.value = slot
44
+ .assignedNodes({ flatten: true })
45
+ .map((node) => node.textContent.trim())
46
+ .join(' ');
47
+ }
48
+
41
49
  render() {
42
50
  const slot =
43
51
  this.for === ''
44
- ? html`<slot></slot>`
45
- : html`<label for="${this.for}"><slot></slot></label>`;
52
+ ? html`<slot @slotchange=${this.#onSlotChange}></slot>`
53
+ : html`<label for=${this.for}><slot></slot></label>`;
46
54
  return html`<button @click=${this.#onClick}>
47
55
  ${slot} ${unsafeHTML(icon)}
48
56
  </button>`;
@@ -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/components/cfpb-buttons/vars' as *;
2
+ @use '@cfpb/cfpb-design-system/src/elements/abstracts' as *;
3
+ @use '@cfpb/cfpb-design-system/src/elements/cfpb-button/vars' as *;
4
4
 
5
5
  :host {
6
6
  // Tag group sets the spacing between tags.
@@ -1,5 +1,6 @@
1
1
  import { html, LitElement, css, unsafeCSS } from 'lit';
2
2
  import styles from './cfpb-tag-group.component.scss';
3
+ import { parseChildData } from '../cfpb-utilities/parse-child-data';
3
4
 
4
5
  const SUPPORTED_TAG_LIST = ['CFPB-TAG-FILTER', 'CFPB-TAG-TOPIC'];
5
6
 
@@ -21,16 +22,16 @@ export class CfpbTagGroup extends LitElement {
21
22
  `;
22
23
 
23
24
  /**
25
+ * @property {string} childData - Structure data to create child components.
24
26
  * @property {boolean} stacked - Whether to stack the tags vertically.
25
27
  * @property {Array} tagList - List of the tags in the tag group.
26
28
  * @returns {object} The map of properties.
27
29
  */
28
- static get properties() {
29
- return {
30
- stacked: { type: Boolean, reflect: true },
31
- tagList: { type: Array },
32
- };
33
- }
30
+ static properties = {
31
+ childData: { type: String, attribute: 'childdata' },
32
+ stacked: { type: Boolean, reflect: true },
33
+ tagList: { type: Array },
34
+ };
34
35
 
35
36
  // Private properties.
36
37
  #observer;
@@ -39,6 +40,7 @@ export class CfpbTagGroup extends LitElement {
39
40
 
40
41
  constructor() {
41
42
  super();
43
+ this.childData = '';
42
44
  this.stacked = false;
43
45
  this.tagList = [];
44
46
  this.#observer = new MutationObserver(this.#onMutation.bind(this));
@@ -67,6 +69,51 @@ export class CfpbTagGroup extends LitElement {
67
69
  });
68
70
  }
69
71
 
72
+ updated(changedProps) {
73
+ if (changedProps.has('childData')) {
74
+ const parsed = parseChildData(this.childData);
75
+ if (parsed) {
76
+ this.#renderTagsFromData(parsed);
77
+ }
78
+ }
79
+ }
80
+
81
+ #renderTagsFromData(arr) {
82
+ if (!Array.isArray(arr)) return;
83
+
84
+ this.#clearAllTags();
85
+
86
+ arr.forEach((data, index) => {
87
+ const tag = document.createElement(data.tagName);
88
+ // e.g. 'cfpb-tag-filter' or 'cfpb-tag-topic'
89
+ if (data.text) tag.textContent = data.text;
90
+ if (data.href) tag.href = data.href;
91
+ // any other props from `data`
92
+ this.addTag(tag, index);
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Remove all previous tags from shadow DOM and light DOM.
98
+ */
99
+ #clearAllTags() {
100
+ // Remove shadow DOM wrappers.
101
+ if (this.#tagMap) {
102
+ this.#tagMap.forEach((wrapped) => {
103
+ if (wrapped.parentElement) wrapped.remove();
104
+ });
105
+ this.#tagMap.clear();
106
+ }
107
+
108
+ // Remove light DOM tags.
109
+ [...this.children].forEach((child) => {
110
+ if (SUPPORTED_TAG_LIST.includes(child.tagName)) child.remove();
111
+ });
112
+
113
+ // Reset tagList
114
+ this.tagList = [];
115
+ }
116
+
70
117
  /**
71
118
  * Set up a MutationObserver to watch changes in the light DOM.
72
119
  */
@@ -17,12 +17,10 @@ export class CfpbTagTopic extends LitElement {
17
17
  * Whether the preceding sibling is a jump link or not.
18
18
  * @returns {object} The map of properties.
19
19
  */
20
- static get properties() {
21
- return {
22
- href: { type: String, reflect: true },
23
- siblingOfJumpLink: { type: Boolean },
24
- };
25
- }
20
+ static properties = {
21
+ href: { type: String, reflect: true },
22
+ siblingOfJumpLink: { type: Boolean },
23
+ };
26
24
 
27
25
  /*
28
26
  * @property {string} href - The URL to link to (makes the tag a link).
@@ -55,7 +53,7 @@ export class CfpbTagTopic extends LitElement {
55
53
  ? html`<span class="a-tag-topic"
56
54
  >${bullet}<span class="a-tag-topic__text"><slot></slot></span
57
55
  ></span>`
58
- : html`<a class="${this.#tagClass}" href="${this.href}"
56
+ : html`<a class=${this.#tagClass} href=${this.href}
59
57
  >${bullet}<span class="a-tag-topic__text"><slot></slot></span
60
58
  ></a>`;
61
59
  return html`${slot}`;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Normalize "childData" style inputs into an array.
3
+ * Accepts:
4
+ * - JS arrays
5
+ * - JSON strings
6
+ * - JSON-like strings with single quotes
7
+ * @param {Array | string} input
8
+ * @param {object} options - optional settings.
9
+ * @param {boolean} options.allowSingleQuotes - default true.
10
+ * @returns {Array|null} Parsed array/string, or null if invalid.
11
+ */
12
+ export function parseChildData(input, options = {}) {
13
+ const { allowSingleQuotes = true } = options;
14
+
15
+ if (!input) return null;
16
+
17
+ // Already an array - most desirable case.
18
+ if (Array.isArray(input)) {
19
+ return input;
20
+ }
21
+
22
+ if (typeof input !== 'string') {
23
+ console.error('childData must be a string or array.');
24
+ return null;
25
+ }
26
+
27
+ let text = input.trim();
28
+
29
+ // String is empty after trim.
30
+ if (!text) return null;
31
+
32
+ // Optional conversation: single -> double quotes for HTML convenience.
33
+ if (allowSingleQuotes) {
34
+ text = text.replace(/'/g, '"');
35
+ }
36
+
37
+ try {
38
+ const parsed = JSON.parse(text);
39
+
40
+ if (!Array.isArray(parsed)) {
41
+ console.error('childData JSON must parse to an array.');
42
+ return null;
43
+ }
44
+
45
+ return parsed;
46
+ } catch (err) {
47
+ console.error('Failed to parse childData JSON:', err);
48
+ return null;
49
+ }
50
+ }
@@ -0,0 +1,56 @@
1
+ import { parseChildData } from './parse-child-data.js';
2
+
3
+ describe('parseChildData', () => {
4
+ it('returns null for empty input', () => {
5
+ expect(parseChildData(null)).toBeNull();
6
+ expect(parseChildData(undefined)).toBeNull();
7
+ expect(parseChildData('')).toBeNull();
8
+ });
9
+
10
+ it('returns the array unchanged if input is already an array', () => {
11
+ const arr = [1, 2, 3];
12
+ expect(parseChildData(arr)).toBe(arr);
13
+ });
14
+
15
+ it('parses valid JSON string into array', () => {
16
+ const json = '[{"text":"a"},{"text":"b"}]';
17
+ const result = parseChildData(json);
18
+ expect(result).toEqual([{ text: 'a' }, { text: 'b' }]);
19
+ });
20
+
21
+ it('parses JSON-like string with single quotes when allowSingleQuotes=true', () => {
22
+ const jsonLike = "[{'text':'a'},{'text':'b'}]";
23
+ const result = parseChildData(jsonLike, { allowSingleQuotes: true });
24
+ expect(result).toEqual([{ text: 'a' }, { text: 'b' }]);
25
+ });
26
+
27
+ it('throws error / returns null for invalid JSON', () => {
28
+ const invalid = '[{text:a},{text:b}]';
29
+ const result = parseChildData(invalid);
30
+ expect(result).toBeNull();
31
+ });
32
+
33
+ it('returns null when parsed JSON is not an array', () => {
34
+ const objString = '{"a":1,"b":2}';
35
+ const result = parseChildData(objString);
36
+ expect(result).toBeNull();
37
+ });
38
+
39
+ it('does not convert single quotes if allowSingleQuotes=false', () => {
40
+ const singleQuoteStr = "[{'text':'a'}]";
41
+ const result = parseChildData(singleQuoteStr, { allowSingleQuotes: false });
42
+ expect(result).toBeNull();
43
+ });
44
+
45
+ it('trims whitespace before parsing', () => {
46
+ const json = ' [ {"text":"x"} ] ';
47
+ const result = parseChildData(json);
48
+ expect(result).toEqual([{ text: 'x' }]);
49
+ });
50
+
51
+ it('works with mixed content and multiple spaces', () => {
52
+ const jsonLike = " [ { 'text' : 'x' } , { 'text':'y' } ] ";
53
+ const result = parseChildData(jsonLike, { allowSingleQuotes: true });
54
+ expect(result).toEqual([{ text: 'x' }, { text: 'y' }]);
55
+ });
56
+ });