UNPKG

@cfpb/cfpb-design-system

Version:
384 lines (336 loc) 10.4 kB
import { LitElement, html, css, unsafeCSS } from 'lit'; import { defineComponent } from '../cfpb-utilities/shared-config'; import { ref, createRef } from 'lit/directives/ref.js'; import styles from './styles.component.scss?inline'; import { CfpbListboxItem } from '../cfpb-listbox-item'; import { parseChildData } from '../cfpb-utilities/parse-child-data'; /** * @element cfpb-listbox. * @slot - Slot for the list of items in the list box. */ export class CfpbListbox extends LitElement { static styles = css` ${unsafeCSS(styles)} `; #internalSync = false; #container = createRef(); #items = []; #checkedItems = []; #visibleItems = []; // index in visibleItems #focusedIndex = -1; // WeakMap to store per-item click listeners. #clickListeners = new WeakMap(); /** * @property {Array} childData - Structure data to create child components. * @property {boolean} multiple - Whether the select supports multiple or not. * @property {string} type - List item type: plain, check, or checkbox. * @property {string} ariaLabel - The aria-label for the list container. * @returns {object} The map of properties. */ static properties = { childData: { type: Array, attribute: 'childdata' }, multiple: { type: Boolean, reflect: true }, type: { type: String, reflect: true }, ariaLabel: { type: String, attribute: 'aria-label' }, }; constructor() { super(); this.childData = ''; this.multiple = false; this.type = 'plain'; this.ariaLabel = ''; } firstUpdated() { this.#syncItems(); } updated(changedProps) { if (!this.#internalSync && changedProps.has('childData')) { const parsed = parseChildData(this.childData); if (parsed) this.#renderItemsFromData(parsed); } if (changedProps.has('type')) { this.#applyTypeToItems(); } } // ------------------------- // ITEMS ACCESS // ------------------------- get items() { return this.#items; } get checkedItems() { return this.#checkedItems; } get visibleItems() { return this.#visibleItems; } get visibleCheckedItems() { return this.#visibleItems.filter((item) => item.checked); } // ------------------------- // RENDER ITEMS // ------------------------- #renderItemsFromData(itemsArray) { [...this.children].forEach((child) => { if (child.tagName !== 'TEMPLATE' && child.tagName !== 'NOSCRIPT') child.remove(); }); let firstChecked = null; itemsArray.forEach((data) => { const item = document.createElement('cfpb-listbox-item'); item.textContent = data.value ?? ''; if ('disabled' in data) item.disabled = data.disabled; if ('hidden' in data) item.hidden = data.hidden; if ('href' in data) item.href = data.href; item.type = data.type ?? this.type; if (this.multiple) { if ('checked' in data) item.checked = data.checked; } else if (!firstChecked && data.checked) { firstChecked = item; item.checked = true; } this.appendChild(item); }); this.#syncItems(); } // ------------------------- // SYNC ITEMS & LISTENERS // ------------------------- #syncItems() { // Collect items. this.#items = [...this.querySelectorAll('cfpb-listbox-item')]; // Ensure each item has a type. this.#items.forEach((item) => { if (!item.type) item.type = this.type; }); // Visible items. this.#visibleItems = this.#items.filter((item) => !item.hidden); // Populate initial checked states. if (this.multiple) { this.#checkedItems = this.#items.filter((item) => item.checked); } else { const firstChecked = this.#items.find((item) => item.checked); this.#checkedItems = firstChecked ? [firstChecked] : []; // Uncheck all others. this.#items.forEach((item) => { if (item !== firstChecked) item.checked = false; }); } // Assign tabindex, role, listeners. this.#items.forEach((item, index) => { item.setAttribute('tabindex', '-1'); item.setAttribute('role', 'option'); // Remove prior listener if present. const prev = this.#clickListeners.get(item); if (prev) item.removeEventListener('item-click', prev); // Listener that toggles the item before handling. const listener = (evt) => { // Prevent actual click bubbling to list container. evt.stopPropagation(); this.#handleToggle(item, item.checked, index); }; item.addEventListener('item-click', listener); this.#clickListeners.set(item, listener); // Track focus index. item.addEventListener('focus', () => { const visIndex = this.#visibleItems.indexOf(item); if (visIndex !== -1) this.#focusedIndex = visIndex; }); }); this.dispatchEvent( new CustomEvent('items-ready', { detail: { items: this.#items, checkedItems: this.#checkedItems, visibleItems: this.#visibleItems, visibleCheckedItems: this.visibleCheckedItems, }, bubbles: true, composed: true, }), ); } #syncChildDataFromItems() { const data = this.#items.map((item) => ({ value: item.value, label: item.textContent.trim(), checked: item.checked, disabled: item.disabled, })); this.#internalSync = true; this.childData = data; this.#internalSync = false; } #applyTypeToItems() { if (!['plain', 'check', 'checkbox'].includes(this.type)) { // eslint-disable-next-line no-console console.warn(`<cfpb-listbox>: invalid type "${this.type}".`); return; } this.#items.forEach((item) => (item.type = this.type)); } #handleToggle(element, isChecked, index) { if (this.multiple) { if (isChecked) { // Add if not already present. if (!this.#checkedItems.includes(element)) this.#checkedItems.push(element); } else { // Remove cleanly. this.#checkedItems = this.#checkedItems.filter( (item) => item !== element, ); } } else { if (isChecked) { // Select this item, uncheck all others. this.#items.forEach((item) => { if (item !== element) item.checked = false; }); this.#checkedItems = [element]; } else { // Item is unchecked -> clear selection. this.#checkedItems.forEach((item) => (item.checked = false)); this.#checkedItems = []; } } this.#syncChildDataFromItems(); window.queueMicrotask(() => { const visIndex = this.#visibleItems.indexOf(element); this.focusItemAt(visIndex !== -1 ? visIndex : -1); }); this.dispatchEvent( new CustomEvent('item-click', { detail: { index, value: element.value, element }, bubbles: true, composed: true, }), ); } // ------------------------- // FILTER & FOCUS // ------------------------- /** * @param {Array} queryList - List of search words. * @returns {Array} List of visible list items. */ filterItems(queryList) { this.#visibleItems = []; let firstVisibleIndex = -1; this.#items.forEach((item) => { const matches = queryList.some((q) => item.value.toLowerCase().includes(q.toLowerCase()), ); item.hidden = !matches; if (matches) { if (firstVisibleIndex === -1) firstVisibleIndex = this.#visibleItems.length; this.#visibleItems.push(item); } }); this.#focusedIndex = firstVisibleIndex; this.#broadcastFiltered(); return this.#visibleItems; } showAllItems() { this.#items.forEach((item) => (item.hidden = false)); this.#visibleItems = [...this.#items]; this.#focusedIndex = 0; this.#broadcastFiltered(); } #broadcastFiltered() { this.dispatchEvent( new CustomEvent('items-filter', { detail: { items: this.#items, checkedItems: this.#checkedItems, visibleItems: this.#visibleItems, visibleCheckedItems: this.visibleCheckedItems, }, bubbles: true, composed: true, }), ); } #focusContainer() { this.#container.value.focus(); this.#focusedIndex = -1; } /** * Focus a visible item by index. * Pass -1 to move focus to the list container (no active item). * @param {number} index - The index of the item to focus. * @returns {undefined} If nothing to focus. */ focusItemAt(index) { // No active item (sentinel or invalid input). const visibleItems = this.#visibleItems; if ( !visibleItems.length || index == null || typeof index !== 'number' || Number.isNaN(index) || index === -1 ) { this.#focusContainer(); return; } const normalizedIndex = ((index % visibleItems.length) + visibleItems.length) % visibleItems.length; visibleItems[normalizedIndex].focus(); this.#focusedIndex = normalizedIndex; } #onFocus(evt) { // If the focus is on the container itself (not an item), set index to -1. if (evt.target === this.#container.value) this.#focusContainer(); } #onKeyDown(evt) { const visibleItems = this.#visibleItems; if (!visibleItems.length) return; const last = visibleItems.length - 1; switch (evt.key) { case 'ArrowDown': evt.preventDefault(); this.focusItemAt(this.#focusedIndex < 0 ? 0 : this.#focusedIndex + 1); break; case 'ArrowUp': evt.preventDefault(); this.focusItemAt( this.#focusedIndex < 0 ? last : this.#focusedIndex - 1, ); break; case 'Home': evt.preventDefault(); this.focusItemAt(0); break; case 'End': evt.preventDefault(); this.focusItemAt(last); break; } } get focusedIndex() { return this.#focusedIndex; } render() { return html` <div role="listbox" tabindex="0" @keydown=${this.#onKeyDown} @focus=${this.#onFocus} aria-label=${this.ariaLabel} ?aria-multiselectable=${this.multiple} ${ref(this.#container)} > <slot></slot> </div> `; } static init() { CfpbListboxItem.init(); defineComponent('cfpb-listbox', CfpbListbox); } }