Truncated APTitle - a Custom Web Component

Applies AP title case and trims text to the specified length

Web Components are potent, offering reusability and encapsulation right in the browser. However, handling attribute changes in custom elements can sometimes be tricky. Today, let's take a closer look at a custom built-in web component <truncated-aptitle> that tackles this issue.

A Brief Intro to TruncatedAPTitle

The TruncatedAPTitle component is used like a span element but adds a couple of unique attributes: apstyle and textlength.

Used like this <truncated-aptitle apstyle textlength="30">headline goes here</truncated-aptitle>, it applies AP title case styling to the text within it and trims it down to the specified length.

The tricky part comes from the fact that boolean attributes don't exist on Custom Elements, rather they follow a common convention: an existing attribute means true, while the absense means it's false. This behavior is not unique to Custom Elements, but is common to HTML5 boolean attributes in general. HTML5 defines boolean attributes where the mere presence of the attribute, regardless of its actual value, means true, and its absence implies false.

Addressing Attribute Changes

While most changes to attributes on our custom element would typically be handled through the attributeChangedCallback, it's important to note that the current specifications of the Custom Elements API may not support the lifecycle callback attributeChangedCallback for customized built components as expected.

For example, if we add a boolean attribute to our custom element, like apstyle, and then remove it, the attributeChangedCallback will not be triggered. This is because the attributeChangedCallback is only triggered when an attribute's value changes, not when it's added or removed.

In the case of our TruncatedAPTitle element, we handle this issue as follows. The textlength attribute, a non-boolean attribute, is managed by leveraging the attributeChangedCallback. On the other hand, for boolean attributes like apstyle and changes to textContent, we deploy a MutationObserver.

attributeChangedCallback(property, oldValue, newValue) {
  if (property === 'textlength') {
    this.props.textlength = newValue;
    this.updateTextContent(this.props.text);
  }
}

We establish a MutationObserver within the constructor of the custom element, and set it to observe specific changes, namely changes to attributes and textContent. This MutationObserver allows us to respond to changes that otherwise wouldn't be caught by attributeChangedCallback.

this.mutationObserver = new MutationObserver(this.mutationObserverCallback.bind(this));
this.mutationObserver.observe(this, { 
  attributes: true,
  characterData: true, 
  childList: true
});

Then, we define the mutationObserverCallback to handle changes to the apstyle attribute and textContent, using a updateTextContent method, which applies the truncation and title casing as needed.

mutationObserverCallback(mutations) {
  mutations.forEach((mutation) => {
    if (mutation.type === 'characterData' || mutation.type === 'childList') {
      // handle changes to textContent...
    }

    if (mutation.type === 'attributes' && mutation.attributeName === 'apstyle') {
        this.props.apstyle = mutation.target.hasAttribute('apstyle');
        this.updateTextContent(this.props.text);
    }
  });
}

This approach provides a solution for our attribute observation requirements. It gives us flexibility and control, effectively monitoring and responding to changes in our custom element.

Defining Properties and Attributes

The TruncatedAPTitle component accepts two attributes: textlength and apstyle. The state of these attributes will be reflected in the properties of the component and the properties state will be cached in an object called props. Changing attributes will update this props object and all element updates will be based on the props object.

The props object is defined in the constructor.

this.props = {
  text: "",
  textlength: "",
  apstyle: false
};

Instead of defining getters and setters dynamically within the constructor, we'll explicitly set them in the class body. It's best practice to place getters and setters outside of the constructor. Adhering to this convention ensures our custom elements align with standards set by other elements and libraries, simplifying the process for other developers to understand and collaborate on our code.

It's important to note that we don't modify attributes when properties change. This convention aligns with typical behavior seen in HTML elements and other web components. Attributes are read once in the connectedCallback and subsequently cached in the props object. When properties alter, the props object updates, triggering an appropriate component update.

// explicitly define properties reflecting to attributes
get text() {
  return this.props.text;
}
set text(value) { 
  this.props.text = value;
  this.updateTextContent(value);
}
get textlength() {
  return this.props.textlength;
}
set textlength(value) { 
  this.props.textlength = value;
  this.updateTextContent(this.props.text);
}
get apstyle() {
  return this.props.apstyle;
}
set apstyle(value) {
  this.props.apstyle = !!value;
  this.updateTextContent(this.props.text);
}

Updating TextContent

To encapsulate the logic needed for handling text changes, we've created the updateTextContent method. It takes in the text, trims it down to the specified length, applies the AP style (if specified), and updates the component's textContent.

this.updateTextContent = text => {
  if (!text) return;
  const textlength = this.props.textlength;
  const trimmedText = this.truncateAfterWord(text, textlength);
  const apstyle = this.props.apstyle;
  this.textContent = apstyle ? this.apStyleTitleCase(trimmedText) : trimmedText;
};

Applying the AP Title Case

We've also added a simple apStyleTitleCase method to handle the title-casing of the text according to Associated Press (AP) style. It takes care of all those pesky small words and punctuation and ensures the text is cased just right.

apStyleTitleCase(str) {
  if (!str) return ''
  const lowercaseWords = ['a', 'an', 'and', 'at', 'but', 'by', 'for', 'in', 'nor', 'of', 'on', 'or', 'so', 'the', 'to', 'up', 'yet'];
  return str
    .toLowerCase()
    .replace(/\w+/g, function (word, index) {
      // Always capitalize the first and last word
      if (index === 0 || index + word.length === str.length) return word.charAt(0).toUpperCase() + word.substr(1);
      
      // Otherwise, only capitalize if it's not in the list of lowercase words
      return lowercaseWords.includes(word) ? word : word.charAt(0).toUpperCase() + word.substr(1);
    });
}

Bringing it All Together

Finally, we've registered our component using customElements.define, extending the native span element. Voila! Our TruncatedAPTitle is ready to roll.

customElements.define( 'truncated-aptitle', TruncatedAPTitle, { extends: 'span' } );

While attribute changes in custom elements may be a hurdle, they can be cleared with MutationObserver and JavaScript.

Here is the code for the TruncatedAPTitle component:

/**
* @name TruncatedAPTitle
* @description Custom element with text length and ap title style
* @example <truncated-aptitle apstyle textlength="30">everything you need to know about headline style 
*          and capitalization</truncated-aptitle> will result in "Everything You Need to Know..."
* @param {boolean} apstyle - styling according to AP style
* @param {string} textlength - number of characters to trim to
* 
* @notes Due to the Custom Elements API's current specifications, there might 
* be limitations with `attributeChangedCallback`. It may not properly support 
* lifecycle callbacks like `attributeChangedCallback` and `adoptedCallback` 
* for customized built-in elements. To monitor changes to the element, I utilize
* the `MutationObserver` interface.
*/

class TruncatedAPTitle extends HTMLElement {
  
  constructor() {
    super();

    // cache the state of the component
    this.props = {
      text: "",
      textlength: "",
      apstyle: false
    };

    // reflect internal state to textContent
    this.updateTextContent = text => {
      if (!text) return;
      const textlength = this.props.textlength;
      const trimmedText = this.truncateAfterWord(text, textlength);
      const apstyle = this.props.apstyle;
      this.textContent = apstyle ? this.apStyleTitleCase(trimmedText) : trimmedText;
    };

    // watch for textContent and boolean attribute changes
    this.mutationObserver = new MutationObserver(this.mutationObserverCallback.bind(this));
    this.mutationObserver.observe(this, { 
      characterData: true, 
      childList: true,
      attributes: true
    });
  } // end constructor


  // observe these component attributes
  static get observedAttributes() {
    return ['textlength', 'apstyle'];
  }

  // explicitly define properties reflecting to attributes
  get text() {
    return this.props.text;
  }
  set text(value) { 
    this.props.text = value;
    this.updateTextContent(value);
  }
  get textlength() {
    return this.props.textlength;
  }
  set textlength(value) { 
    this.props.textlength = value;
    this.updateTextContent(this.props.text);
  }
  get apstyle() {
    return this.props.apstyle;
  }
  set apstyle(value) {
    this.props.apstyle = !!value;
    this.updateTextContent(this.props.text);
  }
  
  // attribute change
  attributeChangedCallback(property, oldValue, newValue) {
    if (property === 'textlength') {
      this.props.textlength = newValue;
      this.updateTextContent(this.props.text);
    }
  }

  mutationObserverCallback(mutations) {
    mutations.forEach((mutation) => {
      // characterData and childList mutations are for text changes
      if (mutation.type === 'characterData' || mutation.type === 'childList') {
        /**
        * @notes
        * We store the original text in `this.props.text`. If changes occur, we verify if 
        * it's due to truncation or AP style adjustments. If so, we don't update the 
        * props. However, if the new text isn't a substring of the original, we 
        * recognize it as a genuine change and update the `this.props.text`.
        */
        const newtext = mutation.target.textContent.toLowerCase().slice(0, -3);
        if (!this.props.text.toLowerCase().includes(newtext)) {
          this.props.text = mutation.target.textContent;
          this.updateTextContent(this.props.text);
        } 
      }
      
      /**
      * @notes
      * For boolean attributes, we use attribute mutations since they don't trigger 
      * the `attributeChangedCallback`. All other attribute changes are managed by the 
      * `attributeChangedCallback`.
      */
      if (mutation.type === 'attributes' && mutation.attributeName === 'apstyle') {
          this.props.apstyle = mutation.target.hasAttribute('apstyle');
          this.updateTextContent(this.props.text);
      }

      
    });
  } // end mutationObserverCallback

  connectedCallback() {
    // set the props to the current attributes
    this.props.text = this.textContent;
    this.props.textlength = this.getAttribute('textlength');
    this.props.apstyle = this.hasAttribute('apstyle');
    // and initialize textContent
    this.updateTextContent(this.props.text);
  }

  disconnectedCallback() {
    this.mutationObserver.disconnect();
  }

  truncateAfterWord (str, chars) {
    if (!chars || !str ) return str;
    return str.length < chars ? str : `${str.substr( 0, str.substr(0, chars - 3).lastIndexOf(" "))}...`;
  }

  capitalize(value) {
    return value.charAt(0).toUpperCase() + value.slice(1);
  }

  /**
  * 
  * @param {*} str 
  * @returns An AP style formatted string
  * Simple implementation of title-casing according to the AP Stylebook. 
  * One general rule is to capitalize the first word, the last word, and 
  * all words in between except for certain short conjunctions, 
  * prepositions, and articles. 
  */
  apStyleTitleCase(str) {
    if (!str) return ''
    const lowercaseWords = ['a', 'an', 'and', 'at', 'but', 'by', 'for', 'in', 'nor', 'of', 'on', 'or', 'so', 'the', 'to', 'up', 'yet'];
    return str
      .toLowerCase()
      .replace(/\w+/g, function (word, index) {
        // Always capitalize the first and last word
        if (index === 0 || index + word.length === str.length) return word.charAt(0).toUpperCase() + word.substr(1);
        
        // Otherwise, only capitalize if it's not in the list of lowercase words
        return lowercaseWords.includes(word) ? word : word.charAt(0).toUpperCase() + word.substr(1);
      });
  } // end apStyleTitleCase
}

// register component
customElements.define( 'truncated-aptitle', TruncatedAPTitle );

The code can be found on GitHub and on NPM.

Scroll to top