@cfpb/cfpb-design-system
Version:
CFPB's UI framework
371 lines (316 loc) • 10.2 kB
JavaScript
import { LitElement, html, css, unsafeCSS } from 'lit';
import { defineComponent } from '../cfpb-utilities/shared-config';
import styles from './styles.component.scss?inline';
import { parseChildData } from '../cfpb-utilities/parse-child-data';
const SUPPORTED_TAG_LIST = ['CFPB-TAG-FILTER', 'CFPB-TAG-TOPIC'];
/**
* @element cfpb-tag-group.
* @description A group of tags (filter or topic tags) that can be added and
* removed.
*
* The tag group has a list of tags in the lightDOM that gets re-written
* inside an unordered list in the shadowDOM so that it is read out
* as a list of items in VoiceOver.
* @fires CfpbTagGroup#event:"tag-added" - A tag was added to the group.
* @fires CfpbTagGroup#event:"tag-click" - A tag was clicked.
* @fires CfpbTagGroup#event:"tag-removed" - A tag was removed to the group.
*/
export class CfpbTagGroup extends LitElement {
static styles = css`
${unsafeCSS(styles)}
`;
/**
* @property {string} childData - Structure data to create child components.
* @property {boolean} stacked - Whether to stack the tags vertically.
* @property {Array} tagList - List of the tags in the tag group.
* @returns {object} The map of properties.
*/
static properties = {
childData: { type: String, attribute: 'childdata' },
stacked: { type: Boolean, reflect: true },
tagList: { type: Array },
};
// Private properties.
#observer;
#initialized = false;
#tagMap;
constructor() {
super();
this.childData = '';
this.stacked = false;
this.tagList = [];
this.#observer = new MutationObserver(this.#onMutation.bind(this));
}
connectedCallback() {
super.connectedCallback();
this.#observeLightDom();
}
disconnectedCallback() {
this.#observer.disconnect();
super.disconnectedCallback();
}
firstUpdated() {
// Wait for the browser to complete its render cycle.
requestAnimationFrame(() => {
// Add the tags from the light DOM.
SUPPORTED_TAG_LIST.forEach((tagName) => {
const tags = this.querySelectorAll(`${tagName.toLowerCase()}`);
tags.forEach((tag) => this.addTag(tag));
});
this.#initialized = true;
});
}
updated(changedProps) {
if (changedProps.has('childData')) {
const parsed = parseChildData(this.childData);
if (parsed) {
this.#renderTagsFromData(parsed);
}
}
}
async focus() {
// Wait for tagList to update.
await this.updateComplete;
const firstChild = this.tagList[0];
if (firstChild) firstChild.focus();
}
#renderTagsFromData(arr) {
if (!Array.isArray(arr)) return;
this.#clearAllTags();
arr.forEach((data, index) => {
const tag = document.createElement(data.tagName);
// e.g. 'cfpb-tag-filter' or 'cfpb-tag-topic'
if (data.text) tag.textContent = data.text;
if (data.href) tag.href = data.href;
// any other props from `data`
this.addTag(tag, index);
});
}
/**
* Remove all previous tags from shadow DOM and light DOM.
*/
#clearAllTags() {
// Remove shadow DOM wrappers.
if (this.#tagMap) {
this.#tagMap.forEach((wrapped) => {
if (wrapped.parentElement) wrapped.remove();
});
this.#tagMap.clear();
}
// Remove light DOM tags.
[...this.children].forEach((child) => {
if (SUPPORTED_TAG_LIST.includes(child.tagName)) child.remove();
});
// Reset tagList
this.tagList = [];
}
/**
* Set up a MutationObserver to watch changes in the light DOM.
*/
#observeLightDom() {
this.#observer.observe(this, {
childList: true,
subtree: false,
});
}
/**
* Whether a particular node tagName is supported as a tag of this tag group.
* @param {string} tagName - The name of a supported custom element tag.
* @returns {boolean} true if the tagName is supported, false otherwise.
*/
#supportedTag(tagName) {
return SUPPORTED_TAG_LIST.includes(tagName);
}
/**
* Handle a change of the light DOM.
* @param {MutationRecord} mutationList - The record of observed DOM changes.
*/
#onMutation(mutationList) {
if (!this.#initialized) return;
for (const mutation of mutationList) {
// Ignore mutations that occur within the shadow DOM.
if (mutation.type === 'childList') {
mutation.addedNodes.forEach((node) => this.#handleNodeAdded(node));
mutation.removedNodes.forEach((node) => this.#handleNodeRemoved(node));
}
}
}
/**
* @param {Node} node - The node that was added to the light DOM.
*/
#handleNodeAdded(node) {
if (this.#supportedTag(node.tagName)) {
const index = Array.from(this.children).indexOf(node);
this.addTag(node, index);
}
}
/**
* @param {Node} node - The node that was removed from the light DOM.
*/
#handleNodeRemoved(node) {
if (this.#supportedTag(node.tagName)) {
this.#removeTagNode(node);
}
}
/**
* Refresh the tagList property from the DOM list.
*/
#refreshTagList() {
this.tagList = [...this.renderRoot.querySelectorAll('ul li > *')];
// Iterate over the list, and if there are topic tag links adjacent to each
// other, then we set the siblingOfJumpLink property, which adjusts the
// styles for adjacent jumplinks so that the borders aren't doubled up.
if (this.tagList.length > 0) {
let lastItemIsLink = false;
this.tagList.forEach((item) => {
if (lastItemIsLink) {
item.siblingOfJumpLink = true;
lastItemIsLink = false;
}
if (item.href !== '') {
lastItemIsLink = true;
}
});
}
}
/**
* Add a tag to the light and dark DOM.
* @param {HTMLElement} tag - The tag to add.
* @param {number} index - The position at which to add the tag.
* @returns {boolean} false if the tag is already in the light DOM.
*/
addTag(tag, index = -1) {
const alreadyInDom = Array.from(this.children).includes(tag);
if (!alreadyInDom) {
this.#insertIntoLightDom(tag, index);
return false;
}
this.#insertIntoShadowDom(tag, index);
this.#refreshTagList();
return true;
}
/**
* Add a tag to the light DOM.
* @param {HTMLElement} tag - The tag to add.
* @param {number} index - The position at which to add the tag.
*/
#insertIntoLightDom(tag, index) {
if (index === -1 || index >= this.children.length) {
this.appendChild(tag);
} else {
this.insertBefore(tag, this.children[index]);
}
}
/**
* Add a tag to the shadow DOM.
* @param {HTMLElement} tag - The tag to add.
* @param {number} index - The position at which to add the tag.
*/
#insertIntoShadowDom(tag, index) {
const cloned = tag.cloneNode(true);
const wrapped = document.createElement('li');
wrapped.appendChild(cloned);
const ul = this.shadowRoot.querySelector('ul');
let actualIndex = index;
if (index === -1 || index >= ul.children.length) {
ul.appendChild(wrapped);
actualIndex = ul.children.length - 1;
} else {
ul.insertBefore(wrapped, ul.children[index]);
}
cloned.addEventListener('tag-click', () => {
this.dispatchEvent(
new CustomEvent('tag-click', {
detail: { target: cloned, index: actualIndex },
bubbles: true,
composed: true,
}),
);
this.#removeTagNode(cloned);
});
this.#tagMap ??= new Map();
const id = this.#tagIdentifier(tag);
this.#tagMap.set(id, wrapped);
this.dispatchEvent(
new CustomEvent('tag-added', {
detail: { target: tag, index: actualIndex },
bubbles: true,
composed: true,
}),
);
}
/**
* @param {HTMLElement} tag - The tag to add.
* @returns {string} A unique ID.
*/
#tagIdentifier(tag) {
return `${tag.tagName}::${tag.textContent.trim()}`;
}
/**
* Remove a filter tag from the light DOM.
* This is private because it's called by the mutation observer.
* @param {HTMLElement} tag - The tag to remove.
* @returns {boolean} false if the wrapped tag was not found.
*/
#removeTagNode(tag) {
const id = this.#tagIdentifier(tag);
const wrapped = this.#tagMap.get(id);
if (!wrapped) return false;
// Try getting the index from the light DOM.
let index = Array.from(this.children).indexOf(tag);
// If not found (e.g. manually removed via DevTools), fallback to shadow DOM.
if (index === -1 && wrapped.parentElement) {
const shadowChildren = Array.from(wrapped.parentElement.children);
index = shadowChildren.indexOf(wrapped);
}
// Remove from light DOM and shadow DOM.
if (tag.parentElement === this) {
tag.remove();
}
if (wrapped.parentElement) {
wrapped.remove();
}
this.#tagMap.delete(id);
this.dispatchEvent(
new CustomEvent('tag-removed', {
detail: { target: tag, index: index },
bubbles: true,
composed: true,
}),
);
this.#refreshTagList();
this.focus();
return true;
}
/**
* Remove a filter tag from the light and dark DOM.
* @param {HTMLElement} tag - The tag to remove.
*/
removeTag(tag) {
// Support passing in either light DOM <tag> or shadow DOM <li> if needed
// Normalize to the light DOM tag element:
const lightDomTag = this.#getLightDomTag(tag);
this.#removeTagNode(lightDomTag);
}
/**
* Get light and dark DOM.
* @param {HTMLElement} tag - The tag to remove.
* @returns {HTMLElement|null} The tag node.
*/
#getLightDomTag(tag) {
// If node is a wrapped shadow DOM <li>, get the orignal tag inside it.
if (tag.tagName === 'LI' && tag.shadowRoot) {
// unlikely scenario if you don't expose shadow nodes externally.
return tag.querySelector('cfpb-tag-filter');
}
// If node is already a light DOM tag or child <cfpb-tag-group>, return it.
if (this.contains(tag)) return tag;
return null;
}
render() {
return html`<ul ?stacked=${this.stacked}></ul>`;
}
static init() {
defineComponent('cfpb-tag-group', CfpbTagGroup);
}
}