Skip to content

Custom Elements API Style Guide

Steven Spriggs edited this page Apr 23, 2024 · 11 revisions

Looking for advice on writing base classes vs subclasses? See Base Classes

Host Attributes

  • DO reflect camelCase DOM properties as dash-case attributes
  • DO reflect host attributes that a user would anyways set from the light DOM
  • DO use JSDoc to document all public properties
  • ⚠️ Avoid reflecting host attributes that expose internal state
  • DONT reflect host attributes that are strictly for the purpose of styling
    // BAD - Don't allow the user to force `mobile` state in a non-mobile viewport 
    @property({ reflect: true, type: Boolean }) mobile = false;
    
    // GOOD - Allow user to force `open` state by setting an attr, and provide additional visibility via `MutationObserver`
    @property({ reflect: true, type: Boolean }) open = false;
  • DONT prefix boolean attributes and properties with is, it's implied
  • ⚠️ Avoid using multiple words for public attrs and props
    // BAD - syntactic noise
    @property({ reflect: true, type: Boolean, attribute: 'is-open-mode' }) isOpenMode = false;
    
    // GOOD - concise user-facing API
    @property({ reflect: true, type: Boolean }) open = false;

For things like mobile above, prefer to set a class on a private (i.e. shadow DOM) element:

#screenSize = new ScreenSizeController(this);

render() {
  const { mobile } = this.#screenSize;
  return html`
    <div id="container" class=${classMap({ mobile })}>...</div>
  `;
}

Events

  • DO extend Event
  • DO export your subclassed events, so that users can instanceof
  • DO set state on the event object instead of detail, because each event is its own object
  • DONT use new CustomEvent() because this is a holdover from before class extends Event was legal
    export class JazzHandsSelectEvent extends Event {
      declare target: RhJazzHands;
      constructor(
        /** The Jazz era selected */
        public era: 'ragtime'|'golden'|'smooth'
      ) {
        super('select');
      }
    }

When listening for events, be sure to check that the event in an instance of the expected event, to prevent name collisions.

#onSelect(event: Event) {
  if (event instanceof JazzHandsSelectEvent) {
    this.#swing();
  }
}

Class Members

  • DO write one element class per file
  • DO use ECMAScript #private fields and methods
  • DO use TypeScript's protected keyword, because it communicates intent to users
  • DO use TypeScript's override keyword, because it can surface more compile-time errors
  • ⚠️ Avoid using the TypeScript private keyword, because ECMA #private is now available at the language level Exception: decorated private members, but in that case consider refactoring
  • DONT use _ prefix to simulate privacy, because there are now language features for that

Ordering

  1. Statics
  2. Public reactive properties
  3. Public fields
  4. Private reactive state
  5. Private fields
  6. Lifecycle methods
    1. constructor
    2. connectedCallback
    3. update
    4. render
    5. firstUpdated
    6. updated
    7. disconnectedCallback
  7. Private and protected methods
  8. Public methods

Statics should come first because there's really not much better place for them, and because that convention is generally well accepted in OOP. Public instance fields come next because they represent the element's public HTML and DOM APIs (excepting slots, which are listed in JSDoc). Private state comes next, because they fluently follow from the public fields.

Prefer to list the lifecycle methods in their order of execution, rather than listing render() first. While the benefits of listing render - i.e. the element's DOM template - separately are compelling, syntax-highlighting text editors make visually finding the template easier, while listing the callbacks in order aids in reasoning around state management and performance.

List private and protected methods before public methods to make the public JavaScript API easier to find - simply navigate to the end of the file.

JSDoc

Moved to JSDoc.

Delegates Focus

static readonly shadowRootOptions: ShadowRootInit = { ...LitElement.shadowRootOptions, delegatesFocus: true };

delegatesFocus will only apply focus to a shadowRoot owned object. Slotted elements will not receive focus through delegation. Use focus() override to target the slotted element.

focus() {
  this.slottedEl.focus();
}