@cfpb/cfpb-design-system 4.2.3 → 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 (147) hide show
  1. package/CHANGELOG.md +178 -1
  2. package/dist/base/index.css +1 -1
  3. package/dist/base/index.css.map +2 -2
  4. package/dist/base/index.js.map +1 -1
  5. package/dist/components/cfpb-buttons/index.css +1 -1
  6. package/dist/components/cfpb-buttons/index.css.map +2 -2
  7. package/dist/components/cfpb-buttons/index.js.map +1 -1
  8. package/dist/components/cfpb-expandables/index.css.map +1 -1
  9. package/dist/components/cfpb-expandables/index.js +1 -1
  10. package/dist/components/cfpb-expandables/index.js.map +3 -3
  11. package/dist/components/cfpb-forms/index.css +1 -1
  12. package/dist/components/cfpb-forms/index.css.map +2 -2
  13. package/dist/components/cfpb-forms/index.js +1 -1
  14. package/dist/components/cfpb-forms/index.js.map +2 -2
  15. package/dist/components/cfpb-layout/index.css +1 -1
  16. package/dist/components/cfpb-layout/index.css.map +1 -1
  17. package/dist/components/cfpb-notifications/index.css.map +1 -1
  18. package/dist/components/cfpb-pagination/index.css.map +1 -1
  19. package/dist/components/cfpb-tables/index.css.map +1 -1
  20. package/dist/components/cfpb-typography/index.css +1 -1
  21. package/dist/components/cfpb-typography/index.css.map +2 -2
  22. package/dist/components/cfpb-typography/index.js.map +1 -1
  23. package/dist/elements/cfpb-button/index.js +4 -4
  24. package/dist/elements/cfpb-button/index.js.map +3 -3
  25. package/dist/elements/cfpb-checkbox-icon/index.js +29 -0
  26. package/dist/elements/{cfpb-checkbox → cfpb-checkbox-icon}/index.js.map +4 -4
  27. package/dist/elements/cfpb-expandable/index.css +2 -0
  28. package/dist/elements/cfpb-expandable/index.css.map +7 -0
  29. package/dist/elements/cfpb-expandable/index.js +33 -0
  30. package/dist/elements/cfpb-expandable/index.js.map +7 -0
  31. package/dist/elements/cfpb-file-upload/index.js +4 -4
  32. package/dist/elements/cfpb-file-upload/index.js.map +3 -3
  33. package/dist/elements/cfpb-form-alert/index.js +32 -0
  34. package/dist/elements/cfpb-form-alert/index.js.map +7 -0
  35. package/dist/elements/cfpb-form-choice/index.js +12 -3
  36. package/dist/elements/cfpb-form-choice/index.js.map +4 -4
  37. package/dist/elements/cfpb-form-search/index.js +41 -0
  38. package/dist/elements/cfpb-form-search/index.js.map +7 -0
  39. package/dist/elements/cfpb-form-search-input/index.js +41 -0
  40. package/dist/elements/cfpb-form-search-input/index.js.map +7 -0
  41. package/dist/elements/cfpb-icon-text/index.js +3 -3
  42. package/dist/elements/cfpb-icon-text/index.js.map +3 -3
  43. package/dist/elements/cfpb-label/index.js +3 -3
  44. package/dist/elements/cfpb-label/index.js.map +2 -2
  45. package/dist/elements/cfpb-list/index.js +39 -0
  46. package/dist/elements/cfpb-list/index.js.map +7 -0
  47. package/dist/elements/cfpb-list-item/index.js +39 -0
  48. package/dist/elements/cfpb-list-item/index.js.map +7 -0
  49. package/dist/elements/cfpb-multiselect/index.js +13 -4
  50. package/dist/elements/cfpb-multiselect/index.js.map +4 -4
  51. package/dist/elements/cfpb-pagination/index.js +3 -3
  52. package/dist/elements/cfpb-pagination/index.js.map +2 -2
  53. package/dist/elements/cfpb-select/index.css +2 -0
  54. package/dist/elements/cfpb-select/index.css.map +7 -0
  55. package/dist/elements/cfpb-select/index.js +42 -0
  56. package/dist/elements/cfpb-select/index.js.map +7 -0
  57. package/dist/elements/cfpb-select-list/index.js +39 -0
  58. package/dist/elements/cfpb-select-list/index.js.map +7 -0
  59. package/dist/elements/cfpb-tag-filter/index.js +3 -3
  60. package/dist/elements/cfpb-tag-filter/index.js.map +3 -3
  61. package/dist/elements/cfpb-tag-group/index.js +3 -3
  62. package/dist/elements/cfpb-tag-group/index.js.map +4 -4
  63. package/dist/elements/cfpb-tag-topic/index.js +4 -4
  64. package/dist/elements/cfpb-tag-topic/index.js.map +2 -2
  65. package/dist/elements/index.css +2 -0
  66. package/dist/elements/index.css.map +7 -0
  67. package/dist/elements/index.js +7 -6
  68. package/dist/elements/index.js.map +4 -4
  69. package/dist/index.css +1 -1
  70. package/dist/index.css.map +2 -2
  71. package/dist/index.js +7 -6
  72. package/dist/index.js.map +4 -4
  73. package/dist/utilities/index.css.map +1 -1
  74. package/dist/utilities/index.js +1 -1
  75. package/dist/utilities/index.js.map +3 -3
  76. package/package.json +1 -1
  77. package/src/base/base.scss +1 -1
  78. package/src/components/cfpb-buttons/button-link.scss +0 -1
  79. package/src/components/cfpb-expandables/expandable.js +3 -0
  80. package/src/components/cfpb-forms/multiselect.js +1 -1
  81. package/src/components/cfpb-typography/mixins.scss +3 -0
  82. package/src/elements/abstracts/custom-props.css +123 -0
  83. package/src/elements/abstracts/grid-mixins.scss +83 -0
  84. package/src/elements/abstracts/heading-mixins.scss +346 -0
  85. package/src/elements/abstracts/index.scss +7 -0
  86. package/src/elements/abstracts/media-queries.scss +35 -0
  87. package/src/elements/abstracts/sizing-vars.scss +65 -0
  88. package/src/elements/abstracts/vars-breakpoints.scss +16 -0
  89. package/src/elements/abstracts/vars.css +79 -0
  90. package/src/elements/base/base.scss +375 -0
  91. package/src/elements/base/font.scss +27 -0
  92. package/src/elements/base/index.scss +3 -0
  93. package/src/elements/base/normalize.scss +290 -0
  94. package/src/elements/cfpb-button/cfpb-button-group.scss +10 -0
  95. package/src/elements/cfpb-button/cfpb-button-link.scss +96 -0
  96. package/src/elements/cfpb-button/cfpb-button.component.scss +11 -4
  97. package/src/elements/cfpb-button/cfpb-button.scss +222 -0
  98. package/src/elements/cfpb-button/index.js +28 -29
  99. package/src/elements/cfpb-button/vars.css +30 -0
  100. package/src/elements/cfpb-checkbox-icon/cfpb-checkbox-icon.component.scss +88 -0
  101. package/src/elements/cfpb-checkbox-icon/index.js +104 -0
  102. package/src/elements/cfpb-expandable/cfpb-expandable.component.scss +218 -0
  103. package/src/elements/cfpb-expandable/index.js +127 -0
  104. package/src/elements/cfpb-file-upload/cfpb-file-upload.component.scss +2 -2
  105. package/src/elements/cfpb-file-upload/index.js +16 -18
  106. package/src/elements/cfpb-form-alert/cfpb-form-alert.component.scss +36 -0
  107. package/src/elements/cfpb-form-alert/index.js +55 -0
  108. package/src/elements/cfpb-form-choice/cfpb-form-choice.component.scss +42 -81
  109. package/src/elements/cfpb-form-choice/index.js +58 -18
  110. package/src/elements/cfpb-form-search/cfpb-form-search.component.scss +54 -0
  111. package/src/elements/cfpb-form-search/index.js +194 -0
  112. package/src/elements/cfpb-form-search-input/cfpb-form-search-input.component.scss +217 -0
  113. package/src/elements/cfpb-form-search-input/index.js +136 -0
  114. package/src/elements/cfpb-icon-text/cfpb-icon-text.component.scss +32 -39
  115. package/src/elements/cfpb-icon-text/index.js +32 -104
  116. package/src/elements/cfpb-label/cfpb-label.component.scss +2 -2
  117. package/src/elements/cfpb-label/index.js +6 -9
  118. package/src/elements/cfpb-list/cfpb-list.component.scss +23 -0
  119. package/src/elements/cfpb-list/index.js +357 -0
  120. package/src/elements/cfpb-list/index.spec.js +169 -0
  121. package/src/elements/cfpb-list-item/cfpb-list-item.component.scss +69 -0
  122. package/src/elements/cfpb-list-item/index.js +215 -0
  123. package/src/elements/cfpb-pagination/cfpb-pagination.component.scss +2 -7
  124. package/src/elements/cfpb-pagination/index.js +6 -8
  125. package/src/elements/cfpb-select/cfpb-select.component.scss +241 -0
  126. package/src/elements/cfpb-select/index.js +381 -0
  127. package/src/elements/cfpb-tag-filter/cfpb-tag-filter.component.scss +6 -3
  128. package/src/elements/cfpb-tag-filter/index.js +15 -7
  129. package/src/elements/cfpb-tag-group/cfpb-tag-group.component.scss +2 -2
  130. package/src/elements/cfpb-tag-group/index.js +53 -6
  131. package/src/elements/cfpb-tag-topic/index.js +5 -7
  132. package/src/elements/cfpb-utilities/parse-child-data.js +50 -0
  133. package/src/elements/cfpb-utilities/parse-child-data.spec.js +56 -0
  134. package/src/elements/cfpb-utilities/search-service.js +46 -0
  135. package/src/elements/cfpb-utilities/search-service.spec.js +138 -0
  136. package/src/elements/cfpb-utilities/transition/transition.scss +98 -0
  137. package/src/elements/index.js +7 -1
  138. package/src/index.scss +11 -0
  139. package/src/tokens/abstracts/custom-props.json +1642 -0
  140. package/src/tokens/abstracts/vars.json +1319 -0
  141. package/src/tokens/cfpb-button/vars.json +436 -0
  142. package/src/utilities/transition/max-height-transition.js +74 -0
  143. package/dist/elements/cfpb-checkbox/index.js +0 -29
  144. package/src/elements/cfpb-multiselect/cfpb-multiselect.component.scss +0 -225
  145. package/src/elements/cfpb-multiselect/index.js +0 -444
  146. package/src/elements/cfpb-multiselect/multiselect-model.js +0 -288
  147. package/src/elements/cfpb-multiselect/multiselect-model.spec.js +0 -236
@@ -2,7 +2,6 @@ import { html, LitElement, css, unsafeCSS } from 'lit';
2
2
  import styles from './cfpb-icon-text.component.scss';
3
3
 
4
4
  /**
5
- *
6
5
  * @element cfpb-icon-text
7
6
  * @slot - The main content for the text and icon.
8
7
  */
@@ -15,132 +14,61 @@ export class CfpbIconText extends LitElement {
15
14
  * @property {boolean} disabled - Apply disabled styles or not.
16
15
  * @returns {object} The map of properties.
17
16
  */
18
- static get properties() {
19
- return {
20
- disabled: { type: Boolean, reflect: true },
21
- };
22
- }
23
-
24
- // DOM references.
25
- #svgObserver;
26
- #iconClasses;
17
+ static properties = {
18
+ disabled: { type: Boolean, reflect: true },
19
+ iconHidden: { type: Boolean, reflect: true, attribute: 'icon-hidden' },
20
+ };
27
21
 
28
22
  constructor() {
29
23
  super();
30
- this.#iconClasses = '';
24
+ this.disabled = false;
25
+ this.iconHidden = false;
31
26
  }
32
27
 
33
- connectedCallback() {
34
- super.connectedCallback();
35
-
36
- this.#svgObserver = new MutationObserver(() => {
37
- this.#processLightDom();
38
- });
28
+ firstUpdated() {
29
+ const slot = this.shadowRoot.querySelector('slot');
30
+ this.#updateDividers();
39
31
 
40
- this.#svgObserver.observe(this, {
41
- childList: true,
42
- subtree: false,
43
- });
32
+ // Handle dynamically added/removed nodes.
33
+ slot.addEventListener('slotchange', () => this.#updateDividers());
44
34
  }
45
35
 
46
- disconnectedCallback() {
47
- super.disconnectedCallback();
48
- if (this.#svgObserver) {
49
- this.#svgObserver.disconnect();
50
- this.#svgObserver = null;
36
+ updated(changedProps) {
37
+ if (changedProps.has('iconHidden')) {
38
+ this.#updateDividers();
51
39
  }
52
40
  }
53
41
 
54
- #processLightDom() {
55
- const div = this.shadowRoot.querySelector('div');
42
+ #updateDividers() {
43
+ const wrapper = this.shadowRoot.querySelector('.wrapper');
56
44
  const slot = this.shadowRoot.querySelector('slot');
57
- const nodes = slot.assignedNodes({ flatten: true });
58
-
59
- let svgEl = null;
60
- let spanEl = null;
45
+ const nodes = slot.assignedNodes({ flatten: true }).filter((node) => {
46
+ return (
47
+ node.nodeType === Node.ELEMENT_NODE ||
48
+ (node.nodeType === Node.TEXT_NODE && node.textContent.trim())
49
+ );
50
+ });
61
51
 
62
- for (const node of nodes) {
63
- if (
64
- node.nodeType === Node.TEXT_NODE &&
65
- node.textContent.trim().length > 0
66
- ) {
67
- const span = document.createElement('span');
68
- span.textContent = node.textContent;
69
- node.replaceWith(span);
70
- if (!spanEl) spanEl = span;
71
- } else if (node.nodeType === Node.ELEMENT_NODE) {
72
- const tag = node.tagName.toLowerCase();
73
- if (tag === 'svg' && !svgEl) {
74
- svgEl = node;
75
- } else if (tag === 'span' && !spanEl) {
76
- spanEl = node;
77
- }
78
- }
79
- }
52
+ const showLeft =
53
+ !this.iconHidden && nodes[0]?.tagName?.toLowerCase() === 'svg';
54
+ const showRight =
55
+ !this.iconHidden &&
56
+ nodes[nodes.length - 1]?.tagName?.toLowerCase() === 'svg';
80
57
 
81
- if (svgEl && spanEl) {
82
- div.classList.add('u-has-icon');
83
- if (
84
- svgEl.compareDocumentPosition(spanEl) & Node.DOCUMENT_POSITION_FOLLOWING
85
- ) {
86
- div.classList.add('u-has-icon--left');
87
- } else {
88
- div.classList.add('u-has-icon--right');
89
- }
90
- }
58
+ wrapper.classList.toggle('left-divider', showLeft);
59
+ wrapper.classList.toggle('right-divider', showRight);
91
60
  }
92
61
 
93
- firstUpdated() {
94
- this.#processLightDom();
95
- }
96
-
97
- /**
98
- * Hide any icon in the slot.
99
- */
100
62
  hideIcon() {
101
- const icon = this.#findIconInSlot();
102
- const div = this.shadowRoot.querySelector('div');
103
- if (icon) {
104
- this.#iconClasses = div.className;
105
- div.className = '';
106
- }
63
+ this.iconHidden = true;
107
64
  }
108
65
 
109
- /**
110
- * Show any icon in the slot, if it was hidden.
111
- */
112
66
  showIcon() {
113
- const icon = this.#findIconInSlot();
114
- const div = this.shadowRoot.querySelector('div');
115
- if (icon) div.className = this.#iconClasses;
116
- }
117
-
118
- /**
119
- * @returns {boolean} True if it has an icon, false otherwise.
120
- */
121
- hasIcon() {
122
- const icon = this.#findIconInSlot();
123
- if (icon) return true;
124
- return false;
125
- }
126
-
127
- /**
128
- * Find the icon SVG in the slot.
129
- * @returns {Node} The icon SVG node.
130
- */
131
- #findIconInSlot() {
132
- const slot = this.shadowRoot.querySelector('slot');
133
- const nodes = slot.assignedNodes({ flatten: true });
134
-
135
- for (const node of nodes) {
136
- if (node.tagName && node.tagName.toLowerCase() === 'svg') {
137
- return node;
138
- }
139
- }
67
+ this.iconHidden = false;
140
68
  }
141
69
 
142
70
  render() {
143
- return html`<div><slot></slot></div>`;
71
+ return html`<span class="wrapper"><slot></slot></span>`;
144
72
  }
145
73
 
146
74
  static init() {
@@ -1,5 +1,5 @@
1
1
  @use 'sass:math';
2
- @use '@cfpb/cfpb-design-system/src/abstracts' as *;
2
+ @use '@cfpb/cfpb-design-system/src/elements/abstracts' as *;
3
3
 
4
4
  :host {
5
5
  .a-label {
@@ -8,7 +8,7 @@
8
8
  }
9
9
 
10
10
  &__helper {
11
- color: $label-helper;
11
+ color: var(--label-helper);
12
12
  font-size: math.div(16px, $base-font-size-px) + rem;
13
13
  font-weight: normal;
14
14
 
@@ -14,17 +14,14 @@ export class CfpbLabel extends LitElement {
14
14
  `;
15
15
 
16
16
  /**
17
- * @property {string} for - Associate the label with an ID elsewhere.
18
17
  * @property {boolean} block - Whether this has block or inline helper text.
18
+ * @property {string} for - Associate the label with an ID elsewhere.
19
19
  * @returns {object} The map of properties.
20
20
  */
21
- static get properties() {
22
- return {
23
- // Other properties.
24
- block: { type: Boolean, reflect: true },
25
- for: { type: String },
26
- };
27
- }
21
+ static properties = {
22
+ block: { type: Boolean, reflect: true },
23
+ for: { type: String },
24
+ };
28
25
 
29
26
  constructor() {
30
27
  super();
@@ -48,7 +45,7 @@ export class CfpbLabel extends LitElement {
48
45
  for=${ifDefined(this.for && this.for.trim() ? this.for : undefined)}
49
46
  >
50
47
  <slot name="label"></slot>
51
- <small class="${this.#helperClass}">
48
+ <small class=${this.#helperClass}>
52
49
  <slot name="helper"></slot>
53
50
  </small>
54
51
  </label>
@@ -0,0 +1,23 @@
1
+ @use 'sass:math';
2
+ @use '@cfpb/cfpb-design-system/src/elements/abstracts' as *;
3
+ @use '@cfpb/cfpb-design-system/src/components/cfpb-icons/icon';
4
+
5
+ :host {
6
+ ::slotted(cfpb-list-item) {
7
+ border-bottom: 1px solid var(--gray-20);
8
+
9
+ // Overlap with the border of prior item.
10
+ margin-top: -1px;
11
+ }
12
+
13
+ ::slotted(cfpb-list-item:first-of-type) {
14
+ border-top: 1px solid var(--gray-20);
15
+
16
+ // Don't shift the first item.
17
+ margin-top: 0;
18
+ }
19
+
20
+ :focus {
21
+ outline: 1px dotted var(--pacific);
22
+ }
23
+ }
@@ -0,0 +1,357 @@
1
+ import { LitElement, html, css, unsafeCSS } from 'lit';
2
+ import { ref, createRef } from 'lit/directives/ref.js';
3
+ import styles from './cfpb-list.component.scss';
4
+ import { CfpbListItem } from '../cfpb-list-item';
5
+ import { parseChildData } from '../cfpb-utilities/parse-child-data';
6
+
7
+ export class CfpbList extends LitElement {
8
+ static styles = css`
9
+ ${unsafeCSS(styles)}
10
+ `;
11
+
12
+ #container = createRef();
13
+ #items = [];
14
+ #checkedItems = [];
15
+ #visibleItems = [];
16
+ #focusedIndex = 0;
17
+ #internalSync = false;
18
+
19
+ // WeakMap to store per-item click listeners.
20
+ #clickListeners = new WeakMap();
21
+
22
+ /**
23
+ * @property {Array} childData - Structure data to create child components.
24
+ * @property {boolean} multiple - Whether the select supports multiple or not.
25
+ * @property {string} type - List item type: plain, check, or checkbox.
26
+ * @property {string} ariaLabel - The aria-label for the list container.
27
+ * @returns {object} The map of properties.
28
+ */
29
+ static properties = {
30
+ childData: { type: Array, attribute: 'childdata' },
31
+ multiple: { type: Boolean, reflect: true },
32
+ type: { type: String, reflect: true },
33
+ ariaLabel: { type: String, attribute: 'aria-label' },
34
+ };
35
+
36
+ constructor() {
37
+ super();
38
+ this.childData = '';
39
+ this.multiple = false;
40
+ this.type = 'plain';
41
+ this.ariaLabel = '';
42
+ }
43
+
44
+ firstUpdated() {
45
+ this.#syncItems();
46
+ }
47
+
48
+ updated(changedProps) {
49
+ if (!this.#internalSync && changedProps.has('childData')) {
50
+ const parsed = parseChildData(this.childData);
51
+ if (parsed) this.#renderItemsFromData(parsed);
52
+ }
53
+
54
+ if (changedProps.has('type')) {
55
+ this.#applyTypeToItems();
56
+ }
57
+ }
58
+
59
+ // -------------------------
60
+ // ITEMS ACCESS
61
+ // -------------------------
62
+ get items() {
63
+ return this.#items;
64
+ }
65
+
66
+ get checkedItems() {
67
+ return this.#checkedItems;
68
+ }
69
+
70
+ get visibleItems() {
71
+ return this.#visibleItems;
72
+ }
73
+
74
+ get visibleCheckedItems() {
75
+ return this.#visibleItems.filter((item) => item.checked);
76
+ }
77
+
78
+ // -------------------------
79
+ // RENDER ITEMS
80
+ // -------------------------
81
+ #renderItemsFromData(itemsArray) {
82
+ // Remove all children except <template> and <noscript>.
83
+ [...this.children].forEach((child) => {
84
+ if (child.tagName !== 'TEMPLATE' && child.tagName !== 'NOSCRIPT')
85
+ child.remove();
86
+ });
87
+
88
+ let firstChecked;
89
+ itemsArray.forEach((data) => {
90
+ const item = document.createElement('cfpb-list-item');
91
+ item.textContent = data.value ?? '';
92
+ if ('disabled' in data) item.disabled = data.disabled;
93
+ if ('hidden' in data) item.hidden = data.hidden;
94
+ if ('href' in data) item.href = data.href;
95
+ item.type = data.type ?? this.type;
96
+ if (this.multiple) {
97
+ if ('checked' in data) item.checked = data.checked;
98
+ } else if (!firstChecked && data.checked) {
99
+ firstChecked = true;
100
+ if ('checked' in data) item.checked = true;
101
+ }
102
+
103
+ this.appendChild(item);
104
+ });
105
+
106
+ this.#syncItems();
107
+ }
108
+
109
+ // -------------------------
110
+ // SYNC ITEMS & LISTENERS
111
+ // -------------------------
112
+ #syncItems() {
113
+ // Collect items.
114
+ this.#items = [...this.querySelectorAll('cfpb-list-item')];
115
+
116
+ // Ensure each item has a type.
117
+ this.#items.forEach((item) => {
118
+ if (!item.type) item.type = this.type;
119
+ });
120
+
121
+ // Visible items.
122
+ this.#visibleItems = this.#items.filter((item) => !item.hidden);
123
+
124
+ // Populate initial checked states.
125
+ if (this.multiple) {
126
+ this.#checkedItems = this.#items.filter((item) => item.checked);
127
+ } else {
128
+ const firstChecked = this.#items.find((item) => item.checked);
129
+ this.#checkedItems = firstChecked ? [firstChecked] : [];
130
+
131
+ // Uncheck all others.
132
+ this.#items.forEach((item) => {
133
+ if (item !== firstChecked) item.checked = false;
134
+ });
135
+ }
136
+
137
+ // Assign tabindex, role, listeners.
138
+ this.#items.forEach((item, index) => {
139
+ item.setAttribute('tabindex', index === 0 ? '0' : '-1');
140
+ item.setAttribute('role', 'option');
141
+
142
+ // Remove prior listener if present.
143
+ const prev = this.#clickListeners.get(item);
144
+ if (prev) item.removeEventListener('click-item', prev);
145
+
146
+ // Listener that toggles the item before handling.
147
+ const listener = (evt) => {
148
+ // Prevent actual click bubbling to list container.
149
+ evt.stopPropagation();
150
+
151
+ this.#handleToggle(item, item.checked, index);
152
+ };
153
+
154
+ item.addEventListener('click-item', listener);
155
+ this.#clickListeners.set(item, listener);
156
+
157
+ // Track focus index.
158
+ item.addEventListener('focus', () => {
159
+ this.#focusedIndex = index;
160
+ });
161
+ });
162
+
163
+ this.dispatchEvent(
164
+ new CustomEvent('items-ready', {
165
+ detail: {
166
+ items: this.#items,
167
+ checkedItems: this.#checkedItems,
168
+ visibleItems: this.#visibleItems,
169
+ visibleCheckedItems: this.visibleCheckedItems,
170
+ },
171
+ bubbles: true,
172
+ composed: true,
173
+ }),
174
+ );
175
+ }
176
+
177
+ #syncChildDataFromItems() {
178
+ const data = this.#items.map((item) => ({
179
+ value: item.value,
180
+ label: item.textContent.trim(),
181
+ checked: item.checked,
182
+ disabled: item.disabled,
183
+ }));
184
+
185
+ this.#internalSync = true;
186
+ this.childData = data;
187
+ this.#internalSync = false;
188
+ }
189
+
190
+ #applyTypeToItems() {
191
+ if (!['plain', 'check', 'checkbox'].includes(this.type)) {
192
+ console.warn(`<cfpb-list>: invalid type "${this.type}".`);
193
+ return;
194
+ }
195
+ this.#items.forEach((item) => (item.type = this.type));
196
+ }
197
+
198
+ #handleToggle(element, isChecked, index) {
199
+ if (this.multiple) {
200
+ if (isChecked) {
201
+ // Add if not already present.
202
+ if (!this.#checkedItems.includes(element)) {
203
+ this.#checkedItems.push(element);
204
+ }
205
+ } else {
206
+ // Remove cleanly.
207
+ this.#checkedItems = this.#checkedItems.filter(
208
+ (item) => item !== element,
209
+ );
210
+ }
211
+ } else {
212
+ if (isChecked) {
213
+ // Select this item, uncheck all others.
214
+ this.#items.forEach((item) => {
215
+ if (item !== element) item.checked = false;
216
+ });
217
+ this.#checkedItems = [element];
218
+ } else {
219
+ // Item is unchecked -> clear selection.
220
+ this.#checkedItems.forEach((item) => (item.checked = false));
221
+ this.#checkedItems = [];
222
+ }
223
+ }
224
+
225
+ this.#syncChildDataFromItems();
226
+
227
+ this.dispatchEvent(
228
+ new CustomEvent('item-click', {
229
+ detail: { index, value: element.value, element },
230
+ bubbles: true,
231
+ composed: true,
232
+ }),
233
+ );
234
+ }
235
+
236
+ // -------------------------
237
+ // FILTER & FOCUS
238
+ // -------------------------
239
+
240
+ /**
241
+ * @param {Array} queryList - List of search words.
242
+ * @returns {Array} List of visible list items.
243
+ */
244
+ filterItems(queryList) {
245
+ let firstIndex = 0;
246
+ this.#visibleItems = [];
247
+
248
+ this.items.forEach((item) => {
249
+ const valueLower = item.value.toLowerCase();
250
+ const matches = queryList.some((q) =>
251
+ valueLower.includes(q.toLowerCase()),
252
+ );
253
+ if (!matches) firstIndex++;
254
+ else this.#visibleItems.push(item);
255
+ item.hidden = !matches;
256
+ });
257
+
258
+ this.#focusedIndex = firstIndex;
259
+
260
+ this.#broadcastFiltered();
261
+
262
+ return this.#visibleItems;
263
+ }
264
+
265
+ showAllItems() {
266
+ this.items.forEach((item) => (item.hidden = false));
267
+ this.#focusedIndex = 0;
268
+ this.#visibleItems = this.#items;
269
+
270
+ this.#broadcastFiltered();
271
+ }
272
+
273
+ #broadcastFiltered() {
274
+ this.dispatchEvent(
275
+ new CustomEvent('items-filter', {
276
+ detail: {
277
+ items: this.#items,
278
+ checkedItems: this.#checkedItems,
279
+ visibleItems: this.#visibleItems,
280
+ visibleCheckedItems: this.visibleCheckedItems,
281
+ },
282
+ bubbles: true,
283
+ composed: true,
284
+ }),
285
+ );
286
+ }
287
+
288
+ focusItemAt(index) {
289
+ const visibleItems = this.items.filter((item) => !item.hidden);
290
+ if (!visibleItems.length) return;
291
+
292
+ const normalizedIndex =
293
+ ((index % visibleItems.length) + visibleItems.length) %
294
+ visibleItems.length;
295
+ const item = visibleItems[normalizedIndex];
296
+ item.focus();
297
+ this.#focusedIndex = normalizedIndex;
298
+ }
299
+
300
+ #onFocus(evt) {
301
+ // If the focus is on the container itself (not an item), set index to -1.
302
+ if (evt.target === this.#container.value) {
303
+ this.#focusedIndex = -1;
304
+ }
305
+ }
306
+
307
+ // -------------------------
308
+ // KEYBOARD NAVIGATION
309
+ // -------------------------
310
+ #onKeyDown(evt) {
311
+ const visibleItems = this.items.filter((item) => !item.hidden);
312
+ if (!visibleItems.length) return;
313
+ const last = visibleItems.length - 1;
314
+
315
+ switch (evt.key) {
316
+ case 'ArrowDown':
317
+ evt.preventDefault();
318
+ this.focusItemAt(this.#focusedIndex + 1);
319
+ break;
320
+ case 'ArrowUp':
321
+ evt.preventDefault();
322
+ this.focusItemAt(this.#focusedIndex - 1);
323
+ break;
324
+ case 'Home':
325
+ evt.preventDefault();
326
+ this.focusItemAt(0);
327
+ break;
328
+ case 'End':
329
+ evt.preventDefault();
330
+ this.focusItemAt(last);
331
+ break;
332
+ }
333
+ }
334
+
335
+ render() {
336
+ return html`
337
+ <div
338
+ role="listbox"
339
+ tabindex="0"
340
+ @keydown=${this.#onKeyDown}
341
+ @focus=${this.#onFocus}
342
+ aria-label=${this.ariaLabel}
343
+ ?aria-multiselectable=${this.multiple}
344
+ ${ref(this.#container)}
345
+ >
346
+ <slot></slot>
347
+ </div>
348
+ `;
349
+ }
350
+
351
+ static init() {
352
+ CfpbListItem.init();
353
+ if (!window.customElements.get('cfpb-list')) {
354
+ window.customElements.define('cfpb-list', CfpbList);
355
+ }
356
+ }
357
+ }