@cfpb/cfpb-design-system
Version:
CFPB's UI framework
313 lines (267 loc) • 8.54 kB
JavaScript
import { html, LitElement, css, unsafeCSS } from 'lit';
import styles from './cfpb-tag-group.component.scss';
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.
* @attribute {string} lang - The element's language.
* @fires addtag - A tag was added to the group.
* @fires removetag - A tag was removed from the group.
*/
export class CfpbTagGroup extends LitElement {
static styles = css`
${unsafeCSS(styles)}
`;
/**
* @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 get properties() {
return {
stacked: { type: Boolean, reflect: true },
tagList: { type: Array },
};
}
// Private properties.
#observer;
#initialized = false;
#tagMap;
constructor() {
super();
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;
});
}
/**
* 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 {*} 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();
}
/**
* Add a tag to the light DOM.
* @param {*} 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 {*} 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 {*} 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 {*} 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();
}
/**
* Remove a filter tag from the light and dark DOM.
* @param {*} 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 {Node} node - The tag to remove.
* @returns {Node|null} The tag node.
*/
#getLightDomTag(node) {
// If node is a wrapped shadow DOM <li>, get the orignal tag inside it.
if (node.tagName === 'LI' && node.shadowRoot) {
// unlikely scenario if you don't expose shadow nodes externally.
return node.querySelector('cfpb-tag-filter');
}
// If node is already a light DOM tag or child <cfpb-tag-group>, return it.
if (this.contains(node)) return node;
return null;
}
render() {
return html`<ul ?stacked=${this.stacked}></ul>`;
}
static init() {
window.customElements.get('cfpb-tag-group') ||
window.customElements.define('cfpb-tag-group', CfpbTagGroup);
}
}